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:
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "직답 결과 — single-pass mock 응답입니다.",
|
||||
"createdAt": 1786009859146,
|
||||
"createdAt": 1786452145565,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||
"createdAt": 1786009865515,
|
||||
"createdAt": 1786452152471,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||
"createdAt": 1786009865514,
|
||||
"createdAt": 1786452152471,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
||||
"createdAt": 1786009865510,
|
||||
"createdAt": 1786452152459,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||
"createdAt": 1786009865512,
|
||||
"createdAt": 1786452152465,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+10
-10
@@ -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": {
|
||||
@@ -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 라벨 명확화
|
||||
사용자 피드백: 인기 키워드 결과에 "노트북"처럼 카테고리명만 반복돼 실제 쓸 정보가 없음(구체적 모델까지 알고 싶음) + "클릭률"까지 보고 싶음.
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<typeof chunk>[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<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> {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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<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 };
|
||||
}
|
||||
|
||||
@@ -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<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
@@ -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<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 로
|
||||
// 번들하면 배열 최상위 JSON 이 `{ default: [...] }` 로 감싸져 x 자체가 배열이 아니게
|
||||
// 된다. ts-jest 는 require() 를 그대로 써서 이 버그가 안 보여 통과했지만 실제 배포
|
||||
|
||||
Reference in New Issue
Block a user