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
@@ -1,5 +1,5 @@
{ {
"result": "직답 결과 — single-pass mock 응답입니다.", "result": "직답 결과 — single-pass mock 응답입니다.",
"createdAt": 1785978343885, "createdAt": 1785979804062,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,5 +1,5 @@
{ {
"result": "---\nid: wiki_on\ndate: 2026-08-06T01:05:43.891Z\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) (17ms)\n", "result": "---\nid: wiki_on\ndate: 2026-08-06T01:30:04.066Z\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) (27ms)\n",
"createdAt": 1785978343891, "createdAt": 1785979804069,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,8 +1,8 @@
{ {
"missionId": "wiki_on", "missionId": "wiki_on",
"status": "completed", "status": "completed",
"startTime": "2026-08-06T01:05:43.866Z", "startTime": "2026-08-06T01:30:04.033Z",
"totalElapsedMs": 27, "totalElapsedMs": 39,
"results": { "results": {
"direct": "직답 결과 — single-pass mock 응답입니다." "direct": "직답 결과 — single-pass mock 응답입니다."
}, },
@@ -12,16 +12,16 @@
{ {
"from": "idle", "from": "idle",
"to": "direct", "to": "direct",
"durationMs": 17, "durationMs": 27,
"message": "답변 작성 중... (단일 호출 fast-path)", "message": "답변 작성 중... (단일 호출 fast-path)",
"ts": "2026-08-06T01:05:43.883Z" "ts": "2026-08-06T01:30:04.060Z"
}, },
{ {
"from": "direct", "from": "direct",
"to": "completed", "to": "completed",
"durationMs": 9, "durationMs": 11,
"message": "미션 완료", "message": "미션 완료",
"ts": "2026-08-06T01:05:43.892Z" "ts": "2026-08-06T01:30:04.071Z"
} }
], ],
"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": 1785978351712, "createdAt": 1785979811217,
"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": 1785978351711, "createdAt": 1785979811215,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,5 +1,5 @@
{ {
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", "result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
"createdAt": 1785978351707, "createdAt": 1785979811209,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,5 +1,5 @@
{ {
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", "result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
"createdAt": 1785978351709, "createdAt": 1785979811212,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,7 +1,7 @@
{ {
"missionId": "stress_conflict_1785978351690", "missionId": "stress_conflict_1785979811195",
"status": "completed", "status": "completed",
"startTime": "2026-08-06T01:05:51.690Z", "startTime": "2026-08-06T01:30:11.195Z",
"totalElapsedMs": 23, "totalElapsedMs": 23,
"results": { "results": {
"outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", "outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
@@ -14,30 +14,30 @@
{ {
"from": "idle", "from": "idle",
"to": "outline", "to": "outline",
"durationMs": 16, "durationMs": 13,
"message": "답변 구조 잡는 중...", "message": "답변 구조 잡는 중...",
"ts": "2026-08-06T01:05:51.706Z" "ts": "2026-08-06T01:30:11.208Z"
}, },
{ {
"from": "outline", "from": "outline",
"to": "section", "to": "section",
"durationMs": 3, "durationMs": 3,
"message": "본문 작성 중...", "message": "본문 작성 중...",
"ts": "2026-08-06T01:05:51.709Z" "ts": "2026-08-06T01:30:11.211Z"
}, },
{ {
"from": "section", "from": "section",
"to": "polish", "to": "polish",
"durationMs": 1, "durationMs": 3,
"message": "최종 다듬기 중...", "message": "최종 다듬기 중...",
"ts": "2026-08-06T01:05:51.710Z" "ts": "2026-08-06T01:30:11.214Z"
}, },
{ {
"from": "polish", "from": "polish",
"to": "completed", "to": "completed",
"durationMs": 3, "durationMs": 4,
"message": "미션 완료", "message": "미션 완료",
"ts": "2026-08-06T01:05:51.713Z" "ts": "2026-08-06T01:30:11.218Z"
} }
], ],
"resilienceMetrics": { "resilienceMetrics": {
+7
View File
@@ -1,5 +1,12 @@
# Astra Patch Notes # Astra Patch Notes
## v2.2.321 (2026-08-05)
### 🛍️ `/naver-shopping` "분야 안의 인기 검색어" 질문이 쓸모없는 1행 표만 주던 문제 수정
실사례: `/naver-shopping 디지털/가전 인기 검색어` → "디지털/가전" 분야 자체의 클릭 비율 1행짜리 표만 나와 사용자가 "쓸 수 있는 데이터가 없다"고 피드백. 원인: 쇼핑인사이트 API는 "분야 자체"의 클릭 추이만 제공하고, 분야 *안*에서 어떤 상품/검색어가 인기인지는 애초에 이 API로 알 수 없다(그런 API가 없음) — intentParser 가 "인기 검색어"라는 표현도 그냥 분야명 비교(compare)로 잘못 처리했다.
- **scope 필드 추가(intentParser)**: `category`(분야 자체 트렌드/비교, 기존 동작) vs `keywords`(분야 "안"의 인기 상품/검색어가 궁금함 — "인기 검색어"/"뭐가 잘 팔려"/"인기 상품" 같은 표현) 를 LLM 이 구분해서 판정.
- **scope="keywords" 처리**: 분야명을 맥락으로 LLM 이 검색어 후보를 제안하고, 이미 검증된 검색어트렌드 API(`rankKeywords`)로 순위를 매겨 보여준다 — 쇼핑인사이트에 없는 "분야 내 키워드 순위" API를 검색어트렌드로 우회 검증하는 방식. 결과에는 "쇼핑 카테고리로 필터링된 수치가 아님"을 명시.
검증: tsc 무오류 + jest 1015 통과(신규 2) + esbuild 정상.
## v2.2.320 (2026-08-05) ## v2.2.320 (2026-08-05)
### 🐛 `/naver-shopping` "CATEGORY_TREE is not iterable" 크래시 수정 + 카테고리 목록 조회 ### 🐛 `/naver-shopping` "CATEGORY_TREE is not iterable" 크래시 수정 + 카테고리 목록 조회
실사례: 배포 직후 `/naver-shopping 요즘 뜨는 뷰티 카테고리 알려줘` 실행 시 `CATEGORY_TREE is not iterable` 에러. 원인: `import * as x from './data.json'`(네임스페이스 import)를 esbuild 로 번들하면 배열 최상위 JSON 이 `{ default: [...] }` 로 감싸져 `x` 자체가 배열이 아니게 됨 — ts-jest 는 require() 그대로라 이 클래스의 버그를 테스트로 못 잡는다(실제로 기존 유닛 테스트는 전부 통과했었음). 실사례: 배포 직후 `/naver-shopping 요즘 뜨는 뷰티 카테고리 알려줘` 실행 시 `CATEGORY_TREE is not iterable` 에러. 원인: `import * as x from './data.json'`(네임스페이스 import)를 esbuild 로 번들하면 배열 최상위 JSON 이 `{ default: [...] }` 로 감싸져 `x` 자체가 배열이 아니게 됨 — ts-jest 는 require() 그대로라 이 클래스의 버그를 테스트로 못 잡는다(실제로 기존 유닛 테스트는 전부 통과했었음).
+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.320", "version": "2.2.321",
"publisher": "g1nation", "publisher": "g1nation",
"license": "MIT", "license": "MIT",
"icon": "assets/icon.png", "icon": "assets/icon.png",
+25 -8
View File
@@ -72,7 +72,8 @@ function cmdShoppingHelp(view: Webview): void {
'**/naver-shopping** — 네이버 쇼핑인사이트(분야별 트렌드)', '**/naver-shopping** — 네이버 쇼핑인사이트(분야별 트렌드)',
'', '',
'- `/naver-shopping 패션의류 vs 화장품/미용 최근 일주일` — 명시 분야 비교', '- `/naver-shopping 패션의류 vs 화장품/미용 최근 일주일` — 명시 분야 비교',
'- `/naver-shopping 요즘 뜨는 뷰티 카테고리 알려줘` — 탐색(후보 제안 + 검증)', '- `/naver-shopping 요즘 뜨는 뷰티 카테고리 알려줘` — 분야 자체 탐색(후보 제안 + 검증)',
'- `/naver-shopping 디지털/가전 인기 검색어` — 분야 "안"의 인기 키워드 후보(검색어트렌드로 검증)',
'- `/naver-shopping categories` — 대분류(11종) 목록 보기', '- `/naver-shopping categories` — 대분류(11종) 목록 보기',
'- `/naver-shopping categories 원피스` — 카테고리 검색(이름 · 경로 · cat_id)', '- `/naver-shopping categories 원피스` — 카테고리 검색(이름 · 경로 · cat_id)',
'', '',
@@ -119,6 +120,29 @@ async function runShopping(arg: string, view: Webview, context?: vscode.Extensio
const ai = new AIService(); const ai = new AIService();
const intent = await parseShoppingIntent(ai, trimmed, ymd(new Date())); 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 resolved: Array<{ name: string; path?: string; code: string }> = [];
const unresolved: string[] = []; const unresolved: string[] = [];
for (const name of intent.categories) { for (const name of intent.categories) {
@@ -132,13 +156,6 @@ async function runShopping(arg: string, view: Webview, context?: vscode.Extensio
return true; 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`); chunk(view, `\n📊 조회 중 — ${resolved.map(r => r.name).join(', ')} (${range.startDate} ~ ${range.endDate})…\n`);
const result = await rankCategories(context, resolved, range, filters); const result = await rankCategories(context, resolved, range, filters);
+26 -5
View File
@@ -20,7 +20,17 @@ export type IntentMode = 'compare' | 'discover';
export interface ShoppingIntent { export interface ShoppingIntent {
mode: IntentMode; mode: IntentMode;
/**
* 'category' = 분야 자체의 클릭 추이/비교를 원함(쇼핑인사이트 API 그대로 처리).
* 'keywords' = 그 분야 "안에서" 어떤 상품/검색어가 인기인지 궁금함(예: "디지털/가전
* 인기 검색어", "요즘 뭐가 잘 팔려") — 쇼핑인사이트엔 분야 내 개별 키워드 순위를
* 주는 API가 없어(카테고리 자체의 클릭 추이만 제공), 검색어트렌드 API로 우회
* 검증한다(handlers.ts 에서 rankKeywords 재사용).
*/
scope: 'category' | 'keywords';
categories: string[]; categories: string[];
/** scope='keywords' 일 때만 사용 — 그 분야 맥락에서 제안된 검색어 후보. */
keywords: string[];
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
timeUnit?: 'date' | 'week' | 'month'; timeUnit?: 'date' | 'week' | 'month';
@@ -132,12 +142,20 @@ function _buildUserMessage(userText: string, todayYmd: string): string {
const SHOPPING_SYSTEM_PROMPT = `당신은 네이버 쇼핑인사이트(분야별 트렌드) API 질의 파서입니다. 사용자의 자연어 질문을 분석해 아래 JSON 하나만 출력하세요. 다른 텍스트(설명, 코드펜스, 머리말) 절대 금지. const SHOPPING_SYSTEM_PROMPT = `당신은 네이버 쇼핑인사이트(분야별 트렌드) API 질의 파서입니다. 사용자의 자연어 질문을 분석해 아래 JSON 하나만 출력하세요. 다른 텍스트(설명, 코드펜스, 머리말) 절대 금지.
- "mode": 사용자가 구체적인 분야명(예: "패션의류", "화장품", "가전")을 하나 이상 언급했으면 "compare". "요즘 뭐가 뜨나"/"인기 분야"/"top N" 처럼 분야를 직접 지목하지 않고 탐색을 원하면 "discover". ⚠️ 중요한 API 제약: 쇼핑인사이트는 "분야 자체"의 클릭 추이만 제공하고, 분야 "안에서" 어떤 상품/검색어가 인기인지는 이 API로 알 수 없다(그런 API 자체가 없음). 이 구분이 scope 필드다.
- "categories": mode="compare"면 사용자가 언급한 분야명을 그대로(정규화하지 말고 원문 그대로) 배열로. mode="discover"면 질문 맥락에 맞는 분야 후보를 당신의 지식으로 최대 9개 제안(3개씩 묶어 순차 조회하므로 3의 배수 권장).
- "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} ${COMMON_FIELDS_PROMPT}
⚠️ 반드시 아래 JSON 형식 정확히 한 번: ⚠️ 반드시 아래 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( export async function parseShoppingIntent(
ai: IAIService, ai: IAIService,
@@ -157,18 +175,21 @@ export async function parseShoppingIntent(
raw = result.content || ''; raw = result.content || '';
} catch (e: any) { } catch (e: any) {
logError('naverTrend intentParser(shopping): AI 호출 실패 — compare 모드로 원문 사용.', { error: e?.message ?? String(e) }); 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); const obj = _parseJsonLoose(raw);
if (!obj) { if (!obj) {
logInfo('naverTrend intentParser(shopping): 파싱 실패 — compare 모드로 원문 사용.', { rawHead: raw.slice(0, 100) }); 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 categories = _asStringArray(obj.categories);
const scope = _asScope(obj.scope);
return { return {
mode: _asMode(obj.mode), mode: _asMode(obj.mode),
scope,
categories: categories.length > 0 ? categories : [trimmed], categories: categories.length > 0 ? categories : [trimmed],
keywords: scope === 'keywords' ? _asStringArray(obj.keywords) : [],
startDate: _asDate(obj.startDate), startDate: _asDate(obj.startDate),
endDate: _asDate(obj.endDate), endDate: _asDate(obj.endDate),
timeUnit: _asTimeUnit(obj.timeUnit), timeUnit: _asTimeUnit(obj.timeUnit),
+16
View File
@@ -85,6 +85,22 @@ describe('parseShoppingIntent — tolerant JSON 파싱', () => {
expect(r.mode).toBe('compare'); expect(r.mode).toBe('compare');
expect(r.categories).toEqual(['패션의류 트렌드']); expect(r.categories).toEqual(['패션의류 트렌드']);
}); });
test('scope="keywords" — 분야 자체가 아니라 분야 "안"의 인기 검색어를 묻는 질문', async () => {
const ai = mockAi('{"scope":"keywords","mode":"discover","categories":["디지털/가전"],"keywords":["무선이어폰","보조배터리","블루투스스피커"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}');
const r = await parseShoppingIntent(ai, '디지털/가전 인기 검색어', '2026-08-04');
expect(r.parsed).toBe(true);
expect(r.scope).toBe('keywords');
expect(r.categories).toEqual(['디지털/가전']);
expect(r.keywords).toEqual(['무선이어폰', '보조배터리', '블루투스스피커']);
});
test('scope 미명시 응답은 기존 동작과 동일하게 category 로 기본값 처리', async () => {
const ai = mockAi('{"mode":"compare","categories":["패션의류"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}');
const r = await parseShoppingIntent(ai, '패션의류 트렌드', '2026-08-04');
expect(r.scope).toBe('category');
expect(r.keywords).toEqual([]);
});
}); });
describe('parseSearchTrendIntent — tolerant JSON 파싱', () => { describe('parseSearchTrendIntent — tolerant JSON 파싱', () => {