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
+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)
* - 인접 레벨 (concrete↔operational, operational↔strategic) → 변화 없음
* - 양 끝 mismatch (concrete↔strategic) → 페널티 (× 0.7)
*
* 약한 시그널 — TF-IDF dominant 유지, 동점 깨기 역할. 검색 결과를 *제외* 하지 않음.
*/
import { RetrievalChunk } from './types';
export type AbstractionLevel = 'concrete' | 'operational' | 'strategic';
const QUERY_STRATEGIC_KEYWORDS = [
'전략', '방향', '비전', '미션', '목표', '의사결정', '아키텍처', '설계 방향',
'왜 이렇게', '왜 그렇게', '뭐가 맞', '어떤 게 좋', '어떻게 가야', '어떤 방향',
'판단', '결정', '관점', '평가', '검토',
'strategy', 'vision', 'mission', 'roadmap', 'direction', 'goal',
'why', 'rationale', 'pros and cons', 'tradeoff', 'evaluate',
];
const QUERY_CONCRETE_KEYWORDS = [
'코드', '함수', '버그', '에러', '로그', '실행', '명령어', '스크립트', '디버그',
'고쳐', '수정', '리팩토링', '리팩터', '커밋', '머지', '배포해', '돌려',
'에러 메시지', '스택 트레이스', 'syntax', 'compile',
'code', 'function', 'bug', 'error', 'log', 'execute', 'command', 'script',
'debug', 'fix', 'refactor', 'commit', 'merge', 'deploy', 'run',
];
const FOLDER_STRATEGIC_HINTS = ['strategy', 'vision', 'mission', 'roadmap', 'decision', 'principle', '전략', '비전'];
const FOLDER_OPERATIONAL_HINTS = ['playbook', 'runbook', 'operation', 'process', 'sop', '운영', '절차', 'meeting', '회의'];
const FOLDER_CONCRETE_HINTS = ['code', 'snippet', 'log', 'debug', 'fix', 'patch', '디버그', 'commit'];
const TITLE_STRATEGIC_HINTS = ['strategy', 'vision', 'rationale', 'direction', 'decision', 'plan', '전략', '계획', '방향', '결정', '평가'];
const TITLE_CONCRETE_HINTS = ['fix', 'bug', 'error', 'log', 'script', 'command', '버그', '에러', '로그', '커밋'];
function countMatches(text: string, keywords: string[]): number {
const lower = text.toLowerCase();
let n = 0;
for (const k of keywords) if (lower.includes(k.toLowerCase())) n++;
return n;
}
/**
* 질의 추상도 분류. 키워드 카운트 우열로 결정, 동률·없음이면 'operational' (기본).
*/
export function classifyQueryLevel(query: string): AbstractionLevel {
if (!query) return 'operational';
const s = countMatches(query, QUERY_STRATEGIC_KEYWORDS);
const c = countMatches(query, QUERY_CONCRETE_KEYWORDS);
if (s > c && s >= 1) return 'strategic';
if (c > s && c >= 1) return 'concrete';
return 'operational';
}
/**
* 한 chunk 의 추상도 분류 — 폴더 경로 → 파일명/제목 → 본문 순으로 강도 감소.
* 어느 신호도 없으면 'operational' (기본).
*/
export function classifyChunkLevel(chunk: RetrievalChunk): AbstractionLevel {
// 1. 폴더 경로 (가장 강함)
const fp = (chunk.metadata?.filePath || '').toLowerCase();
if (fp) {
for (const h of FOLDER_STRATEGIC_HINTS) if (fp.includes(`/${h}`) || fp.includes(`\\${h}`)) return 'strategic';
for (const h of FOLDER_CONCRETE_HINTS) if (fp.includes(`/${h}`) || fp.includes(`\\${h}`)) return 'concrete';
for (const h of FOLDER_OPERATIONAL_HINTS) if (fp.includes(`/${h}`) || fp.includes(`\\${h}`)) return 'operational';
}
// 2. 제목
const t = (chunk.title || '').toLowerCase();
if (t) {
let strat = 0, conc = 0;
for (const h of TITLE_STRATEGIC_HINTS) if (t.includes(h.toLowerCase())) strat++;
for (const h of TITLE_CONCRETE_HINTS) if (t.includes(h.toLowerCase())) conc++;
if (strat > conc && strat >= 1) return 'strategic';
if (conc > strat && conc >= 1) return 'concrete';
}
return 'operational';
}
const LEVEL_INDEX: Record<AbstractionLevel, number> = {
concrete: 0, operational: 1, strategic: 2,
};
export interface HierarchicalWeights {
/** 같은 레벨 매치 multiplier. 기본 1.15. */
sameLevelBonus: number;
/** 양 끝 mismatch (concrete↔strategic) multiplier. 기본 0.70. */
farMismatchPenalty: number;
}
export const DEFAULT_HIERARCHICAL_WEIGHTS: HierarchicalWeights = {
sameLevelBonus: 1.15,
farMismatchPenalty: 0.70,
};
/**
* 질의 레벨에 따라 chunks 의 score 를 hierarchical 매칭으로 재가중. in-place.
* metadata 에 분류 결과 기록 (debug/UI 노출).
*/
export function applyHierarchicalReweight(
chunks: RetrievalChunk[],
queryLevel: AbstractionLevel,
weights: HierarchicalWeights = DEFAULT_HIERARCHICAL_WEIGHTS,
): { sameLevel: number; farMismatch: number } {
let sameLevel = 0;
let farMismatch = 0;
const qi = LEVEL_INDEX[queryLevel];
for (const c of chunks) {
const cl = classifyChunkLevel(c);
(c.metadata as any).abstractionLevel = cl;
const ci = LEVEL_INDEX[cl];
const diff = Math.abs(qi - ci);
if (diff === 0) {
c.score *= weights.sameLevelBonus;
sameLevel++;
} else if (diff === 2) {
c.score *= weights.farMismatchPenalty;
farMismatch++;
}
// diff === 1: 인접 레벨 → 변화 없음
}
return { sameLevel, farMismatch };
}
+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
`;