/** * Critic Agent + Debate Loop (v1) — 제출된 업무 산출물의 LLM 검수. * * Self-Evolving OS 마스터 플랜 Phase 1 / Track 2-3. 신뢰 조건 T3 의 LLM 계층: * Requirement Coverage(결정론적, 정규식) 가 "요소가 *언급* 됐는가" 만 보면, * Critic 은 "내용이 *충실* 한가 + 결정/미결 구분이 맞는가 + 근거 없는 단정이 * 없는가" 를 본다. * * Debate Loop 원형은 작성→비판→재작성→재검토지만, 로컬 Gemma 의 latency 비용 * 때문에 v1 은 *조건부 1-pass 검수* — 결정론적 검사(커버리지 누락 또는 확신도 * <70)가 문제를 신호할 때만 Critic LLM 1회 호출, 결과를 답변 아래 보완 카드로 * 표시. 전면 다회전 debate 는 config knob(maxRounds) 만 준비해 두고 후속 증분. * * 모든 LLM 의존은 주입(critique caller) — 모듈 자체는 순수, 테스트 가능. */ import type { TaskRequirement } from './requirementGraph'; export interface CriticIssue { severity: 'major' | 'minor'; description: string; } export interface CritiqueResult { /** true = 검수 통과 (보완 불필요). */ pass: boolean; issues: CriticIssue[]; /** 누락 요소를 보완하는 추가 섹션 제안 (Critic 이 생성 가능했을 때만). */ supplement: string; /** 디버그용 원문 (파싱 실패 분석). */ raw?: string; } /** 주입형 LLM caller — agent.ts 의 callNonStreaming 또는 평가 하니스의 단순 호출. */ export type CritiqueLlmCall = (system: string, user: string, maxTokens: number) => Promise; export interface CriticOptions { /** 검수 대상 초안 최대 길이 (chars) — 초과분 잘라서 전달. 기본 12000. */ maxDraftChars: number; /** Critic 응답 max tokens. 기본 700. */ maxTokens: number; } export const DEFAULT_CRITIC_OPTIONS: CriticOptions = { maxDraftChars: 12000, maxTokens: 700, }; export function buildCritiquePrompt( userPrompt: string, draft: string, requirement: TaskRequirement | null, missingLabels: string[], opts: CriticOptions = DEFAULT_CRITIC_OPTIONS, ): { system: string; user: string } { const reqSection = requirement ? [ `업무 유형: ${requirement.label}`, '필수 요소:', ...requirement.elements.map((e) => `- ${e.label}: ${e.hint}`), ].join('\n') : '업무 유형: (미분류)'; const missingSection = missingLabels.length > 0 ? `결정론적 검사가 누락 가능성을 표시한 요소: ${missingLabels.join(', ')}` : '결정론적 검사 통과 (참고용 재확인)'; const system = [ '너는 업무 산출물 검수자(Critic)다. 동료가 작성한 초안을 비판적으로 검토한다.', '검수 기준:', '1. 필수 요소가 *내용으로* 충실한가 (단어만 등장 ≠ 충족).', '2. 결정사항과 미결(논의만 된 것)이 구분돼 있는가.', '3. 근거 없는 단정·지어낸 수치/이름/날짜가 없는가. 원문에 없는 내용 발견 시 major.', '4. 정보가 없는 항목은 "(확인 필요)" 로 솔직히 표시했는가.', '', '반드시 아래 JSON *만* 출력 (다른 텍스트 금지):', '{"pass": true|false, "issues": [{"severity": "major"|"minor", "description": "..."}], "supplement": "누락 보완 텍스트 (보완 불가능하면 빈 문자열)"}', 'supplement 는 초안에 *실제로 추가할 수 있는* 마크다운 섹션만. 원문에 없는 내용을 지어내 보완하는 것은 금지 — 그 경우 "(확인 필요)" 항목으로 작성.', ].join('\n'); const draftCapped = draft.length > opts.maxDraftChars ? draft.slice(0, opts.maxDraftChars) + '\n…(잘림)' : draft; const user = [ `[원래 요청]\n${userPrompt}`, `[검수 기준 컨텍스트]\n${reqSection}\n${missingSection}`, `[검수 대상 초안]\n${draftCapped}`, ].join('\n\n'); return { system, user }; } /** Critic LLM 응답에서 JSON 추출 — 코드펜스/잡설 섞여도 첫 균형 {} 블록을 파싱. */ export function parseCritique(raw: string): CritiqueResult | null { if (!raw || !raw.trim()) return null; const start = raw.indexOf('{'); if (start === -1) return null; // 균형 괄호 스캔 — 중첩 객체(issues 배열 내부) 안전. let depth = 0; let end = -1; let inString = false; let escaped = false; for (let i = start; i < raw.length; i++) { const ch = raw[i]; if (escaped) { escaped = false; continue; } if (ch === '\\') { escaped = true; continue; } if (ch === '"') { inString = !inString; continue; } if (inString) continue; if (ch === '{') depth++; else if (ch === '}') { depth--; if (depth === 0) { end = i; break; } } } if (end === -1) return null; try { const obj = JSON.parse(raw.slice(start, end + 1)); const issues: CriticIssue[] = Array.isArray(obj.issues) ? obj.issues .filter((i: any) => i && typeof i.description === 'string') .map((i: any) => ({ severity: i.severity === 'major' ? 'major' : 'minor', description: String(i.description).slice(0, 500), })) : []; return { pass: obj.pass === true && issues.length === 0, issues, supplement: typeof obj.supplement === 'string' ? obj.supplement.slice(0, 4000) : '', raw: raw.slice(0, 200), }; } catch { return null; } } /** * Critic 검수 1회 실행. LLM 실패/파싱 실패 시 null — 호출자(hook)는 silent skip * (검수 실패가 main turn 을 막지 않도록). */ export async function runCriticReview(params: { userPrompt: string; draft: string; requirement: TaskRequirement | null; missingLabels: string[]; callLlm: CritiqueLlmCall; options?: Partial; }): Promise { const opts: CriticOptions = { ...DEFAULT_CRITIC_OPTIONS, ...(params.options || {}) }; const { system, user } = buildCritiquePrompt(params.userPrompt, params.draft, params.requirement, params.missingLabels, opts); let raw: string; try { raw = await params.callLlm(system, user, opts.maxTokens); } catch { return null; } return parseCritique(raw); } /** 검수 결과 footer — pass 면 빈 문자열 (노이즈 방지). */ export function formatCriticFooter(critique: CritiqueResult): string { if (critique.pass) return ''; const lines: string[] = []; lines.push('\n\n> 🔁 **검수 (Critic)** — 초안에서 발견된 문제:'); for (const issue of critique.issues.slice(0, 6)) { const tag = issue.severity === 'major' ? '🔴' : '🟡'; lines.push(`> - ${tag} ${issue.description}`); } if (critique.supplement && critique.supplement.trim()) { lines.push('>'); lines.push('> **보완 제안:**'); for (const l of critique.supplement.trim().split('\n')) { lines.push(`> ${l}`); } } return lines.join('\n'); }