v2.2.313~319: 보고 품질·조사 신뢰성 수술 + 네이버 쇼핑인사이트/검색어트렌드 연동

- 파트별 완성, 웹 실접속 증빙, 턴 의도 브리핑, 본문 불일치 검증, 하위 질문
  체크리스트, 사이트 심층 수집 (PATCHNOTES.md v2.2.313~318 참조)
- /naver-shopping, /naver-trend: 네이버 데이터랩 API 연동. compare/discover
  자동 판별, API 배치 상한 캡슐화, 카테고리 트리 fuzzy 매칭, 자격증명은
  Astra 설정 패널 SecretStorage 로 관리
This commit is contained in:
2026-08-05 18:20:32 +09:00
parent 7d6b8b509f
commit c79eda1862
51 changed files with 37435 additions and 89 deletions
+225 -9
View File
@@ -24,10 +24,13 @@ import { buildAstraModeArchitectureContext } from './lib/contextBuilders/astraMo
import { isScheduleRequest, buildScheduleContext } from './lib/contextBuilders/scheduleContext';
import { isSelfAssessRequest, isAboutSelf, buildSelfAssessContext } from './lib/contextBuilders/selfAssessContext';
import { ensureFeatureInventory } from './extension/featureInventory';
import { buildUrlContext } from './lib/contextBuilders/urlContext';
import { buildUrlContext, isUrlContextSuccessBlock, buildWebFetchNotice, extractSuccessMeta, detectWebContentMismatch, formatWebContentMismatchFooter, type TurnWebFetchStats } from './lib/contextBuilders/urlContext';
import { extractUrls } from './features/web/webFetch';
import { looksLikeCorrection, looksLikeBehaviorComplaint, captureCorrection } from './intelligence/correctionLoop';
import { isLocalInvestigationPrompt, detectHollowInvestigation, formatHollowInvestigationFooter } from './intelligence/investigationPipeline';
import { isLocalInvestigationPrompt, detectHollowInvestigation, formatHollowInvestigationFooter, detectUnreadAnalysis, formatUnreadAnalysisFooter } from './intelligence/investigationPipeline';
import { detectTaskType } from './intelligence/requirementGraph';
import { shouldBuildTurnIntentBrief, buildTurnIntentBrief, formatTurnIntentBlock } from './intelligence/turnIntentBrief';
import { buildSubQuestionBlock, checkSubQuestionCoverage, formatSubQuestionFooter } from './intelligence/subQuestions';
import { shouldUseMultiAgentWorkflow } from './lib/contextBuilders/multiAgentRouting';
import { buildThinkingPartnerResponseContract } from './lib/contextBuilders/thinkingPartnerContract';
import { buildDroppedHistorySummary } from './lib/contextBuilders/droppedHistorySummary';
@@ -43,7 +46,7 @@ import {
isAnalysisRequest,
} from './lib/contextBuilders/promptDetection';
import { stripAstraFormattingForAgentMode, computeModeSignature } from './lib/contextBuilders/systemPromptShaping';
import { sanitizeAssistantContent, isRestartedAnswer, parseRationale } from './lib/contextBuilders/outputSanitization';
import { sanitizeAssistantContent, isRestartedAnswer, parseRationale, splitLsepReasoning, isCloserOnlyAnswer, looksLikeReportBody } from './lib/contextBuilders/outputSanitization';
import { LiveReasoningFilter } from './lib/contextBuilders/liveReasoningFilter';
import { buildEngineMessageVariants } from './lib/contextBuilders/engineMessages';
import { buildMemoryContext as buildMemoryContextFn } from './lib/contextBuilders/memoryContext';
@@ -332,6 +335,12 @@ export class AgentExecutor {
* KV 캐시도 깨졌다. actionStats 와 같은 이유로 depth 0 진입부에서만 초기화.
*/
memoryCtxCache: string | null;
/**
* [v2.2.315] 이번 turn 의 웹 실접속 결과 — URL 이 있는 요청에서 접속 성패를
* 답변에 결정론으로 표시(실패=상단 경고, 성공=하단 출처)하기 위한 추적.
* depth 0 진입부에서만 초기화 (memoryContext 의 resetTurnContext 와 무관).
*/
webFetch: TurnWebFetchStats | null;
} = {
retrieval: null,
lessons: [],
@@ -342,6 +351,7 @@ export class AgentExecutor {
actionStats: { reads: 0, lists: 0, investigates: 0 },
investigationModelOverride: null,
memoryCtxCache: null,
webFetch: null,
};
/** Per-turn state 일괄 정리. turn 시작/abort/load session 시 호출. */
@@ -572,6 +582,7 @@ export class AgentExecutor {
this._turnCtx.actionStats = { reads: 0, lists: 0, investigates: 0 };
this._turnCtx.investigationModelOverride = null;
this._turnCtx.memoryCtxCache = null;
this._turnCtx.webFetch = null;
}
// 1. Prepare Context
@@ -655,14 +666,62 @@ export class AgentExecutor {
// v2: Bridge 추출 → 직접 fetch 폴백 (urlContext 내부) + 최대 2개 URL + config 게이트.
if (prompt && loopDepth === 0 && !isCasualConversation && getConfig().webAutoFetchEnabled !== false) {
const urls = extractUrls(prompt, 2);
// [v2.2.315] 접속 성패 추적 — 답변에 결정론으로 표시(실패=상단 경고,
// 성공=하단 출처). "실접속 없이 일반론으로 사이트 분석" 실사례의 재발 방지.
if (urls.length > 0) {
this._turnCtx.webFetch = { urls: urls.slice(), succeeded: [], failed: [] };
}
for (const url of urls) {
try {
contextBlock += `\n\n${await buildUrlContext(url)}`;
logInfo('URL 컨텍스트 주입 시도.', { url });
const block = await buildUrlContext(url);
contextBlock += `\n\n${block}`;
if (isUrlContextSuccessBlock(block)) {
this._turnCtx.webFetch!.succeeded.push(url);
(this._turnCtx.webFetch!.succeededDetail ??= []).push(extractSuccessMeta(url, block));
} else {
const reason = (block.match(/상태: 접근 실패 — ([^\n]*)/)?.[1] || '본문 추출 실패').slice(0, 120);
this._turnCtx.webFetch!.failed.push({ url, reason });
}
logInfo('URL 컨텍스트 주입 시도.', { url, ok: isUrlContextSuccessBlock(block) });
} catch (e: any) {
this._turnCtx.webFetch!.failed.push({ url, reason: String(e?.message ?? e).slice(0, 120) });
logError('URL 컨텍스트 주입 실패 (계속 진행).', { error: e?.message ?? String(e) });
}
}
// [v2.2.318] 사이트 심층 수집 — 루트 URL 분석/평가 요청이면 홈 1장으로는
// "글의 퀄리티" 물음에 답할 데이터가 없다. 내부 글 링크 상위 2개를 추가
// 수집 (병렬). 실패는 조용히 홈 데이터만으로 진행.
try {
const first = urls[0];
const isRootUrl = urls.length === 1 && !!first && new URL(first).pathname.replace(/\/+$/, '') === '';
if (isRootUrl && this._turnCtx.webFetch?.succeeded.length
&& getConfig().webDeepAnalyzeEnabled !== false
&& /(분석|평가|리뷰|퀄리티|품질|어떤|어떠)/.test(prompt)) {
const { collectInternalArticleLinks } = await import('./lib/contextBuilders/urlContext');
const articleUrls = await collectInternalArticleLinks(first, 2);
if (articleUrls.length > 0) {
const blocks = await Promise.all(articleUrls.map(async (au) => {
try { return { au, block: await buildUrlContext(au) }; } catch { return null; }
}));
for (const b of blocks) {
if (!b || !isUrlContextSuccessBlock(b.block)) continue;
contextBlock += `\n\n${b.block}`;
this._turnCtx.webFetch!.succeeded.push(b.au);
(this._turnCtx.webFetch!.succeededDetail ??= []).push(extractSuccessMeta(b.au, b.block));
}
logInfo('사이트 심층 수집.', { root: first, articles: articleUrls });
}
}
} catch (e: any) {
logError('사이트 심층 수집 실패 (홈 데이터로 진행).', { error: e?.message ?? String(e) });
}
}
// [v2.2.318] 하위 질문 체크리스트 — 물음이 2개 이상 명시된 요청은 각 물음에
// 별도 답변을 강제 (LLM 없음, 규칙 기반). 커버리지는 답변 후 footer 로 검사.
if (prompt && loopDepth === 0 && !isCasualConversation) {
const subQBlock = buildSubQuestionBlock(prompt);
if (subQBlock) contextBlock += `\n\n${subQBlock}`;
}
// [Correction Loop ①] 이 발화가 직전 답변에 대한 *정정*(사실) 또는 *행동/스타일
@@ -766,6 +825,30 @@ export class AgentExecutor {
const secondBrainTraceCtx = secondBrainTrace
? `\n\n${renderSecondBrainTraceContext(secondBrainTrace)}`
: '';
// [v2.2.316] 턴 의도 브리핑 — retrieval 과 *병렬* 시작 (검색이 1~8초 걸리는
// 동안 브리핑이 함께 돌아 체감 추가 지연 최소화). 소형 모델은 "의도를
// 파악하라"는 추상 지시를 실행하지 못한다 — 파악된 의도를 텍스트로 받아야
// 따른다. 멀티에이전트 보고서 경로 전용이던 의도 브리핑의 전 턴 확장.
let intentBriefPromise: Promise<import('./intelligence/turnIntentBrief').TurnIntentBrief | null> | null = null;
if (loopDepth === 0 && config.intentBriefEnabled !== false
&& shouldBuildTurnIntentBrief(prompt, isCasualConversation)) {
const recentCtx = this.chatHistory
.filter(m => !m.internal && (m.role === 'user' || m.role === 'assistant'))
.slice(-4)
.map(m => `${m.role === 'user' ? '사용자' : '아스트라'}: ${String(m.content || '').replace(/\s+/g, ' ').slice(0, 120)}`)
.join('\n');
const briefModel = (config.intentBriefModel || '').trim() || actualModel;
intentBriefPromise = buildTurnIntentBrief({
prompt: prompt!,
recentContext: recentCtx,
llm: async (system, user, maxTokens) => {
const { coreChat } = require('./core/services') as typeof import('./core/services');
const r = await coreChat({ system, user, model: briefModel, temperature: 0.1, maxTokens, timeoutMs: 12_000 });
return r.content;
},
}).catch(() => null);
}
const retrievalStartMs = Date.now();
// [v2.2.311] continuation depth 는 depth 0 의 memoryCtx 를 재사용 — 재검색
// 3~8초 제거 + 빈 쿼리 재검색으로 청크가 갈리는 문제 제거 + 프롬프트 안정화
@@ -790,6 +873,22 @@ export class AgentExecutor {
});
this._turnCtx.memoryCtxCache = memoryCtx;
}
// [v2.2.316] 병렬 시작한 의도 브리핑 수확 — 보호 구역(dynamicBlocks)에 주입.
// 실패/타임아웃은 조용히 브리핑 없이 진행 (종전 동작).
if (intentBriefPromise) {
try {
const _brief = await intentBriefPromise;
const _blk = formatTurnIntentBlock(_brief);
if (_blk) {
this._turnCtx.dynamicBlocks.set('turn-intent-brief', _blk);
logInfo('턴 의도 브리핑 주입.', {
purpose: _brief!.purpose.slice(0, 60),
accessPath: _brief!.accessPath,
deliverable: _brief!.deliverable.slice(0, 40),
});
}
} catch { /* 브리핑 없이 진행 */ }
}
if (loopDepth === 0 && !isCasualConversation && this._turnCtx.retrieval) {
recordTelemetry({
kind: 'retrieval',
@@ -1279,10 +1378,35 @@ export class AgentExecutor {
});
}
// [v2.2.314] LSEP 구분자 뒤가 "마무리 인사"뿐인 답변 — 모델이 본문 전체를
// 추론 구간에 쓰고 가시 답변은 한 줄만 낸 실사례("이상으로 분석을 마칩니다"만
// 남고 본문 증발). sanitize 가 마커 이전을 통째로 버리기 전에 여기서 잡아
// thought-only 로 간주 → 아래 (b) final-only retry 가 본문을 재생성한다.
// 재생성까지 실패하면 마커 이전 텍스트가 보고 본문 형태일 때만 구제 승격.
let lsepTrappedBody = '';
{
const lsep = splitLsepReasoning(cleaned.visible);
if (lsep.hasMarker && isCloserOnlyAnswer(lsep.after) && lsep.before.trim().length > 800) {
lsepTrappedBody = extractVisibleFinal(lsep.before).visible;
cleaned = {
...cleaned,
visible: lsep.after.trim(),
hiddenReasoning: [cleaned.hiddenReasoning, lsep.before].filter(Boolean).join('\n\n---\n\n'),
hadHiddenReasoning: true,
wasThoughtOnly: true,
};
logInfo('LSEP 마무리-인사-만 답변 감지 — 본문이 추론 구간에 갇힘, 재생성 시도.', {
model: actualModel, trappedChars: lsepTrappedBody.length, closer: cleaned.visible.slice(0, 60),
});
}
}
// (b) Final-only retry — the reply was reasoning-only, no visible answer.
// [v2.2.314] depth 제한 제거: thought-only 라운드는 실행할 액션도 가시 본문도
// 없으므로 continuation(depth>0)에서도 재생성이 안전하고 필요하다 (분석 보고의
// 최종 라운드가 정확히 이 지점에서 실패했다).
if (shouldFinalOnlyRetry(cleaned)
&& config.finalOnlyRetryOnThoughtLeak
&& loopDepth === 0
&& !this.abortController?.signal.aborted) {
try {
this.webview.postMessage({ type: 'autoContinue', value: '답변을 정리하는 중입니다...' });
@@ -1305,6 +1429,23 @@ export class AgentExecutor {
}
}
// [v2.2.314] 재생성까지 실패(여전히 마무리 인사뿐)했고, LSEP 마커 앞에 갇힌
// 텍스트가 명백한 보고 본문 형태(헤딩/불릿/문단 다수)면 구제 승격 — 사용자가
// 화면에서 봤다가 지워진 그 본문을 되살린다. 형태가 애매하면(진짜 추론일 수
// 있음) 승격하지 않는다 — 생각 과정 노출(v2.2.310에서 수정한 문제)의 회귀 방지.
if (lsepTrappedBody
&& isCloserOnlyAnswer(cleaned.visible)
&& looksLikeReportBody(lsepTrappedBody)) {
logInfo('LSEP 갇힌 본문 구제 승격 — 재생성 실패 후 보고 형태 확인됨.', {
trappedChars: lsepTrappedBody.length,
});
cleaned = {
...cleaned,
visible: `${lsepTrappedBody.trim()}\n\n${cleaned.visible}`.trim(),
wasThoughtOnly: false,
};
}
// (c) Auto-continuation → src/agent/handlePrompt/applyAutoContinuation.ts
let continuationCount = 0;
if (config.autoContinueOnOutputLimit && config.maxAutoContinuations > 0 && loopDepth === 0) {
@@ -1363,6 +1504,50 @@ export class AgentExecutor {
}
}
// [v2.2.313] 파트별 완성 — 이어쓰기(연장)로도 출력 한계에 닿은 보고서형 답변은
// "더 좁은 주제로 나눠 질문하세요"로 사용자에게 떠넘기지 않고, 남은 섹션을
// 백엔드가 독립 호출로 나눠 생성해 한 문서로 통합한다 (사용자 제안 반영).
// 섹션 호출은 시스템+히스토리 프리픽스를 그대로 재사용 → KV 캐시로 프리필 저렴.
// 액션 태그가 남은 답변(아직 실행 단계)은 제외 — 완성은 최종 서술 답변에만.
if (getConfig().sectionedCompletionEnabled !== false
&& cleaned.visible.trim().length >= 600
&& !this.abortController?.signal.aborted
&& !/<(read_file|list_files|investigate_files|run_command|run_code|create_file|edit_file|delete_file)\b/i.test(cleaned.visible)) {
const _stopKindEarly = classifyStopReason(finishStopReason);
if (_stopKindEarly === 'output-limit' || looksCutOff(cleaned.visible)) {
const lastUserMsg = String([...this.chatHistory].reverse().find(m => m.role === 'user' && !m.internal)?.content || prompt || '');
const detectedTask = detectTaskType(lastUserMsg);
if (detectedTask || isAnalysisRequest(lastUserMsg)) {
try {
this.webview.postMessage({ type: 'autoContinue', value: '남은 섹션을 나눠 작성해 한 문서로 통합하는 중...' });
const { completeSectioned } = await import('./agent/handlePrompt/sectionedCompletion');
const done = await completeSectioned({
draft: cleaned.visible,
userPrompt: lastUserMsg,
baseMessages: messagesForRequest,
requiredElements: detectedTask?.elements.map(e => e.label),
callLLM: async (messages, maxTokens) => {
const r = await this.callNonStreaming({
baseUrl: ollamaUrl, modelName: actualModel, engine, messages,
temperature: 0.2, maxTokens, contextLength: ctxLimits.contextLength,
signal: this.abortController?.signal,
});
const rc = extractVisibleFinal(r.text);
return rc.visible || r.text;
},
onProgress: (m) => this.webview?.postMessage({ type: 'autoContinue', value: m }),
});
if (done.sectionsAdded > 0) {
cleaned = { ...cleaned, visible: done.merged };
finishStopReason = 'eosFound'; // 통합 완성 — 잘림 안내 억제
}
} catch (e: any) {
logError('파트별 완성 실패 — 초안 유지 (종전 안내로 폴백).', { error: e?.message ?? String(e) });
}
}
}
}
// 답변 sanitize / policy enforcement → src/agent/handlePrompt/processFinalAnswer.ts
const _finalProc = processFinalAnswer({
visibleAnswer: cleaned.visible,
@@ -1376,6 +1561,7 @@ export class AgentExecutor {
actualModel,
engine,
inputTokens,
recordGuardActive: !!options.designerContext,
});
const cleanedVisible = _finalProc.cleanedVisible;
const assistantContent = _finalProc.assistantContent;
@@ -1563,7 +1749,29 @@ export class AgentExecutor {
// append via streamChunk because the bubble has multiple action
// segments and we don't have a single "final" to replace with.
if (loopDepth === 0) {
this.webview.postMessage({ type: 'streamReplace', value: finalAssistantContent });
// [v2.2.315] 웹 실접속 표시 — URL 요청이면 실패는 상단 경고, 성공은 하단 출처.
// [v2.2.317] + 본문 불일치 검증 — 접속 성공인데 실데이터 미반영 일반론이면 경고.
const _webNotice = buildWebFetchNotice(this._turnCtx.webFetch);
const _mismatch = detectWebContentMismatch(finalAssistantContent, this._turnCtx.webFetch);
const _mismatchFooter = _mismatch.mismatch ? formatWebContentMismatchFooter() : '';
if (_mismatch.mismatch) logInfo('웹 본문 불일치 감지.', { hits: _mismatch.hits, checked: _mismatch.checkedTokens });
// [v2.2.318] 하위 질문 커버리지 — 명시된 물음이 답변에서 빠졌으면 목록 표시.
const _subCov = checkSubQuestionCoverage(prompt, finalAssistantContent);
const _subFooter = formatSubQuestionFooter(_subCov);
if (_subFooter) logInfo('하위 질문 누락 감지.', { total: _subCov.total, missing: _subCov.missing.length });
this.webview.postMessage({ type: 'streamReplace', value: `${_webNotice.top}${finalAssistantContent}${_webNotice.bottom}${_mismatchFooter}${_subFooter}` });
// [v2.2.313] 파일 미확인 분석 경고 — 로컬 경로 분석 요청인데 이 턴에
// 액션을 한 번도 안 쓰고 답한 경우 (hollow/ungrounded 가 못 잡는 사각지대).
// [v2.2.315] URL 요청은 제외 — 웹 분석은 파일 액션이 필요 없고, 웹 쪽은
// 위의 실접속 표시가 담당한다 (koritips.com 오탐 실사례).
try {
if (prompt && isAnalysisRequest(prompt)
&& !(this._turnCtx.webFetch && this._turnCtx.webFetch.urls.length > 0)
&& detectUnreadAnalysis(finalAssistantContent, this._turnCtx.actionStats, !!localPathContext)) {
this.webview.postMessage({ type: 'streamChunk', value: formatUnreadAnalysisFooter() });
logInfo('Unread Analysis 감지 (depth 0, 액션 0회).', { stats: this._turnCtx.actionStats });
}
} catch { /* 감지 실패가 답변을 막지 않음 */ }
recordTelemetry({
kind: 'turn',
durationMs: Date.now() - turnStartMs,
@@ -1574,7 +1782,10 @@ export class AgentExecutor {
stopReason: finishStopReason,
brainFiles: this._turnCtx.retrieval?.usedBrainFiles.length ?? 0,
memoryLayers: this._turnCtx.retrieval?.usedMemoryLayers ?? [],
note: `continuations=${continuationCount} historyDropped=${reqMessages.length - budgetedHistory.length}`,
note: `continuations=${continuationCount} historyDropped=${reqMessages.length - budgetedHistory.length}`
+ (this._turnCtx.webFetch
? ` webFetch=${this._turnCtx.webFetch.succeeded.length}/${this._turnCtx.webFetch.urls.length}`
: ''),
});
// ── Post-answer hooks (v2.2.197) — Devil + SelfCheck + TermValidator 통합 레지스트리. ──
// 새 hook 추가 = `src/agent/postAnswerHooks/index.ts` 에 한 객체 push.
@@ -1597,7 +1808,12 @@ export class AgentExecutor {
},
});
} else {
this.webview.postMessage({ type: 'streamChunk', value: finalAssistantContent });
// [v2.2.315] 웹 실접속 표시 — 액션 턴의 최종 라운드에도 동일 적용.
const _webNotice2 = buildWebFetchNotice(this._turnCtx.webFetch);
const _mismatch2 = detectWebContentMismatch(finalAssistantContent, this._turnCtx.webFetch);
const _lastUser2 = String([...this.chatHistory].reverse().find(m => m.role === 'user' && !m.internal)?.content || '');
const _subFooter2 = formatSubQuestionFooter(checkSubQuestionCoverage(_lastUser2, finalAssistantContent));
this.webview.postMessage({ type: 'streamChunk', value: `${_webNotice2.top}${finalAssistantContent}${_webNotice2.bottom}${_mismatch2.mismatch ? formatWebContentMismatchFooter() : ''}${_subFooter2}` });
// [v2.2.309] Hollow Investigation — 액션 turn 의 최종 답변(continuation)은
// post-answer hooks(depth 0 전용)를 타지 않으므로 여기서 직접 검사한다.
// "list_files 만 하고 read/investigate 없이 파일 여러 개를 서술" = 헛조사.