Files
connectai/src/agent/handlePrompt/processFinalAnswer.ts
T
koriweb c79eda1862 v2.2.313~319: 보고 품질·조사 신뢰성 수술 + 네이버 쇼핑인사이트/검색어트렌드 연동
- 파트별 완성, 웹 실접속 증빙, 턴 의도 브리핑, 본문 불일치 검증, 하위 질문
  체크리스트, 사이트 심층 수집 (PATCHNOTES.md v2.2.313~318 참조)
- /naver-shopping, /naver-trend: 네이버 데이터랩 API 연동. compare/discover
  자동 판별, API 배치 상한 캡슐화, 카테고리 트리 fuzzy 매칭, 자격증명은
  Astra 설정 패널 SecretStorage 로 관리
2026-08-05 18:20:32 +09:00

159 lines
6.9 KiB
TypeScript

import { logError } from '../../utils';
import { getConfig, BrainProfile } from '../../config';
import { stripMarkdownFormatting, looksCutOff } from '../../core/responseRecovery';
import {
sanitizeAssistantContent,
parseRationale,
stripLeakedInternalSections,
} 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;
/**
* [v2.2.314] Project Record Guard(designerContext) 활성 턴 여부 — 활성이면 가드가
* 요구하는 "요청 요약" 류 섹션을 보존하고, 아니면 유출 스캐폴딩으로 보고 제거한다.
*/
recordGuardActive?: boolean;
}
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<typeof parseRationale>;
outputTokens: number;
stopKind: ReturnType<typeof classifyStopReason>;
}
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
);
// [v2.2.314] 내부 스캐폴딩 섹션 유출 제거 — Record Guard 턴이 아니면 "요청 요약/
// 사용자 의도 추론/핵심 확인 질문" 류는 규칙 4 위반 유출이므로 결정론으로 걷어낸다.
if (!input.recordGuardActive) {
assistantContent = stripLeakedInternalSections(assistantContent);
}
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,
};
}