사용자 지적: "유럽은 여전한데?" 로그를 보니 모델은 유럽 요청을 이렇게 보낸다.
news_search({"category":"top","country":"gb,de,fr,it,es"})
직전 커밋(13e2f79)의 매체 제한이 두 이유로 빠져나갔다:
1. category가 있으면 건너뛰게 했는데 "top"은 분야가 아니라 전체 피드다.
correctNewsCategoryForBreadth는 이미 top을 통과시키고 있었으니, 두 곳의
판단이 어긋나 있었던 셈이다
2. country가 "gb,de,fr,it,es" 콤마 목록이라 나라별 조회 자체가 실패했다
- de/fr/it/es 목록 추가(전부 라이브 확인). 멜로니 개각설, 트럼프 비자 취소
17만5천 건 같은 실제 뉴스가 나온다
- 다국가는 라운드로빈으로 5개를 채운다. 앞에서부터 채우면 영국 매체만 다섯이
되어 대륙의 나머지가 통째로 빠진다
- category=top은 넓은 요청으로 취급한다
그리고 내가 만든 버그 하나를 같이 고쳤다: gb 목록의 bbc.co.uk는 NewsData DB에
없는 도메인이라(정답은 bbc.com) 요청 전체가 HTTP 422로 죽었다. **도메인 하나가
잘못되면 나머지 넷도 같이 실패한다.** us/kr 목록은 라이브로 확인하고 넣었는데
gb만 검증 없이 추가했다가 유럽 요청이 통째로 터졌다. 목록에 도메인을 추가할 땐
반드시 확인할 것 — 주석으로 남겼다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
248 lines
14 KiB
TypeScript
248 lines
14 KiB
TypeScript
/**
|
|
* news.test.ts
|
|
*
|
|
* resolveNewsParams / stripCategoryWordForRetry were extracted from news_search's execute()
|
|
* (2026-08-10) after schema-description guidance alone repeatedly failed to change model
|
|
* behavior in live tests — 4 consecutive real chat turns still called news_search without a
|
|
* country param, or with a category+name query that returns zero results, despite the tool's
|
|
* own description explaining both pitfalls with the exact verified numbers. Deterministic
|
|
* code fixes the failure regardless of what the model writes; these tests pin the fix down.
|
|
*/
|
|
|
|
import { test, describe } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { resolveNewsParams, stripCategoryWordForRetry, correctNewsCategoryForBreadth, fillNewsCategoryWhenExplicit, applyMajorOutletsForBroadRequest, newsSearchTool } from '../src/tools/news';
|
|
|
|
describe('resolveNewsParams — country/language/category 정규화', () => {
|
|
test('한글 쿼리 + country/language 둘 다 없으면 kr로 기본값 설정', () => {
|
|
// 실제 사고: "태풍 돌핀" 쿼리에 country를 4번 연속 안 넣었다.
|
|
assert.equal(resolveNewsParams({ query: '태풍 돌핀' }).country, 'kr');
|
|
});
|
|
|
|
test('영어 쿼리엔 kr을 강제하지 않는다', () => {
|
|
assert.equal(resolveNewsParams({ query: 'typhoon dolphin' }).country, '');
|
|
});
|
|
|
|
test('country나 language를 명시하면 한글 쿼리여도 그대로 존중한다', () => {
|
|
assert.equal(resolveNewsParams({ query: '태풍', country: 'jp' }).country, 'jp');
|
|
assert.equal(resolveNewsParams({ query: '태풍', language: 'en' }).country, '');
|
|
});
|
|
|
|
test('country가 6개 이상이면 5개로 자른다', () => {
|
|
assert.equal(resolveNewsParams({ country: 'de,fr,gb,it,es,pl' }).country, 'de,fr,gb,it,es');
|
|
});
|
|
|
|
test('country가 있으면 language는 무시된다', () => {
|
|
assert.equal(resolveNewsParams({ country: 'de', language: 'en' }).language, '');
|
|
});
|
|
|
|
test('country + category=world 조합은 category를 지운다', () => {
|
|
assert.equal(resolveNewsParams({ country: 'de', category: 'world' }).category, '');
|
|
});
|
|
});
|
|
|
|
describe('stripCategoryWordForRetry — 카테고리어+고유명사 조합 실패 대응', () => {
|
|
// 실제 사고(2026-08-10, 라이브 API로 검증): NewsData.io에 직접 호출해본 결과
|
|
// q="태풍 돌핀"+country=kr → 0건
|
|
// q="태풍"+country=kr → 12건 (돌핀 기사 포함)
|
|
// q="돌핀"+country=kr → 5건 (전부 관련 기사)
|
|
// 실제 기사 제목이 "태풍 '돌핀'"(따옴표 있음)이라 "태풍 돌핀"과 리터럴 매칭이 안 됐다.
|
|
test('재해 카테고리어 + 고유명사 조합에서 카테고리어를 제거한다', () => {
|
|
assert.equal(stripCategoryWordForRetry('태풍 돌핀'), '돌핀');
|
|
});
|
|
|
|
test('카테고리어만 있으면 재시도할 게 없다', () => {
|
|
assert.equal(stripCategoryWordForRetry('태풍'), '');
|
|
});
|
|
|
|
test('재해 키워드가 아예 없으면 재시도할 게 없다', () => {
|
|
assert.equal(stripCategoryWordForRetry('삼성전자 실적'), '');
|
|
});
|
|
|
|
test('카테고리어가 중간에 껴 있어도 나머지를 살린다', () => {
|
|
assert.equal(stripCategoryWordForRetry('간토 지진 피해'), '간토 피해');
|
|
});
|
|
});
|
|
|
|
describe('correctNewsCategoryForBreadth — 사용자가 지정 안 한 분야로 좁히지 않는다', () => {
|
|
// 2026-08-12 실사고: "오늘 미국 주요 뉴스"에 모델이 category=technology, category=business를
|
|
// 스스로 붙여 답변이 "미국 내 기술 및 비즈니스 분야"로 좁혀졌다. 사용자는 분야를 말한 적이 없다.
|
|
test('실사고 재현 — 분야를 안 말했으면 category를 뺀다', () => {
|
|
const args = { category: 'technology', country: 'us', language: 'en' };
|
|
assert.deepEqual(
|
|
correctNewsCategoryForBreadth(args, '오늘 미국 주요 뉴스'),
|
|
{ country: 'us', language: 'en' },
|
|
);
|
|
assert.deepEqual(
|
|
correctNewsCategoryForBreadth({ category: 'business', country: 'us' }, '오늘 미국 주요 뉴스'),
|
|
{ country: 'us' },
|
|
);
|
|
});
|
|
|
|
test('사용자가 분야를 말했으면 그대로 둔다', () => {
|
|
const keep = (cat: string, msg: string) =>
|
|
assert.equal(correctNewsCategoryForBreadth({ category: cat }, msg).category, cat, msg);
|
|
keep('technology', '미국 기술 뉴스 알려줘');
|
|
keep('technology', '오늘 AI 뉴스');
|
|
keep('business', '미국 경제 뉴스');
|
|
keep('sports', '스포츠 소식');
|
|
keep('politics', '미국 정치 뉴스');
|
|
keep('health', '건강 관련 뉴스');
|
|
keep('world', '국제 뉴스 정리해줘');
|
|
});
|
|
|
|
test('후속 요청에서 앞 턴의 주제를 잃지 않는다 — 최근 발화를 함께 본다', () => {
|
|
// 호출부는 직전 사용자 발화 2턴 + 현재 메시지를 이어붙여 넘긴다. "더 보여줘"만 보면
|
|
// 주제가 사라지지만, 앞 턴이 함께 오면 분야가 유지된다.
|
|
assert.equal(
|
|
correctNewsCategoryForBreadth({ category: 'technology' }, '미국 기술 뉴스 알려줘\n더 보여줘').category,
|
|
'technology',
|
|
);
|
|
// 반대로 앞 턴에도 분야가 없으면 그대로 넓힌다.
|
|
assert.equal(
|
|
correctNewsCategoryForBreadth({ category: 'technology' }, '오늘 미국 주요 뉴스\n더 보여줘').category,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
test('추가된 crime/education도 보정 안전망이 걸린다', () => {
|
|
// 스키마에만 넣고 CATEGORY_TOPIC에 빼먹으면, 모델이 엉뚱하게 붙인 분야가 그대로 통과한다.
|
|
assert.equal(correctNewsCategoryForBreadth({ category: 'crime' }, '미국 범죄 뉴스').category, 'crime');
|
|
assert.equal(correctNewsCategoryForBreadth({ category: 'crime' }, '살인 사건 관련 뉴스').category, 'crime');
|
|
assert.equal(correctNewsCategoryForBreadth({ category: 'education' }, '교육 정책 뉴스').category, 'education');
|
|
assert.equal(correctNewsCategoryForBreadth({ category: 'education' }, '대학 입시 소식').category, 'education');
|
|
// 사용자가 안 말했으면 제거된다
|
|
assert.equal(correctNewsCategoryForBreadth({ category: 'crime' }, '오늘 미국 주요 뉴스').category, undefined);
|
|
assert.equal(correctNewsCategoryForBreadth({ category: 'education' }, '오늘 미국 주요 뉴스').category, undefined);
|
|
});
|
|
|
|
test('스키마에 있는 분야는 전부 보정 맵에도 있어야 한다 — top 제외', () => {
|
|
// 둘이 어긋나면 그 분야만 조용히 안전망 밖으로 빠진다. 이 테스트가 그 드리프트를 막는다.
|
|
const schema = String((newsSearchTool as any).schema.category);
|
|
const declared = (schema.match(/"([a-z]+)"/g) || []).map(x => x.replace(/"/g, ''));
|
|
for (const cat of declared) {
|
|
if (cat === 'top') continue;
|
|
assert.equal(
|
|
correctNewsCategoryForBreadth({ category: cat }, '오늘 뉴스').category, undefined,
|
|
`${cat} 가 CATEGORY_TOPIC 에 없어서 보정을 통과함`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('"top"은 분야가 아니라 전체 피드이므로 건드리지 않는다', () => {
|
|
assert.equal(correctNewsCategoryForBreadth({ category: 'top' }, '오늘 뉴스').category, 'top');
|
|
});
|
|
|
|
test('category가 없거나 모르는 값이면 그대로', () => {
|
|
assert.deepEqual(correctNewsCategoryForBreadth({ country: 'us' }, '뉴스'), { country: 'us' });
|
|
assert.equal(correctNewsCategoryForBreadth({ category: 'weird' }, '뉴스').category, 'weird');
|
|
});
|
|
});
|
|
|
|
describe('fillNewsCategoryWhenExplicit — 사용자가 명시한 분야만 채운다', () => {
|
|
// 넓히기 보정의 거울상이지만 훨씬 엄격하다. 저쪽은 넓히기만 하니 오작동해도 범위가 늘 뿐이지만,
|
|
// 이쪽은 좁히므로 잘못 채우면 사용자가 원한 범위를 잃는다.
|
|
test('모델이 안 붙였고 사용자가 명시했으면 채운다', () => {
|
|
assert.equal(fillNewsCategoryWhenExplicit({ country: 'us' }, '미국 범죄 뉴스 알려줘').category, 'crime');
|
|
assert.equal(fillNewsCategoryWhenExplicit({ country: 'us' }, '대학 입시 소식').category, 'education');
|
|
assert.equal(fillNewsCategoryWhenExplicit({}, '미국 경제 뉴스').category, 'business');
|
|
});
|
|
|
|
test('모델이 이미 붙였으면 건드리지 않는다 — 그건 넓히기 보정의 몫', () => {
|
|
assert.equal(fillNewsCategoryWhenExplicit({ category: 'top' }, '미국 범죄 뉴스').category, 'top');
|
|
assert.equal(fillNewsCategoryWhenExplicit({ category: 'sports' }, '미국 범죄 뉴스').category, 'sports');
|
|
});
|
|
|
|
test('분야를 안 말했으면 채우지 않는다', () => {
|
|
assert.equal(fillNewsCategoryWhenExplicit({ country: 'us' }, '오늘 미국 주요 뉴스').category, undefined);
|
|
assert.equal(fillNewsCategoryWhenExplicit({}, '뉴스 보여줘').category, undefined);
|
|
});
|
|
|
|
test('둘 이상 걸리면 채우지 않는다 — 한쪽을 고르면 나머지 절반이 조용히 사라진다', () => {
|
|
assert.equal(fillNewsCategoryWhenExplicit({}, 'AI 기술 관련 범죄 뉴스').category, undefined);
|
|
assert.equal(fillNewsCategoryWhenExplicit({}, '경제와 정치 뉴스 정리해줘').category, undefined);
|
|
// "교육 정책"은 교육(education)과 정책(politics) 둘 다에 걸린다. 사람은 education을
|
|
// 떠올리겠지만 코드가 그렇게 단정하려면 "X 정책"에서 X가 이긴다는 규칙이 필요하고,
|
|
// 그건 이 함수가 감당할 범위를 넘는다. 안 채우면 전체 뉴스로 남으므로 안전한 실패다.
|
|
assert.equal(fillNewsCategoryWhenExplicit({}, '교육 정책 뉴스').category, undefined);
|
|
});
|
|
|
|
test('앞 턴이 아니라 현재 메시지만 본다 — 물려받기는 안전해도 단정은 아니다', () => {
|
|
// 호출부는 넓히기에는 최근 발화 창을 쓰지만 채우기에는 현재 메시지만 넘긴다.
|
|
// "미국 범죄 뉴스" 다음 "그럼 전체 뉴스는?"에 crime을 계속 채우면 안 된다.
|
|
assert.equal(fillNewsCategoryWhenExplicit({}, '그럼 전체 뉴스는?').category, undefined);
|
|
});
|
|
|
|
test('빈 입력', () => {
|
|
assert.deepEqual(fillNewsCategoryWhenExplicit({}, ''), {});
|
|
assert.deepEqual(fillNewsCategoryWhenExplicit(null as any, '범죄 뉴스').category, 'crime');
|
|
});
|
|
});
|
|
|
|
describe('applyMajorOutletsForBroadRequest — 넓은 요청만 주요 매체로 좁힌다', () => {
|
|
// 2026-08-12 실측: country=us 만 준 요청이 물병 신제품, 고등학교 교장 부임, 상가 내 점포
|
|
// 이전을 "주요 뉴스"로 돌려줬다. NewsData의 /latest는 중요도가 아니라 시간 역순이라
|
|
// 인덱싱된 수천 개 지역 매체가 그대로 올라온다. category=top·prioritydomain=top 둘 다
|
|
// 실측했으나 칫솔 할인과 자라 원피스가 계속 나왔고, 매체를 좁히니 해결됐다.
|
|
test('주제 없는 요청은 주요 매체로 좁힌다', () => {
|
|
assert.match(applyMajorOutletsForBroadRequest({}, 'us').domainurl, /reuters\.com/);
|
|
assert.match(applyMajorOutletsForBroadRequest({}, 'kr').domainurl, /joongang\.co\.kr/);
|
|
assert.match(applyMajorOutletsForBroadRequest({}, 'gb').domainurl, /bbc\.com/);
|
|
});
|
|
|
|
test('API 제한상 5개를 넘기지 않는다', () => {
|
|
for (const c of ['us', 'kr', 'gb']) {
|
|
const n = applyMajorOutletsForBroadRequest({}, c).domainurl.split(',').length;
|
|
assert.ok(n <= 5, `${c}: ${n}개 — domainurl은 6개부터 UnsupportedQueryLength`);
|
|
}
|
|
});
|
|
|
|
test('다국가 요청은 나라별로 고르게 뽑는다 — 2026-08-12 유럽 사고', () => {
|
|
// 모델은 유럽 요청을 country="gb,de,fr,it,es" 로 보낸다. 앞에서부터 채우면 영국 매체만
|
|
// 다섯이 되어 대륙의 나머지가 통째로 빠진다.
|
|
const d = applyMajorOutletsForBroadRequest({}, 'gb,de,fr,it,es').domainurl.split(',');
|
|
assert.equal(d.length, 5);
|
|
for (const host of ['bbc.com', 'spiegel.de', 'lemonde.fr', 'corriere.it', 'elpais.com']) {
|
|
assert.ok(d.includes(host), `${host} 누락: ${d.join(',')}`);
|
|
}
|
|
});
|
|
|
|
test('나라가 적으면 각 나라에서 더 많이 뽑는다', () => {
|
|
const d = applyMajorOutletsForBroadRequest({}, 'de,fr').domainurl.split(',');
|
|
assert.equal(d.length, 5);
|
|
const GERMAN = ['spiegel.de', 'zeit.de', 'faz.net', 'sueddeutsche.de', 'welt.de'];
|
|
const FRENCH = ['lemonde.fr', 'lefigaro.fr', 'liberation.fr', 'france24.com', 'lesechos.fr'];
|
|
assert.ok(d.filter(x => GERMAN.includes(x)).length >= 2, d.join(','));
|
|
assert.ok(d.filter(x => FRENCH.includes(x)).length >= 2, d.join(','));
|
|
});
|
|
|
|
test('category=top은 분야가 아니라 전체 피드이므로 보정을 막지 않는다', () => {
|
|
// 유럽 요청이 정확히 이 구멍으로 빠져나갔다: 모델이 top을 붙였다는 이유만으로
|
|
// 매체 제한이 통째로 비활성화됐다. correctNewsCategoryForBreadth도 top을 통과시키므로
|
|
// 두 곳의 판단이 어긋나면 안 된다.
|
|
assert.match(applyMajorOutletsForBroadRequest({ category: 'top' }, 'us').domainurl, /reuters/);
|
|
});
|
|
|
|
test('목록에 없는 나라가 섞여도 있는 나라로 채운다', () => {
|
|
// TLD로 검증하면 안 된다 — faz.net은 독일 매체지만 .de가 아니다.
|
|
const GERMAN = ['spiegel.de', 'zeit.de', 'faz.net', 'sueddeutsche.de', 'welt.de'];
|
|
const d = applyMajorOutletsForBroadRequest({}, 'jp,de').domainurl.split(',');
|
|
assert.ok(d.length > 0);
|
|
assert.ok(d.every(x => GERMAN.includes(x)), d.join(','));
|
|
});
|
|
|
|
test('주제를 지정한 요청은 건드리지 않는다 — 좁히면 그 주제의 보도가 가려진다', () => {
|
|
assert.equal(applyMajorOutletsForBroadRequest({ query: 'crime' }, 'us').domainurl, undefined);
|
|
assert.equal(applyMajorOutletsForBroadRequest({ category: 'sports' }, 'us').domainurl, undefined);
|
|
});
|
|
|
|
test('호출자가 이미 매체를 지정했으면 존중한다', () => {
|
|
assert.equal(applyMajorOutletsForBroadRequest({ domainurl: 'example.com' }, 'us').domainurl, 'example.com');
|
|
});
|
|
|
|
test('목록이 없는 나라는 그대로 둔다', () => {
|
|
assert.equal(applyMajorOutletsForBroadRequest({}, 'jp').domainurl, undefined);
|
|
assert.equal(applyMajorOutletsForBroadRequest({}, '').domainurl, undefined);
|
|
});
|
|
});
|