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 없이 파일 여러 개를 서술" = 헛조사.
@@ -4,6 +4,7 @@ import { stripMarkdownFormatting, looksCutOff } from '../../core/responseRecover
import {
sanitizeAssistantContent,
parseRationale,
stripLeakedInternalSections,
} from '../../lib/contextBuilders/outputSanitization';
import {
isSecondBrainInventoryRequest,
@@ -42,6 +43,11 @@ export interface ProcessFinalAnswerInput {
actualModel: string;
engine: string;
inputTokens: number;
/**
* [v2.2.314] Project Record Guard(designerContext) 활성 턴 여부 — 활성이면 가드가
* 요구하는 "요청 요약" 류 섹션을 보존하고, 아니면 유출 스캐폴딩으로 보고 제거한다.
*/
recordGuardActive?: boolean;
}
export interface ProcessFinalAnswerResult {
@@ -87,6 +93,11 @@ export function processFinalAnswer(input: ProcessFinalAnswerInput): ProcessFinal
),
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);
}
@@ -0,0 +1,116 @@
/**
* [v2.2.313] 파트별 완성 (Sectioned Completion) — 사용자 제안 구현.
*
* 문제: 보고서형 답변이 출력 토큰 한계에 닿으면 자동 이어쓰기(같은 호출의 연장)를
* 최대 N회 시도하고, 그래도 못 채우면 "더 좁은 주제로 나눠 질문하세요" 안내로 끝났다.
* 나눠서 다시 묻는 부담을 사용자에게 떠넘긴 것.
*
* 해결: 한계에 닿은 초안을 받으면 백엔드가
* 1) 남은 섹션 제목을 추출하고 (outline 호출 1회, 이미 쓴 섹션 제외)
* 2) 섹션마다 *독립 호출*로 본문을 생성한 뒤 (호출당 출력 예산이 온전히 확보됨)
* 3) 결정적으로 이어 붙여 한 문서로 통합한다.
* KV 캐시 분리(v2.2.311) 덕에 각 섹션 호출의 프리픽스(시스템+히스토리)는 캐시
* 재사용이라 추가 프리필 비용이 작다. 어느 단계든 실패하면 원본 초안 그대로 반환
* (종전 동작 폴백 — 조용한 폴백 금지 원칙에 따라 로그는 남긴다).
*/
import { logInfo, logWarn } from '../../utils';
import type { ChatMessage } from '../../agent';
export interface SectionedCompletionParams {
/** 길이 한계에 닿은 현재 초안 (가시 본문). */
draft: string;
/** 사용자의 원 요청 (continuation 프롬프트가 아니라 마지막 실제 user 메시지). */
userPrompt: string;
/** 이번 턴 요청에 실제 사용된 메시지 배열 — 시스템/컨텍스트/히스토리 재사용. */
baseMessages: ChatMessage[];
/** 업무 유형 필수 요소 라벨 (requirementGraph) — outline 이 우선 포함해야 할 섹션. */
requiredElements?: string[];
/** 비스트리밍 LLM 호출자 — agent.ts 의 callNonStreaming 클로저. */
callLLM: (messages: ChatMessage[], maxTokens: number) => Promise<string>;
maxSections?: number;
/** 섹션당 출력 토큰 예산. */
perSectionTokens?: number;
onProgress?: (message: string) => void;
}
export interface SectionedCompletionResult {
merged: string;
sectionsAdded: number;
}
/** outline 응답에서 섹션 제목 목록 파싱 — 불릿/번호/헤딩 마커 제거, 길이 필터. */
export function parseOutlineTitles(raw: string, max: number): string[] {
return (raw || '')
.split('\n')
.map((l) => l.replace(/^[\s>*#\-•\d.)\]]+/, '').trim())
.filter((l) => l.length >= 2 && l.length <= 60)
.filter((l) => !/^(없음|완료|끝|done|none)$/i.test(l))
.slice(0, max);
}
/** 초안에 이미 존재하는 섹션과 outline 제목의 중복 제거 (느슨한 포함 매치). */
export function dropAlreadyWritten(titles: string[], draft: string): string[] {
const d = (draft || '').replace(/\s+/g, '');
return titles.filter((t) => {
const key = t.replace(/\s+/g, '');
return key.length > 0 && !d.includes(key);
});
}
export async function completeSectioned(params: SectionedCompletionParams): Promise<SectionedCompletionResult> {
const maxSections = Math.max(1, Math.min(6, params.maxSections ?? 4));
const perSectionTokens = Math.max(400, params.perSectionTokens ?? 1200);
const draftTail = params.draft.length > 3000 ? `…${params.draft.slice(-3000)}` : params.draft;
// 1) 남은 섹션 outline.
params.onProgress?.('남은 섹션을 파악하는 중...');
const requiredBlock = params.requiredElements?.length
? `\n[우선 포함해야 할 요소]\n${params.requiredElements.map((e) => `- ${e}`).join('\n')}`
: '';
const outlineRaw = await params.callLLM([
{
role: 'system',
content: '너는 편집자다. 길이 한계로 잘린 초안을 완성하기 위해, 아직 작성되지 않은 남은 섹션 제목만 한 줄에 하나씩 출력한다. 설명·번호·인사 금지. 이미 초안에 있는 섹션은 제외. 남은 것이 없으면 "없음" 한 단어만 출력.',
},
{
role: 'user',
content: `[사용자 요청]\n${params.userPrompt}\n${requiredBlock}\n\n[잘린 초안 (끝부분)]\n${draftTail}\n\n남은 섹션 제목 (최대 ${maxSections}개):`,
},
], 200);
const titles = dropAlreadyWritten(parseOutlineTitles(outlineRaw, maxSections), params.draft);
if (titles.length === 0) {
logInfo('[SectionedCompletion] 남은 섹션 없음 — 초안 유지.', { outlineRaw: (outlineRaw || '').slice(0, 80) });
return { merged: params.draft, sectionsAdded: 0 };
}
// 2) 섹션별 독립 생성 — 시스템/컨텍스트/히스토리를 그대로 재사용해 근거 접근 유지.
const pieces: string[] = [];
for (let i = 0; i < titles.length; i++) {
const title = titles[i];
params.onProgress?.(`섹션 작성 중 (${i + 1}/${titles.length}): ${title}`);
try {
const body = (await params.callLLM([
...params.baseMessages,
{ role: 'assistant', content: draftTail },
{
role: 'user',
content: `위 답변이 길이 한계로 잘렸다. 이어질 "${title}" 섹션 *하나만* 작성하라.\n- "## ${title}" 헤딩으로 시작.\n- 다른 섹션·서론·맺음말·인사 금지. 이미 쓴 내용 반복 금지.\n- 컨텍스트에 있는 실제 근거(파일명·수치·문서)를 인용. 없으면 "확인 불가"로 명시.\n- 500~900자.`,
},
], perSectionTokens)).trim();
if (!body) continue;
pieces.push(body.startsWith('#') ? body : `## ${title}\n${body}`);
} catch (e: any) {
logWarn('[SectionedCompletion] 섹션 생성 실패 — 건너뜀.', { title, error: e?.message ?? String(e) });
}
}
if (pieces.length === 0) {
logWarn('[SectionedCompletion] 생성된 섹션 없음 — 초안 유지.');
return { merged: params.draft, sectionsAdded: 0 };
}
// 3) 결정적 통합 — 한 문서로.
const merged = `${params.draft.trimEnd()}\n\n${pieces.join('\n\n')}`;
logInfo('[SectionedCompletion] 파트별 완성.', { sections: pieces.length, titles, addedChars: merged.length - params.draft.length });
return { merged, sectionsAdded: pieces.length };
}
+18
View File
@@ -246,6 +246,20 @@ export interface IAgentConfig {
* 검색 결과에 써서 로컬 프리필만 수십 초를 태웠다 — 상한을 낮춰 답변 지연을 줄인다.
*/
retrievalTokenBudget: number;
/**
* [v2.2.313] 파트별 완성 — 보고서형 답변이 이어쓰기로도 출력 한계에 닿으면
* 남은 섹션을 백엔드에서 독립 호출로 나눠 생성해 한 문서로 통합. 기본 true.
*/
sectionedCompletionEnabled: boolean;
/**
* [v2.2.316] 턴 의도 브리핑 — 실질 요청 턴마다 목적/진짜 궁금증/산출물 형태/독자/
* 근거 경로를 LLM 1회로 추출해 주입 (retrieval 과 병렬 — 체감 지연 최소).
*/
intentBriefEnabled: boolean;
/** [v2.2.318] 루트 URL 분석 요청 시 내부 글 2개까지 추가 수집 (글 퀄리티 판단 데이터). */
webDeepAnalyzeEnabled: boolean;
/** 의도 브리핑 전용 모델 ID. 비면 현재 턴 모델. 빠른 소형 모델 권장. */
intentBriefModel: string;
/** Self-check 전용 모델 ID. 비면 defaultModel. 빠른 작은 모델 권장. */
selfCheckModel: string;
/** Self-check LLM 호출 타임아웃 (초). 기본 6. */
@@ -597,6 +611,10 @@ export function getConfig(): IAgentConfig {
citationTraceEnabled: cfg.get<boolean>('citationTraceEnabled', true),
investigationModel: (cfg.get<string>('investigationModel', '') || '').trim(),
kvCachePromptSplit: cfg.get<boolean>('kvCachePromptSplit', true),
sectionedCompletionEnabled: cfg.get<boolean>('sectionedCompletionEnabled', true),
intentBriefEnabled: cfg.get<boolean>('intentBriefEnabled', true),
webDeepAnalyzeEnabled: cfg.get<boolean>('webDeepAnalyzeEnabled', true),
intentBriefModel: (cfg.get<string>('intentBriefModel', '') || '').trim(),
retrievalTokenBudget: Math.max(0, cfg.get<number>('retrievalTokenBudget', 0) || 0),
selfCheckEnabled: cfg.get<boolean>('selfCheckEnabled', false),
selfCheckModel: cfg.get<string>('selfCheckModel', '') || '',
+1
View File
@@ -8,6 +8,7 @@ import './features/system/handlers';
import './features/claude/claudeLauncher'; // /claude — Claude Code CLI 위임 (v2.2.303)
import './features/datacollect/handlers';
import './features/email/handlers'; // /email — Gmail + Outlook (v2.2.269)
import './features/naverTrend/handlers'; // /naver-shopping, /naver-trend — 네이버 데이터랩
// axios removed in favor of native fetch
import {
_getBrainDir,
+119
View File
@@ -0,0 +1,119 @@
/**
* 네이버쇼핑 카테고리 이름 ↔ cat_id 매핑.
*
* 데이터랩 쇼핑인사이트 문서는 "네이버쇼핑에서 카테고리 선택 시 URL의 cat_id
* 파라미터 값으로 확인"이라고만 안내한다 — 즉 API 가 받는 cat_id 는 대분류
* 11종의 "루트" 코드에 국한되지 않고, 네이버쇼핑 전체 카테고리 트리(중/소/세
* 분류 포함)의 어떤 노드든 가능하다.
*
* ROOT_CATEGORY_CODES 는 공식 예시(패션의류=50000000, 화장품/미용=50000002)와
* 교차 검증된 대분류 루트 코드만 담는다 — 검증 못 한 번호는 추측해 넣지 않는다
* (틀린 cat_id 는 API 가 에러 없이 빈 데이터를 조용히 돌려줘서 발견하기 어려움).
* 나머지 대분류/세부 카테고리는 CATEGORY_TREE(전체 트리, out-of-repo 소스:
* isnow890/naver-search-mcp, MIT) 에서 fuzzy 매칭으로 찾는다.
*/
import * as categoryTreeRaw from './data/naverShoppingCategories.json';
export interface CategoryRow {
code: string;
level1: string;
level2: string;
level3: string;
level4: string;
}
export interface CategoryMatch {
/** 사람이 읽는 이름 — 매칭된 가장 구체적인 레벨. */
name: string;
/** 전체 경로 — "패션의류 > 원피스" 처럼 사용자에게 확인용으로 보여줄 문자열. */
path: string;
code: string;
/** 'root' = 검증된 대분류, 'tree' = 카테고리 트리 매칭, 'raw' = 사용자가 cat_id 숫자를 직접 입력. */
source: 'root' | 'tree' | 'raw';
}
/** 공식 문서 예시 + 독립 교차 검증된 대분류 루트 코드만. 검증 못 한 나머지는 CATEGORY_TREE 매칭에 위임. */
export const ROOT_CATEGORY_CODES: Record<string, string> = {
'패션의류': '50000000',
'화장품/미용': '50000002',
'디지털/가전': '50000003',
'식품': '50000008',
};
const CATEGORY_TREE = categoryTreeRaw as unknown as CategoryRow[];
function normalize(s: string): string {
return s.trim().toLowerCase().replace(/\s+/g, '');
}
function pathOf(row: CategoryRow): string {
return [row.level1, row.level2, row.level3, row.level4].filter(Boolean).join(' > ');
}
/** row 의 가장 구체적인(비어있지 않은 마지막) 레벨 이름. */
function leafName(row: CategoryRow): string {
return row.level4 || row.level3 || row.level2 || row.level1;
}
/**
* 카테고리 이름(또는 이미 알고 있는 cat_id 숫자) → 후보 매칭 목록.
* - 순수 숫자 입력이면 그대로 raw 코드로 통과.
* - ROOT_CATEGORY_CODES 에 정확/부분 일치하면 그것을 최우선 반환.
* - 그 외엔 전체 트리에서 이름이 포함된 행을 찾아, 레벨이 얕을수록(대분류에
* 가까울수록) 우선 정렬 — level4>level3>level2>level1 매치 시 더 구체적인
* 걸 원하는 사용자 의도를 존중하되, 동일 매치 개수면 얕은 레벨 우선.
*/
export function findCategoryCode(query: string, limit = 5): CategoryMatch[] {
const q = query.trim();
if (!q) return [];
if (/^\d{6,}$/.test(q)) {
return [{ name: q, path: q, code: q, source: 'raw' }];
}
const nq = normalize(q);
for (const [name, code] of Object.entries(ROOT_CATEGORY_CODES)) {
if (normalize(name) === nq || normalize(name).includes(nq) || nq.includes(normalize(name))) {
return [{ name, path: name, code, source: 'root' }];
}
}
type Scored = { row: CategoryRow; level: number; exact: boolean };
const scored: Scored[] = [];
for (const row of CATEGORY_TREE) {
const levels: Array<[string, number]> = [
[row.level1, 1], [row.level2, 2], [row.level3, 3], [row.level4, 4],
];
let bestLevel = 0;
let exact = false;
for (const [val, lvl] of levels) {
if (!val) continue;
const nv = normalize(val);
if (nv === nq) { bestLevel = lvl; exact = true; break; }
if (nv.includes(nq) || nq.includes(nv)) {
if (bestLevel === 0) bestLevel = lvl;
}
}
if (bestLevel > 0) scored.push({ row, level: bestLevel, exact });
}
scored.sort((a, b) => {
if (a.exact !== b.exact) return a.exact ? -1 : 1;
return a.level - b.level; // 대분류에 가까운(레벨 낮은) 매치 우선
});
const seen = new Set<string>();
const out: CategoryMatch[] = [];
for (const { row } of scored) {
if (seen.has(row.code)) continue;
seen.add(row.code);
out.push({ name: leafName(row), path: pathOf(row), code: row.code, source: 'tree' });
if (out.length >= limit) break;
}
return out;
}
/** 사람이 읽을 대분류(level1) 이름 전체 목록 — 도움말/에러 메시지용. */
export function listTopLevelCategoryNames(): string[] {
return [...new Set(CATEGORY_TREE.map(r => r.level1))];
}
File diff suppressed because it is too large Load Diff
+178
View File
@@ -0,0 +1,178 @@
/**
* ============================================================
* /naver-shopping, /naver-trend — 네이버 쇼핑인사이트 · 검색어트렌드
*
* /naver-shopping <자연어 질문> — 분야별 트렌드 비교/탐색
* /naver-trend <자연어 질문> — 검색어 트렌드 비교/탐색
*
* 두 API 모두 "비교 대상을 지정하면 상대 비율을 돌려주는" 구조라, 사용자가
* 대상을 지목하지 않은 탐색성 질문("요즘 뭐가 뜨나")은 LLM이 후보를 제안하고
* 실제 API로 검증한 뒤 비율순으로 정렬해 보여준다(discover 모드) — 네이버
* 전체 실시간 인기검색어 순위가 아니라는 점을 결과에 항상 명시한다.
*
* import 만으로 등록 — extension.ts 에서 `import './features/naverTrend/handlers'`.
* ============================================================
*/
import * as vscode from 'vscode';
import { registerSlashCommand, chunk } from '../datacollect/slashRouter';
import { AIService } from '../../core/services';
import { parseShoppingIntent, parseSearchTrendIntent } from './intentParser';
import { findCategoryCode, listTopLevelCategoryNames, type CategoryMatch } from './categoryCodes';
import { rankCategories, rankKeywords, type RankedResult } from './rankEngine';
import type { CommonFilters, TimeUnit } from './naverApi';
type Webview = Parameters<typeof chunk>[0];
const DISCLAIMER = '⚠️ 이 순위는 네이버 전체 실시간 인기 순위가 아니라, 후보군 내에서 실제 네이버 데이터랩 API로 검증한 상대 비교치입니다.';
function ymd(d: Date): string {
return d.toISOString().slice(0, 10);
}
/** 기본 조회 기간 — "오늘"은 데이터랩 집계가 안 됐을 수 있어 어제를 종료일로. */
function defaultRange(): { startDate: string; endDate: string; timeUnit: TimeUnit } {
const end = new Date();
end.setDate(end.getDate() - 1);
const start = new Date(end);
start.setDate(start.getDate() - 6);
return { startDate: ymd(start), endDate: ymd(end), timeUnit: 'date' };
}
function buildFilters(intent: { device?: 'pc' | 'mo'; gender?: 'm' | 'f'; ages?: string[] }): CommonFilters {
const filters: CommonFilters = {};
if (intent.device) filters.device = intent.device;
if (intent.gender) filters.gender = intent.gender;
if (intent.ages && intent.ages.length > 0) filters.ages = intent.ages;
return filters;
}
function formatRankedTable(result: RankedResult, itemLabel: string): string {
if (result.items.length === 0) return '_(결과 없음)_\n';
const lines: string[] = [];
lines.push(`| # | ${itemLabel} | 최근 ratio |`);
lines.push('|---|---|---|');
result.items.forEach((it, i) => {
const label = it.path && it.path !== it.name ? `${it.name} _(${it.path})_` : it.name;
lines.push(`| ${i + 1} | ${label} | ${it.latestRatio.toFixed(1)} |`);
});
return lines.join('\n') + '\n';
}
function formatErrors(errors: string[]): string {
if (errors.length === 0) return '';
return '\n' + errors.map(e => `> ⚠️ ${e}`).join('\n') + '\n';
}
// ── /naver-shopping ──────────────────────────────────────────────────────
function cmdShoppingHelp(view: Webview): void {
chunk(view, [
'',
'**/naver-shopping** — 네이버 쇼핑인사이트(분야별 트렌드)',
'',
'- `/naver-shopping 패션의류 vs 화장품/미용 최근 일주일` — 명시 분야 비교',
'- `/naver-shopping 요즘 뜨는 뷰티 카테고리 알려줘` — 탐색(후보 제안 + 검증)',
'',
'- API 키 설정: Astra 설정 → Naver Trend 탭에서 Client ID/Secret 입력',
'',
].join('\n'));
}
async function runShopping(arg: string, view: Webview, context?: vscode.ExtensionContext): Promise<boolean> {
if (!context) { chunk(view, '\n⚠️ ExtensionContext 없음.\n'); return true; }
const trimmed = arg.trim();
if (!trimmed) { cmdShoppingHelp(view); return true; }
try {
chunk(view, '\n🔍 질문 분석 중…\n');
const ai = new AIService();
const intent = await parseShoppingIntent(ai, trimmed, ymd(new Date()));
const resolved: Array<{ name: string; path?: string; code: string }> = [];
const unresolved: string[] = [];
for (const name of intent.categories) {
const matches: CategoryMatch[] = findCategoryCode(name, 1);
if (matches.length > 0) resolved.push({ name: matches[0].name, path: matches[0].path, code: matches[0].code });
else unresolved.push(name);
}
if (resolved.length === 0) {
chunk(view, `\n❓ 분야를 찾지 못했습니다: ${unresolved.join(', ')}\n\n지원 대분류 예시: ${listTopLevelCategoryNames().join(', ')}\n(세부 카테고리명으로도 검색 가능 — 예: "원피스", "무선이어폰")\n`);
return true;
}
const range = {
startDate: intent.startDate || defaultRange().startDate,
endDate: intent.endDate || defaultRange().endDate,
timeUnit: intent.timeUnit || 'date' as TimeUnit,
};
const filters = buildFilters(intent);
chunk(view, `\n📊 조회 중 — ${resolved.map(r => r.name).join(', ')} (${range.startDate} ~ ${range.endDate})…\n`);
const result = await rankCategories(context, resolved, range, filters);
const title = intent.mode === 'discover'
? `**네이버 쇼핑인사이트 — "${trimmed}" 관련 분야 TOP ${result.items.length}**`
: `**네이버 쇼핑인사이트 — 분야 비교**`;
chunk(view, `\n${title} (${range.startDate}~${range.endDate})\n\n`);
chunk(view, formatRankedTable(result, '분야'));
if (unresolved.length > 0) chunk(view, `\n> ❓ 못 찾은 분야: ${unresolved.join(', ')}\n`);
chunk(view, formatErrors(result.errors));
chunk(view, `\n${DISCLAIMER}\n`);
} catch (e: any) {
chunk(view, `\n❌ /naver-shopping 오류: ${e?.message || String(e)}\n`);
}
return true;
}
// ── /naver-trend ─────────────────────────────────────────────────────────
function cmdSearchTrendHelp(view: Webview): void {
chunk(view, [
'',
'**/naver-trend** — 네이버 검색어트렌드',
'',
'- `/naver-trend 원피스,블라우스 비교` — 명시 키워드 비교',
'- `/naver-trend 20대 여성한테 인기있는 원피스 스타일 키워드 10개` — 탐색(후보 제안 + 검증)',
'',
'- API 키 설정: Astra 설정 → Naver Trend 탭에서 Client ID/Secret 입력',
'',
].join('\n'));
}
async function runSearchTrend(arg: string, view: Webview, context?: vscode.ExtensionContext): Promise<boolean> {
if (!context) { chunk(view, '\n⚠️ ExtensionContext 없음.\n'); return true; }
const trimmed = arg.trim();
if (!trimmed) { cmdSearchTrendHelp(view); return true; }
try {
chunk(view, '\n🔍 질문 분석 중…\n');
const ai = new AIService();
const intent = await parseSearchTrendIntent(ai, trimmed, ymd(new Date()));
const range = {
startDate: intent.startDate || defaultRange().startDate,
endDate: intent.endDate || defaultRange().endDate,
timeUnit: intent.timeUnit || 'date' as TimeUnit,
};
const filters = buildFilters(intent);
chunk(view, `\n📊 조회 중 — ${intent.keywords.join(', ')} (${range.startDate} ~ ${range.endDate})…\n`);
const result = await rankKeywords(context, intent.keywords, range, filters);
const title = intent.mode === 'discover'
? `**네이버 검색어트렌드 — "${trimmed}" 관련 키워드 TOP ${result.items.length}**`
: `**네이버 검색어트렌드 — 키워드 비교**`;
chunk(view, `\n${title} (${range.startDate}~${range.endDate})\n\n`);
chunk(view, formatRankedTable(result, '키워드'));
chunk(view, formatErrors(result.errors));
chunk(view, `\n${DISCLAIMER}\n`);
} catch (e: any) {
chunk(view, `\n❌ /naver-trend 오류: ${e?.message || String(e)}\n`);
}
return true;
}
registerSlashCommand({ name: '/naver-shopping', description: '네이버 쇼핑인사이트 — 분야별 트렌드 조회/비교', handler: runShopping });
registerSlashCommand({ name: '/naver-trend', description: '네이버 검색어트렌드 — 키워드 검색 추이 조회/비교', handler: runSearchTrend });
+232
View File
@@ -0,0 +1,232 @@
/**
* 자연어 질문 → 쇼핑인사이트/검색어트렌드 API 파라미터 구조화.
*
* intentClassifier.ts 의 4단계 tolerant JSON 파서(JSON.parse → 코드펜스 제거 →
* balanced-brace 추출 → regex pluck) 와 "파싱 실패 시 안전한 기본값" 원칙을
* 그대로 재사용한다. 이 코드베이스는 같은 패턴을 파일마다 반복 구현하는 관례가
* 있어(ceoPlanner/dispatcher/intentClassifier) 별도 공유 유틸로 뽑지 않았다.
*
* mode:
* - 'compare' : 사용자가 비교 대상(분야/키워드)을 명시적으로 언급함 → 그대로 추출.
* - 'discover' : "요즘 뭐가 뜨나/인기 뭐야/top N" 같은 탐색성 질문 → LLM이 도메인
* 지식으로 후보를 제안(진짜 실시간 순위가 아니라 "검증할 후보"라는 점을
* handlers.ts 가 출력 시 디스클레이머로 명시).
*/
import { IAIService } from '../../core/services';
import { logError, logInfo } from '../../utils';
export type IntentMode = 'compare' | 'discover';
export interface ShoppingIntent {
mode: IntentMode;
categories: string[];
startDate?: string;
endDate?: string;
timeUnit?: 'date' | 'week' | 'month';
device?: 'pc' | 'mo';
gender?: 'm' | 'f';
ages?: string[]; // '10'|'20'|'30'|'40'|'50'|'60'
parsed: boolean;
}
export interface SearchTrendIntent {
mode: IntentMode;
keywords: string[];
startDate?: string;
endDate?: string;
timeUnit?: 'date' | 'week' | 'month';
device?: 'pc' | 'mo';
gender?: 'm' | 'f';
ages?: string[]; // '1'..'11'
parsed: boolean;
}
function _extractFirstBalancedObject(s: string): string | null {
const start = s.indexOf('{');
if (start === -1) return null;
let depth = 0;
let inString = false;
let escape = false;
for (let i = start; i < s.length; i++) {
const ch = s[i];
if (inString) {
if (escape) escape = false;
else if (ch === '\\') escape = true;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') { inString = true; continue; }
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) return s.slice(start, i + 1);
}
}
return null;
}
/** JSON.parse → 코드펜스 제거 → balanced-brace 추출, 3단계. 실패하면 null. */
function _parseJsonLoose(raw: string): Record<string, unknown> | null {
if (!raw || !raw.trim()) return null;
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
const stage1 = (fenced ? fenced[1] : raw).trim();
try {
const obj = JSON.parse(stage1);
if (obj && typeof obj === 'object') return obj as Record<string, unknown>;
} catch { /* fall through */ }
const balanced = _extractFirstBalancedObject(stage1);
if (balanced) {
try {
const obj = JSON.parse(balanced);
if (obj && typeof obj === 'object') return obj as Record<string, unknown>;
} catch { /* fall through */ }
}
return null;
}
function _asStringArray(v: unknown): string[] {
if (!Array.isArray(v)) return [];
return v.filter((x): x is string => typeof x === 'string' && x.trim().length > 0).map(s => s.trim());
}
function _asMode(v: unknown): IntentMode {
return v === 'discover' ? 'discover' : 'compare';
}
function _asDate(v: unknown): string | undefined {
return typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) ? v : undefined;
}
function _asTimeUnit(v: unknown): 'date' | 'week' | 'month' | undefined {
return v === 'date' || v === 'week' || v === 'month' ? v : undefined;
}
function _asDevice(v: unknown): 'pc' | 'mo' | undefined {
return v === 'pc' || v === 'mo' ? v : undefined;
}
function _asGender(v: unknown): 'm' | 'f' | undefined {
return v === 'm' || v === 'f' ? v : undefined;
}
const COMMON_FIELDS_PROMPT = `
- "startDate"/"endDate": 사용자가 기간을 명시했으면 yyyy-mm-dd, 아니면 빈 문자열 "".
- "timeUnit": "date"(일간)|"week"(주간)|"month"(월간). 명시 없으면 "".
- "device": "pc"|"mo". 명시 없으면 "".
- "gender": "m"|"f". 명시 없으면 "".
- "ages": 연령대 배열. 명시 없으면 빈 배열.`;
function _buildUserMessage(userText: string, todayYmd: string): string {
return [
`(오늘 날짜: ${todayYmd})`,
'',
'[사용자 질문]',
userText,
'',
'판정 JSON만 출력:',
].join('\n');
}
// ── 쇼핑인사이트 ──────────────────────────────────────────────────────────
const SHOPPING_SYSTEM_PROMPT = `당신은 네이버 쇼핑인사이트(분야별 트렌드) API 질의 파서입니다. 사용자의 자연어 질문을 분석해 아래 JSON 하나만 출력하세요. 다른 텍스트(설명, 코드펜스, 머리말) 절대 금지.
- "mode": 사용자가 구체적인 분야명(예: "패션의류", "화장품", "가전")을 하나 이상 언급했으면 "compare". "요즘 뭐가 뜨나"/"인기 분야"/"top N" 처럼 분야를 직접 지목하지 않고 탐색을 원하면 "discover".
- "categories": mode="compare"면 사용자가 언급한 분야명을 그대로(정규화하지 말고 원문 그대로) 배열로. mode="discover"면 질문 맥락에 맞는 분야 후보를 당신의 지식으로 최대 9개 제안(3개씩 묶어 순차 조회하므로 3의 배수 권장).
${COMMON_FIELDS_PROMPT}
⚠️ 반드시 아래 JSON 형식 정확히 한 번:
{"mode":"compare"|"discover","categories":["..."],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}`;
export async function parseShoppingIntent(
ai: IAIService,
userText: string,
todayYmd: string,
options: { model?: string; timeoutMs?: number } = {},
): Promise<ShoppingIntent> {
const trimmed = userText.trim();
let raw = '';
try {
const result = await ai.chat({
system: SHOPPING_SYSTEM_PROMPT,
user: _buildUserMessage(trimmed, todayYmd),
model: options.model,
timeoutMs: options.timeoutMs,
});
raw = result.content || '';
} catch (e: any) {
logError('naverTrend intentParser(shopping): AI 호출 실패 — compare 모드로 원문 사용.', { error: e?.message ?? String(e) });
return { mode: 'compare', categories: [trimmed], parsed: false };
}
const obj = _parseJsonLoose(raw);
if (!obj) {
logInfo('naverTrend intentParser(shopping): 파싱 실패 — compare 모드로 원문 사용.', { rawHead: raw.slice(0, 100) });
return { mode: 'compare', categories: [trimmed], parsed: false };
}
const categories = _asStringArray(obj.categories);
return {
mode: _asMode(obj.mode),
categories: categories.length > 0 ? categories : [trimmed],
startDate: _asDate(obj.startDate),
endDate: _asDate(obj.endDate),
timeUnit: _asTimeUnit(obj.timeUnit),
device: _asDevice(obj.device),
gender: _asGender(obj.gender),
ages: _asStringArray(obj.ages),
parsed: true,
};
}
// ── 검색어트렌드 ──────────────────────────────────────────────────────────
const SEARCH_TREND_SYSTEM_PROMPT = `당신은 네이버 검색어트렌드 API 질의 파서입니다. 사용자의 자연어 질문을 분석해 아래 JSON 하나만 출력하세요. 다른 텍스트(설명, 코드펜스, 머리말) 절대 금지.
- "mode": 사용자가 구체적인 검색어를 하나 이상 언급했으면 "compare". "인기 검색어"/"요즘 뜨는 키워드"/"top N" 처럼 키워드를 직접 지목하지 않고 탐색을 원하면 "discover".
- "keywords": mode="compare"면 사용자가 언급한 검색어를 그대로 배열로. mode="discover"면 질문 맥락(주제/카테고리/타겟)에 맞는 검색어 후보를 당신의 지식으로 최대 10개 제안(개별 순위를 매기려면 키워드 1개=API 그룹 1개라 5개씩 순차 조회함을 감안).
${COMMON_FIELDS_PROMPT}
- "ages" 값은 검색어트렌드 전용 코드(1=0~12세,2=13~18세,3=19~24세,4=25~29세,5=30~34세,6=35~39세,7=40~44세,8=45~49세,9=50~54세,10=55~59세,11=60세이상)를 사용하세요 — 쇼핑인사이트의 10/20/30 대 코드와 다릅니다.
⚠️ 반드시 아래 JSON 형식 정확히 한 번:
{"mode":"compare"|"discover","keywords":["..."],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}`;
export async function parseSearchTrendIntent(
ai: IAIService,
userText: string,
todayYmd: string,
options: { model?: string; timeoutMs?: number } = {},
): Promise<SearchTrendIntent> {
const trimmed = userText.trim();
let raw = '';
try {
const result = await ai.chat({
system: SEARCH_TREND_SYSTEM_PROMPT,
user: _buildUserMessage(trimmed, todayYmd),
model: options.model,
timeoutMs: options.timeoutMs,
});
raw = result.content || '';
} catch (e: any) {
logError('naverTrend intentParser(searchTrend): AI 호출 실패 — compare 모드로 원문 사용.', { error: e?.message ?? String(e) });
return { mode: 'compare', keywords: [trimmed], parsed: false };
}
const obj = _parseJsonLoose(raw);
if (!obj) {
logInfo('naverTrend intentParser(searchTrend): 파싱 실패 — compare 모드로 원문 사용.', { rawHead: raw.slice(0, 100) });
return { mode: 'compare', keywords: [trimmed], parsed: false };
}
const keywords = _asStringArray(obj.keywords);
return {
mode: _asMode(obj.mode),
keywords: keywords.length > 0 ? keywords : [trimmed],
startDate: _asDate(obj.startDate),
endDate: _asDate(obj.endDate),
timeUnit: _asTimeUnit(obj.timeUnit),
device: _asDevice(obj.device),
gender: _asGender(obj.gender),
ages: _asStringArray(obj.ages),
parsed: true,
};
}
+120
View File
@@ -0,0 +1,120 @@
/**
* 네이버 API HUB — 쇼핑인사이트(분야별 트렌드) / 검색어트렌드 REST 클라이언트.
*
* naverFundamentals.ts 의 fetch + AbortSignal.timeout + try/catch 관례를 따르되,
* 이쪽은 공식 인증 API(X-NCP-APIGW-API-KEY-ID/-KEY 헤더)라 결과를 discriminated
* union 으로 돌려준다 — 호출자가 try/catch 없이 `if (!res.ok) ...`로 처리.
*/
import * as vscode from 'vscode';
import { logError } from '../../utils';
import { readNaverTrendConfig } from './naverConfig';
const BASE_URL = 'https://naverapihub.apigw.ntruss.com';
export type NaverResult<T> = { ok: true; data: T } | { ok: false; error: string };
export type TimeUnit = 'date' | 'week' | 'month';
export type Device = 'pc' | 'mo';
export type Gender = 'm' | 'f';
export interface CommonFilters {
device?: Device;
gender?: Gender;
ages?: string[];
}
export interface ShoppingCategoryTrendRequest extends CommonFilters {
startDate: string;
endDate: string;
timeUnit: TimeUnit;
category: Array<{ name: string; param: string[] }>;
}
export interface TrendDataPoint {
period: string;
ratio: number;
}
export interface ShoppingCategoryTrendResult {
title: string;
category: string[];
data: TrendDataPoint[];
}
export interface ShoppingCategoryTrendResponse {
startDate: string;
endDate: string;
timeUnit: TimeUnit;
results: ShoppingCategoryTrendResult[];
}
export interface SearchTrendRequest extends CommonFilters {
startDate: string;
endDate: string;
timeUnit: TimeUnit;
keywordGroups: Array<{ groupName: string; keywords: string[] }>;
}
export interface SearchTrendResult {
title: string;
keywords: string[];
data: TrendDataPoint[];
}
export interface SearchTrendResponse {
startDate: string;
endDate: string;
timeUnit: TimeUnit;
results: SearchTrendResult[];
}
async function postJson<TReq, TRes>(
context: vscode.ExtensionContext,
path: string,
body: TReq,
timeoutMs: number,
): Promise<NaverResult<TRes>> {
const cfg = await readNaverTrendConfig(context);
if (!cfg.clientId || !cfg.clientSecret) {
return { ok: false, error: 'Naver API 키가 설정되지 않았습니다 — Astra 설정 → Naver Trend 탭에서 Client ID/Secret을 입력하세요.' };
}
try {
const res = await fetch(`${BASE_URL}${path}`, {
method: 'POST',
headers: {
'X-NCP-APIGW-API-KEY-ID': cfg.clientId,
'X-NCP-APIGW-API-KEY': cfg.clientSecret,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
return { ok: false, error: `Naver API ${res.status}: ${text.slice(0, 300) || res.statusText}` };
}
const data = await res.json() as TRes;
return { ok: true, data };
} catch (e: any) {
const msg = e?.message ?? String(e);
logError('naverApi: fetch 실패.', { path, error: msg });
return { ok: false, error: `Naver API 호출 실패: ${msg}` };
}
}
export async function fetchShoppingCategoryTrend(
context: vscode.ExtensionContext,
body: ShoppingCategoryTrendRequest,
timeoutMs = 10000,
): Promise<NaverResult<ShoppingCategoryTrendResponse>> {
return postJson(context, '/shopping/v1/categories', body, timeoutMs);
}
export async function fetchSearchTrend(
context: vscode.ExtensionContext,
body: SearchTrendRequest,
timeoutMs = 10000,
): Promise<NaverResult<SearchTrendResponse>> {
return postJson(context, '/search-trend/v1/search', body, timeoutMs);
}
+48
View File
@@ -0,0 +1,48 @@
/**
* 네이버 API HUB (쇼핑인사이트/검색어트렌드) Client ID/Secret 저장소.
*
* providerConfig.ts 와 동일 설계: 값 자체는 vscode.SecretStorage 에만 —
* settings.json / Settings Sync 침범 안 받음. 패널은 원문을 절대 echo 안 하고
* hasCredentials boolean 만 노출한다.
*/
import * as vscode from 'vscode';
const CLIENT_ID_KEY = 'g1nation.naverTrend.clientId';
const CLIENT_SECRET_KEY = 'g1nation.naverTrend.clientSecret';
export interface NaverTrendConfig {
clientId: string;
clientSecret: string;
}
export async function readNaverTrendConfig(context: vscode.ExtensionContext): Promise<NaverTrendConfig> {
const [clientId, clientSecret] = await Promise.all([
context.secrets.get(CLIENT_ID_KEY),
context.secrets.get(CLIENT_SECRET_KEY),
]);
return { clientId: clientId ?? '', clientSecret: clientSecret ?? '' };
}
/** patch 의 필드만 갱신. truthy 값이면 저장, 빈 문자열이면 삭제. */
export async function writeNaverTrendConfig(
context: vscode.ExtensionContext,
patch: Partial<NaverTrendConfig>,
): Promise<void> {
if (typeof patch.clientId === 'string') {
const v = patch.clientId.trim();
if (v) await context.secrets.store(CLIENT_ID_KEY, v);
else await context.secrets.delete(CLIENT_ID_KEY);
}
if (typeof patch.clientSecret === 'string') {
const v = patch.clientSecret.trim();
if (v) await context.secrets.store(CLIENT_SECRET_KEY, v);
else await context.secrets.delete(CLIENT_SECRET_KEY);
}
}
/** SettingsPanel 표시용 — secret 값 자체는 노출 X. 설정 여부만 boolean. */
export async function readNaverTrendStatus(context: vscode.ExtensionContext): Promise<{ hasCredentials: boolean }> {
const cfg = await readNaverTrendConfig(context);
return { hasCredentials: !!(cfg.clientId && cfg.clientSecret) };
}
+111
View File
@@ -0,0 +1,111 @@
/**
* 배치 호출 + 병합 정렬 — 네이버 API 의 단일 요청 상한(카테고리 3개/키워드그룹 5개)을
* 캡슐화해서 handlers.ts 가 "후보 N개를 비율순으로 랭킹"만 요청하면 되게 한다.
*
* 키워드 랭킹은 groupName 하나 = keyword 하나로 보낸다 — 같은 groupName 아래
* 여러 keyword 를 넣으면 API 가 하나의 시리즈로 합산해버려 키워드별 개별 순위를
* 못 매기기 때문(검색어트렌드 문서 참조).
*/
import * as vscode from 'vscode';
import {
fetchShoppingCategoryTrend, fetchSearchTrend,
type CommonFilters, type TimeUnit, type TrendDataPoint,
} from './naverApi';
export interface DateRange {
startDate: string;
endDate: string;
timeUnit: TimeUnit;
}
export interface RankedItem {
name: string;
path?: string;
latestRatio: number;
series: TrendDataPoint[];
}
export interface RankedResult extends DateRange {
items: RankedItem[];
/** 배치 중 일부가 실패해도 나머지는 반환 — 실패한 배치의 에러 메시지 모음. */
errors: string[];
}
function chunkArray<T>(arr: T[], size: number): T[][] {
const out: T[][] = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}
function delay(ms: number): Promise<void> {
return new Promise(r => setTimeout(r, ms));
}
function latestRatioOf(series: TrendDataPoint[]): number {
return series.length ? series[series.length - 1].ratio : 0;
}
export async function rankCategories(
context: vscode.ExtensionContext,
candidates: Array<{ name: string; path?: string; code: string }>,
range: DateRange,
filters: CommonFilters,
): Promise<RankedResult> {
const items: RankedItem[] = [];
const errors: string[] = [];
const batches = chunkArray(candidates, 3);
for (let i = 0; i < batches.length; i++) {
const batch = batches[i];
const res = await fetchShoppingCategoryTrend(context, {
...range,
...filters,
category: batch.map(c => ({ name: c.name, param: [c.code] })),
});
if (!res.ok) {
errors.push(`[${batch.map(c => c.name).join(', ')}] ${res.error}`);
} else {
for (const r of res.data.results) {
const match = batch.find(c => c.name === r.title);
items.push({ name: r.title, path: match?.path, latestRatio: latestRatioOf(r.data), series: r.data });
}
}
if (i < batches.length - 1) await delay(200);
}
items.sort((a, b) => b.latestRatio - a.latestRatio);
return { ...range, items, errors };
}
export async function rankKeywords(
context: vscode.ExtensionContext,
candidates: string[],
range: DateRange,
filters: CommonFilters,
): Promise<RankedResult> {
const items: RankedItem[] = [];
const errors: string[] = [];
const uniqueCandidates = [...new Set(candidates.map(k => k.trim()).filter(Boolean))];
const batches = chunkArray(uniqueCandidates, 5);
for (let i = 0; i < batches.length; i++) {
const batch = batches[i];
const res = await fetchSearchTrend(context, {
...range,
...filters,
keywordGroups: batch.map(k => ({ groupName: k, keywords: [k] })),
});
if (!res.ok) {
errors.push(`[${batch.join(', ')}] ${res.error}`);
} else {
for (const r of res.data.results) {
items.push({ name: r.title, latestRatio: latestRatioOf(r.data), series: r.data });
}
}
if (i < batches.length - 1) await delay(200);
}
items.sort((a, b) => b.latestRatio - a.latestRatio);
return { ...range, items, errors };
}
@@ -133,6 +133,10 @@ interface SettingsState {
anthropic: { enabled: boolean; hasApiKey: boolean; defaultModel: string };
gemini: { enabled: boolean; hasApiKey: boolean; defaultModel: string };
};
/** 네이버 API HUB (쇼핑인사이트/검색어트렌드) — Client ID/Secret 둘 다 secrets, *설정 여부* 만 전송. */
naver: {
hasCredentials: boolean;
};
email: {
outlookEnabled: boolean;
gmailEnabled: boolean;
@@ -303,6 +307,9 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
case 'providers.update':
await this._handleProvidersUpdate(msg);
return;
case 'naver.update':
await this._handleNaverUpdate(msg);
return;
case 'devilAgent.toggle':
await this._safeConfigUpdate('devilAgent.enabled', !!msg.enabled);
return;
@@ -607,6 +614,26 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
await this._refreshState();
}
// 네이버 API HUB (쇼핑인사이트/검색어트렌드) — Client ID/Secret 둘 다 Secret Storage 만.
// google.clientSecret 처리와 동일하게 원문은 절대 echo 안 함 (hasCredentials boolean 만).
private async _buildNaverState(): Promise<SettingsState['naver']> {
const { readNaverTrendStatus } = require('../naverTrend/naverConfig') as typeof import('../naverTrend/naverConfig');
return await readNaverTrendStatus(this._deps.context);
}
private async _handleNaverUpdate(msg: any): Promise<void> {
const { writeNaverTrendConfig } = require('../naverTrend/naverConfig') as typeof import('../naverTrend/naverConfig');
const patch: any = {};
if (typeof msg.clientId === 'string') patch.clientId = msg.clientId;
if (typeof msg.clientSecret === 'string') patch.clientSecret = msg.clientSecret;
if (Object.keys(patch).length === 0) return;
await writeNaverTrendConfig(this._deps.context, patch);
this._lastSuccess = 'Naver Trend 저장 완료';
this._lastError = undefined;
await this._refreshState();
}
private async _handleAdvancedUpdate(msg: any): Promise<void> {
if (typeof msg.dryRun === 'boolean') {
await this._safeConfigUpdate('dryRun', msg.dryRun);
@@ -784,6 +811,7 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
},
google: this._buildGoogleState(),
providers: await this._buildProvidersState(),
naver: await this._buildNaverState(),
email: {
outlookEnabled: cfg.get<boolean>('email.outlookEnabled', true) !== false,
gmailEnabled: cfg.get<boolean>('email.gmailEnabled', true) !== false,
+26
View File
@@ -246,6 +246,32 @@ export function detectUngroundedAnalysis(
return { ungrounded: refs.size < 2, readCount, fileRefs: refs.size };
}
/**
* [v2.2.313] 세 번째 사각지대 — 로컬 프로젝트 분석 요청인데 이 턴에 액션을 *한 번도*
* 쓰지 않고(read/list/investigate 전부 0) 장문 분석을 내놓은 경우. 실사례: 사전
* 주입된 아키텍처 요약만으로 답해 "무엇을 할 수 있는지"는 있는데 실제 구조 상세가
* 없는 보고. hollow(목록만 봄)·ungrounded(읽고도 인용 없음) 둘 다 액션 1회 이상을
* 전제해서 이 케이스를 놓쳤다.
*/
export function detectUnreadAnalysis(
answer: string,
stats: TurnActionStats | null | undefined,
hasLocalProjectContext: boolean,
): boolean {
if (!hasLocalProjectContext) return false;
const total = (stats?.reads ?? 0) + (stats?.lists ?? 0) + (stats?.investigates ?? 0);
if (total > 0) return false;
return (answer || '').trim().length >= 300;
}
export function formatUnreadAnalysisFooter(): string {
return [
'',
'---',
'⚠️ **파일 미확인 분석** — 이 답변은 프로젝트 파일을 실제로 열지 않고 사전 요약·일반 지식으로 작성되었습니다. 구조 상세(모듈·파일 단위)가 필요하면 "실제 파일을 읽고 구조 중심으로 다시 분석해줘"라고 요청해 주세요.',
].join('\n');
}
export function formatUngroundedAnalysisFooter(readCount: number): string {
return [
'',
+85
View File
@@ -0,0 +1,85 @@
/**
* [v2.2.318] 하위 질문 체크리스트 — "질문 3개에 하나도 직접 답하지 않는" 실사례 대응.
*
* 사용자가 한 메시지에 물음을 여러 개 명시하면("어떤 주제인지, 퀄리티는 어떤지,
* 도움이 될지") 각 물음이 답변에서 다뤄지는지를 결정론으로 관리한다:
* 1) 프롬프트에서 물음 절(clause)을 규칙 기반 추출 (LLM 없음)
* 2) 답변 전: "각 물음에 별도 소제목 + 구체 근거로 답하라" 체크리스트 주입
* 3) 답변 후: 각 물음의 특징 토큰이 답변에 없으면 누락 footer
*
* requirementGraph(업무 유형별 고정 요소)와 달리 이 모듈은 *그 턴의 문장*에서
* 요소를 동적으로 뽑는다 — 유형 레지스트리에 없는 임의 질문 조합도 커버.
*/
const QUESTION_TAIL_RE = /(인지|한지|을지|를지|는지|던지|었을지|였을지|일까|을까|ㄹ까|까\?|어떠한|어떤|무엇|뭐가|누가|누구|어디|어떻게|왜|얼마나)/;
/** 물음 절에서 커버리지 판정에 쓸 특징 토큰 — 조사·의문사 등 잡토큰 제외. */
const STOP_TOKENS = new Set([
'어떠한', '어떤', '무엇', '뭐', '누가', '누구', '어디', '어떻게', '왜', '얼마나',
'있는지', '인지', '한지', '을지', '는지', '될지', '했는지', '되었을지',
'그리고', '그러고', '대한', '대해', '위한', '통해', '사람', '사이트', '해줘', '해주세요',
'분석해줘', '알려줘', '정리해줘', '평가해줘',
]);
export function extractSubQuestions(prompt: string | null | undefined): string[] {
const p = (prompt || '').replace(/https?:\/\/[^\s]+/gi, ' ').trim();
if (!p) return [];
const clauses = p
.split(/[,.?!\n;·]+/)
.map((c) => c.trim())
.filter((c) => c.length >= 6 && c.length <= 120 && QUESTION_TAIL_RE.test(c));
// 물음이 2개 이상일 때만 의미 있음 — 단일 질문은 본문 전체가 답이다.
return clauses.length >= 2 ? clauses.slice(0, 5) : [];
}
/** 물음 절의 특징 토큰 (긴 것 우선 최대 3개). */
export function clauseKeyTokens(clause: string): string[] {
return (clause || '')
.split(/[^A-Za-z0-9가-힣]+/)
.map((t) => t
.replace(/(은|는|이|가|을|를|의|로|으로|에|에서|와|과|도|만)$/, '')
// 동사형 접미 제거 — "검색해서"→"검색" 처럼 답변의 명사형과 매치되게.
.replace(/(해서|해줘|하기|하는|했는|하며|하고|되었|되어|됐던)$/, ''))
.filter((t) => t.length >= 2 && !STOP_TOKENS.has(t))
.sort((a, b) => b.length - a.length)
.slice(0, 3);
}
/** 주입 블록 — 물음이 2개 미만이면 빈 문자열. */
export function buildSubQuestionBlock(prompt: string | null | undefined): string {
const qs = extractSubQuestions(prompt);
if (qs.length === 0) return '';
return [
'[하위 질문 체크리스트 — 사용자가 명시한 물음 전부에 답해야 한다]',
...qs.map((q, i) => `${i + 1}. ${q}`),
'각 물음에 *별도 소제목 또는 단락*으로 직접 답하라. 판단("높다/우수하다")만 쓰지 말고, 각 판단에 확인된 구체 근거(글 제목·주제·예시·수치)를 최소 1개 붙여라. 근거가 없으면 "확인 불가"라고 명시하라. 물음을 건너뛰면 실패다.',
].join('\n');
}
export interface SubQuestionCoverage {
total: number;
missing: string[];
}
/** 결정론 커버리지 — 각 물음의 특징 토큰이 답변에 하나도 없으면 누락. */
export function checkSubQuestionCoverage(prompt: string | null | undefined, answer: string): SubQuestionCoverage {
const qs = extractSubQuestions(prompt);
if (qs.length === 0 || !(answer || '').trim()) return { total: 0, missing: [] };
const a = answer.toLowerCase();
const missing = qs.filter((q) => {
const keys = clauseKeyTokens(q);
return keys.length > 0 && !keys.some((k) => a.includes(k.toLowerCase()));
});
return { total: qs.length, missing };
}
export function formatSubQuestionFooter(cov: SubQuestionCoverage): string {
if (cov.missing.length === 0) return '';
return [
'',
'---',
`⚠️ **미답변 물음 감지** — 요청하신 물음 ${cov.total}개 중 ${cov.missing.length}개가 답변에서 다뤄지지 않은 것으로 보입니다:`,
...cov.missing.map((m) => `- ${m}`),
'누락된 물음만 골라 "위 물음에 각각 답해줘"라고 재요청하실 수 있습니다.',
].join('\n');
}
+121
View File
@@ -0,0 +1,121 @@
/**
* [v2.2.316] 턴 의도 브리핑 — "질문 의도를 판단하지 못한다" 문제의 구조적 해결.
*
* 진단: 의도 브리핑(왜 묻는지·진짜 궁금증·답의 용도 추출→주입)은 멀티에이전트
* 보고서 경로(v2.2.301)에만 있었고, 일반 채팅 턴은 "[답변 전 이해 원칙]" 4줄짜리
* 추상 지시뿐이었다. 소형 로컬 모델은 추상 지시("의도를 파악하라")를 실행하지
* 못한다 — 파악된 의도를 *텍스트로 받아야* 따른다 (강제 주입 패턴의 6번째 적용:
* 일정→캘린더, 자기평가→인벤토리, 정정→캡처, URL→실데이터, 조사→게이트에 이어).
*
* 확장: 기존 브리핑(목적/진짜 궁금증/용도)에 실무 3축을 추가 —
* - audience: 답을 받아 볼 사람 (본인/경영진/팀 공유) → 어조·깊이 결정
* - deliverable: 기대 산출물 형태 (보고서/한 줄 답/실행/목록/의견) → 형식 결정
* - accessPath: 근거를 어디서 가져와야 하는가 (web/local-files/brain/general)
* → "실접속 없이 일반론" 류 근거-경로 착오 방지의 상류 방어.
*
* 지연: 호출자는 이 브리핑을 retrieval(1~8초)과 *병렬* 실행한다 — 체감 추가 지연
* 최소화. 실패/타임아웃은 null — 종전 동작 그대로 (브리핑 없는 턴).
*/
import { logInfo, logWarn } from '../utils';
export interface TurnIntentBrief {
purpose: string;
realQuestions: string[];
deliverable: string;
audience: string;
accessPath: 'web' | 'local-files' | 'brain' | 'general' | string;
constraints: string;
}
/** LLM 호출자 — (system, user, maxTokens) → 응답 텍스트. 테스트 주입용. */
export type IntentLlm = (system: string, user: string, maxTokens: number) => Promise<string>;
const REQUEST_SHAPE_RE = /(해\s*줘|해\s*주세요|부탁|알려\s*줘|정리|분석|보고|조사|평가|추천|비교|검토|만들|작성|찾아|알아봐|설명|요약|research|report|analy[sz]e|review|explain|summar)/i;
/**
* 브리핑을 만들 가치가 있는 턴인지 — 잡담·초단문·단순 조회는 제외 (지연 예산 보호).
*/
export function shouldBuildTurnIntentBrief(prompt: string | null | undefined, isCasual: boolean): boolean {
if (isCasual) return false;
const p = (prompt || '').trim();
if (p.length < 15) return false;
if (/^\/[a-zA-Z]/.test(p)) return false; // 슬래시 명령은 자체 파이프라인
return REQUEST_SHAPE_RE.test(p);
}
/** LLM 응답에서 느슨한 JSON 파싱 — 코드펜스/앞뒤 잡담 허용. 실패 시 null. */
export function parseIntentBrief(raw: string): TurnIntentBrief | null {
try {
const m = (raw || '').match(/\{[\s\S]*\}/);
if (!m) return null;
const j = JSON.parse(m[0]);
if (typeof j.purpose !== 'string' || !j.purpose.trim()) return null;
const qs = Array.isArray(j.realQuestions) ? j.realQuestions.map(String).filter(Boolean).slice(0, 3) : [];
return {
purpose: j.purpose.trim().slice(0, 200),
realQuestions: qs,
deliverable: String(j.deliverable || '').trim().slice(0, 100),
audience: String(j.audience || '요청자 본인').trim().slice(0, 60),
accessPath: String(j.accessPath || 'general').trim().slice(0, 20),
constraints: String(j.constraints || '').trim().slice(0, 120),
};
} catch {
return null;
}
}
export async function buildTurnIntentBrief(params: {
prompt: string;
recentContext?: string;
llm: IntentLlm;
timeoutMs?: number;
}): Promise<TurnIntentBrief | null> {
const system = [
'너는 질문 분석가다. 사용자의 요청을 읽고 JSON 으로만 응답한다. 다른 말 금지.',
'형식: {"purpose":"왜 묻는가 한 문장","realQuestions":["표면 뒤에 정말 궁금한 것 1~3개"],"deliverable":"기대 산출물 형태(예: 구조 중심 보고서/한 줄 확인/실행/비교표/의견)","audience":"답을 볼 사람(본인/경영진 보고용/팀 공유 등)","accessPath":"web|local-files|brain|general 중 근거를 가져와야 할 곳","constraints":"명시된 제약(분량·형식·기한) 없으면 빈 문자열"}',
'accessPath 판단: URL 이 있으면 web, 로컬 경로·프로젝트·코드면 local-files, 회사 지식·기록·과거 대화면 brain, 그 외 일반 지식이면 general.',
].join('\n');
const user = `${params.recentContext ? `[최근 대화 요지]\n${params.recentContext}\n\n` : ''}[사용자 요청]\n${params.prompt}`;
const timeoutMs = params.timeoutMs ?? 10_000;
try {
const raw = await Promise.race([
params.llm(system, user, 300),
new Promise<string>((_, rej) => setTimeout(() => rej(new Error(`timeout ${timeoutMs}ms`)), timeoutMs)),
]);
const brief = parseIntentBrief(raw);
if (!brief) logWarn('[TurnIntentBrief] 파싱 실패 — 브리핑 없이 진행.', { raw: (raw || '').slice(0, 100) });
return brief;
} catch (e: any) {
logWarn('[TurnIntentBrief] 호출 실패 — 브리핑 없이 진행.', { error: e?.message ?? String(e) });
return null;
}
}
/** 시스템 프롬프트 주입 블록. 빈 브리핑이면 빈 문자열. */
export function formatTurnIntentBlock(brief: TurnIntentBrief | null): string {
if (!brief) return '';
const lines = [
'[질문자 의도 브리핑 — 이 브리핑에 실제로 답하는 것이 이번 답변의 성공 기준]',
`- 목적: ${brief.purpose}`,
];
if (brief.realQuestions.length) lines.push(`- 진짜 궁금증: ${brief.realQuestions.join(' / ')}`);
if (brief.deliverable) lines.push(`- 기대 산출물: ${brief.deliverable} — 이 형태로 답하라.`);
if (brief.audience) lines.push(`- 독자: ${brief.audience} — 어조·깊이를 여기에 맞춰라.`);
if (brief.constraints) lines.push(`- 제약: ${brief.constraints}`);
switch (brief.accessPath) {
case 'web':
lines.push('- 근거 경로: 웹 — 주입된 [URL CONTENT] 실데이터만 근거로 쓰고, 없으면 접속 실패를 첫 줄에 밝혀라. 일반 지식으로 사이트를 평하는 것은 금지.');
break;
case 'local-files':
lines.push('- 근거 경로: 로컬 파일 — 이 대화에서 실제 읽은 파일만 근거로 쓰고, 부족하면 <read_file>/<investigate_files> 로 먼저 읽어라.');
break;
case 'brain':
lines.push('- 근거 경로: 두뇌 기록 — [CONTEXT] 의 두뇌 근거를 우선 인용하고, 없으면 "기록 없음"을 밝혀라.');
break;
default:
lines.push('- 근거 경로: 일반 지식 — 단, 수치·고유명사는 확신 없으면 단정하지 마라.');
}
lines.push('- 표면 질문만 문자적으로 답하고 목적을 놓치면 실패다. 답변 첫 문장은 위 목적에 대한 결론이어야 한다.');
return lines.join('\n');
}
@@ -31,6 +31,14 @@ export function shouldUseMultiAgentWorkflow(prompt: string, configEnabled: boole
return false;
}
// [v2.2.315] URL 이 포함된 요청은 단일 턴 강제 — 멀티에이전트 파이프라인은 웹
// 접근 경로(URL 실데이터 주입)가 없어서, "사이트 조사해줘"가 실접속 0회의
// 일반론 보고서로 끝난다. 단일 턴은 URL 본문을 컨텍스트로 주입받는다.
try {
const { extractUrls } = require('../../features/web/webFetch') as typeof import('../../features/web/webFetch');
if (extractUrls(prompt, 1).length > 0) return false;
} catch { /* 추출 실패 시 기존 라우팅 유지 */ }
const cfg = getConfig();
const mode = cfg.workflowMultiAgentMode || 'auto';
@@ -44,6 +44,73 @@ export function sanitizeAssistantContent(text: string): string {
return cleanDegeneratedOutput(stripped);
}
/**
* [v2.2.314] LSEP 구분자 분해 — 마지막 END 마커 기준 before(추론 취급 구간)/after(답변 구간).
* 마커가 없으면 hasMarker=false. agent.ts 의 "본문이 추론 구간에 갇힘" 구제 판단용.
*/
export function splitLsepReasoning(text: string): { hasMarker: boolean; before: string; after: string } {
const t = text || '';
const re = /__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_[0-9a-f]{6,64}__/gi;
const matches = [...t.matchAll(re)];
if (matches.length === 0) return { hasMarker: false, before: '', after: t };
const last = matches[matches.length - 1];
const idx = (last.index ?? 0);
return {
hasMarker: true,
before: t.slice(0, idx).replace(/__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_[A-Z]+_[0-9a-f]{6,64}__/gi, ''),
after: t.slice(idx + last[0].length),
};
}
/**
* [v2.2.314] "마무리 인사만 남은 답변" 감지 — 본문 없이 "이상으로 분석을 마칩니다.
* 추가 요청 주세요" 류로만 구성된 짧은 답. 실사례: 모델이 분석 전체를 추론 구간에
* 쓰고 가시 답변은 마무리 한 줄만 낸 경우, 화면의 본문이 전부 지워지고 이것만 남았다.
*/
export function isCloserOnlyAnswer(text: string): boolean {
const t = (text || '').trim();
if (!t || t.length >= 300) return false;
return /(이상으로|이로써|마칩니다|마치겠습니다|마무리하겠|분석을 마|검토를 마|요청해 ?주세요|문의해 ?주세요|질문해 ?주세요|말씀해 ?주세요)/.test(t);
}
/**
* [v2.2.314] 내부 스캐폴딩 섹션 유출 제거 — 시스템 규칙 4([NO SECTION LEAKAGE])가
* 금지하는 "요청 요약/사용자 의도 추론/프로젝트 기록 대상 확인/핵심 확인 질문/근거
* 파일 경로" 섹션을 소형 모델이 그대로 출력하는 실사례 대응. 프롬프트 지시(확률적)
* 위에 결정론 필터를 한 겹 더 — Project Record Guard 가 요구한 턴은 호출자가 이
* 함수를 건너뛴다.
*/
const LEAKED_SECTION_NAMES = '(요청 요약|사용자 의도 추론|프로젝트 기록 대상 확인|핵심 확인 질문|근거 파일 경로)';
const LEAKED_HEADING_RE = new RegExp(`^\\s*(?:#{1,6}\\s*|\\*\\*)?\\s*${LEAKED_SECTION_NAMES}\\s*(?:\\*\\*)?\\s*[::]?\\s*$`);
export function stripLeakedInternalSections(text: string): string {
const lines = (text || '').split('\n');
const out: string[] = [];
let skipping = false;
for (const line of lines) {
if (LEAKED_HEADING_RE.test(line)) { skipping = true; continue; }
if (skipping) {
// 금지 섹션은 다음 일반 헤딩 또는 구분선에서 끝난다.
if (/^\s*#{1,6}\s+\S/.test(line) || /^\s*---\s*$/.test(line)) skipping = false;
else continue;
if (/^\s*---\s*$/.test(line)) continue; // 구분선 자체도 유출 잔재 — 버린다
}
if (/^\s*질문 의도\s*[::]/.test(line)) continue;
out.push(line);
}
return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
}
/** [v2.2.314] 추론 구간에 갇힌 텍스트가 실제 보고 본문처럼 보이는지 — 구제 승격 판단. */
export function looksLikeReportBody(text: string): boolean {
const t = (text || '').trim();
if (t.length < 600) return false;
const headings = (t.match(/(?:^|\n)#{1,6}\s+\S/g) || []).length;
const bullets = (t.match(/(?:^|\n)\s*[-*•]\s+\S/g) || []).length;
const paragraphs = t.split(/\n{2,}/).filter((p) => p.trim().length > 80).length;
return headings >= 2 || bullets >= 4 || paragraphs >= 4;
}
/**
* Long context 에서 small 모델이 degenerate 됐을 때의 blast radius 봉쇄:
* - 문자 벽 (`_______…`, `~~~~~~`) 8+ 반복은 모두 제거. markdown rule 은 3.
+126
View File
@@ -86,6 +86,132 @@ export async function buildUrlContext(url: string): Promise<string> {
].join('\n');
}
/** [v2.2.315] 주입 블록이 실데이터 성공 블록인지 (실패-정직 블록과 구분). */
export function isUrlContextSuccessBlock(block: string): boolean {
return (block || '').startsWith('[URL CONTENT — 실데이터');
}
export interface TurnWebFetchStats {
urls: string[];
succeeded: string[];
failed: Array<{ url: string; reason: string }>;
/** [v2.2.317] 성공 건의 제목·본문 표본 — 답변이 실데이터를 반영했는지 검증용. */
succeededDetail?: Array<{ url: string; title: string; sample: string }>;
}
/**
* [v2.2.318] 사이트 심층 수집 — 루트 페이지 분석 요청("사이트 분석/평가")은 홈
* 1장만으로는 "글의 퀄리티" 같은 물음에 답할 데이터가 없다 (실사례: 평가 형용사만
* 나열). 루트 HTML 에서 같은 도메인의 글 링크를 뽑아 상위 N개 본문을 추가 수집한다.
*/
export async function collectInternalArticleLinks(rootUrl: string, max = 2): Promise<string[]> {
try {
const base = new URL(rootUrl);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10_000);
let html = '';
try {
const res = await fetch(rootUrl, {
signal: controller.signal,
redirect: 'follow',
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36' },
});
if (!res.ok) return [];
html = await res.text();
} finally {
clearTimeout(timer);
}
const seen = new Set<string>();
const out: string[] = [];
for (const m of html.matchAll(/<a\b[^>]*href="([^"#?]+)"/gi)) {
let href = m[1];
try {
const u = new URL(href, base);
if (u.host !== base.host) continue;
const path = u.pathname;
// 글 본문 링크 휴리스틱: 카테고리/태그/페이지네이션/피드/미디어 제외,
// 슬러그가 실제로 있는 경로만.
if (/\/(category|tag|page|feed|wp-|author|search|login|cart|privacy|about|contact)\b/i.test(path)) continue;
if (/\.(png|jpe?g|gif|svg|css|js|xml|ico|webp)$/i.test(path)) continue;
const slug = path.replace(/\/+$/, '');
if (slug.length < 8) continue; // '/', '/en' 류 제외
const abs = `${u.origin}${slug}`;
if (seen.has(abs) || abs === rootUrl.replace(/\/+$/, '')) continue;
seen.add(abs);
out.push(abs);
if (out.length >= max) break;
} catch { /* 잘못된 href 무시 */ }
}
return out;
} catch {
return [];
}
}
/** [v2.2.317] 성공 블록에서 제목/본문 표본 추출 (검증용 메타). */
export function extractSuccessMeta(url: string, block: string): { url: string; title: string; sample: string } {
const title = block.match(/^제목: (.*)$/m)?.[1]?.trim() || '';
const bodyStart = block.indexOf('\n\n');
const sample = bodyStart >= 0 ? block.slice(bodyStart + 2, bodyStart + 2 + 800) : '';
return { url, title: title === '(없음)' ? '' : title, sample };
}
/**
* [v2.2.317] 실데이터 반영 검증 — fetch 는 성공했는데 답변이 가져온 본문과 무관한
* 일반론인 경우(실사례: Blogging Tips 블로그를 "IT 엔지니어 대상 기술 플랫폼"으로
* 서술). 제목+본문 표본의 특징 토큰이 답변에 2개 미만으로 나타나면 불일치로 판정.
* URL/도메인 토큰은 제외 — 도메인 언급만으로 통과하지 못하게.
*/
export function detectWebContentMismatch(
answer: string,
stats: TurnWebFetchStats | null | undefined,
): { mismatch: boolean; checkedTokens: number; hits: number } {
const details = stats?.succeededDetail || [];
if (!stats || stats.succeeded.length === 0 || details.length === 0) return { mismatch: false, checkedTokens: 0, hits: 0 };
const a = (answer || '').toLowerCase();
if (a.trim().length < 300) return { mismatch: false, checkedTokens: 0, hits: 0 };
const domainTokens = new Set(
stats.urls.flatMap((u) => u.toLowerCase().replace(/https?:\/\//, '').split(/[^a-z0-9가-힣]+/)).filter(Boolean),
);
const tokens = new Set<string>();
for (const d of details) {
for (const t of `${d.title} ${d.sample}`.toLowerCase().split(/[^a-z0-9가-힣]+/)) {
if (t.length >= 3 && !domainTokens.has(t) && !/^\d+$/.test(t)) tokens.add(t);
if (tokens.size >= 60) break;
}
}
if (tokens.size < 5) return { mismatch: false, checkedTokens: tokens.size, hits: 0 };
let hits = 0;
for (const t of tokens) { if (a.includes(t)) hits++; }
return { mismatch: hits < 2, checkedTokens: tokens.size, hits };
}
export function formatWebContentMismatchFooter(): string {
return '\n\n---\n⚠️ **본문 불일치 가능** — 사이트 실접속에는 성공했지만, 가져온 본문의 핵심 표현이 이 답변에 반영되지 않았습니다. 위 내용은 실데이터가 아닌 일반론일 수 있습니다. "가져온 본문 내용만 근거로 다시 분석해줘"라고 요청해 주세요.';
}
/**
* [v2.2.315] 웹 접속 결과의 결정론 표시 — 실사례: 사이트 분석 요청에 실접속 없이
* 일반론으로 답하고 그 사실이 어디에도 안 보였다. 프롬프트 지시("접속 실패를
* 알리라")는 소형 모델이 무시하므로, 코드가 답변 앞뒤에 사실을 박는다.
* - 전부 실패: 답변 *맨 위* 경고 (아래 내용은 실측 아님).
* - 1건 이상 성공: 답변 끝에 실접속 출처 한 줄 (신뢰 증빙).
*/
export function buildWebFetchNotice(stats: TurnWebFetchStats | null | undefined): { top: string; bottom: string } {
if (!stats || stats.urls.length === 0) return { top: '', bottom: '' };
if (stats.succeeded.length === 0) {
const reason = stats.failed[0]?.reason ? ` (${stats.failed[0].reason})` : '';
return {
top: `⚠️ **사이트 실접속 실패** — ${stats.urls.join(', ')} 의 본문을 가져오지 못했습니다${reason}. 아래 내용은 실제 사이트 확인 없이 일반 지식으로 작성된 추정입니다.\n\n`,
bottom: '',
};
}
return {
top: '',
bottom: `\n\n🌐 실접속 확인: ${stats.succeeded.join(', ')} 본문을 가져와 근거로 사용했습니다.${stats.failed.length ? ` (실패: ${stats.failed.map(f => f.url).join(', ')})` : ''}`,
};
}
function _successBlock(url: string, title: string, description: string, body: string, truncated: boolean): string {
return [
`[URL CONTENT — 실데이터 · ${url}]`,