diff --git a/package.json b/package.json index d461744..1c5ec72 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,9 @@ "build": "rm -rf dist && tsc", "dev": "tsx src/cli/index.ts", "start": "node dist/cli/index.js", - "gateway": "tsx src/gateway/server-v2.ts", - "test": "tsx tests/test-v2.ts", - "test:desktop": "tsx tests/desktop-tools.ts" + "gateway": "tsx src/gateway/server.ts", + "test": "tsx --test tests/*.test.ts", + "test:watch": "tsx --test --watch tests/*.test.ts" }, "files": [ "dist/", diff --git a/src/gateway/guards/prompt-gates.ts b/src/gateway/guards/prompt-gates.ts index 8424e31..10cb9ee 100644 --- a/src/gateway/guards/prompt-gates.ts +++ b/src/gateway/guards/prompt-gates.ts @@ -110,7 +110,14 @@ export function isFactualInfoRequest(message: string): boolean { // SPEC_CONTEXT_KEYWORD like GHz/원/달러 already are. const STRICT_SPEC_UNITS = /\d[\d,.]*\s*(GB|TB|MB|tok\/s|tps|TFLOPS?|GFLOPS?|토큰(\s*\/\s*초)?)/i; const AMBIGUOUS_SPEC_UNITS = /\d[\d,.]*\s*(GHz|MHz|watts?|원|달러|USD|%|\$)/i; -const SPEC_CONTEXT_KEYWORD = /(스펙|사양|가격|가격대|출시|모델명|버전|성능|GPU|CPU|VRAM|램|벤치마크|사이즈|용량)/i; +// 2026-07-29 (2): found by the regression test added the same day. The percentage tier above +// only fires when SPEC_CONTEXT_KEYWORD also matches, and a throughput claim phrased without any +// hardware noun — "토큰 처리 속도 손실이 약 15%~25% 정도까지 벌어집니다" — hit none of them +// ("토큰" as a bare noun is not preceded by a digit, so the strict tier misses it too). The +// production sentence happened to contain "GPU" and was caught; the same claim written one word +// differently would not have been. Throughput/bandwidth/inference nouns are as spec-specific as +// the hardware ones already listed, so they belong in this set. +const SPEC_CONTEXT_KEYWORD = /(스펙|사양|가격|가격대|출시|모델명|버전|성능|GPU|CPU|VRAM|램|벤치마크|사이즈|용량|토큰|대역폭|추론|처리\s*속도)/i; export function looksLikeUnverifiedSpecClaim(content: string): boolean { const text = String(content || ''); diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..dab105d --- /dev/null +++ b/tests/README.md @@ -0,0 +1,57 @@ +# tests/ + +```bash +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줄이라 위 둘 다 막혀 있다. 그 분해가 테스트 커버리지를 +넓히는 가장 큰 지렛대다. diff --git a/tests/prompt-gates.test.ts b/tests/prompt-gates.test.ts new file mode 100644 index 0000000..b970d2b --- /dev/null +++ b/tests/prompt-gates.test.ts @@ -0,0 +1,210 @@ +/** + * prompt-gates.test.ts + * + * Regression tests for the hardcoded regex gates that force/suppress model behavior. + * These are pure string->boolean functions with no I/O, which makes them the highest-value + * thing in the codebase to pin down: every one of them exists because a real production + * failure got through, and a silent regex regression re-opens that exact hole with no + * crash and no stack trace to notice it by. + * + * Cases marked [2026-07-29] come from a production log audit of a GPU/PCIe hardware chat + * where the model fabricated tok/s figures and benchmark percentages. They were verified by + * hand against the real transcript before being written here — do not "simplify" them into + * synthetic strings, the awkward phrasing is the point. + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { + looksLikeUnverifiedSpecClaim, + isExemptFromVerification, + isFactualInfoRequest, + isLiveDataRequest, + isNewsRequest, + isExecutionLikeRequest, + isGreetingLikeMessage, + isUsableGroundingResult, + claimsMessageSent, + isMessagingRequest, + isCancelIntent, + isRerunIntent, +} from '../src/gateway/guards/prompt-gates'; + +describe('looksLikeUnverifiedSpecClaim — 출력에서 검증 안 된 수치 감지', () => { + test('명시적 스펙 단위는 단독으로 걸린다', () => { + assert.equal(looksLikeUnverifiedSpecClaim('약 70~80GB 정도 필요합니다.'), true); + assert.equal(looksLikeUnverifiedSpecClaim('31 tok/s가 나옵니다.'), true); + assert.equal(looksLikeUnverifiedSpecClaim('약 15 TFLOPS 수준입니다.'), true); + }); + + test('[2026-07-29] 단위 없는 "N토큰"도 걸린다 — /초 접미사 없는 실제 사례', () => { + assert.equal( + looksLikeUnverifiedSpecClaim( + 'PCIe 3.0 슬롯의 대역폭 한계로 인해 GPU 간 통신 병목이 발생하여, 70B나 31B 같은 대형 모델 구동 시 15토큰 안팎의 속도가 나오는 것이 맞습니다.', + ), + true, + ); + }); + + test('[2026-07-29] 퍼센트 벤치마크는 스펙 문맥과 함께일 때 걸린다', () => { + assert.equal( + looksLikeUnverifiedSpecClaim( + 'RTX 5060 Ti는 PCIe 5.0 x8 규격이라 PCIe 3.0 환경에서 구동할 때 성능 손실은 약 5% 안팎 수준입니다.', + ), + true, + ); + // 프로덕션 원문 그대로 — "GPU"가 들어있어서 스펙 문맥으로 걸린다. + assert.equal( + looksLikeUnverifiedSpecClaim( + 'RTX 5060 Ti 2장을 묶어 텐서 병렬(Tensor Parallelism)로 LLM을 돌릴 때, PCIe 3.0 환경은 4.0에 비해 GPU 간 통신 병목으로 인해 토큰 처리 속도 손실이 약 15%~25% 정도까지 벌어집니다.', + ), + true, + ); + }); + + test('[2026-07-29-2] 하드웨어 명사 없는 처리량 주장도 걸린다 — 이 테스트가 찾아낸 구멍', () => { + // 위 원문을 한 단어(GPU) 빼고 바꿔 쓰면 통과해버리던 케이스. + // SPEC_CONTEXT_KEYWORD에 토큰/대역폭/추론/처리속도를 추가해 막았다. + assert.equal( + looksLikeUnverifiedSpecClaim( + 'PCIe 3.0은 4.0 대비 토큰 처리 속도 손실이 약 15%~25%까지 벌어집니다.', + ), + true, + ); + assert.equal( + looksLikeUnverifiedSpecClaim('메모리 대역폭이 약 40% 낮습니다.'), + true, + ); + }); + + test('퍼센트가 스펙 문맥 없이 쓰이면 걸리지 않는다 (오탐 방지)', () => { + // % 를 STRICT tier에 넣었다면 이 셋이 전부 헛검색을 유발했을 것 — 이 테스트가 그 회귀를 막는다. + assert.equal(looksLikeUnverifiedSpecClaim('오늘 강수확률은 20%입니다.'), false); + assert.equal(looksLikeUnverifiedSpecClaim('이 상품은 지금 30% 할인 중이에요.'), false); + assert.equal(looksLikeUnverifiedSpecClaim('배터리가 15% 남았습니다.'), false); + }); + + test('일반 대화/숫자 없는 설명은 걸리지 않는다', () => { + assert.equal(looksLikeUnverifiedSpecClaim('저는 GLM 모델이며, 1+1은 2입니다.'), false); + assert.equal(looksLikeUnverifiedSpecClaim('오늘 날씨는 20도 정도입니다.'), false); + assert.equal(looksLikeUnverifiedSpecClaim('이 노트북은 90만원 정도 합니다.'), false); + assert.equal(looksLikeUnverifiedSpecClaim('트랜스포머는 어텐션 기반 구조입니다.'), false); + }); + + test('한국어 헤지 표현은 숫자 없이도 걸린다', () => { + assert.equal(looksLikeUnverifiedSpecClaim('대략 그 정도일 것으로 예상됩니다.'), true); + assert.equal(looksLikeUnverifiedSpecClaim('정확하진 않지만 그렇게 추정됩니다.'), true); + }); + + test('코드블록 안의 숫자는 무시한다', () => { + assert.equal(looksLikeUnverifiedSpecClaim('아래 코드입니다:\n```py\nSIZE_GB = 80\n```'), false); + }); + + test('빈 입력/널은 안전하게 false', () => { + assert.equal(looksLikeUnverifiedSpecClaim(''), false); + assert.equal(looksLikeUnverifiedSpecClaim(null as any), false); + assert.equal(looksLikeUnverifiedSpecClaim(undefined as any), false); + }); +}); + +describe('isExemptFromVerification — 개인 인프라 별칭', () => { + test('사용자 개인 서버 별칭은 검색 강제에서 면제', () => { + assert.equal(isExemptFromVerification('지서버는 PCIe 3.0인가?'), true); + assert.equal(isExemptFromVerification('클로서버 사양 알려줘'), true); + }); + + test('일반 제품 질문은 면제 아님', () => { + assert.equal(isExemptFromVerification('RTX 5090 스펙 알려줘'), false); + }); +}); + +describe('isFactualInfoRequest — 검증이 필요한 질문 분류', () => { + test('비교/스펙/가격 질문을 잡는다', () => { + assert.equal(isFactualInfoRequest('A100 vs V100 비교'), true); + assert.equal(isFactualInfoRequest('RTX 5090 가격은?'), true); + assert.equal(isFactualInfoRequest('실제 속도는 얼마나 되나 비교 도표'), true); + }); + + test('코딩/실행 요청은 제외 (검색이 아니라 실행할 일)', () => { + assert.equal(isFactualInfoRequest('로그인 페이지 만들어줘'), false); + assert.equal(isFactualInfoRequest('이 버그 고쳐줘'), false); + }); + + test('개인 인프라 질문은 제외 — 검색해도 나올 수 없음', () => { + assert.equal(isFactualInfoRequest('지서버 사양 비교해줘'), false); + }); + + test('인사말은 제외', () => { + assert.equal(isFactualInfoRequest('hello'), false); + assert.equal(isFactualInfoRequest(''), false); + }); +}); + +describe('isNewsRequest / isLiveDataRequest', () => { + test('뉴스 요청을 잡는다', () => { + assert.equal(isNewsRequest('오늘 미국 주요 뉴스'), true); + assert.equal(isNewsRequest('속보 있어?'), true); + }); + + test('"무슨 소식 있어?"류 안부 인사는 뉴스가 아니다', () => { + // 이 관용구 예외가 없으면 잡담이 news_search 강제로 이어진다. + assert.equal(isNewsRequest('무슨 좋은 소식 있어?'), false); + assert.equal(isNewsRequest('별 소식 없지?'), false); + }); + + test('안부 관용구 + 실제 뉴스 키워드가 섞이면 뉴스로 본다', () => { + assert.equal(isNewsRequest('오늘 무슨 뉴스 있어?'), true); + }); + + test('날씨/환율 등 라이브 데이터', () => { + assert.equal(isLiveDataRequest('내일 구미 날씨'), true); + assert.equal(isLiveDataRequest('환율 알려줘'), true); + assert.equal(isLiveDataRequest('파이썬 리스트 정렬법'), false); + }); +}); + +describe('isUsableGroundingResult — 빈 검색결과 판별', () => { + test('실제 내용이 있으면 사용 가능', () => { + assert.equal(isUsableGroundingResult({ result: '[1] 어떤 기사 제목' }), true); + }); + + test('에러/빈문자/센티넬은 사용 불가 — 이게 false여야 정직한 재답변이 강제된다', () => { + assert.equal(isUsableGroundingResult({ error: true, result: 'x' }), false); + assert.equal(isUsableGroundingResult({ result: '' }), false); + assert.equal(isUsableGroundingResult({ result: ' ' }), false); + assert.equal(isUsableGroundingResult({ result: '(no articles found)' }), false); + assert.equal(isUsableGroundingResult({ result: '에 대한 검색 결과가 없습니다.' }), false); + }); +}); + +describe('메시징 완료 주장 검증', () => { + test('전송 요청을 잡는다', () => { + assert.equal(isMessagingRequest('이메일 보내줘'), true); + assert.equal(isMessagingRequest('카톡으로 전송해줘'), true); + assert.equal(isMessagingRequest('이메일 읽어줘'), false); + }); + + test('보냈다는 주장을 잡는다 — 도구 호출 없이 이러면 재확인이 강제된다', () => { + assert.equal(claimsMessageSent('메일 보냈습니다.'), true); + assert.equal(claimsMessageSent('전송 완료했어요.'), true); + assert.equal(claimsMessageSent('보낼까요?'), false); + }); +}); + +describe('의도 분류', () => { + test('취소/재실행 의도', () => { + assert.equal(isCancelIntent('취소해'), true); + assert.equal(isCancelIntent('그만해'), true); + assert.equal(isRerunIntent('다시 실행해줘'), true); + assert.equal(isRerunIntent('안녕'), false); + }); +}); + +describe('보조 분류기', () => { + test('실행형 요청 / 인사말', () => { + assert.equal(isExecutionLikeRequest('파일 만들어줘'), true); + assert.equal(isExecutionLikeRequest('오늘 날씨'), false); + assert.equal(isGreetingLikeMessage('hello'), true); + assert.equal(isGreetingLikeMessage('hello, search the web for X'), false); + }); +}); diff --git a/tests/usage-log.test.ts b/tests/usage-log.test.ts new file mode 100644 index 0000000..11633a7 --- /dev/null +++ b/tests/usage-log.test.ts @@ -0,0 +1,50 @@ +/** + * usage-log.test.ts + * + * parseOpenAiUsage is the single point where every non-Ollama provider's token counts get + * picked up (openai-compat serves Gemini/LM Studio/OpenAI, openai-codex serves Codex). It + * returning null silently means a provider vanishes from the cost-comparison log entirely — + * which is exactly the bug this module was extracted to fix on 2026-07-29, and the kind that + * produces no error, just a quietly wrong conclusion months later. + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseOpenAiUsage } from '../src/providers/usage-log'; + +describe('parseOpenAiUsage', () => { + test('표준 OpenAI usage 필드를 읽는다', () => { + assert.deepEqual( + parseOpenAiUsage({ prompt_tokens: 137, completion_tokens: 42 }), + { promptTokens: 137, completionTokens: 42 }, + ); + }); + + test('camelCase 변형도 받는다', () => { + assert.deepEqual( + parseOpenAiUsage({ promptTokens: 10, completionTokens: 20 }), + { promptTokens: 10, completionTokens: 20 }, + ); + }); + + test('한쪽만 있어도 기록한다 — 절반이라도 남기는 게 0건보다 낫다', () => { + assert.deepEqual(parseOpenAiUsage({ prompt_tokens: 500 }), { promptTokens: 500, completionTokens: 0 }); + assert.deepEqual(parseOpenAiUsage({ completion_tokens: 7 }), { promptTokens: 0, completionTokens: 7 }); + }); + + test('usage가 없거나 값이 없으면 null — 0/0짜리 가짜 행을 남기지 않는다', () => { + assert.equal(parseOpenAiUsage(undefined), null); + assert.equal(parseOpenAiUsage(null), null); + assert.equal(parseOpenAiUsage({}), null); + assert.equal(parseOpenAiUsage({ prompt_tokens: 0, completion_tokens: 0 }), null); + assert.equal(parseOpenAiUsage('nope' as any), null); + }); + + test('숫자가 아닌 값은 0으로 떨어뜨린다 (NaN이 로그에 새지 않게)', () => { + assert.equal(parseOpenAiUsage({ prompt_tokens: 'abc', completion_tokens: 'def' }), null); + assert.deepEqual( + parseOpenAiUsage({ prompt_tokens: 'abc', completion_tokens: 5 }), + { promptTokens: 0, completionTokens: 5 }, + ); + }); +});