v2.2.324: /naver-trend "<분야> <기간>" 질문이 원문을 통째로 키워드로 조회하던 버그 수정

실사례: `/naver-trend 경제 8/11~8/12` → 키워드 칸에 "경제 8/11~8/12" 가 그대로
박힌 ratio 0.0 짜리 1행 표. 기대는 그 기간에 경제 분야에서 많이 검색된 키워드 목록.

원인 1 — 질문 원문 폴백: parseSearchTrendIntent 가 LLM 후보(keywords)가 비면
질문 원문을 키워드로 채웠다. 기간 표현까지 붙은 문자열이라 검색량이 잡힐 리 없다.
후보가 비면 빈 채로 두고, handlers 가 topic 기반 후보 생성으로 복구하게 변경.
isPlausibleKeyword 에 기간 표현 차단(8/11~8/12, 8월 11일, 최근 일주일, 지난 30일)
추가 + 질문 원문·기간 제거 잔여 텍스트와 일치하는 후보 제거.

원인 2 — 분류 누락: 프롬프트가 "<분야> <기간>" 형태를 어느 모드로도 안내하지
않아 LLM 이 contains(포함단어 없음)로 찍고 discover 로 강등되며 topic 까지 버려졌다.
"분야명 + 기간만 있는 질문 = discover" 를 프롬프트에 명시하고, 강등 시에도 topic 을
살려 후보 생성에 쓴다.

- 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 — 슬래시 라우팅부터 끝까지 태우는
e2e 회귀 3건 포함) + esbuild/vsce 패키징 정상.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 22:04:06 +09:00
co-authored by Claude Opus 5
parent 0b315b9c6a
commit b73f1f48b3
15 changed files with 322 additions and 82 deletions
@@ -1,5 +1,5 @@
{ {
"result": "직답 결과 — single-pass mock 응답입니다.", "result": "직답 결과 — single-pass mock 응답입니다.",
"createdAt": 1786452145565, "createdAt": 1786539817249,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -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", "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": 1786452145565, "createdAt": 1786539817249,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,8 +1,8 @@
{ {
"missionId": "wiki_on", "missionId": "wiki_on",
"status": "completed", "status": "completed",
"startTime": "2026-08-11T12:42:25.546Z", "startTime": "2026-08-12T13:03:37.237Z",
"totalElapsedMs": 19, "totalElapsedMs": 13,
"results": { "results": {
"direct": "직답 결과 — single-pass mock 응답입니다." "direct": "직답 결과 — single-pass mock 응답입니다."
}, },
@@ -12,16 +12,16 @@
{ {
"from": "idle", "from": "idle",
"to": "direct", "to": "direct",
"durationMs": 18, "durationMs": 12,
"message": "답변 작성 중... (단일 호출 fast-path)", "message": "답변 작성 중... (단일 호출 fast-path)",
"ts": "2026-08-11T12:42:25.564Z" "ts": "2026-08-12T13:03:37.249Z"
}, },
{ {
"from": "direct", "from": "direct",
"to": "completed", "to": "completed",
"durationMs": 1, "durationMs": 1,
"message": "미션 완료", "message": "미션 완료",
"ts": "2026-08-11T12:42:25.565Z" "ts": "2026-08-12T13:03:37.250Z"
} }
], ],
"resilienceMetrics": { "resilienceMetrics": {
@@ -1,5 +1,5 @@
{ {
"result": "Final report with inconsistencies. This should be long enough to pass validation.", "result": "Final report with inconsistencies. This should be long enough to pass validation.",
"createdAt": 1786452152471, "createdAt": 1786539824316,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,5 +1,5 @@
{ {
"result": "Final report with inconsistencies. This should be long enough to pass validation.", "result": "Final report with inconsistencies. This should be long enough to pass validation.",
"createdAt": 1786452152471, "createdAt": 1786539824316,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,5 +1,5 @@
{ {
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", "result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
"createdAt": 1786452152459, "createdAt": 1786539824310,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,5 +1,5 @@
{ {
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", "result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
"createdAt": 1786452152465, "createdAt": 1786539824311,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,7 +1,7 @@
{ {
"missionId": "stress_conflict_1786452152446", "missionId": "stress_conflict_1786539824291",
"status": "completed", "status": "completed",
"startTime": "2026-08-11T12:42:32.446Z", "startTime": "2026-08-12T13:03:44.291Z",
"totalElapsedMs": 25, "totalElapsedMs": 25,
"results": { "results": {
"outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", "outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
@@ -16,28 +16,28 @@
"to": "outline", "to": "outline",
"durationMs": 12, "durationMs": 12,
"message": "답변 구조 잡는 중...", "message": "답변 구조 잡는 중...",
"ts": "2026-08-11T12:42:32.458Z" "ts": "2026-08-12T13:03:44.303Z"
}, },
{ {
"from": "outline", "from": "outline",
"to": "section", "to": "section",
"durationMs": 1, "durationMs": 7,
"message": "본문 작성 중...", "message": "본문 작성 중...",
"ts": "2026-08-11T12:42:32.459Z" "ts": "2026-08-12T13:03:44.310Z"
}, },
{ {
"from": "section", "from": "section",
"to": "polish", "to": "polish",
"durationMs": 6, "durationMs": 1,
"message": "최종 다듬기 중...", "message": "최종 다듬기 중...",
"ts": "2026-08-11T12:42:32.465Z" "ts": "2026-08-12T13:03:44.311Z"
}, },
{ {
"from": "polish", "from": "polish",
"to": "completed", "to": "completed",
"durationMs": 6, "durationMs": 5,
"message": "미션 완료", "message": "미션 완료",
"ts": "2026-08-11T12:42:32.471Z" "ts": "2026-08-12T13:03:44.316Z"
} }
], ],
"resilienceMetrics": { "resilienceMetrics": {
+10
View File
@@ -1,5 +1,15 @@
# Astra Patch Notes # 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) ## v2.2.323 (2026-08-11)
### 🔎 `/naver-trend` contains 모드 — "분야 + 포함단어 + 기간"으로 검색어 뽑기 ### 🔎 `/naver-trend` contains 모드 — "분야 + 포함단어 + 기간"으로 검색어 뽑기
사용자 요청: `/naver-trend 경제 방법 8월1일부터 오늘까지` 처럼 쓰면 경제 분야 인기 검색어 중 "방법"이 들어간 것을 검색량 상위 10개로 달라. 기존엔 `<카테고리> <기간>` 조회만 가능했다. 사용자 요청: `/naver-trend 경제 방법 8월1일부터 오늘까지` 처럼 쓰면 경제 분야 인기 검색어 중 "방법"이 들어간 것을 검색량 상위 10개로 달라. 기존엔 `<카테고리> <기간>` 조회만 가능했다.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "astra", "name": "astra",
"version": "2.2.323", "version": "2.2.324",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "astra", "name": "astra",
"version": "2.2.323", "version": "2.2.324",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@lmstudio/sdk": "^1.5.0", "@lmstudio/sdk": "^1.5.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "astra", "name": "astra",
"displayName": "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.", "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", "publisher": "g1nation",
"license": "MIT", "license": "MIT",
"icon": "assets/icon.png", "icon": "assets/icon.png",
+11
View File
@@ -47,6 +47,17 @@ function withInferredYear(month: number, day: number, today: Date): Date {
return candidate; 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 { interface DateToken {
index: number; index: number;
length: number; length: number;
+91 -29
View File
@@ -18,12 +18,12 @@ import * as vscode from 'vscode';
import { registerSlashCommand, chunk } from '../datacollect/slashRouter'; import { registerSlashCommand, chunk } from '../datacollect/slashRouter';
import { AIService } from '../../core/services'; import { AIService } from '../../core/services';
import { import {
parseShoppingIntent, parseSearchTrendIntent, generateContainingKeywords, parseShoppingIntent, parseSearchTrendIntent, generateKeywordCandidates,
parseContainsShorthand, keywordContains, parseContainsShorthand, keywordContains,
} from './intentParser'; } from './intentParser';
import { findCategoryCode, listTopLevelCategoryNames, ROOT_CATEGORY_CODES, type CategoryMatch } from './categoryCodes'; import { findCategoryCode, listTopLevelCategoryNames, ROOT_CATEGORY_CODES, type CategoryMatch } from './categoryCodes';
import { rankCategories, rankKeywords, rankKeywordsAnchored, type RankedResult, type DateRange } from './rankEngine'; 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'; import type { CommonFilters, TimeUnit } from './naverApi';
type Webview = Parameters<typeof chunk>[0]; type Webview = Parameters<typeof chunk>[0];
@@ -73,7 +73,7 @@ function formatRankedTable(result: RankedResult, itemLabel: string): string {
return lines.join('\n') + '\n'; return lines.join('\n') + '\n';
} }
/** contains 모드 표 — 하루치 최신값이 아니라 조회 구간 평균으로 줄 세운다. */ /** topic/contains 모드 표 — 하루치 최신값이 아니라 조회 구간 평균으로 줄 세운다. */
function formatContainsTable(items: RankedResult['items']): string { function formatContainsTable(items: RankedResult['items']): string {
if (items.length === 0) return '_(결과 없음)_\n'; if (items.length === 0) return '_(결과 없음)_\n';
const lines: string[] = []; const lines: string[] = [];
@@ -206,9 +206,10 @@ function cmdSearchTrendHelp(view: Webview): void {
'', '',
'**/naver-trend** — 네이버 검색어트렌드', '**/naver-trend** — 네이버 검색어트렌드',
'', '',
'- `/naver-trend 원피스,블라우스 비교` — 명시 키워드 비교', '- `/naver-trend 경제 8/11~8/12` — **`<분야> <기간>`**: 그 기간에 그 분야에서 많이 검색된 키워드 상위 10개',
'- `/naver-trend 20대 여성한테 인기있는 원피스 스타일 키워드 10개` — 탐색(후보 제안 + 검증)',
'- `/naver-trend 경제 방법 8월1일부터 오늘까지` — **`<분야> <포함단어> <기간>`**: 그 분야 인기 검색어 중 해당 단어가 들어간 것만 검색량 상위 10개', '- `/naver-trend 경제 방법 8월1일부터 오늘까지` — **`<분야> <포함단어> <기간>`**: 그 분야 인기 검색어 중 해당 단어가 들어간 것만 검색량 상위 10개',
'- `/naver-trend 원피스,블라우스 비교` — 명시 키워드 2개 이상 비교',
'- `/naver-trend 20대 여성한테 인기있는 원피스 스타일 키워드 10개` — 탐색(후보 제안 + 검증)',
'', '',
'기간 표현: `8월1일부터 오늘까지`, `8/1~8/10`, `최근 일주일`, `지난 30일` 등 (생략 시 최근 7일)', '기간 표현: `8월1일부터 오늘까지`, `8/1~8/10`, `최근 일주일`, `지난 30일` 등 (생략 시 최근 7일)',
'', '',
@@ -217,27 +218,37 @@ function cmdSearchTrendHelp(view: Webview): void {
].join('\n')); ].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(); 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 가 없어서(지정 키워드의 상대 지수만 * 데이터랩엔 "분야 내 인기 검색어 목록" API 가 없어서(지정 키워드의 상대 지수만
* 반환) 후보 생성은 LLM, 검증·랭킹은 API 가 맡는 2단 구조다. 포함 여부는 LLM 을 * 반환) 후보 생성은 LLM, 검증·랭킹은 API 가 맡는 2단 구조다. 포함 여부는 LLM 을
* 믿지 않고 코드에서 다시 거른다. * 믿지 않고 코드에서 다시 거른다.
*/ */
async function runContainsMode( async function runTopicMode(
view: Webview, view: Webview,
context: vscode.ExtensionContext, context: vscode.ExtensionContext,
ai: AIService, ai: AIService,
opts: { opts: {
topic: string; topic: string;
containsWord: string; containsWord?: string;
limit: number; limit: number;
seeded: string[]; seeded: string[];
range: DateRange; range: DateRange;
@@ -246,31 +257,60 @@ async function runContainsMode(
): Promise<boolean> { ): Promise<boolean> {
const { topic, containsWord, range, filters } = opts; const { topic, containsWord, range, filters } = opts;
const limit = Math.min(Math.max(opts.limit, 1), 20); 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 는 확보하고 검증에 들어간다. // 후보가 얇으면 순위가 후보 운에 좌우된다 — 최소 limit*2 는 확보하고 검증에 들어간다.
if (pool.length < limit * 2) { if (pool.length < limit * 2) {
chunk(view, `\n🧩 "${topic}" 분야에서 "${containsWord}" 들어간 검색어 후보 만드는 중…\n`); chunk(view, containsWord
const more = await generateContainingKeywords(ai, topic, containsWord, CONTAINS_POOL_MAX); ? `\n🧩 "${topic}" 분야에서 "${containsWord}" 들어간 검색어 후보 만드는 중…\n`
pool = [...new Set([...pool, ...more.filter(isPlausibleKeyword)])]; : `\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); pool = pool.slice(0, CONTAINS_POOL_MAX);
// 후보 생성이 통째로 실패(AI 다운/파싱 실패)한 경우 — 그래도 분야명 자체의
// 추이는 보여주고 무슨 일이 있었는지 밝힌다.
let degraded = false;
if (pool.length === 0) { if (pool.length === 0) {
chunk(view, `\n❓ "${topic}" 분야에서 "${containsWord}" 가 들어간 검색어 후보를 만들지 못했습니다. 분야를 더 구체적으로(예: "주식 투자") 적어보세요.\n`); if (!containsWord && isPlausibleKeyword(topic)) {
pool = [topic];
degraded = true;
} else {
chunk(view, containsWord
? `\n❓ "${topic}" 분야에서 "${containsWord}" 가 들어간 검색어 후보를 만들지 못했습니다. 분야를 더 구체적으로(예: "주식 투자") 적어보세요.\n`
: `\n❓ "${topic}" 분야의 검색어 후보를 만들지 못했습니다. 분야를 더 구체적으로(예: "주식 투자") 적어보세요.\n`);
return true; return true;
} }
}
chunk(view, `\n📊 조회 중 — 후보 ${pool.length}개를 네이버 검색어트렌드로 검증 (${range.startDate} ~ ${range.endDate})…\n`); chunk(view, `\n📊 조회 중 — 후보 ${pool.length}개를 네이버 검색어트렌드로 검증 (${range.startDate} ~ ${range.endDate})…\n`);
const result = await rankKeywordsAnchored(context, pool, range, filters); const result = await rankKeywordsAnchored(context, pool, range, filters);
const ranked = result.items.filter(it => it.avgRatio > 0).slice(0, limit); 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; 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)); 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`); if (dropped > 0) chunk(view, `\n> ℹ️ 검색량이 잡히지 않은 후보 ${dropped}개는 제외했습니다.\n`);
chunk(view, formatErrors(result.errors)); 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`); chunk(view, `\n${DISCLAIMER}\n`);
return true; return true;
} }
@@ -302,6 +342,10 @@ async function runSearchTrend(arg: string, view: Webview, context?: vscode.Exten
}; };
const filters = buildFilters(intent); const filters = buildFilters(intent);
// 기간 표현을 걷어낸 나머지 = 사용자가 말한 분야/키워드. LLM 이 topic 을
// 못 뽑았을 때의 기준값이자, 질문 원문이 키워드로 새는 걸 막는 필터다.
const residual = stripDateExpression(trimmed, preRange);
// `<분야> <포함단어> <기간>` — LLM 판정이 실패했을 때만 문법 규칙으로 건진다. // `<분야> <포함단어> <기간>` — LLM 판정이 실패했을 때만 문법 규칙으로 건진다.
// 성공했는데 compare 로 봤다면 존중한다("원피스 블라우스 최근 일주일" 처럼 // 성공했는데 compare 로 봤다면 존중한다("원피스 블라우스 최근 일주일" 처럼
// 두 낱말이 비교 대상인 경우와 구분할 근거가 문장 안에는 없기 때문). // 두 낱말이 비교 대상인 경우와 구분할 근거가 문장 안에는 없기 때문).
@@ -309,7 +353,7 @@ async function runSearchTrend(arg: string, view: Webview, context?: vscode.Exten
let topic = intent.topic; let topic = intent.topic;
let containsWord = intent.containsWord; let containsWord = intent.containsWord;
if (mode !== 'contains' && !intent.parsed && preRange) { if (mode !== 'contains' && !intent.parsed && preRange) {
const shorthand = parseContainsShorthand(trimmed.replace(preRange.matchedText, ' ')); const shorthand = parseContainsShorthand(residual);
if (shorthand) { if (shorthand) {
mode = 'contains'; mode = 'contains';
topic = shorthand.topic; 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, { // "경제 8/11~8/12" 같은 검색량 0 짜리 1행 표가 나온다.
topic: topic || trimmed, 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, containsWord,
limit: intent.limit ?? 10, limit: intent.limit ?? 10,
seeded: intent.parsed ? intent.keywords : [], seeded: candidates,
range, range,
filters, filters,
}); });
} }
chunk(view, `\n📊 조회 중 — ${intent.keywords.join(', ')} (${range.startDate} ~ ${range.endDate})…\n`); // 비교는 대상이 둘 이상일 때만 성립한다. 그 외(분야만 말한 discover,
const result = await rankKeywords(context, intent.keywords, range, filters); // 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' chunk(view, `\n📊 조회 중 — ${candidates.join(', ')} (${range.startDate} ~ ${range.endDate})…\n`);
? `**네이버 검색어트렌드 — "${trimmed}" 관련 키워드 TOP ${result.items.length}**` const result = await rankKeywords(context, candidates, range, filters);
: `**네이버 검색어트렌드 — 키워드 비교**`;
chunk(view, `\n${title} (${range.startDate}~${range.endDate})\n\n`); chunk(view, `\n**네이버 검색어트렌드 — 키워드 비교** (${range.startDate}~${range.endDate})\n\n`);
chunk(view, formatRankedTable(result, '키워드')); chunk(view, formatRankedTable(result, '키워드'));
chunk(view, formatErrors(result.errors)); chunk(view, formatErrors(result.errors));
chunk(view, `\n${DISCLAIMER}\n`); chunk(view, `\n${DISCLAIMER}\n`);
+38 -22
View File
@@ -54,7 +54,7 @@ export interface ShoppingIntent {
export interface SearchTrendIntent { export interface SearchTrendIntent {
mode: SearchTrendMode; mode: SearchTrendMode;
keywords: string[]; keywords: string[];
/** mode='contains' 전용 — 검색어를 찾을 분야/주제 (예: "경제"). */ /** mode='contains'|'discover' — 검색어를 찾을 분야/주제 (예: "경제"). */
topic?: string; topic?: string;
/** mode='contains' 전용 — 검색어에 반드시 들어가야 하는 단어 (예: "방법"). */ /** mode='contains' 전용 — 검색어에 반드시 들어가야 하는 단어 (예: "방법"). */
containsWord?: string; containsWord?: string;
@@ -223,15 +223,15 @@ export async function parseShoppingIntent(
const SEARCH_TREND_SYSTEM_PROMPT = `당신은 네이버 검색어트렌드 API 질의 파서입니다. 사용자의 자연어 질문을 분석해 아래 JSON 하나만 출력하세요. 다른 텍스트(설명, 코드펜스, 머리말) 절대 금지. const SEARCH_TREND_SYSTEM_PROMPT = `당신은 네이버 검색어트렌드 API 질의 파서입니다. 사용자의 자연어 질문을 분석해 아래 JSON 하나만 출력하세요. 다른 텍스트(설명, 코드펜스, 머리말) 절대 금지.
- "mode": 셋 중 하나. - "mode": 셋 중 하나.
· "compare" — 사용자가 구체적인 검색어를 하나 이상 지목해 비교/조회를 원함. · "compare" — 사용자가 **구체적인 검색어를 둘 이상** 지목해 서로 비교하길 원함. 예: "원피스랑 블라우스 비교", "아이폰17 vs 갤럭시S26".
· "discover" — "인기 검색어"/"요즘 뜨는 키워드"/"top N" 처럼 키워드를 지목하지 않고 탐색을 원함. · "discover" — 개별 검색어를 지목하지 않고 **분야/주제만** 말함. "인기 검색어"/"요즘 뜨는 키워드"/"top N" 뿐 아니라, **"<분야> <기간>" 처럼 분야명과 기간만 있는 질문도 전부 여기**다 — 예: "경제 8/11~8/12", "여행 최근 일주일", "부동산 지난 30일"(= 그 기간에 그 분야에서 많이 검색된 키워드를 뽑아달라는 뜻). 검색어를 하나만 말한 경우도 그 검색어를 분야로 보고 discover 로 처리.
· "contains" — "<분야> <단어> <기간>" 형태로, **그 분야의 인기 검색어 중 특정 단어가 들어간 것**을 원함. 예: "경제 방법 8월1일부터 오늘까지"(경제 분야에서 '방법'이 들어간 검색어), "여행 추천 최근 한달", "부동산 투자 방법 알려줘". 분야명과 포함 단어 두 덩어리가 나란히 오면 대부분 이 모드다. · "contains" — "<분야> <단어> <기간>" 형태로, **그 분야의 인기 검색어 중 특정 단어가 들어간 것**을 원함. 예: "경제 방법 8월1일부터 오늘까지"(경제 분야에서 '방법'이 들어간 검색어), "여행 추천 최근 한달", "부동산 투자 방법 알려줘". 기간 표현을 뺀 나머지가 분야명과 포함 단어 두 덩어리면 대부분 이 모드다.
- "topic": mode="contains"일 때 분야/주제 (예: "경제"). 그 외에는 "". - "topic": mode="discover"|"contains" 일 때 분야/주제 (예: "경제"). **기간 표현("8/11~8/12", "최근 일주일")은 절대 넣지 말 것.** mode="compare" 면 "".
- "containsWord": mode="contains"일 때 검색어에 반드시 들어가야 하는 단어 (예: "방법"). 그 외에는 "". 사용자가 쓴 형태 그대로, 조사(을/를/이/가/은/는) 없이. - "containsWord": mode="contains"일 때 검색어에 반드시 들어가야 하는 단어 (예: "방법"). 그 외에는 "". 사용자가 쓴 형태 그대로, 조사(을/를/이/가/은/는) 없이. 기간 표현은 포함 단어가 아니다.
- "limit": 사용자가 "10개"처럼 개수를 밝혔으면 그 숫자, 아니면 0. - "limit": 사용자가 "10개"처럼 개수를 밝혔으면 그 숫자, 아니면 0.
- "keywords": - "keywords": **어떤 모드에서도 빈 배열로 두지 말 것. 질문 원문이나 기간 표현을 그대로 넣는 것도 금지.**
· mode="compare" — 사용자가 언급한 검색어를 그대로 배열로. · mode="compare" — 사용자가 언급한 검색어를 그대로 배열로.
· mode="discover" — 질문 맥락(주제/카테고리/타겟)에 맞는 검색어 후보를 당신의 지식으로 최대 10개 제안. · mode="discover" — topic 분야에서 그 기간에 실제로 많이 검색될 법한 검색어를 당신의 지식으로 최대 20개 제안(분야명 자체를 그대로 넣지 말 것 — 예: topic="경제" → "금리 인상", "환율 전망", "코스피 전망", "부동산 시세", "적금 금리" …).
· mode="contains" — topic 분야에서 실제로 많이 검색될 법하면서 **containsWord 를 글자 그대로 포함하는** 검색어를 최대 30개 제안(예: topic="경제", containsWord="방법" → "재테크 방법", "돈 모으는 방법", "주식 투자 방법", "절세 방법" …). containsWord 가 안 들어간 후보는 버려지니 넣지 말 것. containsWord 한 단어만 덜렁 넣는 것도 금지. · mode="contains" — topic 분야에서 실제로 많이 검색될 법하면서 **containsWord 를 글자 그대로 포함하는** 검색어를 최대 30개 제안(예: topic="경제", containsWord="방법" → "재테크 방법", "돈 모으는 방법", "주식 투자 방법", "절세 방법" …). containsWord 가 안 들어간 후보는 버려지니 넣지 말 것. containsWord 한 단어만 덜렁 넣는 것도 금지.
⚠️ 질문이 특정 상품군(가전/패션/뷰티 등)을 가리키면 그 상위 카테고리명 자체를 반복하지 말고 **구체적인 상품/모델명**(브랜드+제품라인명, 예: "맥북 에어 M3", "다이슨 에어랩")을 우선 제안 — 일반 명사만 나열하면 사용자가 얻을 정보가 없다. 여러 브랜드를 섞을 것. ⚠️ 질문이 특정 상품군(가전/패션/뷰티 등)을 가리키면 그 상위 카테고리명 자체를 반복하지 말고 **구체적인 상품/모델명**(브랜드+제품라인명, 예: "맥북 에어 M3", "다이슨 에어랩")을 우선 제안 — 일반 명사만 나열하면 사용자가 얻을 정보가 없다. 여러 브랜드를 섞을 것.
${COMMON_FIELDS_PROMPT} ${COMMON_FIELDS_PROMPT}
@@ -292,12 +292,15 @@ export async function parseSearchTrendIntent(
const containsWord = _asWord(obj.containsWord); const containsWord = _asWord(obj.containsWord);
const keywords = _asStringArray(obj.keywords); const keywords = _asStringArray(obj.keywords);
// contains 모드에서 포함 단어가 비면 성립하지 않는 요청 — discover 로 강등해 // contains 모드에서 포함 단어가 비면 성립하지 않는 요청 — discover 로 강등해
// 최소한 후보 랭킹은 보여준다. // 최소한 후보 랭킹은 보여준다(topic 은 그대로 살려 후보 생성에 쓴다).
const effectiveMode: SearchTrendMode = mode === 'contains' && !containsWord ? 'discover' : mode; const effectiveMode: SearchTrendMode = mode === 'contains' && !containsWord ? 'discover' : mode;
return { return {
mode: effectiveMode, 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, containsWord: effectiveMode === 'contains' ? containsWord : undefined,
limit: _asLimit(obj.limit), limit: _asLimit(obj.limit),
startDate: _asDate(obj.startDate), startDate: _asDate(obj.startDate),
@@ -310,7 +313,7 @@ export async function parseSearchTrendIntent(
}; };
} }
// ── contains 모드 후보 보충 ─────────────────────────────────────────────── // ── 후보 보충 ─────────────────────────────────────────────────────────────
const CONTAINS_SYSTEM_PROMPT = `당신은 네이버 검색어 후보 생성기입니다. 주어진 분야에서 실제 사람들이 네이버에 많이 칠 법한 검색어를, **지정된 단어를 글자 그대로 포함하도록** 만들어 JSON 하나만 출력하세요. 다른 텍스트 절대 금지. const CONTAINS_SYSTEM_PROMPT = `당신은 네이버 검색어 후보 생성기입니다. 주어진 분야에서 실제 사람들이 네이버에 많이 칠 법한 검색어를, **지정된 단어를 글자 그대로 포함하도록** 만들어 JSON 하나만 출력하세요. 다른 텍스트 절대 금지.
@@ -323,27 +326,40 @@ const CONTAINS_SYSTEM_PROMPT = `당신은 네이버 검색어 후보 생성기
⚠️ 반드시 아래 JSON 형식 정확히 한 번: ⚠️ 반드시 아래 JSON 형식 정확히 한 번:
{"keywords":["...","..."]}`; {"keywords":["...","..."]}`;
const TOPIC_SYSTEM_PROMPT = `당신은 네이버 검색어 후보 생성기입니다. 주어진 분야에서 실제 사람들이 네이버에 많이 칠 법한 검색어를 만들어 JSON 하나만 출력하세요. 다른 텍스트 절대 금지.
규칙:
- 그 분야에서 검색량이 실제로 나올 법한 구체적·대중적인 검색어. 지나치게 길거나(8어절+) 문장형인 것은 배제.
- **분야명 자체나 그 상위 개념을 그대로 쓰지 말 것** — 예: 분야가 "경제"면 "경제"/"경제뉴스" 말고 "금리 인상", "환율 전망", "코스피 전망", "청약 통장", "연말정산 환급" 처럼 실제로 검색될 구문.
- 상품군이면 브랜드+모델/제품라인명(예: "맥북 에어 M3", "다이슨 에어랩") 위주로, 여러 브랜드를 섞을 것.
- 서로 다른 세부 주제를 폭넓게 커버할 것(같은 말 반복 금지).
- 기간 표현("8/11~8/12", "최근 일주일")이나 질문 문장은 절대 후보로 넣지 말 것.
⚠️ 반드시 아래 JSON 형식 정확히 한 번:
{"keywords":["...","..."]}`;
/** /**
* "<분야>에서 <단어>가 들어간 검색어" 후보를 LLM 으로 생성. * "<분야>의 인기 검색어" 후보를 LLM 으로 생성. `word` 를 주면 그 단어를 글자 그대로
* 포함하는 후보만 만든다(contains 모드).
* *
* parseSearchTrendIntent 가 이미 후보를 주지만(같은 호출에서 뽑음) 개수가 모자라거나 * parseSearchTrendIntent 가 이미 후보를 주지만(같은 호출에서 뽑음) 개수가 모자라거나
* 포함 단어 필터에 많이 걸러진 경우 handlers 가 이 함수로 한 번 더 채운다. * 필터에 많이 걸러진 경우 handlers 가 이 함수로 한 번 더 채운다. 반환 전 포함 검사를
* 반환 전 포함 검사를 다시 하므로 호출자는 그대로 써도 된다. * 다시 하므로 호출자는 그대로 써도 된다.
*/ */
export async function generateContainingKeywords( export async function generateKeywordCandidates(
ai: IAIService, ai: IAIService,
topic: string, topic: string,
word: string, word: string | undefined,
count: number, count: number,
options: { model?: string; timeoutMs?: number } = {}, options: { model?: string; timeoutMs?: number } = {},
): Promise<string[]> { ): Promise<string[]> {
let raw = ''; let raw = '';
try { try {
const result = await ai.chat({ const result = await ai.chat({
system: CONTAINS_SYSTEM_PROMPT, system: word ? CONTAINS_SYSTEM_PROMPT : TOPIC_SYSTEM_PROMPT,
user: [ user: [
`분야/주제: ${topic}`, `분야/주제: ${topic}`,
`반드시 포함할 단어: ${word}`, ...(word ? [`반드시 포함할 단어: ${word}`] : []),
`개수: ${count}개`, `개수: ${count}개`,
'', '',
'JSON만 출력:', 'JSON만 출력:',
@@ -353,15 +369,15 @@ export async function generateContainingKeywords(
}); });
raw = result.content || ''; raw = result.content || '';
} catch (e: any) { } catch (e: any) {
logError('naverTrend generateContainingKeywords: AI 호출 실패.', { error: e?.message ?? String(e) }); logError('naverTrend generateKeywordCandidates: AI 호출 실패.', { error: e?.message ?? String(e) });
return []; return [];
} }
const obj = _parseJsonLoose(raw); const obj = _parseJsonLoose(raw);
if (!obj) { if (!obj) {
logInfo('naverTrend generateContainingKeywords: 파싱 실패.', { rawHead: raw.slice(0, 100) }); logInfo('naverTrend generateKeywordCandidates: 파싱 실패.', { rawHead: raw.slice(0, 100) });
return []; return [];
} }
return _asStringArray(obj.keywords).filter(k => keywordContains(k, word)); return _asStringArray(obj.keywords).filter(k => !word || keywordContains(k, word));
} }
/** /**
+147 -6
View File
@@ -8,12 +8,26 @@ jest.mock('../src/features/naverTrend/naverApi', () => ({
fetchShoppingCategoryTrend: jest.fn(), 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 { findCategoryCode, ROOT_CATEGORY_CODES } from '../src/features/naverTrend/categoryCodes';
import { import {
parseShoppingIntent, parseSearchTrendIntent, generateContainingKeywords, parseShoppingIntent, parseSearchTrendIntent, generateKeywordCandidates,
parseContainsShorthand, keywordContains, parseContainsShorthand, keywordContains,
} from '../src/features/naverTrend/intentParser'; } 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 { rankKeywordsAnchored } from '../src/features/naverTrend/rankEngine';
import { fetchSearchTrend } from '../src/features/naverTrend/naverApi'; import { fetchSearchTrend } from '../src/features/naverTrend/naverApi';
import type { IAIService, AIChatResult } from '../src/core/services'; import type { IAIService, AIChatResult } from '../src/core/services';
@@ -228,18 +242,145 @@ describe('contains 모드 — 분야 + 포함단어', () => {
expect(r.containsWord).toBeUndefined(); expect(r.containsWord).toBeUndefined();
}); });
test('generateContainingKeywords — 포함 단어가 빠진 후보는 코드에서 걸러낸다', async () => { test('generateKeywordCandidates — 포함 단어가 빠진 후보는 코드에서 걸러낸다', async () => {
const ai = mockAi('{"keywords":["재테크 방법","주식 투자","절세 방법"]}'); const ai = mockAi('{"keywords":["재테크 방법","주식 투자","절세 방법"]}');
const out = await generateContainingKeywords(ai, '경제', '방법', 30); const out = await generateKeywordCandidates(ai, '경제', '방법', 30);
expect(out).toEqual(['재테크 방법', '절세 방법']); expect(out).toEqual(['재테크 방법', '절세 방법']);
}); });
test('generateContainingKeywords — AI 실패해도 예외 전파 없이 빈 배열', async () => { test('generateKeywordCandidates — 포함 단어가 없으면(분야만) 필터 없이 전부 후보', async () => {
const out = await generateContainingKeywords(mockAi('', true), '경제', '방법', 30); 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([]); 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<string> => {
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 으로 두는 상대 지수라, // 검색어트렌드 ratio 는 "요청 하나" 안에서 최댓값을 100 으로 두는 상대 지수라,
// 5개씩 끊어 호출하면 배치마다 기준이 달라진다. 후보 30개를 검색량순으로 줄 // 5개씩 끊어 호출하면 배치마다 기준이 달라진다. 후보 30개를 검색량순으로 줄
// 세우려면 배치마다 공통 앵커를 끼워 되돌려야 한다는 걸 고정한다. // 세우려면 배치마다 공통 앵커를 끼워 되돌려야 한다는 걸 고정한다.