/** * Post-answer hook registry — 답변 완료 후 실행되는 부가 작업 모음. * * 새 hook 추가 = 1 객체 push. agent.ts 는 이 배열을 iterate 만 함. * * 현재 등록 순서 (v2.2.197): * 1. devilRebuttal — Devil Agent 반박 카드 (비활성 시 silent skip) * 2. postHocSelfCheck — 답변 검증 LLM 호출 (opt-in, 기본 OFF) * 3. termValidator — 결정론적 글로서리 forbidden 검사 (기본 ON) */ import type { PostAnswerHook, PostAnswerHookContext } from './types'; import { maybeEmitDevilRebuttal as maybeEmitDevilRebuttalFn } from '../llm/devilRebuttal'; import { postHocSelfCheck, formatSelfCheckFooter, DEFAULT_SELF_CHECK_OPTIONS } from '../postHocSelfCheck'; import { validateTermUsage, formatTermValidatorFooter } from '../termValidator'; import { getConfig } from '../../config'; const devilRebuttalHook: PostAnswerHook = { id: 'devil-rebuttal', runAsync: true, async run(ctx: PostAnswerHookContext): Promise { await maybeEmitDevilRebuttalFn( { getAbortSignal: ctx.getAbortSignal, callNonStreaming: ctx.callNonStreaming, // agent.ts 에서 vscode.Webview 를 통과시키므로 실런타임 호환. 타입 cast 로 hook 일반화. getWebview: ctx.getWebview as any, }, { userPrompt: ctx.userPrompt, assistantAnswer: ctx.assistantAnswer, baseUrl: ctx.baseUrl, modelName: ctx.modelName, contextLength: ctx.contextLength, engine: ctx.engine, }, ); }, }; const postHocSelfCheckHook: PostAnswerHook = { id: 'self-check', runAsync: true, async run(ctx: PostAnswerHookContext): Promise { const cfg = getConfig(); if (!cfg.selfCheckEnabled) return; if (!ctx.userPrompt.trim() || !ctx.assistantAnswer.trim()) return; const model = (cfg.selfCheckModel || '').trim() || cfg.defaultModel; if (!model || !cfg.ollamaUrl) return; const result = await postHocSelfCheck(ctx.userPrompt, ctx.assistantAnswer, ctx.selfCheckSources, { ollamaUrl: cfg.ollamaUrl, model, timeoutMs: (cfg.selfCheckTimeoutSec ?? 6) * 1000, excerptLength: DEFAULT_SELF_CHECK_OPTIONS.excerptLength, maxSources: DEFAULT_SELF_CHECK_OPTIONS.maxSources, }); const footer = formatSelfCheckFooter(result, model); ctx.getWebview()?.postMessage({ type: 'streamChunk', value: footer }); }, }; const termValidatorHook: PostAnswerHook = { id: 'term-validator', runAsync: false, run(ctx: PostAnswerHookContext): void { const cfg = getConfig(); if (cfg.termValidatorEnabled === false) return; if (!ctx.assistantAnswer || !ctx.assistantAnswer.trim()) return; const result = validateTermUsage(ctx.assistantAnswer, cfg.glossaryPath || '.astra/glossary.md'); if (!result.ran || result.dictionarySize === 0) return; const footer = formatTermValidatorFooter(result); if (footer) ctx.getWebview()?.postMessage({ type: 'streamChunk', value: footer }); }, }; export const POST_ANSWER_HOOKS: PostAnswerHook[] = [ devilRebuttalHook, postHocSelfCheckHook, termValidatorHook, ]; /** 모든 hook 을 안전하게 실행 — 한 hook 의 throw 가 다른 hook 막지 않음. */ export function runPostAnswerHooks(ctx: PostAnswerHookContext): void { for (const hook of POST_ANSWER_HOOKS) { try { if (hook.runAsync) { void Promise.resolve(hook.run(ctx)).catch(() => { /* swallow */ }); } else { hook.run(ctx); } } catch { /* hook never breaks the turn */ } } } export type { PostAnswerHook, PostAnswerHookContext } from './types';