diff --git a/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json b/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json index 12f38b9..1815cff 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": 1786009859146, + "createdAt": 1786452145565, "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 6e55f32..71499d4 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-06T09:50:59.150Z\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) (23ms)\n", - "createdAt": 1786009859151, + "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", + "createdAt": 1786452145565, "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 90401fe..ff49b19 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-06T09:50:59.117Z", - "totalElapsedMs": 40, + "startTime": "2026-08-11T12:42:25.546Z", + "totalElapsedMs": 19, "results": { "direct": "직답 결과 — single-pass mock 응답입니다." }, @@ -12,16 +12,16 @@ { "from": "idle", "to": "direct", - "durationMs": 23, + "durationMs": 18, "message": "답변 작성 중... (단일 호출 fast-path)", - "ts": "2026-08-06T09:50:59.140Z" + "ts": "2026-08-11T12:42:25.564Z" }, { "from": "direct", "to": "completed", - "durationMs": 14, + "durationMs": 1, "message": "미션 완료", - "ts": "2026-08-06T09:50:59.154Z" + "ts": "2026-08-11T12:42:25.565Z" } ], "resilienceMetrics": { diff --git a/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json b/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json index 24e7e00..ca5d255 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": 1786009865515, + "createdAt": 1786452152471, "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 302b8c0..ca5d255 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": 1786009865514, + "createdAt": 1786452152471, "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 458404c..f19383f 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": 1786009865510, + "createdAt": 1786452152459, "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 875eff0..6716c1e 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": 1786009865512, + "createdAt": 1786452152465, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/missions/stress_conflict_1786009865491.json b/.astra/tests/stress/.astra/missions/stress_conflict_1786452152446.json similarity index 75% rename from .astra/tests/stress/.astra/missions/stress_conflict_1786009865491.json rename to .astra/tests/stress/.astra/missions/stress_conflict_1786452152446.json index 8a3f983..afe276c 100644 --- a/.astra/tests/stress/.astra/missions/stress_conflict_1786009865491.json +++ b/.astra/tests/stress/.astra/missions/stress_conflict_1786452152446.json @@ -1,7 +1,7 @@ { - "missionId": "stress_conflict_1786009865491", + "missionId": "stress_conflict_1786452152446", "status": "completed", - "startTime": "2026-08-06T09:51:05.491Z", + "startTime": "2026-08-11T12:42:32.446Z", "totalElapsedMs": 25, "results": { "outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", @@ -14,30 +14,30 @@ { "from": "idle", "to": "outline", - "durationMs": 18, + "durationMs": 12, "message": "답변 구조 잡는 중...", - "ts": "2026-08-06T09:51:05.509Z" + "ts": "2026-08-11T12:42:32.458Z" }, { "from": "outline", "to": "section", - "durationMs": 2, + "durationMs": 1, "message": "본문 작성 중...", - "ts": "2026-08-06T09:51:05.511Z" + "ts": "2026-08-11T12:42:32.459Z" }, { "from": "section", "to": "polish", - "durationMs": 2, + "durationMs": 6, "message": "최종 다듬기 중...", - "ts": "2026-08-06T09:51:05.513Z" + "ts": "2026-08-11T12:42:32.465Z" }, { "from": "polish", "to": "completed", - "durationMs": 3, + "durationMs": 6, "message": "미션 완료", - "ts": "2026-08-06T09:51:05.516Z" + "ts": "2026-08-11T12:42:32.471Z" } ], "resilienceMetrics": { diff --git a/PATCHNOTES.md b/PATCHNOTES.md index 679d2ef..6c4193d 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -1,5 +1,15 @@ # Astra Patch Notes +## v2.2.323 (2026-08-11) +### 🔎 `/naver-trend` contains 모드 — "분야 + 포함단어 + 기간"으로 검색어 뽑기 +사용자 요청: `/naver-trend 경제 방법 8월1일부터 오늘까지` 처럼 쓰면 경제 분야 인기 검색어 중 "방법"이 들어간 것을 검색량 상위 10개로 달라. 기존엔 `<카테고리> <기간>` 조회만 가능했다. +- **contains 모드 추가(intentParser)**: `compare`/`discover` 에 이어 세 번째 모드. LLM 이 `topic`(분야)·`containsWord`(포함 단어)·`limit` 을 뽑고, 그 분야에서 실제 검색될 법하면서 해당 단어를 포함하는 후보를 최대 30개 제안한다. **포함 여부는 LLM 을 믿지 않고 `keywordContains()` 로 코드에서 재검사**(공백 무시, 단어 자체만인 후보 제외). 후보가 얇으면 `generateContainingKeywords()` 로 한 번 더 채운 뒤 검증에 들어간다. `containsWord` 가 비면 필터 없이 조회되는 사고를 막으려 discover 로 강등. +- **배치 경계를 넘는 순위 보정(`rankKeywordsAnchored`)**: 검색어트렌드 ratio 는 *요청 하나 안에서* 최댓값을 100 으로 두는 상대값이라, 기존 `rankKeywords` 처럼 5개씩 끊어 호출하면 배치마다 기준이 달라져 6위 이하 순위가 사실상 무의미했다(후보 30개를 줄 세우려면 치명적). 첫 배치 최댓값 키워드를 **앵커로 잡아 이후 모든 배치에 끼워 넣고 `앵커의 1배치 값 / 현재 배치 값` 으로 배치 전체를 되돌린 뒤** 1위=100 으로 재정규화한다. 정렬 기준도 하루치 최신값이 아니라 조회 구간 평균(`avgRatio`) — 기간 질문의 대표값은 이쪽이다. +- **한국어 기간 파서 신설(`dateRange.ts`)**: 기간은 결과 전체를 좌우하는 축인데 LLM 이 "오늘"과 연도 추론을 자주 틀린다. `8월1일부터 오늘까지` · `8/1~8/10` · `2026년 8월 1일부터` · `최근 일주일` · `지난 30일` · `최근 한 달` 을 규칙으로 먼저 확정하고, 못 읽은 경우에만 LLM 판정으로 넘긴다. 연도 생략 시 미래 날짜면 작년으로 해석. 범위 연결어(부터/~/까지) 없는 날짜 나열이나 버전 문자열(`2.2.322`)은 기간으로 오독하지 않는다. +- **날짜 UTC 밀림 수정**: `toISOString().slice(0,10)` 은 UTC 로 잘려 KST 새벽 0~9시에 "오늘"이 하루 전으로 밀렸다 — 로컬 시간 기준 `ymdLocal()` 로 교체. 긴 구간은 timeUnit 을 자동으로 week/month 로 낮춘다(일 단위로 뽑으면 포인트가 수백 개). +- **한계 명시**: 네이버엔 "분야 안의 인기 검색어 목록" API 가 없다(지정 키워드의 상대 지수만 반환). 후보 생성은 LLM, 검증·랭킹은 실제 API 인 2단 구조이므로 "후보에 없던 검색어는 순위에 나올 수 없음"을 결과 하단에 고정 표시. +검증: tsc 무오류 + jest 1044 통과(신규 24) + esbuild 정상. + ## v2.2.322 (2026-08-05) ### 🛍️ 네이버 트렌드 — 구체적 모델명 제안 + ratio 라벨 명확화 사용자 피드백: 인기 키워드 결과에 "노트북"처럼 카테고리명만 반복돼 실제 쓸 정보가 없음(구체적 모델까지 알고 싶음) + "클릭률"까지 보고 싶음. diff --git a/package-lock.json b/package-lock.json index b695cb5..f1640d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "astra", - "version": "2.2.250", + "version": "2.2.323", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "astra", - "version": "2.2.250", + "version": "2.2.323", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", diff --git a/package.json b/package.json index 37a05c0..c99f743 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.322", + "version": "2.2.323", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", diff --git a/src/features/naverTrend/dateRange.ts b/src/features/naverTrend/dateRange.ts new file mode 100644 index 0000000..8ccd7aa --- /dev/null +++ b/src/features/naverTrend/dateRange.ts @@ -0,0 +1,183 @@ +/** + * 한국어 기간 표현 → yyyy-mm-dd 결정적 파싱. + * + * intentParser 는 기간도 LLM 에 맡기지만, "8월1일부터 오늘까지" 같은 표현은 + * LLM 이 연도를 틀리거나(작년/올해) "오늘"을 학습 시점 날짜로 착각하는 사고가 + * 잦다. 기간은 결과 전체를 좌우하는 축이라 규칙으로 먼저 확정하고, 못 읽은 + * 경우에만 LLM 판정으로 넘긴다. + * + * ⚠️ 날짜는 반드시 로컬 시간 기준 — `toISOString().slice(0,10)` 은 UTC 로 + * 잘려서 KST 새벽에 "오늘"이 하루 전으로 밀린다. + */ + +export interface ParsedDateRange { + startDate: string; + endDate: string; + /** 원문에서 기간 표현이 차지한 구간 — 나머지 텍스트(분야/단어) 추출에 사용. */ + matchedText: string; +} + +/** 로컬 시간 기준 yyyy-mm-dd. */ +export function ymdLocal(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +function addDays(d: Date, n: number): Date { + const out = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + out.setDate(out.getDate() + n); + return out; +} + +function addMonths(d: Date, n: number): Date { + const out = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + out.setMonth(out.getMonth() + n); + return out; +} + +/** + * 연도 없는 "8월 1일" → 올해로 두되, 그 날짜가 오늘보다 미래면 작년으로. + * (8월 11일에 "12월 1일"이라고 하면 지난 12월을 뜻하는 게 자연스럽다.) + */ +function withInferredYear(month: number, day: number, today: Date): Date { + const candidate = new Date(today.getFullYear(), month - 1, day); + if (candidate.getTime() > today.getTime()) return new Date(today.getFullYear() - 1, month - 1, day); + return candidate; +} + +interface DateToken { + index: number; + length: number; + date: Date; +} + +/** + * 날짜 토큰 스캐너. 우선순위가 높은(더 구체적인) 패턴부터 붙이고, 이미 매칭된 + * 구간과 겹치는 뒤쪽 패턴은 버린다 — "2026년 8월 1일"이 "8월 1일"로 또 잡히는 + * 중복을 막기 위함. + */ +const TOKEN_PATTERNS: Array<{ + re: RegExp; + toDate: (m: RegExpExecArray, today: Date) => Date | null; +}> = [ + // 2026년 8월 1일 / 2026-08-01 / 2026.8.1 / 2026/8/1 + { + re: /(\d{4})\s*[년./-]\s*(\d{1,2})\s*[월./-]\s*(\d{1,2})\s*일?/g, + toDate: m => { + const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])]; + return mo >= 1 && mo <= 12 && d >= 1 && d <= 31 ? new Date(y, mo - 1, d) : null; + }, + }, + // 8월 1일 + { + re: /(\d{1,2})\s*월\s*(\d{1,2})\s*일?/g, + toDate: (m, today) => { + const [mo, d] = [Number(m[1]), Number(m[2])]; + return mo >= 1 && mo <= 12 && d >= 1 && d <= 31 ? withInferredYear(mo, d, today) : null; + }, + }, + // 8/1 — 앞뒤에 숫자가 붙지 않는 경우만(버전 문자열 "2.2.322" 오인 방지) + { + re: /(? { + const [mo, d] = [Number(m[1]), Number(m[2])]; + return mo >= 1 && mo <= 12 && d >= 1 && d <= 31 ? withInferredYear(mo, d, today) : null; + }, + }, + { re: /오늘|현재|지금/g, toDate: (_m, today) => today }, + { re: /어제|전일/g, toDate: (_m, today) => addDays(today, -1) }, +]; + +function scanTokens(text: string, today: Date): DateToken[] { + const tokens: DateToken[] = []; + for (const { re, toDate } of TOKEN_PATTERNS) { + re.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + const start = m.index; + const end = m.index + m[0].length; + const overlaps = tokens.some(t => start < t.index + t.length && end > t.index); + if (overlaps) continue; + const date = toDate(m, today); + if (date) tokens.push({ index: start, length: m[0].length, date }); + } + } + return tokens.sort((a, b) => a.index - b.index); +} + +/** "최근 일주일", "지난 30일", "최근 3개월", "요즘 한 달" 같은 상대 기간. */ +const RELATIVE_RE = /(?:최근|지난|요즘)\s*(\d+|한|두|세|네|다섯|여섯|열)?\s*(일주일|주일|주간|주|개월|달|일|년)/; + +const KOREAN_NUM: Record = { 한: 1, 두: 2, 세: 3, 네: 4, 다섯: 5, 여섯: 6, 열: 10 }; + +function parseRelative(text: string, today: Date): ParsedDateRange | null { + const m = text.match(RELATIVE_RE); + if (!m) return null; + const rawN = m[1] ? (KOREAN_NUM[m[1]] ?? Number(m[1])) : NaN; + const unit = m[2]; + let start: Date; + if (unit === '일주일' || unit === '주일' || unit === '주간' || unit === '주') { + const weeks = Number.isFinite(rawN) ? rawN : 1; + start = addDays(today, -(weeks * 7) + 1); + } else if (unit === '개월' || unit === '달') { + const months = Number.isFinite(rawN) ? rawN : 1; + start = addDays(addMonths(today, -months), 1); + } else if (unit === '년') { + const years = Number.isFinite(rawN) ? rawN : 1; + start = addDays(addMonths(today, -12 * years), 1); + } else { + const days = Number.isFinite(rawN) ? rawN : 7; + start = addDays(today, -days + 1); + } + return { startDate: ymdLocal(start), endDate: ymdLocal(today), matchedText: m[0] }; +} + +/** + * 한국어 기간 표현을 파싱. 읽어내지 못하면 null (호출자가 LLM 판정/기본값 사용). + * + * 지원: "8월1일부터 오늘까지", "8/1~오늘", "2026년 8월 1일 ~ 8월 10일", + * "8월 1일부터", "최근 일주일", "지난 30일". + */ +export function parseKoreanDateRange(text: string, today: Date = new Date()): ParsedDateRange | null { + if (!text || !text.trim()) return null; + const normalized = text.replace(/[~〜∼]/g, '~'); + const base = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + + const tokens = scanTokens(normalized, base); + + if (tokens.length >= 2) { + const [a, b] = tokens; + const between = normalized.slice(a.index + a.length, b.index); + // 두 날짜 사이에 범위 연결어가 있어야 기간으로 인정 — "8월 1일 방법 3일차" 같은 + // 무관한 숫자 나열을 기간으로 오독하지 않기 위함. + if (/부터|에서|~|-|—|–|to/i.test(between)) { + const tail = normalized.slice(b.index + b.length); + const closing = tail.match(/^\s*(?:까지|사이|간)?/); + const end = b.index + b.length + (closing ? closing[0].length : 0); + const [start, finish] = a.date <= b.date ? [a.date, b.date] : [b.date, a.date]; + return { + startDate: ymdLocal(start), + endDate: ymdLocal(finish), + matchedText: normalized.slice(a.index, end), + }; + } + } + + if (tokens.length >= 1) { + const t = tokens[0]; + const tail = normalized.slice(t.index + t.length); + // "8월 1일부터" — 끝을 안 밝혔으면 오늘까지. + const openEnded = tail.match(/^\s*(?:부터|이후|이래로|에서)/); + if (openEnded && t.date <= base) { + return { + startDate: ymdLocal(t.date), + endDate: ymdLocal(base), + matchedText: normalized.slice(t.index, t.index + t.length + openEnded[0].length), + }; + } + } + + return parseRelative(normalized, base); +} diff --git a/src/features/naverTrend/handlers.ts b/src/features/naverTrend/handlers.ts index 1e74270..cd1c5af 100644 --- a/src/features/naverTrend/handlers.ts +++ b/src/features/naverTrend/handlers.ts @@ -17,18 +17,23 @@ import * as vscode from 'vscode'; import { registerSlashCommand, chunk } from '../datacollect/slashRouter'; import { AIService } from '../../core/services'; -import { parseShoppingIntent, parseSearchTrendIntent } from './intentParser'; +import { + parseShoppingIntent, parseSearchTrendIntent, generateContainingKeywords, + parseContainsShorthand, keywordContains, +} from './intentParser'; import { findCategoryCode, listTopLevelCategoryNames, ROOT_CATEGORY_CODES, type CategoryMatch } from './categoryCodes'; -import { rankCategories, rankKeywords, type RankedResult } from './rankEngine'; +import { rankCategories, rankKeywords, rankKeywordsAnchored, type RankedResult, type DateRange } from './rankEngine'; +import { parseKoreanDateRange, ymdLocal } from './dateRange'; import type { CommonFilters, TimeUnit } from './naverApi'; type Webview = Parameters[0]; const DISCLAIMER = '⚠️ 이 순위는 네이버 전체 실시간 인기 순위가 아니라, 후보군 내에서 실제 네이버 데이터랩 API로 검증한 상대 비교치입니다. "ratio"는 조회 구간 내 최댓값을 100으로 둔 상대 지수이며, 실제 클릭수·노출수 기반 클릭률(CTR, %)이 아닙니다 — 네이버 데이터랩 API 자체가 CTR을 제공하지 않습니다.'; -function ymd(d: Date): string { - return d.toISOString().slice(0, 10); -} +/** contains 모드에서 API로 검증할 후보 상한 — 5개 + 4개씩이라 30개면 호출 약 7회. */ +const CONTAINS_POOL_MAX = 30; + +const ymd = ymdLocal; /** 기본 조회 기간 — "오늘"은 데이터랩 집계가 안 됐을 수 있어 어제를 종료일로. */ function defaultRange(): { startDate: string; endDate: string; timeUnit: TimeUnit } { @@ -39,6 +44,15 @@ function defaultRange(): { startDate: string; endDate: string; timeUnit: TimeUni return { startDate: ymd(start), endDate: ymd(end), timeUnit: 'date' }; } +/** 긴 구간을 'date' 로 뽑으면 데이터 포인트가 수백 개라 무의미 — 구간 길이로 단위 결정. */ +function pickTimeUnit(startDate: string, endDate: string): TimeUnit { + const days = (Date.parse(endDate) - Date.parse(startDate)) / 86_400_000; + if (!Number.isFinite(days)) return 'date'; + if (days > 400) return 'month'; + if (days > 180) return 'week'; + return 'date'; +} + function buildFilters(intent: { device?: 'pc' | 'mo'; gender?: 'm' | 'f'; ages?: string[] }): CommonFilters { const filters: CommonFilters = {}; if (intent.device) filters.device = intent.device; @@ -59,6 +73,18 @@ function formatRankedTable(result: RankedResult, itemLabel: string): string { return lines.join('\n') + '\n'; } +/** contains 모드 표 — 하루치 최신값이 아니라 조회 구간 평균으로 줄 세운다. */ +function formatContainsTable(items: RankedResult['items']): string { + if (items.length === 0) return '_(결과 없음)_\n'; + const lines: string[] = []; + lines.push('| # | 검색어 | 기간 평균 지수(1위=100) |'); + lines.push('|---|---|---|'); + items.forEach((it, i) => { + lines.push(`| ${i + 1} | ${it.name} | ${it.avgRatio.toFixed(1)} |`); + }); + return lines.join('\n') + '\n'; +} + function formatErrors(errors: string[]): string { if (errors.length === 0) return ''; return '\n' + errors.map(e => `> ⚠️ ${e}`).join('\n') + '\n'; @@ -182,12 +208,73 @@ function cmdSearchTrendHelp(view: Webview): void { '', '- `/naver-trend 원피스,블라우스 비교` — 명시 키워드 비교', '- `/naver-trend 20대 여성한테 인기있는 원피스 스타일 키워드 10개` — 탐색(후보 제안 + 검증)', + '- `/naver-trend 경제 방법 8월1일부터 오늘까지` — **`<분야> <포함단어> <기간>`**: 그 분야 인기 검색어 중 해당 단어가 들어간 것만 검색량 상위 10개', + '', + '기간 표현: `8월1일부터 오늘까지`, `8/1~8/10`, `최근 일주일`, `지난 30일` 등 (생략 시 최근 7일)', '', '- API 키 설정: Astra 설정 → Naver Trend 탭에서 Client ID/Secret 입력', '', ].join('\n')); } +/** 후보로 쓸 만한 검색어인지 — 질문 원문이 통째로 후보에 섞여 들어오는 걸 막는다. */ +function isPlausibleKeyword(k: string): boolean { + const t = k.trim(); + return t.length > 0 && t.length <= 25 && t.split(/\s+/).length <= 5; +} + +/** + * `<분야> <포함단어> <기간>` — 그 분야의 인기 검색어 중 특정 단어가 들어간 것을 + * 검색량 상위 N개로. + * + * 데이터랩엔 "분야 내 인기 검색어 목록" API 가 없어서(지정 키워드의 상대 지수만 + * 반환) 후보 생성은 LLM, 검증·랭킹은 API 가 맡는 2단 구조다. 포함 여부는 LLM 을 + * 믿지 않고 코드에서 다시 거른다. + */ +async function runContainsMode( + view: Webview, + context: vscode.ExtensionContext, + ai: AIService, + opts: { + topic: string; + containsWord: string; + limit: number; + seeded: string[]; + range: DateRange; + filters: CommonFilters; + }, +): Promise { + const { topic, containsWord, range, filters } = opts; + const limit = Math.min(Math.max(opts.limit, 1), 20); + + let pool = [...new Set(opts.seeded.filter(k => isPlausibleKeyword(k) && keywordContains(k, containsWord)))]; + // 후보가 얇으면 순위가 후보 운에 좌우된다 — 최소 limit*2 는 확보하고 검증에 들어간다. + if (pool.length < limit * 2) { + chunk(view, `\n🧩 "${topic}" 분야에서 "${containsWord}" 들어간 검색어 후보 만드는 중…\n`); + const more = await generateContainingKeywords(ai, topic, containsWord, CONTAINS_POOL_MAX); + pool = [...new Set([...pool, ...more.filter(isPlausibleKeyword)])]; + } + pool = pool.slice(0, CONTAINS_POOL_MAX); + + if (pool.length === 0) { + chunk(view, `\n❓ "${topic}" 분야에서 "${containsWord}" 가 들어간 검색어 후보를 만들지 못했습니다. 분야를 더 구체적으로(예: "주식 투자") 적어보세요.\n`); + return true; + } + + chunk(view, `\n📊 조회 중 — 후보 ${pool.length}개를 네이버 검색어트렌드로 검증 (${range.startDate} ~ ${range.endDate})…\n`); + const result = await rankKeywordsAnchored(context, pool, range, filters); + 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; + + chunk(view, `\n**네이버 검색어트렌드 — "${topic}" 분야에서 "${containsWord}" 포함 검색어 TOP ${ranked.length}** (${range.startDate}~${range.endDate})\n\n`); + chunk(view, formatContainsTable(ranked)); + if (dropped > 0) chunk(view, `\n> ℹ️ 검색량이 잡히지 않은 후보 ${dropped}개는 제외했습니다.\n`); + chunk(view, formatErrors(result.errors)); + chunk(view, `\nℹ️ 네이버는 "분야 안의 인기 검색어 목록"을 주는 API가 없어, "${containsWord}" 가 들어간 검색어 후보 ${pool.length}개를 만든 뒤 실제 검색어트렌드로 검증해 줄 세웠습니다 — 후보에 없던 검색어는 순위에 나올 수 없습니다.\n`); + chunk(view, `\n${DISCLAIMER}\n`); + return true; +} + async function runSearchTrend(arg: string, view: Webview, context?: vscode.ExtensionContext): Promise { if (!context) { chunk(view, '\n⚠️ ExtensionContext 없음.\n'); return true; } const trimmed = arg.trim(); @@ -196,15 +283,51 @@ async function runSearchTrend(arg: string, view: Webview, context?: vscode.Exten try { chunk(view, '\n🔍 질문 분석 중…\n'); const ai = new AIService(); - const intent = await parseSearchTrendIntent(ai, trimmed, ymd(new Date())); + const today = new Date(); + // 기간은 규칙 파서를 우선 — LLM 은 "오늘"/연도 추론을 자주 틀린다. + const preRange = parseKoreanDateRange(trimmed, today); + const intent = await parseSearchTrendIntent(ai, trimmed, ymd(today)); - const range = { - startDate: intent.startDate || defaultRange().startDate, - endDate: intent.endDate || defaultRange().endDate, - timeUnit: intent.timeUnit || 'date' as TimeUnit, - }; + const fallback = defaultRange(); + const range = preRange + ? { + startDate: preRange.startDate, + endDate: preRange.endDate, + timeUnit: intent.timeUnit || pickTimeUnit(preRange.startDate, preRange.endDate), + } + : { + startDate: intent.startDate || fallback.startDate, + endDate: intent.endDate || fallback.endDate, + timeUnit: intent.timeUnit || 'date' as TimeUnit, + }; const filters = buildFilters(intent); + // `<분야> <포함단어> <기간>` — LLM 판정이 실패했을 때만 문법 규칙으로 건진다. + // 성공했는데 compare 로 봤다면 존중한다("원피스 블라우스 최근 일주일" 처럼 + // 두 낱말이 비교 대상인 경우와 구분할 근거가 문장 안에는 없기 때문). + let mode = intent.mode; + let topic = intent.topic; + let containsWord = intent.containsWord; + if (mode !== 'contains' && !intent.parsed && preRange) { + const shorthand = parseContainsShorthand(trimmed.replace(preRange.matchedText, ' ')); + if (shorthand) { + mode = 'contains'; + topic = shorthand.topic; + containsWord = shorthand.containsWord; + } + } + + if (mode === 'contains' && containsWord) { + return await runContainsMode(view, context, ai, { + topic: topic || trimmed, + containsWord, + limit: intent.limit ?? 10, + seeded: intent.parsed ? intent.keywords : [], + range, + filters, + }); + } + chunk(view, `\n📊 조회 중 — ${intent.keywords.join(', ')} (${range.startDate} ~ ${range.endDate})…\n`); const result = await rankKeywords(context, intent.keywords, range, filters); diff --git a/src/features/naverTrend/intentParser.ts b/src/features/naverTrend/intentParser.ts index 2072d8f..22e16aa 100644 --- a/src/features/naverTrend/intentParser.ts +++ b/src/features/naverTrend/intentParser.ts @@ -18,6 +18,17 @@ import { logError, logInfo } from '../../utils'; export type IntentMode = 'compare' | 'discover'; +/** + * 검색어트렌드 전용 모드. 'contains' 는 "분야 + 포함단어 + 기간" 형태의 질문 + * (예: `/naver-trend 경제 방법 8월1일부터 오늘까지`) — 그 분야의 인기 검색어 중 + * 특정 단어가 들어간 것만 골라 검색량순으로 뽑는다. + * + * 데이터랩엔 "분야 내 인기 검색어 목록" API 가 없으므로(지정한 키워드의 상대 + * 지수만 반환) LLM 이 후보를 넓게 만들고 → 포함 단어로 걸러낸 뒤 → 실제 API 로 + * 검증·랭킹하는 discover 계열 흐름을 그대로 쓴다. + */ +export type SearchTrendMode = IntentMode | 'contains'; + export interface ShoppingIntent { mode: IntentMode; /** @@ -41,8 +52,14 @@ export interface ShoppingIntent { } export interface SearchTrendIntent { - mode: IntentMode; + mode: SearchTrendMode; keywords: string[]; + /** mode='contains' 전용 — 검색어를 찾을 분야/주제 (예: "경제"). */ + topic?: string; + /** mode='contains' 전용 — 검색어에 반드시 들어가야 하는 단어 (예: "방법"). */ + containsWord?: string; + /** 사용자가 "10개"처럼 개수를 지정했을 때. 기본 10. */ + limit?: number; startDate?: string; endDate?: string; timeUnit?: 'date' | 'week' | 'month'; @@ -205,14 +222,45 @@ export async function parseShoppingIntent( const SEARCH_TREND_SYSTEM_PROMPT = `당신은 네이버 검색어트렌드 API 질의 파서입니다. 사용자의 자연어 질문을 분석해 아래 JSON 하나만 출력하세요. 다른 텍스트(설명, 코드펜스, 머리말) 절대 금지. -- "mode": 사용자가 구체적인 검색어를 하나 이상 언급했으면 "compare". "인기 검색어"/"요즘 뜨는 키워드"/"top N" 처럼 키워드를 직접 지목하지 않고 탐색을 원하면 "discover". -- "keywords": mode="compare"면 사용자가 언급한 검색어를 그대로 배열로. mode="discover"면 질문 맥락(주제/카테고리/타겟)에 맞는 검색어 후보를 당신의 지식으로 최대 10개 제안(개별 순위를 매기려면 키워드 1개=API 그룹 1개라 5개씩 순차 조회함을 감안). +- "mode": 셋 중 하나. + · "compare" — 사용자가 구체적인 검색어를 하나 이상 지목해 비교/조회를 원함. + · "discover" — "인기 검색어"/"요즘 뜨는 키워드"/"top N" 처럼 키워드를 지목하지 않고 탐색을 원함. + · "contains" — "<분야> <단어> <기간>" 형태로, **그 분야의 인기 검색어 중 특정 단어가 들어간 것**을 원함. 예: "경제 방법 8월1일부터 오늘까지"(경제 분야에서 '방법'이 들어간 검색어), "여행 추천 최근 한달", "부동산 투자 방법 알려줘". 분야명과 포함 단어 두 덩어리가 나란히 오면 대부분 이 모드다. +- "topic": mode="contains"일 때 분야/주제 (예: "경제"). 그 외에는 "". +- "containsWord": mode="contains"일 때 검색어에 반드시 들어가야 하는 단어 (예: "방법"). 그 외에는 "". 사용자가 쓴 형태 그대로, 조사(을/를/이/가/은/는) 없이. +- "limit": 사용자가 "10개"처럼 개수를 밝혔으면 그 숫자, 아니면 0. +- "keywords": + · mode="compare" — 사용자가 언급한 검색어를 그대로 배열로. + · mode="discover" — 질문 맥락(주제/카테고리/타겟)에 맞는 검색어 후보를 당신의 지식으로 최대 10개 제안. + · mode="contains" — topic 분야에서 실제로 많이 검색될 법하면서 **containsWord 를 글자 그대로 포함하는** 검색어를 최대 30개 제안(예: topic="경제", containsWord="방법" → "재테크 방법", "돈 모으는 방법", "주식 투자 방법", "절세 방법" …). containsWord 가 안 들어간 후보는 버려지니 넣지 말 것. containsWord 한 단어만 덜렁 넣는 것도 금지. ⚠️ 질문이 특정 상품군(가전/패션/뷰티 등)을 가리키면 그 상위 카테고리명 자체를 반복하지 말고 **구체적인 상품/모델명**(브랜드+제품라인명, 예: "맥북 에어 M3", "다이슨 에어랩")을 우선 제안 — 일반 명사만 나열하면 사용자가 얻을 정보가 없다. 여러 브랜드를 섞을 것. ${COMMON_FIELDS_PROMPT} - "ages" 값은 검색어트렌드 전용 코드(1=0~12세,2=13~18세,3=19~24세,4=25~29세,5=30~34세,6=35~39세,7=40~44세,8=45~49세,9=50~54세,10=55~59세,11=60세이상)를 사용하세요 — 쇼핑인사이트의 10/20/30 대 코드와 다릅니다. ⚠️ 반드시 아래 JSON 형식 정확히 한 번: -{"mode":"compare"|"discover","keywords":["..."],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}`; +{"mode":"compare"|"discover"|"contains","topic":"","containsWord":"","limit":0,"keywords":["..."],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}`; + +function _asSearchTrendMode(v: unknown): SearchTrendMode { + return v === 'discover' || v === 'contains' ? v : 'compare'; +} + +function _asLimit(v: unknown): number | undefined { + const n = typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : NaN; + return Number.isFinite(n) && n > 0 ? Math.min(Math.floor(n), 20) : undefined; +} + +function _asWord(v: unknown): string | undefined { + return typeof v === 'string' && v.trim() ? v.trim() : undefined; +} + +/** 공백/대소문자를 무시한 포함 검사 — "재테크 방법", "재테크방법" 둘 다 "방법" 포함으로 본다. */ +export function keywordContains(keyword: string, word: string): boolean { + const norm = (s: string) => s.replace(/\s+/g, '').toLowerCase(); + const k = norm(keyword); + const w = norm(word); + if (!w) return true; + return k.includes(w) && k !== w; +} export async function parseSearchTrendIntent( ai: IAIService, @@ -240,10 +288,18 @@ export async function parseSearchTrendIntent( logInfo('naverTrend intentParser(searchTrend): 파싱 실패 — compare 모드로 원문 사용.', { rawHead: raw.slice(0, 100) }); return { mode: 'compare', keywords: [trimmed], parsed: false }; } + const mode = _asSearchTrendMode(obj.mode); + const containsWord = _asWord(obj.containsWord); const keywords = _asStringArray(obj.keywords); + // contains 모드에서 포함 단어가 비면 성립하지 않는 요청 — discover 로 강등해 + // 최소한 후보 랭킹은 보여준다. + const effectiveMode: SearchTrendMode = mode === 'contains' && !containsWord ? 'discover' : mode; return { - mode: _asMode(obj.mode), + mode: effectiveMode, keywords: keywords.length > 0 ? keywords : [trimmed], + topic: effectiveMode === 'contains' ? (_asWord(obj.topic) || trimmed) : undefined, + containsWord: effectiveMode === 'contains' ? containsWord : undefined, + limit: _asLimit(obj.limit), startDate: _asDate(obj.startDate), endDate: _asDate(obj.endDate), timeUnit: _asTimeUnit(obj.timeUnit), @@ -253,3 +309,70 @@ export async function parseSearchTrendIntent( parsed: true, }; } + +// ── contains 모드 후보 보충 ─────────────────────────────────────────────── + +const CONTAINS_SYSTEM_PROMPT = `당신은 네이버 검색어 후보 생성기입니다. 주어진 분야에서 실제 사람들이 네이버에 많이 칠 법한 검색어를, **지정된 단어를 글자 그대로 포함하도록** 만들어 JSON 하나만 출력하세요. 다른 텍스트 절대 금지. + +규칙: +- 모든 후보는 지정 단어를 반드시 포함할 것(공백 유무는 무관). 안 들어간 후보는 폐기됩니다. +- 지정 단어 하나만 덜렁 쓰지 말 것 — 앞뒤에 맥락 단어를 붙인 실제 검색 구문으로. +- 그 분야에서 검색량이 실제로 나올 법한 구체적·대중적인 표현 위주. 지나치게 길거나(8어절+) 문장형인 것은 배제. +- 서로 다른 세부 주제를 폭넓게 커버할 것(같은 말 반복 금지). + +⚠️ 반드시 아래 JSON 형식 정확히 한 번: +{"keywords":["...","..."]}`; + +/** + * "<분야>에서 <단어>가 들어간 검색어" 후보를 LLM 으로 생성. + * + * parseSearchTrendIntent 가 이미 후보를 주지만(같은 호출에서 뽑음) 개수가 모자라거나 + * 포함 단어 필터에 많이 걸러진 경우 handlers 가 이 함수로 한 번 더 채운다. + * 반환 전 포함 검사를 다시 하므로 호출자는 그대로 써도 된다. + */ +export async function generateContainingKeywords( + ai: IAIService, + topic: string, + word: string, + count: number, + options: { model?: string; timeoutMs?: number } = {}, +): Promise { + let raw = ''; + try { + const result = await ai.chat({ + system: CONTAINS_SYSTEM_PROMPT, + user: [ + `분야/주제: ${topic}`, + `반드시 포함할 단어: ${word}`, + `개수: ${count}개`, + '', + 'JSON만 출력:', + ].join('\n'), + model: options.model, + timeoutMs: options.timeoutMs, + }); + raw = result.content || ''; + } catch (e: any) { + logError('naverTrend generateContainingKeywords: AI 호출 실패.', { error: e?.message ?? String(e) }); + return []; + } + const obj = _parseJsonLoose(raw); + if (!obj) { + logInfo('naverTrend generateContainingKeywords: 파싱 실패.', { rawHead: raw.slice(0, 100) }); + return []; + } + return _asStringArray(obj.keywords).filter(k => keywordContains(k, word)); +} + +/** + * `<분야> <단어>` 형태의 잔여 텍스트(기간 표현을 걷어낸 뒤)에서 분야/포함단어를 뽑는 + * 결정적 폴백. LLM 이 죽거나 모드를 잘못 잡아도 사용자가 문서화된 형태로 입력했다면 + * 동작하도록 한다. 두 덩어리가 아니면 null. + */ +export function parseContainsShorthand(text: string): { topic: string; containsWord: string } | null { + const tokens = text.trim().split(/\s+/).filter(Boolean); + if (tokens.length !== 2) return null; + const [topic, containsWord] = tokens; + if (!topic || !containsWord) return null; + return { topic, containsWord }; +} diff --git a/src/features/naverTrend/rankEngine.ts b/src/features/naverTrend/rankEngine.ts index 697f506..8d8cd91 100644 --- a/src/features/naverTrend/rankEngine.ts +++ b/src/features/naverTrend/rankEngine.ts @@ -23,6 +23,8 @@ export interface RankedItem { name: string; path?: string; latestRatio: number; + /** 조회 구간 전체 평균 ratio — 기간 단위 질문("8/1~오늘")은 마지막 하루보다 이쪽이 대표값. */ + avgRatio: number; series: TrendDataPoint[]; } @@ -46,6 +48,11 @@ function latestRatioOf(series: TrendDataPoint[]): number { return series.length ? series[series.length - 1].ratio : 0; } +function avgRatioOf(series: TrendDataPoint[]): number { + if (!series.length) return 0; + return series.reduce((sum, p) => sum + p.ratio, 0) / series.length; +} + export async function rankCategories( context: vscode.ExtensionContext, candidates: Array<{ name: string; path?: string; code: string }>, @@ -68,7 +75,7 @@ export async function rankCategories( } else { for (const r of res.data.results) { const match = batch.find(c => c.name === r.title); - items.push({ name: r.title, path: match?.path, latestRatio: latestRatioOf(r.data), series: r.data }); + items.push({ name: r.title, path: match?.path, latestRatio: latestRatioOf(r.data), avgRatio: avgRatioOf(r.data), series: r.data }); } } if (i < batches.length - 1) await delay(200); @@ -100,7 +107,7 @@ export async function rankKeywords( errors.push(`[${batch.join(', ')}] ${res.error}`); } else { for (const r of res.data.results) { - items.push({ name: r.title, latestRatio: latestRatioOf(r.data), series: r.data }); + items.push({ name: r.title, latestRatio: latestRatioOf(r.data), avgRatio: avgRatioOf(r.data), series: r.data }); } } if (i < batches.length - 1) await delay(200); @@ -109,3 +116,97 @@ export async function rankKeywords( items.sort((a, b) => b.latestRatio - a.latestRatio); return { ...range, items, errors }; } + +/** + * 배치 경계를 넘어 비교 가능한 키워드 랭킹. + * + * 검색어트렌드의 ratio 는 "요청 하나 안에서" 최댓값을 100 으로 두는 상대 지수라, + * 5개씩 끊어 여러 번 호출하면 배치마다 기준이 달라진다(rankKeywords 의 한계 — + * 후보 5개 이하일 때만 순위가 정확). 후보 30개를 진짜 검색량순으로 줄 세우려면 + * 공통 기준점이 필요하다. + * + * 그래서 첫 배치에서 값이 가장 큰 키워드를 앵커로 잡고, 이후 모든 배치에 + * 앵커를 끼워 넣어 함께 조회한 뒤 `앵커의 1배치 값 / 앵커의 현재 배치 값` 으로 + * 배치 전체를 되돌린다. 배치당 신규 키워드는 4개(앵커 1자리를 내주므로). + * + * 마지막에 1위를 100 으로 재정규화해 표시 단위를 통일한다. + */ +export async function rankKeywordsAnchored( + context: vscode.ExtensionContext, + candidates: string[], + range: DateRange, + filters: CommonFilters, +): Promise { + const items: RankedItem[] = []; + const errors: string[] = []; + const uniqueCandidates = [...new Set(candidates.map(k => k.trim()).filter(Boolean))]; + if (uniqueCandidates.length === 0) return { ...range, items, errors }; + + const fetchBatch = async (group: string[]) => fetchSearchTrend(context, { + ...range, + ...filters, + keywordGroups: group.map(k => ({ groupName: k, keywords: [k] })), + }); + + const head = uniqueCandidates.slice(0, 5); + const headRes = await fetchBatch(head); + if (!headRes.ok) { + // 앵커를 못 잡으면 정규화가 불가능 — 기존 배치 방식으로 떨어뜨리고 경고를 남긴다. + errors.push(`[${head.join(', ')}] ${headRes.error}`); + const fallback = await rankKeywords(context, uniqueCandidates, range, filters); + return { + ...fallback, + errors: [...errors, ...fallback.errors, '기준 키워드 조회에 실패해 배치 간 보정 없이 정렬했습니다 — 6위 이하 순위는 정확하지 않을 수 있습니다.'], + }; + } + for (const r of headRes.data.results) { + items.push({ name: r.title, latestRatio: latestRatioOf(r.data), avgRatio: avgRatioOf(r.data), series: r.data }); + } + + const anchorItem = items.reduce((best, it) => (it.avgRatio > (best?.avgRatio ?? 0) ? it : best), null); + const rest = uniqueCandidates.slice(5); + // 앵커가 없다(첫 배치 전부 0) = 기준점이 무의미 — 남은 배치는 5개씩 보정 없이 조회. + const perBatch = anchorItem ? 4 : 5; + const batches = chunkArray(rest, perBatch); + + for (let i = 0; i < batches.length; i++) { + const batch = batches[i]; + await delay(200); + const group = anchorItem ? [...batch, anchorItem.name] : batch; + const res = await fetchBatch(group); + if (!res.ok) { + errors.push(`[${batch.join(', ')}] ${res.error}`); + continue; + } + let factor = 1; + if (anchorItem) { + const anchorHere = res.data.results.find(r => r.title === anchorItem.name); + const anchorValue = anchorHere ? avgRatioOf(anchorHere.data) : 0; + if (anchorValue > 0) { + factor = anchorItem.avgRatio / anchorValue; + } else { + errors.push(`[${batch.join(', ')}] 기준 키워드("${anchorItem.name}") 값이 0이라 배치 간 보정을 적용하지 못했습니다.`); + } + } + for (const r of res.data.results) { + if (anchorItem && r.title === anchorItem.name) continue; + items.push({ + name: r.title, + latestRatio: latestRatioOf(r.data) * factor, + avgRatio: avgRatioOf(r.data) * factor, + series: r.data, + }); + } + } + + const max = items.reduce((m, it) => Math.max(m, it.avgRatio), 0); + if (max > 0) { + const scale = 100 / max; + for (const it of items) { + it.avgRatio *= scale; + it.latestRatio *= scale; + } + } + items.sort((a, b) => b.avgRatio - a.avgRatio); + return { ...range, items, errors }; +} diff --git a/tests/naverTrend.test.ts b/tests/naverTrend.test.ts index 7250f10..80af310 100644 --- a/tests/naverTrend.test.ts +++ b/tests/naverTrend.test.ts @@ -3,8 +3,19 @@ * 실제 네이버 API 호출은 통합 영역이라 제외 — 카테고리 매칭 + intent 파서(tolerant * JSON 파싱, AI 호출 mock)만 고정. */ +jest.mock('../src/features/naverTrend/naverApi', () => ({ + fetchSearchTrend: jest.fn(), + fetchShoppingCategoryTrend: jest.fn(), +})); + import { findCategoryCode, ROOT_CATEGORY_CODES } from '../src/features/naverTrend/categoryCodes'; -import { parseShoppingIntent, parseSearchTrendIntent } from '../src/features/naverTrend/intentParser'; +import { + parseShoppingIntent, parseSearchTrendIntent, generateContainingKeywords, + parseContainsShorthand, keywordContains, +} from '../src/features/naverTrend/intentParser'; +import { parseKoreanDateRange, ymdLocal } from '../src/features/naverTrend/dateRange'; +import { rankKeywordsAnchored } from '../src/features/naverTrend/rankEngine'; +import { fetchSearchTrend } from '../src/features/naverTrend/naverApi'; import type { IAIService, AIChatResult } from '../src/core/services'; import * as esbuild from 'esbuild'; import * as fs from 'fs'; @@ -128,6 +139,177 @@ describe('parseSearchTrendIntent — tolerant JSON 파싱', () => { }); }); +describe('parseKoreanDateRange — 기간 표현 결정적 파싱', () => { + const TODAY = new Date(2026, 7, 11); // 2026-08-11 + + test('"8월1일부터 오늘까지"', () => { + const r = parseKoreanDateRange('경제 방법 8월1일부터 오늘까지', TODAY); + expect(r).toMatchObject({ startDate: '2026-08-01', endDate: '2026-08-11' }); + }); + + test('matchedText 를 걷어내면 분야/포함단어만 남는다', () => { + const text = '경제 방법 8월1일부터 오늘까지'; + const r = parseKoreanDateRange(text, TODAY)!; + expect(text.replace(r.matchedText, ' ').trim().split(/\s+/)).toEqual(['경제', '방법']); + }); + + test('"8/1~8/10" 슬래시 표기', () => { + const r = parseKoreanDateRange('8/1~8/10', TODAY); + expect(r).toMatchObject({ startDate: '2026-08-01', endDate: '2026-08-10' }); + }); + + test('연도를 밝힌 표현', () => { + const r = parseKoreanDateRange('2026년 8월 1일부터 8월 10일까지', TODAY); + expect(r).toMatchObject({ startDate: '2026-08-01', endDate: '2026-08-10' }); + }); + + test('연도 없는 미래 날짜는 작년으로 해석 — 8월 기준 "12월 1일"은 지난 12월', () => { + const r = parseKoreanDateRange('12월 1일부터', TODAY); + expect(r).toMatchObject({ startDate: '2025-12-01', endDate: '2026-08-11' }); + }); + + test('상대 기간 — "최근 일주일"은 오늘 포함 7일', () => { + expect(parseKoreanDateRange('최근 일주일', TODAY)).toMatchObject({ startDate: '2026-08-05', endDate: '2026-08-11' }); + }); + + test('상대 기간 — "지난 30일"', () => { + expect(parseKoreanDateRange('지난 30일', TODAY)).toMatchObject({ startDate: '2026-07-13', endDate: '2026-08-11' }); + }); + + test('상대 기간 — 한글 수사 "최근 한 달"', () => { + expect(parseKoreanDateRange('최근 한 달', TODAY)).toMatchObject({ startDate: '2026-07-12', endDate: '2026-08-11' }); + }); + + test('기간 표현이 없으면 null', () => { + expect(parseKoreanDateRange('경제 방법 알려줘', TODAY)).toBeNull(); + }); + + test('두 날짜 사이에 범위 연결어가 없으면 기간으로 오독하지 않는다', () => { + expect(parseKoreanDateRange('8월 1일 방법 8월 10일 후기', TODAY)).toBeNull(); + }); + + test('버전 문자열의 점 표기를 날짜로 오독하지 않는다', () => { + expect(parseKoreanDateRange('2.2.322 릴리즈 노트', TODAY)).toBeNull(); + }); + + test('ymdLocal 은 UTC 로 밀리지 않는다 — KST 새벽 0시 30분', () => { + expect(ymdLocal(new Date(2026, 7, 11, 0, 30))).toBe('2026-08-11'); + }); +}); + +describe('contains 모드 — 분야 + 포함단어', () => { + test('keywordContains — 공백 무시, 단어 자체만인 후보는 제외', () => { + expect(keywordContains('재테크 방법', '방법')).toBe(true); + expect(keywordContains('재테크방법', '방법')).toBe(true); + expect(keywordContains('주식 투자', '방법')).toBe(false); + expect(keywordContains('방법', '방법')).toBe(false); + }); + + test('parseContainsShorthand — 두 덩어리일 때만 성립', () => { + expect(parseContainsShorthand(' 경제 방법 ')).toEqual({ topic: '경제', containsWord: '방법' }); + expect(parseContainsShorthand('경제')).toBeNull(); + expect(parseContainsShorthand('경제 방법 추천')).toBeNull(); + }); + + test('parseSearchTrendIntent — mode="contains" 파싱', async () => { + const ai = mockAi('{"mode":"contains","topic":"경제","containsWord":"방법","limit":10,"keywords":["재테크 방법","돈 모으는 방법"],"startDate":"2026-08-01","endDate":"2026-08-11","timeUnit":"","device":"","gender":"","ages":[]}'); + const r = await parseSearchTrendIntent(ai, '경제 방법 8월1일부터 오늘까지', '2026-08-11'); + expect(r.mode).toBe('contains'); + expect(r.topic).toBe('경제'); + expect(r.containsWord).toBe('방법'); + expect(r.limit).toBe(10); + expect(r.keywords).toEqual(['재테크 방법', '돈 모으는 방법']); + }); + + test('contains 인데 포함 단어가 비면 discover 로 강등 — 필터 없이 조회되는 사고 방지', async () => { + const ai = mockAi('{"mode":"contains","topic":"경제","containsWord":"","keywords":["재테크"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}'); + const r = await parseSearchTrendIntent(ai, '경제 트렌드', '2026-08-11'); + expect(r.mode).toBe('discover'); + expect(r.containsWord).toBeUndefined(); + }); + + test('generateContainingKeywords — 포함 단어가 빠진 후보는 코드에서 걸러낸다', async () => { + const ai = mockAi('{"keywords":["재테크 방법","주식 투자","절세 방법"]}'); + const out = await generateContainingKeywords(ai, '경제', '방법', 30); + expect(out).toEqual(['재테크 방법', '절세 방법']); + }); + + test('generateContainingKeywords — AI 실패해도 예외 전파 없이 빈 배열', async () => { + const out = await generateContainingKeywords(mockAi('', true), '경제', '방법', 30); + expect(out).toEqual([]); + }); +}); + +// 검색어트렌드 ratio 는 "요청 하나" 안에서 최댓값을 100 으로 두는 상대 지수라, +// 5개씩 끊어 호출하면 배치마다 기준이 달라진다. 후보 30개를 검색량순으로 줄 +// 세우려면 배치마다 공통 앵커를 끼워 되돌려야 한다는 걸 고정한다. +describe('rankKeywordsAnchored — 배치 경계를 넘는 순위 보정', () => { + // 실제 검색량(테스트 기준값). 네이버가 하듯 "요청 안에서 최댓값=100" 으로 정규화해 응답한다. + const VOLUME: Record = { + k1: 100, k2: 50, k3: 30, k4: 20, k5: 10, + k6: 400, k7: 300, k8: 5, k9: 1, + }; + const mockFetch = fetchSearchTrend as unknown as jest.Mock; + + beforeEach(() => { + mockFetch.mockReset(); + mockFetch.mockImplementation(async (_ctx: unknown, body: any) => { + const names: string[] = body.keywordGroups.map((g: any) => g.groupName); + const max = Math.max(...names.map(n => VOLUME[n])); + return { + ok: true, + data: { + startDate: body.startDate, endDate: body.endDate, timeUnit: body.timeUnit, + results: names.map(n => ({ + title: n, + keywords: [n], + data: [ + { period: '2026-08-01', ratio: (VOLUME[n] / max) * 100 }, + { period: '2026-08-02', ratio: (VOLUME[n] / max) * 100 }, + ], + })), + }, + }; + }); + }); + + const RANGE = { startDate: '2026-08-01', endDate: '2026-08-11', timeUnit: 'date' as const }; + + test('2배치에 걸친 9개 후보가 실제 검색량 순서로 정렬된다', async () => { + const r = await rankKeywordsAnchored({} as any, Object.keys(VOLUME), RANGE, {}); + expect(r.items.map(i => i.name)).toEqual(['k6', 'k7', 'k1', 'k2', 'k3', 'k4', 'k5', 'k8', 'k9']); + expect(r.errors).toEqual([]); + }); + + test('1위를 100 으로 재정규화하고, 비율이 실제 검색량 비와 일치한다', async () => { + const r = await rankKeywordsAnchored({} as any, Object.keys(VOLUME), RANGE, {}); + expect(r.items[0].avgRatio).toBeCloseTo(100, 5); + // k7(300) / k6(400) = 0.75 + expect(r.items[1].avgRatio).toBeCloseTo(75, 5); + // k1(100) / k6(400) = 0.25 — 앵커 보정이 없으면 k1 이 100 으로 남아 1위가 된다. + expect(r.items[2].avgRatio).toBeCloseTo(25, 5); + }); + + test('두 번째 배치부터는 앵커 키워드가 함께 조회된다', async () => { + await rankKeywordsAnchored({} as any, Object.keys(VOLUME), RANGE, {}); + const secondCall = mockFetch.mock.calls[1][1]; + expect(secondCall.keywordGroups.map((g: any) => g.groupName)).toEqual(['k6', 'k7', 'k8', 'k9', 'k1']); + }); + + test('첫 배치가 실패하면 보정 없이라도 결과를 내고 경고를 남긴다', async () => { + mockFetch.mockImplementationOnce(async () => ({ ok: false, error: 'Naver API 500' })); + const r = await rankKeywordsAnchored({} as any, Object.keys(VOLUME), RANGE, {}); + expect(r.items.length).toBeGreaterThan(0); + expect(r.errors.some(e => e.includes('배치 간 보정 없이'))).toBe(true); + }); + + test('후보가 없으면 빈 결과 — API 호출도 하지 않는다', async () => { + const r = await rankKeywordsAnchored({} as any, [], RANGE, {}); + expect(r.items).toEqual([]); + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); + // v2.2.319 회귀: `import * as x from './data.json'`(namespace import) 를 esbuild 로 // 번들하면 배열 최상위 JSON 이 `{ default: [...] }` 로 감싸져 x 자체가 배열이 아니게 // 된다. ts-jest 는 require() 를 그대로 써서 이 버그가 안 보여 통과했지만 실제 배포