v2.2.308~310: Standing Rules + 조사 파이프라인 + LM Studio 추론 노출 수리

v2.2.308 — Standing Rules (Correction Loop ④): 행동/스타일 교정의 세션 영속화
- 행동 지적 감지("또 ~하네", "하지 말라고 했잖아" 등 5종) → LLM 이 명령형 규칙으로
  정규화 → .astra/growth/standing-rules.json 즉시 저장 → 매 턴 보호 구역 주입.
  self-reflection 이 컨텍스트 창(단기 기억)에 갇혀 새 세션에서 증발하던 문제 해결.
- 중복 지적은 hits 증가(토큰 자카드), 상한 10개 초과 시 자동 은퇴, 재지적 시 부활.
- "Astra: 상시 행동 규칙" 커맨드 — 활성/은퇴 규칙 + 실제 주입 블록 열람(도달 증거).

v2.2.309 — 조사 파이프라인: 파일명만 보고 상상하는 헛조사 4중 차단
- <investigate_files path focus> 액션: 폴더의 텍스트 파일을 코드가 강제 정독해
  파일별 노트 주입(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_<hex>__ 로만 경계 표시 →
  생각 과정 전체가 채팅에 노출되던 문제.
- LiveReasoningFilter: 마커 감지 시 streamReplace 신호로 화면 즉시 리셋(토큰 분절
  대응 holdback 포함), sanitizeAssistantContent: 마커 이전 추론 최종 제거(이중 방어).

기타: tests/wikiSave.test.ts 상대경로 테스트 Windows 호환 수정(드라이브 문자).
검증: 전체 테스트 926건 통과 (신규 39건: standingRules 15 + investigationPipeline 16 + LSEP 8).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 18:18:49 +09:00
parent 137b83d8b6
commit 5feacb4799
17 changed files with 1098 additions and 16 deletions
+14 -2
View File
@@ -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'. 소형 로컬 모델이 파일명만 보고 내용을 상상하는 헛조사 방지책의 하나 — 비우면 현재 선택 모델 사용."
}
}
}
+66 -9
View File
@@ -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 {
- 이 워크스페이스의 코드·문서·기능에 대한 주장은 *이 대화에서 실제로 읽은 파일*에만 근거하라.
- 확인하지 않은 구현을 "~로 보입니다", "~일 것입니다"라고 추측 서술하는 것은 금지. 먼저 <list_files path="..."/> 와 <read_file path="..."/> 태그로 관련 파일을 직접 열어 확인한 뒤 답하라. 태그를 emit 하면 시스템이 파일 내용을 주입하고 자동으로 이어서 답변하게 된다.
- ⚠️ "소스 코드 확인이 필요합니다"라고 말만 하고 끝내는 것은 금지다. 확인이 필요하다고 판단했다면 *바로 이 답변 안에서* <list_files>/<read_file> 태그를 emit 하라 — 그것이 확인하는 방법이다. 태그로 접근 불가능한 대상(외부 시스템·미설치 도구 등)에 한해서만 "확인하지 못함"으로 명시하라.
- 폴더 전체를 조사해야 하면 파일을 하나씩 읽지 말고 <investigate_files path="상대경로" focus="조사 목적"/> 태그 하나를 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);
+53
View File
@@ -0,0 +1,53 @@
/**
* <investigate_files path="..." focus="..."/> — 폴더(또는 단일 파일) 강제 정독 조사.
*
* 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<void> {
const { aiMessage, rootPath, report } = ctx;
const regex = /<investigate_files\s+path=['"]?([^'">]+)['"]?(?:\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) });
}
}
}
+6
View File
@@ -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 });
}
};
+20
View File
@@ -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,
+2
View File
@@ -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. */
+7
View File
@@ -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<boolean>('intentClarificationEnabled', true),
intentClarificationStrictness: (cfg.get<string>('intentClarificationStrictness', 'medium') as 'low' | 'medium' | 'high') || 'medium',
citationTraceEnabled: cfg.get<boolean>('citationTraceEnabled', true),
investigationModel: (cfg.get<string>('investigationModel', '') || '').trim(),
selfCheckEnabled: cfg.get<boolean>('selfCheckEnabled', false),
selfCheckModel: cfg.get<string>('selfCheckModel', '') || '',
selfCheckTimeoutSec: Math.max(1, Math.min(60, cfg.get<number>('selfCheckTimeoutSec', 6))),
+53
View File
@@ -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<void> {
}
/** 성장 리포트 — Reflection 기록(.astra/growth/reflections.jsonl)의 주별 추이 + 반복 실수 Top. */
/**
* Standing Rules 열람 — "주입엔 항상 도달 증거를" 원칙의 확인 창구.
* 현재 매 턴 주입 중인 상시 행동 규칙과 실제 주입 블록 원문을 파일로 열어준다.
* 규칙 수정/삭제는 standing-rules.json 을 직접 편집 (Permission Based Learning).
*/
async function standingRulesCommand(): Promise<void> {
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<void> {
try {
const brain = getActiveBrainProfile();
+175
View File
@@ -178,8 +178,21 @@ export async function captureCorrection(opts: {
wrongAnswer: string;
correction: string;
llm: { baseUrl: string; model: string };
/** 행동/스타일 지적으로 감지됨 (looksLikeBehaviorComplaint) — Standing Rule 파생 대상. */
behaviorHint?: boolean;
}): Promise<string | null> {
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<string> {
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');
}
+228
View File
@@ -0,0 +1,228 @@
/**
* Investigation Pipeline — "파일명만 보고 상상하는 조사" 를 구조적으로 차단. (v2.2.309)
*
* 문제: 소형 로컬 모델은 <list_files> 로 파일명 목록을 받으면 <read_file> 을 부르지
* 않고 이름에서 내용을 상상해 조사 결과를 지어낸다 (lazy tool-use 할루시네이션).
*
* 해법 3중:
* ① 증거 게이트 — 조사형 요청 감지 시 "읽지 않은 파일 서술 금지" 행동 제약 주입
* (buildInvestigationGateBlock → memoryContext behavior-constraints 채널).
* ② Map-Reduce — <investigate_files> 액션이 폴더의 파일을 *코드가 하나씩 강제로
* 읽어* 파일별 추출 노트 생성(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 [
'[조사 증거 게이트 — 로컬 자료 조사 시 필수]',
'- 파일명·폴더 목록만 보고 내용을 서술하는 것 금지. 내용에 대한 모든 주장은 실제로 읽은 것만 쓴다.',
'- 폴더 단위 조사는 <investigate_files path="상대경로" focus="조사 목적"/> 한 번으로 위임하라 — 각 파일을 자동으로 읽어 파일별 노트를 만들어준다. 개별 파일은 <read_file>.',
'- 읽지 않은 파일을 언급해야 하면 이름 뒤에 "(미확인)"을 붙여라.',
'- 조사 결과의 각 주장 끝에 근거 파일 경로를 붙여라 (예: [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<string>;
maxFiles?: number;
onProgress?: (done: number, total: number, relPath: string) => void;
}): Promise<InvestigationResult> {
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}건 — 필요 시 <read_file> 로 개별 확인: ${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 ? ' 외' : ''})을 서술했습니다.`,
'내용 검증이 필요하면 다시 요청해 주세요 — 다음 턴에서 `<investigate_files>`로 각 파일을 실제로 읽고 답합니다.',
].join('\n');
}
+62 -1
View File
@@ -27,6 +27,28 @@ const TAG_OPENERS: { literal: string; closer: RegExp }[] = [
{ literal: '<analysis>', closer: /<\/analysis>/i },
];
/**
* [v2.2.310] LM Studio 합성 추론 구분자 — 일부 모델/버전 조합에서 LM Studio 가
* 추론 텍스트를 *여는 마커 없이* content 스트림 맨 앞에 흘리고, 실제 답변과의
* 경계에 이 합성 마커를 삽입한다:
* `<추론 텍스트>__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_<hex>__<답변>`
* 여는 마커가 없어 앞부분을 미리 숨길 수는 없다 — 대신 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|>` / `<|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;
}
}
+11 -2
View File
@@ -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<strin
// [Correction Loop ③-c] 약점 프로필 → 자기검토 블록. 최근 정정 통계가 다음 턴의
// 행동을 직접 바꾼다 (태그 2회 이상만 — 1회성 실수로 프롬프트를 어지럽히지 않게).
const selfReviewBlock = buildSelfReviewBlock(loadWeaknessProfile(deps.activeBrain.localBrainPath));
// [Correction Loop ④] Standing Rules — 행동/스타일 교정의 영속 규칙. 레슨(유사도
// 검색)과 달리 질문 내용과 무관하게 *매 턴* 주입된다. 세션을 새로 열어도 유지 —
// "self-reflection 이 단기 기억에 갇힌다" 문제의 구조적 해결 (v2.2.308).
const standingRulesBlock = buildStandingRulesBlock(loadStandingRules(deps.activeBrain.localBrainPath));
// [조사 증거 게이트 v2.2.309] 로컬 자료 조사형 요청이면 "읽지 않은 파일 서술 금지 +
// investigate_files 위임" 제약 주입 — 파일명만 보고 상상하는 헛조사 차단의 1차 방어선.
const investigationGateBlock = buildInvestigationGateBlock(deps.currentPrompt);
// 행동 제약 블록(자기검토·확신도 정책·레슨 체크리스트)은 dynamicBlocks 채널로 —
// [CONTEXT] *밖* 보호 구역에 주입되어 context-overflow truncation 에서도 살아남는다.
@@ -438,7 +447,7 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
'- 첫 문장은 그 목적에 바로 답하는 결론으로 시작하라. 해석이 갈리면 가장 가능성 높은 해석으로 답하되, 어떤 해석으로 답했는지 한 줄로 밝혀라.',
].join('\n');
const constraintBlock = [intentPrinciple, routingHint, selfReviewBlock, groundingBlock, lessonBlock].filter(Boolean).join('\n\n');
const constraintBlock = [intentPrinciple, standingRulesBlock, investigationGateBlock, routingHint, selfReviewBlock, groundingBlock, lessonBlock].filter(Boolean).join('\n\n');
if (constraintBlock) blocks.set('behavior-constraints', constraintBlock);
return memoryBlock;
}
+10 -1
View File
@@ -22,7 +22,16 @@
* `<|return|>`) 보수적으로 매칭.
*/
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(/<rationale>[\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, '')
+167
View File
@@ -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');
});
});
+71
View File
@@ -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('일반 답변입니다.');
});
});
+151
View File
@@ -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('');
});
});
+2 -1
View File
@@ -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('상대경로인데 두뇌도 없으면 다음 후보(워크스페이스)로', () => {