990ea0ae5f
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>
157 lines
6.4 KiB
TypeScript
157 lines
6.4 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|
||
}
|
||
}
|