v2.2.311~312: 응답 지연 근본 개선(KV 캐시 분리·제2뇌 상주 캐시) + 보고 품질 수술
v2.2.311 — 응답 지연 (실측: 출력 22토큰에 94.7초, 원인은 매 턴 13k+ 토큰 전체 재프리필) - KV 캐시 친화 프롬프트 분리(kvCachePromptSplit, 기본 ON): message[0]을 불변 정적 본문으로 고정, 날짜/RAG/[CONTEXT]/동적 블록을 마지막 user 메시지 직전의 internal system 메시지로 이동 — llama.cpp prompt cache 프리픽스 재사용으로 턴당 재프리필을 "직전 교환 + 동적 컨텍스트"로 축소. truncation 도 tail 적용. - 검색 토큰 예산 현실화(retrievalTokenBudget, 0=자동): 창의 25%(8k~80k) → 12%(2.5k~6k 클램프). - continuation 은 depth-0 memoryCtx 재사용: 라운드당 재검색 3~8초 제거 + 빈 쿼리 재검색으로 청크가 갈리던 문제 제거 + 턴 내 프롬프트 안정화. - 제2뇌 상주 캐시(신규 brainWatch.ts): 재귀 fs.watch 세대 카운터로 변경 없으면 디렉터리 워크·파일별 statSync 전면 생략. 수정 직후 3초 창은 신뢰 제외(이벤트 지연 레이스 가드), 워처 불가 시 종전 폴백, 활성화 시 백그라운드 워밍, 유휴 해제 30분→2시간. v2.2.312 — 보고 품질 (실사례: "## 4."부터 시작하는 7줄 일반론 보고서) - 중간 라운드 본문 표시 버그 수정: 액션과 함께 작성된 섹션(1~3)이 화면에 한 번도 안 나가고 최종 라운드만 표시되던 근본 원인 제거 — stripForDisplay.ts 로 액션 태그만 걷어내고 라운드 순서대로 버블에 표시. - '분석 보고' 업무 유형 신설(requirementGraph): 보고 개요(첫 줄 자기선언)·파일 근거(주장마다 실제 읽은 파일 인용, 일반론 금지)·구조·발견·다음 단계 강제, 유형 감지 시 "최대 3섹션" 규칙보다 필수 요소 커버 우선. - 근거 없는 분석 감지: 파일을 읽고도 인용 2개 미만인 장문 분석에 "근거 인용 없음" footer 경고 (헛조사 감지의 반대 방향). 검증: 전체 테스트 954건 통과(신규 28), tsc 무오류, vsix 패키징·설치 확인. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,23 @@
|
||||
# Astra Patch Notes
|
||||
|
||||
## v2.2.312 (2026-07-20)
|
||||
### 📋 보고 품질 — "## 4.부터 시작하는 보고서" 버그 + 일반론 총평 차단
|
||||
실사례: "connectai 프로젝트를 분석하고 보고해줘" → 답변이 "## 4. 종합 분석 결과"부터 시작(1~3 증발), 내용은 프로젝트명을 바꿔도 성립하는 7줄 일반론.
|
||||
- **중간 라운드 본문 표시 버그 수정 (근본 원인)**: 액션 루프에서 액션과 *함께* 본문 섹션을 쓴 라운드는 화면에 한 번도 표시되지 않고(라이브 스트리밍은 depth 0 전용) 최종 라운드만 append 됐다. 모델은 히스토리에서 자기 이전 섹션(1~3)을 보고 "## 4."부터 이어 써서 사용자에겐 4번부터 도착. 이제 중간 라운드의 가시 본문을 액션 태그만 걷어내고(신규 stripForDisplay.ts) 버블에 순서대로 표시한다.
|
||||
- **분석 보고 업무 유형 신설 (requirementGraph)**: "분석하고 보고해줘/현황 보고/검토 보고"가 어떤 유형에도 안 걸리던 갭을 막음. 필수 요소: 보고 개요(첫 줄에 대상—근거—결론 자기선언), **파일 근거**(주장마다 실제 읽은 파일 인용, 일반론 금지), 구조·현황, 핵심 발견, 다음 단계. 유형 감지 시 "최대 3섹션" 일반 규칙보다 필수 요소 커버 우선 명시(보고서가 얕아지는 상충 제거).
|
||||
- **근거 없는 분석 감지 (Hollow 의 반대 방향)**: 파일을 읽고도(read/investigate ≥1) 답변에 파일 인용이 2개 미만인 장문 분석이면 "근거 인용 없음" footer 로 경고 — 그럴듯한 총평이 검증된 분석처럼 통과하는 것을 차단.
|
||||
검증: tsc 무오류 + jest 954 통과(신규 13: analysis-report 3 + ungrounded 5 + stripForDisplay 5) + esbuild 정상.
|
||||
|
||||
## v2.2.311 (2026-07-20)
|
||||
### ⚡ 응답 지연 근본 개선 — KV 캐시 친화 프롬프트 + 제2뇌 상주 캐시
|
||||
실측(usage.jsonl): 출력 22토큰 답변에 94.7초 — 지연의 90%+ 가 생성이 아니라 **매 턴 13k+ 토큰 전체 재프리필**이었다. 원인은 (a) 시스템 프롬프트 상단의 분 단위 시각이 llama.cpp prompt cache 를 첫 토큰부터 무효화, (b) 턴마다 바뀌는 RAG/동적 블록이 전부 message[0] 에 있어 히스토리 캐시까지 연쇄 무효화, (c) 32K 창에서 검색 결과에만 8k 토큰 예산, (d) continuation 마다 빈 쿼리로 재검색(3~8초), (e) 매 질의 6,500 파일 디렉터리 워크 + statSync.
|
||||
- **KV 캐시 분리 (`kvCachePromptSplit`, 기본 ON)**: message[0] 을 불변 정적 본문(페르소나·규칙·환경)으로 고정하고, 날짜/RAG/[CONTEXT]/동적 블록 전부를 *마지막 user 메시지 직전*의 internal system 메시지로 이동. 프리픽스(정적 프롬프트+과거 히스토리)가 캐시에서 재사용되어 재프리필이 "직전 교환 + 동적 컨텍스트"로 줄어든다. [CONTEXT] truncation 도 tail 에 적용. 커스텀 systemPrompt 호출자(멀티에이전트 등)는 종전 경로 유지.
|
||||
- **검색 토큰 예산 현실화 (`retrievalTokenBudget`, 0=자동)**: 자동값을 창의 25%(8k~80k) → 12%(2.5k~6k 클램프)로. 매 턴 재프리필되는 tail 을 직접 얇게 만든다.
|
||||
- **continuation 은 depth-0 memoryCtx 재사용**: 액션 라운드마다의 재검색 제거 — 라운드당 3~8초 절감 + 빈 쿼리 재검색으로 청크가 갈리던 문제 제거 + 턴 내 프롬프트 안정화.
|
||||
- **제2뇌 상주 캐시 (신규 brainWatch.ts)**: 브레인 루트에 재귀 fs.watch 를 걸고 세대 카운터로 무효화 — 변경 없으면 디렉터리 워크와 파일별 statSync(수천 회)를 통째로 생략하고 메모리의 파일 목록·토큰 인덱스를 신뢰한다. "제2뇌를 항상 메모리에" 아이디어의 안전한 구현: 원본 대신 검색 파생물을 상주시키고 워처가 정합성을 맡는다. 수정 직후 3초 창은 신뢰 명부에서 제외(이벤트 지연 레이스 가드), 워처 불가 환경은 자동으로 종전(매번 stat) 폴백. 활성화 시 백그라운드 워밍으로 첫 질의의 인덱스 파싱 비용도 선불 처리, 유휴 해제 30분→2시간.
|
||||
- 기대 효과: 동일 세션 반복 턴의 프리필이 13k → 수백~2k 토큰 수준. LM Studio 쪽 GPU 오프로드/Flash Attention 설정과 병행 권장.
|
||||
검증: tsc 무오류 + jest 941 통과(신규 15: split 6 + brainCache 4 + promptSplit 5) + esbuild 정상.
|
||||
|
||||
## v2.2.307 (2026-07-11)
|
||||
### 🏗️ /benchmark 레이아웃 골격 분석 + 합성 안정화
|
||||
koritips 실측 피드백 반영: ① 합성이 파트 2 에서 타임아웃("This operation was aborted")으로 죽으면서 105초 걸린 파트 1까지 폐기 ② 남은 원시 요약이 색·폰트뿐이라 "배너가 롤링인지, 각 페이지가 어떤 블록 순서인지" 알 길이 없었음.
|
||||
|
||||
+11
-1
@@ -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.310",
|
||||
"version": "2.2.312",
|
||||
"publisher": "g1nation",
|
||||
"license": "MIT",
|
||||
"icon": "assets/icon.png",
|
||||
@@ -1294,6 +1294,16 @@
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"markdownDescription": "조사형 요청('~폴더 조사해줘') 전용 모델 오버라이드. 예: 'claude-code:sonnet'. 소형 로컬 모델이 파일명만 보고 내용을 상상하는 헛조사 방지책의 하나 — 비우면 현재 선택 모델 사용."
|
||||
},
|
||||
"g1nation.kvCachePromptSplit": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"markdownDescription": "**KV 캐시 친화 프롬프트 분리** — 시스템 프롬프트를 '불변 본문(message[0])'과 '턴별 동적 컨텍스트(마지막 질문 직전 주입)'로 분리해 LM Studio/llama.cpp 의 prompt cache 프리픽스 재사용을 극대화. 매 턴 전체 재프리필(1만+ 토큰, 수십 초)을 직전 교환 분량으로 줄인다. 특정 모델 템플릿에서 응답 품질 문제가 보이면 끄세요."
|
||||
},
|
||||
"g1nation.retrievalTokenBudget": {
|
||||
"type": "number",
|
||||
"default": 0,
|
||||
"markdownDescription": "**RAG 검색 토큰 예산 상한** — 두뇌/메모리 검색 결과가 프롬프트에서 차지할 최대 토큰. `0 = 자동`(컨텍스트 창의 12%, 2,500~6,000 클램프). 값이 클수록 근거는 풍부해지지만 로컬 모델 프리필이 느려진다."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+80
-20
@@ -5,6 +5,8 @@ import * as fs from 'fs';
|
||||
import {
|
||||
findBrainFiles,
|
||||
getSystemPrompt,
|
||||
getStaticSystemPrompt,
|
||||
getDateTimeContextBlock,
|
||||
shouldAutoPushBrain,
|
||||
buildApiUrl,
|
||||
getActiveBrainProfile,
|
||||
@@ -322,6 +324,14 @@ export class AgentExecutor {
|
||||
actionStats: { reads: number; lists: number; investigates: number };
|
||||
/** [v2.2.309] 조사 턴 모델 오버라이드 — depth 0 에서 결정, continuation 에도 유지. */
|
||||
investigationModelOverride: string | null;
|
||||
/**
|
||||
* [v2.2.311] depth 0 에서 빌드한 memoryCtx 문자열 캐시 — continuation depth 는
|
||||
* 재검색하지 않고 이걸 재사용한다. 종전엔 depth 마다 buildMemoryContext 를 다시
|
||||
* 돌렸는데, (a) 검색 3~8초가 라운드마다 추가되고 (b) continuation 의 prompt 는
|
||||
* null 이라 *빈 쿼리로* 재검색해 엉뚱한 청크로 갈아끼우고 (c) 프롬프트가 흔들려
|
||||
* KV 캐시도 깨졌다. actionStats 와 같은 이유로 depth 0 진입부에서만 초기화.
|
||||
*/
|
||||
memoryCtxCache: string | null;
|
||||
} = {
|
||||
retrieval: null,
|
||||
lessons: [],
|
||||
@@ -331,6 +341,7 @@ export class AgentExecutor {
|
||||
confidenceSignals: null,
|
||||
actionStats: { reads: 0, lists: 0, investigates: 0 },
|
||||
investigationModelOverride: null,
|
||||
memoryCtxCache: null,
|
||||
};
|
||||
|
||||
/** Per-turn state 일괄 정리. turn 시작/abort/load session 시 호출. */
|
||||
@@ -560,6 +571,7 @@ export class AgentExecutor {
|
||||
// [v2.2.309] turn 전체(모든 depth) 누적 상태 — depth 0 에서만 초기화.
|
||||
this._turnCtx.actionStats = { reads: 0, lists: 0, investigates: 0 };
|
||||
this._turnCtx.investigationModelOverride = null;
|
||||
this._turnCtx.memoryCtxCache = null;
|
||||
}
|
||||
|
||||
// 1. Prepare Context
|
||||
@@ -755,22 +767,29 @@ export class AgentExecutor {
|
||||
? `\n\n${renderSecondBrainTraceContext(secondBrainTrace)}`
|
||||
: '';
|
||||
const retrievalStartMs = Date.now();
|
||||
const memoryCtx = isCasualConversation
|
||||
? ''
|
||||
: await (async () => {
|
||||
this.resetTurnContext();
|
||||
return buildMemoryContextFn({
|
||||
currentPrompt: prompt || '',
|
||||
activeBrain,
|
||||
agentSkillFile: options.agentSkillFile,
|
||||
chatHistory: this.chatHistory,
|
||||
memoryManager: this.memoryManager,
|
||||
retrievalOrchestrator: this.retrievalOrchestrator,
|
||||
context: this.context,
|
||||
currentTaskId: this.currentTaskId,
|
||||
turnCtx: this._turnCtx,
|
||||
});
|
||||
})();
|
||||
// [v2.2.311] continuation depth 는 depth 0 의 memoryCtx 를 재사용 — 재검색
|
||||
// 3~8초 제거 + 빈 쿼리 재검색으로 청크가 갈리는 문제 제거 + 프롬프트 안정화
|
||||
// (dynamicBlocks 등 turnCtx 파생물도 depth 0 것이 그대로 유지된다).
|
||||
let memoryCtx: string;
|
||||
if (isCasualConversation) {
|
||||
memoryCtx = '';
|
||||
} else if (loopDepth > 0 && this._turnCtx.memoryCtxCache !== null) {
|
||||
memoryCtx = this._turnCtx.memoryCtxCache;
|
||||
} else {
|
||||
this.resetTurnContext();
|
||||
memoryCtx = await buildMemoryContextFn({
|
||||
currentPrompt: prompt || '',
|
||||
activeBrain,
|
||||
agentSkillFile: options.agentSkillFile,
|
||||
chatHistory: this.chatHistory,
|
||||
memoryManager: this.memoryManager,
|
||||
retrievalOrchestrator: this.retrievalOrchestrator,
|
||||
context: this.context,
|
||||
currentTaskId: this.currentTaskId,
|
||||
turnCtx: this._turnCtx,
|
||||
});
|
||||
this._turnCtx.memoryCtxCache = memoryCtx;
|
||||
}
|
||||
if (loopDepth === 0 && !isCasualConversation && this._turnCtx.retrieval) {
|
||||
recordTelemetry({
|
||||
kind: 'retrieval',
|
||||
@@ -806,9 +825,19 @@ export class AgentExecutor {
|
||||
? buildPriorTurnConclusionContext(this.chatHistory)
|
||||
: '';
|
||||
// System prompt build (agent vs astra mode) → src/agent/handlePrompt/{buildAgentModeSystemPrompt,buildAstraModeSystemPrompt}.ts
|
||||
const fullSystemPrompt: string = isAgentMode
|
||||
//
|
||||
// [KV 캐시 분리 v2.2.311] 기본 경로(호출자가 systemPrompt 를 넘기지 않은 경우)는
|
||||
// message[0] 을 *정적 본문만* 으로 고정하고, 날짜/RAG/[CONTEXT]/동적 블록 전부를
|
||||
// dynamicContextTail 로 분리해 computeBudgetedRequest 가 마지막 user 메시지 직전에
|
||||
// 삽입한다. llama.cpp prompt cache 가 정적 프롬프트+과거 히스토리를 재사용하게 되어
|
||||
// 매 턴 전체 재프리필(실측 13k 토큰 ≈ 90초)이 "직전 교환 + tail" 로 줄어든다.
|
||||
// 커스텀 systemPrompt 호출자(멀티에이전트 등)는 종전 단일-시스템 경로 유지.
|
||||
const kvSplitEnabled = getConfig().kvCachePromptSplit !== false
|
||||
&& options.systemPrompt === undefined;
|
||||
const builderBasePrompt = kvSplitEnabled ? '' : systemPrompt;
|
||||
const builtSystemPrompt: string = isAgentMode
|
||||
? buildAgentModeSystemPrompt({
|
||||
systemPrompt,
|
||||
systemPrompt: builderBasePrompt,
|
||||
agentSkillContext: options.agentSkillContext || '',
|
||||
modeBridgeCtx,
|
||||
priorConclusionCtx,
|
||||
@@ -824,7 +853,7 @@ export class AgentExecutor {
|
||||
})
|
||||
: buildAstraModeSystemPrompt({
|
||||
prompt,
|
||||
systemPrompt,
|
||||
systemPrompt: builderBasePrompt,
|
||||
modeBridgeCtx,
|
||||
priorConclusionCtx,
|
||||
designerCtx,
|
||||
@@ -839,6 +868,12 @@ export class AgentExecutor {
|
||||
knowledgeMix: this._turnCtx.knowledgeMix,
|
||||
dynamicBlocks: this._turnCtx.dynamicBlocks,
|
||||
});
|
||||
// Split 모드: head = 정적 프롬프트(불변), tail = 날짜 + 빌더 산출(동적 전부).
|
||||
// Legacy 모드: 종전 그대로 head 에 전부.
|
||||
const fullSystemPrompt: string = kvSplitEnabled ? getStaticSystemPrompt() : builtSystemPrompt;
|
||||
const dynamicContextTail: string | undefined = kvSplitEnabled
|
||||
? `${getDateTimeContextBlock()}${builtSystemPrompt}`
|
||||
: undefined;
|
||||
// Context budget computation → src/agent/handlePrompt/computeBudgetedRequest.ts
|
||||
const imageCount = (reqMessages as any[])
|
||||
.reduce((n, m) => n + (Array.isArray(m?.images) ? m.images.length : 0), 0);
|
||||
@@ -874,7 +909,7 @@ export class AgentExecutor {
|
||||
const lastUserIdx = reqMessages.map((m) => m.role).lastIndexOf('user');
|
||||
const lastUser = lastUserIdx >= 0 ? reqMessages[lastUserIdx] : undefined;
|
||||
const content = typeof lastUser?.content === 'string' ? lastUser.content : '';
|
||||
const sysTokens = estimateTokens(fullSystemPrompt) + 4;
|
||||
const sysTokens = estimateTokens(fullSystemPrompt) + (dynamicContextTail ? estimateTokens(dynamicContextTail) : 0) + 4;
|
||||
const mrCfg = {
|
||||
enabled: true,
|
||||
triggerRatio: config.mapReduceTriggerRatio,
|
||||
@@ -934,6 +969,7 @@ export class AgentExecutor {
|
||||
|
||||
const _budget = computeBudgetedRequest({
|
||||
fullSystemPrompt,
|
||||
dynamicContextTail,
|
||||
reqMessages,
|
||||
actualModel,
|
||||
config,
|
||||
@@ -1462,6 +1498,22 @@ export class AgentExecutor {
|
||||
await this.context.workspaceState.update('lastActionStr', currentActionStr);
|
||||
logInfo('Autonomous loop continuing after actions.', { loopDepth: loopDepth + 1, actions: report });
|
||||
|
||||
// [v2.2.312] 중간 라운드 본문 표시 — 액션과 *함께* 작성된 섹션이 화면에서
|
||||
// 증발하던 버그 수정. 종전엔 액션이 있는 라운드는 여기서 return 하며 본문을
|
||||
// 한 번도 webview 에 보내지 않았고(표시는 라이브 스트리밍뿐 — depth 0 전용),
|
||||
// 최종 라운드만 streamChunk 로 붙었다. 그 결과 모델이 히스토리에서 자기 이전
|
||||
// 섹션(1~3)을 보고 "## 4."부터 이어 써서, 사용자에게는 4번부터 시작하는
|
||||
// 보고서가 도착했다 (실사례). 라이브로 이미 표시된 depth 0 는 중복 방지로 제외.
|
||||
if (loopDepth > 0 || !postLiveDeltas) {
|
||||
try {
|
||||
const { stripActionTagsForDisplay } = await import('./agent/actions/stripForDisplay');
|
||||
const roundVisible = stripActionTagsForDisplay(finalAssistantContent);
|
||||
if (roundVisible) {
|
||||
this.webview.postMessage({ type: 'streamChunk', value: `${loopDepth > 0 ? '\n\n' : ''}${roundVisible}` });
|
||||
}
|
||||
} catch { /* 표시 실패가 루프를 막지 않음 */ }
|
||||
}
|
||||
|
||||
// Explicitly tell the AI to look at the results and continue
|
||||
const continuationPrompt = `The requested local action has been executed.\nAction report:\n${report.join('\n')}\nUse the action result messages already in the conversation to answer the user's original request directly, in the user's language. Do not say you are waiting for the next instruction.`;
|
||||
|
||||
@@ -1554,6 +1606,14 @@ export class AgentExecutor {
|
||||
if (hollowInv.hollow) {
|
||||
this.webview.postMessage({ type: 'streamChunk', value: formatHollowInvestigationFooter(hollowInv.fileMentions) });
|
||||
logInfo('Hollow Investigation 감지 (continuation).', { files: hollowInv.fileMentions, stats: this._turnCtx.actionStats });
|
||||
} else {
|
||||
// [v2.2.312] 반대 방향 — 파일을 읽고도 근거 인용 없는 일반론 분석 경고.
|
||||
const { detectUngroundedAnalysis, formatUngroundedAnalysisFooter } = await import('./intelligence/investigationPipeline');
|
||||
const ug = detectUngroundedAnalysis(finalAssistantContent, this._turnCtx.actionStats);
|
||||
if (ug.ungrounded) {
|
||||
this.webview.postMessage({ type: 'streamChunk', value: formatUngroundedAnalysisFooter(ug.readCount) });
|
||||
logInfo('Ungrounded Analysis 감지 (continuation).', { readCount: ug.readCount, fileRefs: ug.fileRefs });
|
||||
}
|
||||
}
|
||||
} catch { /* 감지 실패가 답변을 막지 않음 */ }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* [v2.2.312] 화면 표시용 액션 태그 제거.
|
||||
*
|
||||
* 액션 루프의 중간 라운드(액션과 함께 본문 섹션을 쓴 라운드)를 사용자 버블에
|
||||
* 표시할 때, <read_file/> 같은 실행 태그 원문이 그대로 노출되지 않도록 걷어낸다.
|
||||
* 실행 자체는 이 함수와 무관 — executeActions 는 원문(cleanedVisible)을 그대로
|
||||
* 받는다. 여기는 *표시 전용* 경로다.
|
||||
*/
|
||||
|
||||
const ACTION_TAG_NAMES = [
|
||||
'read_file', 'list_files', 'investigate_files',
|
||||
'create_file', 'edit_file', 'delete_file',
|
||||
'run_command', 'run_code',
|
||||
'read_brain', 'list_brain',
|
||||
'read_sheet', 'write_sheet', 'append_sheet',
|
||||
'fetch_url', 'calculate',
|
||||
'create_calendar_event', 'add_task', 'update_task', 'complete_task',
|
||||
// edit_file 내부 서브 태그 — 블록 제거가 실패한 잔재까지 정리.
|
||||
'search', 'replace',
|
||||
];
|
||||
|
||||
export function stripActionTagsForDisplay(text: string): string {
|
||||
let t = text || '';
|
||||
for (const name of ACTION_TAG_NAMES) {
|
||||
// 자기닫힘 <tag ... /> → 블록 <tag ...>...</tag> → 잔재 여는/닫는 태그 순.
|
||||
t = t.replace(new RegExp(`<${name}\\b[^>]*/>`, 'gi'), '');
|
||||
t = t.replace(new RegExp(`<${name}\\b[^>]*>[\\s\\S]*?</${name}>`, 'gi'), '');
|
||||
t = t.replace(new RegExp(`</?${name}\\b[^>]*>`, 'gi'), '');
|
||||
}
|
||||
return t.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
@@ -13,6 +13,14 @@ import { buildDroppedHistorySummary } from '../../lib/contextBuilders/droppedHis
|
||||
|
||||
export interface ComputeBudgetedRequestInput {
|
||||
fullSystemPrompt: string;
|
||||
/**
|
||||
* [KV 캐시 분리 v2.2.311] 턴마다 변하는 컨텍스트(RAG/[CONTEXT]/날짜/동적 블록)를
|
||||
* message[0] 이 아니라 *마지막 user 메시지 직전*의 internal system 메시지로 싣는다.
|
||||
* message[0](fullSystemPrompt)은 턴 사이에 불변 → llama.cpp prompt cache 가
|
||||
* "정적 프롬프트 + 과거 히스토리" 프리픽스를 재사용하고, 매 턴 프리필은
|
||||
* "직전 교환 + 이 블록"만으로 줄어든다. undefined 면 종전 단일-시스템 동작.
|
||||
*/
|
||||
dynamicContextTail?: string;
|
||||
/** Caller is expected to have run `capChatHistory` on this already. */
|
||||
reqMessages: ChatMessage[];
|
||||
actualModel: string;
|
||||
@@ -126,12 +134,27 @@ export function computeBudgetedRequest(input: ComputeBudgetedRequestInput): Comp
|
||||
1024,
|
||||
Math.floor((ctxLimits.contextLength - ctxLimits.safetyMargin - preferredOutputReserve - imageTokenReserve) * 0.65)
|
||||
);
|
||||
const { prompt: budgetedSystemPrompt, truncated: systemTruncated } =
|
||||
truncateSystemPromptContext(fullSystemPrompt, systemCapTokens);
|
||||
// Split 모드면 [CONTEXT] 는 dynamicContextTail 쪽에 있으므로 truncation 도 tail 에 적용.
|
||||
// (정적 head 에는 [CONTEXT] 마커가 없어 truncate 가 no-op — head 는 그대로 둔다.)
|
||||
const splitMode = typeof input.dynamicContextTail === 'string' && input.dynamicContextTail.trim().length > 0;
|
||||
let budgetedSystemPrompt = fullSystemPrompt;
|
||||
let budgetedTail = splitMode ? input.dynamicContextTail! : '';
|
||||
let systemTruncated = false;
|
||||
if (splitMode) {
|
||||
const headTokens = estimateTokens(fullSystemPrompt);
|
||||
const tailCap = Math.max(512, systemCapTokens - headTokens);
|
||||
const t = truncateSystemPromptContext(budgetedTail, tailCap);
|
||||
budgetedTail = t.prompt;
|
||||
systemTruncated = t.truncated;
|
||||
} else {
|
||||
const t = truncateSystemPromptContext(fullSystemPrompt, systemCapTokens);
|
||||
budgetedSystemPrompt = t.prompt;
|
||||
systemTruncated = t.truncated;
|
||||
}
|
||||
if (systemTruncated) {
|
||||
logInfo('System prompt context truncated to fit the context window.', { model: actualModel, systemCapTokens });
|
||||
}
|
||||
const systemTokens = estimateTokens(budgetedSystemPrompt) + 4;
|
||||
const systemTokens = estimateTokens(budgetedSystemPrompt) + (splitMode ? estimateTokens(budgetedTail) + 4 : 0) + 4;
|
||||
|
||||
// (2) 대화 기록 압축.
|
||||
const historyBudget = Math.max(
|
||||
@@ -156,9 +179,27 @@ export function computeBudgetedRequest(input: ComputeBudgetedRequestInput): Comp
|
||||
}
|
||||
}
|
||||
|
||||
// Split 모드: 동적 컨텍스트를 마지막 user 메시지 *직전*에 삽입. 이 위치라야
|
||||
// (a) 이전 요청과의 공통 프리픽스(정적 시스템 + 과거 히스토리)가 최대로 보존되고
|
||||
// (b) continuation(액션 결과가 뒤에 붙는 라운드)에서도 같은 자리라 턴 내 캐시가 유지된다.
|
||||
// user 메시지가 없으면(이론상) 히스토리 끝에 붙인다 — 생성 직전이므로 여전히 유효.
|
||||
let historyWithTail = budgetedHistory;
|
||||
if (splitMode) {
|
||||
const tailMsg: ChatMessage = { role: 'system', content: budgetedTail, internal: true };
|
||||
const lastUserIdx = (() => {
|
||||
for (let i = budgetedHistory.length - 1; i >= 0; i--) {
|
||||
if (budgetedHistory[i].role === 'user') return i;
|
||||
}
|
||||
return -1;
|
||||
})();
|
||||
historyWithTail = lastUserIdx >= 0
|
||||
? [...budgetedHistory.slice(0, lastUserIdx), tailMsg, ...budgetedHistory.slice(lastUserIdx)]
|
||||
: [...budgetedHistory, tailMsg];
|
||||
}
|
||||
|
||||
const messagesForRequest: ChatMessage[] = [
|
||||
{ role: 'system', content: budgetedSystemPrompt, internal: true },
|
||||
...budgetedHistory
|
||||
...historyWithTail
|
||||
];
|
||||
|
||||
// (3) 동적 출력 상한.
|
||||
|
||||
@@ -233,6 +233,19 @@ export interface IAgentConfig {
|
||||
* 할루시네이션 대책. 빈 문자열이면 비활성 (현재 선택 모델 그대로).
|
||||
*/
|
||||
investigationModel: string;
|
||||
/**
|
||||
* [v2.2.311] KV 캐시 친화 프롬프트 분리 — message[0] 을 정적 본문으로 고정하고
|
||||
* 날짜/RAG/[CONTEXT]/동적 블록을 마지막 user 메시지 직전의 system 메시지로 옮긴다.
|
||||
* llama.cpp prompt cache 프리픽스 재사용으로 턴당 재프리필을 대폭 축소. 기본 true.
|
||||
* 특정 모델 템플릿에서 문제 시 false 로 종전 단일-시스템 프롬프트 복귀.
|
||||
*/
|
||||
kvCachePromptSplit: boolean;
|
||||
/**
|
||||
* [v2.2.311] RAG/메모리 검색 토큰 예산 상한. 0 = 자동(컨텍스트 창의 12%,
|
||||
* 2,500~6,000 클램프). 종전 자동값(창의 25%, 최대 80k)은 32k 창에서 8k 토큰을
|
||||
* 검색 결과에 써서 로컬 프리필만 수십 초를 태웠다 — 상한을 낮춰 답변 지연을 줄인다.
|
||||
*/
|
||||
retrievalTokenBudget: number;
|
||||
/** Self-check 전용 모델 ID. 비면 defaultModel. 빠른 작은 모델 권장. */
|
||||
selfCheckModel: string;
|
||||
/** Self-check LLM 호출 타임아웃 (초). 기본 6. */
|
||||
@@ -583,6 +596,8 @@ export function getConfig(): IAgentConfig {
|
||||
intentClarificationStrictness: (cfg.get<string>('intentClarificationStrictness', 'medium') as 'low' | 'medium' | 'high') || 'medium',
|
||||
citationTraceEnabled: cfg.get<boolean>('citationTraceEnabled', true),
|
||||
investigationModel: (cfg.get<string>('investigationModel', '') || '').trim(),
|
||||
kvCachePromptSplit: cfg.get<boolean>('kvCachePromptSplit', true),
|
||||
retrievalTokenBudget: Math.max(0, cfg.get<number>('retrievalTokenBudget', 0) || 0),
|
||||
selfCheckEnabled: cfg.get<boolean>('selfCheckEnabled', false),
|
||||
selfCheckModel: cfg.get<string>('selfCheckModel', '') || '',
|
||||
selfCheckTimeoutSec: Math.max(1, Math.min(60, cfg.get<number>('selfCheckTimeoutSec', 6))),
|
||||
|
||||
@@ -86,6 +86,17 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
// 두뇌에 기록. 자기 평가가 구식 스냅샷 대신 항상 현행 소스를 근거로 하게 한다.
|
||||
void ensureFeatureInventory(context);
|
||||
|
||||
// [v2.2.311] 제2뇌 캐시 워밍 — 활성화 직후 백그라운드로 파일 목록 + 토큰 인덱스
|
||||
// (수십 MB JSON)를 메모리에 올려, 세션 첫 질의가 그 비용(수 초)을 뒤집어쓰지
|
||||
// 않게 한다. 워처가 같이 걸려 이후 턴은 변경 없으면 워크/stat 을 통째로 생략.
|
||||
try {
|
||||
const { getActiveBrainProfile } = require('./utils') as typeof import('./utils');
|
||||
const { warmBrainCache } = require('./retrieval/brainWatch') as typeof import('./retrieval/brainWatch');
|
||||
const { getBrainTokenIndex } = require('./retrieval/brainIndex') as typeof import('./retrieval/brainIndex');
|
||||
const brainPath = getActiveBrainProfile()?.localBrainPath;
|
||||
if (brainPath) warmBrainCache(brainPath, (files) => { getBrainTokenIndex(brainPath, files); });
|
||||
} catch { /* 워밍 실패는 비치명 — 첫 질의가 종전 경로로 처리 */ }
|
||||
|
||||
// Initialize Astra Path Resolver (.astra → ConnectAI/.astra/)
|
||||
initAstraPathResolver(context);
|
||||
|
||||
|
||||
@@ -226,3 +226,31 @@ export function formatHollowInvestigationFooter(fileMentions: string[]): string
|
||||
'내용 검증이 필요하면 다시 요청해 주세요 — 다음 턴에서 `<investigate_files>`로 각 파일을 실제로 읽고 답합니다.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* [v2.2.312] 헛조사의 반대 방향 실패 — 파일을 *읽고도* 답변에 파일 근거를 하나도
|
||||
* 인용하지 않은 "일반론 분석" 감지. 실사례: 프로젝트 분석 보고가 7줄짜리
|
||||
* 프로젝트명만 바꿔도 성립하는 총평("~가 인상적입니다")으로 끝남.
|
||||
* 조건: read/investigate 를 1회 이상 했고, 답변이 실질 분량(300자+)인데
|
||||
* 파일 참조가 2개 미만 → ungrounded. (짧은 확인형 응답은 오탐 방지로 제외.)
|
||||
*/
|
||||
export function detectUngroundedAnalysis(
|
||||
answer: string,
|
||||
stats: TurnActionStats | null | undefined,
|
||||
): { ungrounded: boolean; readCount: number; fileRefs: number } {
|
||||
const readCount = (stats?.reads ?? 0) + (stats?.investigates ?? 0);
|
||||
if (!stats || readCount === 0) return { ungrounded: false, readCount, fileRefs: 0 };
|
||||
const body = (answer || '').trim();
|
||||
if (body.length < 300) return { ungrounded: false, readCount, fileRefs: 0 };
|
||||
const refs = new Set(Array.from(body.matchAll(FILE_MENTION_RE), m => m[0]));
|
||||
return { ungrounded: refs.size < 2, readCount, fileRefs: refs.size };
|
||||
}
|
||||
|
||||
export function formatUngroundedAnalysisFooter(readCount: number): string {
|
||||
return [
|
||||
'',
|
||||
'---',
|
||||
`⚠️ **근거 인용 없음** — 파일을 ${readCount}회 읽고도 답변에 실제 파일·코드 근거가 인용되지 않았습니다. 위 내용은 일반론일 수 있습니다.`,
|
||||
'구체 근거가 필요하면 "실제 읽은 파일명을 인용해서 다시 정리해줘"라고 요청해 주세요.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -154,6 +154,42 @@ export const DEFAULT_TASK_REQUIREMENTS: TaskRequirement[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// [v2.2.312] 실사례 대응 — "E:\...프로젝트를 분석하고 어떻게 되어 있는지 보고해줘"가
|
||||
// 어떤 유형에도 안 걸려 7줄짜리 일반론 총평으로 끝났다. '보고해줘/현황 보고' 류를
|
||||
// 잡아 파일 근거 인용을 필수 요소로 강제한다.
|
||||
id: 'analysis-report',
|
||||
label: '분석 보고',
|
||||
detectKeywords: ['분석[^\\n]{0,20}보고', '보고해\\s*줘?', '현황\\s*(보고|정리)', '어떻게\\s*되어\\s*있는지', '검토[^\\n]{0,10}보고'],
|
||||
coverageCheck: true,
|
||||
elements: [
|
||||
{
|
||||
id: 'overview', label: '보고 개요',
|
||||
hint: '첫 줄에 "보고 개요: (분석 대상) — (확인한 근거: 실제 읽은 파일 수) — (한 줄 결론)" 형식으로 시작. 요청자가 첫 줄만 읽어도 무엇을 어떤 근거로 판단했는지 알 수 있게.',
|
||||
detectPatterns: ['보고 ?개요', '분석 ?대상', '개요'],
|
||||
},
|
||||
{
|
||||
id: 'file-evidence', label: '파일 근거',
|
||||
hint: '모든 주요 주장에 이번 대화에서 실제로 읽은 파일명·경로를 인용. 프로젝트 이름을 바꿔도 성립하는 일반론("~가 인상적입니다", "~할 가능성이 높습니다")은 금지 — 근거 없는 문장은 쓰지 말 것.',
|
||||
detectPatterns: ['\\.(ts|tsx|js|mjs|cjs|py|md|json|html|css|java|cs)\\b', 'src[\\\\/]', 'package\\.json'],
|
||||
},
|
||||
{
|
||||
id: 'structure', label: '구조·현황',
|
||||
hint: '프로젝트/대상의 실제 구성 — 핵심 모듈, 진행 상태, 확인된 사실. STATUS.md 등 현재 상태 기록이 있으면 그것을 기준으로.',
|
||||
detectPatterns: ['구조', '구성', '모듈', '아키텍처', '현황', '상태'],
|
||||
},
|
||||
{
|
||||
id: 'findings', label: '핵심 발견·이슈',
|
||||
hint: '분석에서 드러난 강점·문제·이슈. 각 항목에 근거 파일 연결.',
|
||||
detectPatterns: ['발견', '강점', '문제', '이슈', '리스크', '과제'],
|
||||
},
|
||||
{
|
||||
id: 'next-steps', label: '다음 단계',
|
||||
hint: '이 보고를 받은 사람이 취할 수 있는 구체적 다음 행동.',
|
||||
detectPatterns: ['다음 ?단계', '권장', '제안', '후속'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'work-research',
|
||||
label: '업무조사',
|
||||
@@ -233,6 +269,9 @@ export function buildRequirementGraphBlock(
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('제출 전 위 체크리스트를 스스로 점검하고, 누락 요소가 있으면 보완 후 답변할 것.');
|
||||
// [v2.2.312] 업무 유형이 감지된 답변은 일반 "최대 3섹션" 규칙보다 필수 요소 커버가
|
||||
// 우선 — 보고서가 3섹션 제한 때문에 얕아지는 상충 제거.
|
||||
lines.push('이 업무 유형이 감지된 답변에서는 "최대 3섹션" 일반 규칙보다 필수 요소 커버가 우선한다 — 요소별로 섹션을 나눠도 된다.');
|
||||
lines.push('[/TASK REQUIREMENTS]');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -166,11 +166,13 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
|
||||
if (domainScope.folders.length > 0) effectiveScopeFolders = domainScope.folders;
|
||||
}
|
||||
|
||||
// Context 윈도우 비례 retrieval 예산. 32K → 8K, 230K → 57K, 80K cap (scoring 속도).
|
||||
const scaledTotalBudget = Math.min(
|
||||
80000,
|
||||
Math.max(8000, Math.floor(config.contextLength * 0.25)),
|
||||
);
|
||||
// Context 윈도우 비례 retrieval 예산.
|
||||
// [v2.2.311] 종전 자동값(창의 25%, 8k~80k)은 32K 창에서 검색 결과에만 8k 토큰을 태워
|
||||
// 로컬 프리필 수십 초의 주범이었다. 자동값을 창의 12%(2.5k~6k 클램프)로 낮추고,
|
||||
// 더 풍부한 근거가 필요하면 g1nation.retrievalTokenBudget 으로 명시 상향.
|
||||
const scaledTotalBudget = config.retrievalTokenBudget > 0
|
||||
? config.retrievalTokenBudget
|
||||
: Math.min(6000, Math.max(2500, Math.floor(config.contextLength * 0.12)));
|
||||
|
||||
// medium-term layer 용 옛 세션 후보. sidebar 가 직접 쓰는 key 를 read-through.
|
||||
const rawSessions = deps.context.globalState.get<any[]>('chat_sessions', []) || [];
|
||||
|
||||
@@ -17,6 +17,7 @@ import { tokenize, countConflictIndicators } from './scoring';
|
||||
import { detectLessonKind } from './lessonHelpers';
|
||||
import { splitIntoSections } from './chunker';
|
||||
import { logInfo } from '../utils';
|
||||
import { ensureBrainWatcher, getBrainGeneration } from './brainWatch';
|
||||
|
||||
// v5 adds optional per-file `chunks` (section-level index, Phase 1-가) alongside the
|
||||
// v4 per-file `embedding`. Older indexes are auto-rebuilt on first load — no migration
|
||||
@@ -109,6 +110,16 @@ interface BrainState {
|
||||
diskPath: string | null; // null if we can't determine a writable path
|
||||
writeTimer?: ReturnType<typeof setTimeout>;
|
||||
evictTimer?: ReturnType<typeof setTimeout>;
|
||||
/**
|
||||
* [v2.2.311] statSync 생략 최적화의 장부. statValidatedGen 세대에서 실제로
|
||||
* stat(또는 재색인)을 거친 파일만 statValidated 에 들어간다 — 세대가 그대로면
|
||||
* 그 파일들은 재-stat 없이 캐시를 신뢰한다. 세대가 바뀌면(파일시스템 변경)
|
||||
* 집합을 비워 전 파일이 다시 한 번 stat 을 거친다. 파일 *단위* 로 기록하는
|
||||
* 이유: 스코프 검색(부분 목록) 뒤 전체 검색이 와도, 검증 안 된 파일이
|
||||
* 신뢰 경로로 새지 않게 하기 위함.
|
||||
*/
|
||||
statValidatedGen?: number;
|
||||
statValidated?: Set<string>;
|
||||
}
|
||||
|
||||
const _states = new Map<string, BrainState>();
|
||||
@@ -117,7 +128,16 @@ const _states = new Map<string, BrainState>();
|
||||
// 확장 호스트 메모리에 세션 내내 상주했다. 인덱스는 mtime 키 디스크 캐시에서 언제든 싸게
|
||||
// 재로드되므로, 마지막 사용 후 IDLE_EVICT_MS 가 지나면 dirty 를 flush 하고 메모리에서 내린다.
|
||||
// 상시 인터벌 없이 접근할 때마다 재예약되는 one-shot unref 타이머라 그 자체의 발열 비용은 없다.
|
||||
const IDLE_EVICT_MS = 30 * 60_000;
|
||||
// [v2.2.311] 30분 → 2시간: 점심/회의 뒤 첫 질의가 대형 인덱스(수십 MB JSON) 재파싱을
|
||||
// 뒤집어쓰는 빈도를 줄인다. 메모리 절약보다 응답 지연이 더 아픈 로컬-퍼스트 워크로드.
|
||||
const IDLE_EVICT_MS = 120 * 60_000;
|
||||
|
||||
/**
|
||||
* [v2.2.311] stat 생략 신뢰 명부에 올리지 않는 "갓 수정된 파일" 창. fs.watch 이벤트는
|
||||
* 비동기(+디바운스)라, 수정 직후 같은 틱에 들어온 질의는 세대 bump 를 아직 못 본다 —
|
||||
* 그 창 안의 파일은 명부에서 제외해 다음 호출에서도 stat 을 거치게 한다.
|
||||
*/
|
||||
const RECENT_MTIME_GUARD_MS = 3000;
|
||||
|
||||
function touchEviction(st: BrainState, brainPath: string): void {
|
||||
if (st.evictTimer) clearTimeout(st.evictTimer);
|
||||
@@ -206,8 +226,35 @@ export function getBrainTokenIndex(brainPath: string, files: string[]): IndexedB
|
||||
const seen = new Set<string>();
|
||||
let reindexed = 0;
|
||||
|
||||
// [v2.2.311] 워처 세대가 마지막 stat 검증 이후 그대로면 — 파일시스템 변경 없음 —
|
||||
// 그 세대에서 이미 stat 을 거친 파일의 statSync 를 생략하고 캐시 항목을 신뢰.
|
||||
// 워처가 없으면(gen === -1) 종전대로 매번 stat — 정확성 저하 없음.
|
||||
ensureBrainWatcher(brainPath);
|
||||
const watchGen = getBrainGeneration(brainPath);
|
||||
if (watchGen >= 0 && st.statValidatedGen !== watchGen) {
|
||||
st.statValidatedGen = watchGen;
|
||||
st.statValidated = new Set();
|
||||
}
|
||||
const validated = watchGen >= 0 ? (st.statValidated ?? (st.statValidated = new Set())) : null;
|
||||
|
||||
for (const file of files) {
|
||||
seen.add(file);
|
||||
const cachedFast = validated && validated.has(file) ? st.index.entries[file] : undefined;
|
||||
if (cachedFast
|
||||
&& Array.isArray(cachedFast.tokens)
|
||||
&& Array.isArray(cachedFast.titleTokens)) {
|
||||
out.push({
|
||||
filePath: file,
|
||||
relativePath: cachedFast.relativePath,
|
||||
title: cachedFast.title,
|
||||
tokens: cachedFast.tokens,
|
||||
titleTokens: cachedFast.titleTokens,
|
||||
conflictCount: cachedFast.conflictCount || 0,
|
||||
mtimeMs: cachedFast.mtimeMs,
|
||||
kind: cachedFast.kind || '',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(file);
|
||||
@@ -220,6 +267,7 @@ export function getBrainTokenIndex(brainPath: string, files: string[]): IndexedB
|
||||
&& cached.size === stat.size
|
||||
&& Array.isArray(cached.tokens)
|
||||
&& Array.isArray(cached.titleTokens)) {
|
||||
if (Date.now() - stat.mtimeMs > RECENT_MTIME_GUARD_MS) validated?.add(file);
|
||||
out.push({
|
||||
filePath: file,
|
||||
relativePath: cached.relativePath,
|
||||
@@ -255,6 +303,7 @@ export function getBrainTokenIndex(brainPath: string, files: string[]): IndexedB
|
||||
st.index.entries[file] = entry;
|
||||
st.dirty = true;
|
||||
reindexed++;
|
||||
if (Date.now() - entry.mtimeMs > RECENT_MTIME_GUARD_MS) validated?.add(file);
|
||||
out.push({
|
||||
filePath: file,
|
||||
relativePath,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* Brain Watch — 제2뇌 파일시스템 워처 + 상주 파일 목록 캐시 (v2.2.311)
|
||||
*
|
||||
* 문제: 검색은 이미 mtime 키 토큰 인덱스(brain-index.json)로 파일 *내용* 재파싱을
|
||||
* 피하지만, 매 질의마다 (a) 전체 디렉터리 재귀 워크(수천 파일)와 (b) 파일별
|
||||
* fs.statSync 수천 회를 다시 수행했다 — 인덱스가 신선한지 확인할 방법이 "전부
|
||||
* 다시 stat" 뿐이었기 때문. 브레인 6,500 파일 기준 턴당 0.5~1.5초.
|
||||
*
|
||||
* 해결: 브레인 루트에 재귀 fs.watch 를 하나 걸고 변경 시 세대(generation) 카운터만
|
||||
* 올린다. 세대가 그대로면 — 파일 목록도, mtime 도 안 변한 것 — 워크/stat 을 통째로
|
||||
* 생략하고 메모리의 목록·인덱스를 그대로 신뢰한다. 이것이 "제2뇌를 항상 메모리에
|
||||
* 올려놓기"의 안전한 형태다: 원본 파일을 통째로 상주시키는 대신(수백 MB), 검색에
|
||||
* 실제로 쓰는 파생물(토큰 인덱스 + 파일 목록)을 상주시키고 워처가 무효화를 맡는다.
|
||||
*
|
||||
* 워처를 못 여는 환경(권한/네트워크 드라이브 등)에서는 generation 이 -1 로 고정되고
|
||||
* 모든 호출자는 종전 동작(매번 stat)으로 폴백한다 — 정확성은 워처 가용성과 무관.
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { findBrainFiles, invalidateBrainFilesCache, logInfo } from '../utils';
|
||||
|
||||
interface WatchState {
|
||||
watcher: fs.FSWatcher | null;
|
||||
/** 파일시스템 변경마다 증가. 워처 없으면 상태 자체가 failed=true. */
|
||||
generation: number;
|
||||
failed: boolean;
|
||||
/** generation 기준 파일 목록 캐시. */
|
||||
listGeneration: number;
|
||||
list: string[] | null;
|
||||
/** 이벤트 폭주(대량 sync) 시 bump 를 묶는 디바운스 타이머. */
|
||||
bumpTimer: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
const _watches = new Map<string, WatchState>();
|
||||
|
||||
/** 인덱스 캐시 등 우리 자신의 산출물 경로 — 이 아래 변경은 무효화 사유가 아니다. */
|
||||
function isSelfNoise(relPath: string): boolean {
|
||||
// .astra (brain-index.json, eval, growth …) 와 .git 은 검색 대상도 아니고
|
||||
// 턴마다 우리가 직접 쓰는 곳이라, 여기서 세대를 올리면 캐시가 영원히 못 산다.
|
||||
return /(^|[\\/])(\.astra|\.git)([\\/]|$)/.test(relPath);
|
||||
}
|
||||
|
||||
function bump(st: WatchState, brainPath: string): void {
|
||||
if (st.bumpTimer) return; // 이미 예약됨 — 몰아서 1회
|
||||
st.bumpTimer = setTimeout(() => {
|
||||
st.bumpTimer = null;
|
||||
st.generation++;
|
||||
st.list = null;
|
||||
invalidateBrainFilesCache(brainPath);
|
||||
}, 300);
|
||||
if (typeof (st.bumpTimer as any).unref === 'function') (st.bumpTimer as any).unref();
|
||||
}
|
||||
|
||||
/**
|
||||
* 브레인 루트에 워처를 보장한다 (idempotent). 실패해도 throw 하지 않는다 —
|
||||
* 이후 getBrainGeneration() 이 -1 을 반환해 호출자들이 stat 경로로 폴백.
|
||||
*/
|
||||
export function ensureBrainWatcher(brainPath: string): void {
|
||||
if (!brainPath) return;
|
||||
if (_watches.has(brainPath)) return;
|
||||
const st: WatchState = { watcher: null, generation: 0, failed: false, listGeneration: -1, list: null, bumpTimer: null };
|
||||
_watches.set(brainPath, st);
|
||||
try {
|
||||
// recursive fs.watch 는 Windows/macOS 에서 네이티브 지원. (Linux 는 Node 20+.)
|
||||
st.watcher = fs.watch(brainPath, { recursive: true }, (_event, filename) => {
|
||||
const rel = filename ? String(filename) : '';
|
||||
if (rel && isSelfNoise(rel)) return;
|
||||
bump(st, brainPath);
|
||||
});
|
||||
st.watcher.on('error', () => {
|
||||
st.failed = true;
|
||||
try { st.watcher?.close(); } catch { /* already closed */ }
|
||||
st.watcher = null;
|
||||
logInfo('Brain watcher failed — falling back to per-query stat.', { brainPath });
|
||||
});
|
||||
logInfo('Brain watcher started — file list & stat checks now cached until change.', { brainPath });
|
||||
} catch (e: any) {
|
||||
st.failed = true;
|
||||
st.watcher = null;
|
||||
logInfo('Brain watcher unavailable — falling back to per-query stat.', { brainPath, error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 세대 번호. 워처가 없거나 죽었으면 -1 — 호출자는 캐시 신뢰 최적화를 꺼야 한다.
|
||||
*/
|
||||
export function getBrainGeneration(brainPath: string): number {
|
||||
const st = _watches.get(brainPath);
|
||||
if (!st || st.failed || !st.watcher) return -1;
|
||||
return st.generation;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세대 기반 파일 목록 — 세대가 그대로면 디렉터리 워크 없이 메모리 목록을 반환.
|
||||
* 워처가 없으면 findBrainFiles(5초 TTL)로 폴백해 종전과 동일하게 동작.
|
||||
*/
|
||||
export function listBrainFilesCached(brainPath: string): string[] {
|
||||
if (!brainPath) return [];
|
||||
ensureBrainWatcher(brainPath);
|
||||
const st = _watches.get(brainPath)!;
|
||||
if (st.failed || !st.watcher) return findBrainFiles(brainPath);
|
||||
if (st.list !== null && st.listGeneration === st.generation) return st.list.slice();
|
||||
const files = findBrainFiles(brainPath);
|
||||
st.list = files.slice();
|
||||
st.listGeneration = st.generation;
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성화 직후 백그라운드 워밍 — 첫 질의가 78MB 인덱스 파싱 + 전체 워크를
|
||||
* 뒤집어쓰지 않도록 미리 올려 둔다. 실패는 조용히 무시 (다음 질의가 정상 경로).
|
||||
*/
|
||||
export function warmBrainCache(brainPath: string, warmTokenIndex: (files: string[]) => void): void {
|
||||
if (!brainPath) return;
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
const files = listBrainFilesCached(brainPath);
|
||||
warmTokenIndex(files);
|
||||
logInfo('Brain cache warmed.', { brainPath, files: files.length, ms: Date.now() - t0 });
|
||||
} catch (e: any) {
|
||||
logInfo('Brain cache warm failed (non-fatal).', { brainPath, error: e?.message ?? String(e) });
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
/** 테스트/브레인 전환용 — 워처 해제. */
|
||||
export function disposeBrainWatcher(brainPath?: string): void {
|
||||
const targets = brainPath ? [brainPath] : Array.from(_watches.keys());
|
||||
for (const key of targets) {
|
||||
const st = _watches.get(key);
|
||||
if (!st) continue;
|
||||
if (st.bumpTimer) { clearTimeout(st.bumpTimer); st.bumpTimer = null; }
|
||||
try { st.watcher?.close(); } catch { /* noop */ }
|
||||
_watches.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,8 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { BrainProfile } from '../config';
|
||||
import { findBrainFiles, summarizeText } from '../utils';
|
||||
import { summarizeText } from '../utils';
|
||||
import { listBrainFilesCached } from './brainWatch';
|
||||
import { isInside } from '../lib/paths';
|
||||
import { MemoryManager } from '../memory';
|
||||
import { RetrievalChunk, RetrievalResult, ContextBudgetConfig } from './types';
|
||||
@@ -329,7 +330,8 @@ export class RetrievalOrchestrator {
|
||||
try {
|
||||
const scoped = (file: string) => scopeFolders.length === 0
|
||||
|| scopeFolders.some((folder) => isInside(folder, file));
|
||||
const allFiles = findBrainFiles(brain.localBrainPath)
|
||||
// [v2.2.311] 워처 세대 기반 상주 목록 — 변경 없으면 디렉터리 재귀 워크 생략.
|
||||
const allFiles = listBrainFilesCached(brain.localBrainPath)
|
||||
.filter(scoped)
|
||||
.filter((file) => {
|
||||
const rel = path.relative(brain.localBrainPath, file);
|
||||
|
||||
+27
-5
@@ -494,14 +494,19 @@ function getEnvironmentBlock(): string {
|
||||
- Canonical <run_command> example for this OS: ${example}`;
|
||||
}
|
||||
|
||||
export function getSystemPrompt(): string {
|
||||
const now = new Date();
|
||||
const dateTimeStr = now.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'long', hour: '2-digit', minute: '2-digit' });
|
||||
const isoDate = now.toISOString().split('T')[0];
|
||||
/**
|
||||
* [KV 캐시 분리 v2.2.311] 시스템 프롬프트의 *턴 사이에 변하지 않는* 부분만 반환.
|
||||
* 여기에 분 단위 시각 같은 가변 요소가 섞이면 LM Studio(llama.cpp)의 prompt cache 가
|
||||
* 요청 첫 토큰부터 무효화되어 매 턴 1만+ 토큰 전체를 다시 프리필한다 — 실측 94초/턴의
|
||||
* 주범. 날짜/시각은 `getDateTimeContextBlock()` 으로 분리해 요청 후미(동적 컨텍스트
|
||||
* 메시지)에 싣는다.
|
||||
*/
|
||||
export function getStaticSystemPrompt(): string {
|
||||
const envBlock = getEnvironmentBlock();
|
||||
const base = `${BASE_SYSTEM_PROMPT}\n\n[CURRENT DATE/TIME]\nToday: ${isoDate} (${dateTimeStr})\nUse this date as the absolute reference for any date-related calculations (e.g., "this week", "today", "yesterday").\n\n[출력 위생 규칙 — 반드시 준수]\n- 자연스러운 한국어로 작성하고, 한 단어 안에 한글과 영문 알파벳을 섞지 마시오 ("결ently", "인orp" 같은 깨진 합성 표기 절대 금지).\n- 외래어·기술 용어는 완전한 한글 표기 또는 완전한 영문 단어 중 하나로 일관되게 쓰시오.\n- 내부 검증·체크 로그(Consistency/Completeness/Accuracy 등) 블록을 사용자 출력에 포함하지 마시오.${envBlock}`;
|
||||
const base = `${BASE_SYSTEM_PROMPT}\n\n[출력 위생 규칙 — 반드시 준수]\n- 자연스러운 한국어로 작성하고, 한 단어 안에 한글과 영문 알파벳을 섞지 마시오 ("결ently", "인orp" 같은 깨진 합성 표기 절대 금지).\n- 외래어·기술 용어는 완전한 한글 표기 또는 완전한 영문 단어 중 하나로 일관되게 쓰시오.\n- 내부 검증·체크 로그(Consistency/Completeness/Accuracy 등) 블록을 사용자 출력에 포함하지 마시오.${envBlock}`;
|
||||
// Self-Reflector Phase A — 사용자 설정이 켜져 있으면 답변 끝에 자기검증
|
||||
// 블록을 강제하는 룰을 prepend. require로 동적 로드해 순환 import 회피.
|
||||
// (설정은 세션 중 바뀔 수 있지만 드물다 — 바뀌면 그 턴만 캐시 미스, 문제 없음.)
|
||||
try {
|
||||
const { getConfig } = require('./config') as typeof import('./config');
|
||||
const { appendSelfReflectorRule } = require('./features/selfReflector/selfReflectorPrompt') as typeof import('./features/selfReflector/selfReflectorPrompt');
|
||||
@@ -513,4 +518,21 @@ export function getSystemPrompt(): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** [CURRENT DATE/TIME] 블록 — 가변이므로 정적 프롬프트가 아니라 동적 컨텍스트에 주입. */
|
||||
export function getDateTimeContextBlock(): string {
|
||||
const now = new Date();
|
||||
const dateTimeStr = now.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'long', hour: '2-digit', minute: '2-digit' });
|
||||
const isoDate = now.toISOString().split('T')[0];
|
||||
return `[CURRENT DATE/TIME]\nToday: ${isoDate} (${dateTimeStr})\nUse this date as the absolute reference for any date-related calculations (e.g., "this week", "today", "yesterday").`;
|
||||
}
|
||||
|
||||
export function getSystemPrompt(): string {
|
||||
// 기존 호출부 호환 — 정적 본문 + 날짜 블록을 합친 종전 형태 그대로.
|
||||
const staticPart = getStaticSystemPrompt();
|
||||
const dateBlock = getDateTimeContextBlock();
|
||||
// BASE 직후·위생 규칙 앞이던 종전 위치와 달리 끝에 붙지만, 단독 사용처(브리핑·평가
|
||||
// 등 1회성 호출)에는 위치가 의미 없다. 채팅 본류는 split 경로를 쓴다.
|
||||
return `${staticPart}\n\n${dateBlock}`;
|
||||
}
|
||||
|
||||
export const SYSTEM_PROMPT = BASE_SYSTEM_PROMPT;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* [v2.2.311] 제2뇌 상주 캐시 계층 — 워처 세대 기반 stat 생략 + 파일 목록 캐시.
|
||||
*
|
||||
* 핵심 계약:
|
||||
* 1. 워처 세대가 그대로면 getBrainTokenIndex 는 파일별 statSync 를 생략한다.
|
||||
* 2. 세대가 바뀌면(파일시스템 변경) 다시 stat 을 거쳐 변경분을 재색인한다.
|
||||
* 3. 부분(스코프) 목록으로 검증된 상태에서 전체 목록 질의가 와도, 검증 안 된
|
||||
* 파일은 신뢰 경로로 새지 않는다 (파일 단위 검증 장부).
|
||||
* 4. 워처 불가 환경(gen === -1)에서는 종전과 동일하게 매번 stat.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
// brainWatch 를 결정적으로 제어 — 실제 fs.watch 이벤트 타이밍에 의존하지 않는다.
|
||||
let mockGen = 0;
|
||||
jest.mock('../src/retrieval/brainWatch', () => ({
|
||||
ensureBrainWatcher: jest.fn(),
|
||||
getBrainGeneration: jest.fn(() => mockGen),
|
||||
listBrainFilesCached: jest.fn(),
|
||||
warmBrainCache: jest.fn(),
|
||||
disposeBrainWatcher: jest.fn(),
|
||||
}));
|
||||
|
||||
import { getBrainTokenIndex } from '../src/retrieval/brainIndex';
|
||||
|
||||
function makeBrain(files: Record<string, string>): { root: string; paths: string[] } {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'astra-brain-'));
|
||||
const paths: string[] = [];
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
const p = path.join(root, name);
|
||||
fs.writeFileSync(p, content, 'utf8');
|
||||
// RECENT_MTIME_GUARD(3초): 갓 수정된 파일은 신뢰 명부에 오르지 않으므로,
|
||||
// "변경 없음" 시나리오를 테스트하려면 mtime 을 과거로 되돌린다.
|
||||
const past = new Date(Date.now() - 60_000);
|
||||
fs.utimesSync(p, past, past);
|
||||
paths.push(p);
|
||||
}
|
||||
return { root, paths };
|
||||
}
|
||||
|
||||
function countStatsFor(paths: string[], spy: jest.SpyInstance): number {
|
||||
return spy.mock.calls.filter((c) => paths.includes(String(c[0]))).length;
|
||||
}
|
||||
|
||||
describe('getBrainTokenIndex — 워처 세대 기반 stat 생략', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
test('같은 세대의 2번째 호출은 statSync 0회 (캐시 신뢰)', () => {
|
||||
mockGen = 0;
|
||||
const { root, paths } = makeBrain({ 'a.md': '알파 내용', 'b.md': '베타 내용' });
|
||||
expect(getBrainTokenIndex(root, paths)).toHaveLength(2);
|
||||
|
||||
const spy = jest.spyOn(fs, 'statSync');
|
||||
const r2 = getBrainTokenIndex(root, paths);
|
||||
expect(r2).toHaveLength(2);
|
||||
expect(countStatsFor(paths, spy)).toBe(0);
|
||||
expect(r2.map((d) => d.title).sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('세대가 바뀌면 재-stat 후 변경 내용을 재색인한다', () => {
|
||||
mockGen = 0;
|
||||
const { root, paths } = makeBrain({ 'a.md': '원본 토큰들' });
|
||||
getBrainTokenIndex(root, paths);
|
||||
|
||||
// 파일 변경 + 세대 bump (mtime 차이를 보장하기 위해 utimesSync 로 강제)
|
||||
fs.writeFileSync(paths[0], '완전히 새로운 내용입니다', 'utf8');
|
||||
const future = new Date(Date.now() + 5000);
|
||||
fs.utimesSync(paths[0], future, future);
|
||||
mockGen = 1;
|
||||
|
||||
const spy = jest.spyOn(fs, 'statSync');
|
||||
const r = getBrainTokenIndex(root, paths);
|
||||
expect(countStatsFor(paths, spy)).toBeGreaterThan(0);
|
||||
expect(r[0].tokens).toEqual(expect.arrayContaining(['새로운']));
|
||||
});
|
||||
|
||||
test('스코프 검증 후 전체 질의 — 미검증 파일은 신뢰 경로로 새지 않는다', () => {
|
||||
mockGen = 0;
|
||||
const { root, paths } = makeBrain({ 'in-scope.md': '스코프 안', 'out-scope.md': '스코프 밖' });
|
||||
const [inScope, outScope] = paths;
|
||||
getBrainTokenIndex(root, [inScope]); // 부분 목록만 검증됨
|
||||
|
||||
const spy = jest.spyOn(fs, 'statSync');
|
||||
getBrainTokenIndex(root, paths); // 전체 질의
|
||||
expect(countStatsFor([inScope], spy)).toBe(0); // 검증된 파일은 생략
|
||||
expect(countStatsFor([outScope], spy)).toBeGreaterThan(0); // 미검증 파일은 stat
|
||||
});
|
||||
|
||||
test('워처 불가(gen=-1) 환경은 매번 stat (종전 동작)', () => {
|
||||
mockGen = -1;
|
||||
const { root, paths } = makeBrain({ 'a.md': '내용' });
|
||||
getBrainTokenIndex(root, paths);
|
||||
const spy = jest.spyOn(fs, 'statSync');
|
||||
getBrainTokenIndex(root, paths);
|
||||
expect(countStatsFor(paths, spy)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -56,3 +56,92 @@ describe('computeBudgetedRequest — real-window alignment', () => {
|
||||
expect(run({ actualContextLength: NaN }).effectiveContextLength).toBe(32768);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// [v2.2.311] KV 캐시 분리 — dynamicContextTail 삽입 계약.
|
||||
// message[0] 은 불변 정적 프롬프트로 남고, 동적 컨텍스트는 마지막 user 메시지
|
||||
// 직전의 internal system 메시지로 들어가야 프롬프트 프리픽스 캐시가 산다.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('computeBudgetedRequest — dynamicContextTail (KV cache split)', () => {
|
||||
const HEAD = 'STATIC HEAD PROMPT';
|
||||
const TAIL = '[CURRENT DATE/TIME]\nToday: 2026-07-20\n\n[CONTEXT]\nRAG chunk A\n[/CONTEXT]';
|
||||
|
||||
// 주의: default parameter 는 명시적 undefined 에도 적용되므로 쓰지 않는다 —
|
||||
// "tail 없음" 케이스를 정확히 표현하기 위해 항상 명시적으로 넘긴다.
|
||||
function runSplit(reqMessages: ChatMessage[], tail: string | undefined) {
|
||||
return computeBudgetedRequest({
|
||||
fullSystemPrompt: HEAD,
|
||||
dynamicContextTail: tail,
|
||||
reqMessages,
|
||||
actualModel: 'some-13b-model',
|
||||
config: baseConfig,
|
||||
imageCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
test('tail 은 마지막 user 메시지 직전에 internal system 으로 삽입된다', () => {
|
||||
const msgs: ChatMessage[] = [
|
||||
{ role: 'user', content: '첫 질문' },
|
||||
{ role: 'assistant', content: '첫 답' },
|
||||
{ role: 'user', content: '두번째 질문' },
|
||||
];
|
||||
const r = runSplit(msgs, TAIL);
|
||||
expect(r.messagesForRequest[0]).toMatchObject({ role: 'system', content: HEAD });
|
||||
const idx = r.messagesForRequest.findIndex((m) => m.content === TAIL);
|
||||
expect(idx).toBeGreaterThan(0);
|
||||
expect(r.messagesForRequest[idx].role).toBe('system');
|
||||
expect(r.messagesForRequest[idx + 1]).toMatchObject({ role: 'user', content: '두번째 질문' });
|
||||
});
|
||||
|
||||
test('continuation(마지막 user 뒤 액션 결과 system)이 있어도 tail 은 user 앞', () => {
|
||||
const msgs: ChatMessage[] = [
|
||||
{ role: 'user', content: '조사해줘' },
|
||||
{ role: 'system', content: '[Result of read_file] ...', internal: true },
|
||||
];
|
||||
const r = runSplit(msgs, TAIL);
|
||||
const tailIdx = r.messagesForRequest.findIndex((m) => m.content === TAIL);
|
||||
const userIdx = r.messagesForRequest.findIndex((m) => m.role === 'user');
|
||||
const actionIdx = r.messagesForRequest.findIndex((m) => String(m.content).startsWith('[Result of'));
|
||||
expect(tailIdx).toBeLessThan(userIdx);
|
||||
expect(actionIdx).toBeGreaterThan(userIdx);
|
||||
});
|
||||
|
||||
test('user 메시지가 없으면 tail 을 히스토리 끝에 붙인다', () => {
|
||||
const msgs: ChatMessage[] = [{ role: 'system', content: 'internal only', internal: true }];
|
||||
const r = runSplit(msgs, TAIL);
|
||||
const last = r.messagesForRequest[r.messagesForRequest.length - 1];
|
||||
expect(last.content).toBe(TAIL);
|
||||
});
|
||||
|
||||
test('tail 미지정이면 종전 단일-시스템 동작 그대로', () => {
|
||||
const msgs: ChatMessage[] = [{ role: 'user', content: 'hello' }];
|
||||
const r = runSplit(msgs, undefined);
|
||||
expect(r.messagesForRequest).toHaveLength(2);
|
||||
expect(r.messagesForRequest[0].content).toBe(HEAD);
|
||||
});
|
||||
|
||||
test('예산 초과 시 [CONTEXT] truncation 은 tail 에 적용되고 head 는 보존', () => {
|
||||
const hugeTail = `[CONTEXT]\n${'긴 배경 지식 청크. '.repeat(20000)}\n[/CONTEXT]`;
|
||||
const msgs: ChatMessage[] = [{ role: 'user', content: 'hello' }];
|
||||
const r = computeBudgetedRequest({
|
||||
fullSystemPrompt: HEAD,
|
||||
dynamicContextTail: hugeTail,
|
||||
reqMessages: msgs,
|
||||
actualModel: 'some-13b-model',
|
||||
config: { ...baseConfig, contextLength: 8192 },
|
||||
imageCount: 0,
|
||||
});
|
||||
expect(r.systemTruncated).toBe(true);
|
||||
expect(r.messagesForRequest[0].content).toBe(HEAD);
|
||||
const tailMsg = r.messagesForRequest.find((m) => String(m.content).includes('[CONTEXT]'));
|
||||
expect(tailMsg).toBeDefined();
|
||||
expect(String(tailMsg!.content).length).toBeLessThan(hugeTail.length);
|
||||
});
|
||||
|
||||
test('systemTokens 는 head+tail 합산으로 보고된다', () => {
|
||||
const msgs: ChatMessage[] = [{ role: 'user', content: 'hello' }];
|
||||
const withTail = runSplit(msgs, TAIL);
|
||||
const withoutTail = runSplit(msgs, undefined);
|
||||
expect(withTail.systemTokens).toBeGreaterThan(withoutTail.systemTokens);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,3 +165,29 @@ describe('detectHollowInvestigation — 헛조사 감지', () => {
|
||||
expect(footer).toContain('investigate_files');
|
||||
});
|
||||
});
|
||||
|
||||
// [v2.2.312] 근거 없는 분석 감지 — 파일을 읽고도 인용이 없는 일반론 보고.
|
||||
import { detectUngroundedAnalysis, formatUngroundedAnalysisFooter } from '../src/intelligence/investigationPipeline';
|
||||
|
||||
describe('detectUngroundedAnalysis — 일반론 분석 감지', () => {
|
||||
const generic = '이 프로젝트는 로컬 LLM과 RAG 기술의 결합 시도가 매우 인상적입니다. '.repeat(10);
|
||||
|
||||
test('read 를 하고도 파일 인용 없는 장문 = ungrounded', () => {
|
||||
const r = detectUngroundedAnalysis(generic, { reads: 3, lists: 1, investigates: 0 });
|
||||
expect(r.ungrounded).toBe(true);
|
||||
expect(r.readCount).toBe(3);
|
||||
});
|
||||
test('파일 근거를 2개 이상 인용하면 정상', () => {
|
||||
const grounded = `${generic} 근거: agent.ts 의 handlePrompt 와 memoryContext.ts 의 buildMemoryContext 를 확인함.`;
|
||||
expect(detectUngroundedAnalysis(grounded, { reads: 3, lists: 1, investigates: 0 }).ungrounded).toBe(false);
|
||||
});
|
||||
test('read 가 없으면 판정 대상 아님 (hollow 감지의 영역)', () => {
|
||||
expect(detectUngroundedAnalysis(generic, { reads: 0, lists: 1, investigates: 0 }).ungrounded).toBe(false);
|
||||
});
|
||||
test('짧은 확인형 응답은 오탐 방지로 제외', () => {
|
||||
expect(detectUngroundedAnalysis('네, 서버 정상입니다.', { reads: 1, lists: 0, investigates: 0 }).ungrounded).toBe(false);
|
||||
});
|
||||
test('footer 에 읽은 횟수 포함', () => {
|
||||
expect(formatUngroundedAnalysisFooter(3)).toContain('3회');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* [v2.2.311] KV 캐시 친화 프롬프트 분리 — 정적/동적 경계 계약.
|
||||
*
|
||||
* 정적 프롬프트(getStaticSystemPrompt)에 분 단위 시각 같은 가변 요소가 섞이는 순간
|
||||
* llama.cpp prompt cache 는 요청 첫 토큰부터 무효화된다 (실측: 13k 토큰 전체 재프리필
|
||||
* ≈ 90초/턴). 이 테스트는 그 회귀를 막는 가드레일이다.
|
||||
*/
|
||||
import { getStaticSystemPrompt, getDateTimeContextBlock, getSystemPrompt } from '../src/utils';
|
||||
import { buildAstraModeSystemPrompt } from '../src/agent/handlePrompt/buildAstraModeSystemPrompt';
|
||||
|
||||
describe('정적/동적 시스템 프롬프트 분리', () => {
|
||||
test('정적 프롬프트에는 날짜/시각이 없다 (캐시 무효화 가드)', () => {
|
||||
const s = getStaticSystemPrompt();
|
||||
expect(s).not.toContain('[CURRENT DATE/TIME]');
|
||||
// 오늘 날짜 문자열이 어떤 형태로도 들어있으면 안 된다.
|
||||
const iso = new Date().toISOString().split('T')[0];
|
||||
expect(s).not.toContain(iso);
|
||||
});
|
||||
|
||||
test('정적 프롬프트는 연속 호출에서 동일하다 (프리픽스 불변)', () => {
|
||||
expect(getStaticSystemPrompt()).toBe(getStaticSystemPrompt());
|
||||
});
|
||||
|
||||
test('날짜 블록은 오늘 날짜를 포함한다', () => {
|
||||
const block = getDateTimeContextBlock();
|
||||
expect(block).toContain('[CURRENT DATE/TIME]');
|
||||
expect(block).toContain(new Date().toISOString().split('T')[0]);
|
||||
});
|
||||
|
||||
test('레거시 getSystemPrompt 는 정적 본문 + 날짜 블록을 모두 포함 (호환)', () => {
|
||||
const s = getSystemPrompt();
|
||||
expect(s).toContain('[CURRENT DATE/TIME]');
|
||||
expect(s).toContain('[출력 위생 규칙 — 반드시 준수]');
|
||||
});
|
||||
|
||||
test('빌더에 systemPrompt="" 를 넘겨도 [CONTEXT] 골격은 유지된다 (tail 생성 경로)', () => {
|
||||
const tail = buildAstraModeSystemPrompt({
|
||||
prompt: '질문',
|
||||
systemPrompt: '',
|
||||
modeBridgeCtx: '',
|
||||
priorConclusionCtx: '',
|
||||
designerCtx: '',
|
||||
projectArchitectureCtx: '',
|
||||
secondBrainTraceCtx: '',
|
||||
memoryCtx: 'RAG 본문',
|
||||
knowledgeContextForPrompt: '',
|
||||
contextBlock: '열린 파일 내용',
|
||||
negativeCtx: '',
|
||||
isCasualConversation: false,
|
||||
localPathContext: '',
|
||||
knowledgeMix: null,
|
||||
dynamicBlocks: new Map([['behavior-constraints', '[GROUNDING] 근거 강도: 보통']]),
|
||||
});
|
||||
expect(tail).toContain('[CONTEXT]');
|
||||
expect(tail).toContain('RAG 본문');
|
||||
expect(tail).toContain('열린 파일 내용');
|
||||
expect(tail).toContain('[GROUNDING]');
|
||||
expect(tail.startsWith('You are Astra')).toBe(false); // 정적 본문이 섞이면 안 됨
|
||||
});
|
||||
});
|
||||
@@ -124,3 +124,23 @@ describe('DEFAULT_TASK_REQUIREMENTS 무결성', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// [v2.2.312] 분석 보고 유형 — "프로젝트 분석하고 보고해줘" 실사례가 유형 미감지로
|
||||
// 일반론 총평이 되던 갭을 막는다.
|
||||
describe('detectTaskType — analysis-report (v2.2.312)', () => {
|
||||
test('실사례 문장을 정확히 감지한다', () => {
|
||||
expect(detectTaskType('E:\Wiki\connectai 프로젝트를 분석하고 어떻게 되어 있는지 보고해줘')?.id).toBe('analysis-report');
|
||||
expect(detectTaskType('진행 현황 보고 부탁해')?.id).toBe('analysis-report');
|
||||
expect(detectTaskType('이 코드 검토해서 보고해줘')?.id).toBe('analysis-report');
|
||||
});
|
||||
test('더 구체적인 유형이 우선한다 (회의록·시장조사)', () => {
|
||||
expect(detectTaskType('회의록으로 정리해서 보고해줘')?.id).toBe('meeting-minutes');
|
||||
expect(detectTaskType('시장조사 결과 보고해줘')?.id).toBe('market-research');
|
||||
});
|
||||
test('블록에 파일 근거 요소와 섹션 규칙 오버라이드가 포함된다', () => {
|
||||
const block = buildRequirementGraphBlock('프로젝트 분석하고 보고해줘');
|
||||
expect(block).toContain('파일 근거');
|
||||
expect(block).toContain('보고 개요');
|
||||
expect(block).toContain('최대 3섹션');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* [v2.2.312] 중간 라운드 표시용 액션 태그 제거 — "## 4.부터 시작하는 보고서" 버그의
|
||||
* 표시 경로 헬퍼. 태그는 걷어내되 본문 섹션은 보존해야 한다.
|
||||
*/
|
||||
import { stripActionTagsForDisplay } from '../src/agent/actions/stripForDisplay';
|
||||
|
||||
describe('stripActionTagsForDisplay', () => {
|
||||
test('자기닫힘 태그 제거 + 본문 보존', () => {
|
||||
const input = '## 1. 프로젝트 개요\n구조를 확인한다.\n<read_file path="src/agent.ts"/>\n<list_files path="src"/>';
|
||||
const out = stripActionTagsForDisplay(input);
|
||||
expect(out).toContain('## 1. 프로젝트 개요');
|
||||
expect(out).not.toContain('<read_file');
|
||||
expect(out).not.toContain('<list_files');
|
||||
});
|
||||
test('블록 태그(내용 포함) 제거', () => {
|
||||
const input = '분석 시작.\n<run_command>cd x; npm test</run_command>\n<create_file path="a.md">본문</create_file>\n끝.';
|
||||
const out = stripActionTagsForDisplay(input);
|
||||
expect(out).toContain('분석 시작.');
|
||||
expect(out).toContain('끝.');
|
||||
expect(out).not.toContain('npm test');
|
||||
expect(out).not.toContain('<create_file');
|
||||
});
|
||||
test('잔재 여는/닫는 태그도 정리', () => {
|
||||
const out = stripActionTagsForDisplay('앞 <edit_file path="x.ts"> 뒤');
|
||||
expect(out).not.toContain('<edit_file');
|
||||
expect(out).toContain('앞');
|
||||
expect(out).toContain('뒤');
|
||||
});
|
||||
test('태그 없는 본문은 그대로', () => {
|
||||
expect(stripActionTagsForDisplay('## 2. 구조\n- src/agent.ts')).toBe('## 2. 구조\n- src/agent.ts');
|
||||
});
|
||||
test('빈 입력 안전', () => {
|
||||
expect(stripActionTagsForDisplay('')).toBe('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user