Files
homeclaw/tests/search-ranking.test.ts
kimandClaude Opus 5 10e54d38eb fix: 게이트 전수 감사 — 한국어 검색 랭킹이 관련성을 0으로 계산하던 버그 외 5건
실사용 로그의 사용자 메시지 1,011건(고유 782건)을 모든 게이트에 통과시켜 오탐/미탐을
실측했다([[feedback_calibrate_on_real_data]]).

가장 큰 건: rankResults의 토큰화가 `q.replace(/[^a-z0-9\s]/g, ' ')`였다. 이 문자 클래스는
한글을 전부 지운다. **모든 한국어 쿼리**에서 관련성 토큰이 빈 배열이 되고 rel 점수가 항상
0이 되어, 결과가 오로지 domainTrustScore로만 정렬됐다 — 질문에 답하는 페이지인지와 무관하게.
시어엔진 라이브 실측("영리의료법인 비영리 병원 비율"): 수정 전에는 "Chee Lai"라는 사람의
LinkedIn 프로필 4건이 1~4위였고 유일한 관련 기사가 아래로 밀려 있었다. 수정 후 그 기사가
1위가 된다. rankResults는 7개 provider 전부가 쓰므로 영향 범위가 검색 전체다.
유니코드 인식 토큰화로 교체(한글·한자 2자 / 영문은 기존 4자 유지). 시점 표현도 영어만
있어서 한국어 "지금/현재/최신"은 배수를 받은 적이 없었다.

나머지:

* isCancelIntent 오탐 — "하지마 유적지는 바다와 떨어져 있는데?"에서 지명 하지마(波島)가
  `하지\s*마`에 걸렸다. 참이면 진행 중이던 작업이 일시정지되고 지리 질문이 작업제어 응답으로
  하이재킹된다(server.ts 두 호출부 모두). 취소는 문장 끝 명령이므로 종결 위치로 앵커를 걸었다.
  영어 쪽은 표본에 사례가 없어 건드리지 않았다.

* isExecutionLikeRequest 오탐 — 맨 "패치"가 목록에 있어 "인슐린 패치는 혈당 측정 기능이
  없나?", "패치는 많이 비싼가?"가 실행형으로 분류됐다. 이 함수는 검증 강제에서 면제시키는
  쪽이라, 실존 의료기기의 기능·가격을 검색 없이 기억으로 답해도 아무도 막지 않았다.
  "구속"을 체포 맥락으로 좁힌 것과 같은 처리 — 소프트웨어 패치 활용형일 때만 걸린다.

* isFactualInfoRequest 미탐 — "얼마" 계열 수량·가격 질문. 표본에서 이 표현으로 물었는데
  어떤 검증 게이트에도 안 걸린 5건이 있었고 전부 정당한 사실 질문이었다(오탐 0):
  "tesla v100 32gb 메모리 대역폭은 얼마지?", "미국 올 해 예산은 전 부 얼마지?"(실제로
  기억에서 $7조 5,400억이 나갔다), "이자 비용은 연간 얼마지?" 등. 맨 "얼마나"는 비-사실
  용법이 흔해 넣지 않았다.

* tool-scope climate 오탐 — "메모리 가격 추세"가 기후 재분석 아카이브 4종(~820토큰)을
  통째로 실었다. "추세"/"장기"는 기후·기상 명사와 같이 나올 때만 걸리게 좁혔다.

* tool-scope kakao 오탐 — `![KakaoMap_….png](…)` 첨부 파일명이 kakao_send_message를 실었다.
  executeTool은 스키마 소속을 확인하지 않으므로([[project_tool_schema_leak]]) 이름만 언급돼도
  호출될 수 있는 종류의 도구다. 첨부 파일명을 판정에서 빼고 카카오맵도 제외했다.

순효과 측정(고유 782건): 검색 강제 리마인더 주입률 23.8% → 25.4%(+13건). 새로 잡힌 13건은
전부 정당한 사실 질문이고, 잃은 건 0건이다.

감사에서 이상 없음으로 확인한 것: EMPTY-GROUNDING·MESSAGING(전제를 직접 검증),
decideAutoRecover·correctNewsCategoryForBreadth(도구 출력 대조 안 함), link-validator(URL 직접
프로브), satellite 게이트("도달 > 비용" 원칙상 유지), 0건 발동 게이트 6종(전용 앱 탭 경로).

테스트 536개 통과(+15).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
2026-09-04 18:01:26 +09:00

50 lines
2.5 KiB
TypeScript

/**
* 검색 결과 랭킹의 관련성 토큰 — 2026-09-04 감사에서 나온 버그.
*
* rankResults의 토큰화가 `q.replace(/[^a-z0-9\s]/g, ' ')`였다. 이 문자 클래스는 한글을 전부
* 지운다. 그래서 **모든 한국어 쿼리**에서 토큰이 빈 배열이 되고, 관련성 점수가 항상 0이 되어
* 결과가 domainTrustScore만으로 정렬됐다 — 질문에 답하는 페이지인지와 무관하게.
*
* 실측(시어엔진 라이브, "영리의료법인 비영리 병원 비율"): 수정 전에는 "Chee Lai"라는 사람의
* LinkedIn 프로필 4건이 1~4위였고 유일한 관련 기사("개인병원도 비영리법인 전환 허용해야")가
* 아래로 밀려 있었다. 수정 후 그 기사가 1위가 된다.
*
* rankResults는 7개 provider 전부가 쓰므로 영향 범위가 검색 전체다.
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { relevanceTokens } from '../src/tools/web';
describe('relevanceTokens — 한국어 쿼리에서도 내용어가 나와야 한다', () => {
test('한글 쿼리가 더 이상 빈 배열이 아니다 (이 버그의 직접 회귀)', () => {
assert.deepEqual(relevanceTokens('구미 날씨 어때'), ['구미', '날씨', '어때']);
assert.deepEqual(relevanceTokens('영리의료법인 법인세율'), ['영리의료법인', '법인세율']);
});
test('한글은 2자부터 내용어 — 날씨/태풍/세율이 잘려나가면 안 된다', () => {
assert.ok(relevanceTokens('태풍 경로').includes('태풍'));
});
test('영어는 기존 4자 기준 그대로 — 기존 랭킹을 흔들지 않는다', () => {
assert.deepEqual(
relevanceTokens('typical option premium underlying asset'),
['typical', 'option', 'premium', 'underlying', 'asset'],
);
// 관사·전치사는 계속 걸러진다.
assert.deepEqual(relevanceTokens('the cost of a in on at'), ['cost']);
});
test('숫자가 붙은 토큰과 혼합 문자열', () => {
assert.deepEqual(relevanceTokens('RTX 5090 memory bandwidth'), ['5090', 'memory', 'bandwidth']);
// 한글 옆의 짧은 영문 토큰은 영어 기준(4자)을 그대로 따른다.
assert.deepEqual(relevanceTokens('M6 맥미니 시장반응'), ['맥미니', '시장반응']);
});
test('구두점·빈 입력에서 터지지 않는다', () => {
assert.deepEqual(relevanceTokens(''), []);
assert.deepEqual(relevanceTokens('!!! ??? ...'), []);
assert.deepEqual(relevanceTokens(null as any), []);
});
});