From 5feacb47996db8f110133b6cc65eeff7d0523600 Mon Sep 17 00:00:00 2001 From: g1nation Date: Tue, 14 Jul 2026 18:18:49 +0900 Subject: [PATCH] =?UTF-8?q?v2.2.308~310:=20Standing=20Rules=20+=20?= =?UTF-8?q?=EC=A1=B0=EC=82=AC=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20+=20LM=20Studio=20=EC=B6=94=EB=A1=A0=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C=20=EC=88=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2.2.308 — Standing Rules (Correction Loop ④): 행동/스타일 교정의 세션 영속화 - 행동 지적 감지("또 ~하네", "하지 말라고 했잖아" 등 5종) → LLM 이 명령형 규칙으로 정규화 → .astra/growth/standing-rules.json 즉시 저장 → 매 턴 보호 구역 주입. self-reflection 이 컨텍스트 창(단기 기억)에 갇혀 새 세션에서 증발하던 문제 해결. - 중복 지적은 hits 증가(토큰 자카드), 상한 10개 초과 시 자동 은퇴, 재지적 시 부활. - "Astra: 상시 행동 규칙" 커맨드 — 활성/은퇴 규칙 + 실제 주입 블록 열람(도달 증거). v2.2.309 — 조사 파이프라인: 파일명만 보고 상상하는 헛조사 4중 차단 - 액션: 폴더의 텍스트 파일을 코드가 강제 정독해 파일별 노트 주입(Map-Reduce). 내용 해시 캐시(.astra/cache/investigate)로 재조사 시 재사용 — 온디맨드 지식화. - 조사 증거 게이트(행동 제약) + Hollow Investigation 감지(list만 하고 read 0회로 파일 서술 시 경고 footer — continuation 경로 포함) + actionStats turn 추적. - g1nation.investigationModel: 조사형 요청만 상위 모델(예: claude-code:sonnet) 라우팅. v2.2.310 — LM Studio LSEP 합성 추론 구분자 처리 - 일부 모델 조합에서 추론이 여는 마커 없이 content 앞에 흐르고 __LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END___ 로만 경계 표시 → 생각 과정 전체가 채팅에 노출되던 문제. - LiveReasoningFilter: 마커 감지 시 streamReplace 신호로 화면 즉시 리셋(토큰 분절 대응 holdback 포함), sanitizeAssistantContent: 마커 이전 추론 최종 제거(이중 방어). 기타: tests/wikiSave.test.ts 상대경로 테스트 Windows 호환 수정(드라이브 문자). 검증: 전체 테스트 926건 통과 (신규 39건: standingRules 15 + investigationPipeline 16 + LSEP 8). Co-Authored-By: Claude Fable 5 --- package.json | 16 +- src/agent.ts | 75 +++++- src/agent/actions/investigateFiles.ts | 53 ++++ src/agent/llm/streamChatOnce.ts | 6 + src/agent/postAnswerHooks/index.ts | 20 ++ src/agent/postAnswerHooks/types.ts | 2 + src/config.ts | 7 + src/extension/evalCommands.ts | 53 ++++ src/intelligence/correctionLoop.ts | 175 ++++++++++++++ src/intelligence/investigationPipeline.ts | 228 ++++++++++++++++++ .../contextBuilders/liveReasoningFilter.ts | 63 ++++- src/lib/contextBuilders/memoryContext.ts | 13 +- src/lib/contextBuilders/outputSanitization.ts | 11 +- tests/investigationPipeline.test.ts | 167 +++++++++++++ tests/liveReasoningFilter.test.ts | 71 ++++++ tests/standingRules.test.ts | 151 ++++++++++++ tests/wikiSave.test.ts | 3 +- 17 files changed, 1098 insertions(+), 16 deletions(-) create mode 100644 src/agent/actions/investigateFiles.ts create mode 100644 src/intelligence/investigationPipeline.ts create mode 100644 tests/investigationPipeline.test.ts create mode 100644 tests/standingRules.test.ts diff --git a/package.json b/package.json index b7b1800..6c469bd 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "astra", "displayName": "Astra", "description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.", - "version": "2.2.307", + "version": "2.2.310", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", @@ -54,6 +54,10 @@ "command": "g1nation.growth.report", "title": "Astra: 성장 리포트 (Reflection 추이)" }, + { + "command": "g1nation.growth.standingRules", + "title": "Astra: 상시 행동 규칙 (Standing Rules)" + }, { "command": "g1nation.growth.learningQueue", "title": "Astra: 학습 큐 갱신 (Need Engine)" @@ -352,7 +356,10 @@ "g1nation.meetTaskDateFallback": { "type": "string", "default": "today", - "enum": ["today", "hold"], + "enum": [ + "today", + "hold" + ], "markdownDescription": "`/meet` 액션 아이템 중 **기한이 없는(파싱 불가) 확정·기한미정** 항목의 처리. `today`(기본): 회의록 **작성일(오늘)** 을 기한으로 잡아 자동 등록 — 매번 '자동 등록 0건'이 되던 문제를 해소. `hold`: 이전처럼 등록하지 않고 보류→`/meet confirm` 으로 수동 등록. (진행미정·조건부 항목은 이 설정과 무관하게 항상 보류.)" }, "g1nation.meetTaskDetailExpand": { @@ -1282,6 +1289,11 @@ "type": "number", "default": 0, "markdownDescription": "**Stocks 보고서 전용 텔레그램 chatId** — fallback. `g1nation.telegram.allowedChatIds` 가 비어있을 때만 사용. 0 = 미설정." + }, + "g1nation.investigationModel": { + "type": "string", + "default": "", + "markdownDescription": "조사형 요청('~폴더 조사해줘') 전용 모델 오버라이드. 예: 'claude-code:sonnet'. 소형 로컬 모델이 파일명만 보고 내용을 상상하는 헛조사 방지책의 하나 — 비우면 현재 선택 모델 사용." } } } diff --git a/src/agent.ts b/src/agent.ts index bda6fff..6e2925f 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -24,7 +24,8 @@ import { isSelfAssessRequest, isAboutSelf, buildSelfAssessContext } from './lib/ import { ensureFeatureInventory } from './extension/featureInventory'; import { buildUrlContext } from './lib/contextBuilders/urlContext'; import { extractUrls } from './features/web/webFetch'; -import { looksLikeCorrection, captureCorrection } from './intelligence/correctionLoop'; +import { looksLikeCorrection, looksLikeBehaviorComplaint, captureCorrection } from './intelligence/correctionLoop'; +import { isLocalInvestigationPrompt, detectHollowInvestigation, formatHollowInvestigationFooter } from './intelligence/investigationPipeline'; import { shouldUseMultiAgentWorkflow } from './lib/contextBuilders/multiAgentRouting'; import { buildThinkingPartnerResponseContract } from './lib/contextBuilders/thinkingPartnerContract'; import { buildDroppedHistorySummary } from './lib/contextBuilders/droppedHistorySummary'; @@ -163,6 +164,7 @@ import { applyRunCommandActions } from './agent/actions/runCommand'; import { applyCalculateActions } from './agent/actions/calculate'; import { applyRunCodeActions } from './agent/actions/runCode'; import { applyListFilesActions } from './agent/actions/listFiles'; +import { applyInvestigateFilesActions } from './agent/actions/investigateFiles'; import { applyWebFetchActions } from './agent/actions/webFetch'; import { applyBrainOpsActions } from './agent/actions/brainOps'; import { applyCalendarActions } from './agent/actions/calendar'; @@ -316,6 +318,10 @@ export class AgentExecutor { selfCheckSources: Array<{ title: string; excerpt: string }>; /** Confidence Engine 검색 신호 (Phase 2) — memoryContext 가 채움. */ confidenceSignals: import('./intelligence/confidenceEngine').RetrievalConfidenceSignals | null; + /** [v2.2.309] 이번 turn 의 액션 실행 통계 — Hollow Investigation 감지용 (loop depth 누적). */ + actionStats: { reads: number; lists: number; investigates: number }; + /** [v2.2.309] 조사 턴 모델 오버라이드 — depth 0 에서 결정, continuation 에도 유지. */ + investigationModelOverride: string | null; } = { retrieval: null, lessons: [], @@ -323,6 +329,8 @@ export class AgentExecutor { dynamicBlocks: new Map(), selfCheckSources: [], confidenceSignals: null, + actionStats: { reads: 0, lists: 0, investigates: 0 }, + investigationModelOverride: null, }; /** Per-turn state 일괄 정리. turn 시작/abort/load session 시 호출. */ @@ -333,6 +341,10 @@ export class AgentExecutor { this._turnCtx.dynamicBlocks.clear(); this._turnCtx.selfCheckSources = []; this._turnCtx.confidenceSignals = null; + // actionStats / investigationModelOverride 는 여기서 리셋하지 않는다 — + // resetTurnContext 는 continuation depth 에서도 호출되는데(메모리 컨텍스트 재구축), + // 이 둘은 사용자 turn 전체(모든 depth)에 걸쳐 누적/유지되어야 한다. + // 리셋은 loopDepth === 0 진입부에서만 (handlePrompt 초입). } private readonly options: AgentExecutorOptions; @@ -545,6 +557,9 @@ export class AgentExecutor { // buildMemoryContext, the previous turn's value would otherwise leak into this turn's // "참조 범위" footer (the exact "안녕 → 🔎 참조: 에피소드기억" bug). this.resetTurnContext(); + // [v2.2.309] turn 전체(모든 depth) 누적 상태 — depth 0 에서만 초기화. + this._turnCtx.actionStats = { reads: 0, lists: 0, investigates: 0 }; + this._turnCtx.investigationModelOverride = null; } // 1. Prepare Context @@ -618,6 +633,7 @@ export class AgentExecutor { - 이 워크스페이스의 코드·문서·기능에 대한 주장은 *이 대화에서 실제로 읽은 파일*에만 근거하라. - 확인하지 않은 구현을 "~로 보입니다", "~일 것입니다"라고 추측 서술하는 것은 금지. 먼저 태그로 관련 파일을 직접 열어 확인한 뒤 답하라. 태그를 emit 하면 시스템이 파일 내용을 주입하고 자동으로 이어서 답변하게 된다. - ⚠️ "소스 코드 확인이 필요합니다"라고 말만 하고 끝내는 것은 금지다. 확인이 필요하다고 판단했다면 *바로 이 답변 안에서* / 태그를 emit 하라 — 그것이 확인하는 방법이다. 태그로 접근 불가능한 대상(외부 시스템·미설치 도구 등)에 한해서만 "확인하지 못함"으로 명시하라. +- 폴더 전체를 조사해야 하면 파일을 하나씩 읽지 말고 태그 하나를 emit 하라 — 시스템이 폴더의 모든 텍스트 파일을 실제로 읽어 파일별 노트를 주입한다. list_files 로 받은 *파일명*만 보고 내용을 서술하는 것은 날조다. - "X 기능을 추가하라"고 제안하기 전에 그 기능이 이미 구현돼 있는지 해당 모듈을 찾아 읽어라. 이미 있는 기능을 새로 만들라고 제안하는 것은 잘못된 분석이다. - 일반론·추측으로 빈칸을 채우지 마라.`; } @@ -637,24 +653,28 @@ export class AgentExecutor { } } - // [Correction Loop ①] 이 발화가 직전 답변에 대한 *정정*이면 fire-and-forget - // 캡처 — 오류 분류 → 태깅 레슨 + 회귀 케이스(.astra/eval/corrections.jsonl). - // 정정 자체가 Ground Truth 가 되어 주간 회귀 테스트·약점 프로필의 원료가 된다. + // [Correction Loop ①] 이 발화가 직전 답변에 대한 *정정*(사실) 또는 *행동/스타일 + // 지적*("또 ~하네", "하지 말라고 했잖아")이면 fire-and-forget 캡처 — 오류 분류 → + // 태깅 레슨 + 회귀 케이스. 행동 지적은 추가로 Standing Rule 로 정규화되어 + // 다음 턴부터(새 세션 포함) 매 턴 주입된다 (v2.2.308 — 세션 휘발 문제 해결). // 턴 응답을 막지 않는다 (await 없음). - if (prompt && loopDepth === 0 && activeBrain?.localBrainPath && looksLikeCorrection(prompt)) { + const isFactCorrection = !!(prompt && loopDepth === 0 && activeBrain?.localBrainPath && looksLikeCorrection(prompt)); + const isBehaviorComplaint = !!(prompt && loopDepth === 0 && activeBrain?.localBrainPath && looksLikeBehaviorComplaint(prompt)); + if (isFactCorrection || isBehaviorComplaint) { const visible = this.chatHistory.filter(m => !m.internal); const lastAssistant = [...visible].reverse().find(m => m.role === 'assistant'); const lastUserIdx = lastAssistant ? visible.lastIndexOf(lastAssistant) - 1 : -1; const priorQuestion = lastUserIdx >= 0 && visible[lastUserIdx]?.role === 'user' ? visible[lastUserIdx].content : ''; if (lastAssistant && priorQuestion) { void captureCorrection({ - brainPath: activeBrain.localBrainPath, + brainPath: activeBrain!.localBrainPath, question: priorQuestion, wrongAnswer: lastAssistant.content, - correction: prompt, + correction: prompt!, llm: { baseUrl: config.ollamaUrl, model: configDefaultModel }, + behaviorHint: isBehaviorComplaint, }).then(file => { - if (file) logInfo('Correction Loop: 정정 캡처 완료.', { lesson: file }); + if (file) logInfo('Correction Loop: 정정 캡처 완료.', { lesson: file, behavior: isBehaviorComplaint }); }).catch((e: any) => logError('Correction Loop 캡처 실패 (무시).', { error: e?.message ?? String(e) })); } } @@ -670,7 +690,18 @@ export class AgentExecutor { } // 3. API Request Setup (라인 229에서 이미 추출한 ollamaUrl, configDefaultModel 재사용) - const actualModel = (modelName && modelName.trim()) || configDefaultModel; + let actualModel = (modelName && modelName.trim()) || configDefaultModel; + // [v2.2.309-D] 조사형 요청은 설정된 상위 모델로 라우팅 (예: "claude-code:sonnet"). + // 소형 로컬 모델의 lazy tool-use 할루시네이션 대책의 마지막 층 — depth 0 에서 + // 결정하고 continuation(액션 결과로 이어지는 답변)에도 같은 모델을 유지한다. + if (loopDepth === 0 && prompt && config.investigationModel + && isLocalInvestigationPrompt(prompt)) { + this._turnCtx.investigationModelOverride = config.investigationModel; + logInfo('조사 요청 감지 — investigationModel 로 라우팅.', { model: config.investigationModel }); + } + if (this._turnCtx.investigationModelOverride) { + actualModel = this._turnCtx.investigationModelOverride; + } // Bound the in-memory history before building the request — shrinks bulky // older tool-result bodies and drops the oldest messages past the cap. capChatHistory(this.chatHistory, { @@ -994,6 +1025,12 @@ export class AgentExecutor { const liveFilter = postLiveDeltas ? new LiveReasoningFilter() : null; const postLiveToken = (token: string) => { const visible = liveFilter ? liveFilter.push(token) : token; + // [v2.2.310] LM Studio LSEP 구분자 — 여는 마커 없이 흐른 추론이 화면에 + // 이미 표시된 상태. streamReplace 로 걷어내고 실제 답변부터 다시. + if (liveFilter?.consumeSeparatorSignal()) { + this.webview?.postMessage({ type: 'streamReplace', value: visible }); + return; + } if (visible) this.webview?.postMessage({ type: 'streamChunk', value: visible }); }; @@ -1499,6 +1536,7 @@ export class AgentExecutor { engine, selfCheckSources: this._turnCtx.selfCheckSources, confidenceSignals: this._turnCtx.confidenceSignals, + actionStats: this._turnCtx.actionStats, callNonStreaming: (p) => this.callNonStreaming(p), getAbortSignal: () => this.abortController?.signal, getWebview: () => this.webview, @@ -1508,6 +1546,16 @@ export class AgentExecutor { }); } else { this.webview.postMessage({ type: 'streamChunk', value: finalAssistantContent }); + // [v2.2.309] Hollow Investigation — 액션 turn 의 최종 답변(continuation)은 + // post-answer hooks(depth 0 전용)를 타지 않으므로 여기서 직접 검사한다. + // "list_files 만 하고 read/investigate 없이 파일 여러 개를 서술" = 헛조사. + try { + const hollowInv = detectHollowInvestigation(finalAssistantContent, this._turnCtx.actionStats); + if (hollowInv.hollow) { + this.webview.postMessage({ type: 'streamChunk', value: formatHollowInvestigationFooter(hollowInv.fileMentions) }); + logInfo('Hollow Investigation 감지 (continuation).', { files: hollowInv.fileMentions, stats: this._turnCtx.actionStats }); + } + } catch { /* 감지 실패가 답변을 막지 않음 */ } } } catch (error: any) { @@ -1766,6 +1814,15 @@ export class AgentExecutor { await applyCalculateActions(ctx); await applyRunCodeActions(ctx); await applyListFilesActions(ctx); + await applyInvestigateFilesActions(ctx); + + // [v2.2.309] Hollow Investigation 감지용 액션 통계 (report 마커 기반, turn 누적). + for (const line of report) { + if (line.startsWith('📖 Read:')) this._turnCtx.actionStats.reads++; + else if (line.startsWith('📂 Listed:')) this._turnCtx.actionStats.lists++; + else if (line.startsWith('🔎 Investigated:')) this._turnCtx.actionStats.investigates++; + } + await applyWebFetchActions(ctx); await applyBrainOpsActions(ctx); await applyCalendarActions(ctx); diff --git a/src/agent/actions/investigateFiles.ts b/src/agent/actions/investigateFiles.ts new file mode 100644 index 0000000..1955a3a --- /dev/null +++ b/src/agent/actions/investigateFiles.ts @@ -0,0 +1,53 @@ +/** + * — 폴더(또는 단일 파일) 강제 정독 조사. + * + * list_files 가 "이름 목록"을 주는 것과 달리, 이 액션은 대상 파일들을 코드가 직접 + * 읽어 파일별 추출 노트(Map)를 만들고 병합 노트를 컨텍스트에 주입한다 — 모델이 + * 파일명만 보고 내용을 상상하는 헛조사(할루시네이션)를 구조적으로 차단. (v2.2.309) + * 노트는 내용 해시 캐시로 영속화되어 재조사 시 재사용된다 (온디맨드 지식화). + */ +import { HandlerContext } from './types'; +import { validatePath } from '../../security'; +import { getConfig } from '../../config'; +import { simpleChatCompletion } from '../../intelligence/llmCall'; +import { runInvestigation, formatInvestigationNotes } from '../../intelligence/investigationPipeline'; +import { logInfo, logError } from '../../utils'; + +export async function applyInvestigateFilesActions(ctx: HandlerContext): Promise { + const { aiMessage, rootPath, report } = ctx; + const regex = /]+)['"]?(?:\s+focus=['"]?([^'">]*)['"]?)?\s*\/?>(?:<\/investigate_files>)?/gi; + let match: RegExpExecArray | null; + + while ((match = regex.exec(aiMessage)) !== null) { + const relPath = match[1].trim() || '.'; + const focus = (match[2] || '').trim(); + try { + validatePath(rootPath, relPath); // 경로 탈출 방지 (결과는 pipeline 이 재해석) + const cfg = getConfig(); + const llm = { baseUrl: cfg.ollamaUrl, model: cfg.defaultModel }; + const result = await runInvestigation({ + rootPath, + relDir: relPath, + llmCall: (system, user) => simpleChatCompletion(system, user, { + ...llm, temperature: 0.1, maxTokens: 600, timeoutMs: 120000, + }), + }); + + if (result.notes.length === 0) { + report.push(`🔎 Investigate: ${relPath} — 읽을 텍스트 파일 없음 (제외 ${result.skipped.length}건)`); + ctx.chatHistory.push({ + role: 'system', internal: true, + content: `[Result of investigate_files "${relPath}"] 읽을 수 있는 텍스트 파일이 없다. 제외: ${result.skipped.slice(0, 10).join(', ') || '—'}`, + }); + continue; + } + + ctx.chatHistory.push({ role: 'system', internal: true, content: formatInvestigationNotes(result, focus) }); + report.push(`🔎 Investigated: ${relPath} — 파일 ${result.notes.length}개 정독 (캐시 ${result.cacheHits} · LLM ${result.llmCalls} · 제외 ${result.skipped.length})`); + logInfo('Investigation pipeline 완료.', { relPath, files: result.notes.length, cacheHits: result.cacheHits, llmCalls: result.llmCalls }); + } catch (err: any) { + report.push(`❌ Investigate failed: ${relPath} — ${err?.message ?? err}`); + logError('Investigation pipeline 실패.', { relPath, error: err?.message ?? String(err) }); + } + } +} diff --git a/src/agent/llm/streamChatOnce.ts b/src/agent/llm/streamChatOnce.ts index f51c23b..70f0da7 100644 --- a/src/agent/llm/streamChatOnce.ts +++ b/src/agent/llm/streamChatOnce.ts @@ -41,6 +41,12 @@ export async function streamChatOnce(deps: StreamChatOnceDeps, params: { const post = (token: string) => { if (params.postLiveDeltas && token) { const visible = liveFilter.push(token); + // [v2.2.310] LM Studio LSEP 구분자 — 마커 이전에 라이브로 나간 추론 텍스트를 + // streamReplace 로 화면에서 걷어내고, 마커 이후(실제 답변)부터 다시 시작. + if (liveFilter.consumeSeparatorSignal()) { + deps.getWebview()?.postMessage({ type: 'streamReplace', value: visible }); + return; + } if (visible) deps.getWebview()?.postMessage({ type: 'streamChunk', value: visible }); } }; diff --git a/src/agent/postAnswerHooks/index.ts b/src/agent/postAnswerHooks/index.ts index 5bd5da3..0abb581 100644 --- a/src/agent/postAnswerHooks/index.ts +++ b/src/agent/postAnswerHooks/index.ts @@ -26,6 +26,8 @@ import { appendSuccessPattern } from '../../intelligence/skillScore'; import { getConfig } from '../../config'; import { detectReimplementedProposals, formatReimplementationFooter } from '../../extension/featureConceptMap'; import { isSelfAssessRequest, isAboutSelf } from '../../lib/contextBuilders/selfAssessContext'; +import { detectHollowInvestigation, formatHollowInvestigationFooter } from '../../intelligence/investigationPipeline'; +import { logInfo } from '../../utils'; const devilRebuttalHook: PostAnswerHook = { id: 'devil-rebuttal', @@ -86,6 +88,23 @@ const termValidatorHook: PostAnswerHook = { }, }; +/** + * [v2.2.309] Hollow Investigation — "목록만 조회(list_files)하고 읽지는 않은 채(read/ + * investigate 0회) 파일 여러 개를 서술한 답변" 감지. 소형 모델의 파일명 상상 조사를 + * 사용자에게 즉시 노출한다 (결정론적, LLM 호출 없음). + */ +const hollowInvestigationHook: PostAnswerHook = { + id: 'hollow-investigation', + runAsync: false, + run(ctx: PostAnswerHookContext): void { + if (!ctx.assistantAnswer?.trim()) return; + const { hollow, fileMentions } = detectHollowInvestigation(ctx.assistantAnswer, ctx.actionStats); + if (!hollow) return; + ctx.getWebview()?.postMessage({ type: 'streamChunk', value: formatHollowInvestigationFooter(fileMentions) }); + logInfo('Hollow Investigation 감지 — list만 하고 read 없이 파일 서술.', { files: fileMentions }); + }, +}; + const requirementCoverageHook: PostAnswerHook = { id: 'requirement-coverage', runAsync: false, @@ -253,6 +272,7 @@ export const POST_ANSWER_HOOKS: PostAnswerHook[] = [ devilRebuttalHook, postHocSelfCheckHook, termValidatorHook, + hollowInvestigationHook, requirementCoverageHook, confidenceEscalationHook, criticLoopHook, diff --git a/src/agent/postAnswerHooks/types.ts b/src/agent/postAnswerHooks/types.ts index a186321..5aeb329 100644 --- a/src/agent/postAnswerHooks/types.ts +++ b/src/agent/postAnswerHooks/types.ts @@ -29,6 +29,8 @@ export interface PostAnswerHookContext { selfCheckSources: Array<{ title: string; excerpt: string }>; /** Confidence Engine 검색 신호 (Phase 2). memoryContext 가 채움 — 검색 안 돈 turn 은 null. */ confidenceSignals?: import('../../intelligence/confidenceEngine').RetrievalConfidenceSignals | null; + /** [v2.2.309] 이번 turn 액션 실행 통계 — Hollow Investigation(헛조사) 감지용. */ + actionStats?: { reads: number; lists: number; investigates: number } | null; /** Devil Agent 가 호출 — non-streaming LLM. */ callNonStreaming: (params: any) => Promise<{ text: string; stopReason?: string }>; /** Abort signal accessor. */ diff --git a/src/config.ts b/src/config.ts index c00ab52..2ef55f3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -227,6 +227,12 @@ export interface IAgentConfig { * 결과는 답변 아래 footer 한 줄로 표시. */ selfCheckEnabled: boolean; + /** + * [v2.2.309] 조사형 요청("~폴더 조사해줘") 전용 모델 오버라이드. + * 예: "claude-code:sonnet" — 소형 로컬 모델의 lazy tool-use(파일명만 보고 서술) + * 할루시네이션 대책. 빈 문자열이면 비활성 (현재 선택 모델 그대로). + */ + investigationModel: string; /** Self-check 전용 모델 ID. 비면 defaultModel. 빠른 작은 모델 권장. */ selfCheckModel: string; /** Self-check LLM 호출 타임아웃 (초). 기본 6. */ @@ -576,6 +582,7 @@ export function getConfig(): IAgentConfig { intentClarificationEnabled: cfg.get('intentClarificationEnabled', true), intentClarificationStrictness: (cfg.get('intentClarificationStrictness', 'medium') as 'low' | 'medium' | 'high') || 'medium', citationTraceEnabled: cfg.get('citationTraceEnabled', true), + investigationModel: (cfg.get('investigationModel', '') || '').trim(), selfCheckEnabled: cfg.get('selfCheckEnabled', false), selfCheckModel: cfg.get('selfCheckModel', '') || '', selfCheckTimeoutSec: Math.max(1, Math.min(60, cfg.get('selfCheckTimeoutSec', 6))), diff --git a/src/extension/evalCommands.ts b/src/extension/evalCommands.ts index ab92350..11bdf4d 100644 --- a/src/extension/evalCommands.ts +++ b/src/extension/evalCommands.ts @@ -29,6 +29,7 @@ import { computeSkillScores, formatSkillScoresMarkdown, loadSuccessPatterns, for import { runResearch, formatProposalMarkdown } from '../intelligence/researchAgent'; import type { ExistingKnowledgeRef } from '../intelligence/knowledgeValidation'; import { loadQueue, saveQueue, mergeNeedsIntoQueue, formatQueueMarkdown, LEARNING_QUEUE_REL_PATH } from '../intelligence/learningQueue'; +import { loadStandingRules, buildStandingRulesBlock, STANDING_RULES_REL_PATH } from '../intelligence/correctionLoop'; /** * 검색 평가 명령 묶음 (Phase 1-나). @@ -43,6 +44,7 @@ export function registerEvalCommands(): vscode.Disposable[] { vscode.commands.registerCommand('g1nation.embeddings.backfill', backfillEmbeddingsCommand), vscode.commands.registerCommand('g1nation.eval.tasks', runTaskEvalCommand), vscode.commands.registerCommand('g1nation.growth.report', growthReportCommand), + vscode.commands.registerCommand('g1nation.growth.standingRules', standingRulesCommand), vscode.commands.registerCommand('g1nation.growth.learningQueue', learningQueueCommand), vscode.commands.registerCommand('g1nation.knowledge.decayAudit', decayAuditCommand), vscode.commands.registerCommand('g1nation.research.runQueue', researchRunQueueCommand), @@ -307,6 +309,57 @@ async function runTaskEvalCommand(): Promise { } /** 성장 리포트 — Reflection 기록(.astra/growth/reflections.jsonl)의 주별 추이 + 반복 실수 Top. */ +/** + * Standing Rules 열람 — "주입엔 항상 도달 증거를" 원칙의 확인 창구. + * 현재 매 턴 주입 중인 상시 행동 규칙과 실제 주입 블록 원문을 파일로 열어준다. + * 규칙 수정/삭제는 standing-rules.json 을 직접 편집 (Permission Based Learning). + */ +async function standingRulesCommand(): Promise { + try { + const brain = getActiveBrainProfile(); + if (!brain?.localBrainPath || !fs.existsSync(brain.localBrainPath)) { + vscode.window.showErrorMessage('활성 두뇌 폴더를 찾을 수 없습니다.'); + return; + } + const rules = loadStandingRules(brain.localBrainPath); + const active = rules.filter(r => r.status === 'active'); + const retired = rules.filter(r => r.status !== 'active'); + const block = buildStandingRulesBlock(rules); + const md = [ + '# 상시 행동 규칙 (Standing Rules)', + '', + `사용자 지적("또 ~하네", "하지 말라고 했잖아")이 명령형 규칙으로 정규화되어 여기 쌓이고,`, + `**매 턴 시스템 프롬프트 보호 구역에 주입**됩니다 — 세션을 새로 열어도 유지됩니다.`, + `수정/삭제: \`${STANDING_RULES_REL_PATH}\` 파일을 직접 편집하세요.`, + '', + `## 활성 규칙 ${active.length}개`, + '', + ...(active.length + ? active.sort((a, b) => b.hits - a.hits).map(r => + `- **${r.rule}** — 지적 ${r.hits}회, 최근 ${r.lastHitAt.slice(0, 10)}\n - 원 발화: "${r.origin}"`) + : ['(아직 없음 — 행동/형식을 지적하면 자동으로 쌓입니다)']), + '', + `## 은퇴 규칙 ${retired.length}개`, + ...(retired.length ? retired.map(r => `- ${r.rule} (지적 ${r.hits}회)`) : ['(없음)']), + '', + '## 실제 주입 블록 (도달 증거)', + '', + '```', + block || '(활성 규칙이 없어 이번 턴에는 주입되지 않음)', + '```', + '', + ].join('\n'); + const reportPath = path.join(brain.localBrainPath, '.astra', 'growth', 'standing-rules-report.md'); + fs.mkdirSync(path.dirname(reportPath), { recursive: true }); + fs.writeFileSync(reportPath, md, 'utf8'); + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(reportPath)); + await vscode.window.showTextDocument(doc, { preview: false }); + } catch (err: any) { + logError('Standing rules command failed.', { error: err?.message || String(err) }); + vscode.window.showErrorMessage(`상시 규칙 열람 실패: ${err?.message ?? err}`); + } +} + async function growthReportCommand(): Promise { try { const brain = getActiveBrainProfile(); diff --git a/src/intelligence/correctionLoop.ts b/src/intelligence/correctionLoop.ts index 3566155..c686693 100644 --- a/src/intelligence/correctionLoop.ts +++ b/src/intelligence/correctionLoop.ts @@ -178,8 +178,21 @@ export async function captureCorrection(opts: { wrongAnswer: string; correction: string; llm: { baseUrl: string; model: string }; + /** 행동/스타일 지적으로 감지됨 (looksLikeBehaviorComplaint) — Standing Rule 파생 대상. */ + behaviorHint?: boolean; }): Promise { const { tag, title } = await classifyCorrection(opts.question, opts.wrongAnswer, opts.correction, opts.llm); + + // [④ Standing Rules] 행동/스타일 교정은 명령형 규칙으로 정규화해 즉시 영속화 — + // 다음 턴부터(그리고 새 세션에서도) 매 턴 주입된다. 태그가 형식오류/지시불이행 + // 이거나 감지 단계에서 행동 지적 패턴(behaviorHint)에 걸린 경우만. + if (opts.behaviorHint || tag === '형식오류' || tag === '지시불이행') { + try { + const rule = await deriveStandingRule(opts.wrongAnswer, opts.correction, opts.llm); + if (rule) upsertStandingRule(opts.brainPath, rule, opts.correction); + } catch { /* 규칙 파생 실패가 레슨 캡처를 막지 않음 */ } + } + const now = new Date(); const c: CorrectionCase = { ts: now.toISOString(), @@ -380,3 +393,165 @@ export function formatRegressionReport(results: RegressionResult[], meta: { date } return lines.join('\n'); } + +// ── ④ Standing Rules — 세션을 넘는 상시 행동 규칙 ──────────────────────────── +// +// 배경: 기존 파이프라인(①~③)은 *사실 정정*을 영속화하지만, "결론 수정 문구 쓰지 마" +// 같은 *행동/스타일 교정*은 (a) 감지 패턴에 안 걸리고 (b) 레슨은 유사도 검색이라 +// 질문 내용과 무관한 상시 규칙이 주입되지 않아 새 세션에서 증발했다 (v2.2.308). +// +// 해법: 행동 교정을 명령형 1줄 규칙으로 정규화해 파일에 영속화하고, 검색과 무관하게 +// 매 턴 behavior-constraints 보호 구역에 주입한다. 상한(10개)과 재지적 카운트로 +// 프롬프트 오염을 막고, 파일은 사람이 열어 수정/삭제 가능 (Permission Based Learning). + +export const STANDING_RULES_REL_PATH = path.join('.astra', 'growth', 'standing-rules.json'); +export const MAX_ACTIVE_STANDING_RULES = 10; + +export interface StandingRule { + id: string; // 정규화된 규칙의 해시 + rule: string; // 명령형 1줄 (모든 답변에 적용) + origin: string; // 규칙을 만든 원 발화 (사용자 지적) + createdAt: string; // ISO + lastHitAt: string; // 마지막 재지적 ISO + hits: number; // 지적 누계 (1 = 최초) — 클수록 은퇴 대상에서 멂 + status: 'active' | 'retired'; +} + +// 행동/스타일 지적 감지 — 사실 정정(looksLikeCorrection)과 별개의 그물. +// "또 ~하네", "하지 말라고 했잖아", "몇 번을 말해" 같은 반복 지적이 핵심 신호다. +const BEHAVIOR_COMPLAINT_RE = [ + /^\s*또\s+.{1,80}(하네|하잖|이네|했네|라고\s*(썼|하)|나왔|그러)/, // "또 결론 수정이라고 썼네" + /(하지\s*말라(고|니까)|말라고\s*했(잖|는데)|말랬잖)/, // "하지 말라고 했잖아" + /(몇\s*번(을|이나)?\s*(말|얘기|이야기)|매번\s*똑같)/, // "몇 번을 말해" + /(왜\s*자꾸|자꾸\s*왜|계속\s*(그러|반복)|또\s*반복)/, // "왜 자꾸 그래" + /(붙이지\s*마|쓰지\s*마|넣지\s*마|빼\s*달라고|빼라고)/, // 출력 형식 지시 +]; + +export function looksLikeBehaviorComplaint(prompt: string): boolean { + const t = (prompt || '').trim(); + if (t.length < 4 || t.length > 1500) return false; + return BEHAVIOR_COMPLAINT_RE.some(re => re.test(t)); +} + +// LLM 으로 지적 → 명령형 규칙 정규화. 실패 시 휴리스틱 fallback. +const RULE_SYSTEM = [ + '너는 AI 행동 규칙 작성기다. 사용자가 AI 답변의 습관/형식/태도를 지적했다.', + '이 지적을 앞으로 모든 답변에 적용할 명령형 규칙 한 줄(한국어, 70자 이내)로 요약하라.', + '내용 지식(사실)이 아니라 행동 규칙만 만든다. 행동 규칙으로 만들 수 없으면 빈 문자열.', + '반드시 JSON 한 줄만 출력: {"rule":"<규칙 또는 빈 문자열>"}', +].join('\n'); + +export async function deriveStandingRule( + wrongAnswer: string, + correction: string, + llm: { baseUrl: string; model: string }, +): Promise { + const fallback = (): string => { + // "~하지 마/말라" 문장이 있으면 그 문장 자체가 규칙에 가장 가깝다. + const m = correction.match(/[^.!?\n]*(?:하지\s*마|말라|쓰지\s*마|붙이지\s*마|넣지\s*마)[^.!?\n]*/); + const base = (m ? m[0] : correction).trim().replace(/\s+/g, ' ').slice(0, 70); + return base ? `${base} — 이 지적을 반복하게 하지 않는다` : ''; + }; + try { + const user = [ + `[AI 답변 발췌] ${wrongAnswer.slice(0, MAX_FIELD_CHARS)}`, + `[사용자 지적] ${correction.slice(0, MAX_FIELD_CHARS)}`, + ].join('\n'); + const raw = await simpleChatCompletion(RULE_SYSTEM, user, { + baseUrl: llm.baseUrl, model: llm.model, temperature: 0.1, maxTokens: 120, timeoutMs: 30000, + }); + const m = raw.match(/\{[\s\S]*?\}/); + if (!m) return fallback(); + const rule = String(JSON.parse(m[0])?.rule || '').trim().replace(/\s+/g, ' ').slice(0, 90); + return rule || fallback(); + } catch { + return fallback(); + } +} + +function normalizeRuleText(rule: string): string { + return rule.toLowerCase().replace(/[^a-z0-9가-힣]+/g, ' ').trim(); +} + +function ruleId(rule: string): string { + // 의존성 없는 짧은 해시 (djb2) — 파일에서 사람이 규칙을 찾을 때 쓰는 식별자. + const s = normalizeRuleText(rule); + let h = 5381; + for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; + return 'sr-' + h.toString(36); +} + +// 같은 규칙인지 — 토큰 자카드 유사도 (LLM 없이, 재지적 시 hits 만 올리기 위함). +export function isSameRule(a: string, b: string): boolean { + const ta = new Set(normalizeRuleText(a).split(' ').filter(w => w.length >= 2)); + const tb = new Set(normalizeRuleText(b).split(' ').filter(w => w.length >= 2)); + if (ta.size === 0 || tb.size === 0) return false; + let inter = 0; + for (const w of ta) if (tb.has(w)) inter++; + return inter / (ta.size + tb.size - inter) >= 0.6; +} + +export function loadStandingRules(brainPath: string): StandingRule[] { + try { + const file = path.join(brainPath, STANDING_RULES_REL_PATH); + if (!fs.existsSync(file)) return []; + const o = JSON.parse(fs.readFileSync(file, 'utf8')); + return Array.isArray(o) ? o.filter(r => r && typeof r.rule === 'string') : []; + } catch { + return []; + } +} + +export function saveStandingRules(brainPath: string, rules: StandingRule[]): boolean { + try { + const file = path.join(brainPath, STANDING_RULES_REL_PATH); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(rules, null, 2) + '\n', 'utf8'); + return true; + } catch { + return false; + } +} + +/** + * 규칙 추가/재지적 반영. 중복이면 hits++ (은퇴했던 규칙도 부활), 신규면 추가. + * active 가 상한을 넘으면 hits 낮고 오래된 것부터 은퇴. 저장까지 수행. + */ +export function upsertStandingRule(brainPath: string, rule: string, origin: string, nowIso?: string): StandingRule[] { + const now = nowIso || new Date().toISOString(); + const rules = loadStandingRules(brainPath); + const existing = rules.find(r => isSameRule(r.rule, rule)); + if (existing) { + existing.hits += 1; + existing.lastHitAt = now; + existing.status = 'active'; // 재지적 = 아직 필요한 규칙 + } else { + rules.push({ + id: ruleId(rule), rule: rule.trim(), origin: origin.slice(0, 200), + createdAt: now, lastHitAt: now, hits: 1, status: 'active', + }); + } + // 상한 초과 시 은퇴: hits 오름차순 → lastHitAt 오래된 순. + const active = rules.filter(r => r.status === 'active'); + if (active.length > MAX_ACTIVE_STANDING_RULES) { + const toRetire = active + .sort((a, b) => (a.hits - b.hits) || a.lastHitAt.localeCompare(b.lastHitAt)) + .slice(0, active.length - MAX_ACTIVE_STANDING_RULES); + for (const r of toRetire) r.status = 'retired'; + } + saveStandingRules(brainPath, rules); + return rules; +} + +/** 매 턴 주입 블록 — 검색과 무관한 상시 규칙. active 없으면 ''. */ +export function buildStandingRulesBlock(rules: StandingRule[]): string { + const active = rules.filter(r => r.status === 'active') + .sort((a, b) => (b.hits - a.hits) || b.lastHitAt.localeCompare(a.lastHitAt)) + .slice(0, MAX_ACTIVE_STANDING_RULES); + if (active.length === 0) return ''; + const lines = ['[상시 행동 규칙 — 사용자 지적으로 축적됨. 모든 답변에서 반드시 지켜라]']; + for (const r of active) { + lines.push(`- ${r.rule}${r.hits >= 2 ? ` (지적 ${r.hits}회)` : ''}`); + } + return lines.join('\n'); +} diff --git a/src/intelligence/investigationPipeline.ts b/src/intelligence/investigationPipeline.ts new file mode 100644 index 0000000..f851d02 --- /dev/null +++ b/src/intelligence/investigationPipeline.ts @@ -0,0 +1,228 @@ +/** + * Investigation Pipeline — "파일명만 보고 상상하는 조사" 를 구조적으로 차단. (v2.2.309) + * + * 문제: 소형 로컬 모델은 로 파일명 목록을 받으면 을 부르지 + * 않고 이름에서 내용을 상상해 조사 결과를 지어낸다 (lazy tool-use 할루시네이션). + * + * 해법 3중: + * ① 증거 게이트 — 조사형 요청 감지 시 "읽지 않은 파일 서술 금지" 행동 제약 주입 + * (buildInvestigationGateBlock → memoryContext behavior-constraints 채널). + * ② Map-Reduce — 액션이 폴더의 파일을 *코드가 하나씩 강제로 + * 읽어* 파일별 추출 노트 생성(Map) 후 병합 노트를 컨텍스트에 주입(Reduce 는 + * 본 턴 모델이 노트 기반으로 수행). 모델에게 "읽을지" 선택권 자체가 없다. + * ③ 온디맨드 지식화 캐시 — 파일 내용 해시를 키로 노트를 .astra/cache/investigate 에 + * 저장. 같은 내용은 재조사 시 LLM 호출 없이 재사용, 파일이 바뀐 것만 갱신. + * 전량 위키화 없이 조사가 닿는 곳만 점진적으로 지식화된다. + * + * 노트는 질문 비의존(파일의 목적/핵심 사실/관계)으로 뽑는다 — 캐시 재사용률을 + * 최대화하기 위함 (질문 특화 해석은 Reduce 단계의 본 턴 모델 몫). + */ +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import { EXCLUDED_DIRS } from '../config'; + +// ── 상수 ──────────────────────────────────────────────────────────────────── + +export const INVESTIGATE_CACHE_REL_DIR = path.join('.astra', 'cache', 'investigate'); +export const MAX_INVESTIGATION_FILES = 40; // 초과분은 skipped 로 보고 (조용한 누락 금지) +export const MAX_CHARS_PER_FILE = 20_000; // 파일당 읽기 상한 (소형 모델 충실 구간) + +const TEXT_EXTS = new Set([ + '.md', '.txt', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json', '.jsonl', + '.html', '.css', '.py', '.yml', '.yaml', '.csv', '.xml', '.sh', '.bat', '.ps1', '.sql', '.env', +]); + +export interface InvestigationFileNote { + relPath: string; + note: string; + fromCache: boolean; +} + +export interface InvestigationResult { + dir: string; + notes: InvestigationFileNote[]; + /** 상한·확장자·읽기실패로 제외된 파일 (조용한 누락 방지용 보고). */ + skipped: string[]; + cacheHits: number; + llmCalls: number; +} + +// ── ① 조사형 요청 감지 + 증거 게이트 블록 ─────────────────────────────────── + +const INVESTIGATE_VERB_RE = /(조사|분석|검토|파악|리뷰|정리)\s*(해|을|를|좀|부탁|해줘|해주|하고|해서)/; +const LOCAL_TARGET_RE = /(폴더|파일|디렉토리|프로젝트|코드|문서|리소스|소스)|([A-Za-z]:\\|\.{0,2}\/[\w가-힣])/; + +/** 로컬 자료를 대상으로 한 조사형 요청인지 (증거 게이트·모델 라우팅 공용 감지기). */ +export function isLocalInvestigationPrompt(prompt: string): boolean { + const t = (prompt || '').trim(); + if (t.length < 6) return false; + return INVESTIGATE_VERB_RE.test(t) && LOCAL_TARGET_RE.test(t); +} + +/** 조사형 요청일 때만 행동 제약 블록 반환, 아니면 ''. */ +export function buildInvestigationGateBlock(prompt: string): string { + if (!isLocalInvestigationPrompt(prompt)) return ''; + return [ + '[조사 증거 게이트 — 로컬 자료 조사 시 필수]', + '- 파일명·폴더 목록만 보고 내용을 서술하는 것 금지. 내용에 대한 모든 주장은 실제로 읽은 것만 쓴다.', + '- 폴더 단위 조사는 한 번으로 위임하라 — 각 파일을 자동으로 읽어 파일별 노트를 만들어준다. 개별 파일은 .', + '- 읽지 않은 파일을 언급해야 하면 이름 뒤에 "(미확인)"을 붙여라.', + '- 조사 결과의 각 주장 끝에 근거 파일 경로를 붙여라 (예: [src/agent.ts]). 근거 없는 주장은 쓰지 않는다.', + ].join('\n'); +} + +// ── ②③ 파일 수집 → 노트 추출(캐시) → 병합 ────────────────────────────────── + +export function collectInvestigationFiles( + rootPath: string, + relDir: string, + maxFiles = MAX_INVESTIGATION_FILES, +): { files: Array<{ relPath: string; absPath: string }>; skipped: string[] } { + const files: Array<{ relPath: string; absPath: string }> = []; + const skipped: string[] = []; + const baseAbs = path.resolve(rootPath, relDir || '.'); + + const walk = (abs: string, rel: string): void => { + let entries: fs.Dirent[]; + try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch { skipped.push(rel + '/ (읽기 실패)'); return; } + // 결정적 순서 (테스트·캐시 재현성) + entries.sort((a, b) => a.name.localeCompare(b.name)); + for (const e of entries) { + if (e.name.startsWith('.') || EXCLUDED_DIRS.has(e.name)) continue; + const childAbs = path.join(abs, e.name); + const childRel = rel ? `${rel}/${e.name}` : e.name; + if (e.isDirectory()) { walk(childAbs, childRel); continue; } + if (!TEXT_EXTS.has(path.extname(e.name).toLowerCase())) { skipped.push(childRel + ' (비텍스트)'); continue; } + if (files.length >= maxFiles) { skipped.push(childRel + ' (상한 초과)'); continue; } + files.push({ relPath: childRel, absPath: childAbs }); + } + }; + + const st = fs.existsSync(baseAbs) ? fs.statSync(baseAbs) : null; + if (st?.isFile()) { + files.push({ relPath: relDir, absPath: baseAbs }); + } else if (st?.isDirectory()) { + walk(baseAbs, ''); + } + return { files, skipped }; +} + +const EXTRACT_SYSTEM = [ + '너는 파일 내용 분석기다. 제공된 파일 *내용*만 근거로 사실을 추출하라. 내용에 없는 것을 지어내지 말 것.', + '출력 형식 (개조식, 명사형 종결):', + '목적: <이 파일이 무엇인지 한 줄>', + '핵심:', + '- <핵심 사실/설정/결정 3~8개 — 수치·이름·경로 포함>', + '관계: <다른 파일/모듈과의 연결이 내용에 명시되어 있으면, 없으면 "—">', +].join('\n'); + +function cacheKey(content: string): string { + return crypto.createHash('sha1').update(content).digest('hex'); +} + +function cachePathFor(rootPath: string, key: string): string { + return path.join(rootPath, INVESTIGATE_CACHE_REL_DIR, `${key}.json`); +} + +/** + * 폴더(또는 단일 파일)를 강제로 읽어 파일별 노트 생성. llmCall 은 주입식(테스트 용이). + * 캐시: sha1(내용) → 노트. 내용이 같으면 질문이 달라도 재사용 (질문 비의존 노트). + */ +export async function runInvestigation(opts: { + rootPath: string; + relDir: string; + llmCall: (system: string, user: string) => Promise; + maxFiles?: number; + onProgress?: (done: number, total: number, relPath: string) => void; +}): Promise { + const { files, skipped } = collectInvestigationFiles(opts.rootPath, opts.relDir, opts.maxFiles ?? MAX_INVESTIGATION_FILES); + const result: InvestigationResult = { dir: opts.relDir, notes: [], skipped, cacheHits: 0, llmCalls: 0 }; + + for (let i = 0; i < files.length; i++) { + const f = files[i]; + let content: string; + try { content = fs.readFileSync(f.absPath, 'utf8').slice(0, MAX_CHARS_PER_FILE); } + catch { result.skipped.push(f.relPath + ' (읽기 실패)'); continue; } + if (!content.trim()) { result.skipped.push(f.relPath + ' (빈 파일)'); continue; } + + const key = cacheKey(content); + const cacheFile = cachePathFor(opts.rootPath, key); + let note: string | null = null; + let fromCache = false; + try { + if (fs.existsSync(cacheFile)) { + const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); + if (cached && typeof cached.note === 'string' && cached.note.trim()) { + note = cached.note; + fromCache = true; + result.cacheHits++; + } + } + } catch { /* 캐시 손상 → 재추출 */ } + + if (note === null) { + try { + note = (await opts.llmCall(EXTRACT_SYSTEM, `[파일] ${f.relPath}\n\n${content}`)).trim(); + result.llmCalls++; + try { + fs.mkdirSync(path.dirname(cacheFile), { recursive: true }); + fs.writeFileSync(cacheFile, JSON.stringify({ relPath: f.relPath, note, ts: new Date().toISOString() }, null, 2), 'utf8'); + } catch { /* 캐시 저장 실패는 무해 */ } + } catch (e: any) { + result.skipped.push(f.relPath + ` (추출 실패: ${e?.message ?? e})`); + continue; + } + } + result.notes.push({ relPath: f.relPath, note, fromCache }); + opts.onProgress?.(i + 1, files.length, f.relPath); + } + return result; +} + +/** 병합 노트 — 액션이 chatHistory 에 internal system 메시지로 주입하는 본문. */ +export function formatInvestigationNotes(result: InvestigationResult, focus?: string): string { + const lines: string[] = []; + lines.push(`[Result of investigate_files "${result.dir}"] — 아래는 각 파일을 *실제로 읽고* 추출한 노트다 (${result.notes.length}개 파일, 캐시 재사용 ${result.cacheHits}건).`); + if (focus?.trim()) lines.push(`조사 목적: ${focus.trim()}`); + lines.push('이 노트를 근거로 답하되, 각 주장 끝에 근거 파일 경로를 붙여라. 노트에 없는 내용은 "노트에 근거 없음"으로 표기.'); + lines.push(''); + for (const n of result.notes) { + lines.push(`── [${n.relPath}]`); + lines.push(n.note); + lines.push(''); + } + if (result.skipped.length) { + lines.push(`(미확인 파일 ${result.skipped.length}건 — 필요 시 로 개별 확인: ${result.skipped.slice(0, 10).join(', ')}${result.skipped.length > 10 ? ' 외' : ''})`); + } + return lines.join('\n'); +} + +// ── Hollow Investigation 감지 (postAnswerHook 용 순수 함수) ────────────────── + +export interface TurnActionStats { reads: number; lists: number; investigates: number } + +const FILE_MENTION_RE = /[\w가-힣.\-]{2,}\.(?:md|txt|ts|tsx|js|jsx|json|html|css|py|pdf|docx?|xlsx?|pptx?|ya?ml|csv)\b/gi; + +/** + * "목록만 보고(list) 읽지는 않았는데(read/investigate 0) 파일들을 서술" = 헛조사. + * 파일명 언급 2개 이상일 때만 (1개는 단순 지칭일 수 있음 — 오탐 방지). + */ +export function detectHollowInvestigation( + answer: string, + stats: TurnActionStats | null | undefined, +): { hollow: boolean; fileMentions: string[] } { + if (!stats || stats.lists === 0) return { hollow: false, fileMentions: [] }; + if (stats.reads > 0 || stats.investigates > 0) return { hollow: false, fileMentions: [] }; + const mentions = [...new Set(Array.from((answer || '').matchAll(FILE_MENTION_RE), m => m[0]))]; + return { hollow: mentions.length >= 2, fileMentions: mentions.slice(0, 8) }; +} + +export function formatHollowInvestigationFooter(fileMentions: string[]): string { + return [ + '', + '---', + `⚠️ **헛조사 감지** — 이 답변은 파일 목록만 조회하고 실제 내용은 읽지 않은 채 파일 ${fileMentions.length}건(${fileMentions.slice(0, 4).join(', ')}${fileMentions.length > 4 ? ' 외' : ''})을 서술했습니다.`, + '내용 검증이 필요하면 다시 요청해 주세요 — 다음 턴에서 ``로 각 파일을 실제로 읽고 답합니다.', + ].join('\n'); +} diff --git a/src/lib/contextBuilders/liveReasoningFilter.ts b/src/lib/contextBuilders/liveReasoningFilter.ts index ae6fea0..d881165 100644 --- a/src/lib/contextBuilders/liveReasoningFilter.ts +++ b/src/lib/contextBuilders/liveReasoningFilter.ts @@ -27,6 +27,28 @@ const TAG_OPENERS: { literal: string; closer: RegExp }[] = [ { literal: '', closer: /<\/analysis>/i }, ]; +/** + * [v2.2.310] LM Studio 합성 추론 구분자 — 일부 모델/버전 조합에서 LM Studio 가 + * 추론 텍스트를 *여는 마커 없이* content 스트림 맨 앞에 흘리고, 실제 답변과의 + * 경계에 이 합성 마커를 삽입한다: + * `<추론 텍스트>__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END___<답변>` + * 여는 마커가 없어 앞부분을 미리 숨길 수는 없다 — 대신 END 마커를 만나는 순간 + * `separatorSeen` 신호를 세워, 호출자(스트림 루프)가 streamReplace 로 그때까지 + * 표시된 추론을 화면에서 걷어내게 한다. START 변형이 오면 일반 opener 처럼 숨긴다. + */ +const LSEP_CONST_PREFIX = '__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_'; +const LSEP_END_RE = /__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_[0-9a-f]{6,64}__/i; +const LSEP_START_RE = /__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_START_[0-9a-f]{6,64}__/i; + +/** tail 이 LSEP 마커의 접두사가 될 가능성 (emit 보류 판단, '<' 계열과 별도). */ +function couldBeLsepPrefix(tail: string): boolean { + if (!tail.startsWith('_')) return false; + if (tail.length <= LSEP_CONST_PREFIX.length) return LSEP_CONST_PREFIX.startsWith(tail); + if (!tail.startsWith(LSEP_CONST_PREFIX)) return false; + // 상수부 이후: (START|END 일부) + '_' + hex + 닫는 '__' 진행 중이면 보류 + return /^[A-Z]{0,5}_?[0-9a-f]{0,64}_{0,2}$/i.test(tail.slice(LSEP_CONST_PREFIX.length)); +} + /** Harmony 채널 마커 — `<|channel|>` 외에 `` / `<|channel>` 변형도 (sanitize 와 동일). */ const CHANNEL_MARKER = /<\|?channel\|?>/i; const HIDDEN_CHANNEL_WORD = /^(?:thought|analysis|commentary|reasoning)\b/i; @@ -58,6 +80,15 @@ export class LiveReasoningFilter { private pending = ''; /** hiddenTag 모드에서 기다리는 닫힘 패턴. */ private closer: RegExp | null = null; + /** [v2.2.310] LSEP END 마커 감지 신호 — 호출자가 streamReplace 로 화면 리셋. */ + private separatorSeen = false; + + /** LSEP 구분자를 만났는지 (1회성 소비 — 읽으면 리셋). */ + consumeSeparatorSignal(): boolean { + const v = this.separatorSeen; + this.separatorSeen = false; + return v; + } /** * 토큰을 누적하고, 화면에 내보내도 안전한 텍스트 델타를 반환한다. @@ -70,6 +101,29 @@ export class LiveReasoningFilter { // 상태 전이가 한 토큰 안에서 여러 번 일어날 수 있어 (열림+닫힘 동시 도착) 루프. for (;;) { if (this.mode === 'visible') { + // [v2.2.310] LSEP 마커 — START 가 END 보다 먼저면 정상 블록(숨김), + // END 만 단독 등장(여는 마커 없이 추론이 먼저 흐른 케이스)이면 리셋 신호. + const sepStart = this.pending.match(LSEP_START_RE); + const sepEnd = this.pending.match(LSEP_END_RE); + const startIdx = sepStart?.index ?? -1; + const endIdx = sepEnd?.index ?? -1; + if (startIdx >= 0 && (endIdx < 0 || startIdx < endIdx)) { + // START 변형 — 일반 opener 처럼 END 까지 숨김. + out += this.pending.slice(0, startIdx); + this.pending = this.pending.slice(startIdx + sepStart![0].length); + this.mode = 'hiddenTag'; + this.closer = LSEP_END_RE; + continue; + } + if (endIdx >= 0) { + // 여는 마커 없이 흘러온 추론의 끝 — 이미 화면에 나간 앞부분은 되돌릴 + // 수 없으니 신호만 세우고(호출자가 streamReplace), 잔여 추론+마커 폐기. + this.pending = this.pending.slice(endIdx + sepEnd![0].length); + this.separatorSeen = true; + out = ''; // 마커 앞 잔여분(추론)은 방출 취소 + continue; + } + const hit = this.findEarliestOpener(this.pending); if (hit) { out += this.pending.slice(0, hit.index); @@ -127,7 +181,8 @@ export class LiveReasoningFilter { continue; } // 닫힘이 토큰 경계에 걸칠 수 있으니 꼬리만 남기고 버린다. - this.pending = this.pending.slice(-24); + // (LSEP END 마커는 ~85자라 넉넉히 보존 — v2.2.310) + this.pending = this.pending.slice(-100); return out; } @@ -179,6 +234,12 @@ export class LiveReasoningFilter { const tail = s.slice(s.length - k); if (tail.startsWith('<') && couldBeOpenerPrefix(tail)) return k; } + // [v2.2.310] LSEP 마커('_' 시작, 최대 ~110자) 접두사 보류 — 토큰 경계 분절 대응. + const maxLsep = Math.min(s.length, 110); + for (let k = 1; k <= maxLsep; k++) { + const tail = s.slice(s.length - k); + if (tail.startsWith('_') && couldBeLsepPrefix(tail)) return k; + } return 0; } } diff --git a/src/lib/contextBuilders/memoryContext.ts b/src/lib/contextBuilders/memoryContext.ts index 25b6687..bcf5cd3 100644 --- a/src/lib/contextBuilders/memoryContext.ts +++ b/src/lib/contextBuilders/memoryContext.ts @@ -8,7 +8,8 @@ import type { RetrievalOrchestrator } from '../../retrieval'; import { buildLessonChecklistBlock } from '../../retrieval/lessonHelpers'; import { embedQuery, embedTexts } from '../../retrieval/embeddings'; import { backfillBrainEmbeddings, backfillBrainChunkEmbeddings } from '../../retrieval/brainIndex'; -import { loadWeaknessProfile, buildSelfReviewBlock, registerKnowledgeGap } from '../../intelligence/correctionLoop'; +import { loadWeaknessProfile, buildSelfReviewBlock, registerKnowledgeGap, loadStandingRules, buildStandingRulesBlock } from '../../intelligence/correctionLoop'; +import { buildInvestigationGateBlock } from '../../intelligence/investigationPipeline'; import { resolveScopeForAgent } from '../../skills/agentKnowledgeMap'; import { resolveDomainScope, classifyKnowledgeDomain, buildDomainRoutingHint } from '../../retrieval/domainRouter'; import { @@ -418,6 +419,14 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise`) 보수적으로 매칭. */ export function sanitizeAssistantContent(text: string): string { - const stripped = text + // [v2.2.310] LM Studio 합성 추론 구분자 — 일부 모델 조합에서 추론 텍스트가 + // *여는 마커 없이* 답변 앞에 흐르고 이 END 마커로만 경계가 표시된다. + // 마지막 END 마커 이전은 전부 추론 → 폐기. 남은 마커 잔재도 제거. + let t = text; + const lsepEnd = /__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_[0-9a-f]{6,64}__/gi; + const lsepParts = t.split(lsepEnd); + if (lsepParts.length > 1) t = lsepParts[lsepParts.length - 1]; + t = t.replace(/__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_[A-Z]+_[0-9a-f]{6,64}__/gi, ''); + + const stripped = t .replace(/[\s\S]*?<\/rationale>/gi, '') .replace(/^\s*\[PROBLEM\][\s\S]*?\[GOAL\][\s\S]*?\[REASONING\][^\n]*(?:\n+|$)/i, '') .replace(/^\s*\[PROBLEM\][\s\S]*?(?:\n\s*\n|$)/i, '') diff --git a/tests/investigationPipeline.test.ts b/tests/investigationPipeline.test.ts new file mode 100644 index 0000000..7e8c570 --- /dev/null +++ b/tests/investigationPipeline.test.ts @@ -0,0 +1,167 @@ +/** + * Investigation Pipeline — 파일명만 보고 상상하는 헛조사 차단 3중 방어. (v2.2.309) + * ① 조사형 요청 감지 + 증거 게이트 ② 강제 정독 Map + 해시 캐시 ③ Hollow 감지 + */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + isLocalInvestigationPrompt, + buildInvestigationGateBlock, + collectInvestigationFiles, + runInvestigation, + formatInvestigationNotes, + detectHollowInvestigation, + formatHollowInvestigationFooter, + INVESTIGATE_CACHE_REL_DIR, + MAX_INVESTIGATION_FILES, +} from '../src/intelligence/investigationPipeline'; + +function tmpRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'astra-inv-')); +} + +describe('isLocalInvestigationPrompt — 조사형 요청 감지', () => { + test('폴더/프로젝트 조사 요청 감지', () => { + expect(isLocalInvestigationPrompt('E:\\Wiki\\Weekly 프로젝트 조사해줘')).toBe(true); + expect(isLocalInvestigationPrompt('이 폴더의 문서들 분석해서 정리해줘')).toBe(true); + expect(isLocalInvestigationPrompt('./docs 검토 부탁해')).toBe(true); + }); + test('로컬 대상이 없는 일반 요청은 미감지', () => { + expect(isLocalInvestigationPrompt('오늘 날씨 어때?')).toBe(false); + expect(isLocalInvestigationPrompt('삼성전자 주가 조사해줘')).toBe(false); // 로컬 자료 아님 + }); + test('증거 게이트 블록은 감지 시에만 생성', () => { + expect(buildInvestigationGateBlock('이 폴더 코드 분석해줘')).toContain('investigate_files'); + expect(buildInvestigationGateBlock('안녕')).toBe(''); + }); +}); + +describe('collectInvestigationFiles — 파일 수집', () => { + test('텍스트 파일만, 결정적 순서, 제외 목록 보고', () => { + const root = tmpRoot(); + fs.mkdirSync(path.join(root, 'docs')); + fs.writeFileSync(path.join(root, 'docs', 'b.md'), '내용B'); + fs.writeFileSync(path.join(root, 'docs', 'a.md'), '내용A'); + fs.writeFileSync(path.join(root, 'docs', 'img.png'), 'binary'); + const { files, skipped } = collectInvestigationFiles(root, 'docs'); + expect(files.map(f => f.relPath)).toEqual(['a.md', 'b.md']); + expect(skipped.some(s => s.includes('img.png'))).toBe(true); + }); + test('상한 초과분은 skipped 로 (조용한 누락 금지)', () => { + const root = tmpRoot(); + fs.mkdirSync(path.join(root, 'many')); + for (let i = 0; i < MAX_INVESTIGATION_FILES + 5; i++) { + fs.writeFileSync(path.join(root, 'many', `f${String(i).padStart(3, '0')}.txt`), 'x' + i); + } + const { files, skipped } = collectInvestigationFiles(root, 'many'); + expect(files).toHaveLength(MAX_INVESTIGATION_FILES); + expect(skipped.filter(s => s.includes('상한 초과'))).toHaveLength(5); + }); + test('단일 파일 경로도 지원', () => { + const root = tmpRoot(); + fs.writeFileSync(path.join(root, 'one.md'), '단일'); + const { files } = collectInvestigationFiles(root, 'one.md'); + expect(files).toHaveLength(1); + }); +}); + +describe('runInvestigation — 강제 정독 + 캐시', () => { + test('모든 파일에 LLM 추출 실행, 노트 생성', async () => { + const root = tmpRoot(); + fs.mkdirSync(path.join(root, 'p')); + fs.writeFileSync(path.join(root, 'p', 'x.md'), 'X 파일 내용'); + fs.writeFileSync(path.join(root, 'p', 'y.md'), 'Y 파일 내용'); + const calls: string[] = []; + const r = await runInvestigation({ + rootPath: root, relDir: 'p', + llmCall: async (_s, u) => { calls.push(u); return '목적: 테스트\n핵심:\n- 사실1'; }, + }); + expect(r.notes).toHaveLength(2); + expect(r.llmCalls).toBe(2); + expect(r.cacheHits).toBe(0); + // LLM 입력에 실제 파일 내용이 들어감 (파일명만이 아니라) + expect(calls[0]).toContain('X 파일 내용'); + }); + + test('같은 내용 재조사 → 캐시 재사용 (LLM 0회) / 내용 변경 → 재추출', async () => { + const root = tmpRoot(); + fs.mkdirSync(path.join(root, 'p')); + fs.writeFileSync(path.join(root, 'p', 'x.md'), '원본 내용'); + const llm = jest.fn(async () => '목적: v1'); + await runInvestigation({ rootPath: root, relDir: 'p', llmCall: llm }); + expect(llm).toHaveBeenCalledTimes(1); + + // 2차: 동일 내용 → 캐시 + const r2 = await runInvestigation({ rootPath: root, relDir: 'p', llmCall: llm }); + expect(llm).toHaveBeenCalledTimes(1); // 추가 호출 없음 + expect(r2.cacheHits).toBe(1); + expect(r2.notes[0].fromCache).toBe(true); + expect(fs.existsSync(path.join(root, INVESTIGATE_CACHE_REL_DIR))).toBe(true); + + // 3차: 내용 변경 → 재추출 + fs.writeFileSync(path.join(root, 'p', 'x.md'), '변경된 내용'); + const r3 = await runInvestigation({ rootPath: root, relDir: 'p', llmCall: llm }); + expect(llm).toHaveBeenCalledTimes(2); + expect(r3.cacheHits).toBe(0); + }); + + test('추출 실패 파일은 skipped 로 계속 진행', async () => { + const root = tmpRoot(); + fs.mkdirSync(path.join(root, 'p')); + fs.writeFileSync(path.join(root, 'p', 'ok.md'), '정상'); + fs.writeFileSync(path.join(root, 'p', 'zz.md'), '실패 대상'); + const r = await runInvestigation({ + rootPath: root, relDir: 'p', + llmCall: async (_s, u) => { + if (u.includes('실패 대상')) throw new Error('LLM down'); + return '목적: ok'; + }, + }); + expect(r.notes).toHaveLength(1); + expect(r.skipped.some(s => s.includes('zz.md') && s.includes('추출 실패'))).toBe(true); + }); + + test('병합 노트에 파일 경로·미확인 목록 포함', async () => { + const root = tmpRoot(); + fs.mkdirSync(path.join(root, 'p')); + fs.writeFileSync(path.join(root, 'p', 'a.md'), 'A'); + fs.writeFileSync(path.join(root, 'p', 'bin.png'), 'x'); + const r = await runInvestigation({ rootPath: root, relDir: 'p', llmCall: async () => '목적: A' }); + const notes = formatInvestigationNotes(r, '구조 파악'); + expect(notes).toContain('[a.md]'); + expect(notes).toContain('조사 목적: 구조 파악'); + expect(notes).toContain('미확인 파일'); + expect(notes).toContain('실제로 읽고'); + }); +}); + +describe('detectHollowInvestigation — 헛조사 감지', () => { + const answer = '이 프로젝트는 server.mjs가 백엔드이고 dashboard.html이 화면입니다. config.json은 설정입니다.'; + + test('list만 하고 read 0 + 파일 2개 이상 서술 = 헛조사', () => { + const r = detectHollowInvestigation(answer, { reads: 0, lists: 1, investigates: 0 }); + expect(r.hollow).toBe(true); + expect(r.fileMentions.length).toBeGreaterThanOrEqual(2); + }); + test('실제로 읽었으면(read>0) 정상', () => { + expect(detectHollowInvestigation(answer, { reads: 2, lists: 1, investigates: 0 }).hollow).toBe(false); + }); + test('investigate_files 를 썼으면 정상', () => { + expect(detectHollowInvestigation(answer, { reads: 0, lists: 1, investigates: 1 }).hollow).toBe(false); + }); + test('list 자체가 없으면 판정 대상 아님', () => { + expect(detectHollowInvestigation(answer, { reads: 0, lists: 0, investigates: 0 }).hollow).toBe(false); + expect(detectHollowInvestigation(answer, null).hollow).toBe(false); + }); + test('파일 언급 1개는 오탐 방지 위해 통과', () => { + const one = 'README.md 를 확인해보세요.'; + expect(detectHollowInvestigation(one, { reads: 0, lists: 1, investigates: 0 }).hollow).toBe(false); + }); + test('경고 footer 에 파일명 포함', () => { + const r = detectHollowInvestigation(answer, { reads: 0, lists: 1, investigates: 0 }); + const footer = formatHollowInvestigationFooter(r.fileMentions); + expect(footer).toContain('헛조사 감지'); + expect(footer).toContain('investigate_files'); + }); +}); diff --git a/tests/liveReasoningFilter.test.ts b/tests/liveReasoningFilter.test.ts index 2297a20..6aff295 100644 --- a/tests/liveReasoningFilter.test.ts +++ b/tests/liveReasoningFilter.test.ts @@ -63,3 +63,74 @@ describe('LiveReasoningFilter — 스트리밍 중 추론 구간 실시간 차 expect(run(['답변 끝 <'])).toBe('답변 끝 <'); }); }); + +describe('[v2.2.310] LM Studio LSEP 합성 추론 구분자', () => { + const MARKER = '__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_f4e9a8d2c6b14d0c9e5f3a7b8c1d2e6a__'; + + /** run + separator 신호 관찰 버전. */ + function runWithSignal(tokens: string[]): { out: string; replacedAt: number[] } { + const f = new LiveReasoningFilter(); + let out = ''; + const replacedAt: number[] = []; + tokens.forEach((t, i) => { + const visible = f.push(t); + if (f.consumeSeparatorSignal()) { + out = visible; // streamReplace 시뮬레이션 — 화면 리셋 + replacedAt.push(i); + } else { + out += visible; + } + }); + out += f.flush(); + return { out, replacedAt }; + } + + test('END 마커 뒤 텍스트만 최종 화면에 남는다 (실사례 재현)', () => { + const { out, replacedAt } = runWithSignal([ + 'User message: "안녕, 잘지내?" 분석... Option 1... Final choice...', + MARKER + '잘 지내고 있습니다. 별일 없으셨나요?', + ]); + expect(out).toBe('잘 지내고 있습니다. 별일 없으셨나요?'); + expect(replacedAt).toEqual([1]); // 마커 도착 시점에 streamReplace 신호 + }); + + test('마커가 토큰 경계에 잘게 쪼개져도 잡는다', () => { + const { out } = runWithSignal([ + '추론 텍스트 어쩌고 ', + '__LM_STUDIO_INTERNAL_LSEP', + '_SYNTHETIC_REASONING_END_f4e9a8d2c6b14d0c', + '9e5f3a7b8c1d2e6a__', + '실제 답변', + ]); + expect(out).toBe('실제 답변'); + }); + + test('START 변형이 오면 END 까지 통째로 숨긴다', () => { + const start = '__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_START_abc123def456__'; + const { out } = runWithSignal(['답변 앞부분 ', start + '숨길 추론 ' + MARKER, '답변 뒷부분']); + expect(out).toBe('답변 앞부분 답변 뒷부분'); + }); + + test('마커 없는 일반 밑줄 텍스트는 그대로 통과 (오탐 없음)', () => { + const { out, replacedAt } = runWithSignal(['snake_case_variable 와 __init__ 메서드', ' 설명입니다']); + expect(out).toBe('snake_case_variable 와 __init__ 메서드 설명입니다'); + expect(replacedAt).toEqual([]); + }); +}); + +describe('[v2.2.310] sanitizeAssistantContent — LSEP 최종 정리 (이중 방어)', () => { + const { sanitizeAssistantContent } = require('../src/lib/contextBuilders/outputSanitization'); + const MARKER = '__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_f4e9a8d2c6b14d0c9e5f3a7b8c1d2e6a__'; + + test('마커 앞 추론 전체 제거, 답변만 남긴다', () => { + const raw = 'User message 분석... Option 1... Final choice...' + MARKER + '잘 지내고 있습니다. 별일 없으셨나요?'; + expect(sanitizeAssistantContent(raw)).toBe('잘 지내고 있습니다. 별일 없으셨나요?'); + }); + test('마커가 여러 번이면 마지막 마커 뒤만', () => { + const raw = '추론1' + MARKER + '중간' + MARKER + '최종 답변'; + expect(sanitizeAssistantContent(raw)).toBe('최종 답변'); + }); + test('마커 없으면 기존 동작 그대로', () => { + expect(sanitizeAssistantContent('일반 답변입니다.')).toBe('일반 답변입니다.'); + }); +}); diff --git a/tests/standingRules.test.ts b/tests/standingRules.test.ts new file mode 100644 index 0000000..19d13fa --- /dev/null +++ b/tests/standingRules.test.ts @@ -0,0 +1,151 @@ +/** + * Standing Rules (Correction Loop ④) — 행동/스타일 교정의 세션 영속화. (v2.2.308) + * + * 배경: self-reflection 이 컨텍스트 창(단기 기억)에 갇혀 새 세션에서 증발하는 문제. + * 행동 지적 → 명령형 규칙 파일 영속화 → 매 턴 주입으로 구조적으로 해결한다. + */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + looksLikeBehaviorComplaint, + loadStandingRules, + saveStandingRules, + upsertStandingRule, + buildStandingRulesBlock, + isSameRule, + MAX_ACTIVE_STANDING_RULES, + STANDING_RULES_REL_PATH, + StandingRule, +} from '../src/intelligence/correctionLoop'; + +function tmpBrain(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'astra-sr-')); +} + +describe('looksLikeBehaviorComplaint — 행동/스타일 지적 감지', () => { + test('반복 지적: "또 ~라고 썼네" (이번 이슈의 원 사례)', () => { + expect(looksLikeBehaviorComplaint('또 결론 수정이라고 썼네')).toBe(true); + }); + test('"하지 말라고 했잖아"', () => { + expect(looksLikeBehaviorComplaint('결론 유지 문구 넣지 말라고 했잖아')).toBe(true); + }); + test('"몇 번을 말해"', () => { + expect(looksLikeBehaviorComplaint('몇 번을 말해야 알아들어')).toBe(true); + }); + test('"왜 자꾸 ~"', () => { + expect(looksLikeBehaviorComplaint('왜 자꾸 영어로 답해')).toBe(true); + }); + test('출력 형식 지시: "붙이지 마"', () => { + expect(looksLikeBehaviorComplaint('출처 표기를 답변 중간에 붙이지 마')).toBe(true); + }); + test('일반 질문은 오탐하지 않음', () => { + expect(looksLikeBehaviorComplaint('오늘 주가 어때?')).toBe(false); + expect(looksLikeBehaviorComplaint('회의록 요약해줘')).toBe(false); + // "또"로 시작하지만 지적이 아닌 추가 요청 + expect(looksLikeBehaviorComplaint('또 다른 방법도 알려줘')).toBe(false); + }); + test('빈/초장문 입력 방어', () => { + expect(looksLikeBehaviorComplaint('')).toBe(false); + expect(looksLikeBehaviorComplaint('또 ' + 'x'.repeat(2000))).toBe(false); + }); +}); + +describe('isSameRule — 재지적 판정 (토큰 자카드)', () => { + test('같은 규칙의 표현 변형은 동일 판정', () => { + expect(isSameRule( + '답변에 결론 수정 문구를 붙이지 않는다', + '답변에 결론 수정 문구를 절대 붙이지 않는다', + )).toBe(true); + }); + test('다른 규칙은 구분', () => { + expect(isSameRule( + '답변에 결론 수정 문구를 붙이지 않는다', + '모든 답변은 한국어로 작성한다', + )).toBe(false); + }); +}); + +describe('upsertStandingRule — 저장/중복/상한', () => { + test('신규 규칙 저장 → 파일 생성 + active', () => { + const brain = tmpBrain(); + const rules = upsertStandingRule(brain, '결론 수정 라벨을 붙이지 않는다', '또 결론 수정이라고 썼네'); + expect(rules).toHaveLength(1); + expect(rules[0].status).toBe('active'); + expect(rules[0].hits).toBe(1); + expect(fs.existsSync(path.join(brain, STANDING_RULES_REL_PATH))).toBe(true); + // 라운드트립 + const loaded = loadStandingRules(brain); + expect(loaded[0].rule).toBe('결론 수정 라벨을 붙이지 않는다'); + }); + + test('같은 규칙 재지적 → hits 증가 (새 항목 추가 아님)', () => { + const brain = tmpBrain(); + upsertStandingRule(brain, '결론 수정 라벨을 붙이지 않는다', '지적1'); + const rules = upsertStandingRule(brain, '결론 수정 라벨을 절대 붙이지 않는다', '지적2'); + expect(rules).toHaveLength(1); + expect(rules[0].hits).toBe(2); + }); + + test('은퇴한 규칙이 재지적되면 부활', () => { + const brain = tmpBrain(); + const retired: StandingRule[] = [{ + id: 'sr-x', rule: '영어 단어를 남발하지 않는다', origin: 'o', + createdAt: '2026-01-01T00:00:00Z', lastHitAt: '2026-01-01T00:00:00Z', hits: 1, status: 'retired', + }]; + saveStandingRules(brain, retired); + const rules = upsertStandingRule(brain, '영어 단어를 남발하지 않는다', '또 영어 남발이네'); + expect(rules[0].status).toBe('active'); + expect(rules[0].hits).toBe(2); + }); + + test('상한 초과 시 hits 낮고 오래된 규칙부터 은퇴', () => { + const brain = tmpBrain(); + // 서로 유사도 판정(isSameRule)에 걸리지 않는 실제로 구분되는 규칙 10개 + const distinct = [ + '답변은 항상 한국어로 작성한다', + '수치를 말할 때 근거 문서를 인용한다', + '표를 남용하지 않는다', + '이모지 사용을 최소화한다', + '결론부터 먼저 말한다', + '추측일 때는 추정임을 명시한다', + '링크는 마크다운 형식으로 쓴다', + '제목에 물음표를 넣지 않는다', + '인사말을 매번 반복하지 않는다', + '코드 예시에는 설명 주석을 단다', + ]; + distinct.forEach((r, i) => upsertStandingRule(brain, r, `origin${i}`, `2026-01-0${(i % 9) + 1}T00:00:00Z`)); + expect(loadStandingRules(brain)).toHaveLength(MAX_ACTIVE_STANDING_RULES); + // 규칙 하나에 hits 보강 (은퇴 대상에서 멀어지게) + upsertStandingRule(brain, '이모지 사용을 최소화한다', 'again', '2026-02-01T00:00:00Z'); + // 상한+1 번째 신규 규칙 + const rules = upsertStandingRule(brain, '긴 목록 대신 요약 문단을 우선한다', 'new', '2026-03-01T00:00:00Z'); + const active = rules.filter(r => r.status === 'active'); + expect(active).toHaveLength(MAX_ACTIVE_STANDING_RULES); + // hits 2인 규칙은 살아남고, 방금 들어온 신규도 active + expect(active.some(r => r.rule.includes('이모지'))).toBe(true); + expect(active.some(r => r.rule.includes('요약 문단'))).toBe(true); + expect(rules.some(r => r.status === 'retired')).toBe(true); + }); +}); + +describe('buildStandingRulesBlock — 매 턴 주입 블록', () => { + test('active 규칙을 hits 내림차순으로 포함', () => { + const rules: StandingRule[] = [ + { id: 'a', rule: '규칙A', origin: '', createdAt: '', lastHitAt: '2026-01-01', hits: 1, status: 'active' }, + { id: 'b', rule: '규칙B', origin: '', createdAt: '', lastHitAt: '2026-01-02', hits: 3, status: 'active' }, + { id: 'c', rule: '규칙C', origin: '', createdAt: '', lastHitAt: '2026-01-03', hits: 1, status: 'retired' }, + ]; + const block = buildStandingRulesBlock(rules); + expect(block).toContain('[상시 행동 규칙'); + expect(block.indexOf('규칙B')).toBeLessThan(block.indexOf('규칙A')); // hits 순 + expect(block).toContain('(지적 3회)'); + expect(block).not.toContain('규칙C'); // retired 제외 + }); + test('규칙 없으면 빈 문자열 (프롬프트 오염 없음)', () => { + expect(buildStandingRulesBlock([])).toBe(''); + expect(buildStandingRulesBlock([ + { id: 'c', rule: 'x', origin: '', createdAt: '', lastHitAt: '', hits: 1, status: 'retired' }, + ])).toBe(''); + }); +}); diff --git a/tests/wikiSave.test.ts b/tests/wikiSave.test.ts index 307a978..fc5dce2 100644 --- a/tests/wikiSave.test.ts +++ b/tests/wikiSave.test.ts @@ -50,7 +50,8 @@ describe('pickWikiDir — 저장 폴더 우선순위', () => { test('[v2.2.304] ../ 상대경로로 두뇌 밖 형제 폴더도 지정 가능', () => { const r = pickWikiDir('../../Premium/자료', '/Wiki/10_Wiki/Topics', null); - expect(r!.dir.replace(/\\/g, '/')).toBe('/Wiki/Premium/자료'); + // Windows에서는 path.resolve가 드라이브 문자를 붙이므로(E:/Wiki/...) 끝부분만 검증 + expect(r!.dir.replace(/\\/g, '/')).toMatch(/\/Wiki\/Premium\/자료$/); }); test('상대경로인데 두뇌도 없으면 다음 후보(워크스페이스)로', () => {