feat: v2.2.173-193 — 4인 팀 운영 슬래시 13개 + ASTRA 검증 엔진 6종
4인 팀 운영 슬래시 (v2.2.173~189):
- 일과 리듬: /morning, /evening, /weekly, /standup
- 트래커 (event-sourced .astra/*.jsonl): /runway, /customers, /hire
- 작업·결정: /task, /blocked, /onesie, /decisions
- 외부 출력: /draft, /feedback
- 분석: /cohort (MoM 추세)
ASTRA 추론·검색 엔진 (v2.2.183~192):
- v2.2.183 Conflict Surface — scoring.conflictSeverity 를 [CONFLICT WARNINGS] 블록으로
서피스 + 교차-문서 발산(Jaccard) 감지
- v2.2.184 Chain-of-Verification — [VERIFICATION CHECKLIST] 답변 작성 전 그라운딩 자기 점검
(instructional, strictMode 옵션)
- v2.2.185 Actionability Scoring — 최근 슬래시 명령 + 열린 파일 신호로 검색 결과 재가중
- v2.2.186 Temporal Markers + Distillation Loop — LongTerm/Episodic 만료 필터 +
30일+ stale episode → LongTerm 'episode-digest' 승급 (수동 /memory distill + 세션 종료 자동)
- v2.2.187 Hierarchical Context Window + LLM Semantic Re-rank — 3-level 추상도 매칭
+ 토큰 예산 통과 후 LLM 1회로 의도-부합 재정렬 (opt-in)
- v2.2.190 Intent Clarification + Citation Trace — 모호 차원 감지 시 역질문 우선
+ 답변 끝 사용 출처 한 줄 정리
- v2.2.191 Post-hoc Self-Check — 답변 완료 후 별도 LLM 호출 1회로 답함/그라운딩/모순 평가,
footer 한 줄로 표시 (opt-in, semantic re-rank 와 같은 안전 fallback 패턴)
- v2.2.192 Terminology Dictionary — .astra/glossary.md 사용자 편집 파일 + Term Check
지침 통합 + /glossary init/path/reload
- v2.2.193 /help — 카테고리별 명령 목록 + 6종 verification 블록 현재 on/off
신규 모듈:
- src/retrieval/{conflictBlock,coveBlock,actionabilityScoring,hierarchicalLevel,
semanticRerank,intentClarification,citationTrace,terminologyBlock}.ts
- src/memory/distillation.ts + types.ts 에 expiresAt/promoted/episode-digest 추가
- src/agent/postHocSelfCheck.ts
- src/features/{customers,feedback,hire,runway}/*.ts (event-sourced stores)
ASTRA 검증 5종 자동 주입 (buildAstraModeSystemPrompt, casual 모드 제외):
[INTENT CLARIFICATION GUIDANCE] (답변 시작 전) → [TERMINOLOGY DICTIONARY] +
[CONFLICT WARNINGS] + [VERIFICATION CHECKLIST] (작성 중) → [CITATION TRACE] (끝)
+ 6번째: Post-hoc Self-Check footer (답변 완료 후, opt-in)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+77
-1
@@ -164,6 +164,7 @@ import { buildAgentModeSystemPrompt } from './agent/handlePrompt/buildAgentModeS
|
||||
import { buildAstraModeSystemPrompt } from './agent/handlePrompt/buildAstraModeSystemPrompt';
|
||||
import { computeBudgetedRequest } from './agent/handlePrompt/computeBudgetedRequest';
|
||||
import { processFinalAnswer } from './agent/handlePrompt/processFinalAnswer';
|
||||
import { postHocSelfCheck, formatSelfCheckFooter, DEFAULT_SELF_CHECK_OPTIONS } from './agent/postHocSelfCheck';
|
||||
import { applyAutoContinuation } from './agent/handlePrompt/applyAutoContinuation';
|
||||
|
||||
export interface ChatMessage {
|
||||
@@ -285,10 +286,28 @@ export class AgentExecutor {
|
||||
lessons: string[];
|
||||
/** 이번 turn 에 결정된 Knowledge Mix — scope footer 표시용. */
|
||||
knowledgeMix: ResolvedKnowledgeMix | null;
|
||||
/** [CONFLICT WARNINGS] 시스템 프롬프트 블록 — 검색된 출처에서 충돌 신호 감지 시. */
|
||||
conflictWarnings: string;
|
||||
/** [VERIFICATION CHECKLIST] Chain-of-Verification 블록 — 그라운딩 자기 검증 지시. */
|
||||
coveChecklist: string;
|
||||
/** [INTENT CLARIFICATION GUIDANCE] — 모호 질의 감지 시 역질문 우선 지시. */
|
||||
intentClarification: string;
|
||||
/** [CITATION TRACE] — 답변 끝에 사용 출처 한 줄 정리 지시. */
|
||||
citationTrace: string;
|
||||
/** Self-check 용 — selected chunks 의 (title, content) 요약. memoryContext 가 채움. */
|
||||
selfCheckSources: Array<{ title: string; excerpt: string }>;
|
||||
/** [TERMINOLOGY DICTIONARY] — 사용자 편집 글로서리 + Term Check 지침. */
|
||||
terminology: string;
|
||||
} = {
|
||||
retrieval: null,
|
||||
lessons: [],
|
||||
knowledgeMix: null,
|
||||
conflictWarnings: '',
|
||||
coveChecklist: '',
|
||||
intentClarification: '',
|
||||
citationTrace: '',
|
||||
selfCheckSources: [],
|
||||
terminology: '',
|
||||
};
|
||||
|
||||
/** Per-turn state 일괄 정리. turn 시작/abort/load session 시 호출. */
|
||||
@@ -296,6 +315,12 @@ export class AgentExecutor {
|
||||
this._turnCtx.retrieval = null;
|
||||
this._turnCtx.lessons = [];
|
||||
this._turnCtx.knowledgeMix = null;
|
||||
this._turnCtx.conflictWarnings = '';
|
||||
this._turnCtx.coveChecklist = '';
|
||||
this._turnCtx.intentClarification = '';
|
||||
this._turnCtx.citationTrace = '';
|
||||
this._turnCtx.selfCheckSources = [];
|
||||
this._turnCtx.terminology = '';
|
||||
}
|
||||
|
||||
private readonly options: AgentExecutorOptions;
|
||||
@@ -647,6 +672,11 @@ export class AgentExecutor {
|
||||
isCasualConversation,
|
||||
localPathContext,
|
||||
knowledgeMix: this._turnCtx.knowledgeMix,
|
||||
conflictWarningsCtx: this._turnCtx.conflictWarnings,
|
||||
coveChecklistCtx: this._turnCtx.coveChecklist,
|
||||
intentClarificationCtx: this._turnCtx.intentClarification,
|
||||
citationTraceCtx: this._turnCtx.citationTrace,
|
||||
terminologyCtx: this._turnCtx.terminology,
|
||||
});
|
||||
// Context budget computation → src/agent/handlePrompt/computeBudgetedRequest.ts
|
||||
const imageCount = (reqMessages as any[])
|
||||
@@ -1200,6 +1230,12 @@ export class AgentExecutor {
|
||||
contextLength: ctxLimits.contextLength,
|
||||
engine,
|
||||
});
|
||||
// ── Post-hoc Self-Check (v2.2.191) — 별도 LLM 호출 1회로 답변 사후 검증. ──
|
||||
// 비동기 — Devil 과 동일 패턴. 결과를 footer 한 줄로 append.
|
||||
void this._maybePostHocSelfCheck({
|
||||
userPrompt: prompt || '',
|
||||
assistantAnswer: finalAssistantContent,
|
||||
});
|
||||
} else {
|
||||
this.webview.postMessage({ type: 'streamChunk', value: finalAssistantContent });
|
||||
}
|
||||
@@ -1282,10 +1318,18 @@ export class AgentExecutor {
|
||||
const workspaceFolders = vscode.workspace.workspaceFolders;
|
||||
const workspacePath = workspaceFolders ? workspaceFolders[0].uri.fsPath : undefined;
|
||||
|
||||
const cfgNow = getConfig();
|
||||
this.memoryManager.onSessionEnd(
|
||||
this.currentTaskId,
|
||||
this.chatHistory.filter((m) => !m.internal),
|
||||
workspacePath
|
||||
workspacePath,
|
||||
cfgNow.localBrainPath ? {
|
||||
enabled: cfgNow.distillationEnabled !== false,
|
||||
ageThresholdDays: cfgNow.distillationAgeThresholdDays ?? 30,
|
||||
intervalDays: cfgNow.distillationIntervalDays ?? 7,
|
||||
archiveMode: (cfgNow.distillationArchiveMode || 'mark-promoted') as any,
|
||||
brainPath: cfgNow.localBrainPath,
|
||||
} : undefined,
|
||||
);
|
||||
logInfo('Memory extraction completed for session end.', { taskId: this.currentTaskId });
|
||||
recordTelemetry({
|
||||
@@ -1366,6 +1410,38 @@ export class AgentExecutor {
|
||||
}, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-hoc Self-Check — 답변 *완료 후* LLM 1회 호출로 3가지 평가
|
||||
* (사용자 질의 직접 답함 / 출처 그라운딩 / 논리 모순). 비동기 — main turn 에 영향 없음.
|
||||
* 기본 OFF (g1nation.selfCheckEnabled). 결과는 footer 한 줄로 streamChunk append.
|
||||
*/
|
||||
private async _maybePostHocSelfCheck(opts: {
|
||||
userPrompt: string;
|
||||
assistantAnswer: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const cfg = getConfig();
|
||||
if (!cfg.selfCheckEnabled) return;
|
||||
if (!opts.userPrompt.trim() || !opts.assistantAnswer.trim()) return;
|
||||
const model = (cfg.selfCheckModel || '').trim() || cfg.defaultModel;
|
||||
if (!model || !cfg.ollamaUrl) return;
|
||||
const sources = this._turnCtx.selfCheckSources || [];
|
||||
|
||||
const result = await postHocSelfCheck(opts.userPrompt, opts.assistantAnswer, sources, {
|
||||
ollamaUrl: cfg.ollamaUrl,
|
||||
model,
|
||||
timeoutMs: (cfg.selfCheckTimeoutSec ?? 6) * 1000,
|
||||
excerptLength: DEFAULT_SELF_CHECK_OPTIONS.excerptLength,
|
||||
maxSources: DEFAULT_SELF_CHECK_OPTIONS.maxSources,
|
||||
});
|
||||
|
||||
// 성공 실패 모두 footer 표시 — 사용자가 self-check 가 *돌고 있는지* 알 수 있게.
|
||||
// 실패 시 흐릿한 한 줄, 성공 시 평가 한 줄.
|
||||
const footer = formatSelfCheckFooter(result, model);
|
||||
this.webview?.postMessage({ type: 'streamChunk', value: footer });
|
||||
} catch { /* swallow — self-check never breaks the turn */ }
|
||||
}
|
||||
|
||||
private async callNonStreaming(params: {
|
||||
baseUrl: string;
|
||||
modelName: string;
|
||||
|
||||
@@ -22,6 +22,28 @@ export interface BuildAstraModeSystemPromptInput {
|
||||
localPathContext: string;
|
||||
/** From this._turnCtx.knowledgeMix — pass null when absent. */
|
||||
knowledgeMix: any;
|
||||
/**
|
||||
* [CONFLICT WARNINGS] 블록 — buildConflictWarningsBlock 산출. 빈 문자열이면 충돌 없음 → 주입 안 함.
|
||||
* v4 정책 텍스트의 "[CONFLICT WARNING] 플래그" 참조를 실제 데이터로 뒷받침.
|
||||
*/
|
||||
conflictWarningsCtx?: string;
|
||||
/**
|
||||
* [VERIFICATION CHECKLIST] CoVe 블록 — buildCoveChecklistBlock 산출. 답변 *작성 전*
|
||||
* 그라운딩 체크리스트로 모델 self-verify 지시. 빈 문자열이면 비활성.
|
||||
*/
|
||||
coveChecklistCtx?: string;
|
||||
/**
|
||||
* [INTENT CLARIFICATION GUIDANCE] — 모호 질의 감지 시 *역질문 우선* 지시. 모호 아닐 때 빈 문자열.
|
||||
*/
|
||||
intentClarificationCtx?: string;
|
||||
/**
|
||||
* [CITATION TRACE] — 답변 끝에 사용 출처 한 줄 정리 지시. 검색 결과 있을 때 채워짐.
|
||||
*/
|
||||
citationTraceCtx?: string;
|
||||
/**
|
||||
* [TERMINOLOGY DICTIONARY] — 사용자 편집 글로서리 + Term Check 지침. 파일 있을 때만.
|
||||
*/
|
||||
terminologyCtx?: string;
|
||||
}
|
||||
|
||||
export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInput): string {
|
||||
@@ -40,6 +62,11 @@ export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInpu
|
||||
isCasualConversation,
|
||||
localPathContext,
|
||||
knowledgeMix,
|
||||
conflictWarningsCtx,
|
||||
coveChecklistCtx,
|
||||
intentClarificationCtx,
|
||||
citationTraceCtx,
|
||||
terminologyCtx,
|
||||
} = input;
|
||||
|
||||
// 기존 Astra 모드 (에이전트 미선택)
|
||||
@@ -78,5 +105,29 @@ export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInpu
|
||||
// priorConclusionCtx 는 modeBridgeCtx 와 같은 위치 (base systemPrompt 직후) — 모델이
|
||||
// 자기 직전 결론을 anchor 로 잡고 사용자의 follow-up 을 그 결론에 대한 정정으로 해석하게.
|
||||
const priorConclusionBlock = priorConclusionCtx ? '\n\n' + priorConclusionCtx : '';
|
||||
return `${systemPrompt}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}${designerCtx}${projectArchitectureCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}${knowledgeMixCtx}${casualCtx}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}`;
|
||||
// [CONFLICT WARNINGS] 는 [CONTEXT] 밖에 — token-truncation 시 보호. v4 정책이
|
||||
// 충돌 처리 *방법* 을 명시하고, 이 블록이 *어느 출처가 충돌* 인지 데이터 제공.
|
||||
// Casual conversation 모드에서는 RAG context 자체를 안 쓰므로 충돌 경고도 무의미 — 생략.
|
||||
const conflictWarningsBlock = (!isCasualConversation && conflictWarningsCtx && conflictWarningsCtx.trim())
|
||||
? '\n\n' + conflictWarningsCtx
|
||||
: '';
|
||||
// [VERIFICATION CHECKLIST] CoVe — 답변 작성 전 self-verify 지시. Conflict 와 마찬가지로
|
||||
// [CONTEXT] 밖, casual 모드 비활성. CoVe 가 강하면 단정적 답변이 줄고 근거 인용 늘어남.
|
||||
const coveBlock = (!isCasualConversation && coveChecklistCtx && coveChecklistCtx.trim())
|
||||
? '\n\n' + coveChecklistCtx
|
||||
: '';
|
||||
// [INTENT CLARIFICATION GUIDANCE] — 모호 차원 감지 시 *역질문 우선*. Casual 모드는 제외.
|
||||
// 위치: 다른 verification block 보다 *앞* — 모호하면 답변 자체를 안 만들어야 하므로.
|
||||
const intentBlock = (!isCasualConversation && intentClarificationCtx && intentClarificationCtx.trim())
|
||||
? '\n\n' + intentClarificationCtx
|
||||
: '';
|
||||
// [CITATION TRACE] — 답변 끝에 출처 한 줄. CoVe 와 함께 동작 — CoVe 가 라벨, Citation 이 정리.
|
||||
const citationBlock = (!isCasualConversation && citationTraceCtx && citationTraceCtx.trim())
|
||||
? '\n\n' + citationTraceCtx
|
||||
: '';
|
||||
// [TERMINOLOGY DICTIONARY] — 사용자 편집 글로서리. casual 모드 비활성 (greeting 에 용어 강제 의미 없음).
|
||||
const terminologyBlock = (!isCasualConversation && terminologyCtx && terminologyCtx.trim())
|
||||
? '\n\n' + terminologyCtx
|
||||
: '';
|
||||
return `${systemPrompt}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}${designerCtx}${projectArchitectureCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}${knowledgeMixCtx}${casualCtx}${intentBlock}${terminologyBlock}${conflictWarningsBlock}${coveBlock}${citationBlock}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Post-hoc Self-Check — 답변 *완료 후* LLM 한 번 호출로 3가지 평가.
|
||||
*
|
||||
* 사용자 제안: "[Self-Check] 단계 — 이 답변이 사용자 질문에 직접 답하는가 / 규칙
|
||||
* 준수 / 논리 모순 없는가".
|
||||
*
|
||||
* 기존 CoVe (v2.2.184) 와 차이:
|
||||
* - CoVe = *답변 작성 전* 모델에게 self-verify 지시 (instructional, 1 pass)
|
||||
* - Self-Check = *답변 완료 후* 별도 LLM 호출로 검증 (post-hoc, 2 pass)
|
||||
*
|
||||
* 비용·위험:
|
||||
* - 매 turn 추가 LLM 호출 1회 (latency 비용)
|
||||
* - 기본 OFF — semantic re-rank 와 같은 opt-in 패턴
|
||||
* - 짧은 timeout (기본 6초). 실패해도 답변 자체엔 영향 없음 — 그냥 평가 못 함.
|
||||
* - 빠른 작은 모델 권장 (예: gemma2:2b)
|
||||
*
|
||||
* 위치: 답변 streaming 완료 후, `usedScope` 메시지 전송 직전. 비동기 — 답변
|
||||
* 표시를 *블록 하지 않음*. 결과는 webview 에 별도 메시지로 push.
|
||||
*/
|
||||
|
||||
export interface SelfCheckOptions {
|
||||
ollamaUrl: string;
|
||||
model: string;
|
||||
timeoutMs: number;
|
||||
/** 출처 컨텍스트 미리보기 길이. 기본 180 chars. */
|
||||
excerptLength: number;
|
||||
/** 컨텍스트로 넘길 최대 출처 개수. 기본 5. */
|
||||
maxSources: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SELF_CHECK_OPTIONS: Omit<SelfCheckOptions, 'ollamaUrl' | 'model'> = {
|
||||
timeoutMs: 6000,
|
||||
excerptLength: 180,
|
||||
maxSources: 5,
|
||||
};
|
||||
|
||||
export type SelfCheckVerdict = 'yes' | 'partial' | 'no' | 'unknown';
|
||||
export type ContradictionLevel = 'none' | 'minor' | 'major' | 'unknown';
|
||||
|
||||
export interface SelfCheckResult {
|
||||
success: boolean;
|
||||
answersQuestion: SelfCheckVerdict;
|
||||
grounded: SelfCheckVerdict;
|
||||
contradiction: ContradictionLevel;
|
||||
note: string;
|
||||
durationMs: number;
|
||||
/** 디버그·footer 표시용. */
|
||||
rawResponse?: string;
|
||||
}
|
||||
|
||||
const FAILURE_RESULT: Omit<SelfCheckResult, 'durationMs' | 'note'> = {
|
||||
success: false,
|
||||
answersQuestion: 'unknown',
|
||||
grounded: 'unknown',
|
||||
contradiction: 'unknown',
|
||||
};
|
||||
|
||||
function shortExcerpt(text: string, n: number): string {
|
||||
if (!text) return '';
|
||||
const cleaned = text.replace(/\s+/g, ' ').trim();
|
||||
return cleaned.length <= n ? cleaned : cleaned.slice(0, n) + '…';
|
||||
}
|
||||
|
||||
function buildPrompt(
|
||||
userPrompt: string,
|
||||
answer: string,
|
||||
sources: { title: string; excerpt: string }[],
|
||||
excerptLength: number,
|
||||
): { system: string; user: string } {
|
||||
const system = [
|
||||
'당신은 답변 검증기 (judge). 사용자 질문, 답변, 출처를 받아 3가지 평가:',
|
||||
'',
|
||||
'1. answersQuestion: 답변이 질문에 *직접* 답하는가? (yes/partial/no)',
|
||||
'2. grounded: 답변이 *제공된 출처에 근거* 하는가? (출처 없으면 unknown 가능) (yes/partial/no/unknown)',
|
||||
'3. contradiction: 답변에 *논리적 모순* 이 있나? (none/minor/major)',
|
||||
'',
|
||||
'[출력 형식 — 정확히 한 줄 JSON, 다른 텍스트 절대 금지]',
|
||||
'{"answersQuestion":"yes","grounded":"partial","contradiction":"none","note":"답변은 직접적이나 일부 주장이 모델 일반 지식 기반"}',
|
||||
'',
|
||||
'[규칙]',
|
||||
'- partial/minor 는 *진짜* 애매한 경우에만. 둘 중 하나로 단정 가능하면 단정.',
|
||||
'- note 는 1문장, 80자 이내, 핵심 평가 근거.',
|
||||
'- JSON 한 줄 외 텍스트 (서론·설명·코드블록) 절대 출력 금지.',
|
||||
].join('\n');
|
||||
|
||||
const srcLines = sources.length > 0
|
||||
? sources.map((s, i) => `[S${i + 1}] ${s.title}\n ${shortExcerpt(s.excerpt, excerptLength)}`).join('\n')
|
||||
: '(검색된 출처 없음 — grounded 는 unknown 또는 no 평가)';
|
||||
|
||||
const user = [
|
||||
'[사용자 질문]',
|
||||
userPrompt,
|
||||
'',
|
||||
'[답변]',
|
||||
answer,
|
||||
'',
|
||||
'[제공된 출처]',
|
||||
srcLines,
|
||||
'',
|
||||
'위 평가 기준에 따라 JSON 한 줄 출력.',
|
||||
].join('\n');
|
||||
|
||||
return { system, user };
|
||||
}
|
||||
|
||||
function parseResult(raw: string): Omit<SelfCheckResult, 'durationMs' | 'rawResponse'> | null {
|
||||
if (!raw) return null;
|
||||
const match = raw.match(/\{[\s\S]*?\}/);
|
||||
if (!match) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(match[0]);
|
||||
const aq = String(parsed?.answersQuestion || '').toLowerCase();
|
||||
const gr = String(parsed?.grounded || '').toLowerCase();
|
||||
const co = String(parsed?.contradiction || '').toLowerCase();
|
||||
const validVerdict = (v: string): v is SelfCheckVerdict => ['yes', 'partial', 'no', 'unknown'].includes(v);
|
||||
const validCo = (v: string): v is ContradictionLevel => ['none', 'minor', 'major', 'unknown'].includes(v);
|
||||
if (!validVerdict(aq) || !validVerdict(gr) || !validCo(co)) return null;
|
||||
const note = typeof parsed?.note === 'string' ? parsed.note.slice(0, 120) : '';
|
||||
return {
|
||||
success: true,
|
||||
answersQuestion: aq,
|
||||
grounded: gr,
|
||||
contradiction: co,
|
||||
note: note || '평가 노트 없음',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function postHocSelfCheck(
|
||||
userPrompt: string,
|
||||
answer: string,
|
||||
sources: { title: string; excerpt: string }[],
|
||||
options: SelfCheckOptions,
|
||||
): Promise<SelfCheckResult> {
|
||||
const start = Date.now();
|
||||
if (!userPrompt.trim() || !answer.trim()) {
|
||||
return { ...FAILURE_RESULT, note: 'empty input', durationMs: Date.now() - start };
|
||||
}
|
||||
const sourcesCap = (sources || []).slice(0, options.maxSources);
|
||||
const { system, user } = buildPrompt(userPrompt, answer, sourcesCap, options.excerptLength);
|
||||
|
||||
const isOllama = options.ollamaUrl.includes(':11434') || options.ollamaUrl.includes('ollama');
|
||||
const endpoint = isOllama ? `${options.ollamaUrl}/api/chat` : `${options.ollamaUrl}/v1/chat/completions`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), options.timeoutMs);
|
||||
let raw = '';
|
||||
try {
|
||||
const body = isOllama
|
||||
? {
|
||||
model: options.model, stream: false,
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
options: { temperature: 0.0, num_predict: 200 },
|
||||
}
|
||||
: {
|
||||
model: options.model, stream: false, temperature: 0.0, max_tokens: 200,
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
};
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: any = await res.json();
|
||||
raw = String(
|
||||
data?.message?.content ??
|
||||
data?.choices?.[0]?.message?.content ??
|
||||
data?.choices?.[0]?.text ??
|
||||
data?.response ??
|
||||
'',
|
||||
);
|
||||
} catch (e: any) {
|
||||
clearTimeout(timer);
|
||||
return {
|
||||
...FAILURE_RESULT,
|
||||
note: `LLM call failed: ${e?.name || e?.message || 'unknown'}`,
|
||||
durationMs: Date.now() - start,
|
||||
rawResponse: '',
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
const parsed = parseResult(raw);
|
||||
if (!parsed) {
|
||||
return {
|
||||
...FAILURE_RESULT,
|
||||
note: 'unparseable response',
|
||||
durationMs: Date.now() - start,
|
||||
rawResponse: raw.slice(0, 200),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...parsed,
|
||||
durationMs: Date.now() - start,
|
||||
rawResponse: raw.slice(0, 200),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 결과를 markdown 한 줄 footer 로 포맷 — 사용자가 답변 아래에서 바로 봄.
|
||||
*
|
||||
* 형식: `\n\n---\n_🔍 Self-check_: 답함=✓ · 근거=○ · 모순=없음 _(2.4s · 모델: gemma2:2b)_`
|
||||
*
|
||||
* 실패면 흐릿한 한 줄.
|
||||
*/
|
||||
export function formatSelfCheckFooter(result: SelfCheckResult, model: string): string {
|
||||
if (!result.success) {
|
||||
return `\n\n---\n_🔍 Self-check: ⊘ ${result.note} (${(result.durationMs / 1000).toFixed(1)}s)_`;
|
||||
}
|
||||
const aq = result.answersQuestion === 'yes' ? '✓'
|
||||
: result.answersQuestion === 'partial' ? '◐'
|
||||
: result.answersQuestion === 'no' ? '✗' : '?';
|
||||
const gr = result.grounded === 'yes' ? '✓'
|
||||
: result.grounded === 'partial' ? '◐'
|
||||
: result.grounded === 'no' ? '✗' : '?';
|
||||
const co = result.contradiction === 'none' ? '없음'
|
||||
: result.contradiction === 'minor' ? '경미'
|
||||
: result.contradiction === 'major' ? '⚠️ 중대' : '?';
|
||||
return `\n\n---\n_🔍 **Self-check**: 답함=${aq} · 근거=${gr} · 모순=${co} — ${result.note} _(${(result.durationMs / 1000).toFixed(1)}s · ${model})__`;
|
||||
}
|
||||
+132
@@ -60,6 +60,113 @@ export interface IAgentConfig {
|
||||
* Default 0.5 = equal weight, a reasonable starting point.
|
||||
*/
|
||||
embeddingBlendAlpha: number;
|
||||
/**
|
||||
* Conflict Surface — 검색된 출처의 conflictSeverity 신호를 [CONFLICT WARNINGS] 블록
|
||||
* 으로 시스템 프롬프트에 노출. v4 정책 텍스트(buildAstraModeSystemPrompt) 가 이미
|
||||
* "[CONFLICT WARNING] 플래그" 를 참조하지만, 데이터를 LLM 에 전달하지 않아 무용했음.
|
||||
* true(기본) → 충돌 감지 시 블록 주입, false → 비활성.
|
||||
*/
|
||||
conflictHighlightingEnabled: boolean;
|
||||
/**
|
||||
* Conflict 자기-신호 surface 시 최소 severity 임계.
|
||||
* 'low' → LOW 이상 (가장 민감, 노이즈 가능)
|
||||
* 'medium' → MEDIUM 이상 (기본, 균형)
|
||||
* 'high' → HIGH 만 (가장 보수적, 강한 충돌만)
|
||||
*/
|
||||
conflictSeverityThreshold: 'low' | 'medium' | 'high';
|
||||
/**
|
||||
* 교차-문서 발산 감지 (같은 주제 ≥2 chunks, 본문 Jaccard < 0.30 인 잠재 모순 쌍).
|
||||
* 자기-신호와 합쳐 [CONFLICT WARNINGS] 블록에 표시.
|
||||
*/
|
||||
conflictCrossDocEnabled: boolean;
|
||||
/**
|
||||
* CoVe (Chain-of-Verification) — 답변 *작성 전* 그라운딩 체크리스트를 시스템 프롬프트에
|
||||
* 주입해 모델이 self-verify 하도록. 할루시네이션 방지 + 그라운딩 명확화.
|
||||
* true(기본) → 검색된 출처 있을 때 [VERIFICATION CHECKLIST] 블록 주입.
|
||||
*/
|
||||
coveEnabled: boolean;
|
||||
/** CoVe 체크리스트에 나열할 상위 출처 개수. 기본 5. */
|
||||
coveTopSourcesCount: number;
|
||||
/**
|
||||
* CoVe Strict 모드 — 모든 사실 주장 뒤에 출처 ID([S1] 등) inline 인용 강제.
|
||||
* 답변이 좀 더 학술적·verbose 해질 수 있어 기본 off.
|
||||
*/
|
||||
coveStrictMode: boolean;
|
||||
/**
|
||||
* Actionability — "현재 작업 상태" 신호(최근 슬래시 명령 + 열린 파일) 로 검색 결과
|
||||
* 재가중. TF-IDF 매치 점수에 actionability boost 추가해 "지금 작업 중인 컨텍스트" 와
|
||||
* 직접 연결된 문서를 우선. 기본 true.
|
||||
*/
|
||||
actionabilityEnabled: boolean;
|
||||
/**
|
||||
* Memory Lifecycle — Distillation Loop: stale Episodic Memory 를 LongTerm 'episode-digest'
|
||||
* 로 승급. 누적 epimemory 가 검색 노이즈가 되는 것 방지.
|
||||
* true(기본) → /memory distill + 세션 종료 시 자동 트리거 (interval 기준).
|
||||
*/
|
||||
distillationEnabled: boolean;
|
||||
/** 며칠 이상 지난 episode 를 distill 대상으로. 기본 30. */
|
||||
distillationAgeThresholdDays: number;
|
||||
/** 자동 distillation 의 최소 간격 (일). 너무 자주 안 돌도록. 기본 7. */
|
||||
distillationIntervalDays: number;
|
||||
/**
|
||||
* Archive 모드:
|
||||
* - 'mark-promoted' (기본): promoted=true 마킹만, 파일 보존
|
||||
* - 'archive-file': promoted 마킹 + 파일을 memory/episodes/archive/ 로 이동
|
||||
*/
|
||||
distillationArchiveMode: 'mark-promoted' | 'archive-file';
|
||||
/**
|
||||
* Hierarchical Context Window — 질의·문서 추상도(concrete/operational/strategic) 매칭으로
|
||||
* 검색 결과 재가중. 같은 레벨 boost (× 1.15), 양 끝 mismatch penalty (× 0.7). LLM 호출 없음.
|
||||
*/
|
||||
hierarchicalReweightEnabled: boolean;
|
||||
/**
|
||||
* Semantic Re-ranking — 토큰 예산 통과한 selectedChunks 의 *순서* 를 LLM 한 번 호출로
|
||||
* 재정렬. 의도 매치를 키워드 매치보다 우선. 매 turn 1회 추가 LLM 호출 (latency 비용).
|
||||
* 기본 OFF — 명시적으로 on 해야 함.
|
||||
*/
|
||||
semanticRerankEnabled: boolean;
|
||||
/** 재정렬 전용 모델 ID. 비면 defaultModel 사용. 빠른 작은 모델 권장. */
|
||||
semanticRerankModel: string;
|
||||
/** Re-rank 대상 상위 후보 개수. 기본 15. */
|
||||
semanticRerankCandidateK: number;
|
||||
/** Re-rank LLM 호출 타임아웃 (초). 기본 8. */
|
||||
semanticRerankTimeoutSec: number;
|
||||
/**
|
||||
* Intent Clarification — 모호 질의에서 *추측 답변 대신 역질문* 지시.
|
||||
* 휴리스틱 차원(환경/대상/범위/포맷/마감) 별 trigger + specifier 매치. 기본 true.
|
||||
*/
|
||||
intentClarificationEnabled: boolean;
|
||||
/**
|
||||
* 모호 판정 임계:
|
||||
* - 'low': 2개 이상 missing dimension 일 때만 ambiguous (가장 덜 묻기)
|
||||
* - 'medium' (기본): 1개 이상 missing → ambiguous
|
||||
* - 'high': 1개 이상 missing OR 짧은 prompt(<20자)+trigger 있으면 ambiguous (가장 자주 묻기)
|
||||
*/
|
||||
intentClarificationStrictness: 'low' | 'medium' | 'high';
|
||||
/**
|
||||
* Citation Trace — 답변 끝에 "출처:" 한 줄 정리 지시. CoVe Strict 의 가벼운 형제.
|
||||
* 검색 결과 있을 때만 동작. 기본 true.
|
||||
*/
|
||||
citationTraceEnabled: boolean;
|
||||
/**
|
||||
* Post-hoc Self-Check — 답변 *완료 후* LLM 1회 호출로 검증 (답변 직접도 / 그라운딩 /
|
||||
* 논리 모순). 매 turn 추가 LLM 호출 (latency 비용). 기본 OFF — 명시적 opt-in.
|
||||
* 결과는 답변 아래 footer 한 줄로 표시.
|
||||
*/
|
||||
selfCheckEnabled: boolean;
|
||||
/** Self-check 전용 모델 ID. 비면 defaultModel. 빠른 작은 모델 권장. */
|
||||
selfCheckModel: string;
|
||||
/** Self-check LLM 호출 타임아웃 (초). 기본 6. */
|
||||
selfCheckTimeoutSec: number;
|
||||
/**
|
||||
* Terminology Dictionary — 사용자 편집 글로서리 파일을 시스템 프롬프트에 주입,
|
||||
* 표준 표기 강제 + 답변 직전 자기 점검(Term Check). 기본 true. 파일 없으면 자동 no-op.
|
||||
*/
|
||||
glossaryEnabled: boolean;
|
||||
/** Glossary 파일 상대 경로 (workspace root 기준). 기본 '.astra/glossary.md'. */
|
||||
glossaryPath: string;
|
||||
/** Glossary 본문 시스템 프롬프트 cap (chars). 너무 크면 토큰 비용↑. 기본 4000. */
|
||||
glossaryMaxBodyLength: number;
|
||||
/**
|
||||
* Global Knowledge Mix weight (0–100). Controls how much the assistant leans on
|
||||
* Second Brain evidence vs. model general knowledge when answering.
|
||||
@@ -307,6 +414,31 @@ export function getConfig(): IAgentConfig {
|
||||
finalOnlyRetryOnThoughtLeak: cfg.get<boolean>('finalOnlyRetryOnThoughtLeak', true),
|
||||
embeddingModel: (cfg.get<string>('embeddingModel', '') || '').trim(),
|
||||
embeddingBlendAlpha: Math.max(0, Math.min(1, cfg.get<number>('embeddingBlendAlpha', 0.5))),
|
||||
conflictHighlightingEnabled: cfg.get<boolean>('conflictHighlightingEnabled', true),
|
||||
conflictSeverityThreshold: (cfg.get<string>('conflictSeverityThreshold', 'medium') as 'low' | 'medium' | 'high') || 'medium',
|
||||
conflictCrossDocEnabled: cfg.get<boolean>('conflictCrossDocEnabled', true),
|
||||
coveEnabled: cfg.get<boolean>('coveEnabled', true),
|
||||
coveTopSourcesCount: Math.max(1, Math.min(15, cfg.get<number>('coveTopSourcesCount', 5))),
|
||||
coveStrictMode: cfg.get<boolean>('coveStrictMode', false),
|
||||
actionabilityEnabled: cfg.get<boolean>('actionabilityEnabled', true),
|
||||
distillationEnabled: cfg.get<boolean>('distillationEnabled', true),
|
||||
distillationAgeThresholdDays: Math.max(1, Math.min(365, cfg.get<number>('distillationAgeThresholdDays', 30))),
|
||||
distillationIntervalDays: Math.max(1, Math.min(90, cfg.get<number>('distillationIntervalDays', 7))),
|
||||
distillationArchiveMode: (cfg.get<string>('distillationArchiveMode', 'mark-promoted') as 'mark-promoted' | 'archive-file') || 'mark-promoted',
|
||||
hierarchicalReweightEnabled: cfg.get<boolean>('hierarchicalReweightEnabled', true),
|
||||
semanticRerankEnabled: cfg.get<boolean>('semanticRerankEnabled', false),
|
||||
semanticRerankModel: cfg.get<string>('semanticRerankModel', '') || '',
|
||||
semanticRerankCandidateK: Math.max(2, Math.min(30, cfg.get<number>('semanticRerankCandidateK', 15))),
|
||||
semanticRerankTimeoutSec: Math.max(1, Math.min(60, cfg.get<number>('semanticRerankTimeoutSec', 8))),
|
||||
intentClarificationEnabled: cfg.get<boolean>('intentClarificationEnabled', true),
|
||||
intentClarificationStrictness: (cfg.get<string>('intentClarificationStrictness', 'medium') as 'low' | 'medium' | 'high') || 'medium',
|
||||
citationTraceEnabled: cfg.get<boolean>('citationTraceEnabled', true),
|
||||
selfCheckEnabled: cfg.get<boolean>('selfCheckEnabled', false),
|
||||
selfCheckModel: cfg.get<string>('selfCheckModel', '') || '',
|
||||
selfCheckTimeoutSec: Math.max(1, Math.min(60, cfg.get<number>('selfCheckTimeoutSec', 6))),
|
||||
glossaryEnabled: cfg.get<boolean>('glossaryEnabled', true),
|
||||
glossaryPath: cfg.get<string>('glossaryPath', '.astra/glossary.md') || '.astra/glossary.md',
|
||||
glossaryMaxBodyLength: Math.max(500, Math.min(20000, cfg.get<number>('glossaryMaxBodyLength', 4000))),
|
||||
knowledgeMixSecondBrainWeight: Math.max(0, Math.min(100, Math.round(
|
||||
cfg.get<number>('knowledgeMix.secondBrainWeight', 50)
|
||||
))),
|
||||
|
||||
@@ -33,6 +33,8 @@ export {
|
||||
|
||||
export {
|
||||
createTask,
|
||||
listTasks,
|
||||
TaskInput,
|
||||
CreatedTask,
|
||||
ListedTask,
|
||||
} from './tasksApi';
|
||||
|
||||
@@ -103,3 +103,67 @@ export async function createTask(
|
||||
return { ok: false, error: e?.message || String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export interface ListedTask {
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'needsAction' | 'completed';
|
||||
/** 'YYYY-MM-DD' 형식. due 가 없는 task 는 undefined. */
|
||||
due?: string;
|
||||
/** 완료 시각 ISO timestamp. status 'completed' 일 때만 있음. */
|
||||
completed?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Tasks 목록 조회 — /onesie 1:1 카드 등에서 멤버별 필터링용.
|
||||
*
|
||||
* 기본 default list 의 task 들을 가져온다 (완료 포함). 호출자가 클라이언트 측에서
|
||||
* 제목 prefix `[멤버]` 나 notes 의 `@멤버` / `담당: 멤버` 패턴으로 필터하면 됨.
|
||||
*/
|
||||
export async function listTasks(
|
||||
context: vscode.ExtensionContext,
|
||||
options: { taskListId?: string; showCompleted?: boolean; maxResults?: number } = {},
|
||||
): Promise<{ ok: true; tasks: ListedTask[] } | { ok: false; error: string }> {
|
||||
const tokenResult = await getFreshAccessToken(context);
|
||||
if (!tokenResult.ok) return { ok: false, error: tokenResult.error };
|
||||
|
||||
const taskListId = (options.taskListId || '@default').trim() || '@default';
|
||||
const params = new URLSearchParams({
|
||||
maxResults: String(options.maxResults ?? 100),
|
||||
showCompleted: options.showCompleted !== false ? 'true' : 'false',
|
||||
showHidden: 'true', // 완료 후 숨김 처리된 것도 포함 — 1:1 회고에 최근 완료가 중요.
|
||||
});
|
||||
const url = `${API_BASE}/lists/${encodeURIComponent(taskListId)}/tasks?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${tokenResult.accessToken}` },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const json: any = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const msg: string = json?.error?.message || `HTTP ${res.status}`;
|
||||
if (res.status === 401 || res.status === 403 || /insufficient|scope/i.test(msg)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Tasks API 권한 부족 — "Astra: Google Calendar OAuth 연결 (쓰기)" 명령 재실행 + Tasks 스코프 동의 필요. (원인: ${msg})`,
|
||||
};
|
||||
}
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
const items: any[] = Array.isArray(json.items) ? json.items : [];
|
||||
const tasks: ListedTask[] = items.map((item: any) => ({
|
||||
id: String(item.id || ''),
|
||||
title: String(item.title || ''),
|
||||
status: item.status === 'completed' ? 'completed' : 'needsAction',
|
||||
due: typeof item.due === 'string' ? item.due.slice(0, 10) : undefined,
|
||||
completed: typeof item.completed === 'string' ? item.completed : undefined,
|
||||
notes: typeof item.notes === 'string' ? item.notes : undefined,
|
||||
}));
|
||||
return { ok: true, tasks };
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: e?.message || String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* 고객사 / MRR / 갱신 트래커.
|
||||
*
|
||||
* 4인 기업의 수입 쪽 — `/runway` 가 통장과 burn 을 본다면, 여기는 *어디서 돈이 들어오나*.
|
||||
* Salesforce / HubSpot 같은 CRM 아닌 가벼운 event-sourced 로그.
|
||||
*
|
||||
* 저장 형식: JSON Lines (.jsonl) — append-only event log. 같은 customer 의 여러 이벤트를
|
||||
* 시간 순으로 재생하면 현재 상태 (MRR, 갱신일, 위험 등급) 가 나온다.
|
||||
*
|
||||
* 위치: `<workspace>/.astra/customers.jsonl`. 사람이 직접 편집 가능, grep / 백업 친화.
|
||||
* 민감 정보(고객사 이름, 매출) 포함되므로 외부로 안 보냄 — 로컬 only.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
const STORE_REL_PATH = '.astra/customers.jsonl';
|
||||
|
||||
export type CustomerEventType = 'add' | 'note' | 'risk' | 'churn' | 'renew' | 'update';
|
||||
|
||||
export interface CustomerEvent {
|
||||
/** unique id — timestamp 기반. */
|
||||
id: string;
|
||||
/** ISO timestamp. */
|
||||
timestamp: string;
|
||||
/** 고객 식별자 — 소문자·trim 한 name 의 slug. 같은 customer 의 이벤트끼리 그룹. */
|
||||
customerId: string;
|
||||
/** 표시용 원본 이름 (가장 최근 이벤트의 이름 우선). */
|
||||
customerName: string;
|
||||
/** 이벤트 종류. */
|
||||
type: CustomerEventType;
|
||||
/** 월 매출 — add/renew/update 에서 사용. */
|
||||
mrr?: number;
|
||||
/** 갱신일 (YYYY-MM-DD) — add/renew/update. */
|
||||
renewalAt?: string;
|
||||
/** 요금제명 — 'pro' / 'enterprise' / 'starter' 등. */
|
||||
plan?: string;
|
||||
/** 자유 텍스트 — note / risk / churn 의 사유. */
|
||||
memo?: string;
|
||||
}
|
||||
|
||||
export type CustomerStatus = 'active' | 'at-risk' | 'churned';
|
||||
|
||||
export interface CustomerState {
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
mrr: number;
|
||||
plan?: string;
|
||||
renewalAt?: string;
|
||||
status: CustomerStatus;
|
||||
startedAt: string;
|
||||
lastEventAt: string;
|
||||
eventCount: number;
|
||||
notes: { timestamp: string; type: CustomerEventType; memo: string }[];
|
||||
}
|
||||
|
||||
export function getCustomersFilePath(): string | null {
|
||||
const folders = vscode.workspace.workspaceFolders;
|
||||
if (!folders || folders.length === 0) return null;
|
||||
return path.join(folders[0].uri.fsPath, STORE_REL_PATH);
|
||||
}
|
||||
|
||||
export function customerIdFromName(name: string): string {
|
||||
return name.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^\w가-힣-]/g, '');
|
||||
}
|
||||
|
||||
export function readEvents(): CustomerEvent[] {
|
||||
const filePath = getCustomersFilePath();
|
||||
if (!filePath || !fs.existsSync(filePath)) return [];
|
||||
const out: CustomerEvent[] = [];
|
||||
let content = '';
|
||||
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { return []; }
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const e = JSON.parse(trimmed);
|
||||
if (e && typeof e.id === 'string' && typeof e.customerId === 'string' && typeof e.type === 'string') {
|
||||
out.push(e as CustomerEvent);
|
||||
}
|
||||
} catch { /* skip malformed */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function appendEvent(event: CustomerEvent): { ok: true; filePath: string } | { ok: false; error: string } {
|
||||
const filePath = getCustomersFilePath();
|
||||
if (!filePath) return { ok: false, error: '워크스페이스 폴더가 없어 저장 불가.' };
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.appendFileSync(filePath, JSON.stringify(event) + '\n', 'utf-8');
|
||||
return { ok: true, filePath };
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: e?.message || String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 로그를 재생해 customerId 별 현재 상태 도출.
|
||||
*
|
||||
* - add: 신규 생성 (status=active, startedAt 설정)
|
||||
* - update / renew: mrr / renewalAt / plan 갱신, status=active 로 복귀 (renew 시)
|
||||
* - risk: status=at-risk, memo 누적
|
||||
* - churn: status=churned, mrr=0
|
||||
* - note: 노트 누적만, 상태 무변경
|
||||
*/
|
||||
export function computeCustomerStates(): Map<string, CustomerState> {
|
||||
const events = readEvents().slice().sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
|
||||
const states = new Map<string, CustomerState>();
|
||||
for (const e of events) {
|
||||
let s = states.get(e.customerId);
|
||||
if (!s) {
|
||||
if (e.type !== 'add') {
|
||||
// add 이벤트 없이 다른 이벤트가 먼저 와도 묵시적 생성 — 데이터 손상 방어.
|
||||
s = {
|
||||
customerId: e.customerId,
|
||||
customerName: e.customerName,
|
||||
mrr: 0,
|
||||
status: 'active',
|
||||
startedAt: e.timestamp,
|
||||
lastEventAt: e.timestamp,
|
||||
eventCount: 0,
|
||||
notes: [],
|
||||
};
|
||||
states.set(e.customerId, s);
|
||||
} else {
|
||||
s = {
|
||||
customerId: e.customerId,
|
||||
customerName: e.customerName,
|
||||
mrr: e.mrr ?? 0,
|
||||
plan: e.plan,
|
||||
renewalAt: e.renewalAt,
|
||||
status: 'active',
|
||||
startedAt: e.timestamp,
|
||||
lastEventAt: e.timestamp,
|
||||
eventCount: 0,
|
||||
notes: [],
|
||||
};
|
||||
states.set(e.customerId, s);
|
||||
}
|
||||
}
|
||||
s.customerName = e.customerName || s.customerName;
|
||||
s.lastEventAt = e.timestamp;
|
||||
s.eventCount += 1;
|
||||
|
||||
switch (e.type) {
|
||||
case 'add':
|
||||
// 위에서 이미 처리 (첫 진입 분기) — 중복 add 면 update 처럼.
|
||||
if (e.mrr !== undefined) s.mrr = e.mrr;
|
||||
if (e.renewalAt) s.renewalAt = e.renewalAt;
|
||||
if (e.plan) s.plan = e.plan;
|
||||
break;
|
||||
case 'update':
|
||||
case 'renew':
|
||||
if (e.mrr !== undefined) s.mrr = e.mrr;
|
||||
if (e.renewalAt) s.renewalAt = e.renewalAt;
|
||||
if (e.plan) s.plan = e.plan;
|
||||
if (e.type === 'renew' && s.status !== 'churned') s.status = 'active';
|
||||
break;
|
||||
case 'risk':
|
||||
if (s.status !== 'churned') s.status = 'at-risk';
|
||||
if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'risk', memo: e.memo });
|
||||
break;
|
||||
case 'churn':
|
||||
s.status = 'churned';
|
||||
s.mrr = 0;
|
||||
if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'churn', memo: e.memo });
|
||||
break;
|
||||
case 'note':
|
||||
if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'note', memo: e.memo });
|
||||
break;
|
||||
}
|
||||
}
|
||||
return states;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 고객 피드백 누적 저장소.
|
||||
*
|
||||
* 단일 운영자(대표) 모드에서 슬랙·이메일·CS 채널에 흩어진 고객 피드백을
|
||||
* `/feedback <텍스트>` 한 줄로 모아 둔다. 패턴 분석은 `/feedback summary` 로
|
||||
* LLM 이 누적 데이터를 보고 카테고리 분포 + 반복 주제를 추출.
|
||||
*
|
||||
* 저장 형식: JSON Lines (.jsonl) — 한 줄 = 한 entry. 누적·append-only, 사람이
|
||||
* 직접 편집 가능, grep / 백업 친화. 위치: `<workspace>/.astra/customer-feedback.jsonl`.
|
||||
*
|
||||
* 워크스페이스 폴더가 없으면 저장 불가 (null 반환). 사용자가 `/feedback path` 로
|
||||
* 위치 확인 가능. 민감 정보 포함 가능성 있으므로 외부로 안 보냄 — 로컬 only.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
const STORE_REL_PATH = '.astra/customer-feedback.jsonl';
|
||||
|
||||
export interface FeedbackEntry {
|
||||
/** unique id — timestamp 기반 (정렬·dedup 용도). */
|
||||
id: string;
|
||||
/** ISO timestamp of when this entry was captured. */
|
||||
timestamp: string;
|
||||
/** 사용자가 입력한 원본 텍스트 (그대로 보존). */
|
||||
text: string;
|
||||
/** 선택적 출처 — 'slack' / 'email' / 'cs' / 'review' 등. */
|
||||
source?: string;
|
||||
/** LLM 이 부여한 카테고리 (1~3개). */
|
||||
categories?: string[];
|
||||
/** LLM 판정 — 'positive' / 'neutral' / 'negative'. */
|
||||
sentiment?: 'positive' | 'neutral' | 'negative';
|
||||
}
|
||||
|
||||
export function getFeedbackFilePath(): string | null {
|
||||
const folders = vscode.workspace.workspaceFolders;
|
||||
if (!folders || folders.length === 0) return null;
|
||||
return path.join(folders[0].uri.fsPath, STORE_REL_PATH);
|
||||
}
|
||||
|
||||
export function readFeedback(): FeedbackEntry[] {
|
||||
const filePath = getFeedbackFilePath();
|
||||
if (!filePath || !fs.existsSync(filePath)) return [];
|
||||
const out: FeedbackEntry[] = [];
|
||||
let content = '';
|
||||
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { return []; }
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const entry = JSON.parse(trimmed);
|
||||
if (entry && typeof entry.id === 'string' && typeof entry.text === 'string') {
|
||||
out.push(entry as FeedbackEntry);
|
||||
}
|
||||
} catch { /* skip malformed line — append-only 라 손상 1줄이 전체 무력화하면 안 됨 */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function appendFeedback(entry: FeedbackEntry): { ok: true; filePath: string } | { ok: false; error: string } {
|
||||
const filePath = getFeedbackFilePath();
|
||||
if (!filePath) return { ok: false, error: '워크스페이스 폴더가 없어 저장 불가. VS Code 에서 폴더를 열어 주세요.' };
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.appendFileSync(filePath, JSON.stringify(entry) + '\n', 'utf-8');
|
||||
return { ok: true, filePath };
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: e?.message || String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
/** 누적 항목 수 — 빠른 확인용. */
|
||||
export function countFeedback(): number {
|
||||
const filePath = getFeedbackFilePath();
|
||||
if (!filePath || !fs.existsSync(filePath)) return 0;
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return content.split('\n').filter((l) => l.trim()).length;
|
||||
} catch { return 0; }
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 채용 파이프라인 트래커.
|
||||
*
|
||||
* 4인 → 5인 이상 확장 시점에 후보자가 여러 명, 여러 역할(개발/기획/디자인) 로
|
||||
* 들어오기 시작 — 노션·스프레드시트·이메일에 흩어진 정보를 한 명령으로 본다.
|
||||
*
|
||||
* Event-sourced (customersStore 와 동일 패턴) — append-only 이벤트 로그를
|
||||
* 재생해 후보자별 현재 단계 + 노트 누적 도출.
|
||||
*
|
||||
* 위치: `<workspace>/.astra/hire.jsonl`. 사람 직접 편집 가능.
|
||||
* 민감 정보(이름, 연봉, 거절 사유) 포함 — 로컬 only.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
const STORE_REL_PATH = '.astra/hire.jsonl';
|
||||
|
||||
export type HireEventType = 'add' | 'stage' | 'note' | 'offer' | 'reject' | 'decline' | 'hire';
|
||||
|
||||
/**
|
||||
* 기본 파이프라인 단계. 사용자가 다른 단계명 지정 가능 — 그냥 문자열로 저장.
|
||||
* 표시 순서·정렬용으로 알려진 단계는 가중치 부여.
|
||||
*/
|
||||
export const KNOWN_STAGES = ['inbox', 'screened', 'interview', 'final', 'offer', 'accepted', 'hired', 'rejected', 'declined'] as const;
|
||||
export type KnownStage = typeof KNOWN_STAGES[number];
|
||||
|
||||
export interface HireEvent {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
candidateId: string;
|
||||
candidateName: string;
|
||||
role: string;
|
||||
type: HireEventType;
|
||||
/** stage 전환 시 새 단계. add 시 시작 단계 (기본 'inbox'). */
|
||||
stage?: string;
|
||||
/** offer 의 연봉 — KRW. */
|
||||
salary?: number;
|
||||
/** 입사 예정일 / 거절·이탈 사유. */
|
||||
memo?: string;
|
||||
}
|
||||
|
||||
export interface CandidateState {
|
||||
candidateId: string;
|
||||
candidateName: string;
|
||||
role: string;
|
||||
stage: string;
|
||||
salary?: number;
|
||||
addedAt: string;
|
||||
lastEventAt: string;
|
||||
eventCount: number;
|
||||
notes: { timestamp: string; type: HireEventType; memo: string }[];
|
||||
}
|
||||
|
||||
export function getHireFilePath(): string | null {
|
||||
const folders = vscode.workspace.workspaceFolders;
|
||||
if (!folders || folders.length === 0) return null;
|
||||
return path.join(folders[0].uri.fsPath, STORE_REL_PATH);
|
||||
}
|
||||
|
||||
export function candidateIdFromName(name: string): string {
|
||||
return name.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^\w가-힣-]/g, '');
|
||||
}
|
||||
|
||||
export function readHireEvents(): HireEvent[] {
|
||||
const filePath = getHireFilePath();
|
||||
if (!filePath || !fs.existsSync(filePath)) return [];
|
||||
const out: HireEvent[] = [];
|
||||
let content = '';
|
||||
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { return []; }
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const e = JSON.parse(trimmed);
|
||||
if (e && typeof e.id === 'string' && typeof e.candidateId === 'string' && typeof e.type === 'string') {
|
||||
out.push(e as HireEvent);
|
||||
}
|
||||
} catch { /* skip malformed */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function appendHireEvent(event: HireEvent): { ok: true; filePath: string } | { ok: false; error: string } {
|
||||
const filePath = getHireFilePath();
|
||||
if (!filePath) return { ok: false, error: '워크스페이스 폴더가 없어 저장 불가.' };
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.appendFileSync(filePath, JSON.stringify(event) + '\n', 'utf-8');
|
||||
return { ok: true, filePath };
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: e?.message || String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export function computeCandidateStates(): Map<string, CandidateState> {
|
||||
const events = readHireEvents().slice().sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
|
||||
const states = new Map<string, CandidateState>();
|
||||
for (const e of events) {
|
||||
let s = states.get(e.candidateId);
|
||||
if (!s) {
|
||||
s = {
|
||||
candidateId: e.candidateId,
|
||||
candidateName: e.candidateName,
|
||||
role: e.role || '',
|
||||
stage: e.stage || 'inbox',
|
||||
addedAt: e.timestamp,
|
||||
lastEventAt: e.timestamp,
|
||||
eventCount: 0,
|
||||
notes: [],
|
||||
};
|
||||
states.set(e.candidateId, s);
|
||||
}
|
||||
s.candidateName = e.candidateName || s.candidateName;
|
||||
s.role = e.role || s.role;
|
||||
s.lastEventAt = e.timestamp;
|
||||
s.eventCount += 1;
|
||||
|
||||
switch (e.type) {
|
||||
case 'add':
|
||||
s.stage = e.stage || s.stage || 'inbox';
|
||||
break;
|
||||
case 'stage':
|
||||
if (e.stage) s.stage = e.stage;
|
||||
break;
|
||||
case 'offer':
|
||||
s.stage = 'offer';
|
||||
if (e.salary !== undefined) s.salary = e.salary;
|
||||
if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'offer', memo: e.memo });
|
||||
break;
|
||||
case 'hire':
|
||||
s.stage = 'hired';
|
||||
if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'hire', memo: e.memo });
|
||||
break;
|
||||
case 'reject':
|
||||
s.stage = 'rejected';
|
||||
if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'reject', memo: e.memo });
|
||||
break;
|
||||
case 'decline':
|
||||
s.stage = 'declined';
|
||||
if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'decline', memo: e.memo });
|
||||
break;
|
||||
case 'note':
|
||||
if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'note', memo: e.memo });
|
||||
break;
|
||||
}
|
||||
}
|
||||
return states;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Runway / Cash 누적 저장소.
|
||||
*
|
||||
* 4인 기업 운영의 가장 중요한 숫자 — 현금 잔고 / 월 소진율 / 남은 개월수 — 를
|
||||
* 한 명령 (`/runway`) 로 본다. 회계 시스템은 아니고, 대표가 머리에 가지고 있는
|
||||
* "지금 통장에 얼마, 한 달에 얼마 나감" 을 코드 옆에서 잡는 가벼운 트래커.
|
||||
*
|
||||
* 저장 형식: JSON Lines (.jsonl) — 한 줄 = 한 entry. 외부 회계 SaaS 연동 안 함.
|
||||
* 위치: `<workspace>/.astra/runway.jsonl`. 사람이 직접 편집 가능, grep / 백업 친화.
|
||||
*
|
||||
* 민감 정보(현금 잔고) 포함되므로 외부로 안 보냄 — 로컬 only.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
const STORE_REL_PATH = '.astra/runway.jsonl';
|
||||
|
||||
export type RunwayEntryType = 'snapshot' | 'expense' | 'revenue' | 'burn';
|
||||
|
||||
export interface RunwayEntry {
|
||||
/** unique id — timestamp 기반. */
|
||||
id: string;
|
||||
/** ISO timestamp. */
|
||||
timestamp: string;
|
||||
/** 항목 종류 — snapshot(잔고) / expense(지출) / revenue(수입) / burn(월 소진율 수동 설정). */
|
||||
type: RunwayEntryType;
|
||||
/** 금액 — KRW 기본 단위, 소수점 허용. */
|
||||
amount: number;
|
||||
/** 통화 — 기본 'KRW'. 추후 'USD' 등 확장 가능. */
|
||||
currency?: string;
|
||||
/** 카테고리 — expense 의 경우 'salary' / 'rent' / 'saas' / 'misc' 등. */
|
||||
category?: string;
|
||||
/** 자유 메모. */
|
||||
memo?: string;
|
||||
}
|
||||
|
||||
export function getRunwayFilePath(): string | null {
|
||||
const folders = vscode.workspace.workspaceFolders;
|
||||
if (!folders || folders.length === 0) return null;
|
||||
return path.join(folders[0].uri.fsPath, STORE_REL_PATH);
|
||||
}
|
||||
|
||||
export function readRunway(): RunwayEntry[] {
|
||||
const filePath = getRunwayFilePath();
|
||||
if (!filePath || !fs.existsSync(filePath)) return [];
|
||||
const out: RunwayEntry[] = [];
|
||||
let content = '';
|
||||
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { return []; }
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const entry = JSON.parse(trimmed);
|
||||
if (entry && typeof entry.id === 'string' && typeof entry.amount === 'number' && typeof entry.type === 'string') {
|
||||
out.push(entry as RunwayEntry);
|
||||
}
|
||||
} catch { /* skip malformed line */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function appendRunway(entry: RunwayEntry): { ok: true; filePath: string } | { ok: false; error: string } {
|
||||
const filePath = getRunwayFilePath();
|
||||
if (!filePath) return { ok: false, error: '워크스페이스 폴더가 없어 저장 불가. VS Code 에서 폴더를 열어 주세요.' };
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.appendFileSync(filePath, JSON.stringify(entry) + '\n', 'utf-8');
|
||||
return { ok: true, filePath };
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: e?.message || String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 상태 계산 — 최신 snapshot, 최근 30일 net burn, 명시적 burn 설정 중 우선순위.
|
||||
*
|
||||
* - latestCash: 가장 최근 'snapshot' entry 의 amount (없으면 null).
|
||||
* - explicitBurn: 가장 최근 'burn' entry — 사용자가 수동 설정한 월 소진율.
|
||||
* - computedBurn: 최근 30일 expense - revenue, 30일 미만이면 일 평균 × 30 으로 보정.
|
||||
* - effectiveBurn: explicitBurn 우선, 없으면 computedBurn.
|
||||
* - runwayMonths: latestCash / effectiveBurn — burn 이 0 이하면 Infinity.
|
||||
*/
|
||||
export interface RunwayStatus {
|
||||
latestCash: number | null;
|
||||
latestCashAt: string | null;
|
||||
explicitBurn: number | null;
|
||||
computedBurn: number | null;
|
||||
effectiveBurn: number | null;
|
||||
runwayMonths: number | null;
|
||||
last30Expense: number;
|
||||
last30Revenue: number;
|
||||
last30Days: number;
|
||||
totalEntries: number;
|
||||
}
|
||||
|
||||
export function computeRunwayStatus(now: Date = new Date()): RunwayStatus {
|
||||
const entries = readRunway();
|
||||
const nowMs = now.getTime();
|
||||
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
let latestCash: number | null = null;
|
||||
let latestCashAt: string | null = null;
|
||||
let explicitBurn: number | null = null;
|
||||
let last30Expense = 0;
|
||||
let last30Revenue = 0;
|
||||
let oldestRecentMs = nowMs;
|
||||
let hasRecent = false;
|
||||
|
||||
for (const e of entries) {
|
||||
const t = Date.parse(e.timestamp);
|
||||
if (e.type === 'snapshot') {
|
||||
if (!latestCashAt || (Date.parse(e.timestamp) >= Date.parse(latestCashAt))) {
|
||||
latestCash = e.amount;
|
||||
latestCashAt = e.timestamp;
|
||||
}
|
||||
} else if (e.type === 'burn') {
|
||||
if (!explicitBurn || t >= (entries.find(x => x.type === 'burn' && x.amount === explicitBurn)?.timestamp ? Date.parse(e.timestamp) : 0)) {
|
||||
explicitBurn = e.amount;
|
||||
}
|
||||
} else if (e.type === 'expense' && nowMs - t <= thirtyDaysMs) {
|
||||
last30Expense += e.amount;
|
||||
if (t < oldestRecentMs) oldestRecentMs = t;
|
||||
hasRecent = true;
|
||||
} else if (e.type === 'revenue' && nowMs - t <= thirtyDaysMs) {
|
||||
last30Revenue += e.amount;
|
||||
if (t < oldestRecentMs) oldestRecentMs = t;
|
||||
hasRecent = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 최신 burn 정확히 다시 — 위 로직이 꼬여서 단순화.
|
||||
explicitBurn = null;
|
||||
let burnAt = 0;
|
||||
for (const e of entries) {
|
||||
if (e.type !== 'burn') continue;
|
||||
const t = Date.parse(e.timestamp);
|
||||
if (t >= burnAt) { explicitBurn = e.amount; burnAt = t; }
|
||||
}
|
||||
|
||||
const netBurn30 = last30Expense - last30Revenue;
|
||||
let computedBurn: number | null = null;
|
||||
let last30Days = 0;
|
||||
if (hasRecent) {
|
||||
const span = Math.max(1, Math.ceil((nowMs - oldestRecentMs) / (24 * 60 * 60 * 1000)));
|
||||
last30Days = Math.min(30, span);
|
||||
// 30일 미만이면 일 평균 × 30 으로 환산.
|
||||
if (last30Days < 30) computedBurn = (netBurn30 / last30Days) * 30;
|
||||
else computedBurn = netBurn30;
|
||||
}
|
||||
|
||||
const effectiveBurn = explicitBurn ?? computedBurn;
|
||||
let runwayMonths: number | null = null;
|
||||
if (latestCash !== null && effectiveBurn !== null && effectiveBurn > 0) {
|
||||
runwayMonths = latestCash / effectiveBurn;
|
||||
} else if (latestCash !== null && effectiveBurn !== null && effectiveBurn <= 0) {
|
||||
runwayMonths = Infinity;
|
||||
}
|
||||
|
||||
return {
|
||||
latestCash,
|
||||
latestCashAt,
|
||||
explicitBurn,
|
||||
computedBurn,
|
||||
effectiveBurn,
|
||||
runwayMonths,
|
||||
last30Expense,
|
||||
last30Revenue,
|
||||
last30Days,
|
||||
totalEntries: entries.length,
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,14 @@ import {
|
||||
mapWeightToRetrievalRatio,
|
||||
ResolvedKnowledgeMix,
|
||||
} from '../../retrieval/knowledgeMix';
|
||||
import { buildConflictWarningsBlock, ConflictThresholdSetting } from '../../retrieval/conflictBlock';
|
||||
import { buildCoveChecklistBlock } from '../../retrieval/coveBlock';
|
||||
import { captureWorkStateSignals } from '../../retrieval/actionabilityScoring';
|
||||
import { getRecentSlashCommands } from '../../features/datacollect/slashRouter';
|
||||
import { semanticRerank, DEFAULT_SEMANTIC_RERANK_OPTIONS } from '../../retrieval/semanticRerank';
|
||||
import { detectAmbiguity, buildIntentClarificationBlock, IntentStrictness } from '../../retrieval/intentClarification';
|
||||
import { buildCitationTraceBlock } from '../../retrieval/citationTrace';
|
||||
import { buildTerminologyBlock } from '../../retrieval/terminologyBlock';
|
||||
|
||||
/**
|
||||
* 한 turn 의 RAG / 5-layer memory 컨텍스트 빌드.
|
||||
@@ -54,6 +62,29 @@ export interface TurnContextSink {
|
||||
retrieval: TurnRetrievalSummary | null;
|
||||
lessons: string[];
|
||||
knowledgeMix: ResolvedKnowledgeMix | null;
|
||||
/**
|
||||
* [CONFLICT WARNINGS] 시스템 프롬프트 블록 — 빈 문자열이면 충돌 없음.
|
||||
* buildAstraModeSystemPrompt 가 직접 prompt 에 주입.
|
||||
*/
|
||||
conflictWarnings: string;
|
||||
/**
|
||||
* [VERIFICATION CHECKLIST] Chain-of-Verification 블록 — 빈 문자열이면 CoVe 비활성/근거 없음.
|
||||
* 모델이 답변 *작성 전* 그라운딩 체크하도록 instructional prompt 주입.
|
||||
*/
|
||||
coveChecklist: string;
|
||||
/**
|
||||
* [INTENT CLARIFICATION GUIDANCE] 블록 — 질의가 모호한 차원이 감지된 경우 LLM 에게
|
||||
* 답변보다 *역질문* 우선 지시. 빈 문자열이면 모호 차원 없음 또는 disable.
|
||||
*/
|
||||
intentClarification: string;
|
||||
/**
|
||||
* [CITATION TRACE] 블록 — 답변 끝에 사용 출처 한 줄 정리 지시. 검색 결과 있을 때만.
|
||||
*/
|
||||
citationTrace: string;
|
||||
/** Post-hoc Self-Check 용 — selected chunks 의 (title, excerpt) 요약. */
|
||||
selfCheckSources: Array<{ title: string; excerpt: string }>;
|
||||
/** [TERMINOLOGY DICTIONARY] 시스템 프롬프트 블록 — 글로서리 파일 있을 때만 채워짐. */
|
||||
terminology: string;
|
||||
}
|
||||
|
||||
export interface MemoryContextDeps {
|
||||
@@ -163,6 +194,12 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
|
||||
const mixedBrainFileLimit = mapWeightToBrainFileLimit(knowledgeMix.weight, config.memoryLongTermFiles);
|
||||
const mixedRetrievalRatio = mapWeightToRetrievalRatio(knowledgeMix.weight);
|
||||
|
||||
// Actionability — work-state 신호 캡처 (최근 슬래시 명령 + 열린 파일).
|
||||
// 설정으로 disable 가능. 신호 없으면 retrieve() 가 legacy 동작.
|
||||
const workStateSignals = config.actionabilityEnabled !== false
|
||||
? captureWorkStateSignals(getRecentSlashCommands())
|
||||
: undefined;
|
||||
|
||||
// Unified RAG Pipeline 호출.
|
||||
const result = deps.retrievalOrchestrator.retrieve(deps.currentPrompt, {
|
||||
brain: deps.activeBrain,
|
||||
@@ -180,8 +217,29 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
|
||||
queryEmbedding,
|
||||
embeddingModel: config.embeddingModel || undefined,
|
||||
embeddingBlendAlpha: config.embeddingBlendAlpha,
|
||||
workStateSignals,
|
||||
hierarchicalReweightEnabled: config.hierarchicalReweightEnabled !== false,
|
||||
});
|
||||
|
||||
// Semantic Re-rank (LLM, async) — selectedChunks 의 *순서* 만 재배치. 토큰 예산을
|
||||
// 통과한 chunks 안에서 의도-부합도 순으로 재정렬해 LLM attention bias 활용.
|
||||
// 기본 OFF — latency 우려. 사용자가 명시 enable 시만.
|
||||
if (config.semanticRerankEnabled && result.selectedChunks.length >= 3) {
|
||||
const rerankModel = (config.semanticRerankModel || '').trim() || config.defaultModel;
|
||||
if (rerankModel && config.ollamaUrl) {
|
||||
const rerankRes = await semanticRerank(deps.currentPrompt, result.selectedChunks, {
|
||||
ollamaUrl: config.ollamaUrl,
|
||||
model: rerankModel,
|
||||
candidateK: config.semanticRerankCandidateK ?? DEFAULT_SEMANTIC_RERANK_OPTIONS.candidateK,
|
||||
timeoutMs: (config.semanticRerankTimeoutSec ?? 8) * 1000,
|
||||
excerptLength: DEFAULT_SEMANTIC_RERANK_OPTIONS.excerptLength,
|
||||
});
|
||||
// In-place 교체 — buildContextString 가 이 배열을 그대로 읽음.
|
||||
result.selectedChunks = rerankRes.rerankedChunks;
|
||||
result.fusionLog.push(`Semantic re-rank: ${rerankRes.success ? '✓' : '✗'} ${rerankRes.note} (${rerankRes.durationMs}ms)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fire-and-forget background embedding. Vector 없는 파일만 embed 하므로
|
||||
// steady-state turn 은 작업량 0 — 다음 turn 이 혜택.
|
||||
if (config.embeddingModel) {
|
||||
@@ -225,6 +283,67 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
|
||||
};
|
||||
|
||||
deps.turnCtx.lessons = lessonChunks.map((c) => c.content);
|
||||
|
||||
// Conflict Surface — selectedChunks 의 per-doc conflictSeverity 신호 + 교차-문서
|
||||
// 발산 후보를 LLM 에 노출. 블록은 [CONTEXT] *밖*에 주입돼 token-truncation 보호.
|
||||
// 설정으로 disable 가능 — 기본 켜져 있음 (v4 정책이 이미 CONFLICT WARNING 플래그 참조).
|
||||
if (config.conflictHighlightingEnabled !== false) {
|
||||
const threshold: ConflictThresholdSetting = (config.conflictSeverityThreshold || 'medium') as ConflictThresholdSetting;
|
||||
deps.turnCtx.conflictWarnings = buildConflictWarningsBlock(result.selectedChunks, {
|
||||
selfFlagThreshold: threshold,
|
||||
crossDivergenceEnabled: config.conflictCrossDocEnabled !== false,
|
||||
});
|
||||
} else {
|
||||
deps.turnCtx.conflictWarnings = '';
|
||||
}
|
||||
|
||||
// CoVe (Chain-of-Verification) — 답변 *작성 전* 그라운딩 체크리스트를 시스템 프롬프트에
|
||||
// 주입. 모델이 한 패스 안에서 self-verify. Conflict Surface 와 보완 관계 — 충돌
|
||||
// 데이터를 *어떻게* verify 할지 지시.
|
||||
if (config.coveEnabled !== false) {
|
||||
deps.turnCtx.coveChecklist = buildCoveChecklistBlock(result.selectedChunks, deps.currentPrompt, {
|
||||
topSourcesCount: config.coveTopSourcesCount ?? 5,
|
||||
strictMode: config.coveStrictMode === true,
|
||||
});
|
||||
} else {
|
||||
deps.turnCtx.coveChecklist = '';
|
||||
}
|
||||
|
||||
// Intent Clarification — 모호 차원 감지 시 *역질문 우선* 지시. CoVe / Citation 과
|
||||
// 동일 패턴: instructional system prompt block.
|
||||
if (config.intentClarificationEnabled !== false) {
|
||||
const strict = (config.intentClarificationStrictness || 'medium') as IntentStrictness;
|
||||
const ambig = detectAmbiguity(deps.currentPrompt, strict);
|
||||
deps.turnCtx.intentClarification = buildIntentClarificationBlock(ambig);
|
||||
} else {
|
||||
deps.turnCtx.intentClarification = '';
|
||||
}
|
||||
|
||||
// Citation Trace — 답변 끝에 출처 한 줄 명시 지시. CoVe Strict 의 가벼운 형제.
|
||||
// 검색 결과가 있을 때만 의미 있음.
|
||||
if (config.citationTraceEnabled !== false && result.selectedChunks.length > 0) {
|
||||
deps.turnCtx.citationTrace = buildCitationTraceBlock(result.selectedChunks);
|
||||
} else {
|
||||
deps.turnCtx.citationTrace = '';
|
||||
}
|
||||
|
||||
// Self-Check 용 source 미리보기 — agent.ts 가 post-stream 에서 사용.
|
||||
deps.turnCtx.selfCheckSources = result.selectedChunks.slice(0, 5).map((c) => ({
|
||||
title: c.title || '(제목 없음)',
|
||||
excerpt: (c.content || '').slice(0, 200),
|
||||
}));
|
||||
|
||||
// Terminology Dictionary — 사용자 편집 글로서리 파일을 시스템 프롬프트 블록으로 주입.
|
||||
// 파일 없으면 빈 문자열 (no-op). 캐시 + mtime 체크로 매 turn 디스크 read 최소화.
|
||||
if (config.glossaryEnabled !== false) {
|
||||
deps.turnCtx.terminology = buildTerminologyBlock({
|
||||
relPath: config.glossaryPath || '.astra/glossary.md',
|
||||
maxBodyLength: config.glossaryMaxBodyLength ?? 4000,
|
||||
});
|
||||
} else {
|
||||
deps.turnCtx.terminology = '';
|
||||
}
|
||||
|
||||
// Lessons 블록은 일반 RAG context 보다 앞 — context-overflow truncation 에서 먼저
|
||||
// 살아남게.
|
||||
const lessonBlock = buildLessonChecklistBlock(lessonChunks.map((c) => ({ title: c.title, content: c.content })));
|
||||
|
||||
@@ -130,7 +130,12 @@ export class EpisodicMemory {
|
||||
* 프롬프트와 관련된 에피소드를 검색합니다.
|
||||
*/
|
||||
public findRelevantEpisodes(prompt: string, limit = 3): EpisodicEntry[] {
|
||||
const episodes = this.loadAllEpisodes();
|
||||
// Temporal + Distillation 필터: 만료된 episode 와 LongTerm 으로 이미 promote 된
|
||||
// episode 는 검색에서 제외 (digest 가 LongTerm 에 있으니 중복 노출 방지).
|
||||
const now = Date.now();
|
||||
const episodes = this.loadAllEpisodes()
|
||||
.filter((ep) => !ep.expiresAt || ep.expiresAt > now)
|
||||
.filter((ep) => !ep.promoted);
|
||||
const promptLower = prompt.toLowerCase();
|
||||
const terms = promptLower
|
||||
.split(/[^a-z0-9가-힣_]+/g)
|
||||
@@ -276,6 +281,45 @@ export class EpisodicMemory {
|
||||
.map(([word]) => word);
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 episode 의 promoted 플래그를 true 로 마킹하고 LongTerm digest id 를 기록.
|
||||
* Distillation Loop 가 호출. 파일 rewrite 1회.
|
||||
*/
|
||||
public markPromoted(episodeId: string, longTermDigestId: string): boolean {
|
||||
const episodes = this.loadAllEpisodes();
|
||||
const ep = episodes.find((e) => e.id === episodeId);
|
||||
if (!ep) return false;
|
||||
ep.promoted = true;
|
||||
ep.promotedToLongTermId = longTermDigestId;
|
||||
// Find the file holding this episode and rewrite.
|
||||
try {
|
||||
const files = fs.readdirSync(this.episodeDir).filter((f) => f.endsWith('.json'));
|
||||
for (const file of files) {
|
||||
const full = path.join(this.episodeDir, file);
|
||||
try {
|
||||
const raw = fs.readFileSync(full, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as EpisodicEntry;
|
||||
if (parsed.id === episodeId) {
|
||||
parsed.promoted = true;
|
||||
parsed.promotedToLongTermId = longTermDigestId;
|
||||
fs.writeFileSync(full, JSON.stringify(parsed, null, 2), 'utf-8');
|
||||
this._episodeCache = null; // dir mtime bump → cache 다음 호출에 갱신
|
||||
return true;
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Distillation 후보 — 지정 일수보다 오래되고 아직 promoted 되지 않은 episodes. */
|
||||
public findStaleEpisodes(ageThresholdDays: number): EpisodicEntry[] {
|
||||
const cutoff = Date.now() - ageThresholdDays * 24 * 60 * 60 * 1000;
|
||||
return this.loadAllEpisodes()
|
||||
.filter((ep) => !ep.promoted)
|
||||
.filter((ep) => ep.timestamp < cutoff);
|
||||
}
|
||||
|
||||
private pruneOldEpisodes(): void {
|
||||
try {
|
||||
const files = fs.readdirSync(this.episodeDir)
|
||||
|
||||
@@ -53,7 +53,13 @@ export class LongTermMemory {
|
||||
|
||||
// ─── CRUD ───
|
||||
|
||||
public addEntry(category: LongTermCategory, content: string, source: string, confidence = 0.8): LongTermEntry {
|
||||
public addEntry(
|
||||
category: LongTermCategory,
|
||||
content: string,
|
||||
source: string,
|
||||
confidence = 0.8,
|
||||
opts: { expiresAt?: number } = {},
|
||||
): LongTermEntry {
|
||||
const entry: LongTermEntry = {
|
||||
id: crypto.randomUUID(),
|
||||
category,
|
||||
@@ -62,7 +68,8 @@ export class LongTermMemory {
|
||||
confidence,
|
||||
createdAt: Date.now(),
|
||||
lastReferencedAt: Date.now(),
|
||||
referenceCount: 0
|
||||
referenceCount: 0,
|
||||
...(opts.expiresAt ? { expiresAt: opts.expiresAt } : {}),
|
||||
};
|
||||
this.store.entries.push(entry);
|
||||
// Enforce the retention cap — drop the oldest entries (by createdAt) once
|
||||
@@ -87,12 +94,32 @@ export class LongTermMemory {
|
||||
return false;
|
||||
}
|
||||
|
||||
public getAllEntries(): LongTermEntry[] {
|
||||
return [...this.store.entries];
|
||||
/** 만료된 entry 를 필터링한 활성 entries (검색·context build 가 보는 것). */
|
||||
private getActiveEntries(): LongTermEntry[] {
|
||||
const now = Date.now();
|
||||
return this.store.entries.filter((e) => !e.expiresAt || e.expiresAt > now);
|
||||
}
|
||||
|
||||
public getAllEntries(opts: { includeExpired?: boolean } = {}): LongTermEntry[] {
|
||||
return opts.includeExpired ? [...this.store.entries] : this.getActiveEntries();
|
||||
}
|
||||
|
||||
public getEntriesByCategory(category: LongTermCategory): LongTermEntry[] {
|
||||
return this.store.entries.filter((e) => e.category === category);
|
||||
return this.getActiveEntries().filter((e) => e.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 entry 의 expiresAt 갱신. id 또는 id prefix (8자 이상) 로 식별.
|
||||
* 반환: 갱신된 entry 또는 null (못 찾음).
|
||||
*/
|
||||
public setExpiration(idOrPrefix: string, expiresAt: number): LongTermEntry | null {
|
||||
const match = this.store.entries.find((e) => e.id === idOrPrefix)
|
||||
|| (idOrPrefix.length >= 8 ? this.store.entries.find((e) => e.id.startsWith(idOrPrefix)) : undefined);
|
||||
if (!match) return null;
|
||||
match.expiresAt = expiresAt;
|
||||
this.dirty = true;
|
||||
this.save();
|
||||
return match;
|
||||
}
|
||||
|
||||
// ─── Context Building ───
|
||||
@@ -101,7 +128,9 @@ export class LongTermMemory {
|
||||
* 프롬프트와 관련성이 높은 Long-Term 기억을 반환합니다.
|
||||
*/
|
||||
public buildContext(currentPrompt: string, maxEntries = 10): MemoryContextResult | null {
|
||||
if (this.store.entries.length === 0) return null;
|
||||
// 만료된 entry 는 검색에서 자동 제외 — Temporal Markers 의 핵심.
|
||||
const activeEntries = this.getActiveEntries();
|
||||
if (activeEntries.length === 0) return null;
|
||||
|
||||
const promptLower = currentPrompt.toLowerCase();
|
||||
const terms = promptLower
|
||||
@@ -109,7 +138,7 @@ export class LongTermMemory {
|
||||
.filter((t) => t.length >= 2);
|
||||
|
||||
// Score entries by relevance to prompt
|
||||
const scored = this.store.entries.map((entry) => {
|
||||
const scored = activeEntries.map((entry) => {
|
||||
let score = 0;
|
||||
const contentLower = entry.content.toLowerCase();
|
||||
|
||||
@@ -134,8 +163,8 @@ export class LongTermMemory {
|
||||
.slice(0, maxEntries);
|
||||
|
||||
if (relevant.length === 0) {
|
||||
// Still include all rules and goals even without prompt match
|
||||
const alwaysInclude = this.store.entries
|
||||
// Still include all rules and goals even without prompt match — 만료 제외.
|
||||
const alwaysInclude = activeEntries
|
||||
.filter((e) => e.category === 'rule' || e.category === 'goal')
|
||||
.slice(0, 5);
|
||||
if (alwaysInclude.length === 0) return null;
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Distillation Loop — stale Episodic Memory → Long-Term "episode-digest" 승급.
|
||||
*
|
||||
* 배경: Episodic Memory 가 무한히 누적되면 검색 노이즈. 30일+ 지난 에피소드는
|
||||
* "지금 이 순간 관련 가능성" 보다 "역사적 패턴" 가치가 커서, 디테일을 압축해
|
||||
* Long-Term 으로 옮기고 원본은 archive 하는 게 효율적.
|
||||
*
|
||||
* v1 설계 (LLM-less, 예측 가능):
|
||||
* - LLM 호출 없이 기존 EpisodicEntry 의 title/summary/keyDecisions/topics 를
|
||||
* 구조적으로 결합해 LongTerm 'episode-digest' content 생성
|
||||
* - 장점: 비용 0, 결정적·재현 가능, LM Studio 다운 시에도 동작
|
||||
* - 단점: LLM 요약보다 농축도 낮음 — 추후 strict 모드에서 LLM 패스 추가 가능
|
||||
*
|
||||
* 원본 episode 처리: 두 가지 옵션 — 사용자 설정으로 결정.
|
||||
* - 'mark-promoted' (기본): promoted=true 마킹만, 파일 보존. 검색에서 제외되나
|
||||
* 히스토리·디버깅용으로 디스크에 남음.
|
||||
* - 'archive-file': promoted 마킹 + 파일을 memory/episodes/archive/ 로 이동.
|
||||
* 디스크 정리에 더 깔끔하나 복구 시 수동.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { EpisodicMemory } from './EpisodicMemory';
|
||||
import { LongTermMemory } from './LongTermMemory';
|
||||
import { EpisodicEntry } from './types';
|
||||
|
||||
export type DistillationArchiveMode = 'mark-promoted' | 'archive-file';
|
||||
|
||||
export interface DistillationOptions {
|
||||
/** 며칠 이상 지난 episode 를 대상으로. 기본 30. */
|
||||
ageThresholdDays: number;
|
||||
/** Archive 처리 방식. 기본 'mark-promoted'. */
|
||||
archiveMode: DistillationArchiveMode;
|
||||
/** 한 번에 처리할 최대 episode 수 (안전장치). 기본 50. */
|
||||
maxBatchSize: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_DISTILLATION_OPTIONS: DistillationOptions = {
|
||||
ageThresholdDays: 30,
|
||||
archiveMode: 'mark-promoted',
|
||||
maxBatchSize: 50,
|
||||
};
|
||||
|
||||
export interface DistillationReport {
|
||||
candidateCount: number;
|
||||
promotedCount: number;
|
||||
archivedCount: number;
|
||||
longTermDigestIds: string[];
|
||||
skipped: { episodeId: string; reason: string }[];
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Episode → LongTerm 'episode-digest' content 변환. 결정적·LLM 없음.
|
||||
*/
|
||||
function episodeToDigestContent(ep: EpisodicEntry): string {
|
||||
const date = new Date(ep.timestamp).toISOString().slice(0, 10);
|
||||
const parts: string[] = [];
|
||||
parts.push(`[${date}] ${ep.title}`);
|
||||
if (ep.summary && ep.summary.trim()) parts.push(`요약: ${ep.summary.trim()}`);
|
||||
if (ep.keyDecisions && ep.keyDecisions.length > 0) {
|
||||
parts.push(`결정: ${ep.keyDecisions.slice(0, 5).join(' · ')}`);
|
||||
}
|
||||
if (ep.topics && ep.topics.length > 0) {
|
||||
parts.push(`토픽: ${ep.topics.slice(0, 8).join(', ')}`);
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Distillation 실행 — stale episodes 를 LongTerm digest 로 승급 + archive.
|
||||
*
|
||||
* 호출자: `/memory distill` 슬래시 명령 + 세션 종료 시 auto-trigger (선택).
|
||||
*/
|
||||
export function distillStaleEpisodes(
|
||||
episodicMemory: EpisodicMemory,
|
||||
longTermMemory: LongTermMemory,
|
||||
brainPath: string,
|
||||
options: Partial<DistillationOptions> = {},
|
||||
): DistillationReport {
|
||||
const opts: DistillationOptions = { ...DEFAULT_DISTILLATION_OPTIONS, ...options };
|
||||
const start = Date.now();
|
||||
const report: DistillationReport = {
|
||||
candidateCount: 0,
|
||||
promotedCount: 0,
|
||||
archivedCount: 0,
|
||||
longTermDigestIds: [],
|
||||
skipped: [],
|
||||
durationMs: 0,
|
||||
};
|
||||
|
||||
const candidates = episodicMemory.findStaleEpisodes(opts.ageThresholdDays).slice(0, opts.maxBatchSize);
|
||||
report.candidateCount = candidates.length;
|
||||
|
||||
if (candidates.length === 0) {
|
||||
report.durationMs = Date.now() - start;
|
||||
return report;
|
||||
}
|
||||
|
||||
const archiveDir = path.join(brainPath, 'memory', 'episodes', 'archive');
|
||||
if (opts.archiveMode === 'archive-file') {
|
||||
try { fs.mkdirSync(archiveDir, { recursive: true }); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
for (const ep of candidates) {
|
||||
try {
|
||||
// 1. LongTerm digest entry 생성. confidence 약간 낮춤 (압축 손실 반영).
|
||||
const digestContent = episodeToDigestContent(ep);
|
||||
const digest = longTermMemory.addEntry(
|
||||
'episode-digest',
|
||||
digestContent,
|
||||
`episodic:${ep.id}`,
|
||||
0.7,
|
||||
);
|
||||
report.longTermDigestIds.push(digest.id);
|
||||
|
||||
// 2. 원본 episode 처리.
|
||||
const marked = episodicMemory.markPromoted(ep.id, digest.id);
|
||||
if (!marked) {
|
||||
report.skipped.push({ episodeId: ep.id, reason: 'markPromoted failed (file not found)' });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (opts.archiveMode === 'archive-file') {
|
||||
// 파일 위치 찾아서 archive 디렉터리로 이동.
|
||||
const moved = tryMoveEpisodeFileToArchive(ep.id, path.join(brainPath, 'memory', 'episodes'), archiveDir);
|
||||
if (moved) report.archivedCount++;
|
||||
}
|
||||
report.promotedCount++;
|
||||
} catch (e: any) {
|
||||
report.skipped.push({ episodeId: ep.id, reason: e?.message || String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
report.durationMs = Date.now() - start;
|
||||
return report;
|
||||
}
|
||||
|
||||
function tryMoveEpisodeFileToArchive(episodeId: string, episodeDir: string, archiveDir: string): boolean {
|
||||
try {
|
||||
const files = fs.readdirSync(episodeDir).filter((f) => f.endsWith('.json'));
|
||||
for (const file of files) {
|
||||
const full = path.join(episodeDir, file);
|
||||
try {
|
||||
const raw = fs.readFileSync(full, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as EpisodicEntry;
|
||||
if (parsed.id === episodeId) {
|
||||
fs.renameSync(full, path.join(archiveDir, file));
|
||||
return true;
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Distillation 마지막 실행 시각을 저장·조회 — 자동 트리거가 *너무 자주* 안 돌도록.
|
||||
* brainPath 의 marker 파일 사용 (vscode.globalState 안 쓰는 이유: 메모리 인프라가
|
||||
* BrainProfile-scoped 라 brain 디렉터리에 두는 게 일관성 있음).
|
||||
*/
|
||||
const MARKER_FILE = 'distillation_last_run.json';
|
||||
|
||||
export interface DistillationMarker {
|
||||
timestamp: number;
|
||||
report?: Partial<DistillationReport>;
|
||||
}
|
||||
|
||||
export function getLastDistillationRun(brainPath: string): DistillationMarker | null {
|
||||
try {
|
||||
const fp = path.join(brainPath, 'memory', MARKER_FILE);
|
||||
if (!fs.existsSync(fp)) return null;
|
||||
return JSON.parse(fs.readFileSync(fp, 'utf-8')) as DistillationMarker;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function recordDistillationRun(brainPath: string, report: DistillationReport): void {
|
||||
try {
|
||||
const dir = path.join(brainPath, 'memory');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const marker: DistillationMarker = {
|
||||
timestamp: Date.now(),
|
||||
report: {
|
||||
candidateCount: report.candidateCount,
|
||||
promotedCount: report.promotedCount,
|
||||
archivedCount: report.archivedCount,
|
||||
durationMs: report.durationMs,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(path.join(dir, MARKER_FILE), JSON.stringify(marker, null, 2), 'utf-8');
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** 자동 트리거 게이트 — 마지막 실행 후 N일 경과 시 true. */
|
||||
export function shouldAutoDistill(brainPath: string, intervalDays: number): boolean {
|
||||
const last = getLastDistillationRun(brainPath);
|
||||
if (!last) return true;
|
||||
const elapsed = (Date.now() - last.timestamp) / (1000 * 60 * 60 * 24);
|
||||
return elapsed >= intervalDays;
|
||||
}
|
||||
+39
-2
@@ -20,6 +20,12 @@ import { ProceduralMemory } from './ProceduralMemory';
|
||||
import { EpisodicMemory } from './EpisodicMemory';
|
||||
import { MemoryExtractor } from './MemoryExtractor';
|
||||
import { MemoryContextResult, MemoryConfig } from './types';
|
||||
import {
|
||||
distillStaleEpisodes,
|
||||
shouldAutoDistill,
|
||||
recordDistillationRun,
|
||||
type DistillationArchiveMode,
|
||||
} from './distillation';
|
||||
|
||||
export { ShortTermMemory } from './ShortTermMemory';
|
||||
export { LongTermMemory } from './LongTermMemory';
|
||||
@@ -27,6 +33,17 @@ export { ProjectMemory } from './ProjectMemory';
|
||||
export { ProceduralMemory } from './ProceduralMemory';
|
||||
export { EpisodicMemory } from './EpisodicMemory';
|
||||
export { MemoryExtractor } from './MemoryExtractor';
|
||||
export {
|
||||
distillStaleEpisodes,
|
||||
getLastDistillationRun,
|
||||
recordDistillationRun,
|
||||
shouldAutoDistill,
|
||||
DEFAULT_DISTILLATION_OPTIONS,
|
||||
type DistillationOptions,
|
||||
type DistillationReport,
|
||||
type DistillationMarker,
|
||||
type DistillationArchiveMode,
|
||||
} from './distillation';
|
||||
export * from './types';
|
||||
|
||||
export class MemoryManager {
|
||||
@@ -132,11 +149,18 @@ export class MemoryManager {
|
||||
public onSessionEnd(
|
||||
sessionId: string,
|
||||
messages: Array<{ role: string; content: string; timestamp?: number }>,
|
||||
workspacePath?: string
|
||||
workspacePath?: string,
|
||||
distillationOpts?: {
|
||||
enabled: boolean;
|
||||
ageThresholdDays: number;
|
||||
intervalDays: number;
|
||||
archiveMode: DistillationArchiveMode;
|
||||
brainPath: string;
|
||||
},
|
||||
): void {
|
||||
if (!this.config.enabled) return;
|
||||
|
||||
const projectMemory = workspacePath
|
||||
const projectMemory = workspacePath
|
||||
? this.getProjectMemory(workspacePath)
|
||||
: null;
|
||||
|
||||
@@ -153,6 +177,19 @@ export class MemoryManager {
|
||||
|
||||
// Persist long-term memory
|
||||
this.longTerm.save();
|
||||
|
||||
// Auto-distillation — Distillation Loop 가 enabled 이고 interval 충족 시 stale
|
||||
// episodes 를 LongTerm digest 로 승급. 세션 종료 시점이 자연스러움 — 사용자가
|
||||
// 다음 세션 시작 전 한 번 cleanup.
|
||||
if (distillationOpts?.enabled && shouldAutoDistill(distillationOpts.brainPath, distillationOpts.intervalDays)) {
|
||||
try {
|
||||
const report = distillStaleEpisodes(this.episodic, this.longTerm, distillationOpts.brainPath, {
|
||||
ageThresholdDays: distillationOpts.ageThresholdDays,
|
||||
archiveMode: distillationOpts.archiveMode,
|
||||
});
|
||||
recordDistillationRun(distillationOpts.brainPath, report);
|
||||
} catch { /* distillation should never break session end */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Direct Access (for UI & advanced features) ───
|
||||
|
||||
+26
-1
@@ -28,7 +28,12 @@ export interface ShortTermMessage {
|
||||
|
||||
// ─── ② Long-Term Memory ───
|
||||
|
||||
export type LongTermCategory = 'preference' | 'rule' | 'decision' | 'goal';
|
||||
/**
|
||||
* Long-term category.
|
||||
* - 'episode-digest' 는 Distillation Loop 가 stale episodic memory 를 long-term 으로
|
||||
* 승급시킬 때 사용. 사용자가 직접 만드는 'decision' / 'rule' 등과 시각적으로 구분.
|
||||
*/
|
||||
export type LongTermCategory = 'preference' | 'rule' | 'decision' | 'goal' | 'episode-digest';
|
||||
|
||||
export interface LongTermEntry {
|
||||
id: string;
|
||||
@@ -39,6 +44,14 @@ export interface LongTermEntry {
|
||||
createdAt: number;
|
||||
lastReferencedAt: number;
|
||||
referenceCount: number;
|
||||
/**
|
||||
* Temporal marker — 이 사실이 *유효한 마지막 시점* (epoch ms).
|
||||
* 검색·context build 단계에서 expiresAt < now 인 entry 는 자동 제외.
|
||||
* undefined 면 영구 유효 (legacy 동작).
|
||||
*
|
||||
* 사용 예: "Q3 2026 마케팅 계획은 9월 30일까지만 유효" → expiresAt = 2026-09-30 epoch.
|
||||
*/
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
export interface LongTermStore {
|
||||
@@ -105,6 +118,18 @@ export interface EpisodicEntry {
|
||||
timestamp: number;
|
||||
duration: number; // 세션 길이 (ms)
|
||||
messageCount: number;
|
||||
/**
|
||||
* Temporal marker — 에피소드의 *유효 마지막 시점* (epoch ms). 검색에서 자동 제외.
|
||||
* undefined 면 영구 (Distillation 이 archive 할 때까지).
|
||||
*/
|
||||
expiresAt?: number;
|
||||
/**
|
||||
* Distillation Loop 가 이 episode 를 LongTerm digest 로 promote 했음을 표시.
|
||||
* promoted=true 면 검색·context build 에서 제외 (LongTerm 에 digest 가 있으니).
|
||||
*/
|
||||
promoted?: boolean;
|
||||
/** promoted 인 경우 — 생성된 LongTerm digest entry id (역참조용). */
|
||||
promotedToLongTermId?: string;
|
||||
}
|
||||
|
||||
export interface EpisodicStore {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Actionability Scoring — 검색 결과를 "현재 작업 상태" 신호로 재가중.
|
||||
*
|
||||
* 기존 TF-IDF (단어 매칭) + recency (시간) 만으로는 "지금 이 사용자가 하고 있는
|
||||
* 작업과 *직접 연결* 된 문서" 가 우선되지 않음. 예: 사용자가 `/runway` 명령을
|
||||
* 막 실행했다면 runway / 재무 관련 문서가 같은 키워드 매치 점수여도 더 위로 와야 함.
|
||||
*
|
||||
* v1 신호 (사용자 선택):
|
||||
* 1. **최근 슬래시 명령** — 마지막 N개 실행된 슬래시 명령 이름을 키워드로 활용
|
||||
* → 명령 이름이 chunk title/content 에 포함되면 boost
|
||||
* 2. **열린 파일 경로** — VS Code 활성 에디터의 파일 이름·확장자·부모 디렉터리를
|
||||
* 키워드로 활용
|
||||
*
|
||||
* 점수 결합: TF-IDF normalized score (0~1) × (1 + actionabilityScore × weight)
|
||||
* - weight 기본 0.3 → actionability=1.0 인 chunk 는 30% boost
|
||||
* - actionability=0.0 인 chunk 는 변화 없음
|
||||
* - TF-IDF 가 여전히 dominant 인 보수적 합산
|
||||
*
|
||||
* 향후 신호 (#1 v2 후보 — 사용자 선택 안 함):
|
||||
* 3. 최근 7일 Chronicle ADR / decisions
|
||||
* 4. 최근 24시간 customers/hire/runway 이벤트
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { RetrievalChunk } from './types';
|
||||
|
||||
export interface WorkStateSignals {
|
||||
/** 최근 실행된 슬래시 명령 이름 목록 ('/' 포함, 최신 순). 빈 배열이면 신호 없음. */
|
||||
recentSlashCommands: string[];
|
||||
/** VS Code 활성 에디터의 파일 절대 경로. undefined 면 신호 없음. */
|
||||
openFilePath?: string;
|
||||
}
|
||||
|
||||
export interface ActionabilityWeights {
|
||||
/** 슬래시 명령 매치당 boost. 기본 0.30. */
|
||||
slashCommandMatch: number;
|
||||
/** 파일명 매치 boost. 기본 0.40 (가장 강함). */
|
||||
openFileNameMatch: number;
|
||||
/** 부모 디렉터리 매치 boost. 기본 0.20. */
|
||||
openFileParentDirMatch: number;
|
||||
/** 확장자 의미 매치 boost (e.g. .ts → typescript/tsx 단어). 기본 0.10. */
|
||||
openFileExtMatch: number;
|
||||
/** 최종 결합 가중치 — finalScore = base × (1 + actionability × this). 기본 0.30. */
|
||||
combinedWeight: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_ACTIONABILITY_WEIGHTS: ActionabilityWeights = {
|
||||
slashCommandMatch: 0.30,
|
||||
openFileNameMatch: 0.40,
|
||||
openFileParentDirMatch: 0.20,
|
||||
openFileExtMatch: 0.10,
|
||||
combinedWeight: 0.30,
|
||||
};
|
||||
|
||||
/** VS Code 활성 에디터·최근 슬래시 명령에서 work-state 신호 캡처. */
|
||||
export function captureWorkStateSignals(recentSlashCommands: string[]): WorkStateSignals {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
return {
|
||||
recentSlashCommands: recentSlashCommands.slice(0, 5), // 최신 5개로 cap
|
||||
openFilePath: editor?.document.uri.fsPath,
|
||||
};
|
||||
}
|
||||
|
||||
const EXTENSION_KEYWORDS: Record<string, RegExp> = {
|
||||
'.ts': /\b(typescript|tsx?|ts)\b/i,
|
||||
'.tsx': /\b(typescript|tsx|react)\b/i,
|
||||
'.js': /\b(javascript|jsx?)\b/i,
|
||||
'.jsx': /\b(javascript|jsx|react)\b/i,
|
||||
'.py': /\b(python|py)\b/i,
|
||||
'.md': /\b(markdown|md|문서)\b/i,
|
||||
'.json': /\b(json|config)\b/i,
|
||||
'.go': /\b(golang|go)\b/i,
|
||||
'.rs': /\b(rust|rs)\b/i,
|
||||
};
|
||||
|
||||
/**
|
||||
* 한 chunk 의 actionability 점수 계산 — 0.0 ~ 1.0 (cap).
|
||||
* 매치 boost 들의 단순 합산 후 1.0 cap.
|
||||
*/
|
||||
export function computeActionabilityScore(
|
||||
chunk: RetrievalChunk,
|
||||
signals: WorkStateSignals,
|
||||
weights: ActionabilityWeights = DEFAULT_ACTIONABILITY_WEIGHTS,
|
||||
): number {
|
||||
if (!chunk) return 0;
|
||||
const haystack = ((chunk.title || '') + ' ' + (chunk.content || '')).toLowerCase();
|
||||
if (!haystack.trim()) return 0;
|
||||
|
||||
let score = 0;
|
||||
|
||||
// Signal 1: 최근 슬래시 명령 — '/runway' → 'runway' 키워드 매치
|
||||
for (const cmd of signals.recentSlashCommands) {
|
||||
const kw = cmd.replace(/^\//, '').toLowerCase().trim();
|
||||
if (kw.length < 3) continue; // 너무 짧으면 노이즈 (/q 등)
|
||||
// 단어 경계 매치 — substring 이 아닌 단어 단위 (영문 한정, 한글은 substring)
|
||||
const isAscii = /^[a-z0-9-]+$/.test(kw);
|
||||
const wordRe = isAscii ? new RegExp(`\\b${escapeRegex(kw)}\\b`, 'i') : null;
|
||||
if (wordRe ? wordRe.test(haystack) : haystack.includes(kw)) {
|
||||
score += weights.slashCommandMatch;
|
||||
}
|
||||
}
|
||||
|
||||
// Signal 2: 열린 파일 — 파일명·부모 디렉터리·확장자
|
||||
if (signals.openFilePath) {
|
||||
const fp = signals.openFilePath;
|
||||
const ext = path.extname(fp).toLowerCase();
|
||||
const base = path.basename(fp, ext).toLowerCase();
|
||||
const parent = path.basename(path.dirname(fp)).toLowerCase();
|
||||
|
||||
// 파일 자체가 chunk 의 filePath 와 같으면 강한 boost (max)
|
||||
const chunkFile = chunk.metadata?.filePath?.toLowerCase();
|
||||
if (chunkFile && chunkFile === fp.toLowerCase()) {
|
||||
score += weights.openFileNameMatch * 1.5; // exact file = 보너스
|
||||
} else if (base.length >= 3) {
|
||||
const baseRe = new RegExp(`\\b${escapeRegex(base)}\\b`, 'i');
|
||||
if (baseRe.test(haystack)) score += weights.openFileNameMatch;
|
||||
}
|
||||
|
||||
if (parent.length >= 3 && parent !== 'src' && parent !== 'lib') {
|
||||
// 'src' / 'lib' 같은 일반 디렉터리는 신호로서 약함 — 제외
|
||||
const parentRe = new RegExp(`\\b${escapeRegex(parent)}\\b`, 'i');
|
||||
if (parentRe.test(haystack)) score += weights.openFileParentDirMatch;
|
||||
}
|
||||
|
||||
const extRe = EXTENSION_KEYWORDS[ext];
|
||||
if (extRe && extRe.test(haystack)) score += weights.openFileExtMatch;
|
||||
}
|
||||
|
||||
return Math.min(score, 1.0);
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunks 배열의 score 를 actionability 로 재가중. *원본 score 를 덮어씀* — 호출자는
|
||||
* 반드시 retrieval pipeline 안에서 normalizeScores 직후, selectWithinBudget 직전에만 호출.
|
||||
*
|
||||
* 각 chunk 에 actionabilityScore 를 metadata 에 기록 (디버깅·UI 표시 용도).
|
||||
*/
|
||||
export function applyActionabilityBoost(
|
||||
chunks: RetrievalChunk[],
|
||||
signals: WorkStateSignals,
|
||||
weights: ActionabilityWeights = DEFAULT_ACTIONABILITY_WEIGHTS,
|
||||
): void {
|
||||
if (!signals || (signals.recentSlashCommands.length === 0 && !signals.openFilePath)) return;
|
||||
for (const c of chunks) {
|
||||
const a = computeActionabilityScore(c, signals, weights);
|
||||
if (a > 0) {
|
||||
(c.metadata as any).actionabilityScore = a;
|
||||
c.score = c.score * (1 + a * weights.combinedWeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Citation Trace — 답변 *끝* 에 "출처:" 한 줄 명시 지시.
|
||||
*
|
||||
* CoVe Strict 모드 (v2.2.184) 와 차이:
|
||||
* - CoVe Strict: 모든 사실 주장 뒤에 inline `[S1]` 인용 강제 — verbose, 학술적
|
||||
* - Citation Trace: 답변 끝에 *사용된 출처* 한 줄 정리 — 가벼움, 항상 ON 권장
|
||||
*
|
||||
* 둘은 함께 동작 가능. CoVe 가 [S1]..[SN] 라벨을 system prompt 에 노출하면,
|
||||
* Citation Trace 는 LLM 에게 "그 라벨들 중 답변에 *실제로 사용된* 것을 끝에 한 줄
|
||||
* 정리" 라고 지시.
|
||||
*
|
||||
* 효과: 사용자가 답변 검증 가능 — "이 답변이 어느 출처에 기반했나" 명시.
|
||||
* 할루시네이션 억제 — LLM 이 출처 없는 주장 줄임.
|
||||
*
|
||||
* 비용: 시스템 프롬프트 ~10줄 추가. LLM 출력에 1줄 추가.
|
||||
*/
|
||||
|
||||
import { RetrievalChunk } from './types';
|
||||
|
||||
export interface CitationTraceOptions {
|
||||
/** 답변 끝 *출처 한 줄* 형식. 'tail' 만 v1 지원. */
|
||||
format: 'tail';
|
||||
}
|
||||
|
||||
/**
|
||||
* Citation Trace 블록 — chunks 가 *있어야* 의미 있으니 비어 있으면 빈 문자열.
|
||||
* Casual conversation 모드는 호출자가 미리 걸러야.
|
||||
*/
|
||||
export function buildCitationTraceBlock(
|
||||
chunks: RetrievalChunk[],
|
||||
options: Partial<CitationTraceOptions> = {},
|
||||
): string {
|
||||
if (!chunks || chunks.length === 0) return '';
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('[CITATION TRACE]');
|
||||
lines.push('답변에서 *검색된 출처를 사용했다면*, 답변 끝에 다음 형식으로 *한 줄* 정리:');
|
||||
lines.push('');
|
||||
lines.push('*출처:* `파일명.md` · `chunk-title` · `chunk-title2`');
|
||||
lines.push('');
|
||||
lines.push('[규칙]');
|
||||
lines.push('1. 실제 답변 작성에 *사용한* 출처만 나열. 검색됐지만 안 쓴 출처는 제외.');
|
||||
lines.push('2. 출처 라벨은 파일명(있으면) 또는 chunk title 그대로 — 임의 변형 금지.');
|
||||
lines.push('3. 일반 모델 지식만 사용했다면: *출처: 모델 지식 (검색 출처 미사용)*');
|
||||
lines.push('4. 답변이 검증 가능하도록 — 사용자가 그 파일을 열면 답변 근거를 확인할 수 있어야.');
|
||||
lines.push('5. *출처:* 라인은 답변 *맨 끝* 한 번만 — 본문 중간에 흩어 놓지 말 것.');
|
||||
lines.push('[/CITATION TRACE]');
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Conflict Surface — [CONFLICT WARNINGS] 시스템 프롬프트 블록 생성.
|
||||
*
|
||||
* 기존 scoring.ts 가 문서당 conflictSeverity(NONE/LOW/MEDIUM/HIGH) 를 *이미*
|
||||
* 계산하지만(반대/논란/vs 등 indicator 단어 카운트), LLM 은 그 사실을 모름.
|
||||
* buildAstraModeSystemPrompt 의 v4 정책 텍스트는 이미 "[CONFLICT WARNING] 플래그"
|
||||
* 를 *언급*하나, 실제 어떤 문서가 충돌인지 LLM 에게 *전달되지 않음* — 정책이
|
||||
* 명시되어 있지만 데이터가 없어 무용한 상태.
|
||||
*
|
||||
* 이 모듈이 그 갭을 메움:
|
||||
* 1. 자기-신호(self-flag) — chunk.metadata.conflictSeverity ≥ threshold
|
||||
* 2. 교차-문서 발산(cross-divergence) — 같은 주제 2 chunks, Jaccard < 임계
|
||||
*
|
||||
* 둘을 합쳐 마크다운 블록 한 개로. 결과가 비면 빈 문자열 반환 — 호출자가
|
||||
* 안전하게 무조건 join 가능.
|
||||
*/
|
||||
|
||||
import { RetrievalChunk, ConflictSeverity } from './types';
|
||||
import { tokenize } from './scoring';
|
||||
|
||||
/** 사용자 설정 임계값. 'low' = LOW 부터, 'medium' = MEDIUM 부터, 'high' = HIGH 만. */
|
||||
export type ConflictThresholdSetting = 'low' | 'medium' | 'high';
|
||||
|
||||
export interface ConflictBlockOptions {
|
||||
/** 자기-신호 surface 시 최소 severity. 기본 'medium'. */
|
||||
selfFlagThreshold: ConflictThresholdSetting;
|
||||
/** 교차 발산 감지 enable. 기본 true. */
|
||||
crossDivergenceEnabled: boolean;
|
||||
/** 자기-신호 / 교차 발산 각각 표시 최대 건수. 기본 5. */
|
||||
maxPerSection: number;
|
||||
/** Chunk 미리보기 길이. 기본 220 chars. */
|
||||
excerptLength: number;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: ConflictBlockOptions = {
|
||||
selfFlagThreshold: 'medium',
|
||||
crossDivergenceEnabled: true,
|
||||
maxPerSection: 5,
|
||||
excerptLength: 220,
|
||||
};
|
||||
|
||||
function severityRank(s: ConflictSeverity | undefined): number {
|
||||
switch (s) {
|
||||
case 'HIGH': return 3;
|
||||
case 'MEDIUM': return 2;
|
||||
case 'LOW': return 1;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function thresholdRank(t: ConflictThresholdSetting): number {
|
||||
switch (t) {
|
||||
case 'high': return 3;
|
||||
case 'medium': return 2;
|
||||
case 'low': return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function severityEmoji(s: ConflictSeverity | undefined): string {
|
||||
switch (s) {
|
||||
case 'HIGH': return '🔴';
|
||||
case 'MEDIUM': return '🟡';
|
||||
case 'LOW': return '🟠';
|
||||
default: return '⚪';
|
||||
}
|
||||
}
|
||||
|
||||
function shortExcerpt(text: string, n: number): string {
|
||||
if (!text) return '';
|
||||
const cleaned = text.replace(/\s+/g, ' ').trim();
|
||||
return cleaned.length <= n ? cleaned : cleaned.slice(0, n) + '…';
|
||||
}
|
||||
|
||||
/** 두 토큰 집합의 Jaccard 유사도. */
|
||||
function jaccard(a: Set<string>, b: Set<string>): number {
|
||||
if (a.size === 0 || b.size === 0) return 0;
|
||||
let intersect = 0;
|
||||
for (const t of a) if (b.has(t)) intersect++;
|
||||
const union = a.size + b.size - intersect;
|
||||
return union === 0 ? 0 : intersect / union;
|
||||
}
|
||||
|
||||
/**
|
||||
* 교차-문서 발산 후보 쌍 찾기.
|
||||
*
|
||||
* 휴리스틱:
|
||||
* 1. 각 chunk 의 title 토큰(최대 5개) 으로 "주제 키" 생성
|
||||
* 2. 동일 주제 키 2개 이상 공유하는 chunk 쌍을 후보로
|
||||
* 3. 본문 토큰 Jaccard < 0.30 이면 발산으로 판정 (같은 주제 다른 내용)
|
||||
* 4. 점수 = (공유 토픽 토큰 수) × (1 - Jaccard) — 발산이 클수록 우선
|
||||
*
|
||||
* 한 chunk 가 여러 쌍에 등장 가능 — 상위 N 쌍만 반환.
|
||||
*/
|
||||
interface DivergencePair {
|
||||
a: RetrievalChunk;
|
||||
b: RetrievalChunk;
|
||||
sharedTopicTokens: string[];
|
||||
contentJaccard: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
function findCrossDivergence(chunks: RetrievalChunk[], topicJaccardMax: number = 0.30): DivergencePair[] {
|
||||
if (chunks.length < 2) return [];
|
||||
|
||||
// Pre-compute title topic tokens + content token sets — n^2 비교 전에 한 번만.
|
||||
const titleTokenSets: Set<string>[] = [];
|
||||
const contentTokenSets: Set<string>[] = [];
|
||||
for (const c of chunks) {
|
||||
const titleTokens = tokenize(c.title || '').filter((t) => t.length >= 2);
|
||||
titleTokenSets.push(new Set(titleTokens.slice(0, 8)));
|
||||
contentTokenSets.push(new Set(tokenize(c.content || '')));
|
||||
}
|
||||
|
||||
const pairs: DivergencePair[] = [];
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
for (let j = i + 1; j < chunks.length; j++) {
|
||||
// 1. 같은 주제 — title 토큰 공유 ≥ 2
|
||||
const shared: string[] = [];
|
||||
for (const t of titleTokenSets[i]) if (titleTokenSets[j].has(t)) shared.push(t);
|
||||
if (shared.length < 2) continue;
|
||||
|
||||
// 2. 본문 발산 — Jaccard < 임계
|
||||
const cj = jaccard(contentTokenSets[i], contentTokenSets[j]);
|
||||
if (cj >= topicJaccardMax) continue;
|
||||
|
||||
pairs.push({
|
||||
a: chunks[i],
|
||||
b: chunks[j],
|
||||
sharedTopicTokens: shared,
|
||||
contentJaccard: cj,
|
||||
score: shared.length * (1 - cj),
|
||||
});
|
||||
}
|
||||
}
|
||||
pairs.sort((p, q) => q.score - p.score);
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시스템 프롬프트용 [CONFLICT WARNINGS] 블록 생성. 충돌 없으면 빈 문자열 반환.
|
||||
*
|
||||
* 호출 측은 무조건 join 해도 안전 — 빈 문자열이면 프롬프트에 추가 줄바꿈 없음.
|
||||
*/
|
||||
export function buildConflictWarningsBlock(
|
||||
chunks: RetrievalChunk[],
|
||||
options: Partial<ConflictBlockOptions> = {},
|
||||
): string {
|
||||
const opts: ConflictBlockOptions = { ...DEFAULT_OPTIONS, ...options };
|
||||
if (!chunks || chunks.length === 0) return '';
|
||||
|
||||
// ─── Section 1: self-flag ───
|
||||
const threshold = thresholdRank(opts.selfFlagThreshold);
|
||||
const selfFlagged = chunks
|
||||
.filter((c) => severityRank(c.metadata?.conflictSeverity) >= threshold)
|
||||
.sort((a, b) => severityRank(b.metadata?.conflictSeverity) - severityRank(a.metadata?.conflictSeverity))
|
||||
.slice(0, opts.maxPerSection);
|
||||
|
||||
// ─── Section 2: cross-doc divergence ───
|
||||
const divergence = opts.crossDivergenceEnabled
|
||||
? findCrossDivergence(chunks).slice(0, opts.maxPerSection)
|
||||
: [];
|
||||
|
||||
if (selfFlagged.length === 0 && divergence.length === 0) return '';
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('[CONFLICT WARNINGS]');
|
||||
lines.push('다음 검색된 출처에서 충돌 신호 감지. 단일 결론을 강요하지 말고, 상충되는 관점을 명시하고 사용자 판단에 위임할 것.');
|
||||
lines.push('');
|
||||
|
||||
if (selfFlagged.length > 0) {
|
||||
lines.push('## 자기-신호 (출처 내부에서 충돌/논란 키워드 감지)');
|
||||
for (const c of selfFlagged) {
|
||||
const sev = c.metadata?.conflictSeverity || 'NONE';
|
||||
const emoji = severityEmoji(sev);
|
||||
const src = c.source;
|
||||
const title = c.title || '(제목 없음)';
|
||||
lines.push(`- ${emoji} **[${sev}]** \`${src}\` · ${title}`);
|
||||
lines.push(` > ${shortExcerpt(c.content, opts.excerptLength)}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (divergence.length > 0) {
|
||||
lines.push('## 교차-문서 발산 (같은 주제·다른 내용 ─ 잠재적 모순)');
|
||||
for (const p of divergence) {
|
||||
const topics = p.sharedTopicTokens.slice(0, 5).join(' · ');
|
||||
const cjPct = (p.contentJaccard * 100).toFixed(0);
|
||||
lines.push(`- 🔀 **공유 주제**: ${topics} _(본문 중복 ${cjPct}%)_`);
|
||||
lines.push(` - A: \`${p.a.source}\` · ${p.a.title || '(제목 없음)'}`);
|
||||
lines.push(` > ${shortExcerpt(p.a.content, opts.excerptLength)}`);
|
||||
lines.push(` - B: \`${p.b.source}\` · ${p.b.title || '(제목 없음)'}`);
|
||||
lines.push(` > ${shortExcerpt(p.b.content, opts.excerptLength)}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('[지침]');
|
||||
lines.push('1. 답변에 위 출처 중 하나라도 사용한다면, 충돌 가능성을 명시 (예: "출처 A 는 X 라 하나 출처 B 는 Y").');
|
||||
lines.push('2. 어느 쪽이 옳다고 단정하지 말고, 사용자가 판단할 수 있도록 근거를 분리해 제시.');
|
||||
lines.push('3. 충돌이 답변과 무관하면 무시 가능 — 다만 무관 판단 자체도 한 줄로 기록.');
|
||||
lines.push('[/CONFLICT WARNINGS]');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Chain-of-Verification (CoVe) — [VERIFICATION CHECKLIST] 시스템 프롬프트 블록 생성.
|
||||
*
|
||||
* 배경: 사용자 피드백 "추론 결과가 나오기 직전, 이 결론이 확보된 지식(제2뇌)에만
|
||||
* 근거하고 있는가? 를 스스로 질문하고 검증하는 로직" — 할루시네이션 방지 + 그라운딩
|
||||
* 명확화.
|
||||
*
|
||||
* 원논문 CoVe 는 2-pass (draft → verify → revise). 하지만 ASTRA 는 local-first 라
|
||||
* 추가 LLM 호출 비용이 크고, 같은 모델이 self-verify 하는 효과도 제한적. 그래서
|
||||
* v1 은 *instructional* CoVe — 시스템 프롬프트에 명시적 검증 체크리스트를 주입해
|
||||
* 모델이 한 패스 안에서 "답변 작성 전" 그라운딩 점검을 *내재화* 하도록.
|
||||
*
|
||||
* 향후 strict 모드에서 두 번째 verification pass 추가 가능 (config knob 준비).
|
||||
*
|
||||
* Conflict Surface 와의 관계: [CONFLICT WARNINGS] 가 "충돌 출처 데이터" 제공,
|
||||
* 이 CoVe 블록이 "그 데이터를 어떻게 verify 할지" 지시. 둘은 서로 보완.
|
||||
*/
|
||||
|
||||
import { RetrievalChunk } from './types';
|
||||
|
||||
export interface CoveBlockOptions {
|
||||
/** 체크리스트에 나열할 최상위 출처 수. 기본 5. */
|
||||
topSourcesCount: number;
|
||||
/**
|
||||
* Strict 모드 — 켜면 LLM 에게 *모든 주장에 출처 ID 를 inline 으로 인용*하라고 지시.
|
||||
* 끄면 일반 가이드만. 기본 off (자연스러운 답변 유지).
|
||||
*/
|
||||
strictMode: boolean;
|
||||
/** 출처 미리보기 길이. 기본 140 chars. */
|
||||
excerptLength: number;
|
||||
/** 사용자 query 일부를 체크리스트에 echo 할지. 기본 true — 모델이 vague answer 방지. */
|
||||
echoQuery: boolean;
|
||||
/** Query echo 최대 길이. 기본 180. */
|
||||
queryEchoMaxLength: number;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: CoveBlockOptions = {
|
||||
topSourcesCount: 5,
|
||||
strictMode: false,
|
||||
excerptLength: 140,
|
||||
echoQuery: true,
|
||||
queryEchoMaxLength: 180,
|
||||
};
|
||||
|
||||
function shortExcerpt(text: string, n: number): string {
|
||||
if (!text) return '';
|
||||
const cleaned = text.replace(/\s+/g, ' ').trim();
|
||||
return cleaned.length <= n ? cleaned : cleaned.slice(0, n) + '…';
|
||||
}
|
||||
|
||||
/**
|
||||
* CoVe 블록 생성. 검색된 chunks 가 없으면 빈 문자열 — 그라운딩할 출처가 없는 상태에서
|
||||
* CoVe 를 강요하면 모델이 "출처 없음" 으로 답변 거부할 수 있음. 단, 사용자 query 가
|
||||
* 사실 검증 류일 때만 의미가 있으므로 호출자가 enable/disable 결정 가능.
|
||||
*/
|
||||
export function buildCoveChecklistBlock(
|
||||
chunks: RetrievalChunk[],
|
||||
userPrompt: string,
|
||||
options: Partial<CoveBlockOptions> = {},
|
||||
): string {
|
||||
const opts: CoveBlockOptions = { ...DEFAULT_OPTIONS, ...options };
|
||||
if (!chunks || chunks.length === 0) return '';
|
||||
|
||||
// 점수 순 상위 N — 다양한 source 가 섞이도록 source 별로 1개씩 round-robin 도 고려했으나,
|
||||
// CoVe 는 *근거 강한 출처* 가 더 중요해서 score 단순 정렬 채택.
|
||||
const top = chunks
|
||||
.filter((c) => c.source !== 'brain-trace') // brain-trace 는 trace 표시용, 본문 없음
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, opts.topSourcesCount);
|
||||
|
||||
if (top.length === 0) return '';
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('[VERIFICATION CHECKLIST — Chain-of-Verification]');
|
||||
lines.push('답변을 *작성하기 전* 다음을 점검. 검증 통과한 주장만 답변에 포함할 것.');
|
||||
|
||||
if (opts.echoQuery && userPrompt && userPrompt.trim()) {
|
||||
const q = userPrompt.replace(/\s+/g, ' ').trim();
|
||||
const echo = q.length > opts.queryEchoMaxLength ? q.slice(0, opts.queryEchoMaxLength) + '…' : q;
|
||||
lines.push('');
|
||||
lines.push(`> **사용자 질의**: ${echo}`);
|
||||
}
|
||||
|
||||
// ─── Section 1: 근거 매핑 ───
|
||||
lines.push('');
|
||||
lines.push('## 1. 근거 매핑 (Grounding Inventory)');
|
||||
lines.push('이 답변의 핵심 주장 각각이 *어느 출처* 에서 왔는지 명시 가능한가?');
|
||||
lines.push('');
|
||||
for (let i = 0; i < top.length; i++) {
|
||||
const c = top[i];
|
||||
const scoreFmt = c.score.toFixed(2);
|
||||
const sev = c.metadata?.conflictSeverity && c.metadata.conflictSeverity !== 'NONE'
|
||||
? ` ⚠️${c.metadata.conflictSeverity}` : '';
|
||||
lines.push(`- **[S${i + 1}]** \`${c.source}\` · ${c.title || '(제목 없음)'} _(score ${scoreFmt})_${sev}`);
|
||||
lines.push(` > ${shortExcerpt(c.content, opts.excerptLength)}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('출처 미매핑(=어느 S 도 직접 지지하지 않음) 주장은 *모델 일반 지식*. 그 사실을 답변에 명시.');
|
||||
|
||||
// ─── Section 2: 자기 질문 ───
|
||||
lines.push('');
|
||||
lines.push('## 2. 답변 직전 자기 질문 (Pre-Output Self-Check)');
|
||||
lines.push('답변 보내기 전 *반드시* 답하라:');
|
||||
lines.push('- (a) 이 답변의 결론이 위 [S1..SN] 중 어디에 직접 근거하나? 매핑 안 되는 결론 = 일반 지식 → 명시.');
|
||||
lines.push('- (b) "확실하다", "반드시", "이미 결정됨" 같은 단정적 표현을 쓴다면 출처가 그 강도를 지지하는가? 아니면 톤 완화.');
|
||||
lines.push('- (c) 사용자에게 다음 *구체적 액션* 을 제시했는가, 아니면 추상적 조언만 했는가?');
|
||||
lines.push('- (d) [CONFLICT WARNINGS] 블록과 결합 — 충돌 출처 사용 시 양측 명시했는가?');
|
||||
|
||||
// ─── Section 3: Strict 모드 (옵션) ───
|
||||
if (opts.strictMode) {
|
||||
lines.push('');
|
||||
lines.push('## 3. ⚙️ STRICT 모드 — Inline Citation 강제');
|
||||
lines.push('각 사실 주장 뒤에 `[S1]`, `[S2]` 형식으로 출처 ID 를 *반드시* 인용. 인용 없으면 모델 지식으로 간주되어 답변 신뢰도 감점.');
|
||||
lines.push('예) "큐브앤코는 enterprise 요금제다 [S2]." / "일반적으로 SaaS B2B 는 ~ (모델 지식, 직접 출처 없음)."');
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('[/VERIFICATION CHECKLIST]');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Hierarchical Context Window — 질의·문서의 *추상도 레벨* 매칭으로 검색 노이즈 감소.
|
||||
*
|
||||
* 사용자 제안: "사용자가 '배포해줘' 라고 하면 L1(실행) 우선, '전략 검토' 라고 하면
|
||||
* L3(전략) 우선". 같은 키워드 매치 점수여도 추상도가 안 맞으면 noise.
|
||||
*
|
||||
* v1 — 3-level 휴리스틱 (LLM 호출 없음, 결정적):
|
||||
* - `concrete` — 코드, 로그, 디버그, 실행 명령
|
||||
* - `operational` — 작업, 일정, 운영 절차, 회의록 (기본/기본값)
|
||||
* - `strategic` — 전략, 비전, 의사결정 근거, 아키텍처 방향
|
||||
*
|
||||
* 매칭 정책:
|
||||
* - 같은 레벨 → 보너스 (× 1.15)
|
||||
* - 인접 레벨 (concrete↔operational, operational↔strategic) → 변화 없음
|
||||
* - 양 끝 mismatch (concrete↔strategic) → 페널티 (× 0.7)
|
||||
*
|
||||
* 약한 시그널 — TF-IDF dominant 유지, 동점 깨기 역할. 검색 결과를 *제외* 하지 않음.
|
||||
*/
|
||||
|
||||
import { RetrievalChunk } from './types';
|
||||
|
||||
export type AbstractionLevel = 'concrete' | 'operational' | 'strategic';
|
||||
|
||||
const QUERY_STRATEGIC_KEYWORDS = [
|
||||
'전략', '방향', '비전', '미션', '목표', '의사결정', '아키텍처', '설계 방향',
|
||||
'왜 이렇게', '왜 그렇게', '뭐가 맞', '어떤 게 좋', '어떻게 가야', '어떤 방향',
|
||||
'판단', '결정', '관점', '평가', '검토',
|
||||
'strategy', 'vision', 'mission', 'roadmap', 'direction', 'goal',
|
||||
'why', 'rationale', 'pros and cons', 'tradeoff', 'evaluate',
|
||||
];
|
||||
|
||||
const QUERY_CONCRETE_KEYWORDS = [
|
||||
'코드', '함수', '버그', '에러', '로그', '실행', '명령어', '스크립트', '디버그',
|
||||
'고쳐', '수정', '리팩토링', '리팩터', '커밋', '머지', '배포해', '돌려',
|
||||
'에러 메시지', '스택 트레이스', 'syntax', 'compile',
|
||||
'code', 'function', 'bug', 'error', 'log', 'execute', 'command', 'script',
|
||||
'debug', 'fix', 'refactor', 'commit', 'merge', 'deploy', 'run',
|
||||
];
|
||||
|
||||
const FOLDER_STRATEGIC_HINTS = ['strategy', 'vision', 'mission', 'roadmap', 'decision', 'principle', '전략', '비전'];
|
||||
const FOLDER_OPERATIONAL_HINTS = ['playbook', 'runbook', 'operation', 'process', 'sop', '운영', '절차', 'meeting', '회의'];
|
||||
const FOLDER_CONCRETE_HINTS = ['code', 'snippet', 'log', 'debug', 'fix', 'patch', '디버그', 'commit'];
|
||||
|
||||
const TITLE_STRATEGIC_HINTS = ['strategy', 'vision', 'rationale', 'direction', 'decision', 'plan', '전략', '계획', '방향', '결정', '평가'];
|
||||
const TITLE_CONCRETE_HINTS = ['fix', 'bug', 'error', 'log', 'script', 'command', '버그', '에러', '로그', '커밋'];
|
||||
|
||||
function countMatches(text: string, keywords: string[]): number {
|
||||
const lower = text.toLowerCase();
|
||||
let n = 0;
|
||||
for (const k of keywords) if (lower.includes(k.toLowerCase())) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* 질의 추상도 분류. 키워드 카운트 우열로 결정, 동률·없음이면 'operational' (기본).
|
||||
*/
|
||||
export function classifyQueryLevel(query: string): AbstractionLevel {
|
||||
if (!query) return 'operational';
|
||||
const s = countMatches(query, QUERY_STRATEGIC_KEYWORDS);
|
||||
const c = countMatches(query, QUERY_CONCRETE_KEYWORDS);
|
||||
if (s > c && s >= 1) return 'strategic';
|
||||
if (c > s && c >= 1) return 'concrete';
|
||||
return 'operational';
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 chunk 의 추상도 분류 — 폴더 경로 → 파일명/제목 → 본문 순으로 강도 감소.
|
||||
* 어느 신호도 없으면 'operational' (기본).
|
||||
*/
|
||||
export function classifyChunkLevel(chunk: RetrievalChunk): AbstractionLevel {
|
||||
// 1. 폴더 경로 (가장 강함)
|
||||
const fp = (chunk.metadata?.filePath || '').toLowerCase();
|
||||
if (fp) {
|
||||
for (const h of FOLDER_STRATEGIC_HINTS) if (fp.includes(`/${h}`) || fp.includes(`\\${h}`)) return 'strategic';
|
||||
for (const h of FOLDER_CONCRETE_HINTS) if (fp.includes(`/${h}`) || fp.includes(`\\${h}`)) return 'concrete';
|
||||
for (const h of FOLDER_OPERATIONAL_HINTS) if (fp.includes(`/${h}`) || fp.includes(`\\${h}`)) return 'operational';
|
||||
}
|
||||
|
||||
// 2. 제목
|
||||
const t = (chunk.title || '').toLowerCase();
|
||||
if (t) {
|
||||
let strat = 0, conc = 0;
|
||||
for (const h of TITLE_STRATEGIC_HINTS) if (t.includes(h.toLowerCase())) strat++;
|
||||
for (const h of TITLE_CONCRETE_HINTS) if (t.includes(h.toLowerCase())) conc++;
|
||||
if (strat > conc && strat >= 1) return 'strategic';
|
||||
if (conc > strat && conc >= 1) return 'concrete';
|
||||
}
|
||||
|
||||
return 'operational';
|
||||
}
|
||||
|
||||
const LEVEL_INDEX: Record<AbstractionLevel, number> = {
|
||||
concrete: 0, operational: 1, strategic: 2,
|
||||
};
|
||||
|
||||
export interface HierarchicalWeights {
|
||||
/** 같은 레벨 매치 multiplier. 기본 1.15. */
|
||||
sameLevelBonus: number;
|
||||
/** 양 끝 mismatch (concrete↔strategic) multiplier. 기본 0.70. */
|
||||
farMismatchPenalty: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_HIERARCHICAL_WEIGHTS: HierarchicalWeights = {
|
||||
sameLevelBonus: 1.15,
|
||||
farMismatchPenalty: 0.70,
|
||||
};
|
||||
|
||||
/**
|
||||
* 질의 레벨에 따라 chunks 의 score 를 hierarchical 매칭으로 재가중. in-place.
|
||||
* metadata 에 분류 결과 기록 (debug/UI 노출).
|
||||
*/
|
||||
export function applyHierarchicalReweight(
|
||||
chunks: RetrievalChunk[],
|
||||
queryLevel: AbstractionLevel,
|
||||
weights: HierarchicalWeights = DEFAULT_HIERARCHICAL_WEIGHTS,
|
||||
): { sameLevel: number; farMismatch: number } {
|
||||
let sameLevel = 0;
|
||||
let farMismatch = 0;
|
||||
const qi = LEVEL_INDEX[queryLevel];
|
||||
for (const c of chunks) {
|
||||
const cl = classifyChunkLevel(c);
|
||||
(c.metadata as any).abstractionLevel = cl;
|
||||
const ci = LEVEL_INDEX[cl];
|
||||
const diff = Math.abs(qi - ci);
|
||||
if (diff === 0) {
|
||||
c.score *= weights.sameLevelBonus;
|
||||
sameLevel++;
|
||||
} else if (diff === 2) {
|
||||
c.score *= weights.farMismatchPenalty;
|
||||
farMismatch++;
|
||||
}
|
||||
// diff === 1: 인접 레벨 → 변화 없음
|
||||
}
|
||||
return { sameLevel, farMismatch };
|
||||
}
|
||||
@@ -24,6 +24,8 @@ import { selectWithinBudget, assembleContext, estimateTokens } from './contextBu
|
||||
import { getBrainTokenIndex, getBrainEmbeddings } from './brainIndex';
|
||||
import { extractLessonEssence } from './lessonHelpers';
|
||||
import { cosineSimilarity } from './embeddings';
|
||||
import { applyActionabilityBoost, WorkStateSignals, ActionabilityWeights } from './actionabilityScoring';
|
||||
import { applyHierarchicalReweight, classifyQueryLevel, AbstractionLevel, HierarchicalWeights } from './hierarchicalLevel';
|
||||
|
||||
export { tokenize, expandQuery, scoreTfIdf, scoreTfIdfPreTokenized, extractBestExcerpt } from './scoring';
|
||||
export { selectWithinBudget, assembleContext, estimateTokens } from './contextBudget';
|
||||
@@ -81,6 +83,20 @@ interface RetrievalOptions {
|
||||
embeddingModel?: string;
|
||||
/** Blend weight: 0 = TF-IDF only, 1 = cosine only. Default 0.5. */
|
||||
embeddingBlendAlpha?: number;
|
||||
/**
|
||||
* Actionability — "현재 작업 상태" 신호(최근 슬래시 명령 + 열린 파일) 로 검색 결과 재가중.
|
||||
* undefined 면 actionability re-rank 안 함 (legacy 동작).
|
||||
*/
|
||||
workStateSignals?: WorkStateSignals;
|
||||
/** Actionability 결합 가중치. undefined 면 default. */
|
||||
actionabilityWeights?: ActionabilityWeights;
|
||||
/**
|
||||
* Hierarchical Context Window — 질의·문서 추상도 매칭 재가중.
|
||||
* true 면 query 추상도 분류 후 chunks 재가중. false / undefined 면 skip.
|
||||
*/
|
||||
hierarchicalReweightEnabled?: boolean;
|
||||
/** Hierarchical 가중치 override. undefined 면 default. */
|
||||
hierarchicalWeights?: HierarchicalWeights;
|
||||
}
|
||||
|
||||
export class RetrievalOrchestrator {
|
||||
@@ -148,6 +164,25 @@ export class RetrievalOrchestrator {
|
||||
this.normalizeScores(allChunks);
|
||||
fusionLog.push(`Total chunks before budget: ${allChunks.length}`);
|
||||
|
||||
// ── ③-b Actionability Re-rank — work-state 신호로 점수 boost ──
|
||||
// normalize 직후, budget 전 — actionability 가 어떤 chunk 가 살아남는지에 영향.
|
||||
if (options.workStateSignals) {
|
||||
applyActionabilityBoost(allChunks, options.workStateSignals, options.actionabilityWeights);
|
||||
const boosted = allChunks.filter((c) => (c.metadata as any).actionabilityScore > 0).length;
|
||||
const cmds = options.workStateSignals.recentSlashCommands.slice(0, 3).join(',');
|
||||
const openFile = options.workStateSignals.openFilePath ? path.basename(options.workStateSignals.openFilePath) : '-';
|
||||
fusionLog.push(`Actionability re-rank: ${boosted} chunks boosted (cmds=[${cmds}], openFile=${openFile})`);
|
||||
}
|
||||
|
||||
// ── ③-c Hierarchical Context Window — 추상도 레벨 매칭 ──
|
||||
// 질의·문서 추상도 매칭 점수 조정. 같은 레벨 bonus, 양 끝 mismatch penalty.
|
||||
// Actionability 직후 — 두 재가중을 합쳐 한 번의 budget selection.
|
||||
if (options.hierarchicalReweightEnabled) {
|
||||
const queryLevel = classifyQueryLevel(query);
|
||||
const { sameLevel, farMismatch } = applyHierarchicalReweight(allChunks, queryLevel, options.hierarchicalWeights);
|
||||
fusionLog.push(`Hierarchical re-rank (query=${queryLevel}): ${sameLevel} same-level (+), ${farMismatch} far-mismatch (-)`);
|
||||
}
|
||||
|
||||
// ── ④ Context Budget Selection ──
|
||||
const { selected, dropped, tokensUsed } = selectWithinBudget(
|
||||
allChunks,
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Intent Clarification — 모호한 질의에서 *추측 답변 대신 질문 던지기* 지시.
|
||||
*
|
||||
* 사용자 피드백: "ASTRA 는 질문을 받으면 즉시 답변을 생성하려 함. 하지만 '날카로운'
|
||||
* 파악을 위해서는 질문 뒤의 '실행 목적' 을 먼저 정의해야". 예: "배포해줘" → 환경
|
||||
* (dev/prod) / 태그 묻기. "그 부분 고쳐줘" → 어느 파일/모듈인지 묻기.
|
||||
*
|
||||
* 현재 ASTRA: 모호 감지 메커니즘 없음. CoVe(v2.2.184) 가 *답변 작성 시* 출처 매핑
|
||||
* 검증하지만, *질문 자체가 모호한 경우* 는 다루지 않음. 이 모듈이 그 갭.
|
||||
*
|
||||
* 설계:
|
||||
* - 휴리스틱 차원(환경/대상/범위/포맷/마감) 별로 *trigger 키워드 + 명시 키워드* 정의
|
||||
* - trigger 가 있는데 명시가 없으면 missing
|
||||
* - missing 차원이 strictness 임계 이상이면 ambiguous → 시스템 프롬프트에 질문 지시
|
||||
*
|
||||
* 위험: false positive → 사용자가 "그냥 답해" 짜증. strictness 로 조절.
|
||||
*/
|
||||
|
||||
export type IntentStrictness = 'low' | 'medium' | 'high';
|
||||
|
||||
interface AmbiguityDimensionDef {
|
||||
key: string;
|
||||
label: string; // 한국어 표시명
|
||||
/** 이 차원이 *문제 되는 지* 판정하는 trigger 단어들 (있으면 의심 시작). */
|
||||
triggers: string[];
|
||||
/** 차원이 *명시* 됐다고 보는 단어들 (있으면 ambiguity 해소). */
|
||||
specifiers: string[];
|
||||
/** missing 일 때 사용자에게 권장 질문 예시. */
|
||||
suggestedQuestion: string;
|
||||
}
|
||||
|
||||
const DIMENSIONS: AmbiguityDimensionDef[] = [
|
||||
{
|
||||
key: 'environment',
|
||||
label: '환경 (dev/prod/staging)',
|
||||
triggers: ['배포', '롤백', 'deploy', 'rollback', 'release', '릴리스', '릴리즈', '띄워', '재시작', 'restart'],
|
||||
specifiers: ['dev', 'prod', 'staging', 'local', '로컬', '개발', '운영', '프로덕션', '스테이징', '본번', '본 번', '본번에', '운영에'],
|
||||
suggestedQuestion: '어느 환경에 작업할지 (dev/prod/staging) 명시해 주실 수 있나요?',
|
||||
},
|
||||
{
|
||||
key: 'target',
|
||||
label: '대상 (파일/모듈/멤버)',
|
||||
triggers: ['고쳐', '고처', '수정', '바꿔', '추가', '제거', '리팩토', '리팩터', '리팩터링', '리팩토링', '개선', '정리', '리뷰', '검토해'],
|
||||
specifiers: ['.ts', '.tsx', '.js', '.py', '.md', '.json', '.go', '.rs', '파일', '함수', '클래스', '모듈', '@', 'src/', 'lib/', 'features/', '폴더'],
|
||||
suggestedQuestion: '어느 파일/모듈/함수를 대상으로 할지 명시해 주실 수 있나요?',
|
||||
},
|
||||
{
|
||||
key: 'scope',
|
||||
label: '범위 (전체/부분)',
|
||||
triggers: ['리팩토', '리팩터', '리팩터링', '리팩토링', '정리해', '개선', '최적화', '튜닝', '청소', '정비'],
|
||||
specifiers: ['전체', '전부', '모두', '일부', '특정', '하나만', '이것만', '여기만', '단', '단지'],
|
||||
suggestedQuestion: '범위가 전체인지 특정 부분인지 알려 주실 수 있나요?',
|
||||
},
|
||||
{
|
||||
key: 'format',
|
||||
label: '출력 포맷',
|
||||
triggers: ['요약', '보고서', '리포트', '정리', '문서', '카드', '발표', '슬라이드', '프레젠테이션'],
|
||||
specifiers: ['표', '리스트', 'json', 'markdown', '마크다운', '단락', 'bullet', '글머리표', '한장', '한 장', '슬라이드', 'pdf', '문장으로', '항목별', '단계별'],
|
||||
suggestedQuestion: '어떤 형식 (표/리스트/단락 등) 으로 받고 싶은지 알려 주실 수 있나요?',
|
||||
},
|
||||
{
|
||||
key: 'deadline',
|
||||
label: '마감/긴급도',
|
||||
triggers: ['언제까지', '마감', '빨리', '급함', '오늘 안에', '내일까지'],
|
||||
specifiers: ['오늘', '내일', '이번 주', '다음 주', '월', '일', '시', '분'],
|
||||
suggestedQuestion: '마감일이나 긴급도를 알려 주실 수 있나요?',
|
||||
},
|
||||
];
|
||||
|
||||
export interface AmbiguityResult {
|
||||
ambiguous: boolean;
|
||||
missingDimensions: { key: string; label: string; suggestedQuestion: string }[];
|
||||
triggerCount: number;
|
||||
promptLength: number;
|
||||
}
|
||||
|
||||
function hasAnyKeyword(text: string, keywords: string[]): boolean {
|
||||
const lower = text.toLowerCase();
|
||||
return keywords.some((k) => lower.includes(k.toLowerCase()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 모호 감지. strictness 에 따라 threshold 변동:
|
||||
* - low: 2개 이상 missing → ambiguous
|
||||
* - medium: 1개 이상 missing → ambiguous (기본)
|
||||
* - high: 1개 이상 missing OR 프롬프트 짧음 (<20 chars) → ambiguous
|
||||
*/
|
||||
export function detectAmbiguity(prompt: string, strictness: IntentStrictness = 'medium'): AmbiguityResult {
|
||||
const result: AmbiguityResult = {
|
||||
ambiguous: false,
|
||||
missingDimensions: [],
|
||||
triggerCount: 0,
|
||||
promptLength: (prompt || '').length,
|
||||
};
|
||||
if (!prompt || !prompt.trim()) return result;
|
||||
|
||||
for (const dim of DIMENSIONS) {
|
||||
const hasTrigger = hasAnyKeyword(prompt, dim.triggers);
|
||||
if (!hasTrigger) continue;
|
||||
result.triggerCount++;
|
||||
const hasSpecifier = hasAnyKeyword(prompt, dim.specifiers);
|
||||
if (!hasSpecifier) {
|
||||
result.missingDimensions.push({
|
||||
key: dim.key,
|
||||
label: dim.label,
|
||||
suggestedQuestion: dim.suggestedQuestion,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const missingCount = result.missingDimensions.length;
|
||||
if (strictness === 'low') result.ambiguous = missingCount >= 2;
|
||||
else if (strictness === 'medium') result.ambiguous = missingCount >= 1;
|
||||
else result.ambiguous = missingCount >= 1 || (result.promptLength < 20 && result.triggerCount > 0);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시스템 프롬프트용 [INTENT CLARIFICATION GUIDANCE] 블록.
|
||||
* ambiguous=false 면 빈 문자열 반환.
|
||||
*/
|
||||
export function buildIntentClarificationBlock(result: AmbiguityResult): string {
|
||||
if (!result.ambiguous || result.missingDimensions.length === 0) return '';
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('[INTENT CLARIFICATION GUIDANCE]');
|
||||
lines.push('사용자 질의에서 다음 의도 차원이 *명시되지 않음* — 추측 답변보다 *짧은 역질문* 우선:');
|
||||
lines.push('');
|
||||
for (const d of result.missingDimensions) {
|
||||
lines.push(`- **${d.label}** — 예: "${d.suggestedQuestion}"`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('[지침]');
|
||||
lines.push('1. 모호 차원이 답변의 *방향* 을 좌우하는 경우, 1~2개 핵심 질문을 *먼저* 던질 것 (전체 답변 미리 만들지 말 것).');
|
||||
lines.push('2. 사용자가 이미 "추정해도 OK", "그냥 진행", "알아서" 같은 표현을 했으면 합리적 가정 + *가정 명시* 후 진행.');
|
||||
lines.push('3. 모호 차원이 답변과 *무관* 한 정보성/탐색성 질의면 그대로 답변 OK.');
|
||||
lines.push('4. 질문 던질 때 사용자가 다시 입력하기 쉽도록 *선택지 2~3개* 또는 *기대 형식* 같이 명시.');
|
||||
lines.push('[/INTENT CLARIFICATION GUIDANCE]');
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* LLM Semantic Re-ranking — TF-IDF / 임베딩이 놓치는 *의도* 매치를 작은 LLM 호출
|
||||
* 한 번으로 잡는다.
|
||||
*
|
||||
* 동작:
|
||||
* 1. 1차 검색(TF-IDF + embedding + 부스트들) 결과의 *상위 K* (기본 15) 후보를 추출
|
||||
* 2. 가벼운 프롬프트로 LLM 에게 "이 중 query 의도에 가장 부합하는 순서로 ID 나열" 요청
|
||||
* 3. LLM 응답을 파싱해 순서 적용 — 응답 실패/누락 ID 는 원순서 유지
|
||||
*
|
||||
* 비용·위험 관리:
|
||||
* - 기본 OFF (g1nation.semanticRerankEnabled). 사용자가 latency 감수할 의지 있을 때만.
|
||||
* - 짧은 timeout (기본 8초) — 초과 시 원순서 그대로 반환, 검색 실패 안 됨.
|
||||
* - 후보 K 제한 — 토큰 비용 cap.
|
||||
* - 별도 빠른 모델 지정 가능 (`g1nation.semanticRerankModel`) — 메인 모델 외 작은 모델.
|
||||
*
|
||||
* 인터페이스: input chunks 순서는 *원본 score 내림차순* 으로 들어와야 함.
|
||||
* 반환: re-rank 가 성공하면 새 순서의 RetrievalChunk[], 실패하면 원순서.
|
||||
*/
|
||||
|
||||
import { RetrievalChunk } from './types';
|
||||
|
||||
export interface SemanticRerankOptions {
|
||||
ollamaUrl: string;
|
||||
/** Re-rank 전용 모델 ID. 비면 fallback model 사용. */
|
||||
model: string;
|
||||
/** 후보로 LLM 에 넘길 최대 chunk 개수. 기본 15. */
|
||||
candidateK: number;
|
||||
/** LLM 호출 타임아웃 (ms). 기본 8000. */
|
||||
timeoutMs: number;
|
||||
/** 각 chunk 미리보기 길이. 기본 240 chars. */
|
||||
excerptLength: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SEMANTIC_RERANK_OPTIONS: Omit<SemanticRerankOptions, 'ollamaUrl' | 'model'> = {
|
||||
candidateK: 15,
|
||||
timeoutMs: 8000,
|
||||
excerptLength: 240,
|
||||
};
|
||||
|
||||
export interface SemanticRerankResult {
|
||||
rerankedChunks: RetrievalChunk[];
|
||||
/** true 면 LLM 응답으로 순서 변경됨. false 면 원순서 (실패/타임아웃/파싱 실패). */
|
||||
success: boolean;
|
||||
durationMs: number;
|
||||
/** 디버그·footer 표시용 — re-rank 가 어떻게 동작했는지. */
|
||||
note: string;
|
||||
}
|
||||
|
||||
function shortExcerpt(text: string, n: number): string {
|
||||
if (!text) return '';
|
||||
const cleaned = text.replace(/\s+/g, ' ').trim();
|
||||
return cleaned.length <= n ? cleaned : cleaned.slice(0, n) + '…';
|
||||
}
|
||||
|
||||
function buildRerankPrompt(query: string, candidates: RetrievalChunk[], excerptLength: number): { system: string; user: string } {
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
const c = candidates[i];
|
||||
lines.push(`[C${i + 1}] (${c.source}) ${c.title || '(제목 없음)'}`);
|
||||
lines.push(` ${shortExcerpt(c.content, excerptLength)}`);
|
||||
}
|
||||
|
||||
const system = [
|
||||
'당신은 검색 결과 재정렬기 (re-ranker). 사용자 질의의 *의도* 와 각 후보 문서의 *내용 부합도* 를 평가해 가장 유용한 순서로 정렬.',
|
||||
'',
|
||||
'[규칙]',
|
||||
'1. 응답은 *반드시* 한 줄의 JSON: `{"ranking":[3,1,5,2,4,...]}` 형식.',
|
||||
'2. ranking 배열 원소 = 입력 [C1], [C2] 의 *번호* (1-based).',
|
||||
'3. 모든 입력 후보를 한 번씩만 포함. 누락·중복·번호 외 값 금지.',
|
||||
'4. 다른 설명·코드 블록·텍스트 출력 절대 금지 — JSON 한 줄만.',
|
||||
'5. 평가 기준: (a) 질의 의도와의 직접 부합도 > (b) 키워드 매치 > (c) 문맥 풍부도.',
|
||||
].join('\n');
|
||||
|
||||
const user = [
|
||||
`[사용자 질의]\n${query}`,
|
||||
'',
|
||||
`[후보 ${candidates.length}개]`,
|
||||
...lines,
|
||||
'',
|
||||
'위 후보를 가장 부합도 높은 순서로 정렬한 ranking 배열만 JSON 한 줄로 출력.',
|
||||
].join('\n');
|
||||
|
||||
return { system, user };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ollama / OpenAI 호환 endpoint 로 단발 호출. agents/factory.ts 의 BaseAgent.callLLM
|
||||
* 패턴 단순화. timeout, retry 1회만.
|
||||
*/
|
||||
async function callLlmForRerank(
|
||||
ollamaUrl: string,
|
||||
model: string,
|
||||
system: string,
|
||||
user: string,
|
||||
timeoutMs: number,
|
||||
): Promise<string> {
|
||||
const isOllama = ollamaUrl.includes(':11434') || ollamaUrl.includes('ollama');
|
||||
const endpoint = isOllama ? `${ollamaUrl}/api/chat` : `${ollamaUrl}/v1/chat/completions`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const body = isOllama
|
||||
? {
|
||||
model, stream: false,
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
options: { temperature: 0.0, num_predict: 256 },
|
||||
}
|
||||
: {
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
stream: false, temperature: 0.0, max_tokens: 256,
|
||||
};
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: any = await res.json();
|
||||
const content =
|
||||
data?.message?.content ??
|
||||
data?.choices?.[0]?.message?.content ??
|
||||
data?.choices?.[0]?.text ??
|
||||
data?.response ??
|
||||
'';
|
||||
return String(content || '');
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** LLM 응답에서 ranking 배열 추출 + 검증. 실패 시 null. */
|
||||
function parseRanking(raw: string, expectedSize: number): number[] | null {
|
||||
if (!raw) return null;
|
||||
// JSON 한 줄 추출 — { ... } 안에 ranking
|
||||
const match = raw.match(/\{[\s\S]*?\}/);
|
||||
if (!match) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(match[0]);
|
||||
const arr = parsed?.ranking;
|
||||
if (!Array.isArray(arr)) return null;
|
||||
const seen = new Set<number>();
|
||||
const out: number[] = [];
|
||||
for (const v of arr) {
|
||||
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
|
||||
if (!Number.isFinite(n) || n < 1 || n > expectedSize) continue;
|
||||
if (seen.has(n)) continue;
|
||||
seen.add(n);
|
||||
out.push(n);
|
||||
}
|
||||
// 누락 보충 — LLM 이 일부 빠뜨렸으면 원순서로 뒤에 붙임.
|
||||
for (let i = 1; i <= expectedSize; i++) {
|
||||
if (!seen.has(i)) out.push(i);
|
||||
}
|
||||
return out.length === expectedSize ? out : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function semanticRerank(
|
||||
query: string,
|
||||
chunks: RetrievalChunk[],
|
||||
options: SemanticRerankOptions,
|
||||
): Promise<SemanticRerankResult> {
|
||||
const start = Date.now();
|
||||
const k = Math.max(2, Math.min(options.candidateK, chunks.length));
|
||||
if (chunks.length < 2 || k < 2) {
|
||||
return { rerankedChunks: chunks, success: false, durationMs: 0, note: 'too few candidates' };
|
||||
}
|
||||
// 입력은 score 내림차순 가정 — 상위 K 가 re-rank 대상, 나머지는 그대로 꼬리.
|
||||
const candidates = chunks.slice(0, k);
|
||||
const tail = chunks.slice(k);
|
||||
|
||||
const { system, user } = buildRerankPrompt(query, candidates, options.excerptLength);
|
||||
|
||||
let raw = '';
|
||||
try {
|
||||
raw = await callLlmForRerank(options.ollamaUrl, options.model, system, user, options.timeoutMs);
|
||||
} catch (e: any) {
|
||||
return {
|
||||
rerankedChunks: chunks,
|
||||
success: false,
|
||||
durationMs: Date.now() - start,
|
||||
note: `LLM call failed: ${e?.name || e?.message || 'unknown'}`,
|
||||
};
|
||||
}
|
||||
|
||||
const ranking = parseRanking(raw, candidates.length);
|
||||
if (!ranking) {
|
||||
return {
|
||||
rerankedChunks: chunks,
|
||||
success: false,
|
||||
durationMs: Date.now() - start,
|
||||
note: 'unparseable LLM response',
|
||||
};
|
||||
}
|
||||
|
||||
const reranked = ranking.map((i) => candidates[i - 1]);
|
||||
return {
|
||||
rerankedChunks: [...reranked, ...tail],
|
||||
success: true,
|
||||
durationMs: Date.now() - start,
|
||||
note: `re-ranked top ${k} (changed positions: ${ranking.filter((v, i) => v !== i + 1).length})`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Terminology Dictionary — 프로젝트 표준 용어집을 시스템 프롬프트에 주입.
|
||||
*
|
||||
* 사용자 제안: "표준 표기 강제 + 답변 내 표기 일관성 검증". 예: `runway` vs `런웨이`,
|
||||
* `P-Reinforce` vs `p-reinforce`, `Chronicle` vs `크로니클`.
|
||||
*
|
||||
* 설계 — 사용자 편집 markdown 파일:
|
||||
* - 위치: `<workspace>/.astra/glossary.md`
|
||||
* - 형식: 자유 markdown. ASTRA 는 *형식을 강제하지 않고* 통째로 주입
|
||||
* - 권장 컨벤션: H2/H3 섹션으로 표준 표기 / 영-한 컨벤션 / 금지 용어 등 그룹핑
|
||||
*
|
||||
* 시스템 프롬프트 블록 `[TERMINOLOGY DICTIONARY]`:
|
||||
* - 글로서리 본문 + Term Check 지침 (#1 typo/용어 self-check 사용자 제안 통합)
|
||||
* - 답변 작성 시 표준 표기 우선 + 답변 직전 자기 점검 + 새 용어 도입 시 명시
|
||||
*
|
||||
* 캐시: 파일 mtime 기반 — 매 turn 디스크 read 안 함.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
const DEFAULT_GLOSSARY_REL_PATH = '.astra/glossary.md';
|
||||
|
||||
/** mtime-keyed cache — 사용자가 편집할 때만 다시 읽음. */
|
||||
const _cache = new Map<string, { mtime: number; content: string }>();
|
||||
|
||||
export function getGlossaryFilePath(relPath: string = DEFAULT_GLOSSARY_REL_PATH): string | null {
|
||||
const folders = vscode.workspace.workspaceFolders;
|
||||
if (!folders || folders.length === 0) return null;
|
||||
return path.join(folders[0].uri.fsPath, relPath);
|
||||
}
|
||||
|
||||
function readGlossary(relPath: string): string {
|
||||
const fp = getGlossaryFilePath(relPath);
|
||||
if (!fp) return '';
|
||||
try {
|
||||
if (!fs.existsSync(fp)) return '';
|
||||
const st = fs.statSync(fp);
|
||||
const cached = _cache.get(fp);
|
||||
if (cached && cached.mtime === st.mtimeMs) return cached.content;
|
||||
const content = fs.readFileSync(fp, 'utf-8').trim();
|
||||
_cache.set(fp, { mtime: st.mtimeMs, content });
|
||||
return content;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function clearGlossaryCache(): void {
|
||||
_cache.clear();
|
||||
}
|
||||
|
||||
export interface TerminologyBlockOptions {
|
||||
/** Glossary 파일 상대 경로. 기본 '.astra/glossary.md'. */
|
||||
relPath: string;
|
||||
/** 본문 최대 길이 (chars). 너무 큰 글로서리는 시스템 프롬프트 비대 — cap. 기본 4000. */
|
||||
maxBodyLength: number;
|
||||
/** 길이 초과 시 잘릴 안내 표시 여부. */
|
||||
showTruncationNote: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_TERMINOLOGY_OPTIONS: TerminologyBlockOptions = {
|
||||
relPath: DEFAULT_GLOSSARY_REL_PATH,
|
||||
maxBodyLength: 4000,
|
||||
showTruncationNote: true,
|
||||
};
|
||||
|
||||
export function buildTerminologyBlock(options: Partial<TerminologyBlockOptions> = {}): string {
|
||||
const opts: TerminologyBlockOptions = { ...DEFAULT_TERMINOLOGY_OPTIONS, ...options };
|
||||
const raw = readGlossary(opts.relPath);
|
||||
if (!raw) return ''; // 파일 없음 → 블록 안 만듦 (no-op)
|
||||
|
||||
let body = raw;
|
||||
let truncated = false;
|
||||
if (body.length > opts.maxBodyLength) {
|
||||
body = body.slice(0, opts.maxBodyLength);
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('[TERMINOLOGY DICTIONARY]');
|
||||
lines.push('프로젝트 표준 용어집. 답변 생성 시 다음 표기·컨벤션을 *최우선* 으로 사용.');
|
||||
lines.push('');
|
||||
lines.push('---');
|
||||
lines.push(body);
|
||||
if (truncated && opts.showTruncationNote) {
|
||||
lines.push('');
|
||||
lines.push(`_…(글로서리 ${raw.length - opts.maxBodyLength}자 잘림 — 핵심 용어를 앞쪽에 배치해 주세요)_`);
|
||||
}
|
||||
lines.push('---');
|
||||
|
||||
// Term Check 지침 — 사용자 제안 #1 (typo/용어 self-check) 통합.
|
||||
lines.push('');
|
||||
lines.push('[Term Check — 답변 직전 자기 점검]');
|
||||
lines.push('1. **표준 표기 우선**: 위 용어가 답변에 등장하면 *글로서리의 표기를 그대로* 사용. 변형·번역 임의 적용 금지.');
|
||||
lines.push('2. **표기 흔들림 방지**: 같은 용어를 한 답변 안에서 *동일 표기* 로 일관 사용 (예: "Chronicle" 과 "크로니클" 섞지 말 것).');
|
||||
lines.push('3. **새 용어 도입 시**: 글로서리에 없는 고유 명사·약어 처음 사용 시 *"새 용어: X"* 라고 한 번 명시.');
|
||||
lines.push('4. **금지 표기 검증**: 답변 직전, 글로서리의 *금지·비추* 항목이 답변에 들어가지 않았는지 검토. 들어갔으면 *재작성*.');
|
||||
lines.push('5. **모르겠으면 글로서리**: 표기 확신 없을 때 "글로서리에 없어 일반 표기 사용" 한 줄 명시 후 진행.');
|
||||
lines.push('[/TERMINOLOGY DICTIONARY]');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로서리 파일 작성 도우미 — 처음 사용자가 만들 때 권장 컨벤션 템플릿.
|
||||
* 슬래시 명령 `/glossary init` 등에서 호출.
|
||||
*/
|
||||
export const GLOSSARY_TEMPLATE = `# 프로젝트 용어집
|
||||
|
||||
ASTRA 가 답변 시 표준 표기로 사용. 사용자가 자유롭게 편집 가능.
|
||||
파일 저장 후 다음 채팅 turn 부터 자동 반영.
|
||||
|
||||
## 표준 표기
|
||||
|
||||
- **ASTRA** (X: astra, Astra 외) — 본 VS Code extension 이름
|
||||
- **P-Reinforce v3.0** (X: p-reinforce, p reinforce) — 지식 압축 규칙
|
||||
- **Chronicle ADR** (X: chronicle, ADR 단독) — 의사결정 기록
|
||||
|
||||
## 영-한 표기 컨벤션
|
||||
|
||||
- Performance → 성능
|
||||
- Bug → 버그
|
||||
- Memory → 메모리
|
||||
|
||||
## 금지·비추 표현
|
||||
|
||||
- ❌ "절대적", "반드시" (단정적 표현 — 정책 충돌 위험)
|
||||
- ❌ "에이전트가 알아서" (그라운딩 위반)
|
||||
- ❌ 한·영 깨짐 (예: "결ently", "p-rein동")
|
||||
|
||||
## 슬래시 명령 표기
|
||||
|
||||
원문 그대로 — 한국어 번역 금지:
|
||||
- /runway, /customers, /hire, /morning, /evening, /weekly, /cohort, /memory, /glossary
|
||||
`;
|
||||
Reference in New Issue
Block a user