/** * search-budget.test.ts * * The runaway this guards against was real: kimi-k2.6 issuing 7+ (once 50+) searches for one * broad request, walking topic by topic instead of doing a couple of broad searches. * * The hard part is that the obvious fix — a flat call cap — breaks legitimate fan-out. Checking * weather for 8 cities is 8 distinct searches, not a loop. So both directions need pinning: * the cap must bite on repetition AND must not bite on genuine breadth. A regression in either * direction is silent (either searches quietly stop working, or the runaway comes back). */ import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; import { evaluateSearchBudget, extractSearchQueryTokens, SEARCH_SOFT_CAP, SEARCH_HARD_CAP, } from '../src/gateway/chat/search-budget'; const evalWith = (priorQueries: string[], currentQuery: string, priorBlockedCount = 0) => evaluateSearchBudget({ priorQueries, currentQuery, priorBlockedCount }); describe('extractSearchQueryTokens', () => { test('불용어를 걸러 실제 주제어만 남긴다', () => { const t = extractSearchQueryTokens('오늘 서울 날씨 알려줘'); assert.ok(t.has('서울')); for (const stop of ['오늘', '날씨', '알려줘']) assert.ok(!t.has(stop), `${stop} 가 남음`); }); test('대소문자/문장부호를 정규화한다', () => { const t = extractSearchQueryTokens('RTX 5090, PCIe 5.0!'); assert.ok(t.has('rtx')); assert.ok(t.has('5090')); }); test('한 글자 토큰은 버린다 — 변별력이 없다', () => { assert.ok(!extractSearchQueryTokens('a b 서 울').has('a')); }); }); describe('소프트캡 이하 — 자유롭게 허용', () => { test('0회~4회까지는 무조건 통과', () => { for (let n = 0; n < SEARCH_SOFT_CAP; n++) { const prior = Array.from({ length: n }, (_, i) => `쿼리${i}`); assert.equal(evalWith(prior, '쿼리0').allowed, true, `${n}회째에서 막힘`); } }); }); describe('소프트캡 초과 — 반복은 막고 새 주제는 통과', () => { const five = ['우크라이나 전황', '가자 지구', '관세 협상', '금리 인상', '유가 동향']; test('이미 검색한 주제를 또 검색하면 막힌다 (원래의 폭주 패턴)', () => { const d = evalWith(five, '우크라이나 전황 다시'); assert.equal(d.allowed, false); assert.match(d.blockedReason, /\[BLOCKED\]/); assert.match(d.blockedReason, /repeats a topic/); }); test('새로운 주제는 소프트캡을 넘겨도 통과한다 — 정당한 팬아웃 보호', () => { // 도시 8곳 날씨 확인 같은 경우가 여기 해당한다. assert.equal(evalWith(five, '삿포로 적설량').allowed, true); }); test('불용어만 다른 쿼리는 새 주제가 아니다', () => { // "오늘 뉴스" vs "현재 뉴스" — 이게 통과하면 캡이 사실상 무력화된다. const prior = ['오늘 뉴스', '현재 뉴스', '지금 뉴스', '오늘의 뉴스', '뉴스 알려줘']; assert.equal(evalWith(prior, '지금 뉴스 알려줘').allowed, false); }); }); describe('하드캡 — 새 주제라도 결국 멈춘다', () => { const twelve = Array.from({ length: SEARCH_HARD_CAP }, (_, i) => `고유주제${i}`); test('12회에 도달하면 완전히 새로운 주제도 막힌다', () => { const d = evalWith(twelve, '완전히새로운주제'); assert.equal(d.allowed, false); assert.equal(d.hitHardCap, true); assert.match(d.blockedReason, /hard cap/i); }); test('하드캡 직전(11회)에는 새 주제가 아직 통과한다', () => { assert.equal(evalWith(twelve.slice(0, SEARCH_HARD_CAP - 1), '완전히새로운주제').allowed, true); }); }); describe('반복 위반 — 두 번째부터 도구 자체를 끈다', () => { const five = ['가', '나', '다', '라', '마'].map(s => `주제${s}`); test('첫 차단에서는 검색만 막고 도구는 살려둔다', () => { const d = evalWith(five, '주제가', 0); assert.equal(d.allowed, false); assert.equal(d.disableToolsForRestOfTurn, false); }); test('이미 한 번 무시했다면 남은 턴 도구를 전부 끈다', () => { const d = evalWith(five, '주제가', 1); assert.equal(d.disableToolsForRestOfTurn, true); assert.match(d.blockedReason, /already ignored one stop instruction/i); }); }); describe('차단 메시지 — 모델이 다음에 뭘 해야 하는지 알려준다', () => { test('세 경우 모두 "지금 답을 쓰라"고 지시한다', () => { const five = ['주제1', '주제2', '주제3', '주제4', '주제5']; const twelve = Array.from({ length: SEARCH_HARD_CAP }, (_, i) => `t${i}`); for (const d of [evalWith(five, '주제1', 0), evalWith(five, '주제1', 1), evalWith(twelve, 'x', 0)]) { assert.match(d.blockedReason, /answer now|final text answer/i); } }); test('허용될 때는 사유가 비어 있다', () => { const d = evalWith([], '아무거나'); assert.equal(d.blockedReason, ''); assert.equal(d.disableToolsForRestOfTurn, false); assert.equal(d.hitHardCap, false); }); });