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:
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "직답 결과 — single-pass mock 응답입니다.",
|
"result": "직답 결과 — single-pass mock 응답입니다.",
|
||||||
"createdAt": 1786452145565,
|
"createdAt": 1786539817249,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
+2
-2
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
||||||
"createdAt": 1786452152459,
|
"createdAt": 1786539824310,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||||
"createdAt": 1786452152465,
|
"createdAt": 1786539824311,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
+9
-9
@@ -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": {
|
||||||
@@ -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개로 달라. 기존엔 `<카테고리> <기간>` 조회만 가능했다.
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
@@ -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
@@ -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개를 검색량순으로 줄
|
||||||
// 세우려면 배치마다 공통 앵커를 끼워 되돌려야 한다는 걸 고정한다.
|
// 세우려면 배치마다 공통 앵커를 끼워 되돌려야 한다는 걸 고정한다.
|
||||||
|
|||||||
Reference in New Issue
Block a user