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
This commit is contained in:
kim
2026-09-04 17:51:12 +09:00
co-authored by Claude Opus 5
parent e658f6a634
commit db6a8634c3
11 changed files with 879 additions and 12 deletions
+122
View File
@@ -0,0 +1,122 @@
/**
* dead-end-fallback.ts
*
* 검색이 연달아 헛도는데도 모델이 계속 검색만 다시 돌릴 때, 한 번 끊고 "아는 걸 말하되
* 검증 못 했다고 밝혀라"로 돌린다.
*
* THE INCIDENT (2026-09-04, 메인챗 세션 0c367bd7 "옵션 프리미엄"). 사용자가 선물/옵션 설명을
* 잘 받은 뒤 "티켓값은 실물의 얼마나 되지?"라고 물었다. 그때부터 5턴 동안:
* web_search("option premium as percentage of underlying asset price") → 사전/ETF 페이지
* web_search("typical option premium as percentage of underlying …") → Merriam-Webster
* web_search("option premium calculation factors and typical range …") → IMF 보고서
* web_search("typical option premium as percentage of underlying …") (중복) → Merriam-Webster
* 매번 답은 "검색 결과에는 구체적인 수치가 포함되어 있지 않습니다"였다. 사용자는 마지막에
* "대충 얼마냔거지"라고까지 썼는데 끝내 숫자도, 설명도 못 받았다. 6회 검색, 산출물 0.
*
* 쿼리는 멀쩡했다 — 검색엔진이 "typical"을 사전에서 물어왔을 뿐이다. 진짜 실패는 그 다음이다:
* 모델은 옵션 프리미엄이 무엇으로 결정되는지 이미 알고 있었고(실제로 매 턴 마지막 문단에
* 행사가·변동성·잔존만기를 정확히 썼다), 그런데도 "검색에 없으니 답할 수 없다"로 답을 닫았다.
* 도구가 실패하면 아는 것까지 같이 버리는 것 — 그게 이 가드가 잡는 실패다.
*
* WHY NOT A PROMPT. 시스템 프롬프트에는 이미 "불확실성 표현은 길이 제한에서 면제"라는 조항이
* 있고, 모델은 그걸 잘 지켰다 — 못 찾았다고 정직하게, 여러 문장에 걸쳐 말했다. 빠진 건 정직함이
* 아니라 "그럼에도 아는 것은 준다"는 두 번째 동작이다. 제약이 겹치면 부수 제약을 버리는 모델에게
* 조항을 하나 더 얹는 건 듣지 않는다([[feedback_local_model_needs_code_backstop]]).
*
* WHY IT DOES NOT COLLIDE WITH NUMERIC-GROUNDING. 이 가드의 재프롬프트는 숫자를 요구하지
* 않는다. 요구하는 건 메커니즘·구조 설명과 "검색으로 확인 못 함"이라는 명시적 라벨이다. 모델이
* 그래도 근거 없는 수치를 쓰면 NUMERIC-GROUNDING이 그대로 잡는다 — 두 가드는 겹치지 않는다.
*/
import { declaresNothingFound } from './search-abandonment';
/**
* 지난 턴이 "못 찾았다"로 끝났는가. 현재 답변과 **같은 기준**(첫 문장)으로 센다.
*
* 처음에는 지난 턴만 답변 전체로 넓히려 했다 — 위 사건의 한 턴이 "옵션 프리미엄은 HTS나 MTS의
* 시세표를 통해…"로 시작해 첫 문장만 보면 포기가 아닌데 본문은 "수치가 포함되어 있지 않아
* 단정하기 어렵습니다"였기 때문이다. 회귀 테스트를 쓰다가 그게 틀렸음이 드러났다: 그 기준이면
* search-abandonment.ts가 명시적으로 통과시키는 정상 답변 — 태풍을 찾아내고 "다만 좌표는 명시되어
* 있지 않습니다"로 끝나는 형태 — 까지 실패한 턴으로 세게 된다. 두 답변은 텍스트만으로 구별되지
* 않는다(둘 다 마지막 문장이 "…있지 않습니다"다).
*
* 그래서 좁은 쪽을 택한다. 덜 세면 가드가 한 턴 늦게 발동할 뿐이고(안전한 실패 방향), 더 세면
* 제대로 답한 턴을 실패로 몰아 멀쩡한 대화에 재프롬프트가 끼어든다. 실측으로도 좁은 기준이면
* 문제의 스레드에서 세 번째 턴에 발동해 뒤의 두 턴을 그대로 아낀다.
*/
export function answerEndedInDeadEnd(text: string): boolean {
return declaresNothingFound(String(text || '').slice(0, 4000));
}
export interface DeadEndInput {
/** 이번 턴에 모델이 내놓으려는 답변. */
answer: string;
/** 이 세션의 직전 assistant 답변들 (최신순 아님 — 시간순 그대로 넘겨도 된다). */
priorAssistantAnswers: readonly string[];
/**
* SEARCH-ABANDONMENT가 이번 턴을 가져가는가(decideSearchAbandonmentRetry().retry).
* 가져가면 이 가드는 비켜선다 — 두 가드가 같은 턴에 각자 재프롬프트를 밀면 라운드만 두 배로 든다.
*
* 원래는 substantiveResults(알맹이 있는 결과 수) >= 3 으로 대신 판정했는데, 그건 두 가드의
* 조건이 정확히 상보적일 때만 성립하는 가정이었다. 2026-09-04 감사에서 SEARCH-ABANDONMENT에
* 관련성 관문이 붙으면서 그 가정이 깨졌다: 사전 뜻풀이 4건은 substantive로는 3건을 넘지만
* 주제를 안 다뤄서 abandonment가 비켜서고, 이 가드도 "abandonment가 하겠지" 하고 비켜서
* **아무도 안 잡는 구멍**이 생긴다. 판정 결과를 그대로 받는 편이 그 커플링을 없앤다.
*/
abandonmentRetrying: boolean;
/** 이번 턴에 grounding 도구를 실제로 부르긴 했는가. */
calledGroundingTool: boolean;
}
export interface DeadEndDecision {
fallback: boolean;
/** 최근 창에서 헛돈 턴 수(이번 턴 포함) — 로그·재프롬프트 문구에 쓴다. */
deadEndStreak: number;
}
/**
* 최근 몇 턴을 볼 것인가. 4턴이면 "직전 답도 헛돌았다"를 한 턴 걸러 이어져도 잡는다 —
* 위 사건에서 헛돈 턴들은 정확히 붙어 있지 않았고(중간에 절반짜리 답이 하나 끼었다),
* 붙어 있는 경우만 세면 마지막 턴까지 한 번도 발동하지 않는다.
*/
const LOOKBACK_TURNS = 4;
/**
* 임계값 1 — "최근 창 안에서 이번 말고도 한 번 더 헛돌았다".
*
* 즉 발동 조건은 사실상 "연속 두 번째 빈손"이다. 한 번은 정상이다(검색에 정말 없을 수 있고,
* 그때 정직하게 말하는 건 옳은 동작이라 건드리면 안 된다). 두 번째부터는 검색 경로 자체가
* 이 질문에 안 맞는다는 신호로 본다. 위 사건에서는 이 값이면 3턴 앞서 끊긴다.
*
* 낮게 잡아도 위험이 작은 이유: 이 가드는 검색을 막지 않는다. 이미 포기 선언이 나온 뒤에만
* 돌고, 시키는 일도 "아는 것을 라벨 붙여 말하라"라서 최악의 경우가 답변이 조금 길어지는 것이다.
*/
const PRIOR_DEAD_ENDS_REQUIRED = 1;
export function decideDeadEndFallback(input: DeadEndInput): DeadEndDecision {
const priorDeadEnds = input.priorAssistantAnswers
.slice(-LOOKBACK_TURNS)
.filter(answerEndedInDeadEnd)
.length;
const currentIsDeadEnd = declaresNothingFound(input.answer);
const deadEndStreak = priorDeadEnds + (currentIsDeadEnd ? 1 : 0);
return {
fallback: currentIsDeadEnd
&& input.calledGroundingTool
&& !input.abandonmentRetrying
&& priorDeadEnds >= PRIOR_DEAD_ENDS_REQUIRED,
deadEndStreak,
};
}
/** 재프롬프트 본문. 문구를 테스트에서 고정하려고 분리해 둔다. */
export function deadEndFallbackPrompt(streak: number): string {
return `This is the ${streak}th answer in a row where you told the user the search results did not contain what they asked for. Stop searching for it — the search path is not working for this question, and running the same kind of query again will produce the same nothing.
Answer now from what you actually know, and be explicit about its status:
- Explain the substance: the mechanism, what determines the value, the typical shape of the answer, how the user could look it up themselves (a specific site, screen, or filing).
- If you know a rough order of magnitude or a normal range from general knowledge, give it — clearly marked as general knowledge that this turn's search did not confirm (e.g. "일반적으로 알려진 범위는 …인데, 이번 검색으로는 확인하지 못했습니다"). Do not dress it up as a measurement, do not attach false precision, and never present it as something a tool returned.
- If you genuinely do not know even approximately, say that plainly — but still explain the mechanism rather than only reporting the search as empty.
"검색 결과에 없습니다" alone is not an answer. The user asked you, not the search engine.`;
}
+77 -1
View File
@@ -9,6 +9,8 @@ import {
isExecutionLikeRequest,
isLiveDataRequest,
isFactualInfoRequest,
isFactualThreadFollowUp,
confirmsUserGuessWithoutGrounding,
looksLikeUnverifiedSpecClaim,
isExemptFromVerification,
isBrowserAutomationRequest,
@@ -83,6 +85,7 @@ import { decideAutoRecover, shouldForceMessagingRetry as decideMessagingRetry, s
import { logToolSkip } from './tool-skip-log';
import { checkNumericGrounding } from './numeric-grounding';
import { decideSearchAbandonmentRetry } from './search-abandonment';
import { decideDeadEndFallback, deadEndFallbackPrompt, answerEndedInDeadEnd } from './dead-end-fallback';
import { correctNewsCategoryForBreadth, fillNewsCategoryWhenExplicit } from '../../tools/news';
const MAX_TOOL_ROUNDS = 50;
@@ -314,6 +317,12 @@ async function handleChat(
const MAX_NUMERIC_GROUNDING_FORCED_RETRIES = 1;
let searchAbandonmentForcedRetries = 0;
const MAX_SEARCH_ABANDONMENT_FORCED_RETRIES = 1;
// 한 턴에 한 번만. 이 재프롬프트는 "검색 말고 아는 걸 써라"라서, 두 번 걸면 모델이 낼 답이
// 이미 나와 있는데 라운드만 더 쓴다.
let deadEndFallbackNudges = 0;
const MAX_DEAD_END_FALLBACK_NUDGES = 1;
let sycophancyNudges = 0;
const MAX_SYCOPHANCY_NUDGES = 1;
let staleVersionQueryNudges = 0;
// Uncapped-round, unconditional-tool-history version of the round-0 tool-skip recovery
// below: a model that calls one irrelevant tool (e.g. coder_list_files while answering a
@@ -778,7 +787,11 @@ async function handleChat(
role: 'user',
content: `Reminder: this needs live/current data. Call a tool first (${_availableGroundingHint}) before writing anything. Do NOT answer from memory or training data.${newsRequest ? ' If this message names a specific topic, pass it as news_search\'s query param; otherwise (a bare "뉴스"/"news" request) call news_search several times across different categories (e.g. "top", "business", "technology", "world", "sports") rather than a single bare no-category call — a lone bare call tends to return only 4-5 stories clustered around 1-2 dominant topics; spanning categories like this builds a well-rounded ~8-10 item digest. Do NOT default to unrelated topics from earlier in this conversation unless the user explicitly asks for updates on them.' : ''}${disasterRequest ? ` This question likely has two parts — where the storm/event currently is, and what damage/impact it's causing — and one tool rarely covers both. For CURRENT POSITION/TRACK of a typhoon, call weather_map_screenshot if available (it captures the real KMA/JMA forecast-track map — the exact literal URL is documented in that tool's own schema, do not guess a URL). For DAMAGE/IMPACT/human toll, prefer news_search (query: the event name, e.g. "태풍 돌핀") over web_search — it guarantees real publish dates instead of stale/generic pages. If you do use web_search for either part, do NOT default to a generic "현재 위치/실시간 정보" query; use situational keywords instead (e.g. "태풍 돌핀 상륙 피해", "지진 피해 현황") — those match actual news coverage, while location-lookup phrasing tends to surface blank reference portals. If the first attempt returns thin/generic results, that means the query was wrong, not that nothing is happening — retry with sharper keywords before concluding the event has ended.` : ''}`,
});
} else if (isFactualInfoRequest(message)) {
// 주제어가 앞 턴에 있고 이번 턴엔 명사구만 남은 후속 질문("올라마 클라우드 모델은?")도 같은
// 리마인더를 받아야 한다 — 대화가 깊어질수록 모델이 기억으로 답하기 쉬워지는데, 정작 그때
// 게이트는 메시지가 짧아 아무것도 못 본다(2026-09-04 소설-모델 스레드가 그렇게 3턴 갔다).
} else if (isFactualInfoRequest(message)
|| isFactualThreadFollowUp(message, String([...history].reverse().find(h => h.role === 'user')?.content || ''))) {
messages.push({ role: 'assistant', content: 'Got it — let me verify that first.' });
messages.push({
role: 'user',
@@ -1811,11 +1824,23 @@ async function handleChat(
// the exact case that shipped "초당 약 2~5 토큰" for an unbenchmarked GPU pair. This one
// compares the answer's (number, unit) claims against the grounding tool output and only
// objects when EVERY claim is unsupported, so derived figures and partial misses pass.
// 이번 턴에 실제로 나간 검색어. 아래 두 가드(NUMERIC-GROUNDING / SEARCH-ABANDONMENT)가
// 공유한다 — 둘 다 "검색이 이 주제를 다뤘다"를 전제로 답을 지우거나 강요하기 때문이다.
const _searchQueriesThisTurn = groundingResultsThisTurn
.map((r) => String((r as any)?.args?.query || '').trim())
.filter(Boolean);
const _numeric = checkNumericGrounding(
candidateText,
groundingResultsThisTurn.map((r) => String(r?.result || '')),
message,
// 이번 턴에 실제로 나간 검색어. 이게 있어야 "검색 결과가 애초에 이 주제를 다뤘는가"를
// 판정할 수 있다 — 없으면 무관한 결과에 대고 답의 숫자를 대조하게 되고, 그게 2026-09-04
// 옵션 프리미엄 5턴을 통째로 날린 경로였다.
_searchQueriesThisTurn,
);
if (!_numeric.sourceOnTopic) {
console.log(`[v2] NUMERIC-GROUNDING: standing down — search results cover only ${(_numeric.sourceCoverage * 100).toFixed(0)}% of the query's terms, so they cannot show the answer's figures were fabricated.`);
}
const shouldForceNumericRetry = _numeric.allUnsupported
&& numericGroundingForcedRetries < MAX_NUMERIC_GROUNDING_FORCED_RETRIES;
if (shouldForceNumericRetry) {
@@ -1856,6 +1881,9 @@ async function handleChat(
const _abandon = decideSearchAbandonmentRetry({
answer: candidateText,
sourceTexts: groundingResultsThisTurn.map((r) => String(r?.result || '')),
// 결과가 검색어의 주제를 안 다뤘다면 "못 찾았다"는 정확한 서술이다. 이걸 안 보면
// 사전 뜻풀이 4건을 "알맹이 있는 결과"로 세어 그걸로 답하라고 밀게 된다(2026-09-04 감사).
searchQueries: _searchQueriesThisTurn,
});
if (_abandon.retry && searchAbandonmentForcedRetries < MAX_SEARCH_ABANDONMENT_FORCED_RETRIES) {
searchAbandonmentForcedRetries++;
@@ -1869,6 +1897,54 @@ async function handleChat(
continue;
}
// DEAD-END FALLBACK POST-CHECK (2026-09-04). SEARCH-ABANDONMENT above only fires when the
// results DID have content — when they genuinely didn't, it correctly stands down and the
// answer ships as "검색 결과에 없습니다". Repeat that a few turns and the user has asked the
// same thing four times and received nothing, which is what happened on 옵션 프리미엄
// (6 searches, 5 turns, 0 answers — see dead-end-fallback.ts). Once a second turn in a row
// dead-ends, stop re-searching and make the model spend what it knows, labelled as unverified.
const _deadEnd = decideDeadEndFallback({
answer: candidateText,
priorAssistantAnswers: history.filter(h => h.role === 'assistant').map(h => String(h.content || '')),
// SEARCH-ABANDONMENT가 이 턴을 안 가져갈 때만 나선다. 위 if 블록은 재시도 예산이
// 남아 있어야 실행되므로, 예산 소진으로 abandonment가 못 도는 경우까지 여기서 받는다.
abandonmentRetrying: _abandon.retry && searchAbandonmentForcedRetries < MAX_SEARCH_ABANDONMENT_FORCED_RETRIES,
calledGroundingTool: groundingResultsThisTurn.length > 0,
});
if (_deadEnd.fallback && deadEndFallbackNudges < MAX_DEAD_END_FALLBACK_NUDGES) {
deadEndFallbackNudges++;
console.log(`[v2] DEAD-END FALLBACK POST-CHECK (${deadEndFallbackNudges}/${MAX_DEAD_END_FALLBACK_NUDGES}): ${_deadEnd.deadEndStreak} consecutive turns answered "search found nothing" with ${_abandon.substantiveResults} substantive result(s). Requiring a knowledge-grounded answer instead...`);
sendSSE('info', { message: '검색이 반복해서 헛돌았습니다 — 아는 범위에서 답하도록 요청합니다...' });
messages.push({ role: 'assistant', content: candidateText });
messages.push({ role: 'user', content: deadEndFallbackPrompt(_deadEnd.deadEndStreak) });
lastRoundWasGuardReprompt = true;
continue;
}
// SYCOPHANTIC-CONFIRMATION POST-CHECK (2026-09-04). One turn after saying it could not
// verify which Mac mini generation is current, the model answered "아 그럼 M6구나" with
// "네, 맞습니다" and no tool call — confirming, on the user's say-so, the very fact it had
// just declined to state. Scoped to exactly that shape: the previous turn dead-ended, this
// turn called nothing, the user asserted rather than asked, and the answer opens by agreeing.
const _prevAssistant = String([...history].reverse().find(h => h.role === 'assistant')?.content || '');
if (
sycophancyNudges < MAX_SYCOPHANCY_NUDGES
&& groundingResultsThisTurn.length === 0
&& answerEndedInDeadEnd(_prevAssistant)
&& confirmsUserGuessWithoutGrounding(message, candidateText)
) {
sycophancyNudges++;
console.log(`[v2] SYCOPHANTIC-CONFIRMATION POST-CHECK (${sycophancyNudges}/${MAX_SYCOPHANCY_NUDGES}): agreed with the user's guess about something the previous turn said it could not verify, with no grounding call. Re-prompting...`);
sendSSE('info', { message: '직전 턴에 확인 못 했다고 한 내용입니다 — 확인 후 답하도록 요청합니다...' });
messages.push({ role: 'assistant', content: candidateText });
messages.push({
role: 'user',
content: 'One turn ago you told the user you could NOT verify this. Now the user has guessed at it and you are agreeing — but nothing has changed since: you ran no tool this turn, so you still have no more evidence than you had when you said you did not know. The user reads "네, 맞습니다" as confirmation, which is worse than your earlier "I could not verify it", because now they think it is settled. Either verify it with a tool call right now and answer from what comes back, or say plainly that you still cannot confirm it and that you are not in a position to endorse the guess. Agreeing because the user said it is not an answer.',
});
lastRoundWasGuardReprompt = true;
continue;
}
if (shouldForceBrowserRetry) {
browserForcedRetries++;
const reason = `browser advisor route=${browserAdvisorRoute || 'continue_browser'} requires continued execution`;
+84 -3
View File
@@ -79,10 +79,69 @@ export interface NumericGroundingResult {
nonexistenceUnsupported: boolean;
/** The unit the question asked about, when it named one. */
askedUnit: string | null;
/**
* 이번 턴의 검색 결과가 애초에 검색어의 주제를 다루고 있는가. false면 이 가드는 판정을
* 포기한다(allUnsupported도 false가 된다) — 아래 sourceCoversQuery 주석 참고.
*/
sourceOnTopic: boolean;
/** 실측 커버리지(0~1). 로그·디버깅용. */
sourceCoverage: number;
/** True when every claim in scope is unsupported — the caller's retry trigger. */
allUnsupported: boolean;
}
/**
* 검색 결과가 검색어의 주제를 실제로 다루고 있는가.
*
* THE INCIDENT (2026-09-04, 메인챗 세션 0c367bd7 "옵션 프리미엄"). 이 가드는 다섯 턴 연속으로
* 발동해 사용자가 물은 답을 지웠다. 로그 그대로:
* 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%]
* 검색엔진이 "typical"이라는 낱말의 사전 뜻풀이를 물어왔고, 그 뭉치에 "10%"가 없다는 이유로
* 모델의 답이 날조로 판정됐다. 다음 턴엔 [3~10%, 0.1~1%, 10%, 1%]가 같은 이유로 지워졌다.
* 사용자는 "대충 얼마냔거지"까지 쓰며 다섯 번 물었고, 매번 모델은 답을 냈고, 매번 우리가 지웠다.
*
* 이 가드의 전제는 "검색이 그 주제를 다뤘는데 답의 숫자가 거기 없다 → 지어냈다"이다. 전제의
* 앞부분이 무너지면 뒷부분은 아무것도 증명하지 못한다 — 무관한 말뭉치에서의 부재는 날조의
* 증거가 아니라 그냥 검색 실패다. 그런데 지금까지 이 함수는 앞부분을 한 번도 확인하지 않았다.
*
* 판정은 사용자 메시지가 아니라 **검색어**를 기준으로 한다. 사용자는 한국어로 짧게 묻고
* ("그럼 계약금보다 적을 수도 있네") 모델은 영어로 길게 검색하므로, 사용자 메시지와 소스의
* 낱말 겹침은 정상적인 경우에도 0에 가깝다. 검색어와 소스는 같은 언어라 비교가 성립한다.
*/
const OFF_TOPIC_STOPWORDS = new Set([
'the', 'a', 'an', 'of', 'in', 'on', 'at', 'to', 'for', 'and', 'or', 'as', 'by', 'is', 'are',
'was', 'were', 'be', 'with', 'from', 'that', 'this', 'it', 'its', 'per', 'vs', 'versus',
'how', 'what', 'when', 'where', 'which', 'much', 'many', 'about',
]);
/**
* 커버리지 임계값. 검색어의 내용어 중 이 비율 이상이 소스 어딘가에 나와야 "주제를 다뤘다"로 본다.
*
* 0.34는 실측에서 온 값이다. 문제의 사례에서 검색어의 내용어는
* [typical, option, premium, percentage, underlying, asset, price] 7개인데 Merriam-Webster의
* "typical" 페이지 뭉치는 그중 typical 하나만 담고 있어 0.14였다. 반대로 같은 검색어에 대해
* 지금 시어엔진이 돌려주는 정상 결과(Fidelity 용어집·ETF 설명)는 0.7 이상이 나온다.
* 두 값 사이가 넓어서, 낮게 잡아도 정상 결과를 실수로 면제해 주지 않는다 — 그리고 낮게 잡는
* 쪽이 안전한 실패 방향이다(가드가 계속 발동하는 쪽).
*/
const MIN_QUERY_COVERAGE = 0.34;
export function sourceCoversQuery(query: string, source: string): { onTopic: boolean; coverage: number } {
const terms = String(query || '')
.toLowerCase()
.split(/[^\p{L}\p{N}]+/u)
.filter(t => t.length >= 2 && !OFF_TOPIC_STOPWORDS.has(t));
// 검색어를 모르면(도구가 args를 안 남긴 경우 등) 판정하지 않는다 — 예전처럼 전부 검사한다.
if (terms.length < 3) return { onTopic: true, coverage: 1 };
const haystack = String(source || '').toLowerCase();
if (!haystack.trim()) return { onTopic: true, coverage: 1 };
const hits = new Set(terms.filter(t => haystack.includes(t)));
const coverage = hits.size / new Set(terms).size;
return { onTopic: coverage >= MIN_QUERY_COVERAGE, coverage };
}
/** Units the user can ask for by name, mapped to the claim units that answer them. */
const ASKED_UNIT_PATTERNS: Array<[RegExp, string[]]> = [
[/몇\s*토큰|토큰\s*(수|은|이)?\s*(얼마|몇)|초당.*토큰|tok\/s|tokens?\s+per\s+second|처리량|throughput/i, ['토큰', 'tok/s', 'tps']],
@@ -282,8 +341,23 @@ export function checkNumericGrounding(
answer: string,
sourceTexts: readonly string[],
userMessage = '',
/**
* 이번 턴에 실제로 나간 검색어들. 주어지면 결과가 그 주제를 다뤘는지 먼저 확인하고,
* 안 다뤘으면 이 가드는 판정을 포기한다(sourceCoversQuery 주석의 사고 참고).
* 생략하면 예전 동작 그대로 — 소스를 무조건 신뢰하고 숫자만 대조한다.
*/
searchQueries: readonly string[] = [],
): NumericGroundingResult {
const source = sourceTexts.join('\n');
// 검색어가 여럿이면 가장 잘 커버된 것으로 판단한다. 한 턴에 여러 번 검색했을 때 그중 하나라도
// 주제를 제대로 물어왔다면, 그 결과에 대해서는 대조가 유효하다.
const coverages = searchQueries
.map(q => sourceCoversQuery(q, source))
.filter(c => Number.isFinite(c.coverage));
const best = coverages.length
? coverages.reduce((a, b) => (b.coverage > a.coverage ? b : a))
: { onTopic: true, coverage: 1 };
const sourceOnTopic = best.onTopic;
// Numbers the user themselves supplied ("Tesla M40 24GB 2장으로...") are the premise being
// restated, not something the model asserted — counting them as evidence let a fabricated
// answer look grounded purely because it echoed the question back (observed 2026-07-29: the
@@ -326,10 +400,17 @@ export function checkNumericGrounding(
nonexistenceClaim,
nonexistenceUnsupported,
askedUnit: askedUnits ? askedUnits[0] : null,
sourceOnTopic,
sourceCoverage: best.coverage,
// No claims at all → nothing to verify. Sources empty is handled upstream by the
// empty-grounding check, so treat it as "cannot verify" rather than "all fabricated".
allUnsupported: (!!primaryClaim && source.trim().length > 0 && unsupported.includes(primaryClaim))
|| dateUnsupported
|| nonexistenceUnsupported,
//
// sourceOnTopic이 false면 통째로 판정을 포기한다. 세 갈래(수치·날짜·부재주장)가 모두 같은
// 전제 위에 서 있어서다 — 검색이 주제를 안 다뤘다면 그 안에 날짜가 없는 것도, "출시 안 됐다"를
// 반박할 근거가 없는 것도 전부 아무것도 증명하지 않는다.
allUnsupported: sourceOnTopic
&& ((!!primaryClaim && source.trim().length > 0 && unsupported.includes(primaryClaim))
|| dateUnsupported
|| nonexistenceUnsupported),
};
}
+75 -5
View File
@@ -27,18 +27,67 @@
* leads with the finding and puts the gap afterwards. So only the lead sentence is judged.
*/
/** Phrases that declare the search produced nothing. */
const NOTHING_FOUND = /확인(되지|할\s*수)\s*(않|없)|찾(을\s*수\s*없|지\s*못했|아볼\s*수\s*없)|나타나지\s*않|나와\s*있지\s*않|정보(가|는)?\s*(없|부족)|검색되지\s*않|no\s+(results?|information)\s+(found|available)|could\s*n[o']t\s+find/i;
// 관련성 관문은 NUMERIC-GROUNDING과 공유한다 — 두 가드가 같은 전제("검색이 주제를 다뤘다") 위에
// 서 있어서, 판정 기준이 갈라지면 한쪽만 고쳐도 다른 쪽에서 같은 사고가 다시 난다.
import { sourceCoversQuery } from './numeric-grounding';
/**
* Phrases that declare the search produced nothing.
*
* 2026-09-04 — 메인챗 세션 감사에서 이 목록이 **실제 포기 답변 6건을 0건 잡았다**. 위 태풍 사고
* 이후 이 가드는 사실상 발동한 적이 없었고, 아무도 몰랐다. gemma4가 실제로 쓰는 어법이 여기
* 없었기 때문이다 — 이 목록은 "없다/못 찾았다"(주어가 정보)로만 짜였는데, 모델은 검색 결과를
* 주어로 놓고 "결과에 …이 포함되어 있지 않다"(주어가 결과)라고 쓴다. 같은 뜻, 다른 문형.
* 실측 표본(옵션 프리미엄·맥미니·영리병원 비율·Gemma VRAM):
* "구체적인 수치가 포함되어 있지 않았습니다" ×3
* "구체적인 수치나 비율이 명시되어 있지 않아" ×1
* "직접적으로 언급한 내용은 단 하나도 없었습니다" ×1
* "수치가 전혀 포함되어 있지 않아" ×1
* 부정 서술어를 나열하는 대신 "X되어 있지 않다" 꼴을 하나로 묶어 다음 동사에도 견디게 한다.
*/
const NOTHING_FOUND = /확인(되지|할\s*수)\s*(않|없)|찾(을\s*수\s*없|지\s*못했|아볼\s*수\s*없)|(포함|명시|언급|기재|제시|반영|등장|서술)(되어|돼)?\s*있지\s*않|나타나지\s*않|나와\s*있지\s*않|정보(가|는)?\s*(없|부족)|검색되지\s*않|단\s*하나도\s*없|전혀\s*(없|포함되지)|no\s+(results?|information)\s+(found|available)|could\s*n[o']t\s+find|not\s+(?:present|included|mentioned)\s+in\s+the\s+(?:search\s+)?results/i;
/**
* 판정 전에 걷어낼 도입부. 사과·전제 확인만 있고 결론이 없는 문장들이다.
*
* 2026-09-04, 위와 같은 감사에서 나온 두 번째 구멍: "죄송합니다. 이번 검색 결과에서는 …
* 포함되어 있지 않았습니다." 는 **첫 문장이 "죄송합니다."** 라서, NOTHING_FOUND를 고쳐도
* 판정 대상 문자열에 판정 근거가 아예 안 들어온다. "첫 문장에 결론이 있다"는 이 파일의 전제는
* 맞지만, 사과는 결론이 아니라 결론 앞에 붙는 장식이다.
*/
// \b 를 쓰지 않는다 — 한글은 JS 정규식의 \w 가 아니라서 "죄송합니다" 뒤에는 단어경계가 서지
// 않고, `\b` 를 붙이면 한글 대안이 통째로 죽는다([[project_handlechat_refactor]]의 그 함정).
// 실제로 이 패치의 첫 버전이 그것 때문에 사과로 시작하는 표본 1건을 계속 놓쳤다.
const LEADING_FILLER = new RegExp(
'^(?:'
+ '죄송(?:합니다|해요|하지만|하지만요)|미안(?:합니다|해요)|송구합니다|알겠습니다|네|넵|아|음'
+ '|잠깐만요|말씀하신\\s*대로|확인해\\s*보니|(?:다시\\s*)?(?:확인|검토|검색)(?:해|한)\\s*결과'
+ '|(?:sorry|apologies|apologize|sure|okay|ok|hmm)(?![a-z])'
+ ')[^.!?\\n]*$',
'i',
);
/**
* The lead sentence — the one carrying the verdict. Korean statements end in 다./요., so a plain
* sentence split works; the newline case covers answers that lead with a one-line summary.
*
* 도입부(사과 등)로만 이루어진 문장은 건너뛰고 그 다음 문장을 본다. 최대 2번만 건너뛴다 —
* 더 멀리 가면 "발견을 말한 뒤 빈 곳을 덧붙인" 정상 답변의 뒷문장까지 삼켜서, 이 파일이
* 애초에 첫 문장만 보기로 한 이유가 무너진다.
*/
export function leadSentence(text: string): string {
const t = String(text || '').trim();
if (!t) return '';
const m = t.match(/^[\s\S]*?(?:[.!?](?=\s|$)|\n)/);
return (m ? m[0] : t).trim();
let rest = t;
let sentence = '';
for (let i = 0; i < 3; i++) {
const m = rest.match(/^[\s\S]*?(?:[.!?](?=\s|$)|\n)/);
sentence = (m ? m[0] : rest).trim();
if (!m || !LEADING_FILLER.test(sentence.replace(/[.!?]+$/, '').trim())) return sentence;
rest = rest.slice(m[0].length).trim();
if (!rest) return sentence;
}
return sentence;
}
/** Does the answer OPEN by declaring the search found nothing? */
@@ -70,11 +119,18 @@ export interface AbandonmentInput {
answer: string;
/** stdout of this turn's grounding tool calls. */
sourceTexts: readonly string[];
/**
* 이번 턴에 실제로 나간 검색어. 주어지면 결과가 그 주제를 다뤘는지 먼저 본다 — 안 다뤘으면
* "못 찾았다"는 정확한 서술이므로 재시도시키지 않는다. 생략하면 예전 동작 그대로.
*/
searchQueries?: readonly string[];
}
export interface AbandonmentDecision {
retry: boolean;
substantiveResults: number;
/** 결과가 검색어의 주제를 다뤘는가(판정 근거). */
sourceOnTopic: boolean;
}
/**
@@ -87,8 +143,22 @@ export interface AbandonmentDecision {
*/
export function decideSearchAbandonmentRetry(input: AbandonmentInput): AbandonmentDecision {
const substantiveResults = countSubstantiveResults(input.sourceTexts);
// 2026-09-04 감사에서 발견. countSubstantiveResults는 스니펫 길이만 본다 — 40자 넘으면
// "알맹이 있음"이다. 그런데 사전 뜻풀이 페이지의 스니펫은 넉넉히 40자를 넘는다. 실제로 문제의
// 옵션 프리미엄 턴의 Merriam-Webster/Cambridge/위키낱말사전 뭉치는 4건 전부 substantive로
// 세어져, 이 가드가 모델에게 "결과 4건에 알맹이가 있으니 그걸로 답하라"고 밀 참이었다.
// 그 지시대로 하면 옵션 프리미엄을 'typical'의 사전 정의로 답하게 된다.
//
// 이 가드의 전제는 NUMERIC-GROUNDING과 정확히 같다 — "검색이 주제를 다뤘다". 다른 점은 결론의
// 방향뿐이라(한쪽은 답을 지우고 한쪽은 답을 강요한다) 전제가 무너질 때의 피해도 반대 방향으로
// 똑같이 크다. 그래서 같은 관문을 쓴다.
const queries = (input.searchQueries || []).filter(q => String(q || '').trim());
const source = input.sourceTexts.join('\n');
const sourceOnTopic = queries.length === 0
|| queries.some(q => sourceCoversQuery(q, source).onTopic);
return {
retry: substantiveResults >= 3 && declaresNothingFound(input.answer),
retry: sourceOnTopic && substantiveResults >= 3 && declaresNothingFound(input.answer),
substantiveResults,
sourceOnTopic,
};
}
+5 -1
View File
@@ -102,8 +102,12 @@ export function buildSkillToolFilter(input: ToolScopeInput): (toolName: string)
const hasDrugKeyword =
/병용금기|임부금기|수유부|복약|약물\s*상호작용|약\s*상호작용|같이\s*(먹어|복용|드셔)|함께\s*(먹어|복용)|DUR|처방전|약봉투|성분\s*겹|중복\s*처방|이\s*약(을|은|이|,|\s|의|도|과|와|\?)|먹는\s*약|드시는\s*약|복용\s*중(인|during)?\s*약|drug\s*interaction|contraindicat/i
.test(message);
// 2026-09-04: "미국의 영리의료법인 현황"·"영리의료법인의 법인세율?"이 이 게이트를 통과하지
// 못했다. 사용자가 실제로 쓴 말은 "병원"이 아니라 "의료법인"이었는데 목록에 그 낱말이 없었다
// ("영리 병원의 비율"만 우연히 걸렸다). 영리/비영리 소유형태를 묻는 순간이 바로 이 도구가
// 있는 이유라, 그 수식어에 붙는 기관 명사를 넓힌다.
const hasHospitalDataKeyword =
/\bCMS\b|메디케어|\bmedicare\b|care\s*compare|hospital\s*compare|hospital\s*(rating|ratings|star|quality|ownership|performance)|병원.{0,6}(비교|평가|등급|순위|별점|소유|영리|비영리|성과|퀄리티|데이터)|(영리|비영리|투자자\s*소유).{0,4}병원|(for-?profit|non-?profit|nonprofit|investor-owned).{0,20}hospital/i
/\bCMS\b|메디케어|\bmedicare\b|care\s*compare|hospital\s*compare|hospital\s*(rating|ratings|star|quality|ownership|performance)|(병원|의료법인|의료기관|의료재단|요양기관).{0,6}(비교|평가|등급|순위|별점|소유|영리|비영리|성과|퀄리티|데이터|현황|비율)|(영리|비영리|투자자\s*소유).{0,4}(병원|의료법인|의료기관|의료재단|병상)|(for-?profit|non-?profit|nonprofit|investor-owned).{0,20}(hospital|health\s*system|medical\s*(center|corporation))/i
.test(message);
// coder_* (10 tools) is the file-authoring toolkit for actual code/script work — it has its
// own dedicated branch for isCodeAiSession (code.html) below, so this only affects the
+68 -1
View File
@@ -161,7 +161,42 @@ export function isFactualInfoRequest(message: string): boolean {
// 빠르다/느리다는 러-불규칙 활용이라 관형형이 "빠르ㄴ"이 아니라 "빠른"으로 어간 자체가
// 바뀐다("느리다"→"느린"도 동일) — 어간 "빠르"/"느리"만 넣으면 이 활용형을 못 잡는다
// (테스트로 실제로 잡아냄: "이게 저것보다 느린가?"가 최초 버전에서 빠졌었다).
|| /보다\s*(얼마나\s*)?(빠르|빠른|느리|느린|좋|나은|낫|강|약)/.test(m);
|| /보다\s*(얼마나\s*)?(빠르|빠른|느리|느린|좋|나은|낫|강|약)/.test(m)
// 2026-09-04, 메인챗 세션 감사: "소설을 특별히 잘 쓰는 모델이 있을까?" → "올라마에서
// 고른다면?" → "올라마 클라우드 모델은?" 세 턴 연속 도구 호출 0건. 답은 2026년 9월에
// "Claude 3.5 Sonnet / GPT-4o / Llama 3.1 / Gemma 2 27B"였다 — 이 집이 gemma4를 돌리고
// 있는데 로컬 추천이 Gemma **2**다. 전형적인 학습시점 세대 환각인데, 위 목록은 "추천해"는
// 잡아도 "있을까"·"고른다면"은 못 잡는다. 제품·모델을 고르는 질문은 형태가 무엇이든 검색으로
// 확인 가능한 사실 질문이다.
|| /(모델|제품|버전|칩|카드|기기|장비|툴|도구|서비스|앱|브랜드|업체|사이트|종류)(이|가|은|는|을|를|\s)*\s*(있|없)(을까|나요|나|어|니|는지|는가)/.test(m)
|| /(고른다면|고르라면|골라야|골라\s*주|고를까|어느\s*(게|걸|것|쪽|모델|제품)|어떤\s*게\s*(좋|나)|뭐가\s*(좋|나)|추천할\s*만)/.test(m)
// "신형/최신"을 묻는 질문은 정의상 학습 시점 이후를 묻는 것이라, 기억에서 답하면 반드시
// 틀린다. 같은 세션의 맥미니 스레드가 그렇게 8턴을 태웠다. hasUnversionedNewestIntent는
// 검색어에 세대가 박혔는지를 사후에 보는 가드라, 애초에 검색을 시키는 이쪽과 역할이 다르다.
|| /(신형|신제품|새로\s*나온|이번에\s*나온|최신(형|판)?|출시(됐|되었|된|일|가|는)|latest|newest)/.test(m);
}
/**
* 앞 턴이 사실 확인 질문이었을 때, 그 스레드를 잇는 짧은 후속 발화인가.
*
* isFactualInfoRequest는 메시지 하나만 본다. 그래서 "올라마 클라우드 모델은?" 처럼 주제어가
* 앞 턴에 있고 이번 턴에는 명사구만 남은 후속 질문을 통째로 놓친다 — 정작 그런 턴이야말로
* 대화가 깊어져 모델이 기억으로 술술 답하기 가장 쉬운 지점이다.
*
* 짧은 것만 본다. 길면 그 안에 자기 키워드가 있어서 원래 게이트가 알아서 잡고, 길이 제한이
* 없으면 앞 턴 하나 때문에 뒤따르는 모든 발화가 검색 강제로 끌려간다.
*/
export function isFactualThreadFollowUp(message: string, previousUserMessage: string): boolean {
const m = String(message || '').trim();
const prev = String(previousUserMessage || '').trim();
if (!m || !prev) return false;
if (m.length > 30) return false;
if (isExecutionLikeRequest(m) || isGreetingLikeMessage(m)) return false;
if (isExemptFromVerification(m)) return false;
if (!isFactualInfoRequest(prev)) return false;
// 명령·요청형("그거 정리해줘")은 조회가 아니라 후처리 지시다. 물음표나 의문 어미가 있어야
// 앞 주제를 "더 물어보는" 발화로 본다.
return /[??]$/.test(m) || /(까|나요|니|냐|지|는가|은가|어때|얼마|몇|뭐|무엇|어디|언제|누구|왜|어떻게)\s*[??]?$/.test(m);
}
// Output-side counterpart to isFactualInfoRequest()/isLiveDataRequest(): those only catch
@@ -434,6 +469,38 @@ export function isDeferralPromiseReply(text: string): boolean {
return /잠(시|깐)\s*(만)?\s*(기다려|대기|후에|뒤에|만요)|기다려\s*주(세요|십시오)|곧\s*(알려|말씀|정리해|공유)|잠시\s*후\s*(다시|알려)|조금만\s*기다|hold on\b|one moment\b|give me a (moment|sec|minute)|bear with me|be right back|will (get back|come back) to you|let me get back to you/i.test(s);
}
/**
* 사용자가 던진 추측을 근거 없이 확인해 주는 답변인가.
*
* THE INCIDENT (2026-09-04, 맥미니 M5/M6 스레드). 모델이 "M5 칩 출시 소식은 있지만 맥미니
* 특화 반응은 검색 결과에 없습니다"라고 방금 말한 직후, 사용자가 "아 그럼 M6구나"라고 넘겨짚자
* 도구 호출 0개로 "네, 맞습니다. 이제 M6 세대로 넘어가는 시점이라고 보시는 게 정확합니다"라고
* 답했다. 한 턴 전에 모르겠다고 한 바로 그 사실을, 사용자가 말했다는 이유만으로 확정해 준 것이다.
* 사용자 입장에서는 방금 확인을 받은 셈이라, 되레 "모른다"고 했을 때보다 나쁘다.
*
* 판정을 세 겹으로 좁힌다 — 이 대화에는 정당한 "네, 맞습니다"가 여럿 있고(트리플위칭 만기월
* 확인 등) 그걸 건드리면 대화가 뻣뻣해진다:
* 1. 사용자 발화가 단정형 추측이어야 한다(…구나/…군/…네/…겠네/…이지/…잖아).
* 2. 답변이 동의 표현으로 시작해야 한다 — 본문 중간의 "맞습니다"는 대상이 아니다.
* 3. (호출부 책임) 이번 턴에 grounding 도구가 0개이고, 직전 턴이 "못 찾았다"로 끝났어야 한다.
* 3번이 이 가드를 안전하게 만든다. 방금 확인 못 한다고 말한 주제에 한정되므로, 일반 잡담의
* 맞장구나 모델이 실제로 아는 사실의 확인은 애초에 후보에 들지 않는다.
*/
export function confirmsUserGuessWithoutGrounding(userMessage: string, answer: string): boolean {
const u = String(userMessage || '').trim();
const a = String(answer || '').trim();
if (!u || !a) return false;
// 사용자가 결론을 스스로 내린 형태인가. 질문형("M6인가요?")은 제외 — 그건 추측 확인이 아니라
// 그냥 질문이고, 답을 요구받은 모델이 답하는 건 정상이다.
const asserts = /(구나|군요|이군|군|네요|겠네|겠군|겠구나|잖아|이지|맞지|것\s*같네|인가\s*보네|인가\s*보다)[.!\s…]*$/.test(u)
&& !/[??]\s*$/.test(u);
if (!asserts) return false;
if (u.length > 60) return false;
const lead = a.slice(0, 60);
return /^(네|넵|예|맞습니다|맞아요|그렇습니다|정확합니다|정확히|정확한|말씀하신\s*대로|맞는\s*말씀|yes\b|exactly\b|correct\b|that'?s right\b)/i.test(lead)
|| /^(정확한|매우\s*(정확한|날카로운)|훌륭한)\s*(통찰|지적|요약|표현|분석)/.test(lead);
}
/**
* "최신/신형"을 묻지만 세대·버전을 특정하지 않은 질문인가.
* 이런 질문에 모델이 학습 시점 지식으로 세대를 넣어 검색하면(예: 사용자는 "맥미니 신형"인데
+150
View File
@@ -0,0 +1,150 @@
/**
* dead-end-fallback — 검색이 연달아 헛돌 때 지식으로 끊기
*
* 표본은 2026-09-04 메인챗 세션 0c367bd7의 "옵션 프리미엄" 스레드다. 5턴 · 검색 6회 · 답 0건.
* 이 테스트가 고정하는 건 "언제 끊는가"이고, 두 방향 모두 중요하다:
* - 한 번 못 찾은 것으로는 끊지 않는다 (검색에 정말 없을 수 있고, 그때 정직하게 말하는 건 정답).
* - 두 번째부터는 끊는다 (검색 경로가 이 질문에 안 맞는다는 신호).
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { decideDeadEndFallback, answerEndedInDeadEnd, deadEndFallbackPrompt } from '../src/gateway/chat/dead-end-fallback';
/** 실제 로그 그대로. 첫 턴부터 마지막 턴까지 시간순. */
const OPTION_THREAD = [
'검색 결과에는 옵션 프리미엄(티켓값)이 실물 가격의 몇 퍼센트인지에 대한 구체적인 수치나 비율이 명시되어 있지 않아 정확한 값을 단정하기 어렵습니다.',
'비슷한 느낌이지만, 성격은 조금 다릅니다. 계약금은 나중에 잔금을 치르고 물건을 가져가기 위해 걸어두는 돈이지만, 옵션 프리미엄은 소멸성 비용에 가깝습니다.',
'검색 결과에는 옵션 프리미엄이 실물 가격의 몇 퍼센트인지에 대한 구체적인 수치가 포함되어 있지 않아, 계약금보다 적은지 여부를 수치로 단정하기 어렵습니다.',
];
describe('answerEndedInDeadEnd — 지난 턴이 결국 빈손이었나', () => {
test('포기로 시작한 턴을 센다', () => {
assert.equal(answerEndedInDeadEnd(OPTION_THREAD[0]), true);
});
test('발견을 말하고 빈 곳을 덧붙인 턴은 세지 않는다 — 일부러 좁게 잡은 지점', () => {
// 이 답변과, 실제로 헛돌았던 "HTS/MTS로 확인 가능… 다만 수치는 없습니다" 턴은 텍스트로
// 구별되지 않는다(둘 다 마지막이 "…있지 않습니다"). 후자를 놓치는 쪽을 택했다 — 놓치면
// 가드가 한 턴 늦고, 반대로 넓히면 이 정상 답변에 재프롬프트가 붙는다.
assert.equal(answerEndedInDeadEnd(
'검색 결과에 따르면 제15호 태풍 찬홈은 일본 도쿄 동남동쪽 해상에 있습니다. 다만 정확한 좌표는 명시되어 있지 않습니다.',
), false);
});
test('제대로 답한 턴은 세지 않는다', () => {
assert.equal(answerEndedInDeadEnd(OPTION_THREAD[1]), false);
});
});
describe('decideDeadEndFallback — 언제 끊는가', () => {
test('첫 빈손으로는 끊지 않는다', () => {
const d = decideDeadEndFallback({
answer: OPTION_THREAD[0],
priorAssistantAnswers: ['선물은 의무이고 옵션은 권리입니다. 가장 핵심적인 차이는 그것입니다.'],
abandonmentRetrying: false,
calledGroundingTool: true,
});
assert.equal(d.fallback, false);
assert.equal(d.deadEndStreak, 1);
});
test('두 번째 빈손에서 끊는다 — 실제 스레드에서는 여기서 3턴을 아낀다', () => {
const d = decideDeadEndFallback({
answer: OPTION_THREAD[2],
priorAssistantAnswers: OPTION_THREAD.slice(0, 2),
abandonmentRetrying: false,
calledGroundingTool: true,
});
assert.equal(d.fallback, true);
assert.equal(d.deadEndStreak, 2);
});
test('헛돈 턴이 붙어 있지 않아도 최근 창 안이면 센다', () => {
// 실제 스레드가 이 모양이었다 — 중간에 절반짜리 답이 하나 끼어서 연속이 끊긴다.
const d = decideDeadEndFallback({
answer: OPTION_THREAD[2],
priorAssistantAnswers: ['무관한 턴', OPTION_THREAD[0], OPTION_THREAD[1]],
abandonmentRetrying: false,
calledGroundingTool: true,
});
assert.equal(d.fallback, true);
});
test('SEARCH-ABANDONMENT가 가져가는 턴에서는 비켜선다', () => {
const d = decideDeadEndFallback({
answer: OPTION_THREAD[2],
priorAssistantAnswers: OPTION_THREAD.slice(0, 2),
abandonmentRetrying: true,
calledGroundingTool: true,
});
assert.equal(d.fallback, false);
});
test('검색을 아예 안 한 턴은 대상이 아니다 — 검색 실패가 아니라 다른 문제다', () => {
const d = decideDeadEndFallback({
answer: OPTION_THREAD[2],
priorAssistantAnswers: OPTION_THREAD.slice(0, 2),
abandonmentRetrying: false,
calledGroundingTool: false,
});
assert.equal(d.fallback, false);
});
test('이번 턴이 제대로 답했으면 지난 턴이 헛돌았어도 건드리지 않는다', () => {
const d = decideDeadEndFallback({
answer: OPTION_THREAD[1],
priorAssistantAnswers: [OPTION_THREAD[0], OPTION_THREAD[0]],
abandonmentRetrying: false,
calledGroundingTool: true,
});
assert.equal(d.fallback, false);
});
test('오래된 헛턴은 창 밖으로 나간다', () => {
const d = decideDeadEndFallback({
answer: OPTION_THREAD[2],
priorAssistantAnswers: [OPTION_THREAD[0], 'a', 'b', 'c', 'd'],
abandonmentRetrying: false,
calledGroundingTool: true,
});
assert.equal(d.fallback, false);
});
test('이력이 없어도 터지지 않는다', () => {
const d = decideDeadEndFallback({
answer: OPTION_THREAD[0], priorAssistantAnswers: [], abandonmentRetrying: false, calledGroundingTool: true,
});
assert.equal(d.fallback, false);
});
});
describe('deadEndFallbackPrompt — 재프롬프트 문구', () => {
test('숫자를 요구하지 않는다 — NUMERIC-GROUNDING과 부딪히면 안 된다', () => {
const p = deadEndFallbackPrompt(2);
assert.match(p, /mechanism/i);
assert.match(p, /general knowledge that this turn's search did not confirm/i);
assert.match(p, /never present it as something a tool returned/i);
assert.match(p, /2th answer in a row/);
});
});
/**
* 2026-09-04 감사 회귀. SEARCH-ABANDONMENT에 관련성 관문이 붙으면서, 사전 뜻풀이처럼
* "길이는 충분하지만 주제를 안 다룬" 결과에서 두 가드가 서로 비켜설 뻔했다:
* abandonment는 주제 불일치로 서고, dead-end는 substantive>=3이라 "저쪽 몫"이라며 섰다.
* 이제 dead-end는 abandonment의 판정 결과를 그대로 받으므로 그 구멍이 없다.
*/
describe('두 가드 사이에 빠지는 턴이 없어야 한다', () => {
test('abandonment가 비켜서면 dead-end가 받는다 — 알맹이 수와 무관하게', () => {
const d = decideDeadEndFallback({
answer: '검색 결과에는 옵션 프리미엄 수치가 포함되어 있지 않습니다.',
priorAssistantAnswers: ['검색 결과에는 관련 수치가 명시되어 있지 않습니다.'],
abandonmentRetrying: false, // 사전 뭉치 → 주제 불일치로 abandonment 비켜섬
calledGroundingTool: true,
});
assert.equal(d.fallback, true);
});
});
+82 -1
View File
@@ -17,7 +17,7 @@
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { checkNumericGrounding, extractNumericClaims } from '../src/gateway/chat/numeric-grounding';
import { checkNumericGrounding, extractNumericClaims, sourceCoversQuery } from '../src/gateway/chat/numeric-grounding';
describe('주장 추출 — (숫자, 단위) 쌍', () => {
test('단위가 붙은 수치만 뽑는다', () => {
@@ -302,3 +302,84 @@ describe('법령 표기 — 100분의 N = N%', () => {
);
});
});
/**
* 무관한 검색 결과에는 판정하지 않는다 — 2026-09-04 세션 0c367bd7 사고.
*
* 이 가드가 다섯 턴 연속으로 사용자가 물은 답을 지웠다. 로그 그대로:
* 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%]
* 다음 턴엔 [3~10%, 0.1~1%, 10%, 1%]가 같은 이유로 지워졌다. 검색엔진이 "typical"의 사전
* 뜻풀이를 물어왔을 뿐인데, 그 뭉치에 숫자가 없다는 이유로 답이 날조 판정을 받은 것이다.
*/
describe('sourceCoversQuery — 검색 결과가 그 주제를 다뤘는가', () => {
const QUERY = 'typical option premium as percentage of underlying asset price';
const DICTIONARY_JUNK = [
'[1] TYPICAL Definition & Meaning - Merriam-Webster',
' https://www.merriam-webster.com/dictionary/typical',
' The meaning of TYPICAL is combining or exhibiting the essential characteristics of a group.',
'[2] TYPICAL | English meaning - Cambridge Dictionary',
' https://dictionary.cambridge.org/dictionary/english/typical',
' TYPICAL definition: showing all the characteristics you would usually expect from a group.',
].join('\n');
const ON_TOPIC = [
'[1] Glossary - Common Fidelity Terms',
' https://www.fidelity.com/glossary',
' Option premium is the price paid for an option contract. The premium on an underlying',
' asset reflects a percentage of price, volatility and time to expiry.',
].join('\n');
test('사전 뜻풀이 뭉치는 주제를 안 다룬 것으로 본다', () => {
const r = sourceCoversQuery(QUERY, DICTIONARY_JUNK);
assert.equal(r.onTopic, false);
assert.ok(r.coverage < 0.34, `coverage=${r.coverage}`);
});
test('실제로 옵션 프리미엄을 다룬 결과는 통과시킨다', () => {
const r = sourceCoversQuery(QUERY, ON_TOPIC);
assert.equal(r.onTopic, true);
assert.ok(r.coverage > 0.6, `coverage=${r.coverage}`);
});
test('검색어가 너무 짧으면 판정하지 않는다 — 근거가 부족하다', () => {
assert.equal(sourceCoversQuery('맥미니', DICTIONARY_JUNK).onTopic, true);
});
test('소스가 비어 있으면 판정하지 않는다 — 빈 결과는 다른 가드의 몫', () => {
assert.equal(sourceCoversQuery(QUERY, '').onTopic, true);
});
});
describe('checkNumericGrounding — 무관한 소스에는 날조 판정을 내리지 않는다', () => {
const QUERY = 'typical option premium as percentage of underlying asset price';
const DICT = '[1] TYPICAL Definition & Meaning - Merriam-Webster\n The meaning of TYPICAL is exhibiting the essential characteristics of a group.';
const ON_TOPIC = '[1] Fidelity Glossary\n Option premium is the price of an option contract; the premium on an underlying asset is a percentage of price and volatility.';
// 실제로 지워진 답변과 같은 모양.
const ANSWER = '옵션 프리미엄은 보통 기초자산 가격의 3~10% 수준이며, 외가격이면 0.1~1%까지 내려갑니다.';
test('사전 결과에 대고는 판정을 포기한다 — 이 사고의 직접 수정', () => {
const r = checkNumericGrounding(ANSWER, [DICT], '대충 얼마냔거지', [QUERY]);
assert.equal(r.sourceOnTopic, false);
assert.equal(r.allUnsupported, false);
});
test('주제를 다룬 결과에는 그대로 판정한다 — 가드를 무력화하면 안 된다', () => {
const r = checkNumericGrounding(ANSWER, [ON_TOPIC], '대충 얼마냔거지', [QUERY]);
assert.equal(r.sourceOnTopic, true);
assert.equal(r.allUnsupported, true);
});
test('한 번이라도 주제를 제대로 검색했으면 판정한다', () => {
const r = checkNumericGrounding(ANSWER, [DICT, ON_TOPIC], '대충 얼마냔거지',
['some unrelated words here', QUERY]);
assert.equal(r.sourceOnTopic, true);
});
test('검색어를 안 넘기면 예전 동작 그대로', () => {
const r = checkNumericGrounding(ANSWER, [DICT], '대충 얼마냔거지');
assert.equal(r.sourceOnTopic, true);
assert.equal(r.allUnsupported, true);
});
});
+90
View File
@@ -29,6 +29,8 @@ import {
isCancelIntent,
isRerunIntent,
isDisasterRequest,
isFactualThreadFollowUp,
confirmsUserGuessWithoutGrounding,
} from '../src/gateway/guards/prompt-gates';
describe('looksLikeUnverifiedSpecClaim — 출력에서 검증 안 된 수치 감지', () => {
@@ -363,3 +365,91 @@ describe('보조 분류기', () => {
assert.equal(isGreetingLikeMessage('hello, search the web for X'), false);
});
});
/**
* 2026-09-04 메인챗 세션 감사에서 나온 구멍 두 가지.
* 인용은 전부 세션 0c367bd7의 실제 발화·답변이다.
*/
describe('제품·모델 선택 질문 (2026-09-04)', () => {
// 이 세 턴은 연속으로 도구 호출 0건이었고, 2026년 9월에 "Gemma 2 27B"를 로컬 추천으로 냈다.
test('"있을까"·"고른다면" 형태를 잡는다 — 패치 전에는 셋 다 놓쳤다', () => {
assert.equal(isFactualInfoRequest('소설을 특별히 잘 쓰는 모델이 있을까?'), true);
assert.equal(isFactualInfoRequest('올라마에서 고른다면?'), true);
assert.equal(isFactualInfoRequest('어떤 게 좋아?'), true);
});
test('"신형/최신"은 정의상 학습 시점 이후를 묻는 것이라 항상 확인 대상', () => {
assert.equal(isFactualInfoRequest('맥미니 신형이 나왔는데 시장반응은 어때?'), true);
assert.equal(isFactualInfoRequest('최신 버전 나왔어?'), true);
});
test('잡담·감상·실행 요청은 그대로 통과시킨다', () => {
for (const m of [
'모네의 정원 흉내 내고 있지..ㅎ',
'일종의 폰지 스킴이란거군',
'분식회계도 많이 하겠군',
'거기 뭘 심을까 고민중..',
'소설 한 편 써줘',
'이 코드 좀 고쳐줘',
]) {
assert.equal(isFactualInfoRequest(m), false, m);
}
});
test('개인 인프라는 계속 예외 — 검색으로 확인할 수 없다', () => {
assert.equal(isFactualInfoRequest('지서버에 어떤 모델이 있을까?'), false);
});
});
describe('isFactualThreadFollowUp — 주제어가 앞 턴에만 있는 후속 질문', () => {
test('명사구만 남은 후속 질문도 확인 대상이 된다', () => {
assert.equal(isFactualThreadFollowUp('올라마 클라우드 모델은?', '올라마에서 고른다면?'), true);
assert.equal(isFactualThreadFollowUp('잘 팔리고 있는건가?', '맥미니 신형 시장반응 어때?'), true);
});
test('앞 턴이 사실 질문이 아니면 발동하지 않는다', () => {
assert.equal(isFactualThreadFollowUp('그래서?', '모네의 정원 흉내 내고 있지..ㅎ'), false);
});
test('명령·요청형은 조회가 아니다', () => {
assert.equal(isFactualThreadFollowUp('그거 정리해줘', '어떤 모델이 있을까?'), false);
});
test('길면 원래 게이트가 알아서 본다 — 여기서는 짧은 것만', () => {
const long = '그러면 그 중에서 한국어 처리가 가장 자연스럽고 속도도 괜찮은 걸로 하나만 골라서 이유까지 설명해줄래?';
assert.equal(isFactualThreadFollowUp(long, '어떤 모델이 있을까?'), false);
});
});
describe('confirmsUserGuessWithoutGrounding — 근거 없는 동조', () => {
test('직전에 확인 못 했다고 한 사실을 사용자 추측만으로 확정해 준다', () => {
assert.equal(confirmsUserGuessWithoutGrounding(
'아 그럼 M6구나',
'네, 맞습니다. 최신 라인업 기준으로 보면 이제 M6 세대로 넘어가는 시점이라고 보시는 게 정확합니다.',
), true);
});
test('질문형은 대상이 아니다 — 답을 요구받은 모델이 답하는 건 정상', () => {
assert.equal(confirmsUserGuessWithoutGrounding(
'보통 9월인가?',
'네, 맞습니다. 보통 3월, 6월, 9월, 12월 이렇게 분기별 마지막 달의 세 번째 금요일이 만기일입니다.',
), false);
});
test('동의로 시작하지 않으면 대상이 아니다', () => {
assert.equal(confirmsUserGuessWithoutGrounding(
'아 그럼 M6구나',
'검색으로는 M6 맥미니를 확인하지 못했습니다. 단정하기 어렵습니다.',
), false);
assert.equal(confirmsUserGuessWithoutGrounding(
'모네의 정원 흉내 내고 있지..ㅎ',
'와, 170평의 넓은 대지에 모네의 정원이라니 정말 낭만적입니다!',
), false);
});
test('빈 입력에서 터지지 않는다', () => {
assert.equal(confirmsUserGuessWithoutGrounding('', ''), false);
assert.equal(confirmsUserGuessWithoutGrounding(null as any, undefined as any), false);
});
});
+96
View File
@@ -111,3 +111,99 @@ describe('decideSearchAbandonmentRetry — 종합 판정', () => {
assert.equal(d.retry, false);
});
});
/**
* 2026-09-04 감사 회귀. 아래 6건은 메인챗 세션 0c367bd7의 실제 답변이고, 패치 전에는 **6건 전부**
* declaresNothingFound=false 였다 — 2026-08-12 태풍 사고 이후 이 가드는 사실상 한 번도 발동하지
* 않고 있었다. 원인 두 가지: (1) gemma4는 "정보가 없다"가 아니라 "결과에 …이 포함되어 있지 않다"
* 라고 쓴다 (2) 사과로 시작하면 판정 대상인 첫 문장이 "죄송합니다."가 되어 근거가 안 들어온다.
*/
describe('실사용 포기 답변 (2026-09-04 세션 감사)', () => {
const REAL_GIVEUPS = [
'죄송합니다. 이번 검색 결과에서는 미국 내 영리 및 비영리 의료법인의 구체적인 점유 비율(%)에 대한 수치가 포함되어 있지 않았습니다.',
'검색 결과들을 다시 면밀히 검토했으나, 안타깝게도 현재 시점(2026년 9월)의 신형 맥미니에 대한 시장 반응이나 리뷰 내용은 포함되어 있지 않았습니다.',
'검색 결과들을 다시 꼼꼼히 읽어보았으나, M6 맥미니의 시장 반응이나 비판 내용에 대해 직접적으로 언급한 내용은 단 하나도 없었습니다.',
'검색 결과에는 Gemma 4 27B 모델의 FP8 양자화 시 구체적인 VRAM 필요량이나 32GB 환경에서의 구동 가능 여부에 대한 수치가 포함되어 있지 않습니다.',
'검색 결과에는 옵션 프리미엄(티켓값)이 실물 가격의 몇 퍼센트인지에 대한 구체적인 수치나 비율이 명시되어 있지 않아 정확한 값을 단정하기 어렵습니다.',
'검색 결과에는 옵션 프리미엄이 실물 가격의 몇 퍼센트인지에 대한 구체적인 수치가 전혀 포함되어 있지 않아, 정확히 얼마인지 단정할 수 없습니다.',
];
for (const answer of REAL_GIVEUPS) {
test(`포기로 판정: ${answer.slice(0, 26)}…`, () => {
assert.equal(declaresNothingFound(answer), true);
});
}
test('사과로 시작해도 그 다음 문장에서 판정한다', () => {
assert.equal(leadSentence('죄송합니다. 검색 결과에 수치가 포함되어 있지 않았습니다.'),
'검색 결과에 수치가 포함되어 있지 않았습니다.');
});
test('사과로 시작하되 결론이 포기가 아니면 그대로 통과', () => {
// 같은 세션의 정정 답변 — 사과하고 나서 사실을 인정했지 포기한 게 아니다.
assert.equal(declaresNothingFound(
'죄송합니다. 제가 최신 출시 정보를 놓쳤습니다. 현재 시점 기준으로 M5, M6 칩을 탑재한 신형 맥미니가 출시된 상황이군요.',
), false);
});
test('건너뛰기는 두 문장까지만 — 발견 뒤 빈 곳을 덧붙인 답변을 삼키면 안 된다', () => {
assert.equal(declaresNothingFound(
'검색 결과에 따르면 제15호 태풍 찬홈은 일본 도쿄 동남동쪽 해상에 있습니다. 다만 정확한 좌표는 명시되어 있지 않습니다.',
), false);
});
});
/**
* 관련성 관문 (2026-09-04 감사). 이 가드는 스니펫 길이만으로 "알맹이 있음"을 판정했는데,
* 사전 뜻풀이 페이지의 스니펫은 넉넉히 40자를 넘는다. 문제의 옵션 프리미엄 턴에서
* Merriam-Webster/Cambridge/위키낱말사전 4건이 전부 substantive로 세어져, 모델에게
* "결과 4건에 알맹이가 있으니 그걸로 답하라"고 밀 참이었다 — 옵션 프리미엄을 'typical'의
* 사전 정의로 답하라는 지시가 된다. NUMERIC-GROUNDING과 정확히 같은 전제 위의 같은 결함이다.
*/
describe('무관한 결과에는 재시도를 강요하지 않는다', () => {
const DICT_QUERY = 'typical option premium as percentage of underlying asset price';
const DICT = [
'[1] TYPICAL Definition & Meaning - Merriam-Webster',
' https://www.merriam-webster.com/dictionary/typical',
' The meaning of TYPICAL is combining or exhibiting the essential characteristics of a group.',
'',
'[2] TYPICAL | English meaning - Cambridge Dictionary',
' https://dictionary.cambridge.org/dictionary/english/typical',
' TYPICAL definition: showing all the characteristics you would usually expect from a group.',
'',
'[3] typical 뜻과 사용법 | RedKiwi Words',
' https://redkiwiapp.com/ko/english-guide/words/typical',
' This car is typical of its kind, with good gas mileage and a comfortable ride. 전형적인 차입니다.',
'',
'[4] typical - 위키낱말사전',
' https://ko.wiktionary.org/wiki/typical',
' 전형적인, 대표적인. 특유의. 분류 영어 형용사 전형적이라는 뜻으로 널리 쓰이는 단어입니다.',
].join('\n');
const GIVE_UP = '검색 결과에는 관련 수치가 포함되어 있지 않습니다.';
test('길이로는 알맹이 4건이지만, 주제를 안 다뤘으므로 재시도하지 않는다', () => {
assert.equal(countSubstantiveResults([DICT]), 4);
const d = decideSearchAbandonmentRetry({
answer: GIVE_UP, sourceTexts: [DICT], searchQueries: [DICT_QUERY],
});
assert.equal(d.sourceOnTopic, false);
assert.equal(d.retry, false);
});
test('주제를 다룬 결과에서는 그대로 재시도 — 원래 태풍 사고를 계속 잡아야 한다', () => {
const d = decideSearchAbandonmentRetry({
answer: GIVE_UP,
sourceTexts: RESULTS,
searchQueries: ['제15호 태풍 찬홈 현재 위치 예상 경로'],
});
assert.equal(d.sourceOnTopic, true);
assert.equal(d.retry, true);
});
test('검색어를 안 넘기면 예전 동작 그대로', () => {
const d = decideSearchAbandonmentRetry({ answer: GIVE_UP, sourceTexts: [DICT] });
assert.equal(d.sourceOnTopic, true);
assert.equal(d.retry, true);
});
});
+30
View File
@@ -330,3 +330,33 @@ describe('앱 세션 게이트 — 탭 안에서는 키워드 없이도 열린
}
});
});
/**
* cms_hospital_compare — 2026-09-04 세션 감사에서 나온 도달 실패.
*
* 사용자가 "미국의 영리의료법인 현황"으로 시작해 소유형태·비율·법인세율까지 8턴을 물었는데
* 이 도구는 한 번도 스코프에 들지 않았다. 게이트가 "병원"이라는 낱말에만 걸려 있었고, 사용자는
* 내내 "의료법인"이라고 썼기 때문이다("영리 병원의 비율" 한 턴만 우연히 통과했다). 그 사이
* 모델은 공공 비중을 45~50% → 39~40% → 약 50%로 턴마다 다르게 답했다 — 1차 데이터를 쓸 수
* 있었던 질문이다.
*/
describe('cms_hospital_compare — 소유형태 질문에 닿는가', () => {
test('사용자가 실제로 쓴 "의료법인" 어법으로도 켜진다', () => {
for (const m of [
'미국의 영리의료법인 현황',
'영리의료법인의 법인세율?',
'영리 병원의 비율이 얼마나 될까?',
'비영리 의료기관 성과 비교',
'for-profit hospital ownership',
]) {
assert.equal(filterFor(m)('cms_hospital_compare'), true, m);
}
});
test('관련 없는 병원 언급에는 여전히 안 붙는다', () => {
for (const m of ['병원 언제 가지', '치과 예약해줘', '주말 구미 날씨 어때?']) {
assert.equal(filterFor(m)('cms_hospital_compare'), false, m);
}
});
});