diff --git a/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json b/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json index 1815cff..e7a009b 100644 --- a/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json +++ b/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json @@ -1,5 +1,5 @@ { "result": "직답 결과 — single-pass mock 응답입니다.", - "createdAt": 1786452145565, + "createdAt": 1786539817249, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json b/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json index 71499d4..f38903c 100644 --- a/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json +++ b/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json @@ -1,5 +1,5 @@ { - "result": "---\nid: wiki_on\ndate: 2026-08-11T12:42:25.565Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (18ms)\n", - "createdAt": 1786452145565, + "result": "---\nid: wiki_on\ndate: 2026-08-12T13:03:37.249Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (12ms)\n", + "createdAt": 1786539817249, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/engine/.astra/missions/wiki_on.json b/.astra/tests/engine/.astra/missions/wiki_on.json index ff49b19..6d3e7cb 100644 --- a/.astra/tests/engine/.astra/missions/wiki_on.json +++ b/.astra/tests/engine/.astra/missions/wiki_on.json @@ -1,8 +1,8 @@ { "missionId": "wiki_on", "status": "completed", - "startTime": "2026-08-11T12:42:25.546Z", - "totalElapsedMs": 19, + "startTime": "2026-08-12T13:03:37.237Z", + "totalElapsedMs": 13, "results": { "direct": "직답 결과 — single-pass mock 응답입니다." }, @@ -12,16 +12,16 @@ { "from": "idle", "to": "direct", - "durationMs": 18, + "durationMs": 12, "message": "답변 작성 중... (단일 호출 fast-path)", - "ts": "2026-08-11T12:42:25.564Z" + "ts": "2026-08-12T13:03:37.249Z" }, { "from": "direct", "to": "completed", "durationMs": 1, "message": "미션 완료", - "ts": "2026-08-11T12:42:25.565Z" + "ts": "2026-08-12T13:03:37.250Z" } ], "resilienceMetrics": { diff --git a/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json b/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json index ca5d255..f0db1ea 100644 --- a/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json +++ b/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json @@ -1,5 +1,5 @@ { "result": "Final report with inconsistencies. This should be long enough to pass validation.", - "createdAt": 1786452152471, + "createdAt": 1786539824316, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json b/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json index ca5d255..f0db1ea 100644 --- a/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json +++ b/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json @@ -1,5 +1,5 @@ { "result": "Final report with inconsistencies. This should be long enough to pass validation.", - "createdAt": 1786452152471, + "createdAt": 1786539824316, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json b/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json index f19383f..ca0b505 100644 --- a/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json +++ b/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json @@ -1,5 +1,5 @@ { "result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", - "createdAt": 1786452152459, + "createdAt": 1786539824310, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json b/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json index 6716c1e..6b502dd 100644 --- a/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json +++ b/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json @@ -1,5 +1,5 @@ { "result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", - "createdAt": 1786452152465, + "createdAt": 1786539824311, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/missions/stress_conflict_1786452152446.json b/.astra/tests/stress/.astra/missions/stress_conflict_1786539824291.json similarity index 78% rename from .astra/tests/stress/.astra/missions/stress_conflict_1786452152446.json rename to .astra/tests/stress/.astra/missions/stress_conflict_1786539824291.json index afe276c..0aeafe2 100644 --- a/.astra/tests/stress/.astra/missions/stress_conflict_1786452152446.json +++ b/.astra/tests/stress/.astra/missions/stress_conflict_1786539824291.json @@ -1,7 +1,7 @@ { - "missionId": "stress_conflict_1786452152446", + "missionId": "stress_conflict_1786539824291", "status": "completed", - "startTime": "2026-08-11T12:42:32.446Z", + "startTime": "2026-08-12T13:03:44.291Z", "totalElapsedMs": 25, "results": { "outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", @@ -16,28 +16,28 @@ "to": "outline", "durationMs": 12, "message": "답변 구조 잡는 중...", - "ts": "2026-08-11T12:42:32.458Z" + "ts": "2026-08-12T13:03:44.303Z" }, { "from": "outline", "to": "section", - "durationMs": 1, + "durationMs": 7, "message": "본문 작성 중...", - "ts": "2026-08-11T12:42:32.459Z" + "ts": "2026-08-12T13:03:44.310Z" }, { "from": "section", "to": "polish", - "durationMs": 6, + "durationMs": 1, "message": "최종 다듬기 중...", - "ts": "2026-08-11T12:42:32.465Z" + "ts": "2026-08-12T13:03:44.311Z" }, { "from": "polish", "to": "completed", - "durationMs": 6, + "durationMs": 5, "message": "미션 완료", - "ts": "2026-08-11T12:42:32.471Z" + "ts": "2026-08-12T13:03:44.316Z" } ], "resilienceMetrics": { diff --git a/PATCHNOTES.md b/PATCHNOTES.md index 6c4193d..24c86c7 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -1,5 +1,15 @@ # Astra Patch Notes +## v2.2.324 (2026-08-12) +### 🐛 `/naver-trend <분야> <기간>` 이 질문 원문을 통째로 키워드로 조회하던 버그 +실사례: `/naver-trend 경제 8/11~8/12` → 키워드 칸에 **"경제 8/11~8/12"** 가 그대로 박힌 ratio 0.0 짜리 1행 표. 기대한 건 그 기간에 경제 분야에서 많이 검색된 키워드 목록. +- **원인 1 — 질문 원문 폴백**: `parseSearchTrendIntent` 가 LLM 후보(`keywords`)가 비면 질문 원문을 키워드로 채웠다(`keywords.length > 0 ? keywords : [trimmed]`). 기간 표현까지 붙은 문자열이라 검색량이 잡힐 리 없다. 후보가 비면 **빈 채로 두고**, handlers 가 `topic` 기반 후보 생성으로 복구하도록 변경. 후보 필터(`isPlausibleKeyword`)에 기간 표현 패턴(`8/11~8/12`, `8월 11일`, `최근 일주일`, `지난 30일`) 차단을 추가하고, 질문 원문·기간 제거 잔여 텍스트와 일치하는 후보도 제거. +- **원인 2 — 분류 누락**: 프롬프트가 `<분야> <기간>` 형태를 어느 모드로도 안내하지 않아 LLM 이 contains(포함단어 없음) 로 찍고 → discover 로 강등되며 후보가 빈 채로 남았다. 프롬프트에 "분야명 + 기간만 있는 질문 = discover" 를 명시하고, **강등 시에도 `topic` 을 살려** 후보 생성에 쓰도록 수정. +- **contains 모드 일반화(`runContainsMode` → `runTopicMode`)**: 포함단어를 선택 인자로 바꿔 `<분야> <기간>` 도 같은 2단 구조(LLM 후보 생성 → 실제 API 검증·앵커 보정 랭킹)를 타게 했다. 분야명 자체는 후보에서 제외(상위 개념이라 늘 1위를 먹고 정보 가치 0). `generateContainingKeywords` → `generateKeywordCandidates(ai, topic, word?, count)` 로 통합, 포함단어 없을 때 전용 프롬프트 사용. +- **비교는 대상 2개 이상일 때만**: 키워드 1개짜리 `compare` 는 표가 늘 `100.0` 1행이라 무의미 — 그 검색어를 분야로 보고 인기 검색어를 뽑아준다. +- **후보 생성 실패 시 graceful degradation**: AI 가 죽어도 분야명 자체 추이는 보여주고 "후보 생성 실패" 를 명시. 결과가 전부 0 이고 조회 구간에 오늘이 포함되면 "데이터랩 당일 집계 미완" 안내를 붙인다. +검증: tsc 무오류 + jest 1055 통과(신규 12 — 실제 리포트 입력 `/naver-trend 경제 8/11~8/12` 를 슬래시 라우팅부터 끝까지 태우는 e2e 회귀 3건 포함) + esbuild 정상. + ## v2.2.323 (2026-08-11) ### 🔎 `/naver-trend` contains 모드 — "분야 + 포함단어 + 기간"으로 검색어 뽑기 사용자 요청: `/naver-trend 경제 방법 8월1일부터 오늘까지` 처럼 쓰면 경제 분야 인기 검색어 중 "방법"이 들어간 것을 검색량 상위 10개로 달라. 기존엔 `<카테고리> <기간>` 조회만 가능했다. diff --git a/package-lock.json b/package-lock.json index f1640d1..3cce21f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "astra", - "version": "2.2.323", + "version": "2.2.324", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "astra", - "version": "2.2.323", + "version": "2.2.324", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", diff --git a/package.json b/package.json index c99f743..7e2c185 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "astra", "displayName": "Astra", "description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.", - "version": "2.2.323", + "version": "2.2.324", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", diff --git a/src/features/naverTrend/dateRange.ts b/src/features/naverTrend/dateRange.ts index 8ccd7aa..3353006 100644 --- a/src/features/naverTrend/dateRange.ts +++ b/src/features/naverTrend/dateRange.ts @@ -47,6 +47,17 @@ function withInferredYear(month: number, day: number, today: Date): Date { return candidate; } +/** + * 원문에서 기간 표현을 걷어낸 나머지(= 분야/포함단어). matchedText 는 물결표를 + * 정규화한 텍스트 기준이라, 사용자가 '~'(전각) 을 썼으면 원문에 그대로는 없다 — + * 같은 정규화를 거친 뒤 지운다. + */ +export function stripDateExpression(text: string, range: ParsedDateRange | null): string { + const normalized = (text || '').replace(/[~〜∼]/g, '~'); + const stripped = range ? normalized.replace(range.matchedText, ' ') : normalized; + return stripped.replace(/\s+/g, ' ').trim(); +} + interface DateToken { index: number; length: number; diff --git a/src/features/naverTrend/handlers.ts b/src/features/naverTrend/handlers.ts index cd1c5af..dfb23bd 100644 --- a/src/features/naverTrend/handlers.ts +++ b/src/features/naverTrend/handlers.ts @@ -18,12 +18,12 @@ import * as vscode from 'vscode'; import { registerSlashCommand, chunk } from '../datacollect/slashRouter'; import { AIService } from '../../core/services'; import { - parseShoppingIntent, parseSearchTrendIntent, generateContainingKeywords, + parseShoppingIntent, parseSearchTrendIntent, generateKeywordCandidates, parseContainsShorthand, keywordContains, } from './intentParser'; import { findCategoryCode, listTopLevelCategoryNames, ROOT_CATEGORY_CODES, type CategoryMatch } from './categoryCodes'; import { rankCategories, rankKeywords, rankKeywordsAnchored, type RankedResult, type DateRange } from './rankEngine'; -import { parseKoreanDateRange, ymdLocal } from './dateRange'; +import { parseKoreanDateRange, stripDateExpression, ymdLocal } from './dateRange'; import type { CommonFilters, TimeUnit } from './naverApi'; type Webview = Parameters[0]; @@ -73,7 +73,7 @@ function formatRankedTable(result: RankedResult, itemLabel: string): string { return lines.join('\n') + '\n'; } -/** contains 모드 표 — 하루치 최신값이 아니라 조회 구간 평균으로 줄 세운다. */ +/** topic/contains 모드 표 — 하루치 최신값이 아니라 조회 구간 평균으로 줄 세운다. */ function formatContainsTable(items: RankedResult['items']): string { if (items.length === 0) return '_(결과 없음)_\n'; const lines: string[] = []; @@ -206,9 +206,10 @@ function cmdSearchTrendHelp(view: Webview): void { '', '**/naver-trend** — 네이버 검색어트렌드', '', - '- `/naver-trend 원피스,블라우스 비교` — 명시 키워드 비교', - '- `/naver-trend 20대 여성한테 인기있는 원피스 스타일 키워드 10개` — 탐색(후보 제안 + 검증)', + '- `/naver-trend 경제 8/11~8/12` — **`<분야> <기간>`**: 그 기간에 그 분야에서 많이 검색된 키워드 상위 10개', '- `/naver-trend 경제 방법 8월1일부터 오늘까지` — **`<분야> <포함단어> <기간>`**: 그 분야 인기 검색어 중 해당 단어가 들어간 것만 검색량 상위 10개', + '- `/naver-trend 원피스,블라우스 비교` — 명시 키워드 2개 이상 비교', + '- `/naver-trend 20대 여성한테 인기있는 원피스 스타일 키워드 10개` — 탐색(후보 제안 + 검증)', '', '기간 표현: `8월1일부터 오늘까지`, `8/1~8/10`, `최근 일주일`, `지난 30일` 등 (생략 시 최근 7일)', '', @@ -217,27 +218,37 @@ function cmdSearchTrendHelp(view: Webview): void { ].join('\n')); } +/** + * 기간 표현이 남아 있는 조각("경제 8/11~8/12", "경제 최근 일주일")을 걸러낸다 — + * 검색어로 조회하면 검색량 0 짜리 행만 만들고 표를 망친다. 실제 검색어를 잘못 + * 버리지 않도록 "날짜/기간처럼 보이는" 형태만 좁게 잡는다. + */ +const DATE_ISH = /~|\d\s*[/.]\s*\d|\d\s*월\s*\d|(?:최근|지난)\s*(?:\d|한|두|세|일주일|주|달|개월|년)/; + /** 후보로 쓸 만한 검색어인지 — 질문 원문이 통째로 후보에 섞여 들어오는 걸 막는다. */ -function isPlausibleKeyword(k: string): boolean { +export function isPlausibleKeyword(k: string): boolean { const t = k.trim(); - return t.length > 0 && t.length <= 25 && t.split(/\s+/).length <= 5; + if (!t || t.length > 25 || t.split(/\s+/).length > 5) return false; + return !DATE_ISH.test(t); } +const normKeyword = (s: string) => s.replace(/\s+/g, '').toLowerCase(); + /** - * `<분야> <포함단어> <기간>` — 그 분야의 인기 검색어 중 특정 단어가 들어간 것을 - * 검색량 상위 N개로. + * `<분야> [포함단어] <기간>` — 그 분야의 인기 검색어를 검색량 상위 N개로. + * `containsWord` 를 주면 그 단어가 들어간 검색어만 남긴다(contains 모드). * * 데이터랩엔 "분야 내 인기 검색어 목록" API 가 없어서(지정 키워드의 상대 지수만 * 반환) 후보 생성은 LLM, 검증·랭킹은 API 가 맡는 2단 구조다. 포함 여부는 LLM 을 * 믿지 않고 코드에서 다시 거른다. */ -async function runContainsMode( +async function runTopicMode( view: Webview, context: vscode.ExtensionContext, ai: AIService, opts: { topic: string; - containsWord: string; + containsWord?: string; limit: number; seeded: string[]; range: DateRange; @@ -246,19 +257,35 @@ async function runContainsMode( ): Promise { const { topic, containsWord, range, filters } = opts; const limit = Math.min(Math.max(opts.limit, 1), 20); + // 분야명 자체는 후보에서 뺀다 — 상위 개념이라 늘 1위를 먹고 정보가 없다. + const usable = (k: string) => isPlausibleKeyword(k) + && normKeyword(k) !== normKeyword(topic) + && (!containsWord || keywordContains(k, containsWord)); - let pool = [...new Set(opts.seeded.filter(k => isPlausibleKeyword(k) && keywordContains(k, containsWord)))]; + let pool = [...new Set(opts.seeded.filter(usable))]; // 후보가 얇으면 순위가 후보 운에 좌우된다 — 최소 limit*2 는 확보하고 검증에 들어간다. if (pool.length < limit * 2) { - chunk(view, `\n🧩 "${topic}" 분야에서 "${containsWord}" 들어간 검색어 후보 만드는 중…\n`); - const more = await generateContainingKeywords(ai, topic, containsWord, CONTAINS_POOL_MAX); - pool = [...new Set([...pool, ...more.filter(isPlausibleKeyword)])]; + chunk(view, containsWord + ? `\n🧩 "${topic}" 분야에서 "${containsWord}" 들어간 검색어 후보 만드는 중…\n` + : `\n🧩 "${topic}" 분야에서 많이 검색될 법한 검색어 후보 만드는 중…\n`); + const more = await generateKeywordCandidates(ai, topic, containsWord, CONTAINS_POOL_MAX); + pool = [...new Set([...pool, ...more.filter(usable)])]; } pool = pool.slice(0, CONTAINS_POOL_MAX); + // 후보 생성이 통째로 실패(AI 다운/파싱 실패)한 경우 — 그래도 분야명 자체의 + // 추이는 보여주고 무슨 일이 있었는지 밝힌다. + let degraded = false; if (pool.length === 0) { - chunk(view, `\n❓ "${topic}" 분야에서 "${containsWord}" 가 들어간 검색어 후보를 만들지 못했습니다. 분야를 더 구체적으로(예: "주식 투자") 적어보세요.\n`); - return true; + if (!containsWord && isPlausibleKeyword(topic)) { + pool = [topic]; + degraded = true; + } else { + chunk(view, containsWord + ? `\n❓ "${topic}" 분야에서 "${containsWord}" 가 들어간 검색어 후보를 만들지 못했습니다. 분야를 더 구체적으로(예: "주식 투자") 적어보세요.\n` + : `\n❓ "${topic}" 분야의 검색어 후보를 만들지 못했습니다. 분야를 더 구체적으로(예: "주식 투자") 적어보세요.\n`); + return true; + } } chunk(view, `\n📊 조회 중 — 후보 ${pool.length}개를 네이버 검색어트렌드로 검증 (${range.startDate} ~ ${range.endDate})…\n`); @@ -266,11 +293,24 @@ async function runContainsMode( const ranked = result.items.filter(it => it.avgRatio > 0).slice(0, limit); const dropped = result.items.length - result.items.filter(it => it.avgRatio > 0).length; - chunk(view, `\n**네이버 검색어트렌드 — "${topic}" 분야에서 "${containsWord}" 포함 검색어 TOP ${ranked.length}** (${range.startDate}~${range.endDate})\n\n`); + const title = containsWord + ? `**네이버 검색어트렌드 — "${topic}" 분야에서 "${containsWord}" 포함 검색어 TOP ${ranked.length}**` + : `**네이버 검색어트렌드 — "${topic}" 분야 인기 검색어 TOP ${ranked.length}**`; + chunk(view, `\n${title} (${range.startDate}~${range.endDate})\n\n`); chunk(view, formatContainsTable(ranked)); + // 데이터랩은 당일(때로는 전일) 집계가 아직 안 나온다 — 짧고 최근인 구간을 + // 물으면 후보가 멀쩡해도 전부 0 이 나올 수 있어 원인을 짚어준다. + if (ranked.length === 0 && range.endDate >= ymd(new Date())) { + chunk(view, '\n> ℹ️ 조회 구간에 오늘이 포함돼 있습니다 — 네이버 데이터랩은 당일 데이터가 아직 집계되지 않을 수 있으니 종료일을 어제로 잡아 다시 시도해 보세요.\n'); + } + if (degraded) chunk(view, `\n> ⚠️ 검색어 후보 생성에 실패해(AI 응답 없음) "${topic}" 자체만 조회했습니다. 잠시 후 다시 시도해 보세요.\n`); if (dropped > 0) chunk(view, `\n> ℹ️ 검색량이 잡히지 않은 후보 ${dropped}개는 제외했습니다.\n`); chunk(view, formatErrors(result.errors)); - chunk(view, `\nℹ️ 네이버는 "분야 안의 인기 검색어 목록"을 주는 API가 없어, "${containsWord}" 가 들어간 검색어 후보 ${pool.length}개를 만든 뒤 실제 검색어트렌드로 검증해 줄 세웠습니다 — 후보에 없던 검색어는 순위에 나올 수 없습니다.\n`); + if (!degraded) { + chunk(view, containsWord + ? `\nℹ️ 네이버는 "분야 안의 인기 검색어 목록"을 주는 API가 없어, "${containsWord}" 가 들어간 검색어 후보 ${pool.length}개를 만든 뒤 실제 검색어트렌드로 검증해 줄 세웠습니다 — 후보에 없던 검색어는 순위에 나올 수 없습니다.\n` + : `\nℹ️ 네이버는 "분야 안의 인기 검색어 목록"을 주는 API가 없어, "${topic}" 분야에서 검색될 법한 후보 ${pool.length}개를 만든 뒤 실제 검색어트렌드로 검증해 줄 세웠습니다 — 후보에 없던 검색어는 순위에 나올 수 없습니다.\n`); + } chunk(view, `\n${DISCLAIMER}\n`); return true; } @@ -302,6 +342,10 @@ async function runSearchTrend(arg: string, view: Webview, context?: vscode.Exten }; const filters = buildFilters(intent); + // 기간 표현을 걷어낸 나머지 = 사용자가 말한 분야/키워드. LLM 이 topic 을 + // 못 뽑았을 때의 기준값이자, 질문 원문이 키워드로 새는 걸 막는 필터다. + const residual = stripDateExpression(trimmed, preRange); + // `<분야> <포함단어> <기간>` — LLM 판정이 실패했을 때만 문법 규칙으로 건진다. // 성공했는데 compare 로 봤다면 존중한다("원피스 블라우스 최근 일주일" 처럼 // 두 낱말이 비교 대상인 경우와 구분할 근거가 문장 안에는 없기 때문). @@ -309,7 +353,7 @@ async function runSearchTrend(arg: string, view: Webview, context?: vscode.Exten let topic = intent.topic; let containsWord = intent.containsWord; if (mode !== 'contains' && !intent.parsed && preRange) { - const shorthand = parseContainsShorthand(trimmed.replace(preRange.matchedText, ' ')); + const shorthand = parseContainsShorthand(residual); if (shorthand) { mode = 'contains'; topic = shorthand.topic; @@ -317,24 +361,42 @@ async function runSearchTrend(arg: string, view: Webview, context?: vscode.Exten } } - if (mode === 'contains' && containsWord) { - return await runContainsMode(view, context, ai, { - topic: topic || trimmed, + // 질문 원문·기간 표현이 후보로 새어 들어오는 걸 막는다 — 이게 새면 + // "경제 8/11~8/12" 같은 검색량 0 짜리 1행 표가 나온다. + const candidates = [...new Set( + intent.keywords.filter(k => isPlausibleKeyword(k) && k.trim() !== trimmed && k.trim() !== residual), + )]; + const topicText = topic || residual; + + if (mode === 'contains' && containsWord && topicText) { + return await runTopicMode(view, context, ai, { + topic: topicText, containsWord, limit: intent.limit ?? 10, - seeded: intent.parsed ? intent.keywords : [], + seeded: candidates, range, filters, }); } - chunk(view, `\n📊 조회 중 — ${intent.keywords.join(', ')} (${range.startDate} ~ ${range.endDate})…\n`); - const result = await rankKeywords(context, intent.keywords, range, filters); + // 비교는 대상이 둘 이상일 때만 성립한다. 그 외(분야만 말한 discover, + // LLM 이 후보를 못 준 경우, 키워드 하나뿐이라 표가 늘 100.0 1행인 경우)는 + // 그 분야의 인기 검색어를 뽑아주는 게 사용자가 기대하는 답이다. + if (mode !== 'compare' || candidates.length < 2) { + if (!topicText) { cmdSearchTrendHelp(view); return true; } + return await runTopicMode(view, context, ai, { + topic: topicText, + limit: intent.limit ?? 10, + seeded: candidates, + range, + filters, + }); + } - const title = intent.mode === 'discover' - ? `**네이버 검색어트렌드 — "${trimmed}" 관련 키워드 TOP ${result.items.length}**` - : `**네이버 검색어트렌드 — 키워드 비교**`; - chunk(view, `\n${title} (${range.startDate}~${range.endDate})\n\n`); + chunk(view, `\n📊 조회 중 — ${candidates.join(', ')} (${range.startDate} ~ ${range.endDate})…\n`); + const result = await rankKeywords(context, candidates, range, filters); + + chunk(view, `\n**네이버 검색어트렌드 — 키워드 비교** (${range.startDate}~${range.endDate})\n\n`); chunk(view, formatRankedTable(result, '키워드')); chunk(view, formatErrors(result.errors)); chunk(view, `\n${DISCLAIMER}\n`); diff --git a/src/features/naverTrend/intentParser.ts b/src/features/naverTrend/intentParser.ts index 22e16aa..1693849 100644 --- a/src/features/naverTrend/intentParser.ts +++ b/src/features/naverTrend/intentParser.ts @@ -54,7 +54,7 @@ export interface ShoppingIntent { export interface SearchTrendIntent { mode: SearchTrendMode; keywords: string[]; - /** mode='contains' 전용 — 검색어를 찾을 분야/주제 (예: "경제"). */ + /** mode='contains'|'discover' — 검색어를 찾을 분야/주제 (예: "경제"). */ topic?: string; /** mode='contains' 전용 — 검색어에 반드시 들어가야 하는 단어 (예: "방법"). */ containsWord?: string; @@ -223,15 +223,15 @@ export async function parseShoppingIntent( const SEARCH_TREND_SYSTEM_PROMPT = `당신은 네이버 검색어트렌드 API 질의 파서입니다. 사용자의 자연어 질문을 분석해 아래 JSON 하나만 출력하세요. 다른 텍스트(설명, 코드펜스, 머리말) 절대 금지. - "mode": 셋 중 하나. - · "compare" — 사용자가 구체적인 검색어를 하나 이상 지목해 비교/조회를 원함. - · "discover" — "인기 검색어"/"요즘 뜨는 키워드"/"top N" 처럼 키워드를 지목하지 않고 탐색을 원함. - · "contains" — "<분야> <단어> <기간>" 형태로, **그 분야의 인기 검색어 중 특정 단어가 들어간 것**을 원함. 예: "경제 방법 8월1일부터 오늘까지"(경제 분야에서 '방법'이 들어간 검색어), "여행 추천 최근 한달", "부동산 투자 방법 알려줘". 분야명과 포함 단어 두 덩어리가 나란히 오면 대부분 이 모드다. -- "topic": mode="contains"일 때 분야/주제 (예: "경제"). 그 외에는 "". -- "containsWord": mode="contains"일 때 검색어에 반드시 들어가야 하는 단어 (예: "방법"). 그 외에는 "". 사용자가 쓴 형태 그대로, 조사(을/를/이/가/은/는) 없이. + · "compare" — 사용자가 **구체적인 검색어를 둘 이상** 지목해 서로 비교하길 원함. 예: "원피스랑 블라우스 비교", "아이폰17 vs 갤럭시S26". + · "discover" — 개별 검색어를 지목하지 않고 **분야/주제만** 말함. "인기 검색어"/"요즘 뜨는 키워드"/"top N" 뿐 아니라, **"<분야> <기간>" 처럼 분야명과 기간만 있는 질문도 전부 여기**다 — 예: "경제 8/11~8/12", "여행 최근 일주일", "부동산 지난 30일"(= 그 기간에 그 분야에서 많이 검색된 키워드를 뽑아달라는 뜻). 검색어를 하나만 말한 경우도 그 검색어를 분야로 보고 discover 로 처리. + · "contains" — "<분야> <단어> <기간>" 형태로, **그 분야의 인기 검색어 중 특정 단어가 들어간 것**을 원함. 예: "경제 방법 8월1일부터 오늘까지"(경제 분야에서 '방법'이 들어간 검색어), "여행 추천 최근 한달", "부동산 투자 방법 알려줘". 기간 표현을 뺀 나머지가 분야명과 포함 단어 두 덩어리면 대부분 이 모드다. +- "topic": mode="discover"|"contains" 일 때 분야/주제 (예: "경제"). **기간 표현("8/11~8/12", "최근 일주일")은 절대 넣지 말 것.** mode="compare" 면 "". +- "containsWord": mode="contains"일 때 검색어에 반드시 들어가야 하는 단어 (예: "방법"). 그 외에는 "". 사용자가 쓴 형태 그대로, 조사(을/를/이/가/은/는) 없이. 기간 표현은 포함 단어가 아니다. - "limit": 사용자가 "10개"처럼 개수를 밝혔으면 그 숫자, 아니면 0. -- "keywords": +- "keywords": **어떤 모드에서도 빈 배열로 두지 말 것. 질문 원문이나 기간 표현을 그대로 넣는 것도 금지.** · mode="compare" — 사용자가 언급한 검색어를 그대로 배열로. - · mode="discover" — 질문 맥락(주제/카테고리/타겟)에 맞는 검색어 후보를 당신의 지식으로 최대 10개 제안. + · mode="discover" — topic 분야에서 그 기간에 실제로 많이 검색될 법한 검색어를 당신의 지식으로 최대 20개 제안(분야명 자체를 그대로 넣지 말 것 — 예: topic="경제" → "금리 인상", "환율 전망", "코스피 전망", "부동산 시세", "적금 금리" …). · mode="contains" — topic 분야에서 실제로 많이 검색될 법하면서 **containsWord 를 글자 그대로 포함하는** 검색어를 최대 30개 제안(예: topic="경제", containsWord="방법" → "재테크 방법", "돈 모으는 방법", "주식 투자 방법", "절세 방법" …). containsWord 가 안 들어간 후보는 버려지니 넣지 말 것. containsWord 한 단어만 덜렁 넣는 것도 금지. ⚠️ 질문이 특정 상품군(가전/패션/뷰티 등)을 가리키면 그 상위 카테고리명 자체를 반복하지 말고 **구체적인 상품/모델명**(브랜드+제품라인명, 예: "맥북 에어 M3", "다이슨 에어랩")을 우선 제안 — 일반 명사만 나열하면 사용자가 얻을 정보가 없다. 여러 브랜드를 섞을 것. ${COMMON_FIELDS_PROMPT} @@ -292,12 +292,15 @@ export async function parseSearchTrendIntent( const containsWord = _asWord(obj.containsWord); const keywords = _asStringArray(obj.keywords); // contains 모드에서 포함 단어가 비면 성립하지 않는 요청 — discover 로 강등해 - // 최소한 후보 랭킹은 보여준다. + // 최소한 후보 랭킹은 보여준다(topic 은 그대로 살려 후보 생성에 쓴다). const effectiveMode: SearchTrendMode = mode === 'contains' && !containsWord ? 'discover' : mode; return { mode: effectiveMode, - keywords: keywords.length > 0 ? keywords : [trimmed], - topic: effectiveMode === 'contains' ? (_asWord(obj.topic) || trimmed) : undefined, + // 후보가 비어도 질문 원문으로 메우지 않는다 — 기간 표현까지 붙은 원문을 + // 키워드로 조회하면 "경제 8/11~8/12" 같은 검색량 0 짜리 1행 표가 나온다. + // 후보 없음은 handlers 가 topic 기반 후보 생성으로 복구한다. + keywords, + topic: effectiveMode === 'compare' ? undefined : _asWord(obj.topic), containsWord: effectiveMode === 'contains' ? containsWord : undefined, limit: _asLimit(obj.limit), startDate: _asDate(obj.startDate), @@ -310,7 +313,7 @@ export async function parseSearchTrendIntent( }; } -// ── contains 모드 후보 보충 ─────────────────────────────────────────────── +// ── 후보 보충 ───────────────────────────────────────────────────────────── const CONTAINS_SYSTEM_PROMPT = `당신은 네이버 검색어 후보 생성기입니다. 주어진 분야에서 실제 사람들이 네이버에 많이 칠 법한 검색어를, **지정된 단어를 글자 그대로 포함하도록** 만들어 JSON 하나만 출력하세요. 다른 텍스트 절대 금지. @@ -323,27 +326,40 @@ const CONTAINS_SYSTEM_PROMPT = `당신은 네이버 검색어 후보 생성기 ⚠️ 반드시 아래 JSON 형식 정확히 한 번: {"keywords":["...","..."]}`; +const TOPIC_SYSTEM_PROMPT = `당신은 네이버 검색어 후보 생성기입니다. 주어진 분야에서 실제 사람들이 네이버에 많이 칠 법한 검색어를 만들어 JSON 하나만 출력하세요. 다른 텍스트 절대 금지. + +규칙: +- 그 분야에서 검색량이 실제로 나올 법한 구체적·대중적인 검색어. 지나치게 길거나(8어절+) 문장형인 것은 배제. +- **분야명 자체나 그 상위 개념을 그대로 쓰지 말 것** — 예: 분야가 "경제"면 "경제"/"경제뉴스" 말고 "금리 인상", "환율 전망", "코스피 전망", "청약 통장", "연말정산 환급" 처럼 실제로 검색될 구문. +- 상품군이면 브랜드+모델/제품라인명(예: "맥북 에어 M3", "다이슨 에어랩") 위주로, 여러 브랜드를 섞을 것. +- 서로 다른 세부 주제를 폭넓게 커버할 것(같은 말 반복 금지). +- 기간 표현("8/11~8/12", "최근 일주일")이나 질문 문장은 절대 후보로 넣지 말 것. + +⚠️ 반드시 아래 JSON 형식 정확히 한 번: +{"keywords":["...","..."]}`; + /** - * "<분야>에서 <단어>가 들어간 검색어" 후보를 LLM 으로 생성. + * "<분야>의 인기 검색어" 후보를 LLM 으로 생성. `word` 를 주면 그 단어를 글자 그대로 + * 포함하는 후보만 만든다(contains 모드). * * parseSearchTrendIntent 가 이미 후보를 주지만(같은 호출에서 뽑음) 개수가 모자라거나 - * 포함 단어 필터에 많이 걸러진 경우 handlers 가 이 함수로 한 번 더 채운다. - * 반환 전 포함 검사를 다시 하므로 호출자는 그대로 써도 된다. + * 필터에 많이 걸러진 경우 handlers 가 이 함수로 한 번 더 채운다. 반환 전 포함 검사를 + * 다시 하므로 호출자는 그대로 써도 된다. */ -export async function generateContainingKeywords( +export async function generateKeywordCandidates( ai: IAIService, topic: string, - word: string, + word: string | undefined, count: number, options: { model?: string; timeoutMs?: number } = {}, ): Promise { let raw = ''; try { const result = await ai.chat({ - system: CONTAINS_SYSTEM_PROMPT, + system: word ? CONTAINS_SYSTEM_PROMPT : TOPIC_SYSTEM_PROMPT, user: [ `분야/주제: ${topic}`, - `반드시 포함할 단어: ${word}`, + ...(word ? [`반드시 포함할 단어: ${word}`] : []), `개수: ${count}개`, '', 'JSON만 출력:', @@ -353,15 +369,15 @@ export async function generateContainingKeywords( }); raw = result.content || ''; } catch (e: any) { - logError('naverTrend generateContainingKeywords: AI 호출 실패.', { error: e?.message ?? String(e) }); + logError('naverTrend generateKeywordCandidates: AI 호출 실패.', { error: e?.message ?? String(e) }); return []; } const obj = _parseJsonLoose(raw); if (!obj) { - logInfo('naverTrend generateContainingKeywords: 파싱 실패.', { rawHead: raw.slice(0, 100) }); + logInfo('naverTrend generateKeywordCandidates: 파싱 실패.', { rawHead: raw.slice(0, 100) }); return []; } - return _asStringArray(obj.keywords).filter(k => keywordContains(k, word)); + return _asStringArray(obj.keywords).filter(k => !word || keywordContains(k, word)); } /** diff --git a/tests/naverTrend.test.ts b/tests/naverTrend.test.ts index 80af310..dff5537 100644 --- a/tests/naverTrend.test.ts +++ b/tests/naverTrend.test.ts @@ -8,12 +8,26 @@ jest.mock('../src/features/naverTrend/naverApi', () => ({ fetchShoppingCategoryTrend: jest.fn(), })); +/** handlers.ts 가 `new AIService()` 로 직접 만들어 쓰므로 모듈째 교체 — 큐에서 순서대로 꺼내 응답. */ +const mockAiResponses: string[] = []; +jest.mock('../src/core/services', () => ({ + AIService: class { + async call() { return mockAiResponses.shift() ?? ''; } + async chat() { + const content = mockAiResponses.shift() ?? ''; + return { content, engine: 'mock', model: 'mock', empty: !content }; + } + }, +})); + import { findCategoryCode, ROOT_CATEGORY_CODES } from '../src/features/naverTrend/categoryCodes'; import { - parseShoppingIntent, parseSearchTrendIntent, generateContainingKeywords, + parseShoppingIntent, parseSearchTrendIntent, generateKeywordCandidates, parseContainsShorthand, keywordContains, } from '../src/features/naverTrend/intentParser'; -import { parseKoreanDateRange, ymdLocal } from '../src/features/naverTrend/dateRange'; +import { parseKoreanDateRange, stripDateExpression, ymdLocal } from '../src/features/naverTrend/dateRange'; +import { isPlausibleKeyword } from '../src/features/naverTrend/handlers'; +import { listSlashCommands } from '../src/features/datacollect/slashRouter'; import { rankKeywordsAnchored } from '../src/features/naverTrend/rankEngine'; import { fetchSearchTrend } from '../src/features/naverTrend/naverApi'; import type { IAIService, AIChatResult } from '../src/core/services'; @@ -228,18 +242,145 @@ describe('contains 모드 — 분야 + 포함단어', () => { expect(r.containsWord).toBeUndefined(); }); - test('generateContainingKeywords — 포함 단어가 빠진 후보는 코드에서 걸러낸다', async () => { + test('generateKeywordCandidates — 포함 단어가 빠진 후보는 코드에서 걸러낸다', async () => { const ai = mockAi('{"keywords":["재테크 방법","주식 투자","절세 방법"]}'); - const out = await generateContainingKeywords(ai, '경제', '방법', 30); + const out = await generateKeywordCandidates(ai, '경제', '방법', 30); expect(out).toEqual(['재테크 방법', '절세 방법']); }); - test('generateContainingKeywords — AI 실패해도 예외 전파 없이 빈 배열', async () => { - const out = await generateContainingKeywords(mockAi('', true), '경제', '방법', 30); + test('generateKeywordCandidates — 포함 단어가 없으면(분야만) 필터 없이 전부 후보', async () => { + const ai = mockAi('{"keywords":["금리 인상","환율 전망","코스피 전망"]}'); + const out = await generateKeywordCandidates(ai, '경제', undefined, 30); + expect(out).toEqual(['금리 인상', '환율 전망', '코스피 전망']); + }); + + test('generateKeywordCandidates — AI 실패해도 예외 전파 없이 빈 배열', async () => { + const out = await generateKeywordCandidates(mockAi('', true), '경제', '방법', 30); expect(out).toEqual([]); }); }); +// v2.2.324 회귀: `/naver-trend 경제 8/11~8/12` 가 "경제 8/11~8/12" 를 통째로 키워드로 +// 조회해 ratio 0.0 짜리 1행 표를 내던 문제. 원인은 두 갈래 — (1) LLM 이 후보를 못 주면 +// 질문 원문을 키워드로 쓰던 폴백, (2) "<분야> <기간>" 질문이 분야 탐색으로 분류되지 +// 않던 것. +describe('“<분야> <기간>” 질문 — 질문 원문이 키워드로 새지 않는다', () => { + test('후보가 비어도 질문 원문으로 메우지 않는다(handlers 가 topic 으로 복구)', async () => { + const ai = mockAi('{"mode":"discover","topic":"경제","containsWord":"","limit":0,"keywords":[],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}'); + const r = await parseSearchTrendIntent(ai, '경제 8/11~8/12', '2026-08-12'); + expect(r.mode).toBe('discover'); + expect(r.keywords).toEqual([]); + expect(r.topic).toBe('경제'); + }); + + test('contains 강등(포함 단어 없음) 시에도 topic 은 살아남는다', async () => { + const ai = mockAi('{"mode":"contains","topic":"경제","containsWord":"","limit":0,"keywords":[],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}'); + const r = await parseSearchTrendIntent(ai, '경제 8/11~8/12', '2026-08-12'); + expect(r.mode).toBe('discover'); + expect(r.containsWord).toBeUndefined(); + expect(r.topic).toBe('경제'); + }); + + test('stripDateExpression — 기간 표현만 걷어내고 분야만 남긴다', () => { + const today = new Date(2026, 7, 12); + const text = '경제 8/11~8/12'; + expect(stripDateExpression(text, parseKoreanDateRange(text, today))).toBe('경제'); + }); + + test('stripDateExpression — 전각 물결표(~)로 써도 걷어낸다', () => { + const today = new Date(2026, 7, 12); + const text = '경제 8/11~8/12'; + expect(stripDateExpression(text, parseKoreanDateRange(text, today))).toBe('경제'); + }); + + test('stripDateExpression — 기간 표현이 없으면 원문 그대로(공백만 정리)', () => { + expect(stripDateExpression(' 경제 트렌드 ', null)).toBe('경제 트렌드'); + }); + + test('isPlausibleKeyword — 기간 표현이 붙은 조각은 검색어 후보가 아니다', () => { + expect(isPlausibleKeyword('경제 8/11~8/12')).toBe(false); + expect(isPlausibleKeyword('경제 8월 11일')).toBe(false); + expect(isPlausibleKeyword('경제 최근 일주일')).toBe(false); + expect(isPlausibleKeyword('경제 지난 30일')).toBe(false); + }); + + test('isPlausibleKeyword — 숫자가 든 실제 검색어는 살린다', () => { + expect(isPlausibleKeyword('금리 인상')).toBe(true); + expect(isPlausibleKeyword('아이폰 17 프로')).toBe(true); + expect(isPlausibleKeyword('맥북 에어 M3')).toBe(true); + expect(isPlausibleKeyword('갤럭시 Z 플립7')).toBe(true); + }); +}); + +// 라우팅까지 포함한 end-to-end — 실제 리포트된 입력(`/naver-trend 경제 8/11~8/12`)이 +// 표에 질문 원문 대신 진짜 검색어를 채우는지 고정한다. 유닛 테스트만으로는 handlers +// 의 모드 분기(이번 버그의 실제 발생 지점)를 못 잡는다. +describe('/naver-trend 라우팅 — "경제 8/11~8/12"', () => { + const mockFetch = fetchSearchTrend as unknown as jest.Mock; + const runNaverTrend = async (arg: string): Promise => { + const def = listSlashCommands().find(c => c.name === '/naver-trend'); + expect(def).toBeDefined(); + const out: string[] = []; + const view = { postMessage: (m: any) => { out.push(String(m?.value ?? '')); } }; + await def!.handler(arg, view as any, {} as any); + return out.join(''); + }; + + beforeEach(() => { + mockAiResponses.length = 0; + mockFetch.mockReset(); + mockFetch.mockImplementation(async (_ctx: unknown, body: any) => ({ + ok: true, + data: { + startDate: body.startDate, endDate: body.endDate, timeUnit: body.timeUnit, + results: body.keywordGroups.map((g: any, i: number) => ({ + title: g.groupName, + keywords: g.keywords, + data: [{ period: body.startDate, ratio: 100 - i * 10 }], + })), + }, + })); + }); + + test('분야 + 기간 → 그 분야 인기 검색어 표(질문 원문이 키워드로 들어가지 않는다)', async () => { + // 1) intent 판정: 분야만 잡히고 후보는 비어서 돌아온 실제 실패 형태. + mockAiResponses.push('{"mode":"discover","topic":"경제","containsWord":"","limit":0,"keywords":[],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}'); + // 2) 후보 보충. + mockAiResponses.push('{"keywords":["금리 인상","환율 전망","코스피 전망","적금 금리","연말정산 환급"]}'); + + const out = await runNaverTrend('경제 8/11~8/12'); + + expect(out).toContain('금리 인상'); + expect(out).toContain('"경제" 분야 인기 검색어 TOP'); + // 회귀 지점: 질문 원문이 키워드 칸에 들어가면 안 된다. + expect(out).not.toContain('| 경제 8/11~8/12 |'); + const queried = mockFetch.mock.calls.flatMap((c: any[]) => c[1].keywordGroups.map((g: any) => g.groupName)); + expect(queried).not.toContain('경제 8/11~8/12'); + expect(queried).toContain('환율 전망'); + // 기간은 규칙 파서가 확정한 값 그대로. + expect(mockFetch.mock.calls[0][1]).toMatchObject({ startDate: '2026-08-11', endDate: '2026-08-12' }); + }); + + test('AI 가 죽어도 질문 원문을 조회하지 않는다 — 분야명만 조회하고 실패를 밝힌다', async () => { + // 응답 큐가 비어 있음 = 두 번의 AI 호출이 모두 빈 응답. + const out = await runNaverTrend('경제 8/11~8/12'); + + const queried = mockFetch.mock.calls.flatMap((c: any[]) => c[1].keywordGroups.map((g: any) => g.groupName)); + expect(queried).toEqual(['경제']); + expect(out).toContain('검색어 후보 생성에 실패'); + }); + + test('키워드 2개 이상 비교는 그대로 비교 표', async () => { + mockAiResponses.push('{"mode":"compare","topic":"","containsWord":"","limit":0,"keywords":["원피스","블라우스"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}'); + + const out = await runNaverTrend('원피스랑 블라우스 비교 8/11~8/12'); + + expect(out).toContain('키워드 비교'); + const queried = mockFetch.mock.calls.flatMap((c: any[]) => c[1].keywordGroups.map((g: any) => g.groupName)); + expect(queried).toEqual(['원피스', '블라우스']); + }); +}); + // 검색어트렌드 ratio 는 "요청 하나" 안에서 최댓값을 100 으로 두는 상대 지수라, // 5개씩 끊어 호출하면 배치마다 기준이 달라진다. 후보 30개를 검색량순으로 줄 // 세우려면 배치마다 공통 앵커를 끼워 되돌려야 한다는 걸 고정한다.