/** * 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 = { 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 = { 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 | 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 { 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})__`; }