import { logError } from '../../utils'; import { getConfig, BrainProfile } from '../../config'; import { stripMarkdownFormatting, looksCutOff } from '../../core/responseRecovery'; import { sanitizeAssistantContent, parseRationale, } from '../../lib/contextBuilders/outputSanitization'; import { isSecondBrainInventoryRequest, isNoBrainDataRefusal, } from '../../lib/contextBuilders/promptDetection'; import { buildSecondBrainInventoryFallbackAnswer } from '../../lib/contextBuilders/secondBrainInventory'; import { isProjectKnowledgeCreationRequest } from '../../lib/contextBuilders/localProjectIntent'; import { buildProjectKnowledgeFallbackAnswer, writeProjectKnowledgeRecord, } from '../../lib/contextBuilders/projectKnowledge'; import { enforceLocalPathReviewAnswer } from '../../lib/contextBuilders/localProjectPath'; import { isBlockingProjectKnowledgeAnswer } from '../../lib/contextBuilders/recentProjectKnowledge'; import { enforceProjectClaimPolicyInAnswer, SecondBrainTrace, } from '../../features/secondBrainTrace'; import { estimateTokens, classifyStopReason, truncationNotice, shouldShowTruncationNotice, } from '../../lib/contextManager'; export interface ProcessFinalAnswerInput { /** Raw `cleaned.visible` from extractVisibleFinal(). */ visibleAnswer: string; prompt: string | null; secondBrainTrace: SecondBrainTrace | null; localPathContext: string; activeBrain: BrainProfile; brainFiles: string[]; finishStopReason: string | undefined; maxOutputTokens: number; /** From earlier phases — used in logError noise. */ actualModel: string; engine: string; inputTokens: number; } export interface ProcessFinalAnswerResult { /** post-stripMarkdown 1차 — agent.ts 의 `executeActions(cleanedVisible, …)` 호출에 그대로 전달. */ cleanedVisible: string; /** post-enforcers, pre-final-stripMarkdown — used for executeActions and history. */ assistantContent: string; /** post-stripMarkdown-FINAL — emitted to webview. */ finalAssistantContent: string; rationale: ReturnType; outputTokens: number; stopKind: ReturnType; } export function processFinalAnswer(input: ProcessFinalAnswerInput): ProcessFinalAnswerResult { const { visibleAnswer, prompt, secondBrainTrace, localPathContext, activeBrain, brainFiles, finishStopReason, maxOutputTokens, actualModel, engine, inputTokens, } = input; // [Plain Text Output] outputFormat='plain' (기본)이면 모델이 무심코 내보낸 // 마크다운 마커(`##`, `**`, `> `, `* ` …) 를 후처리로 모두 제거. 라벨 텍스트는 유지. // markdown 모드면 legacy 그대로 통과. const cleanedVisible = getConfig().outputFormat === 'plain' ? stripMarkdownFormatting(visibleAnswer) : visibleAnswer; // 5. Execute Actions const rationale = parseRationale(cleanedVisible); let assistantContent = enforceLocalPathReviewAnswer( enforceProjectClaimPolicyInAnswer( sanitizeAssistantContent(cleanedVisible), secondBrainTrace ), localPathContext ); if (prompt && isSecondBrainInventoryRequest(prompt) && brainFiles.length > 0 && isNoBrainDataRefusal(assistantContent)) { assistantContent = buildSecondBrainInventoryFallbackAnswer(activeBrain, brainFiles, secondBrainTrace); } // Note: a previous implementation replaced LLM review answers with a // hardcoded Korean template whenever the answer didn't match enough // keywords. That made every review feel canned and project-agnostic // (the template was Datacollector-flavored). We now let the LLM's // answer stand — the system prompt for review-evaluation // (buildLocalProjectIntentGuidance / buildAstraStanceContext) is // strong enough to keep the response concrete. if (prompt && localPathContext && isProjectKnowledgeCreationRequest(prompt)) { const record = writeProjectKnowledgeRecord(localPathContext); if (isBlockingProjectKnowledgeAnswer(assistantContent)) { assistantContent = buildProjectKnowledgeFallbackAnswer(localPathContext, record); } else if (record && !assistantContent.includes(record.filePath)) { assistantContent = [ assistantContent, '', '## 생성된 기록', `프로젝트 지식 기록을 생성했습니다: \`${record.filePath}\`` ].join('\n'); } } // Surface truncated/abnormal generation so the user knows the answer is incomplete. const stopKind = classifyStopReason(finishStopReason); if (stopKind === 'output-limit' || stopKind === 'context-overflow' || stopKind === 'error') { logError('Generation stopped abnormally.', { model: actualModel, engine, stopReason: finishStopReason, stopKind, inputTokens, maxOutputTokens, answerChars: assistantContent.length, }); } const outputTokens = estimateTokens(assistantContent); // Show the "incomplete" notice when the engine said output-limit/context-overflow/error, // OR when (after all auto-continuation rounds) the answer still plainly ends mid-sentence. const notice = shouldShowTruncationNotice(stopKind, outputTokens, maxOutputTokens) ? truncationNotice(stopKind) : looksCutOff(assistantContent) ? truncationNotice('output-limit') : ''; if (notice && assistantContent.trim()) { assistantContent = assistantContent.trimEnd() + notice; } // [Plain Text Output — FINAL pass] enforcer 들이 `## 경로 확인 결과` 같은 하드코딩 헤더를 // 다시 prepend 한 후에도 마커가 남지 않도록, webview / chatHistory 에 들어가는 최종 문자열을 // 한 번 더 sanitize. cleanedVisible 단계의 1차 sanitize 는 model 출력 자체를 정리하고, // 이 2차 sanitize 는 enforcer 출력까지 모두 청소한다. const finalAssistantContent = getConfig().outputFormat === 'plain' ? stripMarkdownFormatting(assistantContent) : assistantContent; return { cleanedVisible, assistantContent, finalAssistantContent, rationale, outputTokens, stopKind, }; }