Files
homeclaw/tests
kimandClaude Opus 5 db6a8634c3 fix: 가드가 맞는 답을 지우던 경로 — 무관한 검색 결과에 대고 판정하던 문제
2026-09-04 메인챗 세션(0c367bd7) 감사. 사용자가 "옵션 프리미엄이 실물의 몇 %냐"를
다섯 턴에 걸쳐 물었고, 검색 6회에 산출물 0건으로 끝났다. 로그를 보니 원인이 모델이
아니라 우리 가드였다:

  TOOL: web_search("typical option premium as percentage of underlying asset price")
  TOOL OK: [1] TYPICAL Definition & Meaning - Merriam-Webster
  NUMERIC-GROUNDING POST-CHECK (1/1): 3 figure(s) not found in tool output [10%, 1%, 2%]

모델은 매 턴 답을 냈다(10%/1%/2%, 다음 턴엔 3~10%/0.1~1%). 검색엔진이 "typical"의
사전 뜻풀이를 물어왔고, 그 뭉치에 숫자가 없다는 이유로 답이 날조 판정을 받아 지워졌다.
이 가드의 전제는 "검색이 그 주제를 다뤘는데 숫자가 없다 → 지어냈다"인데, 앞부분을
한 번도 확인한 적이 없었다. 무관한 말뭉치에서의 부재는 날조의 증거가 아니다.

* 관련성 관문(sourceCoversQuery): 검색어의 내용어 중 결과에 등장하는 비율이 0.34
  미만이면 판정을 포기한다. 실측 — 사전 뭉치 0.14 / 정상 결과 0.86. 수치·날짜·부재주장
  세 갈래가 같은 전제 위에 있어 한꺼번에 게이트했다. 검색어를 못 넘기는 호출부는 기존
  동작 유지.

* SEARCH-ABANDONMENT도 같은 결함이 있었다(감사 중 발견). countSubstantiveResults는
  스니펫 길이만 봐서 사전 페이지 4건을 전부 "알맹이 있음"으로 세고, 모델에게 "그걸로
  답하라"고 민다 — 답을 지우는 대신 틀린 답을 강요하는, 같은 전제의 반대 방향 피해다.
  같은 관문을 공유시켜 기준이 갈라지지 않게 했다.

* 그 가드는 애초에 죽어 있었다. NOTHING_FOUND가 "정보가 없다"류만 담고 있어, gemma4가
  실제로 쓰는 "결과에 …이 포함되어 있지 않다"(주어가 결과)를 하나도 못 잡았다. 이
  세션의 포기 답변 6건 중 0건 탐지. 사과로 시작하면 판정 대상인 첫 문장이 "죄송합니다."가
  되는 두 번째 구멍도 함께 수정(한글 뒤 \b 미매칭 함정 주의).

* dead-end-fallback (신규): 두 번째로 빈손이면 검색을 끊고 "아는 것을 검증 못 했다고
  라벨 붙여 말하라"로 돌린다. 문제의 스레드에서 3턴 앞서 끊긴다. 재프롬프트가 숫자를
  요구하지 않아 NUMERIC-GROUNDING과 충돌하지 않는다.

* 제품·모델 선택 질문이 isFactualInfoRequest를 통째로 빠져나갔다("모델이 있을까",
  "고른다면", "신형/최신"). 그래서 소설-모델 3턴이 도구 0개로 굴러가 2026년 9월에
  "Gemma 2 27B"를 로컬 추천으로 냈다. 주제어가 앞 턴에만 있는 짧은 후속 질문용
  isFactualThreadFollowUp 추가.

* confirmsUserGuessWithoutGrounding: 직전 턴에 확인 못 했다고 해놓고 사용자 추측에
  "네, 맞습니다"로 동조하는 경로 차단(맥미니 M6 건). 4중 조건으로 좁혀 정당한 맞장구는
  건드리지 않는다.

* cms_hospital_compare 게이트가 "병원"에만 걸려 있어 사용자가 내내 쓴 "의료법인"으로는
  8턴 동안 스코프에 들지 않았다. 그 사이 공공 비중 답이 45~50%→39~40%→약 50%로 흔들렸다.

테스트 521개 통과(+65). 실사용 로그의 실제 발화·답변을 표본으로 고정했다.

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

tests/

npm test          # 전체 실행 (약 0.6초)
npm run test:watch  # 파일 저장할 때마다 재실행
npx tsx --test tests/prompt-gates.test.ts   # 한 파일만

Node 22 내장 러너(node:test) + tsx. 테스트 프레임워크 의존성 없음 — jest/vitest 설치 불필요.

배경

2026-07-29 기준 이 디렉터리는 비어 있었고, package.json의 test 스크립트는 존재하지 않는 tests/test-v2.ts를, gateway 스크립트는 v4.3.5에서 삭제된 src/gateway/server-v2.ts를 가리킨 채 방치돼 있었다. 즉 npm test가 실행 자체가 안 되는 상태로 몇 달을 보냈다.

그 대가는 명확했다. 같은 날 프롬프트 게이트 정규식을 다섯 번 고치면서, 매번 node -e "..."로 임시 검증 스크립트를 손으로 짜고 버렸다. 회귀 방지는 하나도 남지 않았다.

무엇을 테스트하는가

순수 함수 우선. 이 저장소에서 가장 값어치 있는 테스트 대상은 I/O가 없는 판정 함수들이다:

파일 대상 왜 중요한가
prompt-gates.test.ts src/gateway/guards/prompt-gates.ts 모델 동작을 강제/억제하는 정규식 게이트. 각 함수는 전부 실제 프로덕션 사고를 겪고 생겼다
usage-log.test.ts src/providers/usage-log.ts Ollama 외 모든 provider의 토큰 집계 단일 지점

이 함수들의 회귀는 크래시도 스택트레이스도 없이 조용히 구멍을 다시 연다. 예를 들어 looksLikeUnverifiedSpecClaim이 한 패턴을 놓치면, 모델이 지어낸 수치가 검증 없이 그대로 사용자에게 간다 — 로그에는 아무 이상도 남지 않는다.

케이스 작성 규칙

프로덕션 원문을 그대로 쓸 것. 줄이거나 다듬지 말 것.

[2026-07-29] 태그가 붙은 케이스들은 실제 대화 로그에서 가져온 문장이다. 어색한 표현이 바로 핵심이다 — 정규식이 놓치는 건 언제나 "예상하지 못한 말투"이지 교과서적인 문장이 아니다.

이 규칙은 실제로 값을 했다. 위 테스트를 처음 쓸 때 원문 "...GPU 간 통신 병목으로 인해 토큰 처리 속도 손실이 약 15%~25%..."를 "...토큰 처리 속도 손실이 약 15%~25%..."로 줄여 썼더니 테스트가 실패했고, 그게 진짜 버그였다. 원문에 우연히 들어있던 GPU 덕에 걸리던 것이지, 같은 주장을 한 단어 다르게 쓰면 그대로 통과하고 있었다. (→ SPEC_CONTEXT_KEYWORD에 토큰/대역폭/추론/처리속도 추가)

다음에 추가하면 좋을 것

현재 커버리지는 순수 함수에 한정된다. 아래는 값어치는 크지만 먼저 리팩터링이 필요하다:

  • skillToolFilter (handle-chat.ts) — 도구 노출을 결정하는 키워드 게이트. handleChat() 내부 클로저라 지금은 테스트 불가. 순수 함수로 추출하면 바로 테스트 가능해진다.
  • 시스템 프롬프트 조립 — 도구 유무에 따른 조건부 블록(imageEditRuleBlock 등). 역시 handleChat() 내부에 있다.
  • executeWebSearch provider 폴백 체인 — I/O가 있어 mock 서버가 필요하다.

handleChat()은 단일 함수가 2,959줄이라 위 둘 다 막혀 있다. 그 분해가 테스트 커버리지를 넓히는 가장 큰 지렛대다.