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:
2026-05-29 16:05:30 +09:00
parent f3439ddad5
commit 990ea0ae5f
46 changed files with 7172 additions and 136 deletions
+77 -1
View File
@@ -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}`;
}
+230
View File
@@ -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
View File
@@ -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 (0100). 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)
))),
+2
View File
@@ -33,6 +33,8 @@ export {
export {
createTask,
listTasks,
TaskInput,
CreatedTask,
ListedTask,
} from './tasksApi';
+64
View File
@@ -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) };
}
}
+176
View File
@@ -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
+81
View File
@@ -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; }
}
+150
View File
@@ -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;
}
+173
View File
@@ -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,
};
}
+119
View File
@@ -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 })));
+45 -1
View File
@@ -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)
+38 -9
View File
@@ -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;
+200
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+156
View File
@@ -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);
}
}
}
+49
View File
@@ -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');
}
+204
View File
@@ -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');
}
+121
View File
@@ -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');
}
+135
View File
@@ -0,0 +1,135 @@
/**
* Hierarchical Context Window · * * .
*
* : " '배포해줘' L1() , '전략 검토'
* L3() ". noise.
*
* v1 3-level (LLM , ):
* - `concrete` , , ,
* - `operational` , , , (/)
* - `strategic` , , ,
*
* :
* - (× 1.15)
* - (concreteoperational, operationalstrategic)
* - mismatch (concretestrategic) (× 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 };
}
+35
View File
@@ -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,
+141
View File
@@ -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');
}
+213
View File
@@ -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})`,
};
}
+137
View File
@@ -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
`;