From 4764ccd5b39b55622655faa701bdea0c0fee2b8e Mon Sep 17 00:00:00 2001 From: kim Date: Wed, 12 Aug 2026 13:47:47 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9C=A0=EB=9F=BD=20=EB=8B=A4=EA=B5=AD?= =?UTF-8?q?=EA=B0=80=20=EB=89=B4=EC=8A=A4=20=EC=9A=94=EC=B2=AD=EC=9D=B4=20?= =?UTF-8?q?=EB=B9=88=EC=86=90=EC=9C=BC=EB=A1=9C=20=EB=8F=8C=EC=95=84?= =?UTF-8?q?=EC=98=A4=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20(+=20bbc.co.uk=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지적: "유럽은 여전한데?" 로그를 보니 모델은 유럽 요청을 이렇게 보낸다. 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 --- src/tools/news.ts | 42 +++++++++++++++++++++++++++++++++++++----- tests/news.test.ts | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/src/tools/news.ts b/src/tools/news.ts index a924c7a..36caa58 100644 --- a/src/tools/news.ts +++ b/src/tools/news.ts @@ -148,9 +148,21 @@ const CATEGORY_TOPIC: Record = { const MAJOR_OUTLETS: Record = { us: ['reuters.com', 'apnews.com', 'nytimes.com', 'washingtonpost.com', 'npr.org'], kr: ['newsis.com', 'joongang.co.kr', 'chosun.com', 'hani.co.kr', 'donga.com'], - gb: ['bbc.co.uk', 'theguardian.com', 'reuters.com', 'ft.com', 'independent.co.uk'], + // bbc.com 이다. bbc.co.uk 는 NewsData DB에 없어서 요청 전체가 HTTP 422로 죽는다 — + // 도메인 하나가 잘못되면 나머지 넷도 같이 실패한다. 2026-08-12에 이걸 검증 없이 넣었다가 + // 유럽 다국가 요청이 통째로 터졌다. 목록에 도메인을 추가할 땐 반드시 라이브로 확인할 것. + gb: ['bbc.com', 'theguardian.com', 'reuters.com', 'ft.com', 'independent.co.uk'], + // 2026-08-12 추가. 사용자가 "유럽은 여전한데?"라고 지적해 로그를 보니 모델이 유럽 요청을 + // country="gb,de,fr,it,es" 다국가 형태로 보내고 있었다. 아래 네 나라는 라이브로 확인했다. + de: ['spiegel.de', 'zeit.de', 'faz.net', 'sueddeutsche.de', 'welt.de'], + fr: ['lemonde.fr', 'lefigaro.fr', 'liberation.fr', 'france24.com', 'lesechos.fr'], + it: ['corriere.it', 'repubblica.it', 'lastampa.it', 'ansa.it', 'ilsole24ore.com'], + es: ['elpais.com', 'elmundo.es', 'abc.es', 'lavanguardia.com', 'rtve.es'], }; +/** domainurl은 6개째부터 UnsupportedQueryLength로 거절된다. 편집 판단이 아니라 API 제약. */ +const MAX_DOMAINS = 5; + /** * Restricts a BROAD request to major outlets. * @@ -161,11 +173,31 @@ const MAJOR_OUTLETS: Record = { */ export function applyMajorOutletsForBroadRequest(args: any, country: string): any { if (String(args?.query || '').trim()) return args; - if (String(args?.category || '').trim()) return args; if (String(args?.domainurl || '').trim()) return args; - const domains = MAJOR_OUTLETS[String(country || '').toLowerCase()]; - if (!domains) return args; - return { ...(args || {}), domainurl: domains.join(',') }; + // "top"은 분야가 아니라 전체 피드다 — correctNewsCategoryForBreadth도 같은 이유로 top을 + // 통과시킨다. 두 곳이 어긋나면 모델이 top을 붙였다는 이유만으로 이 보정이 통째로 비활성화된다 + // (2026-08-12 유럽 요청이 정확히 그렇게 빠져나갔다). + const category = String(args?.category || '').trim().toLowerCase(); + if (category && category !== 'top') return args; + + // country는 "gb,de,fr,it,es" 처럼 여러 나라가 올 수 있다. 나라별 목록에서 라운드로빈으로 + // 뽑아 5개를 채운다 — 앞에서부터 채우면 첫 나라 매체만 다섯이 되어 나머지가 통째로 빠진다. + const countries = String(country || '').toLowerCase().split(',').map(c => c.trim()).filter(Boolean); + const lists = countries.map(c => MAJOR_OUTLETS[c]).filter(Boolean) as string[][]; + if (!lists.length) return args; + + const picked: string[] = []; + for (let i = 0; picked.length < MAX_DOMAINS; i++) { + const before = picked.length; + for (const list of lists) { + if (picked.length >= MAX_DOMAINS) break; + const d = list[i]; + if (d && !picked.includes(d)) picked.push(d); + } + if (picked.length === before) break; // 모든 목록이 소진됨 + } + if (!picked.length) return args; + return { ...(args || {}), domainurl: picked.join(',') }; } export function fillNewsCategoryWhenExplicit(args: any, currentMessage: string): any { diff --git a/tests/news.test.ts b/tests/news.test.ts index 45192cb..59a0c45 100644 --- a/tests/news.test.ts +++ b/tests/news.test.ts @@ -187,7 +187,7 @@ describe('applyMajorOutletsForBroadRequest — 넓은 요청만 주요 매체로 test('주제 없는 요청은 주요 매체로 좁힌다', () => { assert.match(applyMajorOutletsForBroadRequest({}, 'us').domainurl, /reuters\.com/); assert.match(applyMajorOutletsForBroadRequest({}, 'kr').domainurl, /joongang\.co\.kr/); - assert.match(applyMajorOutletsForBroadRequest({}, 'gb').domainurl, /bbc\.co\.uk/); + assert.match(applyMajorOutletsForBroadRequest({}, 'gb').domainurl, /bbc\.com/); }); test('API 제한상 5개를 넘기지 않는다', () => { @@ -197,6 +197,40 @@ describe('applyMajorOutletsForBroadRequest — 넓은 요청만 주요 매체로 } }); + 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);