Commit Graph
81 Commits
Author SHA1 Message Date
kimandClaude Sonnet 5 58a079fcb1 fix: nvr_snapshot 후속턴에 도구 사라지던 문제 + 캡처 이미지 채팅에 안 뜨던 문제
실사용(papa, 2026-09-07)에서 드러난 2건:
- "cctv확인해봐"로 도구가 돌았는데 다음 "아니다 cam2"/"사진 보여줘" 후속턴엔
  hasCctvKeyword가 현재 메시지만 보느라 도구가 빠졌고, 모델이 "저는 그런 툴이
  없습니다"를 3턴 반복 → topicText(직전 2턴 포함)로 검사, cam N·스냅샷·캡처 등 추가
- 캡처된 프레임을 모델이 프로즈로만 설명하고 이미지 마크다운을 안 뽑아 사용자가
  사진을 못 봄 → satellite_snapshot처럼 execute-tool에서 SSE로 이미지 직접 push
- 도구 결과 지시문을 "이전 대화 짐작 말고 이 프레임에 보이는 것만 구체적으로"로 강화
  (gemma4가 첫 응답에서 맥락에 기대 뭉뚱그린 사례)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
2026-09-07 11:14:24 +09:00
kimandClaude Sonnet 5 1a40512270 feat: nvr_snapshot — 모델이 집 CCTV 화면을 직접 보고 답한다
"마당에 누구 있어?", "택배 왔나 봐줘" 같은 요청에 NVR/독립카메라의 현재 프레임을
ffmpeg로 한 장 캡처해 비전 모델에 임베드한다.

- src/tools/nvr.ts: RTSP → ffmpeg 단일 프레임 → workspace/nvr-snapshots/ → ![](/api/files/…).
  카메라는 이름("현관")·채널번호("2")·자유입력 모두 해석, 못 찾으면 목록 반환.
  main 실패 시 sub 스트림으로 1회 재시도. 파일명은 ASCII(썸네일 execSync 한글경로 회피).
- routes/nvr.ts: getNvrConfig/getExtraCameras/getChannelLabels export + buildRtspUrl 추출
  (라이브 스트림 라우트와 공유, 중복 제거).
- tool-scope.ts: hasCctvKeyword 게이트 — CCTV/카메라/마당/현관/택배/"누구 있" 등에만.
- handle-chat.ts: 비전 판정을 _modelSupportsVision 헬퍼로 통일 + **gemma 추가**
  (gemma4:31b-cloud는 멀티모달인데 4곳의 정규식에서 다 빠져 있어 weather_map_screenshot
  등에서도 "이미지 못 봄" 취급받고 있었음 — 2026-09-07 실측으로 비전 정상 확인).
  _imagingTools 3곳에 nvr_snapshot 추가.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
2026-09-07 11:05:13 +09:00
kimandClaude Opus 5 ddd276c15a docs: 수동 메모리 게이팅의 근거 정정 — USER.md는 prompts/ 아래 실재한다
a1033f6에서 "USER.md도 SOUL.md도 4명 전 사용자 누구에게도 없어서 memory_browse/read는
'not found' 에러만 낼 수 있다"고 적었는데 **틀렸다**. 감사 때 `ls workspace/*.md`로만 확인했고,
파일은 `workspace/prompts/` 아래에 있다 — resolvePromptPath가 PROMPT_FILES에 대해 그쪽을
**먼저** 본다(server.ts:1199). papa의 USER.md는 3.2KB에 카테고리 5개, 최종 수정 2026-08-06으로
homeclaw_memory 컬렉션의 최신 문서 날짜와 정확히 일치한다. 도구 셋 다 정상 동작한다.

게이팅 결정 자체는 유지한다 — 근거가 사용량이기 때문이다(1,011턴에서 browse 0 / read 2 /
write 1회인데 매 턴 357토큰). 다만 코드·테스트 주석에 박힌 거짓 근거를 걷어낸다. "죽은
레거시라 빼도 된다"와 "멀쩡하지만 거의 안 쓰여 게이팅한다"는 다음 사람이 내릴 판단이 달라진다.

memory_write는 파일이 없으면 만들지 않고 에러를 낸다는 점도 함께 정정했다(execute-tool.ts:1220).
앞선 커밋 메시지의 "없으면 새로 만든다"는 서술이 잘못이었다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
2026-09-04 21:12:21 +09:00
kimandClaude Opus 5 a1033f6013 perf: USER.md 수동 메모리 도구 3종을 기억 의도가 있는 턴에만 싣는다
memory_browse / memory_write / memory_read는 지금까지 **매 턴 무조건** 실려 357토큰을 냈다.
실사용 1,011턴에서 호출은 browse 0회 / read 2회 / write 1회.

파고 보니 단순한 저사용이 아니었다: **USER.md도 SOUL.md도 4명 전 사용자 누구에게도 없다.**
resolvePromptPath에 템플릿 폴백이 없으므로(교차 오염 위험은 없다), memory_browse와 memory_read는
파일이 없어 "USER.md not found. Create it first." 에러만 돌려줄 수 있는 상태로 매 턴 실려 있었다.
실제 장기기억은 일별 로그 + Chroma 벡터 추출(자동)이 담당한다 — USER.md 경로는 그 시스템이
대체한 레거시다([[project_legacy_skill_system]]과 같은 유형: 죽은 하위시스템이 스키마 비용만 계속 냄).

제거가 아니라 게이팅인 이유: 사용자가 "기억해둬"라고 명시하는 턴에는 있어야 하고, memory_write는
파일이 없으면 새로 만들므로 그 경로 자체는 지금도 유효하다. topicText로 판정해 "그것도 기억해둬"
같은 후속 발화도 잡는다. memory_stats는 벡터 스토어를 보므로 게이트 대상이 아니다(13회 호출됨).

실측(고유 782턴): 도구 토큰 중앙값 1,221 → 864 (-357/턴), 누적 278,460tok 절감.
수동 메모리 도구가 실리는 턴은 0.3%로 떨어진다.

기존 계약 테스트 2개가 "memory_read는 핵심 도구라 항상 남는다"를 고정하고 있어 갱신했다 —
핵심 도구 목록에서 memory_read를 memory_stats로 바꾸고, 기억 의도가 있으면 다시 나타나는지를
새로 고정했다. 551개 통과.

⚠ 별건: 이번에 사용자가 "기억해둬"라고 5턴에 걸쳐 명시한 세션(0c367bd7 10925~10940)에서
memory_write가 한 번도 불리지 않았음을 확인했다. 모델은 매번 "기억해 두겠습니다"라고만 했다.
정보 자체는 일별 로그에 남아 벡터 추출이 받지만, 명시적 지시가 도구 호출로 이어지지 않는 것은
별도 문제다(MESSAGING 가드와 같은 유형의 도달 실패). 이 커밋은 그 문제를 고치지 않는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
2026-09-04 21:05:16 +09:00
kimandClaude Opus 5 46a5c3f59f perf: 검색 결과 읽기 지침을 시스템 프롬프트에서 빼 결과가 생긴 뒤에만 붙인다
효율 감사 실측(사용자 턴 1,011건 / LLM 호출 3,613건 / prompt 누적 29.1M tok):
  · 고정비 2,494tok/턴 = 전체 prompt의 31%
  · 출력 60토큰 미만 호출이 47% — 도구 라운드·가드 재프롬프트라 고정비가 라운드 수만큼 곱해진다
  · **59%의 턴이 grounding 도구를 한 번도 부르지 않는다**

searchReadingRule(481tok)에는 성격이 다른 두 가지가 한 덩어리로 섞여 있었다:
  ① 검색어를 어떻게 쓸 것인가(세대 추측 금지, 154tok) — 검색 **전에** 필요
  ② 돌아온 결과를 어떻게 읽을 것인가(스테일 타임스탬프·표 행 정렬, 333tok) — 결과가 **생긴 뒤**
     에만 쓸모가 있다
②를 groundingResultReadingRule()로 분리해, handle-chat이 첫 grounding 결과가 들어온 시점에
대화 뒤에 한 번 덧붙인다. 읽을 결과가 없는 59%의 턴은 이제 이 값을 아예 내지 않고, 부르는
턴도 검색어를 쓰는 1라운드에는 내지 않는다. 인사 턴 시스템 프롬프트 1,273 → 904tok(-29%).

**시스템 메시지를 갈아끼우지 않고 뒤에 덧붙인 이유는 KV 캐시다.** 시스템 프롬프트는 라운드 루프
앞에서 한 번 만들어지므로, 라운드마다 다시 만들면 프리픽스가 달라져 캐시가 통째로 무효화된다 —
라운드마다 7K 토큰을 다시 계산하게 되어 아낀 것보다 잃는 게 크다. 뒤에 붙이면 프리픽스는 그대로다.

부수 효과로 지침이 대상(검색 결과) 바로 옆에 놓인다. 제약이 겹치면 먼 것부터 버리는 모델에게는
이쪽이 유리하다([[feedback_local_model_needs_code_backstop]]).

2026-09-02 감사가 만든 hasGroundingTools 게이트는 사실상 죽어 있었다는 점도 이번에 드러났다 —
tool-scope에 web_search를 거르는 분기가 없어 그 조건은 항상 참이다. 이번 분리는 그 게이트에
의존하지 않는다.

기존 회귀 테스트가 이동을 정확히 잡아냈다(2026-08-10 캐시된 기상표 오독 사고의 예시 문자열).
테스트를 새 위치로 옮겨 내용이 사라지지 않았음을 계속 고정한다. 546개 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
2026-09-04 20:59:35 +09:00
kimandClaude Opus 5 b2075c62d8 fix: buildDirectPriceAnswer가 무관한 달러 값을 "Answer:"로 합성해 붙이던 문제
이 함수가 만드는 줄은 검색 결과 stdout 맨 앞에 붙는다. 시스템 프롬프트의 ANTI-HALLUCINATION은
"도구가 돌려준 것을 정확히 보고하라, 절대 무시하지 말라"고 지시하므로, 여기서 틀리면 모델에게
틀린 값을 신뢰하라고 시키는 셈이다. 게다가 합성된 숫자는 도구 출력 텍스트의 일부가 되므로,
모델이 그대로 옮기면 NUMERIC-GROUNDING이 "소스에 있다"며 통과시킨다 — 우리가 만든 숫자가
'근거 있음' 도장을 받아 나간다.

실제 사고(프로덕션 로그):
  USER: 컨테이너선 급유비가 얼마나 될까?
  TOOL: web_search("average fuel cost for 20,000 TEU container ship per voyage")
  TOOL OK: Answer: The current price is approximately $38.00 USD.
$38은 스니펫에 있던 "신조선 7일 항해 시 TEU 1개당 추가 연료비"였고, 실제 답은 항해당 수백만
달러다. 그 턴은 모델이 결과 [1]을 직접 읽어 스스로 바로잡았지만 그건 운이다.

* isPriceQuery에 단어 경계가 없어 통화·가격 낱말이 다른 단어 **안에서** 걸렸다. "eur"가
  "Europe"·"Neuralink"에, "cost"가 "costume"에, "value"가 "valuable"에 들어맞는다. 실사용
  고유 검색어 195건 중 19건이 통과했고 그중 8건이 가격과 무관했다("Europe drought status
  August 2026", "Neuralink Blindsight resolution pixels" 등). 경계를 넣어 19 → 11건.

* generic 자산 경로를 제거했다. 금·은·비트코인은 타당성 범위(온스당 300~10,000 등)와 자산명
  대조가 성립해 판정이 의미가 있다. generic은 둘 다 없다 — 허용 범위가 $0.5~$5,000,000이라
  사실상 모든 달러 표기를 받고, 문구도 무엇의 가격인지 말하지 못한 채 "The current price"라고만
  쓴다. 이 로그 표본에서 generic 경로가 실제로 답을 낸 유일한 사례가 위 컨테이너선 건이다.

* 자산명 대조를 가점(+2)에서 **필수 조건**으로 올렸다. 가점일 때는 자산을 한 번도 언급하지
  않은 스니펫의 숫자가 score 0으로 통과했다 — "금 시세" 질문에 다른 상품 가격이 답으로 나갈
  수 있다는 뜻이다. 티커(XAU/XAG/BTC)도 함께 본다.

순효과(실사용 고유 검색어 195건): 발동 19 → 11건, 그중 실제로 "Answer:" 줄이 생길 수 있는
것 0건. 이 표본의 가격 질문은 전부 GPU·옵션·펌프 같은 generic 대상이라, 원래도 이 기능이
맞는 답을 낸 적이 없다.

테스트 544개 통과(+8).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
2026-09-04 18:06:31 +09:00
kimandClaude Opus 5 10e54d38eb fix: 게이트 전수 감사 — 한국어 검색 랭킹이 관련성을 0으로 계산하던 버그 외 5건
실사용 로그의 사용자 메시지 1,011건(고유 782건)을 모든 게이트에 통과시켜 오탐/미탐을
실측했다([[feedback_calibrate_on_real_data]]).

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

나머지:

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
2026-09-04 18:01:26 +09:00
kimandClaude Opus 5 db6a8634c3 fix: 가드가 맞는 답을 지우던 경로 — 무관한 검색 결과에 대고 판정하던 문제
2026-09-04 메인챗 세션(0c367bd7) 감사. 사용자가 "옵션 프리미엄이 실물의 몇 %냐"를
다섯 턴에 걸쳐 물었고, 검색 6회에 산출물 0건으로 끝났다. 로그를 보니 원인이 모델이
아니라 우리 가드였다:

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
2026-09-04 17:51:12 +09:00
kimandClaude Sonnet 5 e658f6a634 fix: 사진 요청이 AUTO-RECOVER 재프롬프트 루프를 타던 문제
앞 커밋(2537b04)에 이어진 후속. papa "눈향나무 사진" 재현 로그:
  SKIP: duplicate search_images / AUTO-RECOVER (2/2) "live-data query answered
  without a grounding tool call" / LOOP WARN: search_images x3

원인: 모델이 사진 대신 눈향나무 설명 프로즈를 냈는데 그 안의 "5cm·30cm·500년"이
looksLikeUnverifiedSpecClaim에 걸려 unverifiedSpecClaim→liveDataRequest가 참이 됨.
search_images는 GROUNDING_TOOL_PATTERN에 없어(citable text 아님) hasGroundingToolCall
false → 재프롬프트 → gemma가 search_images 반복 호출.

수정: decideAutoRecover 앞에 사진 요청 + search_images 호출 시 재시도 안 함 단락.
이미지 누락은 appendDroppedSearchImages가 복구하므로 재프롬프트가 고칠 게 없음.
대조군 테스트로 unverifiedSpecClaim 가드 자체는 유지 확인. 474 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdchNLTHsyYahp2PVQjoym
2026-09-03 16:06:03 +09:00
kimandClaude Sonnet 5 2537b04c7a fix: search_images 결과를 모델이 프로즈로 바꿔치기해 사진이 안 나오던 문제
papa 세션 실측(2026-09-03): "눈향나무 사진 찾아줘" → search_images가 ![alt](url)
3개를 정상 반환했는데 gemma4가 "눈향나무 사진들입니다" + 설명만 내고 링크를 통째로
누락. 다음 턴 "사진이 어딧지?"에도 "죄송합니다..." 하며 또 0개.

- appendDroppedSearchImages(reply-content.ts): 최종 답변에 이미지 마크다운이
  하나도 없고 이번 턴 search_images가 성공했으면, 도구 결과의 ![](url)를 뒤에 붙임.
  하나라도 있으면 모델이 시도한 것으로 보고 안 건드림
- personality-context photo 프롬프트: "![alt](url) 줄을 VERBATIM으로 복사, 프로즈로
  대체 금지"로 강화 (코드가 backstop, 프롬프트는 보조 — [[feedback_local_model_needs_code_backstop]])

참고: 눈향나무 같은 특정 수종은 Pexels/Unsplash 커버리지가 나빠 "빨간 배경 분재"가
매칭되기도 함 — 그건 스톡 API 한계로 별개 이슈.

tests +5 (473 통과).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdchNLTHsyYahp2PVQjoym
2026-09-03 16:00:42 +09:00
kimandClaude Sonnet 5 b0c098e76d feat: drug_dur_check — 식약처 DUR 의약품 안전정보 조회 도구
닥터앱 "복약·검사" 탭이 핵심인데 약 정보를 조회할 수단이 없어, 상호작용·금기를
모델이 기억으로 답하거나 web_search로 블로그를 물어오고 있었다.

- data.go.kr DURPrdlstInfoService03(품목 기반) 연동. 제품명 부분일치로
  병용금기/임부금기/특정연령대금기/용량주의/투여기간주의/효능군중복/분할주의 조회
- 병용금기는 상대 성분 + 사유별로 묶어 요약(제품 단위론 수백 행이라 무의미)
- getOldSderTabooInfoList03(노인주의)는 API가 폐기 응답 → 목록에서 제외
- 성분 기반 서비스(DURIrdntInfoService03)는 이 키에 미승인 상태라 미사용
- 게이트: 닥터앱(dr_) 세션 무조건 + 일반 챗은 약물 상호작용 문맥 키워드
- 키는 vault(mfds.dur_api_key), config.json엔 참조만. SECRET_FIELD_MAP 등재

tests +2, 468개 통과. 실 API로 타이레놀·심바스타틴 병용금기 확인.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdchNLTHsyYahp2PVQjoym
2026-09-02 17:11:28 +09:00
kimandClaude Sonnet 5 7b0223b071 feat: 닥터앱 탭에 학술 검색 도구(PubMed/OpenAlex) 개방
닥터앱은 기본 도구만 받아서 "이 약 장기복용 괜찮나?", "이 백신 효과 몇 년?",
"이 수치 정상 범위인가?" 류 질문에 web_search로 블로그를 물어오고 있었다.
academicToolNames(pubmed_search/pubmed_fetch/pubmed_fulltext/openalex_search/
semantic_search)를 dr_ 세션 안에서만 키워드 없이 개방 — lawyer 탭이 법령 도구를,
investor 탭이 news_search를 무조건 받는 것과 같은 앱-탭 패턴.

- dr_main / dr_case_<id> / dr_<rand> 전부 dr_ 접두사라 한 번에 잡힘
- 메인챗은 불변(키워드 게이트 그대로) — 테스트로 누출 없음 확인
- 앱 상단 면책("진단·처방 대체 아님")은 유지되므로 페르소나 불변
- 프롬프트 추가 비용 0 (hasGroundingTools는 web_search로 이미 항상 참)

tests +1, 467개 전부 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdchNLTHsyYahp2PVQjoym
2026-09-02 16:10:35 +09:00
kimandClaude Sonnet 5 e12a327c9b fix: 탐정·투자 앱 탭에 세션 게이트 누락 — 탭 안에서 전문도구가 0개였음
앱 탭은 세션 접두사로 무조건 게이팅하는 설계인데(탭 안 후속질문은 키워드가 없어도
그 앱의 맥락이므로), 접두사를 쓰면서 게이트가 없는 앱이 둘 있었다.

- detective-app.html → 'dt_' : 페이지에 판례 12회·법률 11회 언급인데 법령 도구가
  하나도 안 실렸다. lawyer 탭과 같은 도구군이 필요 → 법령 3종 + news_search 개방.
- investor-app.html → 'iv_' : 환율 6회·주가·증시·엑셀·뉴스. "그럼 작년 대비?" 같은
  후속질문에 읽을 게 아무것도 없었다 → news_search + excel 개방.

doctor('dr_')·language('lg_')는 의도적으로 제외 — 전자는 개인 의료기록 앱
(기록 검색/복약·검사 항목), 후자는 튜터라 기본 세트로 충분하고 전문 스키마는 순비용.

메인챗 회귀 없음(키워드 없으면 그대로 닫힘)을 테스트로 고정. tests 466 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZX9JHFSLZRuK9rR4sCVBs
2026-09-02 15:22:21 +09:00
kimandClaude Sonnet 5 1ef06637a5 fix: 레거시 스킬 토글이 도구를 막던 문제 — 키워드 게이트로 일원화
사용자 확인: 스킬 on/off UI는 예전 것이고 지금은 앱별 게이팅을 쓴다. 그런데
skills_state.json이 tool-scope의 meteorologist/lawyer/presenter 게이트를 통해
여전히 도구를 **막는** 유일한 소비자로 남아 있었고, 아무도 갱신하지 않는 값이라
조용한 기능 상실을 만들고 있었다.

실측된 라이브 영향: papa는 meteorologist:false → "오늘 서울 미세먼지 어때?"에
weather_kma / weather_openmeteo 만 실렸다. weather_kma엔 대기질 데이터가 아예
없으므로(모델 프로필에도 명시돼 있음) 답은 실패 아니면 창작 둘 중 하나였다.
바로 옆에 있던 weather_airkorea(에어코리아, 키 승인·정상)가 죽은 플래그에 막혀 있었음.

- meteorologist/lawyer/presenter 스킬 조건 제거 → 키워드 게이트만 사용
- ToolScopeInput.isSkillEnabledForUser는 유지(앱세션 게이트용, 스킬이 실제
  프롬프트 파일을 갖게 되면 다시 쓸 자리)
- 검증: papa·jasmine 모두 미세먼지/발표자료/판례 도구 정상 노출

tests 463 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZX9JHFSLZRuK9rR4sCVBs
2026-09-02 15:08:22 +09:00
kimandClaude Sonnet 5 99d3d387bc fix: 법령 "100분의 N" 표기를 %와 동일하게 인정 (오탐 재측정 후속)
과거 NUMERIC-GROUNDING 발동 20건을 현재 코드로 리플레이한 결과:
- 날씨 4건(87%/55%/65%/70~90%, weather_openmeteo 표) → 전부 통과로 전환 ✅
- 진짜 날조 1건(weather_kma에 없는 "예보 불확실성 40%") → 여전히 발동 ✅
- 나머지 15건 중 7건이 하나의 연금 감액률 스레드였는데, 한국 법령·행정규칙은
  퍼센트를 "%"가 아니라 "100분의 N"으로 쓴다 → 답변 "50%"가 출처 "100분의 50"과
  대조되지 못해 매번 오탐. unitVariants의 % 목록에 "100분의" 추가.

출처에 없는 비율은 여전히 발동함을 테스트로 고정. tests +2, 463개 전부 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZX9JHFSLZRuK9rR4sCVBs
2026-09-02 14:42:22 +09:00
kimandClaude Sonnet 5 58f77152a8 perf: 프롬프트/도구 스키마 고정비 12% 절감 (효율 감사 ①②③)
실측: LLM 호출 3,409회 중 47%가 출력 60토큰 미만(도구 라운드/가드 재프롬프트)이고
매 라운드가 프리픽스를 통째로 재전송 → 고정비 절감이 라운드 수만큼 곱해짐.

① system-prompt: TEMPORAL CONSISTENCY(2,463자)와 OUTPUT FORMAT(1,460자)이 무조건
   블록이었는데 문장 대부분이 "검색 결과를 어떻게 읽고 표시할지"라 그라운딩 도구가
   없는 턴엔 사문. searchReadingRule / newsFormatRule / weatherFormatRule로 분리해
   도구 목록으로 게이팅. 날짜=ground truth와 "3개 이상이면 표"는 무조건 유지.
② tool-scope: ERA5/CDS/CMIP6/NASA POWER는 기후 재분석·시나리오 아카이브인데 평범한
   날씨 키워드에 실려 "오늘 날씨"마다 820토큰 낭비 → climateToolNames + 기후 키워드
   게이트 신설. 일상 예보(kma/openmeteo/search/airkorea)는 그대로.
③ news_search query 파라미터 설명 1,900자→640자. 사고 경위 서술을 규칙만 남기고 압축
   (경위는 git 이력과 project_search_query_routing에). 스키마 2,978→2,089자.
④ 죽은 가드 7종은 제거하지 않음 — browser/desktop/messaging은 의도적 휴면이고
   EMPTY-GROUNDING은 유닛테스트로 정상 동작 확인(검색이 늘 뭔가 반환해서 드물 뿐).

절감 실측(스키마+프롬프트 합):
  인사/일반 3,213→2,847 (-11%) | 날씨 5,254→4,279 (-19%) | 뉴스 4,174→3,594 (-14%)
  전체 평균 -12%

tests +2 (게이팅 계약을 테스트로 고정), 461개 전부 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZX9JHFSLZRuK9rR4sCVBs
2026-09-02 14:36:33 +09:00
kimandClaude Sonnet 5 885bab088b fix: NUMERIC-GROUNDING 가드 전면 점검 — 철자단위·표형출처·숫자경계
프롬프트 가드 전체 감사 중 numeric-grounding에서 오탐 3종 발견·수정:

1. 철자 단위 불일치: 출처가 "25 percent"/"24 gigabytes"인데 답변이 "25%"/"24GB"면
   근접매칭이 실패 → 오탐. unitVariants()로 %↔percent↔퍼센트, GB↔gigabyte↔기가바이트,
   토큰↔tok, tok/s↔tps↔초당 등 동의 표기 허용.
2. 표형 출처(어제 커밋의 weather 범례) 보강 + 정직한 주석: 큰 표는 사실상
   fabrication 체크 면제됨(실패모드는 wrong-row인데 그건 원래 못 잡음).
3. 숫자 부분일치: "5"가 "157.66" 안에서 매칭돼 날조를 가려주던 문제 →
   숫자 매칭에 경계 적용 (앞: 숫자/점 금지, 뒤: 숫자 금지·점은 허용해 반올림 수용).

감사 결과 나머지 가드(AUTO-RECOVER, EMPTY/SEARCH-ABANDONMENT, MESSAGING,
INTENT-ONLY, 재시도예산)는 정상. tests +2, 전체 459 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZX9JHFSLZRuK9rR4sCVBs
2026-09-02 14:21:29 +09:00
kimandClaude Sonnet 5 61cfb180f1 fix: 표형 도구출력에서 NUMERIC-GROUNDING 오탐 → gemma "죄송합니다" 루프
weather_openmeteo 등은 단위를 헤더 범례에만 쓰고("precipitation_probability=%")
값은 열에 맨숫자로 넣는다. checkNumericGrounding의 근접매칭(40자)이 "55"와 "%"를
멀다고 판단 → 정확한 강수확률·습도 답변마다 NUMERIC-GROUNDING POST-CHECK 재프롬프트가
걸렸고, 재프롬프트 지시문이 "죄송합니다, 임의의 수치를 사용했습니다"류 답변을 유도했다.
사용자 신고: "메인챗에서 젬마가 찾은 결과 안 보여주고 죄송합니다만 함".

- numeric-grounding.ts: hasUnitLegend() 추가 — 출처가 "<name>=<unit>" 또는 "단위:" 범례를
  쓰는 표면, 근접매칭 실패 시 "숫자가 독립 토큰으로 출처에 존재"만 확인(열 값 매칭).
  표에 아예 없는 수치는 여전히 잡힘.
- handle-chat.ts: NUMERIC-GROUNDING 재프롬프트에 "죄송합니다로 시작하지 말고 결과에
  뭐가 있었는지 명시하라" 추가.
- tests +2. 전체 458 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZX9JHFSLZRuK9rR4sCVBs
2026-09-02 13:48:40 +09:00
kimandClaude Sonnet 5 88ba78c537 fix: 메인챗 두 가지 — 검색어 세대 추측 + "잠시만 기다려" 후 정지
2026-09-01 맥미니 M5/M6 세션 사후분석에서 나온 두 버그.

1. 세대 추측 검색어 오염: 사용자가 "맥미니 신형"(칩 미지정)이라 물었는데
   gemma4가 학습시점 지식으로 web_search("M4 Mac mini …")를 던졌고, SearXNG는
   시킨 대로 M4(2024) 기사를 정확히 반환 → 답 전체가 구세대 기준으로 틀어짐.
   - system-prompt TEMPORAL CONSISTENCY에 "신형/최신인데 세대 미지정이면
     쿼리에 기억 속 세대를 넣지 말고 현재 연도로 검색" 규칙
   - handle-chat: hasUnversionedNewestIntent + assumedGenerationTokenInQuery로
     감지해 1회 재프롬프트(오염된 검색 실행 전 차단)

2. "잠시만 기다려 주세요" 후 턴 종료: 사용자가 오류 지적하자 모델이
   "다시 확인해 보겠습니다. 잠시만 기다려 주세요."만 내고 도구 0개로 정지.
   isIntentOnlyReply는 답 속 "2026년 9월"을 실질내용으로 오인, lastRoundWas
   GuardReprompt도 false라 기존 넛지 미발동.
   - isDeferralPromiseReply 신규(좁은 "wait for me" 패턴), 가드 재프롬프트
     선행 없이도 1회 강제 continuation

tests/intent-only-reply.test.ts +12 케이스. 전체 456 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZX9JHFSLZRuK9rR4sCVBs
2026-09-01 17:29:19 +09:00
kimandClaude Sonnet 5 493a37eb9e feat: 위치/장소 질문에 구글맵 인라인 임베드 유도하는 MAPS 시스템프롬프트 블록
markdown.js는 이미 독립된 줄의 google.com/maps URL을 keyless 임베드
iframe으로 렌더하고 handle-chat.ts는 그 URL의 browser_open을 가로챈다.
빠져 있던 건 "언제 그 URL을 내놓을지" 모델에게 알려주는 지시뿐이었음.

CHEMISTRY NOTATION과 같은 메시지 게이팅 예외로 추가 — 출력 형식 제약이라
키로 삼을 도구가 없고, 키워드를 놓쳐도 대가가 지도 하나 안 뜨는 것뿐.
/maps/search·/maps/place 형식만 유도(마크다운 변환기가 /maps/dir 미지원).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBDYbeeazVwgQ7RruCXRgY
2026-08-31 11:31:21 +09:00
kimandClaude Opus 5 7ac143652a fix: 논문 도구가 발표자료 키워드에만 묶여 있어 일반 채팅에서 안 켜지던 문제
pubmed_search·pubmed_fetch·pubmed_fulltext·openalex_search·semantic_search
다섯 개가 hasPptxKeyword에만 걸려 있었다. "논문 찾아줘"로는 스키마가 아예
안 실려서 모델이 web_search로 때웠다 — 실사용 로그에서 확인했다.

학술 의도 자체를 게이트로 추가했다. 학술 검색은 web_search보다 확실히 낫다:
openalex_search는 키 없이 2억 건에서 제목·저자·연도·저널·DOI·인용수·오픈액세스
여부를 구조화해 준다.

정규식에서 한 번 걸렸다 — `\bpaper\b`가 복수형 "papers"를 못 잡는다(뒤의 \b가
s 앞에서 끊긴다). papers?·citations?·journals?로 고쳤다. 한글 뒤에는 \b를 쓰지
않는다(ASCII 경계라 절대 매칭 안 됨 — 같은 파일의 hasEmailKeyword 주석 참고).

기존 pptx 경로는 그대로 열려 있다(논문 수집 → create_presentation).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 10:20:07 +09:00
kimandClaude Opus 5 4ac9919449 feat: 날씨에 도로 안개 추가 (CCTV 기반 도로날씨) — 안개 있을 때만 표시
안개는 ASOS·AWS·Open-Meteo 어디에도 없는 값이라 도로 CCTV 영상 분석이 유일한
소스다. 전국 593개 도로 지점에서 안개/비/눈 코드가 온다.

**앞선 판단 정정**: 08-15~18에 이 API가 0행이라 서비스 종료를 의심했는데,
소급 반영이 늦었던 것이었다. 지금 조회하면 08/17이 83,478행으로 채워져 있고
08/14도 57,247 → 67,937행으로 늘었다. "지금 0행"으로 종료를 단정하면 안 된다.

안개가 있을 때만 한 줄 붙인다 — 매번 "안개 없음"을 쓰면 소음이 된다:
  도로 안개: 보통 (CCTV 양재 관측, 13km · 11:20 기준)

어느 지점에서 몇 km 떨어진 값인지 밝혀 대표성 판단은 사용자에게 남긴다.
30km 밖이면 표시하지 않는다(도로변에만 있어 성기게 깔려 있다).

성능: 처음엔 응답이 7초 → 11초로 느려졌다. 조회 창을 90분에서 30분으로 줄이고
(지점 커버리지 584/593로 사실상 동일, 591KB → 87KB) 전국 한 벌을 5분 캐시해
구미 기준 7.1s → 2.7s로 되돌렸다.

같은 지점이 몇 분 간격으로 반복 보고되므로 지점별 최신 행만 남긴다.
야간에는 영상 분석이 없어 데이터가 안 나온다 — 안개 줄이 없는 게 정상이다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:54:10 +09:00
kimandClaude Opus 5 ae4439efba fix: 가드 재프롬프트에 예고만 하고 끝나는 답변을 한 번 밀어준다
"계산하다가 멈춘다"는 신고. 생성이 끊긴 게 아니었다 — 로그에는 답변이
끝까지 있었고, 그 답변의 내용이 예고문뿐이었다.

  USER: 우크라이나 전쟁 미사일 총 CO2?
  1. AUTO-RECOVER (1/2)        검증 안 된 수치 1070자 → 재프롬프트
  2. web_search 호출 → 결과 받음
  3. NUMERIC-GROUNDING (1/1)   "존재하지 않는다"가 검색결과에 없음 → 재프롬프트
  4. 모델: "죄송합니다… 다시 정밀하게 검색해 보겠습니다."  (도구 호출 없음)

3번 재프롬프트는 "다시 검색해서 그 결과로 답하라"고 지시했는데 모델은
예고만 했다. (1/1)이라 재시도 예산이 바닥이어서 그 예고문이 그대로 최종
답변으로 나갔다. 사용자 화면에는 계산하다 멈춘 것으로 보인다.

이걸 잡는 가드는 있었지만 상수 false로 꺼져 있었다:
  // DISABLED: Force continuation was causing excessive unnecessary tool calls
너무 광범위해서 멀쩡한 답변까지 재시도로 끌고 갔던 것이다.

그래서 **가드 재프롬프트 직후로만** 좁혀 되살렸다. 그 상황은 "실행하라고
지시했는데 예고만 함"이라 오탐 여지가 거의 없고, 평상시 답변은 건드리지
않는다. 한 턴에 한 번만 개입한다.

isIntentOnlyReply 판정도 좁다 — 400자 이내 + 미래형 예고 + 실질 내용 없음.
숫자·목록·표·코드·링크가 하나라도 있으면 실질 답변으로 본다:
  "계산해 보겠습니다. 총 배출량은 50,000톤입니다." → 통과
  "정리해 드리겠습니다.\n- 첫째\n- 둘째"          → 통과
  "다시 정밀하게 검색해 보겠습니다."               → 걸림

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:17:00 +09:00
kimandClaude Opus 5 c4911520f7 fix: 지오코딩 count 10→20 — "New York"이 네브래스카 York로 잡히던 문제
고르는 규칙(나라 명시 우선, 아니면 인구 최다)은 멀쩡했다. 문제는 후보
목록에 뉴욕시가 아예 없었던 것이다. count=10으로 받으면 응답이
York(네브래스카, 7,864명) / Florence(인디애나, 80명) / New York(영국) /
New York(자메이카)로 채워지고 인구 880만의 뉴욕시는 10위 밖으로 밀린다.
그래서 "남은 것 중 최다"가 네브래스카 York를 골랐다 — 에러 없이 조용히.

count=20이면 뉴욕시가 후보에 들어온다. 주요 도시 23곳을 10 vs 20으로
대조했고 바뀐 건 New York 하나뿐이다: York는 영국 요크 유지(뉴욕에
끌려가지 않음), Rome/Paris/London/Cairo/Sydney/Springfield 동일,
Los Angeles·Mexico City·Ho Chi Minh City 등 복합어 10곳도 동일.

후보 선택을 pickGeocodeResult()로 빼서 네트워크 없이 회귀 검증되게 했다.
당시 실제 응답을 픽스처로 넣고, 이 함수의 한계도 테스트로 명시했다 —
목록 밖의 정답은 구제할 수 없으므로 방어선은 호출부의 count이고, 둘을
한 몸으로 봐야 한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 17:34:21 +09:00
kimandClaude Opus 5 d0ddce1361 feat: weather_kma에 과거 날짜 조회(type="past") 추가
"어제 제주 비 얼마나 왔어"를 물었더니 도구에 과거 조회가 없어서 모델이
web_search로 새고, 2026년 달력·기상청 도움말 페이지만 긁어온 뒤 "정확한
수치를 찾아드리지 못해 죄송합니다"로 끝났다. 검색 실패가 아니라 도구
공백이었다. ASOS 일자료는 한 번의 호출로 그 값을 준다(제주 8/15 = 13.9mm).

date는 "어제"·"그저께"·"3일 전"·"2026-08-15"·"8월 15일"·"08-15"·"20260815"를
받는다. 기준은 KST — 서버가 UTC라 new Date()로 날짜를 뽑으면 한국 시각
09시 이전에 하루 어긋난다. endDate를 주면 일별 + 기간 합계를 낸다.

모델 실수 두 가지를 코드에서 흡수한다:
 - date만 주고 type을 빠뜨리는 호출이 흔해서, date가 있으면 past로 본다.
 - 오늘 날짜를 넣으면 그냥 실패시키지 않고 "일자료는 전날까지, 오늘은
   type=current" 라고 안내한다. 맨 실패로 두면 또 web_search로 샌다.

sumRn의 빈 값은 결측이 아니라 그날 비가 안 왔다는 뜻이다(시간자료 rn과
같은 규칙, 서울 8/10~8/13이 빈 값이고 그 기간 무강수인 것으로 확인).
처음엔 "관측값 없음"으로 내보냈는데 그대로 뒀으면 맑았던 날마다 모델이
"자료가 없습니다"라고 답했을 것이라 0으로 말하되 미량("0.0")과 구분한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 14:24:20 +09:00
kimandClaude Opus 5 d1bc379093 fix: 한국 지명을 관측지점 이름표로 푼다 — 지오코더가 66개 중 44개를 틀렸다
"독도 날씨"가 전남 고흥 관측소로 잡히길래 파봤더니 한 건이 아니었다.
KR_CITY_COORDS에 없는 ASOS 지명 66개를 기상청 공식 좌표와 대조한 결과
44개가 틀렸다 — 25개는 Open-Meteo 지오코더가 아예 못 찾고, 19개는
엉뚱한 좌표를 줬다.

  홍성 724km / 고산 629km / 보령 551km / 밀양 459km / 서산 / 홍천
      → 전부 북한 동명 지역
  동해 408km / 남원 246km / 제천 214km / 상주 188km / 남해 187km
      → 사람들이 실제로 묻는 도시들

이제 ASOS 97 + AWS 745 지점 이름표를 지오코딩 소스로 쓴다. 순서는
손으로 넣은 표 → 지점 이름표 → 지오코더이고, 한글이 섞인 입력만
이름표를 탄다(영문·해외 지명은 종전 경로 그대로). 지점 좌표는 기상청
발표값이라 이 문제가 구조적으로 안 생기고, 날씨를 묻는 맥락에서는
"그 지명의 관측소 위치"가 사실상 정답이다.

고친 뒤 ASOS 97개 전부 공식 좌표 20km 이내로 들어왔다(수정 전 53/97).

같은 이름이 100km 넘게 떨어져 둘씩 있는 3건(진안·옥천·가산)은 이름표에서
일부러 제외했다 — 조용히 한쪽을 고르면 틀렸을 때 드러나지 않는다. 대신
널리 통하는 행정구역 쪽을 KR_CITY_COORDS에 못박았다. 안 그러면 넘어간
지오코더가 옥천을 북한(39.94, 124.53)으로 보낸다. 버린 쪽은 전부 경기도의
동명 지점(진안 439, 옥천 449, 가산 473).

미해결로 남긴 것: 'New York'이 네브래스카의 York로 간다. 영문·해외 지명
쪽 지오코더 문제라 이번 변경 경로와 무관하다(한글 없는 입력은 이름표를
안 탄다).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 14:04:02 +09:00
kimandClaude Opus 5 6836b12210 feat: 오늘 누적 강수량을 AWS 매분자료 실측으로 전환 (23회 → 1회 호출)
AWS 매분자료에 RN-DAY(일 누적 강수량)가 그대로 들어 있다. 초단기실황을
시간별로 23번 불러 합산하던 걸 1회 조회로 대체한다. 격자 보간이 아니라
관측소 실측이고, 분 단위로 갱신된다.

지점은 ASOS(97개)가 아니라 AWS(745개) 중 최근접으로 따로 고른다.
ASOS에 묶어두면 시흥이 19.1km 떨어진 인천을 읽는데, AWS면 2.5km 시흥
관측소가 잡힌다(용인 17.3km → 처인역삼 1.2km). 실패하면 종전 초단기실황
합산으로 물러나므로 어디서든 값은 나온다.

디버깅에 시간을 쓴 함정 셋을 테스트로 고정했다:

  1) 컬럼이 위치로만 구분된다. RN-12H(12)를 RN-DAY(13)로 착각해서
     "AWS 9.8 vs 초단기실황 10.2"라는 가짜 불일치까지 만들어냈다.
     바로잡으니 서울·강릉은 소수점까지 일치한다.
  2) 마지막 행은 "지금 이 분"이라 아직 안 채워진 경우가 많다 — 전 컬럼
     -99.9. 맨 끝 행을 그대로 쓰면 관측이 멀쩡한데 전부 null이 되어
     폴백으로 샌다. 뒤에서부터 RN-DAY가 유효한 첫 행을 쓴다.
  3) 정시 RN-60m만 더하면 마지막 정시 이후 자투리가 빠진다. 부산에서
     27분 사이 4mm가 누락돼 "오늘 23.6 > 사상 19.6" 모순이 화면에 떴다.
     차액을 현재 시각 한 칸으로 넣어 시간 합이 RN-DAY와 맞게 만들었다.

RE(강수감지)는 전 지점 -99.9(null)라 사상 판정에 못 쓰고, 매분 RN-60m의
정시 값을 그 시간의 강수량으로 쓴다.

알려진 미해결: geocodeCity('독도')가 전남을 돌려줘서 엉뚱한 관측소가
잡힌다(AWS 목록엔 96번 독도가 실재한다). 이번 변경과 무관한 기존
지오코더 문제로, 구미 때 KR_CITY_COORDS를 만든 것과 같은 종류다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 13:32:52 +09:00
kimandClaude Opus 5 79bdcd4054 feat: ASOS 지점 좌표를 공식 목록으로 교체 + 좌표 입력도 관측값 사용
API허브 지점정보(stn_inf.php?inf=SFC)가 승인돼서 97개 지점의 공식
위경도를 받았다. 이걸로 nearestAsosStation을 붙여, 이름으로 못 맞히는
위치(좌표 입력, ASOS 없는 소도시)도 40km 이내 최근접 관측소를 쓴다.
이제 좌표든 이름이든 전부 기상청 실측이고, Open-Meteo 추정은 40km 안에
관측소가 없을 때(독도·먼바다)만 나온다.

같은 서울을 이름과 좌표로 물었을 때 사상 누적이 갈리던 비대칭도 이걸로
사라졌다. 최근접으로 잡은 경우엔 "기상청 산청 관측 (13.6km)"처럼 거리를
같이 내보내, 그 지점이 대표성이 있는지는 사용자가 판단하게 둔다.

앞서 지점명 지오코딩으로 좌표를 만들려던 시도가 왜 틀렸는지도 공식
좌표로 확인됐다 — 홍성·보령·밀양이 북한 동명 지역, 남원이 제주도,
남해가 충청으로 잡혔던 것들이 전부 제자리를 찾았다.

지점 표는 정적으로 박았다. 관측소 좌표는 거의 안 바뀌는데 런타임에
API허브를 타면 별도 키에 매 요청이 묶인다. 갱신은 주석의 URL을 다시
받아 표만 갈아끼우면 된다.

apiHubFetch/getKmaApiHubKey를 같이 넣었다. 키는 vault의 kma.apihub_key
(config.json 평문 아님, data.go.kr 키와 별개 계정). 인코딩이 응답 종류마다
달라서 여기 가둬뒀다 — 자료 본문은 EUC-KR인데 에러는 UTF-8 JSON이라,
에러까지 EUC-KR로 읽으면 "활용신청이 필요합니다"가 깨져 원인을 못 읽는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 13:22:21 +09:00
kimandClaude Opus 5 4c0e19a8ff fix: KMA_GRID 격자 3건 수정 — 구미는 45km 떨어진 산간을 읽고 있었다
ASOS 승인으로 관측 실측이 생겨서, 그걸 정답지로 KMA_GRID 표를
전수 감사했다. 검증 가능한 27개 도시 중 23개는 Δ2.1°C 이내였고
3건이 틀렸다.

  구미  [75,100] → [84,96]   ASOS 31.5°C인데 표는 25.3°C(Δ6.2).
                             45km 떨어진 산간 격자였다. 변환값은 Δ0.4.
  거제  [95,71]  → [90,69]   T1H가 기상청 결측 표식 -999. 자료가 아예
                             없는 격자였다. 변환값은 Δ0.2.
  시흥  [57,120] → [57,123]  안산 [58,121]보다 남쪽에 놓여 있었는데
                             시흥시(37.38°N)는 안산시(37.32°N)보다
                             북쪽이다. ASOS 지점이 없어 실측 대조는
                             못 하고(두 후보 다 값은 그럴듯했다 —
                             해안 23.9°C/94% vs 내륙 30.2°C/67%)
                             인접 도시와의 남북 순서로 판정했다.

인천(Δ4.5)·창원(Δ3.2)도 검사에 걸렸지만 표와 좌표 변환값이 동일해
손대지 않았다 — 격자 오류가 아니라 관측소 위치와 격자 중심의 차이다.

재발 방지로 KMA_GRID ↔ KR_CITY_COORDS 일치 테스트를 걸었다. 둘은
같은 도시를 격자와 좌표로 각각 적어둔 표라 서로 맞아야 하고, 이
대조는 네트워크 없이 결정적으로 돈다. 실제로 시흥을 이 테스트가 잡았다.

지점 좌표 공식 목록은 못 구했다. data.go.kr의 지점정보 서비스는 REST가
아닌 LINK형이라 엔드포인트가 없고(후보 21개 전부 NO_OPENAPI_SERVICE),
apihub.kma.go.kr의 stn_inf.php는 자체 인증키를 요구해 data.go.kr 키로는
401이다. 그게 생기면 좌표 입력도 최근접 ASOS 지점으로 올릴 수 있다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 12:22:47 +09:00
kimandClaude Opus 5 191ca2cd0d feat: 강수 사상 누적을 ASOS 실측으로 전환 + 격자 변환 +1 밀림 수정
ASOS 시간자료/일자료가 승인돼서, 사상 누적을 Open-Meteo 추정에서
기상청 관측으로 바꾼다. 두 소스의 창이 정확히 맞물린다 — ASOS는
"전날 자료까지"(오늘은 resultCode 99), 초단기실황은 "최근 1일"이라
자정에서 나누면 사상 전체가 관측값이 된다.

rn 인코딩 주의: ""=무강수, "0.0"=미량(관측됐으나 0.05mm 미만).
둘을 뭉개면 이슬비로 이어지던 비가 소강으로 잡혀 사상이 끊긴다.
그래서 findRainEpisode가 wet을 명시로 받는다(안 주면 종전 mm 임계).

지점 좌표는 일부러 없다. 최근접 지점 방식을 만들다 폐기했다:
지점명 지오코딩이 홍성·보령·밀양을 북한 동명 지역으로, 남원을
제주도로 돌려줬다. 검증하려고 ASOS 관측기온 vs Open-Meteo 기온을
대조했으나 좌표가 정확한 서울조차 Δ3.1°C(도시열섬·모델편차)라
판별자가 못 됐다. 이름 매칭만 쓰고 못 맞히면 Open-Meteo로 물러난다.
지점 목록 97개는 지점정보 API가 없어서(NO_OPENAPI_SERVICE) 일자료
엔드포인트로 stnIds 90~300을 전수조사해 실응답에서 뽑았다.

같이 고친 것: latLonToKmaGrid가 공식 변환식의 반올림 항을 +0.5가
아닌 +1.5로 써서 nx·ny를 나란히 한 칸씩(대각 ~7km) 밀어 조회했다.
이름이 KMA_GRID 표에 있는 도시는 표를 타서 멀쩡했고 좌표 입력만
틀려서 여태 안 드러났다. 같은 서울을 이름과 좌표로 조회했을 때
일 누적이 9.8mm vs 29mm로 갈리며 발견.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 12:13:09 +09:00
kimandClaude Opus 5 f5e0dcf9bd feat: 날씨에 일 누적 강수량 + 연속 강수 사상 누적 추가
weather_kma가 RN1(직전 1시간)만 내보내서 "오늘 비 얼마나 왔어"에
답할 데이터가 없었다. 모델이 "1시간 값만 있어 일 누적은 못 낸다"고
한 건 정확한 진술이었고, 공백은 도구 쪽이었다.

초단기실황이 같은 날 과거 base_time을 받아준다는 걸 라이브 API로
확인해, 시간별 실측을 합쳐 일 누적을 만든다. 새 API 승인 불필요.
RN1(HH00)은 HH00에 "끝나는" 1시간이라 0000은 어제 23~24시 —
0100부터 합산한다.

여러 날에 걸친 비는 관측으로 안 된다. 초단기실황은 resultCode 10
("최근 1일 간의 자료만 제공합니다")으로 잘리고, ASOS 시간자료는
이 키로 403 미승인이다. 그래서 사상 누적만 Open-Meteo로 내고
오늘 누적(기상청 실측)과 한 숫자로 합치지 않는다 — 이음매에서
서울 기준 9.8 vs 10.8이 충돌하는데 섞은 값은 검증이 안 된다.

사상 판정: 6시간 이상 그치면 다른 비(기상청 강수 계속시간 관례),
시간당 0.1mm 미만은 강수시간 제외, 조회창 7일(꽉 차면 하한 표시).
이미 6시간 넘게 그친 비와 오늘 안에서 시작한 비는 표시하지 않는다.
forecast_days=1로 받은 뒤 현재 시각까지만 잘라 써서 미도래 예보가
누적에 섞이지 않게 한다.

교차검증(2026-08-16 서울, 같은 구간): KMA 9.8mm vs Open-Meteo 10.3mm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 11:52:57 +09:00
kimandClaude Opus 5 faee483cf1 fix: 뉴스 검색이 2분 넘게 걸리던 원인 두 가지
사용자 신고: "지금 뉴스검색을 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은 &lt;em&gt; 식으로 이중 인코딩해 보내므로 디코드→태그제거→디코드를
   한 번 더 돈다. 한 번만 돌면 <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>
2026-08-15 08:39:45 +09:00
kimandClaude Opus 5 554b66d299 fix: 명시적 think:false는 재시도 사다리를 오르지 않는다
지서버를 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>
2026-08-15 08:25:36 +09:00
kim 3b5c93df8e Revert "feat: 지서버 모델을 드롭다운으로 선택 + think:false가 조용히 뒤집히지 않게"
This reverts commit 86063d3ef5.
2026-08-12 23:08:27 +09:00
kimandClaude Opus 5 86063d3ef5 feat: 지서버 모델을 드롭다운으로 선택 + think:false가 조용히 뒤집히지 않게
지서버(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>
2026-08-12 23:02:08 +09:00
kimandClaude Opus 5 6449126b46 feat: 넓은 뉴스 요청은 주요 매체 1면(RSS)에서 가져온다
사용자 지적: "뭔가 이상한데" — 매체를 좁힌 뒤에도 함부르크 아파트 균열,
패러글라이딩 실종자 수색, "하비에르 바르뎀은 친근한 배우" 같은 기사가
"주요 뉴스"로 올라왔다.

원인은 매체가 아니라 정렬이었다. 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>
2026-08-12 13:57:02 +09:00
kimandClaude Opus 5 4764ccd5b3 fix: 유럽 다국가 뉴스 요청이 빈손으로 돌아오던 문제 (+ bbc.co.uk 오류)
사용자 지적: "유럽은 여전한데?" 로그를 보니 모델은 유럽 요청을 이렇게 보낸다.

  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>
2026-08-12 13:47:47 +09:00
kimandClaude Opus 5 13e2f795fe fix: 넓은 뉴스 요청을 주요 매체로 좁혀 지엽적 기사 대신 실제 주요 뉴스를 준다
사용자 지적: "중요한 정치 경제 외교 이런 것이 없는 듯한데, 지엽적이고 세세한
내용들이네." 실측한 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>
2026-08-12 13:39:45 +09:00
kimandClaude Opus 5 08ff1c4429 feat: 사용자가 명시한 뉴스 분야를 모델이 빠뜨리면 채워 넣는다
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>
2026-08-12 13:05:33 +09:00
kimandClaude Opus 5 d0db91d063 feat: news_search에 crime·education 카테고리 추가
라이브 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 13:00:23 +09:00
kimandClaude Opus 5 bf07a15112 fix: 새어나온 바이트 폴백 토큰을 원래 글자로 복원
실사고(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 12:51:50 +09:00
kimandClaude Opus 5 bc2ae25a34 feat: 날씨·뉴스 도구 게이트가 직전 사용자 발화까지 본다
인자 보정에 적용한 것(dbe34ba)과 같은 문제가 도구 선택 게이트에도 있었다.
날씨 대화 중에 "그럼 모레는?"이라고 하면 그 메시지에 날씨 단어가 없어서
weather_* 가 스키마에서 빠진다 — 어제 "향후 비소식" 사고와 같은 결말이 된다.

ToolScopeInput에 recentUserText(선택)를 추가하고, **날씨·뉴스·재난 게이트에만**
적용했다(사용자 결정). 이 셋은 "도구를 제공할까"만 정하므로 오래된 매치가
남더라도 스키마 토큰 몇백 개가 낭비될 뿐 답이 틀리지 않는다.

나머지 게이트는 의도적으로 현재 메시지에 그대로 둔다. 전부 넓히면 두 턴 전
코딩 단어 하나 때문에 무관한 턴에 coder 도구가 딸려온다 — 테스트로 박아뒀다.

recentUserText가 없으면 message로 폴백하므로, 이력을 갖지 않은 호출부는
이전과 완전히 동일하게 동작한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 12:14:27 +09:00
kimandClaude Opus 5 dbe34ba766 fix: 인자 보정 게이트가 직전 사용자 발화까지 보게 한다
사용자 지적(2026-08-12): "메시지간의 연관이 없어진다는건가? 다른 채팅은
그렇지 않던데" — 직전 커밋(8a8c81e)에서 내가 만든 결함이 맞다.

대화 맥락 자체는 없어지지 않는다. 이력은 그대로 모델에 전달되고 모델도
기억한다. 문제는 **모델은 맥락을 아는데 보정 함수만 몰랐다**는 것이다.
"미국 기술 뉴스" 다음에 "더 보여줘"라고 하면, 모델은 맥락을 알고
category=technology를 유지하는데 보정 코드가 "지금 메시지에 기술이란 말이
없다"며 지워버린다. 맥락을 아는 쪽의 판단을 모르는 쪽이 덮는 구조였다.

보정 게이트에 직전 사용자 발화 2턴 + 현재 메시지를 이어붙여 넘긴다.
2턴으로 제한한 이유: 더 거슬러 올라가면 20턴 전에 한 번 말한 주제가 계속
살아남아 이번엔 반대 방향으로 틀린다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 12:11:46 +09:00
kimandClaude Opus 5 8a8c81ea91 fix: news_search가 사용자가 지정하지 않은 분야로 뉴스를 좁히던 문제
실사고(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-12 12:00:59 +09:00
kimandClaude Opus 5 f042a4cb4e feat: 검색 결과를 다 읽지 않고 "못 찾았다"고 하면 재프롬프트
실사고(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>
2026-08-12 11:16:28 +09:00
kimandClaude Opus 5 2afa76d262 fix: "기상현상 + 소식/예보/전망"을 한 패턴으로 일반화
사용자 지적: "비소식, 눈소식, 태풍소식 이런 것도 넣어야겠네."

셋은 직전 커밋(58c01b7)으로 이미 걸리고 있었지만, 지적의 핵심은 맞았다 —
현상별로 흩어 놓으면 다음 현상에서 또 빠진다. 실제로 확인해보니 우박·안개·
서리 소식이 빠져 있었다. "OO 소식 있나?"는 날씨를 묻는 가장 흔한 말투라
개별 대응이 아니라 패턴으로 잡아야 한다.

WEATHER_PHENOMENON(비|눈|태풍|장마|한파|폭염|황사|미세먼지|우박|안개|서리|
더위|추위|바람|구름|강수|기온|날씨) × (소식|예보|전망|상황) 조합으로 통합.

"소식"·"전망" 단독은 넣지 않는다 — 회사 소식·업계 소식·실적 전망에 다
걸린다. 현상 목록에 "비"·"눈"처럼 다른 뜻과 겹치는 짧은 단어가 있으므로
단독 사용을 금지하는 주석을 목록 선언부에 붙였고, 오탐 4건을 테스트로 박아뒀다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 10:59:26 +09:00
kimandClaude Opus 5 58c01b73ea fix: "비소식"·"기상청"이 날씨 키워드에 없어 날씨 도구가 통째로 빠지던 문제
실사고(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>
2026-08-12 10:50:59 +09:00
kimandClaude Opus 5 2e6530db6b feat: 다나와 판매 목록에서 하드웨어 스펙·국내 시세를 가져온다
공개 데이터셋 조사 결과 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>
2026-08-11 14:50:18 +09:00
kimandClaude Opus 5 231ecbfce6 feat: GPU 스펙 구조화 DB를 검색 결과에 자동 주입
"시어엔진에 이런 데이터 전용 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>
2026-08-11 14:34:34 +09:00
kimandClaude Opus 5 de5407b701 feat: 비교 검색에서 스펙이 실린 페이지 본문을 자동으로 가져온다
쪼개기(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>
2026-08-11 13:47:42 +09:00