지서버에 muse-glimmer와 gemma4:26b를 둘 다 두고 골라 쓰기 위해 되살린다.
86063d3에 있었으나 그 커밋이 통째로 되돌려졌던 UI 부분이다. 되돌린 사유였던
에러는 서버 로그상 8시간 앞선 별건(config.json 손상 + 지서버 엔드포인트 .99)으로
확인됐다.
설정에서 지서버 모델은 자유입력 텍스트 박스라 모델명을 손으로 정확히 적어야 했다.
정작 refreshProviderModels는 ollama_local일 때도 목록을 가져오고 있었는데, 그
결과를 항상 ollama 패널의 select에 써넣어서 — 지서버를 고르면 그 패널이 숨겨지므로 —
가져온 목록이 화면에 한 번도 안 보이고 버려지고 있었다.
- 지서버 모델 필드를 select로 바꾸고 "모델 새로고침" 버튼 추가
- refreshProviderModels가 provider별로 맞는 select와 상태줄을 쓰도록 수정
- lm_studio/llama_cpp의 모델 필드는 아직 input이라 SELECT일 때만 option을 채운다
(input에 <option>을 넣으면 사용자가 입력한 값이 날아간다)
onProviderChange는 ollama_local만 자동 새로고침에서 빼둔다 — 드롭다운을 고른 것만으로
WOL 패킷을 쏘고 45초를 기다리면 안 되기 때문이다. 그래서 새로고침 없이 저장하면
select가 placeholder(값 "")뿐이라 설정된 모델명이 빈 값으로 덮어써진다.
seedModelSelect로 저장된 모델을 항상 실제 option으로 심어 막는다(빈 select에
.value를 넣으면 조용히 무시된다는 점도 이유).
app.js 쪽 호출은 typeof 가드로 감쌌다. seedModelSelect는 settings-provider.js에
있으므로, 브라우저가 그 파일의 캐시본을 든 채 새 app.js만 받으면 ReferenceError로
설정 로딩이 통째로 죽는다 — 지난번 "에러가 너무 나네"의 가장 유력한 경로다.
검증(실제 index.html 패널 마크업 + 실제 함수를 그대로 불러오고 네트워크 경계만 스텁):
새로고침 전 저장값이 실제 option으로 심어지고 저장 payload가 그 값을 읽는지,
새로고침 후 저장 모델이 목록 맨 앞에 오는지, 다른 패널 select를 안 건드리는지,
사용자가 고른 값이 저장 payload에 실리는지 — 전부 통과.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
사용자 신고: "지금 뉴스검색을 2분 넘게 하고 있는데?"
"오늘 미국 주요 뉴스" 한 턴에서 도구를 11번 불렀다. 라운드마다 muse-glimmer가
통째로 한 번씩 생성해야 하는데 22 tok/s dense라 라운드 수가 그대로 시간이 된다.
1) 1면 RSS가 제목과 링크만 넘겨서, 모델이 요약을 쓰려고 기사를 하나씩
web_fetch 했다(6번, 그중 NYT는 403). 정작 피드엔 description이 이미 실려
있었다 — NYT 138자, NPR 293자 — 파서가 지나치고 버리고 있었을 뿐이다.
Headline에 summary를 넣고 formatHeadlines가 함께 내보낸다. 실측: us 피드
12건 전부 요약 확보.
NPR은 <em> 식으로 이중 인코딩해 보내므로 디코드→태그제거→디코드를
한 번 더 돈다. 한 번만 돌면 <em>이 그대로 남는다.
2) 가드 두 개가 서로 물려 헛바퀴 3회를 돌았다. 모델이 category를 붙여 다시
부르면 → breadth 가드가 "요청 안 한 category"라며 떼어냄 → 인자가 앞 호출과
같아짐 → 중복으로 스킵 → 모델은 데이터를 못 받았으니 다른 category로 또 시도.
중복 스킵에는 원래 replay 경로가 있는데 대상이 coder_list_files와
coder_read_file 둘뿐이라, 뉴스·검색은 데이터 대신 "Already ran this exact
call"이라는 문구만 받았다. 결과가 없으니 변형해서 또 부르는 게 당연하다.
news_search/web_search/web_fetch를 replay 대상에 넣어 이전 결과를 그대로
돌려준다 — 모델이 원하던 걸 받으면 반복이 끝난다.
테스트 8개 추가(요약 추출, Atom summary, 이중 인코딩, CDATA, 요약 없는 항목,
길이 제한, 출력 포함, 빈 줄 미발생) — 354개 통과.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
지서버를 muse-glimmer:latest nothink 모드로 붙이면서 다시 넣는다.
(86063d3에 함께 있었으나 그 커밋이 통째로 되돌려졌고, 되돌린 사유였던 에러는
서버 로그상 8시간 앞선 별건으로 확인됐다. UI 드롭다운 변경은 제외하고
think 사다리 부분만 복원한다.)
사다리는 glm-5.1:cloud처럼 think를 생략하면 생각만 하고 content를 안 내놓는
모델을 건지려고 만든 것이다. 하지만 false는 성격이 다르다. "이 모델은 생각에
예산을 낭비한다"는 운영자의 결정이라, 빈 응답 한 번에 thinking을 도로 켜면
그 결정이 조용히 뒤집힌다.
08-12 muse-glimmer 실측: 생각비중 85%, 체감 3.1 tok/s. think:false로 20.9 tok/s
(7배)에 답변은 오히려 길어지고 2000토큰 캡 잘림도 사라졌으며 20문항 빈 답변
0건이었다. 사다리가 이걸 되돌리면 의도한 7배가 말없이 느린 경로로 돌아간다.
이제 false는 [false, undefined]까지만 — 모델 기본값 폴백은 남겨서 빈 응답이
막다른 길이 되지는 않게 한다.
테스트 목적으로 모듈 레벨 export로 추출(346개 통과).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
사용자 신고: "모바일에서 앱모음 탭이 풀다운 되지 않던데?"
드롭다운은 원래도 열리고 있었다. 보이지 않게 잘려 있었을 뿐이다.
모바일(max-width:1024px)에서 앱 버튼 스트립은 가로 스크롤 컨테이너가 된다.
header .mode-toggle { overflow-x: auto; mask-image: linear-gradient(...); }
여기서 두 가지가 다 자른다. overflow-x를 visible이 아닌 값으로 두면 명세상
overflow-y도 auto가 되고, mask는 자손까지 함께 잘라낸다. 메뉴는
top:calc(100% + 4px)로 스트립 박스 바깥에 놓이므로 통째로 잘려나갔다.
z-index:2000은 무용지물 — 조상의 클리핑은 z-index로 못 넘는다.
처음엔 position:fixed만 주면 될 줄 알았으나 틀렸다. 브라우저에서 재보니
fixed를 줘도 여전히 안 보였다(메뉴 중심점에 body가 그려짐). fixed는 overflow
클리핑은 벗어나지만 mask는 못 벗어난다. 세 방법을 나란히 측정한 결과:
A. fixed만 -> 안 보임
B. fixed + mask 제거 -> 보임
C. 메뉴를 body로 이동 -> 보임 (mask 유지)
오른쪽 페이드 힌트를 살리려고 C를 택했다. 열릴 때 body로 옮기고 dd-floating
클래스로 fixed 좌표를 주며, 닫힐 때 원래 .mode-dropdown 아래로 되돌린다.
되돌리지 않으면 다음 열기에서 previousElementSibling이 버튼을 못 찾는다.
데스크탑은 그대로 — 거기선 스트립에 스크롤도 mask도 없어서 기존 absolute
배치가 이미 정상이다.
검증(실제 스타일시트와 실제 함수를 그대로 불러온 페이지, 브레이크포인트만 이동):
열기 가시성, 다른 드롭다운 전환 시 이전 것 DOM 복원, 바깥 클릭 후 복원,
재열기 시 버튼 재탐색, 우측 끝 버튼의 화면 밖 이탈 방지 — 전부 통과.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
지서버(ollama_local)에 muse-glimmer를 추가로 올려두고 gemma4:26b와 골라 쓰려 했으나
설정 화면에서 지서버 모델은 자유입력 텍스트 박스라 모델명을 손으로 정확히 적어야 했다.
정작 refreshProviderModels는 ollama_local일 때도 목록을 가져오고 있었는데, 그 결과를
항상 ollama 패널의 select에 써넣어서 — 지서버를 고르면 그 패널이 숨겨지므로 —
가져온 목록이 화면에 한 번도 안 보이고 버려지고 있었다.
- 지서버 모델 필드를 select로 바꾸고 "모델 새로고침" 버튼 추가
- refreshProviderModels가 provider별로 맞는 select와 상태줄을 쓰도록 수정
- lm_studio/llama_cpp의 모델 필드는 아직 input이라, SELECT일 때만 option을 채운다
(input에 <option>을 넣으면 사용자가 입력한 값이 날아간다)
여기서 새로 생기는 함정 하나를 같이 막았다. onProviderChange는 ollama_local만
자동 새로고침에서 빼두는데, 드롭다운을 고른 것만으로 WOL 패킷을 쏘고 45초를 기다리면
안 되기 때문이다. 그러면 새로고침 없이 저장할 때 select가 placeholder(값 "")뿐이라
설정된 모델명이 빈 값으로 덮어써진다. seedModelSelect로 저장된 모델을 항상 실제
option으로 심어둬서 막았다(빈 select에 .value를 넣으면 조용히 무시된다는 점도 이유).
buildThinkCandidates: 명시적 think:false는 재시도 사다리를 오르지 않는다.
사다리는 glm-5.1:cloud처럼 think를 생략하면 생각만 하고 content를 안 내는 모델을
건지려고 만든 것인데, false는 성격이 다르다. "이 모델은 생각에 예산을 낭비한다"는
운영자의 결정이라, 빈 응답 한 번에 thinking을 도로 켜면 그 결정이 조용히 뒤집힌다.
08-12 muse-glimmer 실측: 생각비중 85%, 체감 3.1 tok/s → think:false로 20.9 tok/s(7배)에
답변은 오히려 길어지고 잘림도 사라짐(20문항 빈 답변 0건). 사다리가 이걸 되돌리면
의도한 7배가 말없이 느린 경로로 돌아간다. 이제 false는 모델 기본값까지만 물러선다
— 빈 응답이 막다른 길이 되지는 않게.
테스트용으로 모듈 레벨 export로 추출(346개 통과).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
사용자 지적: "뭔가 이상한데" — 매체를 좁힌 뒤에도 함부르크 아파트 균열,
패러글라이딩 실종자 수색, "하비에르 바르뎀은 친근한 배우" 같은 기사가
"주요 뉴스"로 올라왔다.
원인은 매체가 아니라 정렬이었다. NewsData /latest는 시간 역순이고, 주요지도
하루에 수백 건을 내므로 **가장 최근이 가장 중요한 것은 아니다.** 매체 제한은
바닥(물병 광고)은 올렸지만 천장은 못 올렸다. API 자체 손잡이(category=top,
prioritydomain=top)는 앞서 전부 실측했고 소용없었다.
신문 1면이 빠진 신호다. 사람 편집자가 이미 하루를 정렬해 놓은 유일한 곳이고,
주요지는 전부 RSS로 공개한다. 같은 시각 같은 요청의 결과:
NewsData(매체제한) 함부르크 아파트 균열 / 카탈루냐 패러글라이딩 / 바르뎀 호평
1면 RSS 콜롬비아 지진 180명 사망 / 트럼프 앙카라 비밀 탈출 /
미네소타 경선 결과 / 스페인의 대이탈리아 국경 검문
- 넓은 요청(query 없고 category 없거나 top)에만 적용. 주제를 지정한 요청은
NewsData 전체 풀 그대로다 — 실측 확인: query="crime"은 여전히 지역지 기사까지
들어온다. 1면은 정의상 그날의 톱이라 특정 주제를 물으면 답이 안 나온다
- 다국가는 라운드로빈으로 병합. 발행량 많은 1면 하나가 나머지를 밀어내면 안 된다
- 피드 실패는 조용히 건너뛰고, 전부 실패하면 NewsData로 폴백한다
- 피드는 전부 라이브 확인 후 추가. 한국은 제외했다 — 연합/한겨레/경향/JTBC를
다 확인했으나 전부 시간순이고 편집 정렬된 1면 피드가 없어, kr은 기존
주요매체 경로를 그대로 쓴다
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
사용자 지적: "유럽은 여전한데?" 로그를 보니 모델은 유럽 요청을 이렇게 보낸다.
news_search({"category":"top","country":"gb,de,fr,it,es"})
직전 커밋(13e2f79)의 매체 제한이 두 이유로 빠져나갔다:
1. category가 있으면 건너뛰게 했는데 "top"은 분야가 아니라 전체 피드다.
correctNewsCategoryForBreadth는 이미 top을 통과시키고 있었으니, 두 곳의
판단이 어긋나 있었던 셈이다
2. country가 "gb,de,fr,it,es" 콤마 목록이라 나라별 조회 자체가 실패했다
- de/fr/it/es 목록 추가(전부 라이브 확인). 멜로니 개각설, 트럼프 비자 취소
17만5천 건 같은 실제 뉴스가 나온다
- 다국가는 라운드로빈으로 5개를 채운다. 앞에서부터 채우면 영국 매체만 다섯이
되어 대륙의 나머지가 통째로 빠진다
- category=top은 넓은 요청으로 취급한다
그리고 내가 만든 버그 하나를 같이 고쳤다: gb 목록의 bbc.co.uk는 NewsData DB에
없는 도메인이라(정답은 bbc.com) 요청 전체가 HTTP 422로 죽었다. **도메인 하나가
잘못되면 나머지 넷도 같이 실패한다.** us/kr 목록은 라이브로 확인하고 넣었는데
gb만 검증 없이 추가했다가 유럽 요청이 통째로 터졌다. 목록에 도메인을 추가할 땐
반드시 확인할 것 — 주석으로 남겼다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
사용자 지적: "중요한 정치 경제 외교 이런 것이 없는 듯한데, 지엽적이고 세세한
내용들이네." 실측한 country=us 응답이 정확히 그랬다 — Owala 물병 신제품,
New Gateway High School 교장 부임, Local Lime의 같은 상가 내 호실 이전,
오랄비 칫솔 할인.
원인: NewsData.io의 /latest는 중요도 정렬이 아니라 시간 역순이고, 인덱싱된
수천 개 지역 매체의 최신 기사가 그대로 올라온다.
API 자체 손잡이로는 안 됐다 — category=top, prioritydomain=top, 둘의 조합까지
전부 실측했으나 여전히 칫솔 할인과 자라 원피스가 나왔다.
매체를 좁히니 해결됐다. 같은 요청을 Reuters/AP/NYT/WaPo/NPR로 제한하니
트럼프 백신 행정명령, 호르무즈 해협, 레바논 사형제 폐지, 에트나 화산이 나왔다.
한국도 뉴시스/중앙/조선/한겨레/동아로 삼성전자·SK하이닉스 전망, 이란 정보당국
분석이 나온다.
- 5개 제한은 편집 판단이 아니라 API 제약이다(6개부터 UnsupportedQueryLength)
- 도메인은 NewsData가 실제로 인덱싱하는 것으로 골랐다. yna.co.kr은 거부되고
en.yna.co.kr을 제안한다 — newsis/joongang/chosun/hani/donga는 정상 동작
- **넓은 요청에만 적용한다**(query도 category도 없을 때). 주제를 지정한 요청을
다섯 매체로 좁히면 바로 그 주제의 보도가 가려진다. 지역 사건은 지역지가
유일한 출처인 경우도 많다
- 빈 결과 재시도로 q가 붙으면 더 이상 넓은 요청이 아니므로 매체 제한을 푼다
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
d0db91d로 crime/education을 추가했지만 실전에서 이득이 안 났다. 격리 조건에선
모델이 "미국 범죄 뉴스"에 {"category":"crime"}을 정확히 골랐는데, 실전(도구
30여 개 + 전체 시스템 프롬프트)에선 {"category":"top"}을 고르고 web_search로
메웠다. 오늘 내내 확인한 제약 경쟁 패턴 그대로다.
fillNewsCategoryWhenExplicit은 넓히기 보정의 거울상이지만 훨씬 엄격하다.
저쪽은 넓히기만 하므로 오작동해도 범위가 늘 뿐이지만, 이쪽은 **좁히므로**
잘못 채우면 사용자가 원한 범위를 잃는다. 그래서 세 겹으로 막았다:
1. 모델이 분야를 아예 안 붙였을 때만. 붙인 것은 넓히기 보정의 몫이다
2. 정확히 하나만 걸릴 때만. "AI 기술 관련 범죄 뉴스"는 둘이 걸리는데 한쪽을
고르면 나머지 절반이 조용히 사라진다
3. **현재 메시지만** 본다. 넓히기가 쓰는 최근 발화 창을 여기 쓰면 "미국 범죄
뉴스" 다음 "그럼 전체 뉴스는?"에 crime을 계속 채운다. 주제를 물려받는 것은
안전한 오류지만 단정하는 것은 아니다
테스트가 실제 동작 하나를 잡았다: "교육 정책 뉴스"는 교육(education)과
정책(politics) 둘 다에 걸려 모호 판정으로 안 채워진다. 사람은 education을
떠올리겠지만 "X 정책에서 X가 이긴다"는 규칙은 이 함수 범위를 넘는다.
안 채우면 전체 뉴스로 남으므로 안전한 실패 — 기대값 쪽을 고쳤다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
라이브 API 확인 결과 NewsData.io는 17개를 지원하는데 우리 스키마엔 12개만
있었다. 다섯 개 중 둘만 골라 넣는다.
넣는 근거 — 지서버 모델에 실제로 시켜서 고르는지 확인했다:
"미국 범죄 뉴스" → {"category":"crime","country":"us"} 정확히 고름
단어가 1:1로 대응되는 분야는 잘 고른다.
빼는 근거:
- domestic: "미국 국내 뉴스만 보여줘"에도 모델이 고르지 못했다. 더 결정적으로,
직접 받아보니 이름과 달리 국내 필터가 아니라 지역/로컬 분류다 — country=us에
category=domestic을 걸었더니 카슈미르 기사와 스페인어 기사가 그대로 나왔다.
콩고 같은 국제 뉴스 섞임의 해법이 될 거라 기대했는데 아니었다
- lifestyle/other: 사용자가 그 말을 쓸 일이 드물어 CATEGORY_TOPIC 정규식을
쓸 수가 없다. 안전망 없이 스키마에만 넣으면 모델이 붙인 분야가 그대로 통과한다
추가가 위험하지 않은 이유는 보정(8a8c81e)이 안전망이기 때문이다 — 사용자가
"범죄"라고 하지 않았는데 모델이 crime을 붙이면 제거된다. 단 CATEGORY_TOPIC에
함께 넣는다는 전제에서만 그렇다. 그래서 스키마와 보정 맵이 어긋나면 실패하는
드리프트 테스트를 같이 넣었다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
실사고(2026-08-12): 뉴스 요약 답변이 이렇게 나왔다.
"정치적 갈등과 사회적 사건들이 눈에 <0xEB><0x9D><0x95>니다."
EB 9D 95 = U+B755 = 띕
GGUF 토크나이저에 통글자 항목이 없는 글자는 바이트 단위로 쪼개지고, 디토크나이저가
다시 붙여야 하는데 실패하면 바이트 토큰의 "표기"가 그대로 출력에 남는다. 모델은
정확한 바이트를 냈다 — `띕`(ㄸ+ㅢ+ㅂ)이 토큰 테이블에 없을 만큼 드문 음절일 뿐이다.
잃은 정보가 없고 정답이 완전히 결정되는 무손실 복원이라, 코드로 갈 자리다.
처음 발견했을 땐 로그 전체에 3건이라 빈도가 낮다고 보고 미뤘는데, 바로 다음
답변에서 또 나왔다. "눈에 띕니다"가 뉴스 요약에서 흔한 표현이라 체감 빈도가
계산보다 높다 — 미룬 판단이 틀렸다.
복원이 스스로 해를 끼치지 않도록 두 가지를 지킨다:
- 유효한 UTF-8로 디코드되는 것만 바꾼다. 홀로 나온 <0xFF>는 잘린 글자가 아니므로
U+FFFD로 바꾸면 복원이 아니라 정보 파괴다
- 코드블록·인라인코드 안은 건드리지 않는다. 거기 있는 <0xEB>는 내용이다
(헥스 덤프, 토크나이저 예제, 이 기능 자체의 문서 등)
조립된 전체 텍스트에 적용한다 — 스트리밍 청크에 걸면 여러 청크에 걸쳐 도착한
바이트 열을 놓친다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
실사고(2026-08-12): "오늘 미국 주요 뉴스"에 모델이 분야를 나눠 세 번 불렀다.
news_search({category:"technology", country:"us"})
news_search({category:"business", country:"us"})
news_search({category:"world", country:"us"})
category 보정(8a8c81e)이 셋을 전부 {"country":"us"}로 접었고, 그 뒤에 도는
루프 감지가 이를 "같은 호출 3회"로 읽어 LOOP WARN x3을 찍었다. 모델은 루프에
빠진 적이 없다 — 서로 다른 세 호출을 우리가 같게 만든 것이다.
동작에는 지장이 없었다(중복 제거가 받아 1회만 실행, 같은 초 안에 끝났고 답변
품질은 오히려 개선). 문제는 경고가 거짓이라는 것이다. 이 소음이 쌓이면 진짜
루프를 디버깅할 때 묻힌다.
두 판정의 질문이 다르므로 인자도 달라야 한다:
- 루프 감지 = "모델이 같은 행동을 반복하는가" → 보정 전 원본 인자
- 중복 실행 방지 = "이 실행이 불필요한가" → 보정 후 인자(접힌 뒤 같아진
호출은 실제로 불필요하므로 그대로 둔다)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
인자 보정에 적용한 것(dbe34ba)과 같은 문제가 도구 선택 게이트에도 있었다.
날씨 대화 중에 "그럼 모레는?"이라고 하면 그 메시지에 날씨 단어가 없어서
weather_* 가 스키마에서 빠진다 — 어제 "향후 비소식" 사고와 같은 결말이 된다.
ToolScopeInput에 recentUserText(선택)를 추가하고, **날씨·뉴스·재난 게이트에만**
적용했다(사용자 결정). 이 셋은 "도구를 제공할까"만 정하므로 오래된 매치가
남더라도 스키마 토큰 몇백 개가 낭비될 뿐 답이 틀리지 않는다.
나머지 게이트는 의도적으로 현재 메시지에 그대로 둔다. 전부 넓히면 두 턴 전
코딩 단어 하나 때문에 무관한 턴에 coder 도구가 딸려온다 — 테스트로 박아뒀다.
recentUserText가 없으면 message로 폴백하므로, 이력을 갖지 않은 호출부는
이전과 완전히 동일하게 동작한다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
사용자 지적(2026-08-12): "메시지간의 연관이 없어진다는건가? 다른 채팅은
그렇지 않던데" — 직전 커밋(8a8c81e)에서 내가 만든 결함이 맞다.
대화 맥락 자체는 없어지지 않는다. 이력은 그대로 모델에 전달되고 모델도
기억한다. 문제는 **모델은 맥락을 아는데 보정 함수만 몰랐다**는 것이다.
"미국 기술 뉴스" 다음에 "더 보여줘"라고 하면, 모델은 맥락을 알고
category=technology를 유지하는데 보정 코드가 "지금 메시지에 기술이란 말이
없다"며 지워버린다. 맥락을 아는 쪽의 판단을 모르는 쪽이 덮는 구조였다.
보정 게이트에 직전 사용자 발화 2턴 + 현재 메시지를 이어붙여 넘긴다.
2턴으로 제한한 이유: 더 거슬러 올라가면 20턴 전에 한 번 말한 주제가 계속
살아남아 이번엔 반대 방향으로 틀린다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
실사고(2026-08-12): "오늘 미국 주요 뉴스"에 모델이 category를 스스로 붙였다.
news_search({"category":"technology","country":"us","language":"en"})
news_search({"category":"business","country":"us","language":"en"})
그래서 답변이 "미국 내 기술 및 비즈니스 분야의 주요 뉴스"가 됐다. 사용자는
분야를 지정한 적이 없고, 정치·사회·국제 뉴스는 통째로 빠졌다.
스키마 설명에는 이미 "특정 국가 뉴스면 category를 빼거나 top을 쓰라"고 적혀
있었다. 이틀간 여섯 번째로 프롬프트가 무시된 사례다.
correctNewsCategoryForBreadth: 사용자 메시지에 해당 분야를 가리키는 말이
없으면 category를 뺀다. **오직 넓히기만 한다** — 뺀 결과는 그 나라의 일반
헤드라인이고, 그게 분야를 말하지 않은 요청의 뜻이다. 좁힐 수는 없으므로
오작동해도 사용자가 잃겠다고 하지 않은 범위를 잃는 쪽으로만 틀린다.
"top"은 분야가 아니라 전체 피드이므로 건드리지 않는다.
호출 지점 세 곳(일반/합성/텍스트파싱) 모두에 적용하되, 도구 이름으로
게이트하는 얇은 래퍼를 한 곳에 두어 분기를 모았다 — 바로 아래 image_edit
차단과 같은 자리, 같은 방식이다.
알려진 한계: 현재 메시지만 본다. 분야 요청 후 "더 보여줘" 같은 후속에선
분야가 풀려 일반 뉴스가 나온다. 넓어지는 쪽이라 안전한 실패 방향이다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
실사고(2026-08-10, "태풍 15호 현재 위치는 어디지?"): web_search가 5건을
돌려줬는데 1번이 기상청 쿠키 설정 안내 조각이었다. 모델은 1번만 읽고 검색이
아무것도 못 찾았다고 결론냈다. 같은 응답의 4~5번에 태풍 이름과 진행 방향이
있었고, 동일한 결과를 받은 클라우드 대형 모델은 그걸 제대로 썼다.
이건 원래 프롬프트로 넣었던 것이다 — 지서버 모델 프로필의 1,531자 안에 이
사고를 쿼리와 쿠키 결과까지 명시해 적어놨는데, 08-12에 또 났다. 이틀간
다섯 번째로 프롬프트가 코드에 진 사례이고, 오늘 실험이 이유를 설명한다:
격리 조건에선 지시를 다 지키지만 제약이 경쟁하면 주 목표만 남기고 버린다.
가드는 주의력을 놓고 경쟁하지 않는다.
**핵심 난점은 "못 찾았다"가 자주 맞는 말이라는 것.** 같은 로그의 정상 답변은
태풍을 찾아내고 좌표만 없다고 밝혔다 — 이걸 재시도시키면 순수 낭비다.
구분은 첫 문장에 있다: 포기는 실패로 시작하고, 진짜 답변은 발견으로 시작한 뒤
빈 곳을 뒤에 붙인다. 그래서 판정은 lead sentence에서만 한다.
- countSubstantiveResults: 스니펫 40자 미만은 세지 않는다. 쓰레기 5건이
재시도 근거가 되면 결과가 정말 부실할 때도 발동한다
- 임계값 3건: 2건이면 "여기 쓸 게 없다"가 정직한 독해인 경우가 잦다.
3건 이상인데 못 찾았다면 목록 위쪽만 본 것이 거의 확실하다
- 재시도 1회 상한
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
사용자 지적: "비소식, 눈소식, 태풍소식 이런 것도 넣어야겠네."
셋은 직전 커밋(58c01b7)으로 이미 걸리고 있었지만, 지적의 핵심은 맞았다 —
현상별로 흩어 놓으면 다음 현상에서 또 빠진다. 실제로 확인해보니 우박·안개·
서리 소식이 빠져 있었다. "OO 소식 있나?"는 날씨를 묻는 가장 흔한 말투라
개별 대응이 아니라 패턴으로 잡아야 한다.
WEATHER_PHENOMENON(비|눈|태풍|장마|한파|폭염|황사|미세먼지|우박|안개|서리|
더위|추위|바람|구름|강수|기온|날씨) × (소식|예보|전망|상황) 조합으로 통합.
"소식"·"전망" 단독은 넣지 않는다 — 회사 소식·업계 소식·실적 전망에 다
걸린다. 현상 목록에 "비"·"눈"처럼 다른 뜻과 겹치는 짧은 단어가 있으므로
단독 사용을 금지하는 주석을 목록 선언부에 붙였고, 오탐 4건을 테스트로 박아뒀다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
실사고(2026-08-12): "향후 비소식은 없나?"에 날씨 도구가 하나도 안 붙었다.
스코프 게이트의 hasWeatherKeyword 목록(날씨|기온|강수|미세먼지|...)에 "비"도
"기상청"도 없어서 weather_kma·weather_openmeteo가 스키마에서 빠졌고, 모델은
쓸 도구가 없으니 web_search로 갔다가 2019년 기사를 물어와 "예보를 확인할 수
없다"고 답했다.
이어서 사용자가 "기상청에 알아봐"라고 명시했는데 **그 말조차 목록에 없어서**
두 번째 턴도 web_search로 갔다. 기상청 홈페이지를 검색한 것이지 기상청 API를
부른 게 아니다. 대화 전체가 날씨 도구 없이 돌았다.
이 게이트의 비용 비대칭은 프롬프트 게이트와 반대다 — 오탐은 도구 스키마가
몇백 토큰 늘 뿐이지만 미탐은 답변 경로 자체가 틀린다. 넉넉하게 잡는 쪽이 맞다.
실사용 메시지 103건으로 교정: "기상청" 신규 2건(전부 진짜 날씨),
"비" 문맥형 신규 2건(전부 진짜 날씨), 오탐 0건.
- 기상청|예보|기상 상황
- 비 문맥형(비소식/비예보/비가 오/소나기/호우/폭우/우산) — "비" 단독은 넣지
않는다. 비용·비교·준비에 걸린다. 테스트로 박아둠
- 눈 문맥형(눈 소식/눈이 오/눈 올) — "눈" 단독은 신체 부위와 충돌
- 무더위|더위|추위|쌀쌀|습도|풍속|체감온도
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
사용자 제보: "지서버 모델 연결됐는데 컨텍스트가 세션창에서만 8킬로", 그리고
"웹을 리로드하면 256킬로가 된다".
서버 쪽 마지막 폴백(8192)은 이미 캐시하지 않게 돼 있어서 매번 다시 조회한다
(그 사고를 겪고 고친 주석이 남아 있다). 문제는 브라우저였다 — app.js가
if (_appCtxMax > 0 && !force) return; // 한 번 받으면 끝
로 한 번 받은 값을 페이지 수명 내내 들고 있어서, 지서버가 자고 있을 때 열면
8192가 그대로 박혔다. 리로드가 고친 게 아니라 리로드로 캐시가 비워진 것.
메인 채팅 게이지는 SSE usage로 라이브 값을 받아서 멀쩡했고 세션창만 틀렸던
이유가 이것 — 두 화면이 서로 다른 경로로 같은 숫자를 구하고 있었다.
어댑터 쪽(bfbf04e)과 같은 원칙으로 고친다: **추측을 측정처럼 다루지 않는다.**
- resolveNumCtxInfo()가 value와 known(진짜 해석 결과인지)을 함께 반환
- /api/model-context가 provisional 플래그를 실어 보낸다. /api/show 실패나
마지막 폴백이면 true
- app.js: provisional이면 화면에는 보여주되 _appCtxMax에 저장하지 않아 다음
호출에서 다시 묻는다
- code.js: provisional이면 모델별 1회 래치(_codeAiCtxMaxFetchedFor)를 풀어
다시 조회하게 한다
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
사용자 제보: "지서버 모델 처음 로딩하면 컨텍스트가 항상 8킬로인데, 웹을
리로드하면 256킬로가 된다."
getModelCtx()가 /api/show 실패 시 8192를 반환하면서 그 값을 캐시에 넣었다.
그리고 _resolveCtx가 요청값을 native로 깎는다:
if (requested && requested > 0) return Math.min(requested, native);
→ Math.min(262144, 8192) = 8192
지서버가 자고 있을 때 첫 조회가 실패하면 8192가 캐시에 박히고, 캐시는
updateEndpoint()에서 엔드포인트 문자열이 바뀔 때만 비워진다. 그래서 한 번
오염되면 계속 8K로 돌았고, wol-gate 직결/게이트 전환이 일어나 캐시가 비워질
때 비로소 정상으로 돌아왔다 — 리로드하면 고쳐지는 것처럼 보인 이유가 이것.
추측한 값을 사실처럼 캐시하고, 그걸로 사용자 설정을 덮어쓴 게 버그다.
두 가지로 나눠 고쳤다:
- getModelCtxInfo()가 native와 함께 known(진짜 조회 결과인지)을 반환한다.
**조회에 성공한 값만 캐시한다** — 지금 모델에 못 닿는다는 사실은 그 모델의
컨텍스트 창에 대해 아무것도 말해주지 않으므로, 다음 호출에서 다시 조회한다.
Modelfile num_ctx는 선언된 실제 값이라 캐시 대상으로 유지
- 명시적으로 요청된 창(models.profiles의 fixedNumCtx)은 **ceiling을 실제로 알 때만**
깎는다. 폴백 추측값으로 깎는 게 262144를 8192로 만든 경로다
검증: 응답 없는 엔드포인트로 재현 → 수정 전 8192, 수정 후 262144 유지.
실제 지서버 조회는 262144 정상, 동적 사이징의 8192 폴백은 그대로 동작.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
공개 데이터셋 조사 결과 GPU 말고는 쓸 물건이 없었다. pc-part-dataset의
motherboard.json은 4,973건인데 필드가 7개뿐이고(칩셋·PCIe·M.2·VRM 없음,
색깔은 있음) 13개월 전이 마지막 갱신, CPU 쪽은 1~4년 낡았거나 인텔 전용
이거나 AGPL, 미니PC는 데이터셋 자체가 없다.
"광고에서 찾는 게 빠르겠다"는 사용자 지적이 맞았다. 파는 물건이라 낡을
수가 없고(낡으면 목록에서 사라진다), 데이터셋이 없는 미니PC도 상품 페이지는
반드시 있다. 같은 메인보드 질의 실측 비교:
pc-part-dataset name/price/socket/form_factor/max_memory/slots/colour
다나와 AMD(소켓AM5) / AMD B650 / DDR5 / PCIe5.0 x16 /
M-ATX (24.4x24.4cm) / M.2 : 3개 / DrMOS / 전원부 방열판
라벨링은 GPU DB와 다르게 갔다. 저건 TechPowerUp 계측치라 "확정값"이지만
이건 판매처 표기이고 가격은 움직이는 값이라, 헤더에 그렇게 못박았다.
오늘 하루 주제가 근거에 정확한 라벨 붙이기였으니 출처를 부풀리지 않는다.
정중함은 선택이 아니라 제약으로 넣었다. robots.txt가 /dsearch.php는
허용하지만 Crawl-delay: 10이 걸려 있어서, 요청을 큐로 직렬화하고 10초
간격을 강제하며 30분 캐시를 둔다. 오늘 만든 비교 검색 쪼개기처럼 병렬로
3번 때리는 방식을 여기 쓰면 안 된다.
GPU에서 둘 다 걸릴 때 우선순위는 사용자 결정에 따라 다나와가 앞이고 스펙
DB는 폴백이다. 다만 버리지는 않고 출시일 한 줄은 남긴다 — 판매 목록엔 절대
없는 필드이고, 이 사태의 출발점인 "아직 출시 안 됨" 환각을 못 쓰게 만드는
게 정확히 그 필드다.
- parseDanawaHtml은 순수 함수로 분리해 저장된 페이지로 테스트한다. 스크래핑
마크업은 언젠가 깨지는데 진짜 위험은 "조용히" 깨지는 것이라, 0건 파싱은
전부 데이터 없음으로 처리하고 회귀는 테스트가 잡는다
- 가격은 price_sect 안의 <strong>만 인정한다. 블록 첫 <strong>은 평점일 수
있어 실제 페이지에서 "65"·"2"가 가격으로 잡히는 걸 확인하고 좁혔다
- isPcPartQuery는 부품 신호 + 스펙/가격 의도가 함께 있을 때만 참이다.
10초 딜레이가 있으니 조회를 아껴 쓴다. "메인보드에 CPU 끼우는 법"은 안 잡음
- 테스트가 구멍을 하나 잡았다: 카테고리 명사만 넣었더니 "RTX 5060 최저가"가
통과 못 했다. 사람은 부품을 모델명으로 부른다 — 모델명 표기를 추가
실측: "B650 메인보드 스펙" 1.7초, "미니PC 가격" 10.1초(크롤 딜레이 작동 확인)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"시어엔진에 이런 데이터 전용 API가 있나" → 253개 엔진 중 하드웨어 스펙
엔진은 0개였다(geizhals는 403 봇차단, ddg definitions는 GPU 모델명에 빈
응답). 대신 GitHub에서 RightNow-AI/RightNow-GPU-Database(Apache 2.0,
TechPowerUp 기반, 2,824개 GPU, 55필드)를 찾았고, README엔 언급이 없지만
실물엔 RTX 50 시리즈가 들어있는 것을 데이터를 받아 확인했다.
이게 지금까지의 우회로보다 나은 이유:
- nvidia.com 공식 페이지는 스펙 키워드 0개(JS 렌더링), techpowerup은
봇 차단. HTML 스크래핑으로 살아있는 출처가 nanoreview 하나뿐이었다
- 봇차단·JS·파싱실패·죽은링크가 전부 없고, 캐시 워밍 후 64ms
- 무엇보다 모든 레코드에 releaseDate가 있다. 5060은 2025-05-19다.
이 사태의 출발점이던 "5060은 미출시" 환각은 데이터와 만나는 순간
성립하지 않는다 — 가드는 쓰인 거짓말을 잡지만, 이건 쓸 이유를 없앤다
신선도가 진짜 리스크라 거기에 설계를 집중했다. 호스팅 API가 아니라 GitHub
저장소라서, 저쪽이 멈추면 우리도 멈추고 그러면 지금 고치는 실패가 데이터
계층에서 재현된다. 그래서 주간 갱신 + 결과에 캐시 나이를 항상 같이 실어
낡은 답이 조용히 틀리는 대신 눈에 띄게 낡도록 했다. 갱신은 백그라운드라
질문을 느리게 만들지 않고, 실패해도 옛 캐시로 계속 답한다.
도구가 아니라 주입으로 넣은 이유: 모델이 도구를 안 부른다. 로그 3,300줄
에서 web_fetch 호출 1번, GPU 질문엔 0번이었다(오늘 같은 결론 네 번째).
- extractGpuMentions: 맥락이 있을 때만 맨숫자를 모델명으로 본다.
"4080이 5060보다"는 잡고 "2024년 매출 3800억"은 안 잡는다 — 무관한
답변에 스펙이 끼어들면 그 자체가 오염이다
- findGpu: "RTX 4080"은 SUPER/Mobile 이름에도 부분일치하므로 변형에
가중치를 줘 기본 카드를 고른다. 데스크탑 질문에 노트북 칩 수치를
물려주면 조용히 틀린 답이 된다
- 캐시 쓰기는 write-then-rename (config.json 3회 손상과 같은 실패 방지)
실측: RTX 4080 FP32 48.74 TFLOPS / 716.8 GB/s vs RTX 5060 19.18 TFLOPS /
448 GB/s — 2.54배 차이를 근거를 갖고 말할 수 있게 됐다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
쪼개기(9f31d76)로 좋은 출처가 결과 목록엔 들어왔는데도 답변이 여전히
일반론이었다. 원인은 모델이 결과를 안 펼쳐본다는 것이다 — 프로덕션
로그 3,300줄에서 web_fetch 호출은 딱 1번, 그것도 기상청 페이지였고
GPU 스펙 질문에선 0번이다. 도구 설명에 "결과 URL을 web_fetch로 읽어라"라고
써 있는데도 그렇다. 스니펫엔 제품 이름만 있고 수치는 본문에 있으니,
스니펫만 보고 쓰면 "80 라인업이 60 라인업보다 빠르다"에서 멈춘다.
사용자 질문("스펙을 제조사에서 찾기가 어려운가?")에 답하려고 상위 세
출처를 실제로 가져와 재보니, 제조사 공식 페이지가 셋 중 가장 나빴다:
nvidia.com 6016자, 스펙 키워드 0개 — 표가 JS 렌더링이라 본문은
"This site requires Javascript" + "Game Changer" 홍보 문구
techpowerup 275자, 스펙 키워드 0개 — 봇 차단
nanoreview Cores/TMUs/Boost Clock/Bandwidth/TFLOPS 전부 있음
그래서 가져오되 거르는 구조로 했다. hasSpecContent가 통과시킨 첫 페이지
하나만 대상별로 붙인다 — 이게 없으면 "Game Changer" 6KB가 컨텍스트를
차지하고, 여러 장을 붙이면 모델이 읽고 지나가야 할 양만 늘어난다.
실측(같은 쿼리): nanoreview 4080·5060 본문 2장 자동 확보, 9.4초,
RTX 4080 Cores 9728 / 게이밍 76 vs RTX 5060 Cores 3840 / 게이밍 43 —
"얼마나 빠른지"에 실제로 답할 수 있는 수치가 처음으로 들어왔다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
어제(08-10) "A vs B로 합쳐 검색하지 말고 각각 따로 검색하라"를 web_search
설명에 넣었는데, 오늘 프로덕션 로그에서 모델이 세 턴 연속(3149/3287/3303)
정확히 금지된 형태를 그대로 날렸다:
web_search({"query":"RTX 4080 vs RTX 5060 performance comparison specs"})
매번 technical.city 링크가 죽어 있었고(link-validator 2/5 dead), 모델
손에 남은 건 제목뿐이라 답변은 수치 0건의 일반론이 됐다. 사용자 평가는
"자료가 부실하고 요점도 안 맞는다".
오늘 하루 네 번째 같은 패턴이다 — news_search country 파라미터, 카테고리
단어, 다도시 날씨 배치, 그리고 이번 건. 도구 설명으로 쿼리 구성을 강제할
수 없다는 게 반복 확인됐으므로 코드에서 쪼갠다.
- splitComparisonQuery: 비교 동사(comparison/vs/비교/차이)는 버리고
속성어(performance/specs/대역폭/성능)는 양쪽에 붙여 둘로 나눈다
- 합친 쿼리도 그대로 실행한다. 비교 페이지가 살아있을 땐 그게 최선의
출처라서, 빼면 실패 모드를 다른 실패 모드로 바꾸는 것에 불과하다.
쪼갠 검색은 추가분이고 결과 수를 3개로 더 조인다
- 오탐 쪽을 비싸게 본다: 속성어도 모델번호 꼴도 없으면 쪼개지 않는다
("Lakers vs Celtics"를 두 검색으로 만들면 질문 자체가 사라진다).
"검색결과 스펙"처럼 과/와로 끝나는 단어에 걸리는 것도 길이로 막았다
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RTX 4080 vs 5060 비교 요청에서 모델이 답을 못 주는 이유로
"RTX 5060의 미출시 상태"를 단정했다. 실제로는 이미 출시돼 사용자
지서버에 꽂혀 있는 카드다.
기존 가드 셋이 전부 통과시킨 이유:
- AUTO-RECOVER: 검색을 하긴 했다 (도구 호출 있음)
- EMPTY-GROUNDING: 결과가 비어있지 않았다
- NUMERIC-GROUNDING: 답변에 수치가 0건이라 검증할 주장 자체가 없었다
세 가드 모두 숫자를 전제로 하는데, 이 답변의 유일한 거짓말에는
단위가 없었다.
부재 주장은 별도로 검증할 값어치가 있다. 모델은 "학습 데이터 시점
기준으로 출시 안 됨"만 알 수 있을 뿐 출시 여부 자체를 알 수 없다
(5060은 이 모델 컷오프 4개월 뒤 출시). 기억에 없는 것을 세상에 없는
것으로 바꿔 말하는 건 사실 판단이 아니라 범주 오류다. 검증 방법도
날짜 검증과 같다 — 출처가 정말 미출시라고 하면 그 표현이 출처 텍스트에
있고, 없으면 모델이 지어낸 것이다.
- checkNumericGrounding에 nonexistenceClaim/nonexistenceUnsupported 추가
- 출처 지지 판정(NONEXISTENCE_SUPPORT)은 느슨하게 잡았다. 느슨한 쪽이
재시도를 억제할 뿐 만들어내지는 않는 안전한 실패 방향이다
- 사용자가 질문에 적은 전제("그거 아직 안 나왔지?")를 되풀이한 건
숫자와 동일하게 검증 대상에서 제외
- 재프롬프트 문구를 분리했다. 기존 "출처에 있는 숫자만 써서 다시 써라"는
이 경우 헛도는 지시다 — 고칠 숫자가 없고, 문제는 정반대(지어낸 사실을
구실로 답변을 거절)이기 때문
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
실제 사고(2026-08-11): "5060 보다 얼마나 빠르지 4080?"에 도구 호출
0건, 모델이 "RTX 5060은 아직 출시되지 않은 차세대 모델"이라고
완전히 지어냈다 — 실제로는 이미 출시돼 지서버가 쓰고 있는 카드
(RTX 5060 Ti)다. "성능"/"비교" 등 기존 키워드를 하나도 안 써서
이 파일 자체 주석이 경고하던 "casual paraphrases slip through
unmatched"가 실제로 뚫렸다.
"A보다 (얼마나) 빠른/느린/좋은/나은" 형태의 캐주얼 비교 표현을
형용사 목록으로 추가. 구현 중 발견: 빠르다/느리다는 러-불규칙
활용이라 관형형이 "빠르ㄴ"이 아니라 "빠른"으로 어간 자체가
바뀐다(느리다→느린도 동일) — 어간만 넣으면 이 활용형을 놓쳐서
테스트가 그대로 잡아냈다("이게 저것보다 느린가?"가 최초 버전에서
빠짐), 두 형태 다 등록해서 고쳤다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
실사용 로그로 확인: 1분 간격 폴링 중에도 "지서버 응답 확인(깨어있음)"
과 "ping failed — 지서버 무응답"이 번갈아 찍혔다 — 60초로는 재우기
전에 못 잡는 경우가 있다는 뜻. 30초로 좁힘.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
실제 사고(2026-08-10): "RTX 3090 vs RTX 4080 Super memory bandwidth
comparison" 결합형 쿼리는 일반 개요 페이지만 반환해 두 스펙 다 못
찾았는데, 같은 두 개를 "RTX 3090 memory bandwidth GB/s" /
"RTX 4080 Super memory bandwidth GB/s"로 따로 검색하니 둘 다 바로
정답(936 vs 736 GB/s)이 나왔다.
numeric-grounding 가드가 검증 안 된 936GB 언급을 정확히 잡아냈고
(가드 자체는 M40+Qwen3.6 조합처럼 실존하지 않는 벤치마크를 지어낸
과거 사고 두 건에 근거해 만들어졌고 이번에도 의도대로 작동했다 —
가드를 손보지 않기로 결정), 대신 근본 원인인 검색 쿼리 구성을
고치기로 했다. news_search/weather_openmeteo와 달리 web_search는
결정적으로 고칠 API 단서가 없는 일반 웹검색이라, 이번엔 도구
설명에 프롬프트 지침을 추가하는 선에서 대응한다 — 오늘 다른
사례들처럼 100% 신뢰할 수 있는 방식은 아니지만 비용이 거의 없다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
08-10에 만들었던 2분 간격 버전을 효과가 불확실하다며 지웠는데(그
시점엔 지서버 쪽에 사용자가 직접 건 30초 하트비트가 병행되고 있어
어느 쪽 효과인지 분리가 안 됐음), 오늘 실제 실험으로 인과관계가
확인됐다: 지서버 쪽 30초 폴링을 끄자 몇 분 안에 Windows가 실제로
절전 상태에 들어갔다(게이트가 "깨우는 중" HTML 반환, ARP FAILED,
RDP·ping 전부 무응답 — 전부 실측 확인).
즉 주기적 폴링이 절전을 막는 효과 자체는 실재하고, 남은 건 그걸
지서버 쪽 별도 스크립트로 둘지 게이트웨이가 직접 할지의 문제였다.
사용자가 게이트웨이 쪽으로 다시 넣기로 결정 — 간격은 이전 2분보다
촘촘한 1분으로.
게이팅 로직은 이전과 동일: llm.provider === 'ollama_local'이고
WS 클라이언트가 최소 1개 연결돼 있을 때만(앱이 실제로 열려있고
지서버가 활성 모델일 때만) 직결 엔드포인트에 가벼운 요청을 보낸다.
게이트가 아니라 직결로만 보내므로 이미 잠들어 있으면 조용히
실패한다 — 깨우는 신호가 아니라 "깨어있는 동안 재우지 않는" 신호.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
실제 사고(2026-08-10): "오늘 유럽 대도시 최고 기온"에 모델이 5개
도시("London, Paris, Berlin, Madrid, Rome")를 한 번에 넘겨 실패
(도구별로 나눠 부르라는 안내 자체는 이미 08-09에 잘 만들어져 있었음),
그 뒤 로마·런던 2개만 재시도하고 파리·베를린·마드리드는 빠뜨렸다.
지어내진 않았지만("다른 도시 필요하시면 말씀해주세요") 불완전했다 —
같은 날 news_search/weather_kma에서 겪은 것과 같은 패턴: 프롬프트/
에러 메시지가 정확히 뭘 해야 하는지 알려줘도 여러 번의 후속 호출을
전부 완수하지는 못했다.
같은 해법 적용: 전체 문자열 지오코딩이 실패하면 배치인지 확인하고,
배치면 모델에게 재시도를 맡기는 대신 도구가 직접 각 도시를 병렬로
조회해서 구분선으로 나눈 하나의 결과로 합쳐 반환한다. 일부 도시가
실패해도 나머지는 정상 반환하고 실패한 도시만 목록에 남긴다 — 전체
호출이 부분 실패로 죽지 않는다.
부수적으로 OUTPUT FORMAT의 날씨 지침에 "여러 도시 비교 시 표만 던지지
말고 어디가 제일 덥/추운지, 특이한 도시가 있는지 코멘트를 붙일 것"을
추가 — 실제 로그에서 표 하나에 문장 하나짜리 부실한 답변이 관찰됨.
기존 배치 테스트(2026-08-09, success=false 검증)를 새 동작에 맞게
갱신(success=true + 도시별 결과 포함 검증) — 일부 실패 케이스 테스트도
추가.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
실제 사고(2026-08-10): "이번 주말 날씨 어때?"(5일 후 질문)에
weather_kma만 불렀고, 기상청 단기예보가 3일까지만 커버한다는 걸
확인하자 "금요일쯤 다시 물어봐 주세요"라며 끝냈다. weather_kma
자체 설명에 "3일 넘으면 weather_openmeteo로 보완할 것"이라고
이미 적혀 있었는데도 두 번째 도구를 안 불렀다.
같은 날 news_search에서 겪은 것과 정확히 같은 패턴 — 프롬프트/
스키마 설명 지침을 아무리 정확히 적어도 이 모델은 세부 지시를
안 따랐다. 그래서 같은 해법을 적용했다: 모델이 두 번째 도구를
기억해서 불러주길 바라는 대신, weather_kma가 자기 응답 안에서
Open-Meteo 4~7일차 예보(기온·강수확률)를 자동으로 붙여서
반환한다. 한 번의 호출로 끝나므로 두 번째 호출을 빼먹을 여지가
없다.
기상청이 실제로 커버한 마지막 날짜 이후만 보완하도록 날짜
경계를 계산하고(Open-Meteo가 이미 아는 날짜를 중복 표시하지
않음), 네트워크 실패 시엔 조용히 생략(기상청 데이터는 그대로
반환) — 보완 기능 하나가 핵심 예보 응답을 절대 깨뜨리지 않는다.
순수 필터링/포맷 로직(formatDailySupplement)을 네트워크 호출과
분리해 테스트 가능하게 했다 — link-validator.ts/news.ts와 같은
패턴, 회귀 테스트 4개 추가.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
사용자 관찰("크기가 바뀔 때마다 올라마가 모델을 다시 로드하네")을
직접 재현·측정: 같은 num_ctx로 두 번 호출하면 load_duration 1.3초,
num_ctx만 바꿔 호출하면 18.8초. ollama-adapter.ts의 _resolveCtx()가
매 턴 프롬프트 크기에 맞춰 2의 거듭제곱으로 동적으로 재계산하는데,
그 값이 바뀔 때마다 Ollama가 모델을 통째로 재로드하는 것 — 대화
도중 설명 안 되는 수 초짜리 멈춤으로 나타났다.
동적 사이징 자체는 VRAM이 빠듯한 배포를 위해 존재하는데, 지서버는
그 경우가 아니다: gemma4:26b를 native 최대 컨텍스트(262144)로
로드해도 32GB 중 18.3GB만 쓰고 13.7GB가 남는다(실측, size_vram).
아낄 필요가 없는데 아끼다가 재로드 비용만 치르고 있었다.
models.profiles[<model>].fixedNumCtx로 모델별로 동적 사이징을
끄고 항상 고정값을 요청하게 하는 옵션을 추가했다(getModelProfileForceToolChoice와
같은 패턴). gemma4:26b는 262144로 설정 — 한 번 로드되면 대화가
아무리 길어져도 재로드가 없다. 다른 모델/제공자는 기존 동적
사이징 그대로 유지된다(opt-in이라 이 모델 외엔 영향 없음).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
같은 문제("태풍 돌핀" 쿼리 → 0건)를 프롬프트/스키마 설명으로 4번
연속 고치려 했으나, 실사용 재현 테스트마다 모델이 매번 예전 방식
그대로 호출했다(country 안 넣거나, 카테고리어+고유명사를 한 쿼리에
계속 붙임). prompt-gates.ts 파일 자체의 설계 철학대로 — "프롬프트
지시만으론 안 믿을 때 결정적으로 강제하는 백스톱" — 이번엔 코드가
직접 보정하도록 바꿨다.
execute()에 두 가지 결정적 로직 추가:
1. 쿼리에 한글이 있는데 country/language 둘 다 없으면 자동으로
country="kr" 적용. 모델이 계속 빠뜨리던 파라미터를 기본값으로
메운다.
2. 검색 결과가 0건이고 쿼리에 재해 카테고리어(태풍/지진/산불 등)가
섞여 있으면, 그 단어만 제거하고 자동 재시도. 라이브 API로 직접
검증: q="태풍 돌핀"은 0건, q="돌핀"은 5건 — 실제 기사 제목이
"태풍 '돌핀'"(따옴표 있음)이라 연속 문자열 매칭이 실패했던 것.
두 로직을 resolveNewsParams/stripCategoryWordForRetry로 분리해
네트워크 없이 단위 테스트 가능하게 했다(link-validator.ts의
rewriteDeadLinks와 같은 패턴) — 실제 사고 수치를 그대로 회귀
테스트에 고정, 10개 추가.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
이전 커밋에서 "country 파라미터 누락이 원인"이라 진단했는데, 사용자가
"국내 사이트로 검색하면 쏟아질 텐데?"라고 반박해서 NewsData.io API를
직접 여러 조합으로 호출해 재검증했다:
q="태풍 돌핀"+country=kr → 0건
q="태풍"+country=kr → 12건 (돌핀 기사 포함)
q="돌핀"+country=kr → 5건 (전부 관련 기사)
실제 원인은 country 누락이 아니라 **일반 카테고리어("태풍")와 고유명사
("돌핀")를 한 쿼리에 붙여 쓴 것**이었다. 실제 기사 제목은 "태풍 '돌핀'"
처럼 따옴표가 끼어 있어 "태풍 돌핀"이라는 연속 문자열과 리터럴 매칭이
안 됐다. 이전 커밋의 country 안내 자체는 여전히 유효하지만(비영어
쿼리엔 필요), 그것만으론 이 사고를 설명 못 했다.
query 파라미터 설명에 실제 API 호출로 검증한 세 가지 결과를 근거로
"특정 개체/사건이 있으면 카테고리어와 합치지 말고 그 이름 하나만
쓸 것"이라는 지침을 추가했다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
실제 사고(2026-08-10): news_search({"query": "태풍 돌핀"})가 country
없이 호출돼 "(no articles found)"를 반환했다. 같은 시점에 국내
언론사 6곳 이상이 그 사건(중국 상륙, 100만명 대피)을 보도 중이었다.
원인: NewsData.io는 country 없이 호출되면 영어 위주로 치우친 풀에서
쿼리 텍스트를 그대로 매칭한다. 한국어 쿼리 텍스트는 그 풀 안의 기사와
문자 그대로 안 맞아서 빈 결과가 나온다. 기존 설명엔 "포괄적인 요청엔
쿼리를 생략하라"는 지침만 있었고, "특정 주제의 비영어 쿼리면 country를
같이 넣어라"는 지침은 없었다.
query 파라미터 설명에 이 사례를 실측 근거로 추가해, 비영어 쿼리엔
반드시 country를 함께 넣도록 안내했다. 재해 리마인더가 아니라 도구
스키마 자체에 넣어서 재해 질문뿐 아니라 모든 news_search 호출에
적용된다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
실제 사고(2026-08-10): "태풍 돌핀 지금 어디 있지?"가 메인 채팅에서
web_search만 쓸 수 있었다. weather_map_screenshot(기상청/JMA 태풍
예상진로 지도 캡처)은 날씨 앱 탭에만 열려 있었고, news_search는
"뉴스"라는 단어가 없으면 안 열렸다 — 재해 질문은 둘 다 못 썼다.
그 결과 모델이 자체 생성한 "현재 위치 실시간 정보" 쿼리로 검색해
빈 포털 페이지만 받았고, 정직하게(수치를 지어내지 않고) "이미
소멸했을 가능성이 높다"고 결론 냈다 — 실제로는 중국에 상륙해
100만명 이상 대피, 500mm 폭우 경보 중이었다.
사용자 요청대로 "위치/경로"와 "피해상황" 두 축으로 나눠 연동했다:
- weather_map_screenshot: hasSatelliteKeyword(태풍 포함)로 게이팅
추가 — 기존 eonet_events/satellite_snapshot과 같은 패턴. 검색
스니펫보다 신뢰도 높은 기상청/JMA 공식 소스를 메인 채팅에서도
쓸 수 있게 한다.
- news_search: DISASTER_PATTERN(prompt-gates.ts, isDisasterRequest와
동일 소스 — 두 파일이 서로 다른 재해 키워드 목록을 들고 있다가
어긋나는 걸 막기 위해 재사용)으로 게이팅 추가. "뉴스"라는 단어가
없어도 재해 키워드만으로 피해/대피 상황 검색이 가능해진다.
handle-chat.ts의 사전 리마인더도 "위치/경로는 weather_map_screenshot,
피해상황은 news_search"로 역할을 나눠 안내하도록 확장했다.
이전 커밋(재해→news_search 라우팅 프롬프트 지침)만으로는 실사용
재검증에서 모델이 여전히 예전 쿼리를 그대로 썼다 — 프롬프트 지시만
믿지 않고 도구 자체를 실제로 쓸 수 있게 여는 쪽으로 보강했다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
실제 사고(2026-08-10): "태풍 돌핀 지금 어디 있지?"에 web_search가 자체
생성한 "태풍 돌핀 현재 위치 실시간 정보" 쿼리로 검색해 일반적인/내용없는
결과 3개(기상청 빈 포털, 오래된 지도 SPA, 무관한 결과 1개)만 받았다.
모델은 정직하게(좌표를 지어내지 않고) "검색결과가 없으니 이미 소멸했을
가능성이 높다"고 추론했다 — 추론 자체는 멀쩡했지만 전제(검색해도 안
나온다)가 검색 실패 때문에 거짓이었다. 실제로는 돌핀이 막 중국에
상륙해 500mm 폭우 경보, 100만명 이상 대피 중이었고, 같은 사건을 다룬
국내 언론사 기사가 6개 이상 있었다 — "태풍 돌핀 중국 상륙 피해"처럼
상황어를 넣은 쿼리로는 바로 찾아졌다.
news_search(NewsData.io, 실제 발행일 보장)가 정확히 이런 "지금 벌어지고
있는 일" 질문을 위한 도구인데, 재해 질문은 뉴스로 분류되지 않아
(isNewsRequest는 "뉴스/속보" 키워드가 있어야 걸림) 이 라우팅을 안 탔다.
isLiveDataRequest 안에 있던 재해 정규식을 isDisasterRequest로 분리해
handle-chat.ts의 사전 리마인더와 AUTO-RECOVER 재시도 리마인더 양쪽에
재사용했다 — 이 파일 헤더의 원칙대로, 검증을 강제하는 게이트끼리
어긋나면 한쪽만 고친 게 무의미해진다. 재해 질문엔 news_search를
우선 권하고, web_search를 쓰더라도 "현재 위치/실시간 정보" 같은
일반 쿼리 대신 "...상륙 피해", "...대피" 같은 상황어를 쓰도록
안내한다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
효과가 불확실했다. 로그로 보면 2분 핑이 도는 동안에도 한 번은 실제로
잠들었고("방금 또 잠들었던거 같으네"), 다른 구간에선 40분 연속 안
잤는데 그게 핑 덕분인지 그 시간 내내 실사용 채팅이 있었기 때문인지
분리할 수 없었다.
사용자가 지서버 쪽에 직접 10초 간격 하트비트를 새로 걸었다 — 원격에서
2분마다 찔러보는 것보다 훨씬 촘촘하고, 무엇보다 지서버 자체에서 도는
메커니즘이라 신뢰도를 판단하기 쉽다. 서버 쪽 것은 이제 중복이라
사용자 결정으로 제거.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
실사용 사고(2026-08-10, "16호 태풍" 답변): 모델이 URL을 그 자체로 링크
텍스트로 쓰는 흔한 패턴 `[https://x.com](https://x.com)`에서, 죽은 링크
탐지 정규식(BARE_RE)이 `]`/`[`에서 멈추지 않아 라벨의 `]`, href의 `(`를
넘어 마크다운 링크 전체를 하나의 "URL"로 삼켜버렸다. 그 결과 사용자가
실제로 본 출력은 `~~[~~url](url~~ ⚠️ (링크 끊김)))~~ ⚠️ (링크 끊김)`
같은 뒤섞인 텍스트였다.
BARE_RE의 부정 lookbehind와 문자클래스 양쪽에 `]`/`[` 제외를 추가해
마크다운 링크 경계를 넘지 못하게 했다. LINK_RE(먼저 실행됨)가 이미
"라벨이 URL 자체인" 경우를 포함해 [label](url) 쌍 전체를 처리하므로
BARE_RE는 그 경계 안으로 들어갈 필요가 없다.
rewriteDeadLinks()를 validateLinksInText()에서 분리해 순수 문자열
변환만 네트워크 모킹 없이 테스트 가능하게 했다 — 회귀 테스트 8개
추가, 실제 사고 사례를 그대로 재현해 고정.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
사용자가 "어제 집에서는 괜찮았는데 지금 외부에서 연결하니깐 자꾸
끊어지네"라고 보고. 서버는 이미 30초마다 ping을 보내 프록시/OS 유휴
타임아웃(92초 패턴)을 피하는 방어 로직이 있고(server.ts wsPingInterval),
60초 무응답이면 서버가 직접 연결을 끊는다.
메인 채팅 페이지엔 이 ping에 응답할 JS 타이머를 살려두는 장치가 없었다.
음성통화 페이지(voice-call.js)는 정확히 같은 증상(폰 화면 꺼짐 → 탭
백그라운드 스로틀링 → 소켓이 서버 ping에 응답 못 함 → 끊김)을 07-09에
Wake Lock으로 이미 고쳤는데, 메인 채팅 쪽엔 이 조치가 빠져 있었다 —
집에서는 화면이 잘 안 꺼지니 덜 겪고, 외부에서 폰으로 쓸 때 화면이
자주 잠기니 더 자주 겪는 것과도 맞아떨어진다.
voice-call.js와 같은 패턴을 그대로 적용: WS가 열릴 때 Wake Lock을
요청하고, visibilitychange로 탭이 다시 보일 때 재요청(Wake Lock은 탭이
숨겨지면 자동 해제되므로), WS가 닫히면 명시적으로 해제.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
08-09에 이미 조사했던 "지서버가 30분 설정보다 훨씬 빨리 잔다" 문제는
사용자가 전기료 절약 관점에서 선호하는 동작으로 확정하고 조사를 접었던
건이다([[project_wol_gate]]). 그런데 오늘(08-10) 외부에서 실제로 사용
중일 때 이 빠른 절전이 매번 ~23초 기상 지연으로 이어져 불편해졌고,
사용자가 "지서버가 안자게 신호를 계속 보내자"고 명시적으로 요청했다 —
원인 재조사가 아니라 능동적 keep-alive를 원하는 것으로 확인.
원인 후보였던 "Windows 유휴 타이머가 마지막 사용자 입력 기준이라
API 트래픽으로는 리셋 안 됨"이 맞다면, 주기적으로 실제 HTTP 요청을
보내는 것 자체가 대응책이 된다.
`llm.provider === 'ollama_local'`이고(getProvider()의 단일 캐시 구조상
어차피 이 조건에서만 실제 채팅 트래픽도 지서버로 간다) WS 클라이언트가
최소 1개 연결돼 있을 때만(앱이 실제로 열려 있을 때만) 2분마다 직결
엔드포인트에 가벼운 /api/tags 요청을 보낸다. 게이트가 아니라 직결로만
보내므로, 이미 잠들어 있으면 그냥 조용히 실패한다 — 깨우는 신호가
아니라 "깨어있는 동안 재우지 않는" 신호라, 나머지 시간대의 절전
이점(사용자가 이미 선택한)을 훼손하지 않는다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
사용자가 "지서버 젬마(gemma4:26b)가 도구를 잘 안 부르는 경향이 있다"고
직접 사용 경험을 보고했다. 이건 mistral-large-3 프로필이 이미 대응하는
것과 같은 실패 클래스(확인 가능한 질문에 도구 없이 답함)인데, 젬마
쪽은 뒷받침할 로그 증거가 없었다 — 있는 거라곤 사용자의 기억뿐이었다.
[[feedback_calibrate_on_real_data]]에 이미 세 번 적힌 교훈대로("가상
케이스로 임계값 잡으면 틀린다"), 기억만으로 프롬프트 경고를 새로
박아넣는 대신 실제 신호를 기록하고 나중에 데이터로 판단하기로 했다.
decideAutoRecover()가 이미 정확히 필요한 이벤트("도구가 필요한 질문에
관련 도구 없이 답함")를 계산하고 있었는데, 그 결과가 닿는 곳이 로테이션
되는 콘솔 로그 한 줄뿐이었고 거기엔 모델 이름도 없었다. usage-log.ts와
같은 형태(같은 디렉터리, 같은 JSONL append 패턴)로 tool-skip.jsonl을
추가해 모델별 집계가 가능하게 했다.
effectiveModel이 try 블록 안에서만 스코프였고 AUTO-RECOVER 체크는 그
catch 이후라서, try 블록 진입 시점에 resolvedModelThisRound에 미리
복사해 바깥에서도 참조 가능하게 했다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
이 문장은 245토큰으로 무조건 블록 중 단일 최대 항목인데, 내용이 통째로
"web_search를 먼저 불러라"다. 그 도구가 이번 턴 스키마에 없으면 실행할 수
없는 지시이고, 무엇보다 executeTool()이 스키마 멤버십을 확인하지 않고
이름으로만 디스패치하기 때문에 프롬프트가 도구 이름을 언급하는 것만으로도
모델이 파라미터 문서 없이 그 도구를 호출해버린다. meteorologist 스킬을 끈
사용자에게 weather_kma가 계속 돌던 그 경로다.
조건이 규칙의 실행 가능성과 정확히 일치하므로, 이건 이 파일 규칙 1이 금지하는
메시지 게이팅이 아니라 정상적인 도구 게이팅이다. ANTI-HALLUCINATION 앞부분
(도구 출력을 그대로 옮겨 적으라는 지침)은 도구와 무관하므로 무조건 유지된다.
토큰 절감은 없다. 처음엔 절감을 기대했으나, web_search가 어떤 스킬 게이트에도
속하지 않아 사실상 모든 일반 턴에 스키마에 들어간다는 걸 뒤늦게 확인했다.
실제로 줄어드는 건 부팅 턴(coder_list_files/coder_read_file만 있는 턴)뿐이고,
1687 → 1442 토큰이다. 그 턴에서 스키마에 없는 web_search를 프롬프트가 부르던
누출 벡터가 사라진 것이 이 커밋의 실질적 이득이다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
실사용 로그와 세션 104건을 가드에 직접 돌려서 찾은 오탐들이다.
1) 사용자가 질문에 직접 적은 숫자로 AUTO-RECOVER가 걸렸다.
로그 실례: "북위 27.7도, 동경 124.8도 여기가 어디쯤이지?" — 그 좌표 자체가
한 턴 전 web_search 결과였고 사용자가 그대로 옮겨 물은 것이다. 모델이
정답("동중국해")을 냈는데 답에 좌표를 되풀이했다는 이유로 COORDINATE_CLAIM이
걸려 재생성 + "latitude 27.7 longitude 124.8 location" 검색을 태웠고,
돌아온 건 "농림통계연보(2005).hwp"였다. 그러고서 같은 답을 다시 썼다.
이 가드가 잡으려는 건 "모델이 지어낸 숫자"인데, 사용자가 적어 넣은 숫자는
정의상 모델이 만든 값이 아니다. looksLikeUnverifiedSpecClaim에 userMessage를
받아 스캔 전에 지운다. 모델이 새로 덧붙인 수치는 그대로 남는다. 한 자리 수는
지우지 않는다 — 메시지의 "2" 하나로 답변의 모든 "2"가 지워지면 가드가 통째로
무력화된다(회귀 테스트 포함).
2) nhc_active_storms/eonet_events가 GROUNDING_TOOL_PATTERN에 없었다.
둘 다 권위 있는 원본(미 국립허리케인센터, NASA EONET) 직결이고 태풍 질문이
실제로 도는 도구인데, 08-09에 추가한 MEASUREMENT_UNITS/COORDINATE_CLAIM이
바로 그 도구들이 돌려주는 값(hPa·m/s·북위)에 반응한다. 그래서 NHC 데이터로만
답한 턴이 "근거 도구 미호출"로 분류돼, 방금 읽은 권위 있는 출처를 의심하라고
web_search 재시도를 걸었다. memory_stats 때와 같은 구멍이다.
3) 긴 붙여넣기 문서 본문의 우연한 키워드로 강제검색이 걸렸다.
7,583자짜리 고소장 작성 요청이 본문 한가운데(2578자 지점)의 "시세"(장물
시세) 때문에 isLiveDataRequest에 걸렸다. 문서를 써 달라는 턴에 "메모리에서
답하지 말고 검색부터 하라"는 리마인더가 붙는 건 토큰 낭비를 넘어 작업 방향을
반대로 민다.
처음엔 "길다 + isExecutionLikeRequest"로 막으려 했는데 회귀 테스트가 그게
틀렸음을 잡아냈다 — 그 메시지가 실행형으로 분류된 근거가 본문의 "화면" 한
단어였다. 막으려는 문제와 똑같은 우연 위에 얹은 수정이었다. 대신 위치를
본다: 사람은 지시를 자료의 앞이나 뒤에 쓰지 한가운데 묻지 않는다.
600자 초과 메시지는 앞 400자 + 뒤 300자만 스캔한다(instructionZone).
검증 강제 게이트끼리 예외가 어긋나면 안 되므로 isFactualInfoRequest에도
같이 적용했다.
실사용 104건 대조: isLiveDataRequest 오탐 3건 제거하고 정당한 25건은 전부
유지, 출력측 가드는 로그의 그 좌표 케이스가 정확히 하나 걸러졌다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
이 블록은 470토큰까지 커져 무조건 블록 중 두 번째로 큰 항목이었는데,
실제로 모델이 \ce{}나 ```smiles를 내보낸 건 로그 83일 중 3일(~4%)뿐이었다.
나머지 96%의 턴에 값을 치르는 쪽이 더 나쁜 거래다.
이 파일 규칙 1("도구로만 게이팅, 메시지 문구로는 절대 금지")에 대한 의도적
예외다. 출력 형식 제약이라 키로 삼을 도구가 애초에 없고, 무엇보다 대가의
크기가 다르다 — image_edit 규칙이 빠지면 위험한 도구 호출이 나가지만, 이건
놓쳐봐야 깨진 ASCII 다이어그램 하나다. 파일 헤더의 규칙 2도 "무조건 유지"에서
이 예외를 설명하는 쪽으로 고쳐 적었다.
키워드는 일부러 넓게 잡았다. 애초에 무조건 유지했던 이유가 "치과/의료 질문은
화학 키워드 없이도 구조를 그리게 될 수 있다"였고 그게 이 사용자의 실제
도메인이므로, 성분·약물·마취·불소·아말감·레진·모노머·resin·anesthe 등을
함께 넣었다.
실측: 비화학 턴 시스템 프롬프트 1708 → 1533 토큰.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"지금 비오는 곳이 있나?"에 web_search를 부르긴 했는데, 돌아온 게 URL에
tm=2024.12.13.20:00이 박힌 2024년 캐시 페이지였다. 모델은 그걸 "현재"라며
그대로 소개했고, 표를 옮겨 적으면서 열까지 잘못 읽어 동두천의 강수량을
파주 것으로, 양평의 풍속을 강수량으로 뒤바꿨다.
기존 가드는 "근거 도구를 불렀는가"만 본다(hasGroundingToolCall). 불렀다는
사실만으로는 결과가 최신인지, 옮겨 쓴 값이 맞는지 전혀 검증되지 않는다.
그래서 결과 자체를 의심하라는 지침을 프롬프트에 넣었다:
- 검색결과가 자체 타임스탬프(URL의 날짜, dateline, "as of ...")를 갖고
있는지 확인하고 현재 날짜와 대조할 것. 하루이틀 넘게 묵었으면 "지금"이
아니므로, 옛 수치를 현재처럼 내놓지 말고 실시간 데이터를 못 찾았다고
말하고 찾은 날짜를 밝힐 것.
- 표는 위치가 아니라 행 레이블로 읽을 것. 한 행의 수치가 옆 행으로
밀리면 "못 찾음"보다 나쁘다 — 틀렸는데 권위 있어 보이기 때문이다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
세션 papa@ca71bc1c(로그 12804행): "마두로 미국에 잡혀가지 않았나?"에 세 턴
연속 도구 호출 없이 "체포되지 않았다"고 확신에 차 정정했는데, 실제로는
사용자가 맞았다(이미 미국에 체포·압송된 상태). 직전 커밋에서 체포/사임 등
개별 어휘를 isLiveDataRequest에 추가해 이 사고는 막았지만, 그 방식 자체가
화이트홀이었다 — 다음번엔 태풍도 체포도 아닌 제3의 화제에서 또 뚫린다.
화제별 어휘 목록 대신 "최근/지금/현재"가 들어가면 화제를 안 가리고 걸리도록
일반화했다. 근거는 실사용 로그 403건 전수조사: 이 세 표현이 들어간 66건
(16.4%) 중 오탐 0건 — 비트코인 시세·현직 대통령·최신 모델 버전 등 전부
정당한 실시간 조회였다. 새 레이어가 추가로 잡아낸 32건 중 31건도 마찬가지로
필요한 검색이었고, "지금 몇 시야?"만 이미 시스템 프롬프트에 있는 시각을
다시 검색시키는 사소한 낭비였다(오답은 아님). "요즘"은 실사용 예가 없어
후보에서 뺐다 — "요즘 어떻게 지내?" 같은 잡담과 부딪힐 위험을 검증 없이
감수하지 않는다.
넓히는 과정에서 진짜 버그 두 개가 드러났다:
- isLiveDataRequest가 isExemptFromVerification(지서버/클로서버 등 개인
인프라 예외)을 애초에 한 번도 거치지 않고 있었다. 파일 상단 주석은 "모든
검증 가드가 이 예외를 자동으로 적용받는다"고 주장했지만 사실이 아니었다.
시점 표현을 넓게 걸자 "지서버 최근에 왜 이렇게 느려"가 웹검색 리마인더에
걸릴 뻔하면서 드러났다.
- "시어엔진"(SearXNG 별칭)이 예외 목록에 없었다. 실사용 로그의 "현재
시어엔진 상태 어때?"는 웹검색으로 검증 불가능한 로컬 인프라 질문이다.
둘 다 함께 고쳤다. 부수 효과로, 오타("잡ㅎ간") 때문에 이전 커밋의 체포 어휘
목록이 못 잡던 마두로 사건의 첫 턴도 "현재" 표현으로 이제 잡힌다.
코딩/실행형 요청(isExecutionLikeRequest)과 인사말(isGreetingLikeMessage)은
계속 제외한다 — "지금 이 코드 확인해줘"에 검색 리마인더가 끼어들면 안 된다.
검증: tests/prompt-gates.test.ts 6개 추가, 196개 전부 통과. 실제 채팅에서
"비트코인 현재 가격이 얼마야?" → web_search 호출 확인, "지서버 지금 몇 개
모델 로드돼있나" → 웹검색 없이 로컬 처리 확인.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
지서버는 provider가 바뀌어도 항상 backendOnline=true(클라우드는 하드코딩,
ollama_local은 연결 성공 시)라서, 사이드바가 늘 "ollama_local Online" /
"OpenAI Online"을 표시했다. 초록 점이 이미 온라인을 뜻하는데 그 옆에 같은
정보를 글자로 반복했고, 길어서 좁은 사이드바에서 줄바꿈까지 일으켰다.
온라인일 때는 provider 이름만 남기고, 절전 중/오프라인처럼 실제로 알려줄
정보가 있을 때만 상태 단어를 붙인다. 어떤 provider든 동일한 조건문을 타므로
클라우드에도 자동 적용된다(브라우저에서 "OpenAI"로 확인).
설정 탭의 런타임 정보 행(#r-ollama)은 라벨-값 쌍의 전용 상태 표시라 손대지
않았다 — 값이 비면 어색해진다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
세 계정(papa/cherry/jasmine)의 vault.key와 vault.enc가 저장소에 함께 추적되고
있었다. vault.key는 vault.enc를 여는 마스터 키라 둘이 같은 저장소에 있으면
암호화가 무의미하다. 복호화해보니 담긴 값이 메인 vault와 해시까지 동일한
현재 사용 중인 자격증명이었다 — 이메일 비밀번호 3개와 Unsplash/Pexels 키.
원인은 .gitignore의 `.smallclaw/vault/`가 저장소 루트에 앵커된 패턴이라
한 단계 아래의 사용자별 vault를 못 잡은 것. 아래쪽 `.smallclaw/users/*/workspace/`
규칙이 지금은 이 경로를 덮지만 gitignore는 이미 추적 중인 파일에는 적용되지
않으므로, 그 규칙이 추가되기 전에 커밋된 이 파일들은 계속 남아 있었다.
- git rm --cached (디스크 파일은 유지 — 서비스가 그대로 읽는다)
- `**/vault/vault.key|vault.enc|vault-audit.log` 추가. 누구도 예상 못 한 경로에
새 vault가 생겨도 막히도록 깊이에 무관하게 매칭한다.
메인 vault(API 키·NVR·텔레그램 등 40개)는 기존 규칙에 걸려 유출되지 않았다.
주의: 히스토리에는 그대로 남아 있다. 이 커밋은 앞으로의 추가를 막을 뿐이며,
저장소가 2026-05-05부터 96일간 공개 상태였으므로 노출된 값은 이미 유출된 것으로
간주하고 회전해야 한다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>