/** * Self-Reflector Phase B — *분리된 콘텍스트*에서 LLM 한 번 더 호출해 응답을 * 외부 시각으로 검증. * * Phase A의 self-check는 같은 모델·같은 콘텍스트에서 자기 자신을 보는 한계가 * 있다. 모델이 자기가 만든 답변을 자신 있게 잘못 평가하는 *과신 편향*은 * LLM의 잘 알려진 약점이다. Phase B는 이걸 보완하기 위해: * * 1. specialist 응답이 끝나면 * 2. *새로운* system prompt로 LLM에게 (task + 응답)을 보여주고 * 3. "이 응답이 task를 충실히 처리했나? 명백한 오류가 있나?"를 묻는다 * 4. {verdict: pass|warn|fail, issues: [...]} JSON으로 받음 * 5. fail이면 issue들을 prepend해 같은 specialist 1회 retry * * 호출자(dispatcher)는 verdict + issues + final response를 받아 그대로 chat에 * 표시한다. 검증 LLM 자체가 실패해도 *원본 응답은 보존* — 검증 layer가 * 진행 자체를 막지 않는다. */ import { IAIService } from '../../core/services'; import { logError, logInfo } from '../../utils'; export interface VerifyInput { /** specialist에게 줬던 task 문자열 (revisionNote 등 prefix 포함). */ task: string; /** specialist의 raw 응답 (action-tag 실행 *전*). */ response: string; /** specialist가 누구였는지 (검증 프롬프트 컨텍스트). */ agentName: string; /** 검증에 사용할 모델. 비싸지 않아도 OK — 검증은 짧고 가볍게. */ model?: string; /** Requirement Contract — 있으면 검증 기준으로 직접 활용. */ contractBlock?: string; } export type VerifyVerdict = 'pass' | 'warn' | 'fail'; export interface VerifyResult { verdict: VerifyVerdict; /** 발견된 이슈 목록 (verdict='warn'·'fail' 시 1~3개). */ issues: string[]; /** 한 줄 요약 — chat label용. */ summary: string; /** 검증 LLM이 실패한 경우 true; 호출자는 원본 응답 보존하고 진행. */ verifierError?: boolean; } const SYSTEM_PROMPT = `당신은 *외부 감리* 입니다. 다른 에이전트가 방금 사용자 task에 대해 만든 응답을 객관적으로 점검합니다. 응답을 만든 본인이 아니므로 *과신 없이* 보세요. 점검 기준: 1. task 요구를 *직접* 처리했는가 (회피·동문서답 X) 2. 명백한 사실 오류·논리 모순이 없는가 3. 코드/파일이 포함됐다면 정의되지 않은 변수·잘못된 경로·존재하지 않는 import가 없는가 4. Requirement Contract가 있으면 criteria를 위반하지 않는가 🛑 **빈 깡통(Hollow Code) 자동 fail**: 코드 파일이 포함됐는데 *실제 로직이 비어 있으면* 무조건 "fail": - 함수 본문이 \`pass\` / \`return None\` / \`return null\` / \`...\` 한 줄뿐 - 함수 본문이 TODO/FIXME 주석뿐 - 클래스/모듈에 import만 있고 로직 0줄 - "여기에 X를 구현하세요" 같은 placeholder만 들어 있음 이런 패턴은 *문법은 통과해도 사용자가 원한 결과물이 아닙니다*. issues에 "빈 깡통: <함수명> 본문이 stub뿐" 처럼 *구체적인 위치*를 적으세요. 평가 라벨: - "pass" : 위 모든 기준 통과 - "warn" : 일부 약점이 있지만 사용자가 받아 볼 만함 (issues에 적기) - "fail" : 핵심 오류·누락 또는 *빈 깡통* 발견 — 재작업 필요 (issues에 적기) ⚠️ 반드시 아래 JSON 한 번만. 다른 텍스트(설명·코드펜스) 일체 금지. {"verdict":"pass"|"warn"|"fail","issues":["<이슈1>","<이슈2>"],"summary":"한 줄(30자 이내)"}`; function _buildUserMessage(input: VerifyInput): string { const lines: string[] = []; if (input.contractBlock) { lines.push(input.contractBlock); lines.push(''); } lines.push(`[검증 대상 에이전트] ${input.agentName}`); lines.push(''); lines.push('[task]'); lines.push(input.task.slice(0, 2000)); lines.push(''); lines.push('[해당 에이전트의 응답]'); lines.push((input.response || '').slice(0, 4000)); lines.push(''); lines.push('점검 JSON만 출력:'); return lines.join('\n'); } function _parseVerdictJson(raw: string): { verdict: VerifyVerdict; issues: string[]; summary: string } | null { if (!raw || !raw.trim()) return null; const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i); const stage1 = (fenced ? fenced[1] : raw).trim(); const tryParse = (s: string) => { try { const obj = JSON.parse(s) as Record; const v = typeof obj.verdict === 'string' ? obj.verdict.toLowerCase().trim() : ''; if (v !== 'pass' && v !== 'warn' && v !== 'fail') return null; const issues = Array.isArray(obj.issues) ? obj.issues.filter((x): x is string => typeof x === 'string' && x.trim().length > 0) .map((x) => x.trim()).slice(0, 5) : []; const summary = typeof obj.summary === 'string' ? obj.summary.trim() : ''; return { verdict: v as VerifyVerdict, issues, summary }; } catch { return null; } }; const direct = tryParse(stage1); if (direct) return direct; const m = stage1.match(/\{[\s\S]*\}/); if (m) { const balanced = tryParse(m[0]); if (balanced) return balanced; } // 최후의 fallback: 정규식으로 verdict만이라도. const verdictMatch = stage1.match(/"verdict"\s*:\s*"(pass|warn|fail)"/i); if (verdictMatch) { return { verdict: verdictMatch[1].toLowerCase() as VerifyVerdict, issues: [], summary: '', }; } return null; } /** * 검증 한 회차. 호출 실패 / JSON 파싱 실패는 *통과로 간주* (verifierError=true * 표시). 검증 layer가 작업 진행을 막지 않게 — 본래 의도가 안전망이지 통제관문이 * 아니므로. */ export async function verifyResponse( ai: IAIService, input: VerifyInput, ): Promise { let raw = ''; try { const result = await ai.chat({ system: SYSTEM_PROMPT, user: _buildUserMessage(input), model: input.model, }); raw = result.content || ''; } catch (e: any) { logError('selfReflectorVerifier: call failed; treating as pass.', { error: e?.message ?? String(e) }); return { verdict: 'pass', issues: [], summary: '검증 실패 — 원본 유지', verifierError: true }; } const parsed = _parseVerdictJson(raw); if (!parsed) { logInfo('selfReflectorVerifier: parse failed; treating as pass.', { rawHead: raw.slice(0, 100) }); return { verdict: 'pass', issues: [], summary: '검증 응답 파싱 실패 — 원본 유지', verifierError: true }; } return { verdict: parsed.verdict, issues: parsed.issues, summary: parsed.summary || `verdict: ${parsed.verdict}`, }; } /** * 검증 결과를 retry 시 사용할 prompt prefix로 직렬화. 호출자가 task 앞에 * prepend 해 specialist를 1회 더 호출한다. */ export function formatIssuesForRetry(issues: string[]): string { if (!issues.length) return ''; const lines: string[] = []; lines.push('[외부 감리 지적 — 반드시 반영]'); for (const i of issues) lines.push(`- ${i}`); return lines.join('\n'); }