feat(core): 자기지식 접지·웹 접근·환경 자가점검 — 할루시네이션 방어 3중화 (v2.2.247)
- Alignment Self-Learning: 자가 조사(질문 전 두뇌 검색)·사용자 답변 두뇌 저장·핵심메시지/프로젝트 컨텍스트 주입 (alignmentResearch.ts 신규)
- 웹 접근: Bridge 폴백 직접 fetch(webFetch.ts 신규)·<fetch_url> 액션 태그·기업 모드 URL/아키텍처 컨텍스트 주입·bare 도메인 인식
- 트리거 버그 수정: startsWith('/') 가 절대경로를 슬래시 명령으로 오인 — 분석 지시·URL 주입 전멸 원인 (회귀 테스트 고정)
- 자기지식 접지: 기능 인벤토리 lazy 재생성·학습 메커니즘 정본 섹션·[인벤토리 대조] 태그 의무화·결정론적 재구현 제안 정정 훅(featureConceptMap.ts 신규)
- 환경 자가점검: HealthCheckMonitor 에 Bridge/두뇌 볼륨/git 자격증명/확장 버전 검사 4종 + readyBar ⚠ 표시
- 두뇌 동기화: 원격 미설정 시 로컬 새로고침 모드·staged 기준 commit 판정·인증 부재 안내
- 기타: outputFormat 기본 markdown(제목 렌더 복구)·레슨/행동제약 truncation 보호 구역 이동·[CONTEXT] 절단 우선순위 재정렬
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+34
-7
@@ -20,8 +20,10 @@ import { SessionManager } from './core/session';
|
||||
import { AgentWorkflowManager } from './agents/AgentWorkflowManager';
|
||||
import { buildAstraModeArchitectureContext } from './lib/contextBuilders/astraModeArchitecture';
|
||||
import { isScheduleRequest, buildScheduleContext } from './lib/contextBuilders/scheduleContext';
|
||||
import { isSelfAssessRequest, buildSelfAssessContext } from './lib/contextBuilders/selfAssessContext';
|
||||
import { extractUrlFromPrompt, buildUrlContext } from './lib/contextBuilders/urlContext';
|
||||
import { isSelfAssessRequest, isAboutSelf, buildSelfAssessContext } from './lib/contextBuilders/selfAssessContext';
|
||||
import { ensureFeatureInventory } from './extension/featureInventory';
|
||||
import { buildUrlContext } from './lib/contextBuilders/urlContext';
|
||||
import { extractUrls } from './features/web/webFetch';
|
||||
import { looksLikeCorrection, captureCorrection } from './intelligence/correctionLoop';
|
||||
import { shouldUseMultiAgentWorkflow } from './lib/contextBuilders/multiAgentRouting';
|
||||
import { buildThinkingPartnerResponseContract } from './lib/contextBuilders/thinkingPartnerContract';
|
||||
@@ -35,6 +37,7 @@ import {
|
||||
isExplicitSecondBrainRequest,
|
||||
isSecondBrainInventoryRequest,
|
||||
isNoBrainDataRefusal,
|
||||
isAnalysisRequest,
|
||||
} from './lib/contextBuilders/promptDetection';
|
||||
import { stripAstraFormattingForAgentMode, computeModeSignature } from './lib/contextBuilders/systemPromptShaping';
|
||||
import { sanitizeAssistantContent, isRestartedAnswer, parseRationale } from './lib/contextBuilders/outputSanitization';
|
||||
@@ -154,6 +157,7 @@ import { applyFileCreateEditActions } from './agent/actions/fileCreateEdit';
|
||||
import { applyFileDeleteReadActions } from './agent/actions/fileDeleteRead';
|
||||
import { applyRunCommandActions } from './agent/actions/runCommand';
|
||||
import { applyListFilesActions } from './agent/actions/listFiles';
|
||||
import { applyWebFetchActions } from './agent/actions/webFetch';
|
||||
import { applyBrainOpsActions } from './agent/actions/brainOps';
|
||||
import { applyCalendarActions } from './agent/actions/calendar';
|
||||
import { applySheetsActions } from './agent/actions/sheets';
|
||||
@@ -542,8 +546,14 @@ export class AgentExecutor {
|
||||
// [자기 평가 정본 주입] 기능 개선/자기 평가 질의는 RAG 경쟁에 맡기지 않고
|
||||
// 현행 기능 인벤토리를 결정론적으로 주입 — 모델이 검색 없이 기억으로 답해
|
||||
// 이미 있는 기능을 신규 제안하던 구식화 버그(3회 재발)의 마지막 구멍 봉쇄.
|
||||
if (prompt && loopDepth === 0 && !isCasualConversation && activeBrain?.localBrainPath && isSelfAssessRequest(prompt)) {
|
||||
if (prompt && loopDepth === 0 && !isCasualConversation && activeBrain?.localBrainPath
|
||||
&& (isSelfAssessRequest(prompt) || (isAnalysisRequest(prompt) && isAboutSelf(prompt)))) {
|
||||
try {
|
||||
// 인벤토리 lazy 재생성 — 활성화 시 1회 생성은 brain 볼륨이 늦게
|
||||
// 마운트되면 조용히 건너뛰어 파일이 영영 없는 상태가 됐다 (그 결과
|
||||
// "파일 없음" 안내만 주입돼 모델이 구현 여부를 알 수 없었음).
|
||||
// 질의 시점에 한 번 더 보장. idempotent — 있으면 즉시 return.
|
||||
await ensureFeatureInventory(this.context);
|
||||
const selfAssessBlock = buildSelfAssessContext(activeBrain.localBrainPath);
|
||||
contextBlock += `\n\n${selfAssessBlock}`;
|
||||
// 성공 로그 필수 — "주입이 됐는데 모델이 무시" vs "주입 자체가 안 됨"을
|
||||
@@ -554,11 +564,26 @@ export class AgentExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
// [URL 실데이터] 채팅 프롬프트에 URL 이 있으면 브리지로 본문을 추출해 주입.
|
||||
// [근거 기반 분석 강제] 분석/검토/의견형 요청인데 모델이 코드를 읽지 않고
|
||||
// "~로 보입니다" 추측으로 답하는 실패 모드 차단. 워크스페이스가 열려 있으면
|
||||
// "주장 전에 read_file 로 실제 확인하라"는 지시를 주입 — 강제 주입 패턴의
|
||||
// 5번째 적용 (일정→캘린더, 자기평가→인벤토리, 정정→캡처, URL→실데이터와 동일).
|
||||
if (prompt && loopDepth === 0 && !isCasualConversation && isAnalysisRequest(prompt)
|
||||
&& vscode.workspace.workspaceFolders?.length) {
|
||||
contextBlock += `\n\n[근거 기반 분석 규칙 — 이 요청은 분석/검토형]
|
||||
- 이 워크스페이스의 코드·문서·기능에 대한 주장은 *이 대화에서 실제로 읽은 파일*에만 근거하라.
|
||||
- 확인하지 않은 구현을 "~로 보입니다", "~일 것입니다"라고 추측 서술하는 것은 금지. 먼저 <list_files path="..."/> 와 <read_file path="..."/> 태그로 관련 파일을 직접 열어 확인한 뒤 답하라. 태그를 emit 하면 시스템이 파일 내용을 주입하고 자동으로 이어서 답변하게 된다.
|
||||
- ⚠️ "소스 코드 확인이 필요합니다"라고 말만 하고 끝내는 것은 금지다. 확인이 필요하다고 판단했다면 *바로 이 답변 안에서* <list_files>/<read_file> 태그를 emit 하라 — 그것이 확인하는 방법이다. 태그로 접근 불가능한 대상(외부 시스템·미설치 도구 등)에 한해서만 "확인하지 못함"으로 명시하라.
|
||||
- "X 기능을 추가하라"고 제안하기 전에 그 기능이 이미 구현돼 있는지 해당 모듈을 찾아 읽어라. 이미 있는 기능을 새로 만들라고 제안하는 것은 잘못된 분석이다.
|
||||
- 일반론·추측으로 빈칸을 채우지 마라.`;
|
||||
}
|
||||
|
||||
// [URL 실데이터] 채팅 프롬프트에 URL 이 있으면 본문을 추출해 주입.
|
||||
// /wikify 만 URL 접근이 가능하고 일반 채팅은 "접근 불가"라고 답하던 공백 수정.
|
||||
if (prompt && loopDepth === 0 && !isCasualConversation) {
|
||||
const url = extractUrlFromPrompt(prompt);
|
||||
if (url) {
|
||||
// v2: Bridge 추출 → 직접 fetch 폴백 (urlContext 내부) + 최대 2개 URL + config 게이트.
|
||||
if (prompt && loopDepth === 0 && !isCasualConversation && getConfig().webAutoFetchEnabled !== false) {
|
||||
const urls = extractUrls(prompt, 2);
|
||||
for (const url of urls) {
|
||||
try {
|
||||
contextBlock += `\n\n${await buildUrlContext(url)}`;
|
||||
logInfo('URL 컨텍스트 주입 시도.', { url });
|
||||
@@ -720,6 +745,7 @@ export class AgentExecutor {
|
||||
negativeCtx,
|
||||
actualModel,
|
||||
contextLength: config.contextLength,
|
||||
dynamicBlocks: this._turnCtx.dynamicBlocks,
|
||||
})
|
||||
: buildAstraModeSystemPrompt({
|
||||
prompt,
|
||||
@@ -1586,6 +1612,7 @@ export class AgentExecutor {
|
||||
await applyFileDeleteReadActions(ctx);
|
||||
await applyRunCommandActions(ctx);
|
||||
await applyListFilesActions(ctx);
|
||||
await applyWebFetchActions(ctx);
|
||||
await applyBrainOpsActions(ctx);
|
||||
await applyCalendarActions(ctx);
|
||||
await applySheetsActions(ctx);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { HandlerContext } from './types';
|
||||
import { buildUrlContext } from '../../lib/contextBuilders/urlContext';
|
||||
|
||||
/**
|
||||
* Action 15: Fetch URL (read-only — transaction record 불필요)
|
||||
*
|
||||
* LLM 이 작업 중 웹 페이지의 실제 내용이 필요할 때 emit 하는 태그.
|
||||
* 결과 블록을 chatHistory 에 internal push — read_file 과 동일 패턴이라
|
||||
* 일반 챗의 continuation loop(컨텍스트 주입 → 자동 재호출)가 그대로 작동해
|
||||
* fetch 직후 모델이 내용을 분석한다.
|
||||
*
|
||||
* buildUrlContext 재사용: Bridge 추출 우선 → 직접 fetch 폴백 → 정직 실패
|
||||
* 블록 + 5분 캐시. 실패 블록도 chatHistory 에 push 한다 — 모델이 "가져왔다"고
|
||||
* 지어내지 않고 실패 사실을 사용자에게 전달해야 하므로.
|
||||
*/
|
||||
const FETCH_URL_MAX_PER_TURN = 2;
|
||||
|
||||
export async function applyWebFetchActions(ctx: HandlerContext): Promise<void> {
|
||||
const { aiMessage, report } = ctx;
|
||||
const fetchRegex = /<fetch_url\s+url=['"]?(https?:\/\/[^'">\s]+)['"]?\s*\/?>(?:<\/fetch_url>)?/gi;
|
||||
let match;
|
||||
let handled = 0;
|
||||
const seen = new Set<string>();
|
||||
while ((match = fetchRegex.exec(aiMessage)) !== null) {
|
||||
if (handled >= FETCH_URL_MAX_PER_TURN) {
|
||||
report.push(`⚠️ fetch_url 한도 초과 — 회당 최대 ${FETCH_URL_MAX_PER_TURN}개만 처리합니다.`);
|
||||
break;
|
||||
}
|
||||
const url = match[1].trim();
|
||||
if (seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
handled++;
|
||||
try {
|
||||
const block = await buildUrlContext(url);
|
||||
const ok = block.includes('실데이터');
|
||||
report.push(ok ? `🌐 Fetched: ${url}` : `⚠️ Fetch failed: ${url}`);
|
||||
ctx.chatHistory.push({ role: 'system', content: block, internal: true });
|
||||
} catch (err: any) {
|
||||
// buildUrlContext 는 throw 하지 않지만 방어적으로.
|
||||
report.push(`⚠️ Fetch error: ${url} — ${String(err?.message ?? err).slice(0, 100)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,11 @@ export interface BuildAgentModeSystemPromptInput {
|
||||
actualModel: string;
|
||||
/** For token-cost logging — getConfig().contextLength. */
|
||||
contextLength: number;
|
||||
/**
|
||||
* 행동 제약·동적 블록 (behavior-constraints / cove-checklist 등) — Astra 모드와
|
||||
* 동일하게 [CONTEXT] *밖* 보호 구역에 join. 없으면 옛 동작 그대로.
|
||||
*/
|
||||
dynamicBlocks?: Map<string, string>;
|
||||
}
|
||||
|
||||
export function buildAgentModeSystemPrompt(input: BuildAgentModeSystemPromptInput): string {
|
||||
@@ -38,6 +43,7 @@ export function buildAgentModeSystemPrompt(input: BuildAgentModeSystemPromptInpu
|
||||
negativeCtx,
|
||||
actualModel,
|
||||
contextLength,
|
||||
dynamicBlocks,
|
||||
} = input;
|
||||
|
||||
// The Agent's prompt IS the primary directive (role / persona / tone / output format),
|
||||
@@ -69,10 +75,20 @@ export function buildAgentModeSystemPrompt(input: BuildAgentModeSystemPromptInpu
|
||||
|
||||
// [CONTEXT] … [/CONTEXT] 사이만 컨텍스트 초과 시 trim 대상 — agentBlock(앞)·reminder(뒤)·negative 는 보호.
|
||||
// memoryCtx(RAG/메모리/lessons)도 [CONTEXT] 안에 넣어 토큰이 빡빡할 때 대화 기록보다 먼저 잘리게 한다.
|
||||
// 순서 = 잘림 우선순위 (truncation 은 body *뒤*부터 자름): contextBlock(사용자가
|
||||
// 지금 가리킨 실데이터 — URL 본문/열린 파일/일정)을 맨 앞에 둬 최후까지 보존하고,
|
||||
// 배경 지식(brain RAG)과 memory 는 그보다 먼저 잘리게 한다.
|
||||
const priorConclusionBlock = priorConclusionCtx ? '\n\n' + priorConclusionCtx : '';
|
||||
// [자기 지식 + 1인칭] Astra 모드와 공용 — Agent 모드에서도 자기 오보고/3인칭 화법 방지.
|
||||
const selfIdentityBlock = '\n\n' + buildSelfIdentityBlock();
|
||||
const fullSystemPrompt = `${agentBlock}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}\n\n${strippedSystemPrompt}${selfIdentityBlock}${designerCtx}${secondBrainTraceCtx}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}${agentTailReminder}`;
|
||||
// 동적 블록 (행동 제약·CoVe 등) — [CONTEXT] 밖에 join 해 truncation 보호.
|
||||
let dynamicBlocksJoined = '';
|
||||
if (dynamicBlocks && dynamicBlocks.size > 0) {
|
||||
for (const body of dynamicBlocks.values()) {
|
||||
if (body && body.trim()) dynamicBlocksJoined += '\n\n' + body;
|
||||
}
|
||||
}
|
||||
const fullSystemPrompt = `${agentBlock}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}\n\n${strippedSystemPrompt}${selfIdentityBlock}${designerCtx}${secondBrainTraceCtx}${dynamicBlocksJoined}\n\n[CONTEXT]\n${contextBlock}\n${knowledgeContextForPrompt}\n${memoryCtx}\n[/CONTEXT]\n${negativeCtx}${agentTailReminder}`;
|
||||
|
||||
return fullSystemPrompt;
|
||||
}
|
||||
|
||||
@@ -101,5 +101,8 @@ export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInpu
|
||||
if (body && body.trim()) dynamicBlocksJoined += '\n\n' + body;
|
||||
}
|
||||
}
|
||||
return `${systemPrompt}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}${designerCtx}${projectArchitectureCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}${selfGrowthIdentityCtx}${knowledgeMixCtx}${casualCtx}${dynamicBlocksJoined}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}`;
|
||||
// [CONTEXT] 내부 순서 = 잘림 우선순위 (truncation 은 body *뒤*부터 자름):
|
||||
// contextBlock(사용자가 지금 가리킨 실데이터 — URL 본문/열린 파일/일정)을 맨 앞에
|
||||
// 둬 최후까지 보존, 배경 지식(brain RAG)과 memory 는 그보다 먼저 잘리게 한다.
|
||||
return `${systemPrompt}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}${designerCtx}${projectArchitectureCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}${selfGrowthIdentityCtx}${knowledgeMixCtx}${casualCtx}${dynamicBlocksJoined}\n\n[CONTEXT]\n${contextBlock}\n${knowledgeContextForPrompt}\n${memoryCtx}\n[/CONTEXT]\n${negativeCtx}`;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import { appendReflection } from '../../intelligence/reflectionStore';
|
||||
import { detectGaps } from '../../intelligence/gapDetector';
|
||||
import { appendSuccessPattern } from '../../intelligence/skillScore';
|
||||
import { getConfig } from '../../config';
|
||||
import { detectReimplementedProposals, formatReimplementationFooter } from '../../extension/featureConceptMap';
|
||||
import { isSelfAssessRequest, isAboutSelf } from '../../lib/contextBuilders/selfAssessContext';
|
||||
|
||||
const devilRebuttalHook: PostAnswerHook = {
|
||||
id: 'devil-rebuttal',
|
||||
@@ -226,6 +228,28 @@ const criticLoopHook: PostAnswerHook = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 인벤토리 자동 대조 — 자기 평가/자기 분석 턴에서 답변이 *이미 구현된 기능*을
|
||||
* 신규 도입하라고 제안하면 결정론적으로(LLM 콜 0) 정정 푸터를 붙인다.
|
||||
*
|
||||
* 배경: 인벤토리를 프롬프트에 주입해도 작은 로컬 모델이 mid-prompt 컨텍스트를
|
||||
* 무시하고 "Reflection Layer 도입하라"(이미 Self-Reflector 3단계 구현) 같은
|
||||
* 제안을 반복하는 실패가 재현됐다. 주입은 1차 방어선 — 이 훅이 최종 방어선.
|
||||
*/
|
||||
const inventoryCrossCheckHook: PostAnswerHook = {
|
||||
id: 'inventory-cross-check',
|
||||
runAsync: false,
|
||||
run(ctx: PostAnswerHookContext): void {
|
||||
if (!ctx.assistantAnswer || !ctx.assistantAnswer.trim()) return;
|
||||
// 자기 대상 턴에만 — 일반 코딩/리서치 답변에 오탐 푸터를 붙이지 않게.
|
||||
if (!(isSelfAssessRequest(ctx.userPrompt) || isAboutSelf(ctx.userPrompt))) return;
|
||||
const hits = detectReimplementedProposals(ctx.assistantAnswer);
|
||||
if (hits.length === 0) return;
|
||||
const footer = formatReimplementationFooter(hits);
|
||||
if (footer) ctx.getWebview()?.postMessage({ type: 'streamChunk', value: footer });
|
||||
},
|
||||
};
|
||||
|
||||
export const POST_ANSWER_HOOKS: PostAnswerHook[] = [
|
||||
devilRebuttalHook,
|
||||
postHocSelfCheckHook,
|
||||
@@ -233,6 +257,7 @@ export const POST_ANSWER_HOOKS: PostAnswerHook[] = [
|
||||
requirementCoverageHook,
|
||||
confidenceEscalationHook,
|
||||
criticLoopHook,
|
||||
inventoryCrossCheckHook,
|
||||
];
|
||||
|
||||
/** 모든 hook 을 안전하게 실행 — 한 hook 의 throw 가 다른 hook 막지 않음. */
|
||||
|
||||
+26
-2
@@ -269,6 +269,23 @@ export interface IAgentConfig {
|
||||
companyIntentAlignmentMode: 'off' | 'smart' | 'strict';
|
||||
/** alignment 라운드 최대 횟수 (질문→답변 사이클). 1~5. */
|
||||
companyIntentAlignmentMaxRounds: number;
|
||||
/**
|
||||
* URL 자동 수집 — 사용자 프롬프트에 http(s) URL 이 있으면 LLM 호출 전에
|
||||
* 본문을 가져와 컨텍스트로 주입 (일반 챗 + 기업 모드 + alignment 공통 게이트).
|
||||
* Bridge 추출 우선, 실패 시 직접 fetch 폴백.
|
||||
*/
|
||||
webAutoFetchEnabled: boolean;
|
||||
/**
|
||||
* Alignment 자가 조사 — openQuestions 를 사용자에게 보여주기 전에 두뇌를
|
||||
* 검색해 스스로 답할 수 있는 질문을 걸러낸다. 끄면 기존 동작 (질문 즉시 노출).
|
||||
*/
|
||||
companyAlignmentSelfResearch: boolean;
|
||||
/**
|
||||
* Alignment 학습 루프 — 사용자가 alignment 라운드에서 직접 답한 Q/A 를
|
||||
* 두뇌의 "Alignment Knowledge" 폴더에 노트로 저장. 다음 turn 의 자가 조사가
|
||||
* 이 노트를 발견해 같은 질문을 두 번 묻지 않게 된다.
|
||||
*/
|
||||
companyAlignmentKnowledgeSave: boolean;
|
||||
/**
|
||||
* Pixel Office 시각화 패널을 사이드바에 표시할지 여부. UI layer 전용 —
|
||||
* 끈다고 Agent 행동이 바뀌지 않는다. Off면 webview는 패널을 숨기고
|
||||
@@ -538,6 +555,9 @@ export function getConfig(): IAgentConfig {
|
||||
// 이유는 config 가 features/ 아래 모듈을 의존하면 의도치 않은 순환 import 가 생기기 때문.
|
||||
// 둘이 어긋나면 안 되므로 변경 시 양쪽 같이 갱신.
|
||||
companyIntentAlignmentMaxRounds: Math.max(1, Math.min(5, cfg.get<number>('company.intentAlignmentMaxRounds', 3))),
|
||||
webAutoFetchEnabled: cfg.get<boolean>('web.autoFetchUrls', true),
|
||||
companyAlignmentSelfResearch: cfg.get<boolean>('company.alignmentSelfResearch', true),
|
||||
companyAlignmentKnowledgeSave: cfg.get<boolean>('company.alignmentKnowledgeSave', true),
|
||||
selfReflectorEnabled: cfg.get<boolean>('selfReflector.enabled', false),
|
||||
hollowCheckEnabled: cfg.get<boolean>('hollowCheck.enabled', true),
|
||||
hollowCheckAutoRetry: cfg.get<boolean>('hollowCheck.autoRetry', true),
|
||||
@@ -557,8 +577,12 @@ export function getConfig(): IAgentConfig {
|
||||
polishPersonaOverride: (cfg.get<string>('polishPersonaOverride', '') || '').trim(),
|
||||
liveStreamTokens: cfg.get<boolean>('liveStreamTokens', true),
|
||||
outputFormat: ((): 'plain' | 'markdown' => {
|
||||
const v = (cfg.get<string>('outputFormat', 'plain') || 'plain').trim().toLowerCase();
|
||||
return v === 'markdown' ? 'markdown' : 'plain';
|
||||
// 기본 'markdown' — 채팅 webview 가 marked 로 렌더하므로 plain 이 기본이면
|
||||
// 최종본(streamReplace)에서 ## 헤더가 제거되어 제목/본문이 같은 크기로
|
||||
// 보이는 가독성 문제가 생긴다 (스트리밍 중에는 raw 가 그대로 렌더되어
|
||||
// 멀쩡하다가 완료 순간 평문으로 변하는 증상).
|
||||
const v = (cfg.get<string>('outputFormat', 'markdown') || 'markdown').trim().toLowerCase();
|
||||
return v === 'plain' ? 'plain' : 'markdown';
|
||||
})(),
|
||||
chronicleAutoRecord: cfg.get<boolean>('chronicleAutoRecord', true),
|
||||
lmStudioTopP: Math.max(0, Math.min(1, cfg.get<number>('lmStudio.sampling.topP', 0.9))),
|
||||
|
||||
+92
-4
@@ -1,16 +1,33 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
import { getConfig } from '../config';
|
||||
import { logInfo, logWarn, logError } from '../utils';
|
||||
import { logInfo, logWarn, logError, getActiveBrainProfile } from '../utils';
|
||||
import { getBridgeBaseUrl } from '../features/datacollect/bridgeClient';
|
||||
|
||||
/**
|
||||
* HealthCheckMonitor: Periodically monitors the environment
|
||||
* HealthCheckMonitor: Periodically monitors the environment
|
||||
* (Ollama, Disk, API) to ensure the agent stays functional.
|
||||
*
|
||||
*
|
||||
* v2.2.245: 머신 로컬 전제 조건 4종 추가 — Bridge 응답·두뇌 볼륨 마운트·
|
||||
* git push 자격증명·Antigravity 확장 버전 정합. 한 세션에서 이 4가지가 전부
|
||||
* 사고로 터진 적이 있는데 (URL 분석 실패 / 인벤토리 미생성 / 동기화 실패 /
|
||||
* 구버전 실행), 전부 사후 디버깅으로만 발견됐다. 이제 readyBar ⚠️ 로 사전 가시화.
|
||||
*
|
||||
* Properly tracks the interval timer for cleanup on deactivation.
|
||||
*/
|
||||
export class HealthCheckMonitor {
|
||||
private static intervalHandle: ReturnType<typeof setInterval> | null = null;
|
||||
/** 마지막 검사 결과 — readyBar payload 가 읽어간다 (재검사 비용 없이). */
|
||||
private static _lastReports: string[] = [];
|
||||
/** 직전 토스트 내용 — 같은 경고를 10분마다 반복 토스트하지 않기 위한 dedupe. */
|
||||
private static _lastToastKey = '';
|
||||
|
||||
public static get lastReports(): string[] {
|
||||
return this._lastReports.slice();
|
||||
}
|
||||
|
||||
public static async runAllChecks(): Promise<{ ok: boolean; reports: string[] }> {
|
||||
const reports: string[] = [];
|
||||
@@ -46,9 +63,80 @@ export class HealthCheckMonitor {
|
||||
reports.push('Write permissions denied in the current workspace.');
|
||||
}
|
||||
|
||||
// 4. Datacollect Bridge 응답 — 어떤 HTTP 응답이든(404 포함) 살아 있는 것.
|
||||
// 네트워크 오류만 다운으로 판정. Bridge 다운이어도 URL 분석은 직접
|
||||
// fetch 폴백으로 동작하므로 경고는 영향 범위를 정확히 알린다.
|
||||
try {
|
||||
const bridgeUrl = getBridgeBaseUrl();
|
||||
if (bridgeUrl) {
|
||||
try {
|
||||
await fetch(bridgeUrl, { signal: AbortSignal.timeout(3000) });
|
||||
} catch {
|
||||
reports.push(`Datacollect Bridge(${bridgeUrl})가 응답하지 않습니다 — /wikify·/benchmark 와 고품질 URL 추출이 비활성 (URL 분석 자체는 직접 fetch 폴백으로 동작).`);
|
||||
}
|
||||
}
|
||||
} catch { /* config 읽기 실패 등 — 검사 자체를 조용히 skip */ }
|
||||
|
||||
// 5. 두뇌 볼륨/경로 — 외장 볼륨이 늦게 마운트되면 검색·레슨·인벤토리가
|
||||
// 조용히 비활성화되던 사고의 사전 감지.
|
||||
let brain: ReturnType<typeof getActiveBrainProfile> | undefined;
|
||||
try {
|
||||
brain = getActiveBrainProfile();
|
||||
if (brain?.localBrainPath && !fs.existsSync(brain.localBrainPath)) {
|
||||
reports.push(`두뇌 폴더가 없습니다 (외장 볼륨 미마운트?): ${brain.localBrainPath} — 검색·레슨·인벤토리가 비활성화됩니다.`);
|
||||
}
|
||||
} catch { /* profile 읽기 실패 — skip */ }
|
||||
|
||||
// 6. git push 자격증명 — 원격 동기화를 *설정한* 두뇌만 검사 (secondBrainRepo
|
||||
// 비어 있으면 두뇌 동기화가 로컬 새로고침 모드라 자격증명 불필요).
|
||||
try {
|
||||
if (brain?.secondBrainRepo?.trim() && brain.localBrainPath && fs.existsSync(brain.localBrainPath)) {
|
||||
try {
|
||||
execSync('git push --dry-run', {
|
||||
cwd: brain.localBrainPath,
|
||||
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||
timeout: 5000,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch (e: any) {
|
||||
const msg = String(e?.stderr || e?.message || '');
|
||||
if (/could not read Username|Authentication failed|terminal prompts disabled/i.test(msg)) {
|
||||
reports.push(`두뇌 원격 push 자격증명이 없습니다 — 터미널에서 "cd ${brain.localBrainPath} && git push" 1회 실행으로 키체인에 저장하세요.`);
|
||||
}
|
||||
// 그 외(오프라인·upstream 없음 등)는 소음 방지 — 보고하지 않음.
|
||||
}
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
|
||||
// 7. Antigravity 확장 버전 정합 — VS Code 와 Antigravity 양쪽에 설치된
|
||||
// 경우 버전이 갈리면 "고쳤는데 안 고쳐짐" 혼란의 단골 원인 (실사례:
|
||||
// 2.2.19 vs 2.2.232 공존). Antigravity 미사용 머신에서는 자동 skip.
|
||||
try {
|
||||
const agExtRegistry = path.join(os.homedir(), '.antigravity', 'extensions', 'extensions.json');
|
||||
if (fs.existsSync(agExtRegistry)) {
|
||||
const arr = JSON.parse(fs.readFileSync(agExtRegistry, 'utf8'));
|
||||
const mine = Array.isArray(arr)
|
||||
? arr.find((e: any) => e?.identifier?.id === 'g1nation.astra')
|
||||
: null;
|
||||
const myVersion = vscode.extensions.getExtension('g1nation.astra')?.packageJSON?.version;
|
||||
if (mine?.version && myVersion && mine.version !== myVersion) {
|
||||
reports.push(`Antigravity 에 다른 버전의 Astra(${mine.version})가 설치되어 있습니다 — 현재 실행 중 ${myVersion}. Antigravity 쪽은 수동 업데이트가 필요합니다.`);
|
||||
}
|
||||
}
|
||||
} catch { /* registry 파싱 실패 — skip */ }
|
||||
|
||||
this._lastReports = reports;
|
||||
|
||||
if (reports.length > 0) {
|
||||
logWarn(`Health Check Warnings: ${reports.join(' | ')}`);
|
||||
vscode.window.showWarningMessage(`Astra Health Warning: ${reports[0]}`);
|
||||
// 동일 경고 반복 토스트 방지 — 내용이 바뀐 경우에만 1회 토스트.
|
||||
const toastKey = reports.join('|');
|
||||
if (toastKey !== this._lastToastKey) {
|
||||
this._lastToastKey = toastKey;
|
||||
vscode.window.showWarningMessage(`Astra Health Warning: ${reports[0]}${reports.length > 1 ? ` (외 ${reports.length - 1}건 — 준비 상태 바 참조)` : ''}`);
|
||||
}
|
||||
} else {
|
||||
this._lastToastKey = '';
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 기능 개념 지도 — vscode 의존 없는 순수 모듈 (테스트 용이).
|
||||
*
|
||||
* 두 소비처:
|
||||
* 1. featureInventory.ts — "ASTRA 기능 인벤토리" 마크다운 생성 (LLM 프롬프트 주입용)
|
||||
* 2. postAnswerHooks inventory-cross-check — 답변이 *이미 구현된 기능*을 신규
|
||||
* 제안하는지 결정론적으로 검사해 정정 푸터를 붙임
|
||||
*
|
||||
* 2번이 존재하는 이유: 인벤토리를 프롬프트에 주입해도 작은 로컬 모델은 mid-prompt
|
||||
* 컨텍스트를 무시하고 일반론으로 "X를 도입하라"고 제안하는 실패가 반복 재현됐다.
|
||||
* 프롬프트 주입은 1차 방어선일 뿐 — 모델이 무시해도 사용자에게 정정이 보이는
|
||||
* 결정론적 안전망(LLM 콜 0)이 최종 방어선이다.
|
||||
*/
|
||||
|
||||
export interface ConceptEntry {
|
||||
/** 학술/일반 명칭 (마크다운 표기용). */
|
||||
concept: string;
|
||||
/** ASTRA 구현 내역 한 줄. */
|
||||
impl: string;
|
||||
/** 답변 본문에서 이 개념을 식별하는 키워드 (소문자 비교, 한·영). */
|
||||
keywords: string[];
|
||||
}
|
||||
|
||||
export const CONCEPT_ENTRIES: ConceptEntry[] = [
|
||||
{
|
||||
concept: 'CoVe / Chain-of-Verification / Self-Critique',
|
||||
impl: '구현됨 — coveEnabled(답변 전 그라운딩 체크리스트) + critic-loop 훅(문제 신호 턴 LLM 검수) + citationTrace(출처 역추적)',
|
||||
keywords: ['cove', 'chain-of-verification', 'self-critique', 'selfcritique', 'self-correction', '자기 검증', '자기검증', '자기 수정', '크리틱 에이전트', '비판적 사고 루프', '스스로 검토하는 단계'],
|
||||
},
|
||||
{
|
||||
concept: '지식 노후 점검 자동화 / Automated Decay Audit',
|
||||
impl: '구현됨 — 주간 성장 사이클이 매주 자동 실행 (decay-report.md) + "Astra: 지식 노후 점검" 수동 명령',
|
||||
keywords: ['노후화', '노후 점검', 'decay', '지식 신선도'],
|
||||
},
|
||||
{
|
||||
concept: '지식 충돌 감지/해결 / Conflict Resolver',
|
||||
impl: '구현됨 — 검색 시점 [CONFLICT WARNING] + 일일 충돌 스캔 + 신뢰도(trust·confidence·최신성) 비교 우선 권고. 최종 결정만 사람',
|
||||
keywords: ['충돌 감지', '충돌 해결', 'conflict resolver', '상충되는 정보 발견'],
|
||||
},
|
||||
{
|
||||
concept: '피드백 태깅 / 오류 분류 / Feedback Tagging',
|
||||
impl: '구현됨 — Correction Loop가 사용자 정정을 자동 분류(사실오류/근거누락/맥락누락/추론오류/지시불이행/형식오류)해 레슨+회귀 케이스로 저장',
|
||||
keywords: ['피드백 태깅', '피드백 루프 자동화', '오류 분류', '교훈으로 자동', '자동으로 \'교훈\'', '교훈을 자동', 'feedback tagging'],
|
||||
},
|
||||
{
|
||||
concept: '멀티스텝 플래닝 / Multi-Step Planning / CoT 강제',
|
||||
impl: '구현됨 — multiAgentEnabled(Planner→Researcher→Writer, 기본 OFF) + 1인 기업 모드 디스패처',
|
||||
keywords: ['멀티스텝 플래닝', 'multi-step planning', 'cot 강제', 'reasoning chain', '추론 체인', 'chain of thought', '생각 단계(thought)', '생각의 단계'],
|
||||
},
|
||||
{
|
||||
concept: '골든셋 자동 평가 / Regression Test',
|
||||
impl: '구현됨 — 주간 사이클 자동 평가 + 직전 대비 회귀 경보(regression-alert.md) + 정정 회귀 재검사',
|
||||
keywords: ['골든셋', '회귀 테스트', 'regression test'],
|
||||
},
|
||||
{
|
||||
concept: 'Sleep-time / 유휴 시간 학습',
|
||||
impl: '구현됨 — 일일 지식 사전 소화 (Digests/)',
|
||||
keywords: ['sleep-time', '유휴 시간 학습', '유휴시간 학습'],
|
||||
},
|
||||
{
|
||||
concept: '확신도 게이팅 / 환각 방지 표명',
|
||||
impl: '구현됨 — [GROUNDING] 강함/보통/약함 + 약함 시 표명 강제 + 학습큐 자동 등록',
|
||||
keywords: ['확신도 게이팅', '환각 방지', '확신 편향'],
|
||||
},
|
||||
{
|
||||
concept: 'Reflection Layer / 자기 성찰 / 메타 학습 루프 / Self-Reflection',
|
||||
impl: '구현됨 — Self-Reflector Phase A(답변 자가 점검 블록, opt-in) + Phase B(외부 검증 LLM + 자동 재시도) + Phase C(생성 파일 syntax 검증) + Hollow Code Check(빈 깡통 감지 + 자동 재작업) + 약점 프로필→자기검토 블록(최근 정정 통계가 다음 턴 행동을 직접 변경)',
|
||||
keywords: ['reflection layer', 'self-reflection', '자기 성찰', '성찰 계층', '성찰 단계', '성찰(reflection)', '메타 학습'],
|
||||
},
|
||||
{
|
||||
concept: '질문 전 자가 조사 / Self-Research / 경험 기반 제약 주입',
|
||||
impl: '구현됨 — Intent Alignment 자가 조사(질문을 사용자에게 노출하기 전 두뇌 검색으로 선해결) + 사용자 답변 두뇌 자동 저장(Alignment Knowledge 학습 루프) + 레슨 체크리스트 truncation 보호 구역 주입',
|
||||
keywords: ['자가 조사', 'self-research', '경험 기반 제약'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 신규 도입/추가/구축을 제안하는 문장인지 (또는 부재를 단정하는 문장).
|
||||
* '합' 포함 — "추가합니다/구축합니다" 활용형 미매치 갭 (회귀 테스트로 발견).
|
||||
* '강화' 동사 + "도입:" 명사형(소제목 스타일) 추가 — 12B 답변의
|
||||
* "Reasoning Chain 도입: ..." 패턴 미매치 갭 (회귀 테스트로 발견).
|
||||
*/
|
||||
const PROPOSAL_RE = /(도입|추가|구축|신설|개발|보강|강화|만들)(하|해|할|합|을|를)|(도입|추가|구축|신설)\s*[::]|필요합니다|제안합니다|추천합니다|부족합니다|부재|없습니다|미흡/;
|
||||
/** 같은 문장에서 이미 구현 사실을 인지하고 있으면 정정 불필요. */
|
||||
const ACKNOWLEDGED_RE = /(이미|기)\s*(구현|존재|있)|구현돼|구현되어|작동\s*중/;
|
||||
|
||||
export interface ReimplementationHit {
|
||||
concept: string;
|
||||
impl: string;
|
||||
/** 감지된 문장 (검증/디버그용, 120자 cap). */
|
||||
sentence: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 답변이 이미 구현된 기능을 "도입하라/없다"고 제안·단정하는 문장을 결정론적으로
|
||||
* 감지. LLM 콜 0 — 문장 단위 키워드 + 제안 동사 공기(co-occurrence) 검사.
|
||||
* 보수적 설계: 같은 문장에 "이미 구현/존재" 인지가 있으면 제외 (정정 노이즈 방지).
|
||||
*/
|
||||
export function detectReimplementedProposals(answer: string): ReimplementationHit[] {
|
||||
if (!answer || !answer.trim()) return [];
|
||||
// 문장 분리 — 마침표/줄바꿈/콜론 기준의 느슨한 분할이면 충분.
|
||||
const sentences = answer.split(/(?<=[.!?다요음됨])\s+|\n+/);
|
||||
const hits: ReimplementationHit[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const sentence of sentences) {
|
||||
const lower = sentence.toLowerCase();
|
||||
if (!PROPOSAL_RE.test(sentence)) continue;
|
||||
if (ACKNOWLEDGED_RE.test(sentence)) continue;
|
||||
for (const entry of CONCEPT_ENTRIES) {
|
||||
if (seen.has(entry.concept)) continue;
|
||||
if (entry.keywords.some((k) => lower.includes(k.toLowerCase()))) {
|
||||
seen.add(entry.concept);
|
||||
hits.push({
|
||||
concept: entry.concept,
|
||||
impl: entry.impl,
|
||||
sentence: sentence.trim().slice(0, 120),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
/** 정정 푸터 마크다운 — 빈 hits 면 ''. */
|
||||
export function formatReimplementationFooter(hits: ReimplementationHit[]): string {
|
||||
if (hits.length === 0) return '';
|
||||
const lines: string[] = [];
|
||||
lines.push('\n\n---');
|
||||
lines.push('⚠️ **기능 인벤토리 자동 대조** (결정론적 검사 — LLM 아님): 위 답변이 신규 도입을 제안한 항목 중 다음은 **이미 구현되어 있습니다**.');
|
||||
for (const h of hits) {
|
||||
lines.push(`- **${h.concept}** → ${h.impl}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('_위 제안을 채택하기 전에 기존 구현을 먼저 확인하세요. (두뇌의 "ASTRA 기능 인벤토리.md" 참조)_');
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -32,20 +32,14 @@ const HOOK_DESCRIPTIONS: Record<string, string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 학술/일반 개념 명칭 ↔ ASTRA 구현 매핑. 자기 개선 제안에서 모델이 학술 명칭
|
||||
* ("CoVe 도입하라")으로 제안할 때 설정 키(coveEnabled)와 같은 것임을 모르는
|
||||
* 이름 매핑 갭을 막는다. 코드와 함께 배포되므로 릴리스마다 자동 최신화.
|
||||
* 학술/일반 개념 명칭 ↔ ASTRA 구현 매핑 — featureConceptMap.ts 로 이전 (순수
|
||||
* 모듈). 인벤토리 마크다운과 inventory-cross-check 훅(결정론적 재구현 제안
|
||||
* 감지)이 같은 정본을 공유한다.
|
||||
*/
|
||||
const CONCEPT_MAP: Array<[concept: string, impl: string]> = [
|
||||
['CoVe / Chain-of-Verification / Self-Critique', '구현됨 — coveEnabled(답변 전 그라운딩 체크리스트) + critic-loop 훅(문제 신호 턴 LLM 검수) + citationTrace(출처 역추적)'],
|
||||
['지식 노후 점검 자동화 / Automated Decay Audit', '구현됨 — 주간 성장 사이클이 매주 자동 실행 (decay-report.md)'],
|
||||
['지식 충돌 감지/해결 / Conflict Resolver', '구현됨 — 검색 시점 [CONFLICT WARNING] + 일일 충돌 스캔 + 신뢰도(trust·confidence·최신성) 비교 우선 권고. 최종 결정만 사람'],
|
||||
['피드백 태깅 / 오류 분류 / Feedback Tagging', '구현됨 — Correction Loop가 사용자 정정을 자동 분류(사실오류/근거누락/맥락누락/추론오류/지시불이행/형식오류)해 레슨+회귀 케이스로 저장'],
|
||||
['멀티스텝 플래닝 / Multi-Step Planning / CoT 강제', '구현됨 — multiAgentEnabled(Planner→Researcher→Writer, 기본 OFF) + 1인 기업 모드 디스패처'],
|
||||
['골든셋 자동 평가 / Regression Test', '구현됨 — 주간 사이클 자동 평가 + 직전 대비 회귀 경보(regression-alert.md) + 정정 회귀 재검사'],
|
||||
['Sleep-time / 유휴 시간 학습', '구현됨 — 일일 지식 사전 소화 (Digests/)'],
|
||||
['확신도 게이팅 / 환각 방지 표명', '구현됨 — [GROUNDING] 강함/보통/약함 + 약함 시 표명 강제 + 학습큐 자동 등록'],
|
||||
];
|
||||
import { CONCEPT_ENTRIES } from './featureConceptMap';
|
||||
|
||||
const CONCEPT_MAP: Array<[concept: string, impl: string]> =
|
||||
CONCEPT_ENTRIES.map((e) => [e.concept, e.impl]);
|
||||
|
||||
function stripMd(s: string): string {
|
||||
return (s || '').replace(/\*\*|`|\[|\]/g, '').replace(/\s+/g, ' ').trim();
|
||||
@@ -88,6 +82,24 @@ export function buildInventoryMarkdown(pkg: any, nowIso: string): string {
|
||||
'아래 개념들은 명칭이 달라도 **이미 구현되어 있다**. 이들을 "도입/추가하라"고 제안하면 오답이다:',
|
||||
...CONCEPT_MAP.map(([concept, impl]) => `- **${concept}**: ${impl}`),
|
||||
'',
|
||||
'## 📖 학습 메커니즘 — 정본 (자기 학습 방식 질문에는 이 섹션만 근거로 답할 것)',
|
||||
'',
|
||||
'**대원칙: 모델 가중치는 절대 학습되지 않는다.** 모든 "학습"은 두뇌 폴더',
|
||||
'(설정된 localBrainPath — ConnectAI 프로젝트 루트가 아님)에 *파일로* 쌓이고,',
|
||||
'다음 턴의 검색·프롬프트 주입을 통해 행동이 바뀌는 **외부 기억 기반** 방식이다.',
|
||||
'두뇌 폴더를 지우면 학습도 사라진다. "패턴을 학습해 선제적으로 제시한다" 류의',
|
||||
'서술은 거짓이다 — 선제 제시 엔진은 존재하지 않는다.',
|
||||
'',
|
||||
'자동 학습 회로는 정확히 다음 5가지뿐이다:',
|
||||
'1. **Correction Loop** — 사용자 정정 발화 감지 → 오류 유형 자동 분류 → 레슨 + 회귀 케이스(.astra/eval/corrections.jsonl) 저장',
|
||||
'2. **레슨(Experience Memory)** — 과거 실수가 "실패 방지 체크리스트"로 다음 턴 프롬프트에 주입 (truncation 보호 구역)',
|
||||
'3. **약점 프로필** — 최근 정정 통계가 자기검토 블록을 통해 다음 턴 행동을 직접 변경',
|
||||
'4. **Alignment Knowledge** — 1인 기업 모드 질문에 사용자가 답해준 내용을 두뇌 "Alignment Knowledge" 폴더에 저장 → 같은 질문 재발 방지',
|
||||
'5. **주간 성장 사이클** — decay 리포트(지식 노후), 골든셋 회귀 평가, 학습 큐 갱신',
|
||||
'',
|
||||
'보조 기억(학습이 아닌 *기억*): 단기 대화 히스토리, [PRIOR TURN CONCLUSION] 앵커,',
|
||||
'세션 요약(중기), 두뇌 RAG(장기), Project Chronicle(기록 생성 — 검색 두뇌와 별개).',
|
||||
'',
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Alignment Self-Research — 사용자에게 묻기 전에 두뇌를 먼저 검색.
|
||||
*
|
||||
* Intent Alignment 분석기가 만든 openQuestions 를 사용자에게 노출하기 전에,
|
||||
* 활성 두뇌(지식 폴더)를 TF-IDF 로 검색해 *스스로 답할 수 있는 질문* 을 걸러낸다.
|
||||
* 답을 찾은 질문은 answeredQuestions 로 옮겨지고(자가 조사 marker 부착),
|
||||
* 정말 모르는 질문만 사용자에게 도달한다.
|
||||
*
|
||||
* Phase 3(학습 루프): 사용자가 직접 답해준 Q/A 는 두뇌의 전용 폴더에 일반
|
||||
* 노트로 저장된다. 다음 turn 에서 같은 질문이 나오면 이 모듈의 자가 조사가
|
||||
* 그 노트를 발견해 스스로 해결 — 같은 것을 두 번 묻지 않는 구조.
|
||||
*
|
||||
* 설계 원칙 (intentAlignment 와 동일):
|
||||
* - 절대 throw 하지 않는다. 검색/LLM/IO 실패는 "자가 조사 안 한 것"과
|
||||
* 동일하게 동작해야 한다 — alignment 흐름 차단 금지.
|
||||
* - Lesson(Experience Memory) 시스템과 분리 — 그쪽은 "실수 회피" 카드라
|
||||
* 주입 위치/의미가 다르다. 여기 저장물은 평범한 지식 노트.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { IAIService } from '../../core/services';
|
||||
import { findBrainFiles, logError, logInfo } from '../../utils';
|
||||
import { getBrainTokenIndex } from '../../retrieval/brainIndex';
|
||||
import { tokenize, expandQuery, scoreTfIdfPreTokenized, extractBestExcerpt } from '../../retrieval/scoring';
|
||||
|
||||
/**
|
||||
* 자가 조사로 채워진 답변의 식별 prefix. formatContractForPrompt 를 거쳐
|
||||
* dispatcher 의 LLM 에게 그대로 전달되므로, "사용자가 직접 말한 사실"과
|
||||
* 구분되도록 의미를 텍스트 자체에 내장한다. webview 카드 렌더에서도 이
|
||||
* prefix 로 자가 조사 항목을 골라낸다.
|
||||
*/
|
||||
export const SELF_RESEARCH_PREFIX = '(자가 조사로 두뇌에서 확인) ';
|
||||
|
||||
/** 두뇌 저장 폴더 이름 — Phase 3 의 지식 노트가 쌓이는 곳. */
|
||||
export const ALIGNMENT_KNOWLEDGE_DIR = 'Alignment Knowledge';
|
||||
|
||||
/** 질문 1개에 대해 두뇌에서 모은 근거 발췌. */
|
||||
export interface QuestionEvidence {
|
||||
question: string;
|
||||
excerpts: Array<{ title: string; relativePath: string; excerpt: string }>;
|
||||
}
|
||||
|
||||
/** 질문별 검색 상한 — 질문당 top 2 파일, 발췌 600자, 전체 합계 4,000자. */
|
||||
const FILES_PER_QUESTION = 2;
|
||||
const EXCERPT_MAX_CHARS = 600;
|
||||
const TOTAL_EVIDENCE_CAP = 4000;
|
||||
|
||||
/**
|
||||
* openQuestions 각각을 두뇌에 TF-IDF 검색해 근거 발췌를 모은다.
|
||||
* 빈 두뇌 / 잘못된 경로 / IO 에러 → 해당 질문의 excerpts 가 빈 배열 (throw 금지).
|
||||
* 인덱스는 brainIndex 의 mtime 캐시를 그대로 활용하므로 재호출 비용이 낮다.
|
||||
*/
|
||||
export function gatherEvidenceForQuestions(
|
||||
brainPath: string,
|
||||
questions: string[],
|
||||
): QuestionEvidence[] {
|
||||
const empty = questions.map((q) => ({ question: q, excerpts: [] as QuestionEvidence['excerpts'] }));
|
||||
if (!brainPath || questions.length === 0) return empty;
|
||||
try {
|
||||
const files = findBrainFiles(brainPath);
|
||||
if (files.length === 0) return empty;
|
||||
const index = getBrainTokenIndex(brainPath, files);
|
||||
if (index.length === 0) return empty;
|
||||
|
||||
let totalChars = 0;
|
||||
return questions.map((question) => {
|
||||
const queryTokens = expandQuery(tokenize(question));
|
||||
if (queryTokens.length === 0) return { question, excerpts: [] };
|
||||
const scored = scoreTfIdfPreTokenized(queryTokens, index)
|
||||
.filter((s) => s.score > 0)
|
||||
.slice(0, FILES_PER_QUESTION);
|
||||
const excerpts: QuestionEvidence['excerpts'] = [];
|
||||
for (const s of scored) {
|
||||
if (totalChars >= TOTAL_EVIDENCE_CAP) break;
|
||||
const doc = index[s.index];
|
||||
let content = '';
|
||||
try {
|
||||
content = fs.readFileSync(doc.filePath, 'utf8');
|
||||
} catch {
|
||||
continue; // 인덱스에 있는데 디스크에서 사라진 파일 — skip
|
||||
}
|
||||
const excerpt = extractBestExcerpt(content, tokenize(question), EXCERPT_MAX_CHARS);
|
||||
if (!excerpt.trim()) continue;
|
||||
totalChars += excerpt.length;
|
||||
excerpts.push({ title: doc.title, relativePath: doc.relativePath, excerpt });
|
||||
}
|
||||
return { question, excerpts };
|
||||
});
|
||||
} catch (e: any) {
|
||||
logError('alignmentResearch: evidence gathering failed; skipping self-research.', {
|
||||
error: e?.message ?? String(e),
|
||||
});
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
const SELF_ANSWER_SYSTEM_PROMPT = `당신은 "1인 기업 모드"의 *자가 조사 판정가*입니다. 사용자에게 질문을 던지기 전에, 두뇌(저장된 지식 노트)에서 검색된 근거만으로 각 질문에 답할 수 있는지 판정합니다.
|
||||
|
||||
규칙:
|
||||
- 근거 발췌에 *명시적으로 적혀 있는* 내용으로만 답하세요. 일반 상식·추측으로 채우는 것은 금지입니다.
|
||||
- 근거가 부분적이거나 모호하면 그 질문은 "unanswered" 입니다. 확신이 없으면 unanswered 가 정답입니다.
|
||||
- answered 인 질문의 answer 는 근거에서 추출한 사실을 2~3문장으로 요약하고, 출처 노트 제목을 괄호로 덧붙이세요.
|
||||
|
||||
⚠️ 반드시 아래 JSON 한 번만 출력. 다른 텍스트(설명·코드펜스·머리말) 일체 금지.
|
||||
|
||||
{
|
||||
"answers": [
|
||||
{ "question": "<원문 그대로>", "status": "answered" | "unanswered", "answer": "<answered 일 때만, 아니면 빈 문자열>" }
|
||||
]
|
||||
}`;
|
||||
|
||||
/** 자가 조사 판정 결과 한 건. */
|
||||
export interface SelfAnswer {
|
||||
question: string;
|
||||
answered: boolean;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM 1회 호출로 "근거만으로 답 가능한 질문"을 판정. 호출/파싱 실패 시
|
||||
* 모든 질문을 unanswered 로 반환 — 원래의 질문 카드 흐름이 그대로 동작.
|
||||
*/
|
||||
export async function selfAnswerQuestions(
|
||||
ai: IAIService,
|
||||
input: {
|
||||
userPrompt: string;
|
||||
evidence: QuestionEvidence[];
|
||||
model?: string;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
): Promise<SelfAnswer[]> {
|
||||
const withEvidence = input.evidence.filter((e) => e.excerpts.length > 0);
|
||||
const fallback = input.evidence.map((e) => ({ question: e.question, answered: false, answer: '' }));
|
||||
if (withEvidence.length === 0) return fallback;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('[사용자 원본 요청]');
|
||||
lines.push(input.userPrompt);
|
||||
lines.push('');
|
||||
lines.push('[판정할 질문과 두뇌 검색 근거]');
|
||||
for (const e of withEvidence) {
|
||||
lines.push('');
|
||||
lines.push(`질문: ${e.question}`);
|
||||
for (const x of e.excerpts) {
|
||||
lines.push(`- 근거 (노트: "${x.title}", 경로: ${x.relativePath}):`);
|
||||
lines.push(` ${x.excerpt.replace(/\n/g, '\n ')}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('판정 JSON만 출력:');
|
||||
|
||||
let raw = '';
|
||||
try {
|
||||
const result = await ai.chat({
|
||||
system: SELF_ANSWER_SYSTEM_PROMPT,
|
||||
user: lines.join('\n'),
|
||||
model: input.model,
|
||||
timeoutMs: input.timeoutMs,
|
||||
});
|
||||
raw = result.content || '';
|
||||
} catch (e: any) {
|
||||
logError('alignmentResearch: self-answer call failed; all questions pass through.', {
|
||||
error: e?.message ?? String(e),
|
||||
});
|
||||
return fallback;
|
||||
}
|
||||
const parsed = _parseSelfAnswerJson(raw);
|
||||
if (!parsed) {
|
||||
logInfo('alignmentResearch: self-answer parse failed; all questions pass through.', {
|
||||
rawHead: raw.slice(0, 100),
|
||||
});
|
||||
return fallback;
|
||||
}
|
||||
// LLM 출력을 원래 질문 목록에 매핑 — 누락된 질문은 unanswered 로 채움.
|
||||
const byQuestion = new Map(parsed.map((a) => [a.question.trim(), a]));
|
||||
return input.evidence.map((e) => {
|
||||
const hit = byQuestion.get(e.question.trim());
|
||||
if (hit && hit.status === 'answered' && hit.answer.trim()) {
|
||||
return { question: e.question, answered: true, answer: hit.answer.trim() };
|
||||
}
|
||||
return { question: e.question, answered: false, answer: '' };
|
||||
});
|
||||
}
|
||||
|
||||
/** 4-stage 관용 파서 — intentAlignment 와 동일 패턴 (작은 모델의 펜스/머리말 대응). */
|
||||
export function _parseSelfAnswerJson(raw: string): Array<{
|
||||
question: string;
|
||||
status: 'answered' | 'unanswered';
|
||||
answer: string;
|
||||
}> | null {
|
||||
if (!raw || !raw.trim()) return null;
|
||||
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
||||
const stage1 = (fenced ? fenced[1] : raw).trim();
|
||||
for (const candidate of [stage1, _extractFirstBalancedObject(stage1)]) {
|
||||
if (!candidate) continue;
|
||||
try {
|
||||
const obj = JSON.parse(candidate);
|
||||
const coerced = _coerceSelfAnswers(obj);
|
||||
if (coerced) return coerced;
|
||||
} catch { /* 다음 stage */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _coerceSelfAnswers(obj: unknown): ReturnType<typeof _parseSelfAnswerJson> {
|
||||
if (!obj || typeof obj !== 'object') return null;
|
||||
const answers = (obj as Record<string, unknown>).answers;
|
||||
if (!Array.isArray(answers)) return null;
|
||||
const out: Array<{ question: string; status: 'answered' | 'unanswered'; answer: string }> = [];
|
||||
for (const a of answers) {
|
||||
if (!a || typeof a !== 'object') continue;
|
||||
const r = a as Record<string, unknown>;
|
||||
const question = typeof r.question === 'string' ? r.question.trim() : '';
|
||||
if (!question) continue;
|
||||
const status = r.status === 'answered' ? 'answered' : 'unanswered';
|
||||
const answer = typeof r.answer === 'string' ? r.answer.trim() : '';
|
||||
out.push({ question, status, answer });
|
||||
}
|
||||
return out.length > 0 ? out : null;
|
||||
}
|
||||
|
||||
function _extractFirstBalancedObject(s: string): string | null {
|
||||
const start = s.indexOf('{');
|
||||
if (start === -1) return null;
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
for (let i = start; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (inString) {
|
||||
if (escape) escape = false;
|
||||
else if (ch === '\\') escape = true;
|
||||
else if (ch === '"') inString = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') { inString = true; continue; }
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) return s.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Phase 3: 사용자 답변의 두뇌 저장 (학습 루프) ────────────────────────────
|
||||
|
||||
/** 저장 대상 필터 — 사용자가 *직접* 답한 실질적 정보만. */
|
||||
const MIN_ANSWER_CHARS = 20;
|
||||
|
||||
/**
|
||||
* 사용자가 alignment 라운드에서 직접 답해준 Q/A 를 두뇌의 전용 폴더에 일반
|
||||
* 노트로 저장한다. 자가 조사 항목(SELF_RESEARCH_PREFIX)과 20자 미만의 짧은
|
||||
* 답은 제외 — 두뇌 오염 방지. 같은 날 같은 요청의 노트가 이미 있으면 skip.
|
||||
*
|
||||
* @returns 저장된 파일 경로, 저장할 것이 없거나 실패하면 null (throw 금지).
|
||||
*/
|
||||
export function saveAlignmentKnowledge(
|
||||
brainPath: string,
|
||||
input: { userPrompt: string; qaList: Array<{ q: string; a: string }> },
|
||||
): string | null {
|
||||
try {
|
||||
if (!brainPath || !fs.existsSync(brainPath)) return null;
|
||||
const userAnswered = input.qaList.filter(
|
||||
(qa) => !qa.a.startsWith(SELF_RESEARCH_PREFIX) && qa.a.trim().length >= MIN_ANSWER_CHARS,
|
||||
);
|
||||
if (userAnswered.length === 0) return null;
|
||||
|
||||
const date = new Date();
|
||||
const ymd = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
const slug = _slugify(input.userPrompt, 30);
|
||||
const dir = path.join(brainPath, ALIGNMENT_KNOWLEDGE_DIR);
|
||||
const filePath = path.join(dir, `${ymd} ${slug}.md`);
|
||||
if (fs.existsSync(filePath)) return null; // 같은 turn 재진입/재실행 — 중복 저장 방지
|
||||
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const body: string[] = [];
|
||||
body.push(`# ${input.userPrompt.replace(/\s+/g, ' ').trim().slice(0, 50)}`);
|
||||
body.push('');
|
||||
body.push(`> 1인 기업 모드 Intent Alignment 에서 사용자가 직접 제공한 정보. (${ymd})`);
|
||||
body.push('');
|
||||
body.push('## 원본 요청');
|
||||
body.push(input.userPrompt.trim());
|
||||
body.push('');
|
||||
body.push('## 확인된 정보');
|
||||
for (const qa of userAnswered) {
|
||||
body.push(`### Q. ${qa.q.replace(/\n/g, ' ').trim()}`);
|
||||
body.push(qa.a.trim());
|
||||
body.push('');
|
||||
}
|
||||
fs.writeFileSync(filePath, body.join('\n').trimEnd() + '\n', 'utf8');
|
||||
logInfo('alignmentResearch: saved user-provided knowledge to brain.', { filePath });
|
||||
return filePath;
|
||||
} catch (e: any) {
|
||||
logError('alignmentResearch: knowledge save failed (non-fatal).', {
|
||||
error: e?.message ?? String(e),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 파일명 안전 slug — 한글 보존, 경로 위험 문자만 제거. */
|
||||
export function _slugify(text: string, maxChars: number): string {
|
||||
const cleaned = text
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|#%{}$!'@`+=[\]]/g, '')
|
||||
.slice(0, maxChars)
|
||||
.trim();
|
||||
return cleaned || 'alignment';
|
||||
}
|
||||
@@ -215,6 +215,30 @@ export interface DispatcherDeps {
|
||||
* 'off'였던 경우.
|
||||
*/
|
||||
requirementContract?: RequirementContract;
|
||||
/**
|
||||
* 현재 워크스페이스의 아키텍처 컨텍스트 (architecture.md 요약, 호출자가
|
||||
* 절단해 전달). 일반 챗은 이 컨텍스트를 자동 주입받지만 기업 모드는 빠져
|
||||
* 있던 공백 수정 — specialist 가 "이 프로젝트가 뭐냐"를 추측하지 않게.
|
||||
* contract 와 같은 4개 지점(planner/specialist/verifier/inspector)에 prepend.
|
||||
*/
|
||||
architectureContextBlock?: string;
|
||||
/**
|
||||
* 사용자 프롬프트에 포함된 URL 의 pre-fetch 결과 ([URL CONTENT] 블록).
|
||||
* 기업 모드에는 continuation loop 가 없어 LLM 주도 fetch 결과를 재분석할
|
||||
* 기회가 없으므로, dispatch 전에 호출자가 가져와 모든 에이전트에게 배포.
|
||||
*/
|
||||
webContextBlock?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* deps 의 자동 수집 컨텍스트(architecture + web)를 한 prefix 문자열로 합성.
|
||||
* 둘 다 없으면 빈 문자열 — 기존 동작과 100% 동일.
|
||||
*/
|
||||
function buildExtraContextPrefix(deps: DispatcherDeps): string {
|
||||
const blocks = [deps.architectureContextBlock, deps.webContextBlock]
|
||||
.map((b) => (b || '').trim())
|
||||
.filter(Boolean);
|
||||
return blocks.length > 0 ? blocks.join('\n\n') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -353,11 +377,13 @@ export async function runCompanyTurn(
|
||||
};
|
||||
} else {
|
||||
const ceoModel = modelForAgent(state, 'ceo', deps.defaultModel);
|
||||
const plannerExtraPrefix = buildExtraContextPrefix(deps);
|
||||
const plannerContract = deps.requirementContract
|
||||
? formatContractForPrompt(deps.requirementContract)
|
||||
: undefined;
|
||||
const plannerResult = await runCeoPlanner(deps.ai, userPrompt, state, {
|
||||
model: ceoModel,
|
||||
contractBlock: deps.requirementContract
|
||||
? formatContractForPrompt(deps.requirementContract)
|
||||
: undefined,
|
||||
contractBlock: [plannerExtraPrefix, plannerContract].filter(Boolean).join('\n\n') || undefined,
|
||||
signal: deps.signal,
|
||||
});
|
||||
plan = plannerResult.plan;
|
||||
@@ -666,11 +692,13 @@ async function _dispatchOne(
|
||||
peerOutputs,
|
||||
brainContext, // injected as `[SECOND BRAIN CONTEXT]` block
|
||||
knowledgeMixPolicy: policyBlock, // injected as `[KNOWLEDGE MIX POLICY]` block
|
||||
// alignment 단계에서 도출된 contract가 deps에 있으면 모든 specialist의
|
||||
// system 프롬프트에 같은 ground truth로 prepend된다. 추측 방지.
|
||||
contractBlock: deps.requirementContract
|
||||
? formatContractForPrompt(deps.requirementContract)
|
||||
: undefined,
|
||||
// alignment 단계에서 도출된 contract + 자동 수집 컨텍스트(architecture/web)가
|
||||
// deps에 있으면 모든 specialist의 system 프롬프트에 같은 ground truth로
|
||||
// prepend된다. 추측 방지.
|
||||
contractBlock: [
|
||||
buildExtraContextPrefix(deps),
|
||||
deps.requirementContract ? formatContractForPrompt(deps.requirementContract) : '',
|
||||
].filter(Boolean).join('\n\n') || undefined,
|
||||
});
|
||||
// 우선순위: stage > agent > global default.
|
||||
const model = (stageModelOverride && stageModelOverride.trim())
|
||||
@@ -696,9 +724,10 @@ async function _dispatchOne(
|
||||
// 옛 dynamic import 8회 → 정적 import 로 promote (파일 상단). 모듈 자체 cyclic 없음.
|
||||
const cfgRuntime = getDispatcherConfig();
|
||||
if (cfgRuntime.selfReflectorExternalEnabled && rawResponse) {
|
||||
const contractBlock = deps.requirementContract
|
||||
? formatContractForPrompt(deps.requirementContract)
|
||||
: undefined;
|
||||
const contractBlock = [
|
||||
buildExtraContextPrefix(deps),
|
||||
deps.requirementContract ? formatContractForPrompt(deps.requirementContract) : '',
|
||||
].filter(Boolean).join('\n\n') || undefined;
|
||||
const verdict = await verifyResponse(deps.ai, {
|
||||
task,
|
||||
response: rawResponse,
|
||||
@@ -1048,11 +1077,13 @@ async function _runReviewCycle(args: {
|
||||
return { verdict: 'aborted', rounds: round - 1 };
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
// contract가 있으면 검수자/CEO 모두에게 같은 ground truth를 prepend —
|
||||
// 검수 기준이 contract와 일치하는지를 정확히 평가할 수 있다.
|
||||
const contractPrefix = deps.requirementContract
|
||||
? formatContractForPrompt(deps.requirementContract) + '\n\n'
|
||||
: '';
|
||||
// contract + 자동 수집 컨텍스트가 있으면 검수자/CEO 모두에게 같은 ground
|
||||
// truth를 prepend — 검수 기준이 contract와 일치하는지를 정확히 평가할 수 있다.
|
||||
const contractPrefixParts = [
|
||||
buildExtraContextPrefix(deps),
|
||||
deps.requirementContract ? formatContractForPrompt(deps.requirementContract) : '',
|
||||
].filter(Boolean).join('\n\n');
|
||||
const contractPrefix = contractPrefixParts ? contractPrefixParts + '\n\n' : '';
|
||||
|
||||
// ── 1) 검수자 LLM 콜 ──
|
||||
const inspectorSystem = contractPrefix + '당신은 산출물 *감리*입니다. 작업자의 결과물을 객관적으로 검토하고 한국어 마크다운으로 응답하세요.\n\n반드시 첫 줄을 다음 둘 중 하나로 시작:\n - ✅ 통과 — 산출물이 task 요구 + 위 contract의 criteria를 모두 충족하면.\n - ❌ 보완 필요: <구체 항목 한 줄> — contract 기준 누락·오류·약점이 있으면.\n\n그 다음 줄들에 *구체적인* 피드백 또는 칭찬 1~3줄. 모호한 일반론 금지.';
|
||||
|
||||
@@ -95,6 +95,13 @@ export type { ChatIntent, IntentContext, IntentResult, PipelineHint } from './in
|
||||
|
||||
export { analyzeIntent, formatContractForPrompt } from './intentAlignment';
|
||||
export type { IntentAnalysisInput, IntentAnalysisResult } from './intentAlignment';
|
||||
export {
|
||||
gatherEvidenceForQuestions,
|
||||
selfAnswerQuestions,
|
||||
saveAlignmentKnowledge,
|
||||
SELF_RESEARCH_PREFIX,
|
||||
} from './alignmentResearch';
|
||||
export type { QuestionEvidence, SelfAnswer } from './alignmentResearch';
|
||||
export type { RequirementContract } from './types';
|
||||
|
||||
export {
|
||||
|
||||
@@ -71,6 +71,14 @@ export interface IntentAnalysisInput {
|
||||
* 없으면 undefined (첫 진입 / 모드 토글 없는 케이스).
|
||||
*/
|
||||
priorChatSummary?: string;
|
||||
/**
|
||||
* 현재 열린 워크스페이스의 아키텍처 컨텍스트 (architecture.md 요약).
|
||||
* 사용자가 *지금 열어둔 프로젝트* 에 대해 요청하는 경우가 많은데, 분석기가
|
||||
* 이걸 못 보면 "그 프로젝트가 뭐냐"는 — 워크스페이스가 이미 답하고 있는 —
|
||||
* 질문을 던진다. 첫 라운드에만 첨부 (후속 라운드는 contract 에 흡수됨).
|
||||
* 작은 모델 보호를 위해 호출자가 3,000자 내외로 절단해 전달.
|
||||
*/
|
||||
projectContext?: string;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `당신은 "1인 기업 모드"의 *요청 분석가*입니다. 사용자의 자연어 요청을 받아 그것을 실행 가능한 작업 조건 5가지(C-G-C-F-Q)로 정리합니다.
|
||||
@@ -85,6 +93,8 @@ const SYSTEM_PROMPT = `당신은 "1인 기업 모드"의 *요청 분석가*입
|
||||
|
||||
⚠️ **[모드 전환 시 context 우선 추출]**: 입력에 \`[모드 전환 직전 일반 채팅 요약]\` 블록이 있으면, 그것을 **사용자의 한 줄과 같은 권위로** 취급하세요. 거기서 context/goal/criteria/format 을 *직접 추출* 한 뒤, 그래도 빠진 항목만 openQuestions 에 넣으세요. 사용자가 이미 일반 채팅에서 충분히 설명한 내용을 다시 물어보면 안 됩니다 — 일반 채팅에서 *명시적으로 언급* 된 항목은 추측이 아니라 **명시된 사실** 입니다.
|
||||
|
||||
⚠️ **[프로젝트 컨텍스트 우선 활용]**: 입력에 \`[프로젝트 컨텍스트]\` 블록이 있으면 그것은 *사용자가 지금 열어둔 워크스페이스* 의 실제 구조 요약입니다. 거기서 직접 확인되는 사실(이 프로젝트가 무엇인지, 기술 스택, 폴더 구조, 주요 기능)은 이미 알려진 정보로 취급해 context 슬롯에 반영하고, 그 블록에서 답이 확인되는 질문은 openQuestions 에 만들지 마세요. 예: 사용자가 그 프로젝트 이름·경로를 언급하며 요청하면 "그 프로젝트가 무엇인가요?" 같은 질문은 금지입니다.
|
||||
|
||||
confidence는 다음 기준으로 자체 판정:
|
||||
- "high" : C·G·C·F 4개 모두 prompt에서 직접 추론 가능. openQuestions = [] 가능.
|
||||
- "medium" : 대체로 명확하지만 1~2개 항목에서 합리적 가정 필요. 추가 질문 1~2개.
|
||||
@@ -119,6 +129,16 @@ function _buildUserMessage(input: IntentAnalysisInput): string {
|
||||
lines.push(input.priorChatSummary);
|
||||
lines.push('---');
|
||||
}
|
||||
// 현재 워크스페이스 아키텍처 요약 — "이 프로젝트가 뭐냐"류의 재질문 차단.
|
||||
if (input.projectContext && input.projectContext.trim()) {
|
||||
lines.push('');
|
||||
lines.push('[프로젝트 컨텍스트]');
|
||||
lines.push('아래는 사용자가 현재 열어둔 워크스페이스의 아키텍처 요약입니다. 여기서 직접');
|
||||
lines.push('확인되는 사실은 이미 알려진 정보로 취급해 context 슬롯에 반영하고, 다시 묻지 마세요.');
|
||||
lines.push('---');
|
||||
lines.push(input.projectContext);
|
||||
lines.push('---');
|
||||
}
|
||||
if (input.activePipelineName) {
|
||||
lines.push('');
|
||||
lines.push(`(활성 파이프라인) "${input.activePipelineName}"`);
|
||||
|
||||
@@ -140,6 +140,7 @@ export function buildSpecialistPrompt(inputs: SpecialistPromptInputs): string {
|
||||
parts.push(' • `<add_task title="..." owner="@me" due="2026-05-24T18:00" notes="..."/>` — 작업 추적기에 task 추가');
|
||||
parts.push(' • `<update_task id="t_001" status="in_progress|blocked|done" notes="..."/>` — 진척·blocker 갱신');
|
||||
parts.push(' • `<complete_task id="t_001"/>` — task 완료 처리 (done 섹션으로 이동)');
|
||||
parts.push(' • `<fetch_url url="https://..."/>` — 웹 페이지 본문 가져오기 (회당 최대 2개). "사이트 방문 불가"라고 답하지 말고 이 태그를 사용할 것. 결과는 [URL CONTENT] 블록으로 주입됨.');
|
||||
parts.push('');
|
||||
parts.push('📋 **Task 사용 시점**:');
|
||||
parts.push('- 회의록·요청 처리 중 *명확한 할일* 마다 add_task 1개씩 emit. 추측·확장 금지.');
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Web Fetch — Bridge 무관 직접 URL fetch (vscode 의존 없음 — 테스트 용이).
|
||||
*
|
||||
* 배경: 일반 챗의 URL 주입(urlContext.ts)은 Datacollect Bridge(:3002)에 100%
|
||||
* 의존했다. Bridge가 꺼져 있으면 — 확장은 Bridge를 자동 시작하지 않는다 —
|
||||
* "접근 실패" 블록이 떠서 모델이 "사이트 방문 불가"라고 답하는 공백이 있었다.
|
||||
* 이 모듈은 그 폴백: extension host의 global fetch(Node 18+, bridgeClient가
|
||||
* 이미 사용 중)로 직접 페이지를 가져와 본문 텍스트를 추출한다.
|
||||
*
|
||||
* 설계 원칙: 절대 throw 하지 않는다 — 모든 실패는 {ok:false, error} 로 반환.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 스킴 없는 도메인 인식용 보수적 TLD 목록 — 사용자는 "koritips.com 가서 분석해줘"
|
||||
* 처럼 https:// 를 생략하기 마련이라, 흔한 TLD 만 허용해 파일명(utils.ts,
|
||||
* package.json 등) 오인을 차단한다.
|
||||
*/
|
||||
const BARE_DOMAIN_TLDS = 'com|net|org|io|co|kr|jp|dev|app|ai|me|info|blog|shop|site|xyz|cc|tv|us|uk|edu|gov';
|
||||
|
||||
/**
|
||||
* http(s) URL 추출 — dedupe + trailing 구두점 제거. 슬래시 명령은 자체 처리하므로
|
||||
* 제외. https:// 가 없는 bare 도메인(koritips.com, www.foo.net 등)도 인식해
|
||||
* https:// 를 붙여 반환한다.
|
||||
*/
|
||||
export function extractUrls(text: string, max = 2): string[] {
|
||||
const t = (text || '').trim();
|
||||
// 슬래시 *명령* (/wikify 등)만 제외 — 절대경로("/Volumes/... koritips.com 봐줘")로
|
||||
// 시작하는 프롬프트는 URL 추출 대상이다 (startsWith('/') 는 경로를 오인했던 버그).
|
||||
if (!t || /^\/[a-zA-Z][\w-]*(\s|$)/.test(t)) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
|
||||
// ① 스킴 있는 URL 우선.
|
||||
const re = /https?:\/\/[^\s<>"'`)\]]+/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(t)) !== null && out.length < max) {
|
||||
// 문장 끝 구두점이 URL 에 붙는 흔한 오염 제거.
|
||||
const url = m[0].replace(/[.,;:!?…」』)]+$/, '');
|
||||
if (!seen.has(url)) {
|
||||
seen.add(url);
|
||||
out.push(url);
|
||||
}
|
||||
}
|
||||
if (out.length >= max) return out;
|
||||
|
||||
// ② Bare 도메인 — 이미 찾은 URL 영역은 마스킹해 이중 매칭 방지.
|
||||
// 직전 문자가 @(이메일)·/(경로 일부)·.(서브파트) 면 제외.
|
||||
let masked = t;
|
||||
for (const u of out) masked = masked.split(u).join(' '.repeat(Math.min(u.length, 8)));
|
||||
const bareRe = new RegExp(
|
||||
`(^|[\\s("'\`「『<>])((?:[a-z0-9-]+\\.)+(?:${BARE_DOMAIN_TLDS})(?:\\.[a-z]{2})?(?::\\d{2,5})?(?:/[^\\s<>"'\`)\\]]*)?)`,
|
||||
'gi',
|
||||
);
|
||||
while ((m = bareRe.exec(masked)) !== null && out.length < max) {
|
||||
const candidate = m[2].replace(/[.,;:!?…」』)]+$/, '');
|
||||
if (!candidate.includes('.')) continue;
|
||||
const url = `https://${candidate}`;
|
||||
if (!seen.has(url) && !seen.has(`http://${candidate}`)) {
|
||||
seen.add(url);
|
||||
out.push(url);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface WebFetchResult {
|
||||
ok: boolean;
|
||||
url: string;
|
||||
title: string;
|
||||
text: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_MAX_CHARS = 20_000;
|
||||
|
||||
/**
|
||||
* URL 본문을 직접 fetch 해 텍스트로 변환. HTML 이면 태그를 걷어내고,
|
||||
* 그 외(text/json 등)는 원문 그대로 cap. http/https 만 허용.
|
||||
*/
|
||||
export async function fetchUrlDirect(
|
||||
url: string,
|
||||
opts: { timeoutMs?: number; maxChars?: number } = {},
|
||||
): Promise<WebFetchResult> {
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const maxChars = opts.maxChars ?? DEFAULT_MAX_CHARS;
|
||||
const fail = (error: string): WebFetchResult => ({ ok: false, url, title: '', text: '', error });
|
||||
|
||||
if (!/^https?:\/\//i.test(url)) return fail('http/https URL만 지원합니다.');
|
||||
if (typeof fetch !== 'function') return fail('이 환경은 직접 fetch를 지원하지 않습니다.');
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
redirect: 'follow',
|
||||
headers: {
|
||||
// 일부 사이트가 UA 없는 요청을 차단 — 평범한 브라우저 UA 로.
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/json;q=0.9,text/plain;q=0.8,*/*;q=0.5',
|
||||
},
|
||||
});
|
||||
if (!res.ok) return fail(`HTTP ${res.status} ${res.statusText || ''}`.trim());
|
||||
const contentType = (res.headers.get('content-type') || '').toLowerCase();
|
||||
const raw = await res.text();
|
||||
if (!raw.trim()) return fail('응답 본문이 비어 있습니다.');
|
||||
|
||||
if (contentType.includes('html') || /^\s*<(!doctype|html)/i.test(raw)) {
|
||||
const title = _extractTitle(raw);
|
||||
const text = htmlToText(raw).slice(0, maxChars);
|
||||
if (text.trim().length < 50) {
|
||||
return fail('본문 추출 실패 (콘텐츠 없음 또는 JS 전용 렌더링).');
|
||||
}
|
||||
return { ok: true, url, title, text };
|
||||
}
|
||||
return { ok: true, url, title: '', text: raw.slice(0, maxChars) };
|
||||
} catch (e: any) {
|
||||
const msg = e?.name === 'AbortError'
|
||||
? `타임아웃 (${Math.round(timeoutMs / 1000)}s)`
|
||||
: String(e?.message ?? e).slice(0, 120);
|
||||
return fail(msg);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function _extractTitle(html: string): string {
|
||||
const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||
return m ? decodeEntities(m[1]).replace(/\s+/g, ' ').trim().slice(0, 200) : '';
|
||||
}
|
||||
|
||||
/** HTML → 평문. script/style/noscript 제거 → 블록 태그를 줄바꿈으로 → 태그 strip → 엔티티 → 공백 정리. */
|
||||
export function htmlToText(html: string): string {
|
||||
let s = html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ')
|
||||
.replace(/<!--[\s\S]*?-->/g, ' ');
|
||||
// 블록 요소 경계를 줄바꿈으로 보존 — 문단 구조가 텍스트에도 남게.
|
||||
s = s.replace(/<\/(p|div|section|article|li|tr|h[1-6]|blockquote|pre)>/gi, '\n')
|
||||
.replace(/<(br|hr)\s*\/?>/gi, '\n');
|
||||
s = s.replace(/<[^>]+>/g, ' ');
|
||||
s = decodeEntities(s);
|
||||
// 공백 정리: 줄 내 다중 공백 → 1개, 3개 이상 연속 줄바꿈 → 2개.
|
||||
return s
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/\s+/g, ' ').trim())
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** 최소 엔티티 디코드 — 본문 가독에 필요한 흔한 것만. */
|
||||
export function decodeEntities(s: string): string {
|
||||
return s
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/�?39;|'/gi, "'")
|
||||
.replace(/&#(\d+);/g, (_, code) => {
|
||||
const n = Number(code);
|
||||
return n > 0 && n < 0x110000 ? String.fromCodePoint(n) : '';
|
||||
});
|
||||
}
|
||||
@@ -387,8 +387,6 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
|
||||
excerpt: (c.content || '').slice(0, 200),
|
||||
}));
|
||||
|
||||
// Lessons 블록은 일반 RAG context 보다 앞 — context-overflow truncation 에서 먼저
|
||||
// 살아남게.
|
||||
const lessonBlock = buildLessonChecklistBlock(lessonChunks.map((c) => ({ title: c.title, content: c.content })));
|
||||
const memoryBlock = deps.retrievalOrchestrator.buildContextString(result);
|
||||
// [확신도 전역화] 검색 근거 강도를 평가해 답변 정책을 함께 주입 — /meet 의
|
||||
@@ -409,7 +407,14 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
|
||||
// [Correction Loop ③-c] 약점 프로필 → 자기검토 블록. 최근 정정 통계가 다음 턴의
|
||||
// 행동을 직접 바꾼다 (태그 2회 이상만 — 1회성 실수로 프롬프트를 어지럽히지 않게).
|
||||
const selfReviewBlock = buildSelfReviewBlock(loadWeaknessProfile(deps.activeBrain.localBrainPath));
|
||||
return [selfReviewBlock, groundingBlock, lessonBlock, memoryBlock].filter(Boolean).join('\n\n');
|
||||
|
||||
// 행동 제약 블록(자기검토·확신도 정책·레슨 체크리스트)은 dynamicBlocks 채널로 —
|
||||
// [CONTEXT] *밖* 보호 구역에 주입되어 context-overflow truncation 에서도 살아남는다.
|
||||
// 옛 구현은 이 블록들을 RAG 본문과 같은 문자열에 합쳐 [CONTEXT] 안에 넣었는데,
|
||||
// "실패 방지 제약"이 토큰 압박에서 배경 지식과 함께 잘려나가는 모순이 있었다.
|
||||
const constraintBlock = [selfReviewBlock, groundingBlock, lessonBlock].filter(Boolean).join('\n\n');
|
||||
if (constraintBlock) blocks.set('behavior-constraints', constraintBlock);
|
||||
return memoryBlock;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -83,3 +83,25 @@ export function isCasualConversationPrompt(prompt: string): boolean {
|
||||
if (/^(?:ha){2,}h?$|^(?:he){2,}h?$/.test(normalized)) return true; // haha, hahaha, hehe
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 슬래시 명령(/wikify, /benchmark 등)인지 — *명령만* 골라낸다. 절대경로
|
||||
* ("/Volumes/Data/... 분석해줘")는 명령이 아니다. 옛 `startsWith('/')` 가드가
|
||||
* 경로로 시작하는 프롬프트를 전부 명령으로 오인해 분석 지시·URL 주입이 통째로
|
||||
* 죽는 버그가 있었다 (이 사용자 패턴의 대표형이 "경로 + 분석해줘").
|
||||
* 패턴: 슬래시 + 명령어 + (공백|끝). 경로는 두 번째 '/'가 바로 이어져 미스매치.
|
||||
*/
|
||||
export function isSlashCommand(text: string): boolean {
|
||||
return /^\/[a-zA-Z][\w-]*(\s|$)/.test((text || '').trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 분석/검토/의견형 요청 감지 — 이런 요청에서 모델이 코드를 읽지 않고 "~로
|
||||
* 보입니다" 추측으로 답하는 실패 모드를 막기 위한 grounding 지시 주입 트리거.
|
||||
* 보수적 휴리스틱: 분석 동사 포함 + 슬래시 명령 아님 + 최소 길이.
|
||||
*/
|
||||
export function isAnalysisRequest(prompt: string): boolean {
|
||||
const p = (prompt || '').trim();
|
||||
if (p.length < 8 || isSlashCommand(p)) return false;
|
||||
return /(분석|검토|리뷰|평가|점검|진단|의견|개선점|개선 ?방향|review|analyze|audit|assess)/i.test(p);
|
||||
}
|
||||
|
||||
@@ -15,21 +15,38 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { INVENTORY_FILE } from '../../extension/featureInventory';
|
||||
|
||||
const IMPROVE_RE = /(개선|고도화|발전|보완|제안|평가|분석|방향|방법|아이디어|로드맵|업그레이드|날카롭|강화)/i;
|
||||
// "검토|리뷰|점검|진단|어때" 추가 — "아래 내용 검토해줘" 같은 실사용 재검토
|
||||
// 프롬프트가 매치 안 되던 갭 (회귀 테스트로 발견).
|
||||
// "학습|배우|기억|작동|어떻게" 추가 — "너는 어떻게 학습해?" 류 자기 작동 방식
|
||||
// 질문이 인벤토리(학습 메커니즘 정본 포함) 없이 답변되어 허구 설명이 나오던 갭.
|
||||
const IMPROVE_RE = /(개선|고도화|발전|보완|제안|평가|분석|검토|리뷰|점검|진단|어때|방향|방법|아이디어|로드맵|업그레이드|날카롭|강화|학습|배우|기억|작동|어떻게)/i;
|
||||
const SELF_RE = /(기능|역량|능력|아키텍처|구조|시스템|self.?evolv|자기\s*진화|자기\s*개선|아스트라|astra|connectai|프로젝트|업무\s*능력|너(가|의|는)?|네가)/i;
|
||||
const CAPABILITY_RE = /(무슨|어떤|할\s*수\s*있는)\s*(기능|일|것)|기능\s*(목록|리스트)|capabilit/i;
|
||||
|
||||
/**
|
||||
* 자기 평가·개선·기능 질의인지. 오탐 비용이 낮으므로(인벤토리 ~3KB 추가 주입뿐)
|
||||
* 누락(또 구식 제안)보다 과잉 감지를 택한다. 길이 상한 1500 — 사용자의 개선 요청은
|
||||
* 배경 설명이 붙어 길어지는 경우가 많다 (600 으로는 실사용 질문을 놓침).
|
||||
* 누락(또 구식 제안)보다 과잉 감지를 택한다. 길이 상한 없음 — 사용자가 이전 답변
|
||||
* 전문(5천자+)을 붙여넣고 "이거 어때?"라고 묻는 재검토 패턴이 흔한데, 1500/4000
|
||||
* 상한이 그런 실사용 프롬프트를 탈락시켜 인벤토리 미주입 → 기존 기능 재제안
|
||||
* 버그가 반복 재발했다. 정규식 검사는 큰 문자열에도 저비용.
|
||||
*/
|
||||
export function isSelfAssessRequest(prompt: string): boolean {
|
||||
const p = (prompt || '').trim();
|
||||
if (!p || p.length > 1500) return false;
|
||||
if (!p) return false;
|
||||
return CAPABILITY_RE.test(p) || (IMPROVE_RE.test(p) && SELF_RE.test(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* 분석/검토 대상이 ASTRA/ConnectAI *자신*인지 — isSelfAssessRequest 보다 넓은
|
||||
* 보조 판정 (개선 키워드 없이 "검토해줘/어때?" 만으로 자기 분석을 요청하는 경우).
|
||||
* 분석 경로(isAnalysisRequest)와 결합해 인벤토리 주입 트리거를 보강한다.
|
||||
*/
|
||||
export function isAboutSelf(prompt: string): boolean {
|
||||
const p = (prompt || '').trim();
|
||||
if (!p) return false;
|
||||
return /(아스트라|astra|connectai|자기\s*진화|자기\s*개선|self.?evolv|기능\s*인벤토리)/i.test(p);
|
||||
}
|
||||
|
||||
const MAX_INVENTORY_CHARS = 7000;
|
||||
|
||||
/** 인벤토리 전문 + 대조 지시 블록. 파일 없으면 정직한 안내 (지어내기 방지). */
|
||||
@@ -54,6 +71,11 @@ export function buildSelfAssessContext(brainPath: string): string {
|
||||
'- 제안은 "현재 X가 있고, 빠진 증분은 Y" 형태로.',
|
||||
'- 아래에 없는 기능을 있다고 주장하지도 마라.',
|
||||
'- 직전 대화에서 본인이 했던 제안 목록을 그대로 반복하지 마라 — 이 인벤토리가 그 제안들보다 우선하는 최신 사실이다.',
|
||||
'⚠️ [의무 형식 — 위반 시 오답 처리] 개선 제안을 하나라도 포함하는 답변은, **각 제안의 첫 줄에** 아래 대조 태그를 반드시 붙여라:',
|
||||
' `[인벤토리 대조: 신규]` — 아래 문서 어디에도 없는 기능',
|
||||
' `[인벤토리 대조: 기구현 — <항목명>]` — 이미 있음 (이 경우 "도입" 대신 그 기능의 *증분 확장*만 제안 가능)',
|
||||
' `[인벤토리 대조: 부분 구현 — <항목명>]` — 일부 겹침',
|
||||
' 태그를 붙이려면 아래 문서를 실제로 읽어야 한다. 태그 없는 제안은 검증 안 된 추측으로 간주된다.',
|
||||
'- 답변 끝에 "출처: ASTRA 기능 인벤토리 v<버전>" 을 표기하라 (이 블록을 실제로 읽었다는 증거).',
|
||||
'',
|
||||
body,
|
||||
|
||||
@@ -4,16 +4,30 @@
|
||||
* 문제: /wikify 는 URL 에 접근하지만(브리지 /api/web-extract), 일반 채팅에 URL 을
|
||||
* 주면 추출 경로가 없어 모델이 "접근할 수 없습니다"라고 답하거나 내용을 추측했다.
|
||||
*
|
||||
* 수정: 강제 주입 패턴의 4번째 적용 (일정→캘린더, 자기평가→인벤토리, 정정→캡처와
|
||||
* 동일 설계). 이미 검증된 브리지 추출 인프라를 재사용 — 새 크롤러 없음.
|
||||
* 실패 시(브리지 다운/추출 실패) 정직한 안내 블록 — 모델이 내용을 지어내지 않게.
|
||||
* v2: Bridge 100% 의존이 두 번째 공백이었다 — Bridge 가 꺼져 있으면(확장은 자동
|
||||
* 시작하지 않음) 접근 실패 블록이 떠서 결국 "방문 불가"가 됐다. 이제 ① Bridge
|
||||
* 추출(품질 우선) → ② 직접 fetch 폴백(webFetch.fetchUrlDirect) → ③ 정직 실패
|
||||
* 블록의 3단계. 성공 결과는 5분 TTL 캐시 — chat/alignment/기업모드가 같은 URL 을
|
||||
* 연달아 요청해도 네트워크 1회.
|
||||
*/
|
||||
import { logInfo } from '../../utils';
|
||||
import { bridgeFetch, BRIDGE_API } from '../../features/datacollect/bridgeClient';
|
||||
import { fetchUrlDirect } from '../../features/web/webFetch';
|
||||
|
||||
const URL_RE = /https?:\/\/[^\s<>"'`)\]]+/i;
|
||||
const MAX_BODY_CHARS = 8000;
|
||||
const EXTRACT_TIMEOUT_MS = 45_000;
|
||||
// Bridge 추출 대기 — 직접 fetch 폴백이 있으므로 45s → 15s 로 단축 (총 대기 최대 ~30s).
|
||||
const EXTRACT_TIMEOUT_MS = 15_000;
|
||||
|
||||
// 성공 블록만 캐시 (실패는 캐시하지 않음 — Bridge 재기동/일시 오류 후 재시도 가능해야).
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const CACHE_MAX = 10;
|
||||
const _cache = new Map<string, { block: string; expiresAt: number }>();
|
||||
|
||||
/** 테스트/세션 전환용 캐시 초기화. */
|
||||
export function clearUrlContextCache(): void {
|
||||
_cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 프롬프트에서 추출 대상 URL 을 찾는다. 슬래시 명령(/wikify 등)은 자체 처리하므로
|
||||
@@ -21,7 +35,8 @@ const EXTRACT_TIMEOUT_MS = 45_000;
|
||||
*/
|
||||
export function extractUrlFromPrompt(prompt: string): string | null {
|
||||
const p = (prompt || '').trim();
|
||||
if (!p || p.startsWith('/')) return null;
|
||||
// 슬래시 *명령* 만 제외 — 절대경로 시작 프롬프트는 추출 대상 (경로 오인 버그 수정).
|
||||
if (!p || /^\/[a-zA-Z][\w-]*(\s|$)/.test(p)) return null;
|
||||
const m = URL_RE.exec(p);
|
||||
if (!m) return null;
|
||||
// 문장 끝 구두점이 URL 에 붙는 흔한 오염 제거.
|
||||
@@ -30,6 +45,12 @@ export function extractUrlFromPrompt(prompt: string): string | null {
|
||||
|
||||
/** URL 본문을 추출해 컨텍스트 블록 생성. 실패해도 throw 하지 않는다 (정직 블록 반환). */
|
||||
export async function buildUrlContext(url: string): Promise<string> {
|
||||
const now = Date.now();
|
||||
const cached = _cache.get(url);
|
||||
if (cached && cached.expiresAt > now) return cached.block;
|
||||
|
||||
// ① Bridge 추출 (readability 급 품질 — 우선).
|
||||
let bridgeError = '';
|
||||
try {
|
||||
const data = await bridgeFetch<{ success: boolean; title?: string; description?: string; text?: string; textLength?: number; truncated?: boolean }>(
|
||||
BRIDGE_API.web.extract,
|
||||
@@ -37,28 +58,51 @@ export async function buildUrlContext(url: string): Promise<string> {
|
||||
{ timeoutMs: EXTRACT_TIMEOUT_MS },
|
||||
);
|
||||
const body = String(data?.text || '').slice(0, MAX_BODY_CHARS);
|
||||
if (!body.trim() || body.trim().length < 50) {
|
||||
return [
|
||||
`[URL CONTENT — ${url}]`,
|
||||
'상태: 본문 추출 실패 (콘텐츠 없음 또는 JS 전용 렌더링).',
|
||||
'→ 사용자에게 이 URL 의 본문을 가져오지 못했다고 정직하게 알리고, 내용을 추측해 답하지 마라.',
|
||||
].join('\n');
|
||||
if (body.trim() && body.trim().length >= 50) {
|
||||
logInfo('URL 컨텍스트 주입 (bridge).', { url, chars: body.length, truncated: !!data?.truncated });
|
||||
const block = _successBlock(url, data?.title || '', data?.description || '', body, body.length >= MAX_BODY_CHARS || !!data?.truncated);
|
||||
_cachePut(url, block);
|
||||
return block;
|
||||
}
|
||||
logInfo('URL 컨텍스트 주입.', { url, chars: body.length, truncated: !!data?.truncated });
|
||||
return [
|
||||
`[URL CONTENT — 실데이터 · ${url}]`,
|
||||
`제목: ${data?.title || '(없음)'}`,
|
||||
data?.description ? `설명: ${data.description}` : '',
|
||||
'아래 본문만 근거로 답하라. 본문에 없는 내용은 "본문에서 확인되지 않음"이라고 답하고 지어내지 마라.' +
|
||||
(body.length >= MAX_BODY_CHARS || data?.truncated ? ' (본문 일부 잘림 — 전체가 필요하면 /wikify 사용을 안내하라.)' : ''),
|
||||
'',
|
||||
body,
|
||||
].filter(Boolean).join('\n');
|
||||
bridgeError = '본문 추출 실패 (콘텐츠 없음 또는 JS 전용 렌더링)';
|
||||
} catch (e: any) {
|
||||
return [
|
||||
`[URL CONTENT — ${url}]`,
|
||||
`상태: 접근 실패 — ${String(e?.message ?? e).slice(0, 120)}`,
|
||||
'→ Datacollect 브리지(:3002)가 실행 중인지 확인하라고 사용자에게 안내하고, URL 내용을 추측해 답하지 마라.',
|
||||
].join('\n');
|
||||
bridgeError = String(e?.message ?? e).slice(0, 120);
|
||||
}
|
||||
|
||||
// ② 직접 fetch 폴백 — Bridge 가 꺼져 있거나 추출 실패해도 웹 접근은 살아 있어야.
|
||||
const direct = await fetchUrlDirect(url, { maxChars: MAX_BODY_CHARS });
|
||||
if (direct.ok) {
|
||||
logInfo('URL 컨텍스트 주입 (direct fetch 폴백).', { url, chars: direct.text.length, bridgeError });
|
||||
const block = _successBlock(url, direct.title, '', direct.text, direct.text.length >= MAX_BODY_CHARS);
|
||||
_cachePut(url, block);
|
||||
return block;
|
||||
}
|
||||
|
||||
// ③ 둘 다 실패 — 모델이 내용을 지어내지 않게 정직 블록.
|
||||
return [
|
||||
`[URL CONTENT — ${url}]`,
|
||||
`상태: 접근 실패 — bridge: ${bridgeError || '(미시도)'} / direct: ${direct.error || '(불명)'}`,
|
||||
'→ 사용자에게 이 URL 의 본문을 가져오지 못했다고 정직하게 알리고, 내용을 추측해 답하지 마라.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function _successBlock(url: string, title: string, description: string, body: string, truncated: boolean): string {
|
||||
return [
|
||||
`[URL CONTENT — 실데이터 · ${url}]`,
|
||||
`제목: ${title || '(없음)'}`,
|
||||
description ? `설명: ${description}` : '',
|
||||
'아래 본문만 근거로 답하라. 본문에 없는 내용은 "본문에서 확인되지 않음"이라고 답하고 지어내지 마라.' +
|
||||
(truncated ? ' (본문 일부 잘림 — 전체가 필요하면 /wikify 사용을 안내하라.)' : ''),
|
||||
'',
|
||||
body,
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function _cachePut(url: string, block: string): void {
|
||||
if (_cache.size >= CACHE_MAX) {
|
||||
// 가장 오래된 entry 제거 (Map 삽입 순서).
|
||||
const oldest = _cache.keys().next().value;
|
||||
if (oldest !== undefined) _cache.delete(oldest);
|
||||
}
|
||||
_cache.set(url, { block, expiresAt: Date.now() + CACHE_TTL_MS });
|
||||
}
|
||||
|
||||
@@ -33,6 +33,10 @@ export interface DispatcherDepsInputs {
|
||||
pipelineIdOverride?: string;
|
||||
/** Intent Alignment 가 도출한 사용자 합의 contract. 없으면 legacy 동작. */
|
||||
requirementContract?: RequirementContract;
|
||||
/** 현재 워크스페이스 아키텍처 컨텍스트 (호출자가 절단해 전달). */
|
||||
architectureContextBlock?: string;
|
||||
/** 사용자 프롬프트 URL pre-fetch 결과 ([URL CONTENT] 블록). */
|
||||
webContextBlock?: string;
|
||||
}
|
||||
|
||||
export function buildDispatcherDeps(inputs: DispatcherDepsInputs): DispatcherDeps {
|
||||
@@ -64,5 +68,7 @@ export function buildDispatcherDeps(inputs: DispatcherDepsInputs): DispatcherDep
|
||||
}),
|
||||
pipelineIdOverride: inputs.pipelineIdOverride,
|
||||
requirementContract: inputs.requirementContract,
|
||||
architectureContextBlock: inputs.architectureContextBlock,
|
||||
webContextBlock: inputs.webContextBlock,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,6 +82,8 @@ export interface ReadyStatusDeps {
|
||||
effectiveContextLength: number;
|
||||
cappedForSmallModel: boolean;
|
||||
lmStudioError: string | null;
|
||||
/** HealthCheckMonitor 의 마지막 환경 경고 (Bridge·볼륨·자격증명·버전 등). 없으면 []. */
|
||||
healthWarnings: string[];
|
||||
}
|
||||
|
||||
export function buildReadyStatusPayload(d: ReadyStatusDeps) {
|
||||
@@ -102,6 +104,7 @@ export function buildReadyStatusPayload(d: ReadyStatusDeps) {
|
||||
nominalContextLength: d.contextLength,
|
||||
cappedForSmallModel: d.cappedForSmallModel,
|
||||
lmStudioError: d.lmStudioError,
|
||||
healthWarnings: d.healthWarnings,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+252
-8
@@ -8,6 +8,7 @@ import {
|
||||
buildApiUrl,
|
||||
getActiveBrainProfile,
|
||||
getBrainProfiles,
|
||||
invalidateBrainFilesCache,
|
||||
logError,
|
||||
logInfo,
|
||||
resolveEngine,
|
||||
@@ -108,6 +109,8 @@ registerSidebarHandler(handleChronicleMessage);
|
||||
registerSidebarHandler(handleAgentMessage);
|
||||
import { resolveScopeForAgent } from './skills/agentKnowledgeMap';
|
||||
import { clearBrainTokenIndex } from './retrieval/brainIndex';
|
||||
import { extractUrls } from './features/web/webFetch';
|
||||
import { buildUrlContext } from './lib/contextBuilders/urlContext';
|
||||
import { estimateModelParamsB } from './lib/contextManager';
|
||||
import {
|
||||
buildOrRefreshArchitectureDoc,
|
||||
@@ -123,8 +126,13 @@ import {
|
||||
listResumableSessions,
|
||||
analyzeIntent,
|
||||
resolveActivePipeline,
|
||||
gatherEvidenceForQuestions,
|
||||
selfAnswerQuestions,
|
||||
saveAlignmentKnowledge,
|
||||
SELF_RESEARCH_PREFIX,
|
||||
} from './features/company';
|
||||
import { AIService } from './core/services';
|
||||
import { HealthCheckMonitor } from './core/health';
|
||||
import { presentOfficeSnapshot } from './features/astraOffice';
|
||||
|
||||
export interface SidebarLmStudioDeps {
|
||||
@@ -1104,6 +1112,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
effectiveContextLength,
|
||||
cappedForSmallModel,
|
||||
lmStudioError: this._lmStudioLastError ?? null,
|
||||
healthWarnings: HealthCheckMonitor.lastReports,
|
||||
});
|
||||
this._view.webview.postMessage(payload);
|
||||
} catch (err: any) {
|
||||
@@ -1432,6 +1441,16 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
this._view?.webview.postMessage({ type: 'clearChat' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 두뇌 동기화 — 두 모드:
|
||||
* 1. 원격 미설정 (profile.secondBrainRepo 비어 있음, 기본): 검색 인덱스/캐시
|
||||
* 새로고침만. 사용자의 멘탈 모델("동기화 = 내 로컬 두뇌 폴더 인식")과 일치.
|
||||
* git 을 건드리지 않으므로 인증도 불필요.
|
||||
* 2. 원격 설정됨: pull(rebase) → commit/push → 인덱스 갱신 (백업/공유 모드).
|
||||
*
|
||||
* 배경: 옛 구현은 원격 설정 여부와 무관하게 git push 를 강행해서, 두뇌 폴더가
|
||||
* 우연히 다른 git 레포 안에 있으면 그 레포의 원격 인증을 요구하는 혼란이 있었다.
|
||||
*/
|
||||
public async syncBrain() {
|
||||
const activeBrain = getActiveBrainProfile();
|
||||
const brainDir = activeBrain.localBrainPath;
|
||||
@@ -1439,20 +1458,122 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
vscode.window.showErrorMessage("Second Brain directory not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 모드 1: 원격 미설정 → 로컬 새로고침만 (git 없음, 인증 불필요) ──
|
||||
if (!activeBrain.secondBrainRepo || !activeBrain.secondBrainRepo.trim()) {
|
||||
try {
|
||||
invalidateBrainFilesCache();
|
||||
clearBrainTokenIndex(brainDir);
|
||||
const count = findBrainFiles(brainDir).length;
|
||||
vscode.window.showInformationMessage(
|
||||
`두뇌 새로고침 완료 — 문서 ${count}개 인식됨. ` +
|
||||
`(이 두뇌는 원격 저장소가 설정되어 있지 않아 git 동기화는 건너뜁니다. ` +
|
||||
`새 문서는 평소에도 자동 인식되므로 이 버튼은 강제 새로고침 용도입니다.)`,
|
||||
);
|
||||
} catch (e: any) {
|
||||
vscode.window.showErrorMessage(`두뇌 새로고침 실패: ${String(e?.message ?? e).slice(0, 150)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
vscode.window.withProgress({
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: "Astra: Syncing Second Brain...",
|
||||
cancellable: false
|
||||
}, async () => {
|
||||
const { execSync } = require('child_process');
|
||||
const run = (cmd: string): string =>
|
||||
execSync(cmd, { cwd: brainDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) as string;
|
||||
|
||||
// ── ⓪ upstream 확인 — 없으면 pull/push 없이 로컬 커밋만 (정직 보고) ──
|
||||
// 사례: 두뇌 폴더에 원격 없는 중첩 .git 이 생겨 있던 환경에서
|
||||
// "There is no tracking information" 으로 동기화 전체가 죽었다.
|
||||
// 원격 추적이 없는 두뇌는 로컬 버전 관리만 수행하고 그 사실을 알린다.
|
||||
let hasUpstream = true;
|
||||
try {
|
||||
const { execSync } = require('child_process');
|
||||
execSync(`git add .`, { cwd: brainDir });
|
||||
execSync(`git commit -m "[G1-Sync] Manual knowledge update"`, { cwd: brainDir });
|
||||
execSync(`git push`, { cwd: brainDir });
|
||||
vscode.window.showInformationMessage("Second Brain synced successfully.");
|
||||
} catch (err: any) {
|
||||
vscode.window.showInformationMessage("Brain Synced: Local knowledge is up-to-date (no changes to push).");
|
||||
run('git rev-parse --abbrev-ref --symbolic-full-name "@{u}"');
|
||||
} catch {
|
||||
hasUpstream = false;
|
||||
}
|
||||
|
||||
// ── ① 원격 변경 수신 (pull --rebase --autostash) ──
|
||||
let pulledFiles = 0;
|
||||
if (hasUpstream) {
|
||||
try {
|
||||
run('git fetch');
|
||||
// upstream 대비 새로 들어올 파일 수 — 사용자에게 "몇 개 받았는지" 보여주기 위해.
|
||||
try {
|
||||
const incoming = run('git diff --name-only HEAD "@{u}"').trim();
|
||||
pulledFiles = incoming ? incoming.split('\n').filter(Boolean).length : 0;
|
||||
} catch { /* 카운트만 생략 */ }
|
||||
run('git pull --rebase --autostash');
|
||||
} catch (err: any) {
|
||||
const detail = String(err?.stderr || err?.message || err).slice(0, 200);
|
||||
logError('Brain sync: pull failed.', { brainDir, detail });
|
||||
vscode.window.showErrorMessage(`두뇌 동기화 실패 (원격 수신 중): ${detail}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── ② 로컬 변경 송신 (add → commit → push) ──
|
||||
// 변경 판정은 *staged* 기준 (`git diff --cached --quiet`) — 두뇌 폴더가
|
||||
// 더 큰 레포의 하위 폴더일 때 `git status --porcelain` 은 폴더 *밖* 의
|
||||
// 변경까지 보고해서, staged 가 비어 있는데 commit 을 시도하다
|
||||
// "no changes added" 로 실패하는 버그가 있었다.
|
||||
let pushed = false;
|
||||
let committedLocally = false;
|
||||
try {
|
||||
run('git add .');
|
||||
let hasStaged = false;
|
||||
try {
|
||||
run('git diff --cached --quiet'); // exit 0 = staged 없음
|
||||
} catch {
|
||||
hasStaged = true; // exit 1 = staged 변경 존재
|
||||
}
|
||||
if (hasStaged) {
|
||||
run('git commit -m "[G1-Sync] Manual knowledge update"');
|
||||
committedLocally = true;
|
||||
if (hasUpstream) {
|
||||
run('git push');
|
||||
pushed = true;
|
||||
}
|
||||
} else if (hasUpstream) {
|
||||
// 커밋할 변경 없음 — ahead 상태(이전 커밋 미push)면 push 만.
|
||||
const ahead = run('git rev-list --count "@{u}"..HEAD').trim();
|
||||
if (ahead && ahead !== '0') {
|
||||
run('git push');
|
||||
pushed = true;
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
const detail = String(err?.stderr || err?.message || err).slice(0, 200);
|
||||
logError('Brain sync: push failed.', { brainDir, detail });
|
||||
// 인증 부재는 사용자가 직접 해결해야 하는 환경 문제 — git 원문 대신
|
||||
// 행동 가능한 안내. (확장은 비대화형이라 아이디/토큰을 물어볼 수 없다.)
|
||||
if (/could not read Username|Authentication failed|terminal prompts disabled|403/i.test(detail)) {
|
||||
vscode.window.showErrorMessage(
|
||||
`두뇌 동기화 실패: git 인증 정보가 저장되어 있지 않습니다. ` +
|
||||
`터미널에서 "cd ${brainDir} && git push" 를 1회 실행해 아이디/토큰을 입력하면 ` +
|
||||
`키체인에 저장되어 이후 버튼이 정상 작동합니다. (로컬 커밋은 완료된 상태)`,
|
||||
);
|
||||
} else {
|
||||
vscode.window.showErrorMessage(`두뇌 동기화 실패 (송신 중): ${detail}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── ③ 검색 인덱스/캐시 무효화 — 새 문서가 즉시 검색에 잡히게 ──
|
||||
try {
|
||||
invalidateBrainFilesCache();
|
||||
clearBrainTokenIndex(brainDir);
|
||||
} catch { /* 캐시 무효화 실패는 치명적이지 않음 — TTL/mtime 이 결국 따라잡음 */ }
|
||||
|
||||
const parts: string[] = [];
|
||||
if (pulledFiles > 0) parts.push(`원격 문서 ${pulledFiles}개 수신`);
|
||||
if (pushed) parts.push('로컬 변경 push 완료');
|
||||
if (committedLocally && !pushed) parts.push('로컬 커밋 완료');
|
||||
if (!hasUpstream) parts.push('⚠️ 원격 추적 브랜치 없음 — 로컬 버전 관리만 수행됨');
|
||||
if (parts.length === 0) parts.push('변경 없음 — 이미 최신 상태');
|
||||
vscode.window.showInformationMessage(`두뇌 동기화 완료: ${parts.join(' · ')} (검색 인덱스 갱신됨)`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1928,6 +2049,39 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
} catch { /* history 못 가져와도 alignment 자체는 동작 */ }
|
||||
}
|
||||
|
||||
// 현재 워크스페이스 아키텍처 컨텍스트 — "이 프로젝트가 뭐냐"류의 재질문 차단.
|
||||
// 첫 라운드만 (후속 라운드는 contract 에 이미 흡수). 분석기는 작은 모델이므로
|
||||
// 3,000자로 재절단 (architecture 원본 cap 은 16,000자).
|
||||
let projectContext: string | undefined;
|
||||
if (!opts.previousContract) {
|
||||
try {
|
||||
const arch = this._buildProjectArchitectureContext();
|
||||
if (arch) {
|
||||
projectContext = arch.length > 3000
|
||||
? arch.slice(0, 3000) + '\n…(이하 생략 — 전체는 architecture.md 참조)'
|
||||
: arch;
|
||||
}
|
||||
} catch { /* architecture 없어도 alignment 는 계속 */ }
|
||||
// 사용자 프롬프트에 URL 이 있으면 본문도 컨텍스트에 — "그 사이트가
|
||||
// 뭐냐"는 재질문 차단. 결과는 5분 캐시되어 dispatch 와 중복 비용 없음.
|
||||
try {
|
||||
if (cfg.webAutoFetchEnabled !== false) {
|
||||
const urls = extractUrls(opts.userPrompt, 2);
|
||||
if (urls.length > 0) {
|
||||
const webBlocks: string[] = [];
|
||||
for (const url of urls) {
|
||||
webBlocks.push(await buildUrlContext(url));
|
||||
}
|
||||
const web = webBlocks.join('\n\n');
|
||||
const webCapped = web.length > 2000 ? web.slice(0, 2000) + '\n…(이하 생략)' : web;
|
||||
projectContext = projectContext
|
||||
? `${projectContext}\n\n${webCapped}`
|
||||
: webCapped;
|
||||
}
|
||||
}
|
||||
} catch { /* URL 수집 실패해도 alignment 는 계속 */ }
|
||||
}
|
||||
|
||||
const analysis = await analyzeIntent(
|
||||
new AIService(),
|
||||
{
|
||||
@@ -1937,19 +2091,55 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
activePipelineName: activePipeline?.name,
|
||||
availableRoleCategories: extractActiveRoleCategories(state),
|
||||
priorChatSummary,
|
||||
projectContext,
|
||||
},
|
||||
// 분류기와 같은 작은 모델을 재사용 — 이 단계도 빠르고 가벼워야 함.
|
||||
{ model: cfg.companyIntentClassifierModel || cfg.defaultModel },
|
||||
);
|
||||
|
||||
const contract = analysis.contract;
|
||||
let contract = analysis.contract;
|
||||
const reachedLimit = opts.roundsAsked >= opts.roundsLimit;
|
||||
|
||||
// ── 자가 조사: 사용자에게 묻기 전에 두뇌에서 스스로 답 찾기 ──
|
||||
// 답을 찾은 질문은 answeredQuestions 로 이동(자가 조사 marker), 못 찾은
|
||||
// 것만 카드에 노출. 라운드 카운트는 소비하지 않는다 (사용자 응답이 아니므로).
|
||||
if (cfg.companyAlignmentSelfResearch !== false
|
||||
&& contract.openQuestions.length > 0 && !reachedLimit) {
|
||||
try {
|
||||
const brain = getActiveBrainProfile();
|
||||
const evidence = gatherEvidenceForQuestions(brain.localBrainPath, contract.openQuestions);
|
||||
if (evidence.some((e) => e.excerpts.length > 0)) {
|
||||
const answers = await selfAnswerQuestions(new AIService(), {
|
||||
userPrompt: opts.userPrompt,
|
||||
evidence,
|
||||
model: cfg.companyIntentClassifierModel || cfg.defaultModel,
|
||||
});
|
||||
const solved = answers.filter((a) => a.answered && a.answer.trim());
|
||||
if (solved.length > 0) {
|
||||
const solvedSet = new Set(solved.map((s) => s.question));
|
||||
// 비파괴적 갱신 — analysis.contract 원본은 건드리지 않는다.
|
||||
contract = {
|
||||
...contract,
|
||||
answeredQuestions: [
|
||||
...contract.answeredQuestions,
|
||||
...solved.map((s) => ({ q: s.question, a: SELF_RESEARCH_PREFIX + s.answer })),
|
||||
],
|
||||
openQuestions: contract.openQuestions.filter((q) => !solvedSet.has(q)),
|
||||
};
|
||||
this._pixelOfficeBroadcast({
|
||||
recentLogs: this._pixelOffice.appendLog(`🔎 자가 조사로 질문 ${solved.length}건 해결`),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch { /* 자가 조사 실패 — 원래 질문 그대로 노출 */ }
|
||||
}
|
||||
|
||||
if (shouldAutoProceedAlignment(opts.mode, contract, reachedLimit)) {
|
||||
// contract 한 줄 안내 후 곧장 pipeline. 사용자 friction 최소.
|
||||
this._view?.webview.postMessage(buildAlignmentAutoProceedPayload(contract, reachedLimit));
|
||||
try { this.pixelOfficeOnAlignmentResult('auto-proceed', contract); } catch { /* noop */ }
|
||||
this._alignment.clear();
|
||||
this._saveAlignmentKnowledgeIfAny(opts.userPrompt, contract.answeredQuestions);
|
||||
await this._runCompanyTurn(opts.userPrompt, undefined, opts.pipelineIdOverride, contract);
|
||||
return;
|
||||
}
|
||||
@@ -2014,6 +2204,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
async _proceedWithCurrentAlignment(): Promise<void> {
|
||||
const pending = this._alignment.consume();
|
||||
if (!pending) return;
|
||||
this._saveAlignmentKnowledgeIfAny(pending.userOriginalPrompt, pending.contract.answeredQuestions);
|
||||
await this._runCompanyTurn(
|
||||
pending.userOriginalPrompt,
|
||||
undefined,
|
||||
@@ -2022,6 +2213,26 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alignment 학습 루프 (Phase 3) — 사용자가 직접 답해준 Q/A 를 두뇌에 저장.
|
||||
* 자가 조사 항목과 짧은 답의 필터링은 saveAlignmentKnowledge 내부에서 처리.
|
||||
* fire-and-forget: 저장 실패가 dispatch 를 막으면 안 된다.
|
||||
*/
|
||||
private _saveAlignmentKnowledgeIfAny(userPrompt: string, qaList: Array<{ q: string; a: string }>): void {
|
||||
try {
|
||||
const cfg = getConfig();
|
||||
if (cfg.companyAlignmentKnowledgeSave === false) return;
|
||||
if (!qaList || qaList.length === 0) return;
|
||||
const brain = getActiveBrainProfile();
|
||||
const saved = saveAlignmentKnowledge(brain.localBrainPath, { userPrompt, qaList });
|
||||
if (saved) {
|
||||
this._pixelOfficeBroadcast({
|
||||
recentLogs: this._pixelOffice.appendLog('💾 확인된 정보 두뇌 저장'),
|
||||
});
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자가 카드의 "🛑 취소"를 누르거나 alignment를 그만두려 할 때.
|
||||
* 슬롯 비우고 webview에도 streamEnd push해서 input 풀어줌.
|
||||
@@ -2131,6 +2342,37 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
// `signal.aborted` between phases and short-circuits cleanly.
|
||||
const abort = this._companyTurn.startTurn();
|
||||
try {
|
||||
// ── 자동 수집 컨텍스트 (architecture + URL pre-fetch) ──
|
||||
// 일반 챗은 둘 다 자동 주입받지만 기업 모드 dispatcher 는 빠져 있던 공백.
|
||||
// 실패해도 turn 은 진행 (try/catch).
|
||||
let architectureContextBlock: string | undefined;
|
||||
try {
|
||||
const arch = this._buildProjectArchitectureContext();
|
||||
if (arch) {
|
||||
architectureContextBlock = arch.length > 6000
|
||||
? arch.slice(0, 6000) + '\n…(이하 생략 — 전체는 architecture.md 참조)'
|
||||
: arch;
|
||||
}
|
||||
} catch { /* architecture 없어도 진행 */ }
|
||||
let webContextBlock: string | undefined;
|
||||
try {
|
||||
const cfgTurn = getConfig();
|
||||
if (cfgTurn.webAutoFetchEnabled !== false && !resumeTimestamp) {
|
||||
const urls = extractUrls(userPrompt, 2);
|
||||
if (urls.length > 0) {
|
||||
this._pixelOfficeBroadcast({
|
||||
recentLogs: this._pixelOffice.appendLog(`🌐 URL ${urls.length}건 수집 중`),
|
||||
});
|
||||
const blocks: string[] = [];
|
||||
for (const url of urls) {
|
||||
blocks.push(await buildUrlContext(url)); // 5분 캐시 — alignment 와 중복 비용 없음
|
||||
}
|
||||
const joined = blocks.join('\n\n');
|
||||
webContextBlock = joined.length > 8000 ? joined.slice(0, 8000) + '\n…(이하 생략)' : joined;
|
||||
}
|
||||
}
|
||||
} catch { /* URL 수집 실패해도 진행 */ }
|
||||
|
||||
const deps = buildDispatcherDeps({
|
||||
context: this._context,
|
||||
ai,
|
||||
@@ -2140,6 +2382,8 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
approvalGates: this._approvalGates,
|
||||
pipelineIdOverride,
|
||||
requirementContract,
|
||||
architectureContextBlock,
|
||||
webContextBlock,
|
||||
});
|
||||
// 일반 새 turn vs 재개 turn 분기. 재개 시 _resume.json에서 plan + cursor를
|
||||
// 복원해 그 다음 stage부터 dispatch가 이어진다. resumeCompanyTurn이 null을
|
||||
|
||||
+14
-1
@@ -402,10 +402,23 @@ R7. GUESS-AND-ACT WITH STATED ASSUMPTION. When information is missing but a reas
|
||||
- 진척 보고가 들어오면 즉시 update / complete. "추적해뒀어요" 라고 말만 하지 말 것.
|
||||
- due 시각이 명확한 task 는 add_task + create_calendar_event 함께 emit (둘 다).
|
||||
|
||||
[ACTION 15: FETCH URL]
|
||||
웹 페이지의 *실제 내용* 이 필요할 때 사용. 절대 "사이트에 방문할 수 없다"고
|
||||
답하지 말 것 — 이 태그를 emit 하면 확장이 페이지 본문을 가져와 준다.
|
||||
|
||||
<fetch_url url="https://example.com/page"/>
|
||||
|
||||
- url: http/https URL (required)
|
||||
- 사용 시점: 사용자가 언급한 링크, 또는 작업에 필요한 페이지의 최신 내용이
|
||||
컨텍스트에 없을 때. 일반 지식 질문에는 쓰지 말 것.
|
||||
- 회당 최대 2개. 결과는 [URL CONTENT] 블록으로 주입되며, 실패하면 실패
|
||||
사실이 그대로 전달된다 — 내용을 추측해 채우지 말 것.
|
||||
|
||||
[OPERATIONAL RULES]
|
||||
1. Reply in the same language as the user.
|
||||
2. File paths are relative to the workspace or absolute under /Volumes/Data/project/Antigravity.
|
||||
3. When the user says "분석해줘", "봐줘", "확인해줘", "리뷰해줘" with a path — that IS the confirmation. Access the path immediately and run the full analysis in one continuous response. Do not pause to ask "진행할까요?" or "시작할까요?".`;
|
||||
3. When the user says "분석해줘", "봐줘", "확인해줘", "리뷰해줘" with a path — that IS the confirmation. Access the path immediately and run the full analysis in one continuous response. Do not pause to ask "진행할까요?" or "시작할까요?".
|
||||
4. Claims about THIS workspace's code or features must be grounded in files you actually read in this conversation (via read_file / list_files). Never describe implementation details with guesses like "~로 보입니다" — read the file first, or explicitly say you could not verify. Before proposing "add feature X", check whether X already exists in the codebase.`;
|
||||
|
||||
function getEnvironmentBlock(): string {
|
||||
const platform = process.platform;
|
||||
|
||||
Reference in New Issue
Block a user