/** * 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 = { '.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); } } }