v2.2.324: /naver-trend "<분야> <기간>" 질문이 원문을 통째로 키워드로 조회하던 버그 수정
실사례: `/naver-trend 경제 8/11~8/12` → 키워드 칸에 "경제 8/11~8/12" 가 그대로 박힌 ratio 0.0 짜리 1행 표. 기대는 그 기간에 경제 분야에서 많이 검색된 키워드 목록. 원인 1 — 질문 원문 폴백: parseSearchTrendIntent 가 LLM 후보(keywords)가 비면 질문 원문을 키워드로 채웠다. 기간 표현까지 붙은 문자열이라 검색량이 잡힐 리 없다. 후보가 비면 빈 채로 두고, handlers 가 topic 기반 후보 생성으로 복구하게 변경. isPlausibleKeyword 에 기간 표현 차단(8/11~8/12, 8월 11일, 최근 일주일, 지난 30일) 추가 + 질문 원문·기간 제거 잔여 텍스트와 일치하는 후보 제거. 원인 2 — 분류 누락: 프롬프트가 "<분야> <기간>" 형태를 어느 모드로도 안내하지 않아 LLM 이 contains(포함단어 없음)로 찍고 discover 로 강등되며 topic 까지 버려졌다. "분야명 + 기간만 있는 질문 = discover" 를 프롬프트에 명시하고, 강등 시에도 topic 을 살려 후보 생성에 쓴다. - runContainsMode → runTopicMode 일반화: 포함단어를 선택 인자로 바꿔 "<분야> <기간>" 도 같은 2단 구조(LLM 후보 생성 → 실제 API 검증·앵커 보정 랭킹)를 타게 했다. 분야명 자체는 후보에서 제외(상위 개념이라 늘 1위를 먹고 정보 가치 0). - generateContainingKeywords → generateKeywordCandidates(ai, topic, word?, count) 통합, 포함단어 없을 때 전용 프롬프트 사용. - 비교는 대상 2개 이상일 때만: 키워드 1개짜리 compare 는 표가 늘 100.0 1행이라 무의미 — 그 검색어를 분야로 보고 인기 검색어를 뽑는다. - graceful degradation: AI 가 죽어도 분야명 자체 추이는 보여주고 실패를 명시. 결과가 전부 0 이고 조회 구간에 오늘이 포함되면 "데이터랩 당일 집계 미완" 안내. 검증: tsc 무오류 + jest 1055 통과(신규 12 — 슬래시 라우팅부터 끝까지 태우는 e2e 회귀 3건 포함) + esbuild/vsce 패키징 정상. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+147
-6
@@ -8,12 +8,26 @@ jest.mock('../src/features/naverTrend/naverApi', () => ({
|
||||
fetchShoppingCategoryTrend: jest.fn(),
|
||||
}));
|
||||
|
||||
/** handlers.ts 가 `new AIService()` 로 직접 만들어 쓰므로 모듈째 교체 — 큐에서 순서대로 꺼내 응답. */
|
||||
const mockAiResponses: string[] = [];
|
||||
jest.mock('../src/core/services', () => ({
|
||||
AIService: class {
|
||||
async call() { return mockAiResponses.shift() ?? ''; }
|
||||
async chat() {
|
||||
const content = mockAiResponses.shift() ?? '';
|
||||
return { content, engine: 'mock', model: 'mock', empty: !content };
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { findCategoryCode, ROOT_CATEGORY_CODES } from '../src/features/naverTrend/categoryCodes';
|
||||
import {
|
||||
parseShoppingIntent, parseSearchTrendIntent, generateContainingKeywords,
|
||||
parseShoppingIntent, parseSearchTrendIntent, generateKeywordCandidates,
|
||||
parseContainsShorthand, keywordContains,
|
||||
} from '../src/features/naverTrend/intentParser';
|
||||
import { parseKoreanDateRange, ymdLocal } from '../src/features/naverTrend/dateRange';
|
||||
import { parseKoreanDateRange, stripDateExpression, ymdLocal } from '../src/features/naverTrend/dateRange';
|
||||
import { isPlausibleKeyword } from '../src/features/naverTrend/handlers';
|
||||
import { listSlashCommands } from '../src/features/datacollect/slashRouter';
|
||||
import { rankKeywordsAnchored } from '../src/features/naverTrend/rankEngine';
|
||||
import { fetchSearchTrend } from '../src/features/naverTrend/naverApi';
|
||||
import type { IAIService, AIChatResult } from '../src/core/services';
|
||||
@@ -228,18 +242,145 @@ describe('contains 모드 — 분야 + 포함단어', () => {
|
||||
expect(r.containsWord).toBeUndefined();
|
||||
});
|
||||
|
||||
test('generateContainingKeywords — 포함 단어가 빠진 후보는 코드에서 걸러낸다', async () => {
|
||||
test('generateKeywordCandidates — 포함 단어가 빠진 후보는 코드에서 걸러낸다', async () => {
|
||||
const ai = mockAi('{"keywords":["재테크 방법","주식 투자","절세 방법"]}');
|
||||
const out = await generateContainingKeywords(ai, '경제', '방법', 30);
|
||||
const out = await generateKeywordCandidates(ai, '경제', '방법', 30);
|
||||
expect(out).toEqual(['재테크 방법', '절세 방법']);
|
||||
});
|
||||
|
||||
test('generateContainingKeywords — AI 실패해도 예외 전파 없이 빈 배열', async () => {
|
||||
const out = await generateContainingKeywords(mockAi('', true), '경제', '방법', 30);
|
||||
test('generateKeywordCandidates — 포함 단어가 없으면(분야만) 필터 없이 전부 후보', async () => {
|
||||
const ai = mockAi('{"keywords":["금리 인상","환율 전망","코스피 전망"]}');
|
||||
const out = await generateKeywordCandidates(ai, '경제', undefined, 30);
|
||||
expect(out).toEqual(['금리 인상', '환율 전망', '코스피 전망']);
|
||||
});
|
||||
|
||||
test('generateKeywordCandidates — AI 실패해도 예외 전파 없이 빈 배열', async () => {
|
||||
const out = await generateKeywordCandidates(mockAi('', true), '경제', '방법', 30);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// v2.2.324 회귀: `/naver-trend 경제 8/11~8/12` 가 "경제 8/11~8/12" 를 통째로 키워드로
|
||||
// 조회해 ratio 0.0 짜리 1행 표를 내던 문제. 원인은 두 갈래 — (1) LLM 이 후보를 못 주면
|
||||
// 질문 원문을 키워드로 쓰던 폴백, (2) "<분야> <기간>" 질문이 분야 탐색으로 분류되지
|
||||
// 않던 것.
|
||||
describe('“<분야> <기간>” 질문 — 질문 원문이 키워드로 새지 않는다', () => {
|
||||
test('후보가 비어도 질문 원문으로 메우지 않는다(handlers 가 topic 으로 복구)', async () => {
|
||||
const ai = mockAi('{"mode":"discover","topic":"경제","containsWord":"","limit":0,"keywords":[],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}');
|
||||
const r = await parseSearchTrendIntent(ai, '경제 8/11~8/12', '2026-08-12');
|
||||
expect(r.mode).toBe('discover');
|
||||
expect(r.keywords).toEqual([]);
|
||||
expect(r.topic).toBe('경제');
|
||||
});
|
||||
|
||||
test('contains 강등(포함 단어 없음) 시에도 topic 은 살아남는다', async () => {
|
||||
const ai = mockAi('{"mode":"contains","topic":"경제","containsWord":"","limit":0,"keywords":[],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}');
|
||||
const r = await parseSearchTrendIntent(ai, '경제 8/11~8/12', '2026-08-12');
|
||||
expect(r.mode).toBe('discover');
|
||||
expect(r.containsWord).toBeUndefined();
|
||||
expect(r.topic).toBe('경제');
|
||||
});
|
||||
|
||||
test('stripDateExpression — 기간 표현만 걷어내고 분야만 남긴다', () => {
|
||||
const today = new Date(2026, 7, 12);
|
||||
const text = '경제 8/11~8/12';
|
||||
expect(stripDateExpression(text, parseKoreanDateRange(text, today))).toBe('경제');
|
||||
});
|
||||
|
||||
test('stripDateExpression — 전각 물결표(~)로 써도 걷어낸다', () => {
|
||||
const today = new Date(2026, 7, 12);
|
||||
const text = '경제 8/11~8/12';
|
||||
expect(stripDateExpression(text, parseKoreanDateRange(text, today))).toBe('경제');
|
||||
});
|
||||
|
||||
test('stripDateExpression — 기간 표현이 없으면 원문 그대로(공백만 정리)', () => {
|
||||
expect(stripDateExpression(' 경제 트렌드 ', null)).toBe('경제 트렌드');
|
||||
});
|
||||
|
||||
test('isPlausibleKeyword — 기간 표현이 붙은 조각은 검색어 후보가 아니다', () => {
|
||||
expect(isPlausibleKeyword('경제 8/11~8/12')).toBe(false);
|
||||
expect(isPlausibleKeyword('경제 8월 11일')).toBe(false);
|
||||
expect(isPlausibleKeyword('경제 최근 일주일')).toBe(false);
|
||||
expect(isPlausibleKeyword('경제 지난 30일')).toBe(false);
|
||||
});
|
||||
|
||||
test('isPlausibleKeyword — 숫자가 든 실제 검색어는 살린다', () => {
|
||||
expect(isPlausibleKeyword('금리 인상')).toBe(true);
|
||||
expect(isPlausibleKeyword('아이폰 17 프로')).toBe(true);
|
||||
expect(isPlausibleKeyword('맥북 에어 M3')).toBe(true);
|
||||
expect(isPlausibleKeyword('갤럭시 Z 플립7')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// 라우팅까지 포함한 end-to-end — 실제 리포트된 입력(`/naver-trend 경제 8/11~8/12`)이
|
||||
// 표에 질문 원문 대신 진짜 검색어를 채우는지 고정한다. 유닛 테스트만으로는 handlers
|
||||
// 의 모드 분기(이번 버그의 실제 발생 지점)를 못 잡는다.
|
||||
describe('/naver-trend 라우팅 — "경제 8/11~8/12"', () => {
|
||||
const mockFetch = fetchSearchTrend as unknown as jest.Mock;
|
||||
const runNaverTrend = async (arg: string): Promise<string> => {
|
||||
const def = listSlashCommands().find(c => c.name === '/naver-trend');
|
||||
expect(def).toBeDefined();
|
||||
const out: string[] = [];
|
||||
const view = { postMessage: (m: any) => { out.push(String(m?.value ?? '')); } };
|
||||
await def!.handler(arg, view as any, {} as any);
|
||||
return out.join('');
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockAiResponses.length = 0;
|
||||
mockFetch.mockReset();
|
||||
mockFetch.mockImplementation(async (_ctx: unknown, body: any) => ({
|
||||
ok: true,
|
||||
data: {
|
||||
startDate: body.startDate, endDate: body.endDate, timeUnit: body.timeUnit,
|
||||
results: body.keywordGroups.map((g: any, i: number) => ({
|
||||
title: g.groupName,
|
||||
keywords: g.keywords,
|
||||
data: [{ period: body.startDate, ratio: 100 - i * 10 }],
|
||||
})),
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
test('분야 + 기간 → 그 분야 인기 검색어 표(질문 원문이 키워드로 들어가지 않는다)', async () => {
|
||||
// 1) intent 판정: 분야만 잡히고 후보는 비어서 돌아온 실제 실패 형태.
|
||||
mockAiResponses.push('{"mode":"discover","topic":"경제","containsWord":"","limit":0,"keywords":[],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}');
|
||||
// 2) 후보 보충.
|
||||
mockAiResponses.push('{"keywords":["금리 인상","환율 전망","코스피 전망","적금 금리","연말정산 환급"]}');
|
||||
|
||||
const out = await runNaverTrend('경제 8/11~8/12');
|
||||
|
||||
expect(out).toContain('금리 인상');
|
||||
expect(out).toContain('"경제" 분야 인기 검색어 TOP');
|
||||
// 회귀 지점: 질문 원문이 키워드 칸에 들어가면 안 된다.
|
||||
expect(out).not.toContain('| 경제 8/11~8/12 |');
|
||||
const queried = mockFetch.mock.calls.flatMap((c: any[]) => c[1].keywordGroups.map((g: any) => g.groupName));
|
||||
expect(queried).not.toContain('경제 8/11~8/12');
|
||||
expect(queried).toContain('환율 전망');
|
||||
// 기간은 규칙 파서가 확정한 값 그대로.
|
||||
expect(mockFetch.mock.calls[0][1]).toMatchObject({ startDate: '2026-08-11', endDate: '2026-08-12' });
|
||||
});
|
||||
|
||||
test('AI 가 죽어도 질문 원문을 조회하지 않는다 — 분야명만 조회하고 실패를 밝힌다', async () => {
|
||||
// 응답 큐가 비어 있음 = 두 번의 AI 호출이 모두 빈 응답.
|
||||
const out = await runNaverTrend('경제 8/11~8/12');
|
||||
|
||||
const queried = mockFetch.mock.calls.flatMap((c: any[]) => c[1].keywordGroups.map((g: any) => g.groupName));
|
||||
expect(queried).toEqual(['경제']);
|
||||
expect(out).toContain('검색어 후보 생성에 실패');
|
||||
});
|
||||
|
||||
test('키워드 2개 이상 비교는 그대로 비교 표', async () => {
|
||||
mockAiResponses.push('{"mode":"compare","topic":"","containsWord":"","limit":0,"keywords":["원피스","블라우스"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}');
|
||||
|
||||
const out = await runNaverTrend('원피스랑 블라우스 비교 8/11~8/12');
|
||||
|
||||
expect(out).toContain('키워드 비교');
|
||||
const queried = mockFetch.mock.calls.flatMap((c: any[]) => c[1].keywordGroups.map((g: any) => g.groupName));
|
||||
expect(queried).toEqual(['원피스', '블라우스']);
|
||||
});
|
||||
});
|
||||
|
||||
// 검색어트렌드 ratio 는 "요청 하나" 안에서 최댓값을 100 으로 두는 상대 지수라,
|
||||
// 5개씩 끊어 호출하면 배치마다 기준이 달라진다. 후보 30개를 검색량순으로 줄
|
||||
// 세우려면 배치마다 공통 앵커를 끼워 되돌려야 한다는 걸 고정한다.
|
||||
|
||||
Reference in New Issue
Block a user