v2.2.323: /naver-trend contains 모드 — "분야 + 포함단어 + 기간"으로 검색어 뽑기

`/naver-trend 경제 방법 8월1일부터 오늘까지` → 경제 분야 인기 검색어 중
"방법"이 들어간 것을 검색량 상위 10개로.

- contains 모드(intentParser): topic/containsWord/limit 추출 + 포함 단어를
  가진 후보 최대 30개 제안. 포함 여부는 LLM을 믿지 않고 keywordContains()로
  코드에서 재검사(공백 무시, 단어 자체만인 후보 제외). 후보가 얇으면
  generateContainingKeywords()로 보충. containsWord가 비면 discover로 강등.
- rankKeywordsAnchored: 검색어트렌드 ratio는 "요청 하나 안에서" 최댓값=100인
  상대값이라 5개씩 끊어 호출하면 6위 이하 순위가 무의미했다. 첫 배치 최댓값을
  앵커로 잡아 모든 배치에 끼워 넣고 되돌린 뒤 1위=100으로 재정규화. 정렬은
  하루치 최신값 대신 구간 평균(avgRatio).
- dateRange.ts 신설: 기간을 LLM에 맡기지 않고 규칙으로 확정 —
  "8월1일부터 오늘까지", "8/1~8/10", "최근 일주일", "지난 30일", "최근 한 달".
  연도 생략 시 미래면 작년. 범위 연결어 없는 날짜 나열/버전 문자열은 미인식.
- 날짜 UTC 밀림 수정: toISOString() → ymdLocal() (KST 새벽에 "오늘"이 하루 전으로
  밀리던 문제). 긴 구간은 timeUnit을 week/month로 자동 하향.

검증: tsc 무오류 + jest 1044 통과(신규 24) + esbuild 정상.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-11 21:43:08 +09:00
co-authored by Claude Opus 5
parent b61e06ef41
commit 0b315b9c6a
16 changed files with 767 additions and 45 deletions
@@ -1,5 +1,5 @@
{ {
"result": "직답 결과 — single-pass mock 응답입니다.", "result": "직답 결과 — single-pass mock 응답입니다.",
"createdAt": 1786009859146, "createdAt": 1786452145565,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -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", "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": 1786009859151, "createdAt": 1786452145565,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,8 +1,8 @@
{ {
"missionId": "wiki_on", "missionId": "wiki_on",
"status": "completed", "status": "completed",
"startTime": "2026-08-06T09:50:59.117Z", "startTime": "2026-08-11T12:42:25.546Z",
"totalElapsedMs": 40, "totalElapsedMs": 19,
"results": { "results": {
"direct": "직답 결과 — single-pass mock 응답입니다." "direct": "직답 결과 — single-pass mock 응답입니다."
}, },
@@ -12,16 +12,16 @@
{ {
"from": "idle", "from": "idle",
"to": "direct", "to": "direct",
"durationMs": 23, "durationMs": 18,
"message": "답변 작성 중... (단일 호출 fast-path)", "message": "답변 작성 중... (단일 호출 fast-path)",
"ts": "2026-08-06T09:50:59.140Z" "ts": "2026-08-11T12:42:25.564Z"
}, },
{ {
"from": "direct", "from": "direct",
"to": "completed", "to": "completed",
"durationMs": 14, "durationMs": 1,
"message": "미션 완료", "message": "미션 완료",
"ts": "2026-08-06T09:50:59.154Z" "ts": "2026-08-11T12:42:25.565Z"
} }
], ],
"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": 1786009865515, "createdAt": 1786452152471,
"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": 1786009865514, "createdAt": 1786452152471,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,5 +1,5 @@
{ {
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", "result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
"createdAt": 1786009865510, "createdAt": 1786452152459,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,5 +1,5 @@
{ {
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", "result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
"createdAt": 1786009865512, "createdAt": 1786452152465,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,7 +1,7 @@
{ {
"missionId": "stress_conflict_1786009865491", "missionId": "stress_conflict_1786452152446",
"status": "completed", "status": "completed",
"startTime": "2026-08-06T09:51:05.491Z", "startTime": "2026-08-11T12:42:32.446Z",
"totalElapsedMs": 25, "totalElapsedMs": 25,
"results": { "results": {
"outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", "outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
@@ -14,30 +14,30 @@
{ {
"from": "idle", "from": "idle",
"to": "outline", "to": "outline",
"durationMs": 18, "durationMs": 12,
"message": "답변 구조 잡는 중...", "message": "답변 구조 잡는 중...",
"ts": "2026-08-06T09:51:05.509Z" "ts": "2026-08-11T12:42:32.458Z"
}, },
{ {
"from": "outline", "from": "outline",
"to": "section", "to": "section",
"durationMs": 2, "durationMs": 1,
"message": "본문 작성 중...", "message": "본문 작성 중...",
"ts": "2026-08-06T09:51:05.511Z" "ts": "2026-08-11T12:42:32.459Z"
}, },
{ {
"from": "section", "from": "section",
"to": "polish", "to": "polish",
"durationMs": 2, "durationMs": 6,
"message": "최종 다듬기 중...", "message": "최종 다듬기 중...",
"ts": "2026-08-06T09:51:05.513Z" "ts": "2026-08-11T12:42:32.465Z"
}, },
{ {
"from": "polish", "from": "polish",
"to": "completed", "to": "completed",
"durationMs": 3, "durationMs": 6,
"message": "미션 완료", "message": "미션 완료",
"ts": "2026-08-06T09:51:05.516Z" "ts": "2026-08-11T12:42:32.471Z"
} }
], ],
"resilienceMetrics": { "resilienceMetrics": {
+10
View File
@@ -1,5 +1,15 @@
# Astra Patch Notes # 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) ## v2.2.322 (2026-08-05)
### 🛍️ 네이버 트렌드 — 구체적 모델명 제안 + ratio 라벨 명확화 ### 🛍️ 네이버 트렌드 — 구체적 모델명 제안 + ratio 라벨 명확화
사용자 피드백: 인기 키워드 결과에 "노트북"처럼 카테고리명만 반복돼 실제 쓸 정보가 없음(구체적 모델까지 알고 싶음) + "클릭률"까지 보고 싶음. 사용자 피드백: 인기 키워드 결과에 "노트북"처럼 카테고리명만 반복돼 실제 쓸 정보가 없음(구체적 모델까지 알고 싶음) + "클릭률"까지 보고 싶음.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "astra", "name": "astra",
"version": "2.2.250", "version": "2.2.323",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "astra", "name": "astra",
"version": "2.2.250", "version": "2.2.323",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@lmstudio/sdk": "^1.5.0", "@lmstudio/sdk": "^1.5.0",
+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.322", "version": "2.2.323",
"publisher": "g1nation", "publisher": "g1nation",
"license": "MIT", "license": "MIT",
"icon": "assets/icon.png", "icon": "assets/icon.png",
+183
View File
@@ -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: /(?<![\d.])(\d{1,2})\/(\d{1,2})(?![\d/])/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;
},
},
{ 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<string, number> = { 한: 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);
}
+132 -9
View File
@@ -17,18 +17,23 @@
import * as vscode from 'vscode'; 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 { parseShoppingIntent, parseSearchTrendIntent } from './intentParser'; import {
parseShoppingIntent, parseSearchTrendIntent, generateContainingKeywords,
parseContainsShorthand, keywordContains,
} 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, 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'; import type { CommonFilters, TimeUnit } from './naverApi';
type Webview = Parameters<typeof chunk>[0]; type Webview = Parameters<typeof chunk>[0];
const DISCLAIMER = '⚠️ 이 순위는 네이버 전체 실시간 인기 순위가 아니라, 후보군 내에서 실제 네이버 데이터랩 API로 검증한 상대 비교치입니다. "ratio"는 조회 구간 내 최댓값을 100으로 둔 상대 지수이며, 실제 클릭수·노출수 기반 클릭률(CTR, %)이 아닙니다 — 네이버 데이터랩 API 자체가 CTR을 제공하지 않습니다.'; const DISCLAIMER = '⚠️ 이 순위는 네이버 전체 실시간 인기 순위가 아니라, 후보군 내에서 실제 네이버 데이터랩 API로 검증한 상대 비교치입니다. "ratio"는 조회 구간 내 최댓값을 100으로 둔 상대 지수이며, 실제 클릭수·노출수 기반 클릭률(CTR, %)이 아닙니다 — 네이버 데이터랩 API 자체가 CTR을 제공하지 않습니다.';
function ymd(d: Date): string { /** contains 모드에서 API로 검증할 후보 상한 — 5개 + 4개씩이라 30개면 호출 약 7회. */
return d.toISOString().slice(0, 10); const CONTAINS_POOL_MAX = 30;
}
const ymd = ymdLocal;
/** 기본 조회 기간 — "오늘"은 데이터랩 집계가 안 됐을 수 있어 어제를 종료일로. */ /** 기본 조회 기간 — "오늘"은 데이터랩 집계가 안 됐을 수 있어 어제를 종료일로. */
function defaultRange(): { startDate: string; endDate: string; timeUnit: TimeUnit } { 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' }; 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 { function buildFilters(intent: { device?: 'pc' | 'mo'; gender?: 'm' | 'f'; ages?: string[] }): CommonFilters {
const filters: CommonFilters = {}; const filters: CommonFilters = {};
if (intent.device) filters.device = intent.device; if (intent.device) filters.device = intent.device;
@@ -59,6 +73,18 @@ function formatRankedTable(result: RankedResult, itemLabel: string): string {
return lines.join('\n') + '\n'; 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 { function formatErrors(errors: string[]): string {
if (errors.length === 0) return ''; if (errors.length === 0) return '';
return '\n' + errors.map(e => `> ⚠️ ${e}`).join('\n') + '\n'; return '\n' + errors.map(e => `> ⚠️ ${e}`).join('\n') + '\n';
@@ -182,12 +208,73 @@ function cmdSearchTrendHelp(view: Webview): void {
'', '',
'- `/naver-trend 원피스,블라우스 비교` — 명시 키워드 비교', '- `/naver-trend 원피스,블라우스 비교` — 명시 키워드 비교',
'- `/naver-trend 20대 여성한테 인기있는 원피스 스타일 키워드 10개` — 탐색(후보 제안 + 검증)', '- `/naver-trend 20대 여성한테 인기있는 원피스 스타일 키워드 10개` — 탐색(후보 제안 + 검증)',
'- `/naver-trend 경제 방법 8월1일부터 오늘까지` — **`<분야> <포함단어> <기간>`**: 그 분야 인기 검색어 중 해당 단어가 들어간 것만 검색량 상위 10개',
'',
'기간 표현: `8월1일부터 오늘까지`, `8/1~8/10`, `최근 일주일`, `지난 30일` 등 (생략 시 최근 7일)',
'', '',
'- API 키 설정: Astra 설정 → Naver Trend 탭에서 Client ID/Secret 입력', '- API 키 설정: Astra 설정 → Naver Trend 탭에서 Client ID/Secret 입력',
'', '',
].join('\n')); ].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<boolean> {
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<boolean> { async function runSearchTrend(arg: string, view: Webview, context?: vscode.ExtensionContext): Promise<boolean> {
if (!context) { chunk(view, '\n⚠️ ExtensionContext 없음.\n'); return true; } if (!context) { chunk(view, '\n⚠️ ExtensionContext 없음.\n'); return true; }
const trimmed = arg.trim(); const trimmed = arg.trim();
@@ -196,15 +283,51 @@ async function runSearchTrend(arg: string, view: Webview, context?: vscode.Exten
try { try {
chunk(view, '\n🔍 질문 분석 중…\n'); chunk(view, '\n🔍 질문 분석 중…\n');
const ai = new AIService(); 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 = { const fallback = defaultRange();
startDate: intent.startDate || defaultRange().startDate, const range = preRange
endDate: intent.endDate || defaultRange().endDate, ? {
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, timeUnit: intent.timeUnit || 'date' as TimeUnit,
}; };
const filters = buildFilters(intent); 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`); chunk(view, `\n📊 조회 중 — ${intent.keywords.join(', ')} (${range.startDate} ~ ${range.endDate})…\n`);
const result = await rankKeywords(context, intent.keywords, range, filters); const result = await rankKeywords(context, intent.keywords, range, filters);
+128 -5
View File
@@ -18,6 +18,17 @@ import { logError, logInfo } from '../../utils';
export type IntentMode = 'compare' | 'discover'; export type IntentMode = 'compare' | 'discover';
/**
* 검색어트렌드 전용 모드. 'contains' 는 "분야 + 포함단어 + 기간" 형태의 질문
* (예: `/naver-trend 경제 방법 8월1일부터 오늘까지`) — 그 분야의 인기 검색어 중
* 특정 단어가 들어간 것만 골라 검색량순으로 뽑는다.
*
* 데이터랩엔 "분야 내 인기 검색어 목록" API 가 없으므로(지정한 키워드의 상대
* 지수만 반환) LLM 이 후보를 넓게 만들고 → 포함 단어로 걸러낸 뒤 → 실제 API 로
* 검증·랭킹하는 discover 계열 흐름을 그대로 쓴다.
*/
export type SearchTrendMode = IntentMode | 'contains';
export interface ShoppingIntent { export interface ShoppingIntent {
mode: IntentMode; mode: IntentMode;
/** /**
@@ -41,8 +52,14 @@ export interface ShoppingIntent {
} }
export interface SearchTrendIntent { export interface SearchTrendIntent {
mode: IntentMode; mode: SearchTrendMode;
keywords: string[]; keywords: string[];
/** mode='contains' 전용 — 검색어를 찾을 분야/주제 (예: "경제"). */
topic?: string;
/** mode='contains' 전용 — 검색어에 반드시 들어가야 하는 단어 (예: "방법"). */
containsWord?: string;
/** 사용자가 "10개"처럼 개수를 지정했을 때. 기본 10. */
limit?: number;
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
timeUnit?: 'date' | 'week' | 'month'; timeUnit?: 'date' | 'week' | 'month';
@@ -205,14 +222,45 @@ export async function parseShoppingIntent(
const SEARCH_TREND_SYSTEM_PROMPT = `당신은 네이버 검색어트렌드 API 질의 파서입니다. 사용자의 자연어 질문을 분석해 아래 JSON 하나만 출력하세요. 다른 텍스트(설명, 코드펜스, 머리말) 절대 금지. const SEARCH_TREND_SYSTEM_PROMPT = `당신은 네이버 검색어트렌드 API 질의 파서입니다. 사용자의 자연어 질문을 분석해 아래 JSON 하나만 출력하세요. 다른 텍스트(설명, 코드펜스, 머리말) 절대 금지.
- "mode": 사용자가 구체적인 검색어를 하나 이상 언급했으면 "compare". "인기 검색어"/"요즘 뜨는 키워드"/"top N" 처럼 키워드를 직접 지목하지 않고 탐색을 원하면 "discover". - "mode": 셋 중 하나.
- "keywords": mode="compare"면 사용자가 언급한 검색어를 그대로 배열로. mode="discover"면 질문 맥락(주제/카테고리/타겟)에 맞는 검색어 후보를 당신의 지식으로 최대 10개 제안(개별 순위를 매기려면 키워드 1개=API 그룹 1개라 5개씩 순차 조회함을 감안). · "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", "다이슨 에어랩")을 우선 제안 — 일반 명사만 나열하면 사용자가 얻을 정보가 없다. 여러 브랜드를 섞을 것. ⚠️ 질문이 특정 상품군(가전/패션/뷰티 등)을 가리키면 그 상위 카테고리명 자체를 반복하지 말고 **구체적인 상품/모델명**(브랜드+제품라인명, 예: "맥북 에어 M3", "다이슨 에어랩")을 우선 제안 — 일반 명사만 나열하면 사용자가 얻을 정보가 없다. 여러 브랜드를 섞을 것.
${COMMON_FIELDS_PROMPT} ${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 대 코드와 다릅니다. - "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 형식 정확히 한 번: ⚠️ 반드시 아래 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( export async function parseSearchTrendIntent(
ai: IAIService, ai: IAIService,
@@ -240,10 +288,18 @@ export async function parseSearchTrendIntent(
logInfo('naverTrend intentParser(searchTrend): 파싱 실패 — compare 모드로 원문 사용.', { rawHead: raw.slice(0, 100) }); logInfo('naverTrend intentParser(searchTrend): 파싱 실패 — compare 모드로 원문 사용.', { rawHead: raw.slice(0, 100) });
return { mode: 'compare', keywords: [trimmed], parsed: false }; return { mode: 'compare', keywords: [trimmed], parsed: false };
} }
const mode = _asSearchTrendMode(obj.mode);
const containsWord = _asWord(obj.containsWord);
const keywords = _asStringArray(obj.keywords); const keywords = _asStringArray(obj.keywords);
// contains 모드에서 포함 단어가 비면 성립하지 않는 요청 — discover 로 강등해
// 최소한 후보 랭킹은 보여준다.
const effectiveMode: SearchTrendMode = mode === 'contains' && !containsWord ? 'discover' : mode;
return { return {
mode: _asMode(obj.mode), mode: effectiveMode,
keywords: keywords.length > 0 ? keywords : [trimmed], 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), startDate: _asDate(obj.startDate),
endDate: _asDate(obj.endDate), endDate: _asDate(obj.endDate),
timeUnit: _asTimeUnit(obj.timeUnit), timeUnit: _asTimeUnit(obj.timeUnit),
@@ -253,3 +309,70 @@ export async function parseSearchTrendIntent(
parsed: true, 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<string[]> {
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 };
}
+103 -2
View File
@@ -23,6 +23,8 @@ export interface RankedItem {
name: string; name: string;
path?: string; path?: string;
latestRatio: number; latestRatio: number;
/** 조회 구간 전체 평균 ratio — 기간 단위 질문("8/1~오늘")은 마지막 하루보다 이쪽이 대표값. */
avgRatio: number;
series: TrendDataPoint[]; series: TrendDataPoint[];
} }
@@ -46,6 +48,11 @@ function latestRatioOf(series: TrendDataPoint[]): number {
return series.length ? series[series.length - 1].ratio : 0; 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( export async function rankCategories(
context: vscode.ExtensionContext, context: vscode.ExtensionContext,
candidates: Array<{ name: string; path?: string; code: string }>, candidates: Array<{ name: string; path?: string; code: string }>,
@@ -68,7 +75,7 @@ export async function rankCategories(
} else { } else {
for (const r of res.data.results) { for (const r of res.data.results) {
const match = batch.find(c => c.name === r.title); 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); if (i < batches.length - 1) await delay(200);
@@ -100,7 +107,7 @@ export async function rankKeywords(
errors.push(`[${batch.join(', ')}] ${res.error}`); errors.push(`[${batch.join(', ')}] ${res.error}`);
} else { } else {
for (const r of res.data.results) { 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); if (i < batches.length - 1) await delay(200);
@@ -109,3 +116,97 @@ export async function rankKeywords(
items.sort((a, b) => b.latestRatio - a.latestRatio); items.sort((a, b) => b.latestRatio - a.latestRatio);
return { ...range, items, errors }; 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<RankedResult> {
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<RankedItem | null>((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 };
}
+183 -1
View File
@@ -3,8 +3,19 @@
* 실제 네이버 API 호출은 통합 영역이라 제외 — 카테고리 매칭 + intent 파서(tolerant * 실제 네이버 API 호출은 통합 영역이라 제외 — 카테고리 매칭 + intent 파서(tolerant
* JSON 파싱, AI 호출 mock)만 고정. * 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 { 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 type { IAIService, AIChatResult } from '../src/core/services';
import * as esbuild from 'esbuild'; import * as esbuild from 'esbuild';
import * as fs from 'fs'; 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<string, number> = {
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 로 // v2.2.319 회귀: `import * as x from './data.json'`(namespace import) 를 esbuild 로
// 번들하면 배열 최상위 JSON 이 `{ default: [...] }` 로 감싸져 x 자체가 배열이 아니게 // 번들하면 배열 최상위 JSON 이 `{ default: [...] }` 로 감싸져 x 자체가 배열이 아니게
// 된다. ts-jest 는 require() 를 그대로 써서 이 버그가 안 보여 통과했지만 실제 배포 // 된다. ts-jest 는 require() 를 그대로 써서 이 버그가 안 보여 통과했지만 실제 배포