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:
2026-08-12 22:04:06 +09:00
co-authored by Claude Opus 5
parent 0b315b9c6a
commit b73f1f48b3
15 changed files with 322 additions and 82 deletions
+147 -6
View File
@@ -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개를 검색량순으로 줄
// 세우려면 배치마다 공통 앵커를 끼워 되돌려야 한다는 걸 고정한다.