diff --git a/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json b/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json index 92fcd55..08abe79 100644 --- a/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json +++ b/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json @@ -1,5 +1,5 @@ { "result": "직답 결과 — single-pass mock 응답입니다.", - "createdAt": 1785978343885, + "createdAt": 1785979804062, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json b/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json index 7639f08..02f8d5f 100644 --- a/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json +++ b/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json @@ -1,5 +1,5 @@ { - "result": "---\nid: wiki_on\ndate: 2026-08-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", - "createdAt": 1785978343891, + "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": 1785979804069, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/engine/.astra/missions/wiki_on.json b/.astra/tests/engine/.astra/missions/wiki_on.json index 286a5ca..812410b 100644 --- a/.astra/tests/engine/.astra/missions/wiki_on.json +++ b/.astra/tests/engine/.astra/missions/wiki_on.json @@ -1,8 +1,8 @@ { "missionId": "wiki_on", "status": "completed", - "startTime": "2026-08-06T01:05:43.866Z", - "totalElapsedMs": 27, + "startTime": "2026-08-06T01:30:04.033Z", + "totalElapsedMs": 39, "results": { "direct": "직답 결과 — single-pass mock 응답입니다." }, @@ -12,16 +12,16 @@ { "from": "idle", "to": "direct", - "durationMs": 17, + "durationMs": 27, "message": "답변 작성 중... (단일 호출 fast-path)", - "ts": "2026-08-06T01:05:43.883Z" + "ts": "2026-08-06T01:30:04.060Z" }, { "from": "direct", "to": "completed", - "durationMs": 9, + "durationMs": 11, "message": "미션 완료", - "ts": "2026-08-06T01:05:43.892Z" + "ts": "2026-08-06T01:30:04.071Z" } ], "resilienceMetrics": { diff --git a/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json b/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json index 78164e7..b1ec87d 100644 --- a/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json +++ b/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json @@ -1,5 +1,5 @@ { "result": "Final report with inconsistencies. This should be long enough to pass validation.", - "createdAt": 1785978351712, + "createdAt": 1785979811217, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json b/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json index 9c9c63e..9d9f791 100644 --- a/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json +++ b/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json @@ -1,5 +1,5 @@ { "result": "Final report with inconsistencies. This should be long enough to pass validation.", - "createdAt": 1785978351711, + "createdAt": 1785979811215, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json b/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json index 866f454..62a1855 100644 --- a/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json +++ b/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json @@ -1,5 +1,5 @@ { "result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", - "createdAt": 1785978351707, + "createdAt": 1785979811209, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json b/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json index ec860e9..f84c82f 100644 --- a/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json +++ b/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json @@ -1,5 +1,5 @@ { "result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", - "createdAt": 1785978351709, + "createdAt": 1785979811212, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/missions/stress_conflict_1785978351690.json b/.astra/tests/stress/.astra/missions/stress_conflict_1785979811195.json similarity index 78% rename from .astra/tests/stress/.astra/missions/stress_conflict_1785978351690.json rename to .astra/tests/stress/.astra/missions/stress_conflict_1785979811195.json index cc3d2a8..c88548d 100644 --- a/.astra/tests/stress/.astra/missions/stress_conflict_1785978351690.json +++ b/.astra/tests/stress/.astra/missions/stress_conflict_1785979811195.json @@ -1,7 +1,7 @@ { - "missionId": "stress_conflict_1785978351690", + "missionId": "stress_conflict_1785979811195", "status": "completed", - "startTime": "2026-08-06T01:05:51.690Z", + "startTime": "2026-08-06T01:30:11.195Z", "totalElapsedMs": 23, "results": { "outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", @@ -14,30 +14,30 @@ { "from": "idle", "to": "outline", - "durationMs": 16, + "durationMs": 13, "message": "답변 구조 잡는 중...", - "ts": "2026-08-06T01:05:51.706Z" + "ts": "2026-08-06T01:30:11.208Z" }, { "from": "outline", "to": "section", "durationMs": 3, "message": "본문 작성 중...", - "ts": "2026-08-06T01:05:51.709Z" + "ts": "2026-08-06T01:30:11.211Z" }, { "from": "section", "to": "polish", - "durationMs": 1, + "durationMs": 3, "message": "최종 다듬기 중...", - "ts": "2026-08-06T01:05:51.710Z" + "ts": "2026-08-06T01:30:11.214Z" }, { "from": "polish", "to": "completed", - "durationMs": 3, + "durationMs": 4, "message": "미션 완료", - "ts": "2026-08-06T01:05:51.713Z" + "ts": "2026-08-06T01:30:11.218Z" } ], "resilienceMetrics": { diff --git a/PATCHNOTES.md b/PATCHNOTES.md index 8dbe840..8ef13ae 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -1,5 +1,12 @@ # 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) ### 🐛 `/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() 그대로라 이 클래스의 버그를 테스트로 못 잡는다(실제로 기존 유닛 테스트는 전부 통과했었음). diff --git a/package.json b/package.json index 2651f98..0e6e094 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "astra", "displayName": "Astra", "description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.", - "version": "2.2.320", + "version": "2.2.321", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", diff --git a/src/features/naverTrend/handlers.ts b/src/features/naverTrend/handlers.ts index 622d051..f331a26 100644 --- a/src/features/naverTrend/handlers.ts +++ b/src/features/naverTrend/handlers.ts @@ -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); diff --git a/src/features/naverTrend/intentParser.ts b/src/features/naverTrend/intentParser.ts index 39c42a2..b81a1dd 100644 --- a/src/features/naverTrend/intentParser.ts +++ b/src/features/naverTrend/intentParser.ts @@ -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), diff --git a/tests/naverTrend.test.ts b/tests/naverTrend.test.ts index 0064227..7250f10 100644 --- a/tests/naverTrend.test.ts +++ b/tests/naverTrend.test.ts @@ -85,6 +85,22 @@ describe('parseShoppingIntent — tolerant JSON 파싱', () => { expect(r.mode).toBe('compare'); 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 파싱', () => {