v2.2.321: /naver-shopping "분야 내 인기 검색어" 질문이 무의미한 1행 표만 주던 문제 수정

- 쇼핑인사이트 API 는 분야 자체의 클릭 추이만 주고 분야 안의 키워드 순위 API가
  없음 — intentParser 에 scope(category/keywords) 필드 추가해 "인기 검색어"
  류 질문을 감지하고, 검증된 검색어트렌드 rankKeywords 로 우회 처리하도록 수정
This commit is contained in:
2026-08-06 10:30:30 +09:00
parent 29ad4cc023
commit 61246bb0cb
13 changed files with 97 additions and 36 deletions
+25 -8
View File
@@ -72,7 +72,8 @@ function cmdShoppingHelp(view: Webview): void {
'**/naver-shopping** — 네이버 쇼핑인사이트(분야별 트렌드)',
'',
'- `/naver-shopping 패션의류 vs 화장품/미용 최근 일주일` — 명시 분야 비교',
'- `/naver-shopping 요즘 뜨는 뷰티 카테고리 알려줘` — 탐색(후보 제안 + 검증)',
'- `/naver-shopping 요즘 뜨는 뷰티 카테고리 알려줘` — 분야 자체 탐색(후보 제안 + 검증)',
'- `/naver-shopping 디지털/가전 인기 검색어` — 분야 "안"의 인기 키워드 후보(검색어트렌드로 검증)',
'- `/naver-shopping categories` — 대분류(11종) 목록 보기',
'- `/naver-shopping categories 원피스` — 카테고리 검색(이름 · 경로 · cat_id)',
'',
@@ -119,6 +120,29 @@ async function runShopping(arg: string, view: Webview, context?: vscode.Extensio
const ai = new AIService();
const intent = await parseShoppingIntent(ai, trimmed, ymd(new Date()));
const range = {
startDate: intent.startDate || defaultRange().startDate,
endDate: intent.endDate || defaultRange().endDate,
timeUnit: intent.timeUnit || 'date' as TimeUnit,
};
const filters = buildFilters(intent);
if (intent.scope === 'keywords') {
if (intent.keywords.length === 0) {
chunk(view, `\n❓ "${trimmed}" 맥락에서 제안할 검색어 후보를 찾지 못했습니다. \`/naver-trend\` 로 직접 키워드를 물어보세요.\n`);
return true;
}
const category = intent.categories[0] || trimmed;
chunk(view, `\n📊 조회 중 — "${category}" 맥락의 키워드 후보 ${intent.keywords.length}개 검증 중…\n`);
const kwResult = await rankKeywords(context, intent.keywords, range, filters);
chunk(view, `\n**"${category}" 관련 인기 키워드 후보 TOP ${kwResult.items.length}** (${range.startDate}~${range.endDate})\n\n`);
chunk(view, formatRankedTable(kwResult, '키워드'));
chunk(view, formatErrors(kwResult.errors));
chunk(view, '\nℹ️ 쇼핑인사이트는 "분야 자체"의 클릭 추이만 제공하고 분야 안의 개별 키워드 순위를 주는 API가 없어, 검색어트렌드(네이버 통합검색 전체 기준)로 후보를 검증했습니다 — 쇼핑 카테고리로 필터링된 수치는 아닙니다.\n');
chunk(view, `\n${DISCLAIMER}\n`);
return true;
}
const resolved: Array<{ name: string; path?: string; code: string }> = [];
const unresolved: string[] = [];
for (const name of intent.categories) {
@@ -132,13 +156,6 @@ async function runShopping(arg: string, view: Webview, context?: vscode.Extensio
return true;
}
const range = {
startDate: intent.startDate || defaultRange().startDate,
endDate: intent.endDate || defaultRange().endDate,
timeUnit: intent.timeUnit || 'date' as TimeUnit,
};
const filters = buildFilters(intent);
chunk(view, `\n📊 조회 중 — ${resolved.map(r => r.name).join(', ')} (${range.startDate} ~ ${range.endDate})…\n`);
const result = await rankCategories(context, resolved, range, filters);
+26 -5
View File
@@ -20,7 +20,17 @@ export type IntentMode = 'compare' | 'discover';
export interface ShoppingIntent {
mode: IntentMode;
/**
* 'category' = 분야 자체의 클릭 추이/비교를 원함(쇼핑인사이트 API 그대로 처리).
* 'keywords' = 그 분야 "안에서" 어떤 상품/검색어가 인기인지 궁금함(예: "디지털/가전
* 인기 검색어", "요즘 뭐가 잘 팔려") — 쇼핑인사이트엔 분야 내 개별 키워드 순위를
* 주는 API가 없어(카테고리 자체의 클릭 추이만 제공), 검색어트렌드 API로 우회
* 검증한다(handlers.ts 에서 rankKeywords 재사용).
*/
scope: 'category' | 'keywords';
categories: string[];
/** scope='keywords' 일 때만 사용 — 그 분야 맥락에서 제안된 검색어 후보. */
keywords: string[];
startDate?: string;
endDate?: string;
timeUnit?: 'date' | 'week' | 'month';
@@ -132,12 +142,20 @@ function _buildUserMessage(userText: string, todayYmd: string): string {
const SHOPPING_SYSTEM_PROMPT = `당신은 네이버 쇼핑인사이트(분야별 트렌드) API 질의 파서입니다. 사용자의 자연어 질문을 분석해 아래 JSON 하나만 출력하세요. 다른 텍스트(설명, 코드펜스, 머리말) 절대 금지.
- "mode": 사용자가 구체적인 분야명(예: "패션의류", "화장품", "가전")을 하나 이상 언급했으면 "compare". "요즘 뭐가 뜨나"/"인기 분야"/"top N" 처럼 분야를 직접 지목하지 않고 탐색을 원하면 "discover".
- "categories": mode="compare"면 사용자가 언급한 분야명을 그대로(정규화하지 말고 원문 그대로) 배열로. mode="discover"면 질문 맥락에 맞는 분야 후보를 당신의 지식으로 최대 9개 제안(3개씩 묶어 순차 조회하므로 3의 배수 권장).
⚠️ 중요한 API 제약: 쇼핑인사이트는 "분야 자체"의 클릭 추이만 제공하고, 분야 "안에서" 어떤 상품/검색어가 인기인지는 이 API로 알 수 없다(그런 API 자체가 없음). 이 구분이 scope 필드다.
- "scope": 사용자가 분야들의 트렌드/추이 자체를 비교·조회하고 싶으면 "category". "인기 검색어", "뭐가 잘 팔려", "인기 상품", "요즘 뜨는 상품/키워드" 처럼 분야 "안의" 개별 상품·검색어가 궁금하면 "keywords".
- "mode": scope="category"일 때 — 구체적인 분야명을 하나 이상 언급했으면 "compare", "요즘 뜨는 분야가 뭐야"처럼 분야 자체를 탐색하고 싶으면 "discover". scope="keywords"면 항상 "discover".
- "categories": scope="category"·mode="compare"면 사용자가 언급한 분야명 그대로(정규화 말고 원문 그대로) 배열로. scope="category"·mode="discover"면 후보 분야를 최대 9개 제안(3개씩 순차 조회하므로 3의 배수 권장). scope="keywords"면 사용자가 언급한 분야명(보통 1개)만 넣기.
- "keywords": scope="keywords"일 때만 사용 — 그 분야 맥락에 맞는 검색어/상품 후보를 당신의 지식으로 최대 10개 제안(개별 순위를 매기려면 키워드 1개=API 그룹 1개라 5개씩 순차 조회함을 감안). scope="category"면 빈 배열.
${COMMON_FIELDS_PROMPT}
⚠️ 반드시 아래 JSON 형식 정확히 한 번:
{"mode":"compare"|"discover","categories":["..."],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}`;
{"scope":"category"|"keywords","mode":"compare"|"discover","categories":["..."],"keywords":["..."],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}`;
function _asScope(v: unknown): 'category' | 'keywords' {
return v === 'keywords' ? 'keywords' : 'category';
}
export async function parseShoppingIntent(
ai: IAIService,
@@ -157,18 +175,21 @@ export async function parseShoppingIntent(
raw = result.content || '';
} catch (e: any) {
logError('naverTrend intentParser(shopping): AI 호출 실패 — compare 모드로 원문 사용.', { error: e?.message ?? String(e) });
return { mode: 'compare', categories: [trimmed], parsed: false };
return { mode: 'compare', scope: 'category', categories: [trimmed], keywords: [], parsed: false };
}
const obj = _parseJsonLoose(raw);
if (!obj) {
logInfo('naverTrend intentParser(shopping): 파싱 실패 — compare 모드로 원문 사용.', { rawHead: raw.slice(0, 100) });
return { mode: 'compare', categories: [trimmed], parsed: false };
return { mode: 'compare', scope: 'category', categories: [trimmed], keywords: [], parsed: false };
}
const categories = _asStringArray(obj.categories);
const scope = _asScope(obj.scope);
return {
mode: _asMode(obj.mode),
scope,
categories: categories.length > 0 ? categories : [trimmed],
keywords: scope === 'keywords' ? _asStringArray(obj.keywords) : [],
startDate: _asDate(obj.startDate),
endDate: _asDate(obj.endDate),
timeUnit: _asTimeUnit(obj.timeUnit),