Commit Graph
100 Commits
Author SHA1 Message Date
kimandClaude Sonnet 5 164ce6dcc1 feat: k2-app 웹 UI에 STL 슬라이싱 기능 추가
로컬 PC의 STL 파일을 채팅 없이 K2 앱에서 바로 업로드→슬라이싱→K2 업로드까지
할 수 있는 모달 추가. print3d_model 채팅도구와 로직을 공유하도록
k2-slicer-core.ts로 공통 부분(프린터 프로파일, PrusaSlicer 실행, Moonraker
업로드, 시간 파싱)을 분리했다.

- k2-slicer-core.ts: print3d.ts에서 추출한 공용 슬라이싱 로직 + 오늘 실측한
  FAST_SPEED_ARGS(퍼리미터/인필/트래블 속도업, 브릿지는 유지)
- routes-k2.ts: POST /api/k2/slice(STL 원본 바이트 바디+쿼리스트링 설정,
  --duplicate로 동일 파츠 복제 지원 — 서로 다른 파츠 합치기(--merge)는 이
  헤드리스 환경에서 세그폴트 나서 미지원), POST /api/k2/print-start(Moonraker
  프록시, mixed-content 회피)
- k2-app.html: 헤더에 "🔪 슬라이싱" 버튼 → 모달(파일업로드/수량/소재/레이어
  높이/인필/속도/서포트) → 결과+출력시작 버튼. 실제 파일 업로드로
  슬라이싱→K2업로드까지 브라우저에서 확인함(출력 시작은 실제 프린트 중이라
  테스트 안 함)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 17:13:13 +09:00
kimandClaude Sonnet 5 9dfef8b568 refactor: robot-app 부품 리스트를 표에서 펼침형 카드로 변경
행마다 항상 다 펼쳐져 있던 표 대신, 이름/상태뱃지/소계만 보이는 헤더를
클릭하면 수량·단가·상태·메모 편집 필드가 펼쳐지는 카드로 교체. 펼침
상태는 화면 전용(서버 저장 안 함)이라 다시 그려도 유지됨.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 14:58:38 +09:00
kimandClaude Sonnet 5 54285c8a4c feat: robot-app에 내장 채팅 패널 추가 (doctor/lawyer 앱과 같은 사이드 패널 패턴)
부품 리스트를 보면서 바로 옆에서 로봇 프로젝트 얘기를 할 수 있게 리사이즈
가능한 채팅 패널을 대시보드에 붙임. 세션ID를 "rb_"로 시작하게 해서
tool-scope.ts가 이 세션 안에서는 키워드 매칭 없이 robot_project 도구를
항상 열어주도록 예외 처리(doctor_app의 'dr_'/lawyer_app의 'lw_'와 동일한
패턴) — "이 부품 지워줘"처럼 짧게 말해도 바로 동작한다.

채팅 응답이 끝나면 대시보드(부품표/예산/작업)를 자동으로 다시 불러와서
채팅으로 바꾼 내용이 즉시 화면에 반영되게 함.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 14:48:50 +09:00
kimandClaude Sonnet 5 e3d5da38cf feat: robot_project 전용 채팅도구 추가 — 로봇 프로젝트 대시보드를 채팅으로 편집
로봇 프로젝트("메카넘휠" 이동로봇) 대화에서 부품 상태 변경/추가/삭제, 작업
완료 체크, 새 단계/작업 추가, 메모 추가를 채팅으로 바로 할 수 있게 하는
도구. robot-app.html 대시보드와 같은 저장소(robot-storage.ts로 분리)를
쓰므로 채팅에서 바꾸면 대시보드에도, 대시보드에서 바꾸면 다음 채팅
조회에도 즉시 반영된다.

- robot-storage.ts: routes-robot.ts에서 인라인으로 있던 저장 로직을
  분리(case-storage.ts와 같은 목적) — 라우트와 도구가 공유
- robot-project.ts: get/add_part/update_part/delete_part/add_task/
  set_task_done/add_phase/add_note 8개 액션, 이름 일부일치 검색이라
  내부 id를 몰라도 채팅으로 지칭 가능. 물리적 동작이 없어 confirm 불필요
- registry.ts/build-tools.ts/tool-scope.ts 3곳 등록. 게이트 키워드는
  "로봇청소기"(기존 Valetudo 대화)와 안 겹치게 프로젝트 고유 어휘로만 매칭
- 실제 채팅으로 "예산 얼마야?+메카넘휠 주문완료로 바꿔줘" 동시 요청 테스트,
  정확한 응답+저장 확인

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 14:16:11 +09:00
kimandClaude Sonnet 5 b358cbc968 feat: robot-app에 K2/밀링 장비 연동 위젯(장비 탭) 추가
로봇 프로젝트 대시보드에 실제 공작 장비(K2 3D프린터, CNC 밀링 프린터실
PTZ카메라) 상태를 보는 "장비" 탭을 추가. 우선 가벼운 주기적 스냅샷 방식으로
구현(실시간 WebRTC 상시연결은 자원 부담 커서 보류, 추후 업그레이드 검토).

- routes-k2.ts: GET /api/k2/status — Moonraker print_stats/virtual_sdcard를
  요약해서 반환(온라인여부/상태/파일명/진행률/경과시간)
- nvr.ts: GET /api/nvr/frame/:channelId — go2rtc(127.0.0.1:1985)의
  frame.jpeg를 프록시(밀링 PTZ카메라, channelId=1000 "프린터실"), go2rtc
  포트를 브라우저에 직접 노출하지 않음
- robot-app.html: "장비" 탭 — K2는 20초 간격으로 짧게 WebRTC 연결→ontrack에서
  캔버스 캡처→즉시 종료(상시 연결 안 함), 밀링은 위 프록시로 img 새로고침.
  "장비" 탭을 보고 있을 때만 폴링, 다른 탭으로 나가면 타이머 정지

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 14:07:54 +09:00
kimandClaude Sonnet 5 9641f689b8 feat: 로봇 프로젝트 관리 대시보드(robot-app) 추가
메카넘휠 이동로봇 프로젝트(파이5+RPLidar, 추후 팔 추가)의 부품리스트/
단계별 작업/예산을 관리하는 웹앱. doctor-app의 GET/PUT 단일문서 패턴을
재사용하되 케이스 목록 없이 단일 프로젝트 문서로 단순화했고, 첫 로드시
project_robot_dog.md 메모 기준 실제 진행상황을 기본 시드로 반환한다.

- src/gateway/routes/routes-robot.ts: GET/PUT /api/robot/project,
  사용자별 workspace/.smallclaw/robot-project.json에 원자적 저장
- web-ui/html/robot-app.html: 부품/작업/메모 3탭, 실시간 예산 요약,
  디바운스 자동저장
- index.html 🏠 홈 드롭다운에 🤖 로봇 링크 추가

장비연동/전용챗봇/외부데이터연동/CAD플러그인은 다음 단계로 보류.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:46:37 +09:00
kimandClaude Sonnet 5 cd4843d480 refactor: print3d_model 슬라이서를 CuraEngine에서 PrusaSlicer로 교체
CuraEngine CLI는 구조적으로 예상 출력시간 헤더가 항상 더미값(6666)만 남는
한계가 있어(33b5a25 참고) PrusaSlicer(apt, gcode-flavor=klipper 네이티브
지원)로 교체. DISPLAY 없이도 --export-gcode가 완전 헤드리스로 동작함을
확인했고, 슬라이싱 후 파일에 실제 "estimated printing time"이 직접
기록되므로 stderr 파싱 없이 파일에서 바로 읽으면 됨. K2 프로파일/시작-매크로
값은 그대로 유지. 더 이상 쓰지 않는 resources/cura/*.def.json 제거.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:29:05 +09:00
kimandClaude Sonnet 5 33b5a258b2 fix: print3d_model 예상 출력시간이 항상 6666초로 나오는 CuraEngine CLI 버그 수정
CuraEngine 5.0.0 소스 확인 결과, CommandLine 백엔드는 isSequential()이 항상
true라 슬라이싱 "시작 전"에 G-code 헤더를 미리 써버리고(processStartingCode
-> getFileHeader, print_time 인자 없음) 끝난 뒤 되돌아가 실제값으로 덮어쓰는
코드가 어디에도 없다. 그래서 ;TIME: 헤더는 항상 gcodeExport.cpp에 하드코딩된
더미값(6666)만 남는다 — 확률적 버그가 아니라 CLI 모드의 구조적 한계였음.

실제 값은 슬라이싱 완료 후 stderr(verbose 로그)에 "Print time (s): N"으로만
찍히므로, G-code 파일 헤더 대신 stderr를 파싱하도록 변경.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:16:34 +09:00
kim fdbed6266e feat: STL→K2 프린터 자동 슬라이싱/출력 채팅 도구(print3d_model) 추가
CuraEngine(apt cura-engine, 클로서버)로 STL을 슬라이싱해 K2 콤보(Moonraker)에
업로드하는 도구. action="prepare"(다운로드+슬라이싱+업로드, 출력은 안 함)와
action="start"(confirm=true 필수, 실제 출력 시작)로 분리 — 재료/시간 소모되는
물리적 동작이라 사용자 확인 없이는 출력 시작 안 되게 함.

프린터 프로파일(빌드볼륨/노즐/리트랙션/온도/START_PRINT·END_PRINT 매크로 호출)은
추측이 아니라 K2에 이미 있던 Creality Print 슬라이싱 샘플 gcode 헤더에서 실측값
그대로 추출. fdmprinter.def.json/fdmextruder.def.json은 설치된 CuraEngine
5.0.0과 버전 맞춰서 받아야 함(main 브랜치 최신판 쓰면 설정 스키마 불일치로
슬라이싱이 즉시 실패함 — resources/cura/에 버전 고정해서 커밋).

tool-scope.ts에 3D프린트 관련 키워드 게이트 추가(다른 니치 도구들과 동일 패턴).
end-to-end 테스트 완료(3DBenchy STL로 다운로드→슬라이싱→K2 업로드 확인, 테스트
파일은 삭제함).
2026-09-20 00:19:02 +09:00
kim 101a673827 fix: K2 카메라 연결 지연(20~30초) 수정 — ICE gathering 완전종료 대신 1.5초 바운드
onicecandidate가 ev.candidate===null(전체 gathering 종료)까지 기다린 뒤에만 오퍼를
보내고 있었는데, 이 네트워크에서 그 신호가 20~30초씩 걸림(가상 인터페이스들 순회+
타임아웃으로 추정). 실제 필요한 STUN srflx 후보는 1~2초면 잡히므로, 완전종료를
기다리지 않고 1.5초 타임아웃 뒤 그때까지 모인 후보로 바로 오퍼를 보내도록 변경.
브라우저 실측: 연결까지 20~30초 → 3초 이내로 단축.
2026-09-19 23:55:22 +09:00
kim 5eed244340 fix: NVR go2rtc WS mixed-content, K2 재연결 레이스, 시그널링 타임아웃 + WS 프록시 공통화
- nvr-app.html: go2rtc WS URL을 페이지 오리진 기준 상대경로로 (하드코딩된 ws://LAN IP가
  HTTPS 페이지에서 mixed-content로 막히던 문제)
- k2-app.html: 카메라 재연결 시 오래된 연결의 실패 콜백이 새 연결 상태를 덮어쓰던 레이스 수정
- routes-k2.ts: K2 시그널링 fetch에 타임아웃 추가(프린터 응답없음 시 무한대기 방지)
- nvr.ts/routes-comfyui.ts/routes-android.ts에 중복돼있던 raw WebSocket 업그레이드
  프록시(connect→pipe→cleanup)를 ws-proxy-util.ts의 공통 헬퍼로 통합
2026-09-19 23:46:53 +09:00
kimandClaude Sonnet 5 d65e8d958d feat: K2/밀링 앱을 NPM 서브도메인(HTTPS)으로 전환, 홈 드롭다운 재추가
print.applecherry.net(K2 콤보 Fluidd) · mill.applecherry.net(CNC 밀링
OctoPrint)로 NPM 프록시+Let's Encrypt 설정 완료. 앱들이 이제 HTTPS라
mixed content 경고 없이 홈 드롭다운에 다시 노출.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PppKcQmeTJDQCpfcgKietu
2026-09-19 16:39:30 +09:00
kimandClaude Sonnet 5 f13b40712c feat: 복원된 옛 홈페이지(애플체리, XpressEngine) 앱 추가
트루나스 Plex/applecherry에 있던 옛 XpressEngine(XE 1.11.6) 사이트를
PHP7.4+MariaDB 도커 앱으로 복원하고 home.applecherry.net(NPM+Let's
Encrypt)으로 서빙. 홈클로 홈 드롭다운에 iframe 래퍼 앱으로 추가.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PppKcQmeTJDQCpfcgKietu
2026-09-19 11:24:36 +09:00
kimandClaude Sonnet 5 72d4ac62ee fix: K2 콤보 카메라 STUN 서버 복원 (host candidate만으론 응답 없음)
STUN을 빼고 host candidate만 쓰면 DTLS가 failed로 끝나고 프린터에서
데이터가 전혀 안 옴(bytesReceived 0) — 원인은 불명이나 재현됨. 크리얼리티
데모 코드처럼 STUN을 유지해야 정상 연결됨(다소 느리지만 20초 내외).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PppKcQmeTJDQCpfcgKietu
2026-09-18 23:17:57 +09:00
kimandClaude Sonnet 5 a8149c136a feat: 프린터실 카메라 웹코덱(WebRTC/MSE) 전환 + K2 콤보 카메라 프록시, 옛 3D프린터 앱 제거
- go2rtc가 CORS/Origin 체크로 브라우저 직접접속을 막던 문제 해결: 클라이언트
  스크립트를 벤더링해 같은 오리진으로 서빙하고, WS는 게이트웨이 프록시
  (attachGo2rtcWsProxy)가 Origin을 다시 써서 우회. CCTV 앱의 프린터실 카메라
  라이브뷰가 MJPEG 대신 WebRTC/MSE를 쓰도록 전환(대역폭 절감).
- go2rtc mjpeg 트랜스코더가 멈추는 경우를 감지해 자동 재기동하는
  /api/nvr/mjpeg-health 엔드포인트 추가.
- 신규 3D프린터(K2 콤보, Klipper 스탠드얼론)의 내장 WebRTC 카메라 시그널링을
  프록시하는 /api/k2/webrtc-signal 추가 — 홈클로가 HTTPS로 서빙되는데 카메라가
  평문 HTTP만 지원해 브라우저 mixed-content 차단을 우회하기 위함.
- 옛 3D프린터(단종된 Anet A8 Plus, OctoPrint) 앱과 홈 드롭다운 링크 제거.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PppKcQmeTJDQCpfcgKietu
2026-09-18 22:40:10 +09:00
kimandClaude Sonnet 5 e7a86d6f2d fix: go2rtc mjpeg 트랜스코더 멈춤 자동 감지/복구 엔드포인트 추가
프린터실 카메라의 go2rtc mjpeg 가상 스트림이 카메라 wifi 순단 등으로
멈추면 뷰어가 재연결해도 같은 멈춘 프로세스에 붙어 계속 안 나오는
문제가 있었음. /api/nvr/mjpeg-health가 컨트롤 탭 로드 시 go2rtc
스트림 상태를 확인해 정체된 트랜스코더만 재시작한다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PppKcQmeTJDQCpfcgKietu
2026-09-18 14:27:38 +09:00
kimandClaude Sonnet 5 d34dd10ba7 feat: 독립 카메라에 ONVIF PTZ 지원 추가 (프린터실 카메라 등록)
extra-cameras API/UI에 ptz·onvifPort 필드 추가하고, 프린터실 머큐시스 MC230
카메라를 PTZ 지원 독립 카메라로 등록.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PppKcQmeTJDQCpfcgKietu
2026-09-18 00:31:38 +09:00
kimandClaude Sonnet 5 145f4e698f feat: 작가앱에 작품 제목 수정 기능 추가
- 백엔드 PUT /api/writer/works/:id는 이미 title 갱신을 지원했으나 프론트에 버튼이 없었음
- 헤더에 "✏️ 제목 수정" 버튼 추가, 작품 선택 시에만 표시

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PppKcQmeTJDQCpfcgKietu
2026-09-15 12:00:17 +09:00
kimandClaude Sonnet 5 e3f038617d feat: CNC 밀링 앱 추가, 3D프린터/밀링 서버 IP를 새 옥토서버(.3)로 갱신
- web-ui/html/milling-app.html 신규: OctoPrint 밀링 인스턴스(:5001) iframe 임베드
- 홈 드롭다운에 "⚙️ CNC 밀링" 버튼 추가
- printer-app.html: 미니PC(192.168.0.106) → HP 프로데스크 "옥토서버"(192.168.0.3)로 IP 갱신

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PppKcQmeTJDQCpfcgKietu
2026-09-13 20:27:59 +09:00
kimandClaude Sonnet 5 8eb1a6c160 feat: 3D프린터 앱(OctoPrint) 홈 드롭다운에 추가
작업실 미니PC(192.168.0.106)에 설치한 OctoPrint를 iframe으로 임베드.
홈 CCTV/HA 옆에 🖨️ 3D프린터 항목 추가.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5kyXurhNSboRcY3dAGceY
2026-09-12 00:13:15 +09:00
kimandClaude Sonnet 5 c22377778e feat: 유저별 로고·브랜드명 커스터마이징 — .smallclaw/users/<user>/logo.png, brand_name.txt
헤더/환영화면/채팅 아바타·라벨을 로그인 유저별로 스왑. 파일 없으면 기본 HomeClaw 유지.
애플·자스민 계정에 기본 로고(이모지 아이콘) 적용.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5kyXurhNSboRcY3dAGceY
2026-09-11 16:13:38 +09:00
kimandClaude Sonnet 5 aaf8dbfb33 fix: "Resolution too big" 채널은 서브스트림이 안 나옴 — 메인 강제 + 서브고정 토글 숨김
앞 커밋(24a24c6)으로 마당(ch5)이 6분할에 다시 뜨는데, 6분할 기본이
서브스트림이라 /ch5_1.264 요청 → NVR이 ch5를 제대로 인제스트 못해서
서브 스트림 자체가 안 나옴(메인 /ch5_0.264는 패스스루 정상).

- chnStreamOverride(): statusText가 "Resolution too big"면 무조건 stream 0(메인)
- 이런 채널엔 "저화질(서브) 고정" 토글 숨김 (눌러도 깨지기만 함)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yEadXvr9ZWp2S5iRB1B25
2026-09-09 14:34:15 +09:00
kimandClaude Sonnet 5 24a24c6e7f fix: 홈CCTV 6분할이 online=false 채널을 빈 슬롯 처리 — 마당(ch5)이 안 보임
Xiongmai NVR이 3rd파티 카메라(Mercusys)를 "Resolution too big" +
online=false로 계속 보고하는데, 실제로는 RTSP 패스스루가 정상 동작함
(/ch5_0.264 프레임 정상 수신 확인). 그런데 quadChannels 필터가 c.online만
보고 있어서 마당이 6분할에서 빈 슬롯으로 렌더됐음.

chnStreamable() = online || "resolution too big" 상태 → 이런 채널도
6분할·라이브 버튼에 포함. 진짜 죽은 채널은 img.onerror가 "영상 없음" 처리.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yEadXvr9ZWp2S5iRB1B25
2026-09-09 14:27:26 +09:00
kimandClaude Sonnet 5 2b1caf79e3 fix: NVR 녹화목록 검색이 채널 0~3만 조회 — Channel 배열 4개 하드코딩
MAX_CHN=10인데 R.SearchRecord Channel 배열이 4개라 ch4·5(거실·마당)
녹화가 목록에 안 떴음. 16개 True로 확장.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yEadXvr9ZWp2S5iRB1B25
2026-09-09 12:15:03 +09:00
kimandClaude Sonnet 5 69efc44476 chore: 마당을 NVR 채널 5로 이관, standalone 카메라(1000) 제거
카메라 메인 스트림을 1920×1080으로 낮춰(ONVIF) NVR이 채널 5로
직접 물 수 있게 됨 → NVR 녹화 포함. standalone id 1000 불필요해져 삭제.

- nvr.channelLabels["5"] = "마당"
- nvr.channelStream에서 "1000" 제거 (extra camera 없어짐)
- nvr.extraCameras = []

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yEadXvr9ZWp2S5iRB1B25
2026-09-09 12:12:11 +09:00
kimandClaude Sonnet 5 eb4805a27d fix: NVR 4분할에서 마당(독립 카메라) "영상 없음" — 강제화질 설정 무시하던 버그
routes/nvr.ts의 extra 카메라 매핑이 preferredStream을 null로 하드코딩해서
nvr.channelStream(채널별 서브스트림 고정)이 독립 카메라엔 적용되지 않았음.
Mercusys 마당은 메인 스트림(/stream1) 동시 RTSP 세션을 1개만 허용해서,
4분할 "메인(고화질)" 체크 시 다른 세션과 겹치면 Operation not permitted로
거부 → 타일이 "영상 없음". (확대 모달은 4분할을 먼저 정지해서 됐음)

- getChannelStreamPrefs()[id]를 독립 카메라 preferredStream에도 반영
- config: channelStream["1000"]=1 (마당 서브 720p 고정) — 채널3과 동일 방식

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yEadXvr9ZWp2S5iRB1B25
2026-09-09 11:23:52 +09:00
kimandClaude Sonnet 5 4290b5f201 fix: 마당(머큐시스) 카메라 정위치 재설치에 맞춰 rotate180 해제
카메라를 물리적으로 바로 달아서 소프트웨어 180도 회전(ffmpeg hflip,vflip)이
더는 필요 없음 — extraCameras[1000].rotate180 true→false.

config.json에 이전 세션의 미커밋 변경도 함께 포함:
- nvr.channelLabels 채움 (0 선룸 / 1 옥상 / 2 대문 / 3 뒷산 / 4 거실)
- extraCameras[1000] 라벨 머큐시스→마당
- selfHealer.secondary 모델 kimi-k2.6:cloud→glm-5.3-flash:cloud

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yEadXvr9ZWp2S5iRB1B25
2026-09-08 21:47:20 +09:00
kimandClaude Sonnet 5 f3c392bc1b fix: nvr_snapshot이 위치 이름 대신 외운 채널 번호를 써서 엉뚱한 카메라를 가져오던 문제
실사고(papa): 대화 초반 "2번이 옥상 카메라"를 모델이 외웠는데, 이후 채널 라벨을
붙이면서 채널 2 = 대문이 됨. "옥상 확인"에 모델이 camera:"2" 전달 → 대문 프레임을
받고도 "옥상 카메라 확인했습니다"라고 답함.

- 도구 설명/스키마: 채널 번호 말고 위치 이름(선룸/옥상/대문/뒷산/거실/마당)을 쓰라고 명시
- 번호로 호출 시 결과에 "[확인] 채널 번호 '2' = 대문 카메라입니다" 명시 —
  모델이 스테일 매핑을 자각하고 이름으로 재호출하게

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
2026-09-07 11:25:21 +09:00
kimandClaude Sonnet 5 cf331a7440 fix: nvr_snapshot 오류 상세에서 RTSP 자격증명 제거 + ffmpeg 배너 잡음 제거
ffmpeg가 stderr에 rtsp://user:pass@host 를 통째로 찍어 도구 결과·모델 컨텍스트로
비밀번호가 샜다. user:pass@ → ***@ 로 가리고, 실패 원인 줄만 남긴다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
2026-09-07 11:20:03 +09:00
kimandClaude Sonnet 5 2e2a689b32 fix: nvr_snapshot이 NVR 채널 수를 4로 하드코딩 — 실제 조회로 변경 (우리 건 6채널)
/netsdk/Channel/IPCamInfo + Stat/IPC로 실제 연결된 채널만 나열(nvrEnabledChannelIds).
같은 라벨의 독립카메라(Mercusys)가 있으면 NVR쪽 항목 대신 직접 RTSP 쪽을 쓴다
(NVR ch5 = Mercusys인데 NVR쪽 연결은 Connect Failed).
독립카메라 라벨 변경 엔드포인트 POST /api/nvr/extra-cameras/:id/label 추가.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
2026-09-07 11:18:30 +09:00
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 Sonnet 5 5834242261 fix: "숫자+단위"뿐인 인라인 LaTeX($60\sim80\text{cm}$)를 평문으로 푼다
모델이 "60~80cm", "25°C" 같은 걸 습관적으로 `$...$`로 감싸는데, KaTeX로 렌더하면
수식 이탤릭체라 한글 문장에서 어색하고 TTS·복사도 깨진다. \text{}/\mathrm{} 단위가
있고 나머지가 숫자·범위기호·간단한 연산자뿐이면 유니코드 평문으로 치환한다
(\sim→~, \times→×, ^2→² 등). 치환 뒤 `\ ^ _ { } =`가 남으면 진짜 수식이라 그대로 둔다.

chat-render.js(메인챗)·katex-inline.js(앱페이지) 둘 다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
2026-09-07 10:27:01 +09:00
kimandClaude Sonnet 5 bf6aae12b3 fix: KaTeX 통화 가드가 $60\sim80\text{cm}$ 같은 숫자로 시작하는 수식을 죽이던 문제
`$` 다음에 숫자가 오면 무조건 통화로 보고 여는 `$`를 감췄는데, 모델이
`$60\sim80\text{cm}$`처럼 숫자로 시작하는 LaTeX를 내면 수식 전체가 날것으로
남아 `\sim`·`\text{cm}`가 그대로 노출됐다("단위가 이상하게" 신고).

다음 `$`까지의 구간에 백슬래시가 있으면 수식으로 판단해 그 `$`는 건드리지 않는다.
기존 통화 케이스(`$100 절감 … $\rightarrow$`)는 그대로 통화로 처리됨.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
2026-09-07 10:23:30 +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 83e9bd54f9 chore: 가드 발동률 실측 스크립트 — 회전에 견디는 누적 카운트 방식
첫 버전은 "몇 번째 줄 이후"로 기준선을 잡았는데, 로그 회전 설정을 확인해 보니 그 방식이
하루도 못 간다: logrotate가 크기 1M 기준 + `copytruncate`라, 회전 시 내용이 .1로 복사되고
원본은 0바이트로 잘린다. 패치 당일 gateway.log가 이미 1.05M이라 그날 밤 바로 잘릴 참이었다.

대신 gateway.log* 전 파일(.gz 포함)을 합쳐 패턴별 누적 횟수를 세고 기준선 값을 뺀다. 회전해도
내용은 .1/.2.gz로 옮겨갈 뿐 사라지지 않으므로 이 방식은 회전에 견딘다. rotate 14라 파일이
실제로 없어지려면 현재 증가 속도로 반년 이상 걸리고, 그때는 차이가 음수로 나와 경고한다.

패치 직전 기준(누적 1,011턴, 턴당): NUMERIC 재프롬프트 0.051 / AUTO-RECOVER 0.080 /
SEARCH-ABANDONMENT 0.010 / 중복 SKIP 0.035 / 합성 Answer: 0.009 / standing down 0(미존재).

2026-09-09 10:07 KST 1회 실행되도록 systemd user timer(guard-audit.timer) 등록. 이 박스는
매일 02시에 자므로 Persistent=true로 기상 후 따라잡게 했다. 결과는
.smallclaw/logs/audit/report-<날짜>.txt 로 남는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
2026-09-04 18:12:12 +09:00
kimandClaude Opus 5 1b52acf615 chore: 가드 발동률 실측 스크립트
2026-09-04 가드 패치(db6a863 / 10e54d3 / b2075c6)가 실사용에서 어떻게 도는지 며칠 뒤 재려고
만들었다. 기준선은 .smallclaw/logs/audit/guard-baseline.json(머신 로컬, gitignore 대상)에
줄 번호 + 꼬리 30줄 지문으로 박혀 있다 — 로그에 타임스탬프가 없어서다. 로테이션되면 지문이
안 맞고, 그때는 경고를 내고 전체를 잰다.

패치 직전 기준값(전체 340턴): NUMERIC 재프롬프트 24(0.07/턴), AUTO-RECOVER 37(0.11/턴),
SEARCH-ABANDONMENT 7(0.02/턴), 합성 Answer: 줄 1건, standing down 0(미존재).

판독 기준은 스크립트 말미에 적어 뒀다 — 관련성 관문이 헐거우면 NUMERIC이 계속 맞는 답을
지우고, 빡빡하면 standing down이 과해져 진짜 날조가 통과한다. 양방향 모두 봐야 한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
2026-09-04 18:09:01 +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 e441bc22fd feat: drug_dur_check — 성분(ingredient) 기반 DUR 조회 추가
DURIrdntInfoService03(성분 서비스)도 승인돼 있었다. 안 되던 건 함정 때문:
서비스명은 03인데 오퍼레이션 접미사는 02(getUsjntTabooInfoList02), 필터 파라미터는
병용금기만 ingrKorName·나머지는 ingrName, 응답은 items[].item로 한 겹 더 감쌈.

- ingredient 파라미터 추가 → 성분 서비스로 조회. 성분 단위라 결과가 깔끔하고
  행 수가 1/10. 노인주의(getOdsnAtentInfoList02)는 성분 서비스에만 있음
- drug_name(제품명)은 기존대로 품목 서비스(*List03, itemName 부분일치) 유지
- 둘 중 하나 필수. 모델엔 "성분 알면 ingredient 권장"으로 안내
- 기본 checks에 노인주의 추가

실측: 심바스타틴 → 케토코나졸/마크로라이드/프로테아제억제제 병용금기 + 노인주의,
이소트레티노인 → 임부금기 1·2등급. 468개 테스트 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdchNLTHsyYahp2PVQjoym
2026-09-02 17:25:21 +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 ef9f89db1e chore: config — primary를 gemma4:31b-cloud로 정정 + 법령 API 키 배선
- provider ollama_local→ollama, primary/roles를 gemma4:26b→gemma4:31b-cloud로
  (메모리 기준선과 일치, 런타임이 이미 이 값으로 운영 중이던 것을 커밋에 반영)
- gemma4:26b(지서버) / muse-glimmer 프로필에 fixedNumCtx, 26b엔 airkorea 안내 추가
- mistral-large-3 프로필에서 중복 forceToolChoiceOnLiveData 제거(전역 ON됨)
- legal.law_go_kr_api_key vault 참조 추가

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdchNLTHsyYahp2PVQjoym
2026-09-02 15:37:45 +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 797de9b03f fix: 스킬 컨텍스트가 direct 호출에서 버려지고 8000자에서 잘리던 문제
스킬 프롬프트 전면 점검 중 발견한 버그 2건.

1) callerContext가 'no_preflight' 제어 센티널·BOOT.md 마커·호출자 스킬 텍스트를
   한 필드에 섞어 쓰면서 삼항으로 골랐다 → direct:true와 skillContext를 같이 보내면
   스킬이 조용히 버려짐. pptx-wizard.js의 아웃라인 경로(line 798)가 정확히 그 조합이라
   프레젠터 스킬이 한 번도 전달되지 않았다. 둘 다 싣도록 변경.

2) 캡이 8,000자인데 위저드가 보내는 프레젠터 스킬은 9,861자 → 매 호출 뒤 1,861자 손실.
   잘려나간 것이 [필수] source_files 규칙과 **금지 사항 블록 전체**(파이썬으로 PPTX 직접
   생성 금지, 1회 호출 원칙, 수정은 edit_presentation, spawn_agent 금지)였다. 스킬에서
   가장 값어치 있는 제약들이 모델에 도달한 적이 없다. 캡 12,000으로 올리고 초과 시 경고 로그.

검증: direct 유무 양쪽 경로에서 금지사항·source_files 포함 확인.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZX9JHFSLZRuK9rR4sCVBs
2026-09-02 15:02:13 +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 82ca2999fc feat: cms_hospital_compare — CMS(메디케어) 병원 성과 데이터 조회/비교 도구
data.cms.gov "Hospital General Information" 공개 데이터셋(키 불필요)에서
소유형태(영리/비영리/정부)·CMS 종합별점(1~5)·사망률/안전/재입원 측정치의
전국평균 대비 better/worse 개수를 조회. compare=true면 소유형태별로 집계 —
"영리 vs 비영리 병원 성과" 질문에 근거 데이터 제공.

- registry.ts / build-tools.ts 양쪽에 등록
- tool-scope.ts: 병원 데이터 키워드 게이트 추가(상시 로드 안 함)
- retry-decisions.ts: GROUNDING_TOOL_PATTERN에 추가(검색 강제 재시도 방지)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZX9JHFSLZRuK9rR4sCVBs
2026-09-01 11:01:18 +09:00
kimandClaude Sonnet 5 434dadf038 feat: 논문검색 소스 박스에 주제별 라우팅 안내 추가
각 소스 설명을 "적합 주제" 중심으로 재작성하고, 임상=PubMed /
보건정책·경제=OpenAlex+Semantic / 자연어문장=Semantic / vs 빼기
힌트 줄을 추가. ptitle "PubMed 논문 검색" → "논문 검색".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZX9JHFSLZRuK9rR4sCVBs
2026-09-01 10:30:41 +09:00
kimandClaude Sonnet 5 ec2dac9e1e fix: PubMed 검색어의 단독 vs/versus 연결어 제거
"For-profit vs Non-profit hospital performance" 같은 자연어 검색어를
PubMed에 그대로 넘기면 자동 용어매핑이 "vs"[All Fields]를 AND로 강제해
제목에 "A versus B"가 든 임상 RCT를 대거 끌어왔다. 필드태그/따옴표가
없는 평문 검색어일 때만 vs/versus를 공백으로 치환한다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZX9JHFSLZRuK9rR4sCVBs
2026-09-01 10:26:53 +09:00
kimandClaude Sonnet 5 9e0d047eb9 feat: 작가 앱에 AI 원고 작성(compose) 기능 — 이어쓰기/새 장면/선택부분 다시쓰기
검토만 하던 작가 앱에 실제 원고 문장을 써주는 기능 추가.

- 원고 뷰 하단에 프롬프트 입력창 + [이어쓰기][새 장면][선택부분 다시쓰기]
- POST /api/writer/compose: 장르·요소·설정노트를 프롬프트에 실어 프로즈 생성
  (temp 0.85로 검토보다 창작적, "알겠습니다" 머리말 없이 본문만)
- 생성물은 "AI 초안" 미리보기 → [원고에 넣기]/[다시]/[버리기]. 이어쓰기·새 장면은
  원고 끝에 append, 다시쓰기는 선택 범위를 치환
- 요소가 자동 반영됨을 알리는 힌트("반영 중인 요소: 주제, 문체, 배경 …")

브라우저에서 새 장면·이어쓰기·삽입까지 재현·검증 완료.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBDYbeeazVwgQ7RruCXRgY
2026-08-31 17:49:35 +09:00
kimandClaude Sonnet 5 d47d2ef138 feat: 작가 앱 요소를 AI 즉시호출 버튼 → 작가가 채우는 설정칸 + 전체 검토로 재설계
기존: 요소(주제/문체/구성/배경/인물) = 누르면 바로 AI 비평이 도는 전문가 7명.
요소를 입력할 자리가 없어 "요소 다 채우고 검토받기"라는 자연스러운 흐름이 불가능.

- 요소 = 작품에 저장되는 입력칸 5개 (works.json에 elements 객체, 옛 작품은 읽을 때 정규화)
- 원고 ↔ 요소 뷰 토글, 사이드바 요소 버튼은 해당 입력칸으로 이동
- "📋 전체 검토": expertType='review' — 총괄 편집자가 요소별 의도 vs 원고 실행의
  간극·강점·우선순위를 종합 분석
- 개별 "💬 이 요소 검토": 그 요소의 작가 의도를 프롬프트에 주입해 의도-실행 대조
- saveWork/onWorkSelect가 elements를 함께 저장·복원

브라우저에서 전체 흐름 재현·검증 완료.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBDYbeeazVwgQ7RruCXRgY
2026-08-31 17:37:26 +09:00
kimandClaude Sonnet 5 940f78199e fix: 작가 앱 전문가 전환 시 앞선 요청 응답이 새 전문가 대화창에 들어가던 문제
주제→문체 등으로 빠르게 전환하면 selectExpert가 sendChat(true)를 부르는데
chatBusy 가드에 걸려 새 전문가 요청은 안 나가고, 먼저 나간 요청 결과가
돌아올 때 activeExpert가 바뀐 걸 확인 안 해서 새 전문가 대화창에 그대로
push+render 됐다("모델이 가로챔").

AbortController + 세대 카운터(chatGen)로 수정: 전문가 전환·새 메시지마다
진행 중 요청을 abort하고 세대를 올리며, 응답이 돌아오면 자기 세대가
아직 최신인지 확인 후에만 반영. 늦게 끝난 구세대 요청은 UI를 안 건드림.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBDYbeeazVwgQ7RruCXRgY
2026-08-31 17:27:51 +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 Sonnet 5 63bbc36c6e fix: 임베딩 모델 콜드로드로 벡터메모리 회상이 타임아웃되던 문제
Ollama 재시작(예: 버전 업그레이드) 후 embeddinggemma가 언로드되면
다음 채팅의 buildPersonalityContext에서 콜드 로드(~30s)가
15초 타임아웃을 넘겨 "vector memory query failed ... aborted due to
timeout" 경고가 났다. 답변엔 영향 없지만 회상이 빠지고 첫 응답이
15초 지연됐다.

- EMBED_KEEP_ALIVE '24h' → -1 (영구 상주). embeddinggemma가 유일한
  로컬 모델이고 ~0.6GB라 안전. ollama.service의 OLLAMA_KEEP_ALIVE=-1
  drop-in과 이중 방어.
- warmupEmbedding() 추가 — 게이트웨이 부팅 시 백그라운드 예열해서
  첫 채팅이 콜드 로드를 안 기다리게.
- embedText에 timeoutMs 파라미터 추가(기본 15s 유지, 워밍업만 60s).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh45CPB94UFQFiHov2CQe7
2026-08-27 13:11:44 +09:00
kimandClaude Sonnet 5 38320a9162 fix: 업로드 파일 도구 전반에서 /api/files/ 채팅링크를 경로로 착각하는 문제
pdf_read에서 발견한 것과 동일한 패턴이 6개 파일 9곳에 더 있었음 —
path.isAbsolute()가 /api/files/... URL을 절대경로로 오판해 워크스페이스
상대경로 변환을 건너뛰고 실패함(있으면 "escapes workspace", 없으면
"File not found"). 각 execute() 진입점에서 /api/files/ 접두사를
제거하고, LLM에 보내는 도구 설명에도 부정 예시를 명시함.

- files.ts: resolveWorkspacePath() 헬퍼 하나 수정으로 coder_* 도구
  13곳 전체 커버
- audio-transcribe.ts, excel.ts(read/write), image.ts(read/preview/
  info/edit), imagegen.ts(style_transform/video_generate 소스 이미지)
- pdf-extract.ts: pdf_extract_images/pdf_extract_tables도 같은 방식
  으로 재정리(설명 문구 통일)

실제 업로드 파일로 /api/files/ 경로를 넘겨 재현 테스트, 정상 동작 확인.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SUjjAwB2Cmjf4YFRZpobec
2026-08-25 11:33:10 +09:00
kimandClaude Sonnet 5 c67a23f739 fix: pdf_read가 /api/files/ 채팅링크를 경로로 착각해 실패하던 문제
모델이 업로드 링크 URL을 그대로 path로 넘기면 path.isAbsolute()가
true로 판정돼 워크스페이스 상대경로 변환을 안 타고 "path escapes
workspace"로 실패했음. 도구 설명에 부정 예시 명시 + execute()에서
/api/files/ 접두사 자동 제거로 이중 보강.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SUjjAwB2Cmjf4YFRZpobec
2026-08-25 11:16:53 +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 1811f62a18 feat: 법률앱 검색 UI — 판례 기본 + 결과 내 좁히기 + 결정례 드롭다운
- 판례 탭을 맨 왼쪽으로 옮기고 기본 검색 대상으로 바꿨다. 법률앱에서 가장
  자주 쓰는 게 판례다.
- 결과 목록 위에 좁히기 입력칸. 사건명·법원·사건번호에서 찾는다. 서버를 다시
  부르지 않고 이미 받아온 결과를 숨김/표시로 거르므로 즉시 반응하고 law.go.kr
  쿼터도 안 쓴다. 새 검색이 오면 조건을 비운다(안 그러면 결과가 있는데 빈
  화면으로 보인다). 1건이면 좁힐 게 없으므로 입력칸을 숨긴다.
- 결정례 4종(헌재·법령해석·행정심판·심판)은 드롭다운으로 묶었다. 탭이 8개가
  되면 좁은 화면에서 줄바꿈이 생긴다. 드롭다운 항목을 고르면 탭 하이라이트를
  끄고 드롭다운이 현재 선택을 나타낸다.
- 키가 없어 검색 자체가 불가능한 경우(미국 판례)를 "결과 없음"과 구분해
  안내를 띄운다. 예전엔 "다른 키워드로 검색해보세요"가 떠서 헛되이 재시도했다.

부트스트랩에서 setSearchType()을 부른다. 이 함수는 탭 클릭 때만 돌아서 최초
로드에는 안 걸렸고, 그래서 기본값이 판례인데 placeholder는 법령용이었다 —
📁 버튼이 안 보이던 것과 같은 종류의 누락이라 이번엔 같이 잡았다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 10:19:50 +09:00
kimandClaude Opus 5 f79ba166e4 feat: 법령 검색에 결정례 4종 추가 + 자치법규·미국판례·판례상세 수정
키를 꽂고 나니 여러 문제가 한꺼번에 드러났다.

1) 자치법규가 늘 실패했다. target 코드가 `ordinance`가 아니라 `ordin`이다.
   틀린 코드를 보내면 에러가 아니라 **빈 응답(길이 0)** 이 와서 JSON 파싱
   단계에서 "Unexpected end of JSON input"으로 터진다 — 원인이 target이라는
   게 전혀 안 드러난다. 별칭 정규화를 넣고, 빈 본문은 명확한 메시지로 갈랐다.

2) 미국 판례(CourtListener)는 인증 헤더를 아예 안 보내고 있었다. 예전엔
   무인증으로 열렸지만 지금은 403이다. legal.courtlistener_api_key 지원을
   넣고, 키가 없으면 날것의 403 대신 발급 안내와 직접 검색 URL을 준다
   (korean_law_search의 키 미설정 경로와 같은 모양).

3) 판례 상세가 부실해 보였던 이유:
   - 값이 없는 항목도 "### 판시사항" 헤딩이 찍혀서 자료가 빈약해 보였다
   - 참조판례를 통째로 버리고 있었다
   - 전문이 4000자에서 조용히 잘렸다 → 8000자로 늘리고 잘릴 때 밝힌다
   - 선고일자가 `19860701` 원본 그대로였다 → 1986.07.01
   구조화 데이터도 같이 반환한다(예전엔 case_name 하나뿐이었다).

4) 성격이 다른 DB 4종을 추가했다: 헌재결정례(detc)·법령해석례(expc)·
   행정심판례(decc)·심판례(ppc). law.go.kr은 **대상마다 응답 구조가 전부
   다르다** — 래퍼 이름, 항목 배열 키, 필드명까지. 하나라도 어긋나면 에러가
   아니라 0건으로 조용히 나오므로, 실제 응답을 찍어 확인한 값을 TARGET_SPECS
   표 하나에 모았다. 다음 대상은 그 표만 채우면 된다.

SECRET_FIELD_MAP에 legal 키 둘을 추가해 평문이 들어와도 vault로 이관되게 했다.

검증: 판례 8,748 / 법령 13 / 자치법규 884(주차장) / 헌재 19 / 해석례 13 /
행정심판 13 / 심판례 6 — 전부 정상 응답.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 10:19:33 +09:00
kimandClaude Opus 5 38695fb4fb fix: 판례 ID 필드명 수정 + legal 키를 vault 이관 대상에 추가
law.go.kr API 키(OC 값 `ailawyer`)를 vault에 넣고 나니 드러난 버그.

  코드   item.판례정보일련번호
  API    판례일련번호            ← 실제 필드명

ID가 undefined로 나와서 korean_law_fetch로 본문을 가져올 수 없었다. 검색
결과는 멀쩡히 보이고 상세만 안 되는 모양이라 눈에 잘 안 띈다. 옛 이름도
폴백으로 남겨 응답 스키마가 바뀌었을 때를 대비했다.

SECRET_FIELD_MAP에 legal.law_go_kr_api_key가 빠져 있어서 평문으로 들어와도
vault로 자동 이관되지 않았다. 추가했다 — config.json에는 vault: 참조만 남는다.

확인: 판례 검색 17건 → 본문 조회에서 판시사항·판결요지·참조조문 정상 수신.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:54:25 +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 a9040b7222 feat: 법률앱 — 일반 대화를 사건으로 전환
상담하듯 일반 대화로 물어보다가 사건으로 승격하고 싶을 때 쓴다. 채팅 헤더의
📁 버튼(사건 미선택일 때만 노출).

요약만 옮기면 사건 안에서 이어 물을 때 맥락이 끊기므로 **대화 세션 파일 자체를**
사건 세션(`lw_case_<id>`)으로 옮긴다. 그래서 서버에 세션 이동 API를 새로 만들었다
(POST /api/chat/sessions/:id/move). 파일명으로 그대로 쓰이므로 세션 ID는
[A-Za-z0-9_-]만 허용하고, 목적지가 이미 있으면 409로 거부한다 — 덮어쓰면 다른
사건의 대화가 날아간다.

실패 처리는 되돌릴 수 없는 쪽을 기준으로 잡았다:
 - 제목/개요 추출이 실패해도 진행한다. 첫 질문을 제목으로 쓴다. 이름은 나중에
   고치면 되지만 대화는 못 되찾는다.
 - 세션 이동이 실패하면 사건을 만들지 않는다. 사건만 생기고 대화가 안 따라오면
   일반창에 그대로 남아 중복이 된다.

버튼 표시 토글을 updateToCaseBtn()으로 빼서 부트스트랩에서도 부른다.
처음엔 ensureChatContextForCase 안에만 넣었는데, 그 함수는 모드 전환·사건
선택·대화 지우기에서만 돌아서 최초 로드에는 안 걸렸다 — 버튼이 계속 숨어 있었다.

가드 검증: 없는 세션 404 / 경로조작 from·to 400 / 인증 없음 401.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:53:51 +09:00
kimandClaude Opus 5 d60019f004 fix: 앱 페이지 7개에 수식 렌더러 배선 — 애초에 KaTeX가 없었다
법률앱에서 `$\rightarrow$`가 날것으로 보인다는 신고. 통화 `$` 문제(f06955f)를
먼저 의심했지만 원인은 더 단순했다 — **앱 페이지들은 KaTeX를 아예 로드하지
않고 있었다.**

  index.html        katex O
  lawyer / detective / doctor / investor / accountant / mind / weather   전부 X

메인 채팅에만 chat-render.js의 renderKatexIn이 있었고, 앱 7개는 각자 자기
renderMd를 갖고 있어 수식이 통과할 길이 없었다. 공용 js/app/katex-inline.js로
빼서 전부에 붙였다(통화 `$` 보호 로직 포함).

addMsg에서 DOM 삽입 직후 호출한다. innerHTML은 <script>를 실행하지 않으므로
삽입 시점마다 명시적으로 불러야 한다. TTS 버튼이 innerText를 읽어가므로
그보다 먼저 렌더링되게 배치했다 — 안 그러면 음성이 "달러 백슬래시 rightarrow
달러"를 읽는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:53:35 +09:00
kimandClaude Opus 5 f06955f967 fix: 금액의 $가 수식 여는 기호로 먹혀 뒤의 수식이 깨지던 문제
KaTeX auto-render는 `$`를 만나면 무조건 인라인 수식의 시작으로 보고 다음
`$`까지를 짝으로 묶는다. 그래서 답변에 금액이 하나라도 섞이면 그 뒤의
진짜 수식이 통째로 어긋난다.

브라우저에서 재현 확인:
  "감액 비율 50% $\rightarrow$ 10~20%"           → 화살표 정상
  "월 $100 절감 … 50% $\rightarrow$ 10~20%"      → "100 절감. 감액 비율 50"이
                                                   수식으로 잡히고 뒤는 날것

숫자가 바로 뒤따르는 `$`만 렌더링 직전에 사설영역 문자로 감췄다가 끝난 뒤
되돌린다. 수식이라면 `$\ce{...}$`, `$\rightarrow$`처럼 백슬래시나 문자로
시작하므로 숫자가 오는 건 사실상 전부 통화다. 렌더링이 실패해도 finally에서
복구한다 — 안 그러면 화면에 빈 네모가 남는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:53:21 +09:00
kimandClaude Opus 5 4ea87f1f7a feat: 파이썬 삼중따옴표 안의 HTML을 HTML로 하이라이팅
sprinkler_backend.py의 `DASHBOARD_HTML = """<!DOCTYPE html>…"""` 174줄이
통째로 단색으로 보였다. Monaco의 파이썬 문법(Monarch)이 삼중따옴표 내부를
`string` 토큰 하나로 처리하기 때문이고, VS Code의 TextMate 문법과 달리
Monarch에는 언어 주입이 기본으로 없다.

다만 Monarch에도 nextEmbedded가 있어서 구간을 다른 언어 토크나이저에
넘길 수 있다(Monaco 자체 HTML 문법이 <script>/<style>을 이렇게 처리한다).
그걸 파이썬에 얹었다. HTML로 넘기면 그 안의 <style>/<script>까지 연쇄로
살아난다.

판별: 삼중따옴표 뒤 첫 비어있지 않은 내용이 `<`로 시작하면 HTML로 본다.
Monarch는 줄 단위라 여는 줄에서 바로 판단할 수 없어 대기 상태를 하나 뒀다.
SQL 독스트링(conn.execute("""CREATE TABLE…))과 산문 독스트링은 `<`로
시작하지 않으므로 종전대로 문자열 색을 유지한다. 빗나가도 색만 이상해질 뿐
편집·저장에는 영향이 없다.

구현 중 밟은 것 둘:
 - 빈 줄에서 `/^\s*$/`는 폭 0으로 매칭돼 같은 상태에 머물며 무한 루프가
   된다(Monarch가 "진행 없음"으로 예외를 던진다). `/\s+$/`로 최소 한 글자를
   요구하면 빈 줄은 아예 매칭되지 않고 다음 줄로 넘어가며 상태만 유지된다.
 - HTML 언어를 미리 로드해야 한다. Monaco는 언어를 지연 로딩해서, 파이썬
   파일만 열면 HTML 토크나이저가 없어 임베딩이 조용히 실패한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:19:14 +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 1f4c0ae0ee fix: 코드앱 전체 점검 — 조용한 실패 3건 + 이스케이프 2층 누락
전체 점검에서 나온 것들. 앞선 커밋들과 달리 서버 모드 전용 문제가 아니라
로컬 모드에도 있던 버그다.

1) Write/Edit 툴이 쓰기 결과를 확인하지 않고 "저장 완료"를 돌려줬다.
   실패해도 모델은 파일이 생긴 줄 알고 다음 단계로 넘어간다 — 디스크에
   없는 파일을 Edit하려다 엉키거나, 다 끝났다고 보고해버린다.

2) 이름 변경이 파일을 잃을 수 있었다. 복사 후 삭제 구조인데 복사 성공을
   안 보고 원본을 지웠다. 새 경로에 못 썼는데 원본이 사라지면 파일이
   없어진다. 디렉토리 쪽은 삭제가 재귀라 더 나빴다. 이제 전부 옮겨진 게
   확인돼야만 원본을 지운다.

3) deleteActiveProject가 localStorage를 먼저 지우고 폴더 삭제를 나중에
   했다. 삭제가 실패하면 파일은 디스크에 남았는데 앱에서는 접근할 길이
   없는 유령 폴더가 된다. 순서를 뒤집어 성공했을 때만 정리한다.

4) `onclick="fn('${...}')"`은 HTML 속성과 JS 문자열 **두 층**을 순서대로
   통과하는데(브라우저가 엔티티를 먼저 풀고 그 결과를 JS가 읽는다),
   기존 헬퍼 둘이 각각 반쪽이었다:
     _csbEsc → `'`를 `&#39;`로 바꾸지만 HTML이 도로 `'`로 풀어 JS 문자열이 끊김
     safe    → `\'`로 JS는 막지만 `"`가 그대로라 속성이 조기 종료
   escAttrJs()로 JS 이스케이프 → HTML 이스케이프 순서를 지킨다. 따옴표가
   든 파일명(it's.py, say"hi".py)은 리눅스에서 합법이라 실제로 만들어진다.
   5개 케이스가 두 층을 통과해 원본으로 복원되는 것과, 서버 쪽 쓰기·목록·
   읽기가 정상인 것을 확인했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 11:45:13 +09:00
kimandClaude Opus 5 3a8438d7a7 fix: 코드앱 모드별 상태 분리 마무리 + 경로 처리 버그 2건
앞선 커밋(df56376)에서 프로젝트 목록만 모드별로 갈랐는데, 재점검하니
모드를 타야 할 상태가 둘 더 남아 있었다.

  code_proj_session_ / code_proj_history_
      프로젝트 **이름만**으로 키를 만들어서, 같은 이름이 양쪽에 있으면
      (로컬에도 서버에도 `solar`) 채팅 세션과 히스토리가 한 통으로 섞였다.
      서버 모드에만 `@srv:`를 끼운다 — 프로젝트 이름은 [a-zA-Z0-9가-힣_-]로
      정제되어 `:`가 못 들어가므로 이름 충돌이 원천 불가능하다.
      `_server_` 같은 접두사는 `server_solar`라는 로컬 프로젝트와 부딪힌다.
      키 접두사는 그대로라 전체 삭제 스캔은 계속 동작한다.

  csb_open_dirs
      트리 펼침 상태. 두 모드는 디렉토리 구성이 달라서 공유하면 한쪽에서
      펼쳐둔 경로가 다른 쪽에서 엉뚱하게 적용된다.

에디터 설정·테마·토큰 15개는 공유가 맞아서 그대로 뒀다.

경로 버그 2건:

  1) `f.name.replace(activeProjectName + '/', '')`는 String 인자를 받으면
     위치를 안 가리고 **첫 등장**을 지운다. 프로젝트가 `solar`일 때
     `src/solar/main.py`가 `src/main.py`로 뭉개졌다. startsWith로 앞부분만
     떼는 _clientBareFileName으로 교체.
  2) 모드 판정에 localDirHandle을 쓰던 곳 — 로컬 폴더를 열어둔 채 서버
     모드로 바꾸면 핸들이 살아 있어 로컬 분기로 잘못 빠진다. 모드와 핸들이
     독립인 지금은 실제로 재현되는 조건이다. codeIsServerMode()로 교체.

모델에게 보내는 파일 목록 라벨이 서버 모드에서도 "[로컬 파일 …]"로 나가던
것도 고쳤다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 11:37:16 +09:00
kimandClaude Opus 5 df56376ded fix: 코드앱 프로젝트 목록을 저장소 모드별로 분리
로컬 폴더에서 만든 프로젝트가 서버 모드 풀다운에도 나타났다. 골라봐야
그 모드엔 없는 프로젝트라 빈 트리만 떴다.

파일 목록은 이미 모드별로 갈려 있었는데 localStorage 키 두 개만 공유
상태였다: code_known_projects(알려진 프로젝트)와 code_active_project
(활성 프로젝트). 각각 _server 접미사 키를 두어 분리했다. 레거시 키는
로컬 쪽으로 넘긴다 — 로컬 전용 시절에 쌓인 값이라 거기가 제자리이고,
서버 쪽은 비어서 시작해도 실제 파일 목록에서 프로젝트가 복원된다.

모드를 바꿀 때 활성 프로젝트도 함께 갈아탄다. 예전엔 activeProjectName을
그대로 들고 넘어가서, 저쪽엔 없는 프로젝트가 선택된 채 트리는 비어 있고
헤더에만 이름이 뜨는 상태가 됐다.

이미 섞여 쌓인 목록은 모드마다 최초 1회 청소한다 — 그 모드의 실제 파일에
없는 이름을 걷어내되 활성 프로젝트는 남긴다(비어 있을 수 있으므로).
일회성인 이유는 정상 운영 중에는 파일이 아직 없는 새 프로젝트도 목록에
남아야 하기 때문이다. 매번 돌리면 방금 만든 빈 프로젝트가 사라진다.

activeProjectName 초기화는 _activeProjectKey()를 못 쓴다 —
codeStorageMode가 아래쪽 let이라 그 시점엔 TDZ다. 모드를 localStorage에서
직접 읽어 키를 고른다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 11:26:10 +09:00
kimandClaude Opus 5 dd8abe2e2b fix: 코드앱 서버 모드 전면 수리 — localDirHandle 가드 12곳이 기능을 막고 있었다
서버 워크스페이스 모드는 파일 I/O만 모드별로 갈라놨고, 프로젝트 단위
연산과 프롬프트 구성은 여전히 localDirHandle(로컬 폴더 핸들)을 요구하고
있었다. 서버 모드에선 그 값이 null이라 해당 기능이 조용히 죽었다.

가장 눈에 띈 증상은 "코드가 채팅에 나온다"였는데, 원인은 프롬프트였다.
저장소 안내가 로컬 폴더만 알아서, 서버 워크스페이스가 멀쩡히 붙어
있는데도 모델에게 "로컬 폴더가 연결되지 않았습니다"라고 알려주고 있었다.
저장할 곳이 없다고 들으면 모델은 Write를 안 쓰고 코드를 본문에 뱉는다.
현재 파일 내용을 싣는 블록도 같은 가드에 막혀 있어서, Edit이 맞출 원문을
못 본 채 전체를 다시 출력하는 쪽으로 흘렀다.

"프로젝트 디렉토리가 오락가락한다"는 별개였다. Read/Write/Edit는 베어
이름을 받아 _clientProjectPath로 접두사를 붙이는데 Glob/Grep만 전체
경로를 돌려줘서, 모델이 한 대화 안에서 두 이름 체계를 섞어 봤다. Glob이
준 `otherproj/x.py`를 Write에 넘기면 _clientBareFileName은 활성 프로젝트
접두사만 떼므로 `myproj/otherproj/x.py`로 중첩됐다. 목록이 활성 프로젝트로
범위도 안 잡혀서 남의 프로젝트로 새기도 했다. _projectScopedFiles로
활성 프로젝트 안 + 베어 경로로 통일했다.

"새 세션" 버튼은 부수효과가 물음보다 먼저였다. 히스토리 비우기가 prompt
위에 있어서 취소해도 대화가 이미 날아갔고, 이름 없이 빠져나오면 새 빈
세션이 옛 프로젝트에 묶여 파일이 옛 폴더로 들어갔다. 먼저 묻고, 취소
(null)와 빈 입력('')을 구분한다.

나머지 고친 곳: 프로젝트 생성/삭제/전환, _clientLoadProjectFiles,
폴더 삭제(확인 창조차 안 떴다), 폴더 목록(항상 "파일 없음"), 폴더뷰
파일 삭제, HTML 미리보기(CSS/JS 안 붙은 반쪽), 폴더 이름 바꾸기(빈 원본
디렉토리가 디스크에 남았다).

서버엔 대응 API가 아예 없어서 둘을 추가했다: POST /api/code/mkdir,
DELETE /api/code/dir. 디렉토리 삭제는 파일 삭제(/api/code/file)와 일부러
분리했다 — 한 엔드포인트에 재귀 삭제를 얹으면 파일 하나 지우려던 호출이
오타 하나로 프로젝트를 통째로 날린다. 루트 삭제와 경로 탈출은 403.

Bash 툴과 companion 실행은 손대지 않았다. 서버 파일은 사용자 PC에 없어서
구조적으로 로컬 전용이다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:25:45 +09:00
kimandClaude Opus 5 17d6f2824e fix: 언어앱 커스텀 카테고리를 서버에 저장 + 카드 저장 실패를 삼키지 않기
"어휘카드를 추가해도 자꾸 없어진다" 리포트. 카드는 custom-cards.json으로
이미 서버에 있었는데 **카테고리는 localStorage에만** 있었다. 브라우저
데이터를 지우거나 다른 기기에서 열면 카테고리 버튼이 통째로 사라지고,
그 카테고리로 만든 카드는 분류 탭에서 안 보인다(전체 탭에만 남아
"없어진" 것처럼 보인다).

custom-categories.json에 언어별로 저장한다. 병합 방식인 게 중요하다 —
클라이언트가 보낸 목록으로 덮어쓰면 localStorage가 빈 새 기기에서 앱을
한 번 여는 것만으로 서버 카테고리가 전부 날아간다. 빈 배열을 보내도
기존 값이 유지되는 걸 확인했다. 로컬에만 있던 카테고리는 첫 접속 때
서버로 올라가므로 지금 브라우저에 남아 있는 건 손실 없이 넘어간다.

같이 고친 것: saveCustomCards가 .catch(()=>{})로 실패를 삼키면서 호출부는
"✅ N개 추가되었습니다"를 띄우고 있었다. 세션 만료로 POST가 401이 나도
사용자는 성공한 줄 알고, 새로고침하면 카드가 없다 — 리포트된 증상과
정확히 같은 모양이다. 이제 저장을 기다린 뒤 결과에 맞는 문구를 띄운다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:25:19 +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 955406e3bd fix: .ino 파일이 문법 강조 없이 흑백으로 열리던 문제
사용자 질문: "스케치도 파이썬처럼 컬러풀하게 할 수 없나?"

CODE_LANG_MAP에 ino가 없어서 .ino 가 plaintext 로 열리고 있었다. 파이썬은
알록달록한데 아두이노 스케치만 밋밋했던 이유다.

앞서 주석 색을 고치느라 두 커밋을 썼는데, 정작 .ino 에서는 색 규칙이 적용될
토큰 자체가 없었다. 증상(주석이 안 변함)만 보고 테마 쪽을 팠지, 그 파일이 애초에
어떤 언어로 열리는지를 확인하지 않았다.

ino/pde 를 cpp 로 매핑하고, 같이 빠져 있던 h/hpp/cc/cxx, ini/cfg/conf 도 채웠다.

실측(실제 solar.ino 일부를 Monaco에 그려서 확인): 언어 cpp 로 인식,
주석 #7EC699 / 키워드 #569CD6 / 숫자 #B5CEA8 / 괄호 짝 색칠까지 9종 색상 표시.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:35:18 +09:00
kimandClaude Opus 5 b2316592b6 fix: 주석 색 재정의가 초기 화면에 적용되지 않던 문제
사용자 신고: "색깔이 안 바뀌고 이탤릭체 아닌데?" — 맞는 지적이었다.

앞 커밋에서 vs-dark 의 주석 규칙을 defineCodeThemes() 안에 넣었는데, 정작 그 함수가
불리지 않는 경로가 둘이었다.

1) csbTheme(): 내장 테마 이름이면 defineCodeThemes()를 건너뛰었다.
     if (!['vs','vs-dark','hc-black','hc-light'].includes(theme)) defineCodeThemes();
   커스텀 테마만 정의하던 시절엔 맞는 최적화였지만, 이제 vs-dark 재정의가 바로
   그 목록 안에 있어서 "정확히 필요한 경우에만" 실행되지 않았다.
2) 에디터 생성 시: defineCodeThemes() 없이 theme:'vs-dark' 로 create() 했다.
   첫 화면이 내장 기본값으로 그려지고, 테마를 한 번 바꾸기 전까지 그대로였다.

create() 앞에서 정의하도록 옮기고, 저장된 테마가 있으면 그것으로 생성한다.

실제 defineCodeThemes를 파일에서 그대로 뽑아 브라우저 Monaco로 검증:
  vs-dark  #7EC699 italic  ✅ 초기 렌더부터 적용
  vs       #008000 (내장)   ❌ 같은 방식이 안 먹음 → 죽은 코드 제거
검증 중 알게 된 것: 현재 활성 테마를 defineTheme으로 다시 정의하면 즉시 반영되지
않는다. 테마 전환 테스트는 매번 setTheme 후 재렌더를 기다려야 값이 맞다.

별건으로 확인된 기존 문제(이 커밋에서 건드리지 않음): monokai/dracula 등 커스텀
테마의 주석 foreground 가 자기 정의값이 아니라 #608B4E 로 나온다. fontStyle 은
적용되는데 색만 안 먹는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:31:26 +09:00
kimandClaude Opus 5 276a11a7f2 feat: 기본 테마의 주석 색을 밝은 녹색 + 기울임으로
사용자 요청: "에디터 주석 라인 색깔이 좀 다르면 좋겠네".

커스텀 테마 8종은 주석 색을 직접 정의하고 있었지만, 정작 기본값인 vs-dark / vs 는
Monaco 내장 테마라 그 목록에 없었다. 그래서 주석이 내장 기본색(어두운 녹색
#6A9955)으로 나왔고, 주석이 많은 파일에서는 잘 읽히지 않았다.

내장 테마도 같은 이름으로 defineTheme을 부르면 덮어쓸 수 있다(실측 확인).
inherit:true 라서 주석 규칙만 바뀌고 키워드/문자열/숫자 색은 내장값 그대로 남는다.

  vs-dark : #7EC699 italic
  vs      : #3F8F5F italic  (밝은 배경에서 같은 값은 너무 흐려서 대비가 남는 선까지만)

브라우저에서 실제 Monaco로 검증: 주석 rgb(126,198,153) + italic, 키워드 #569CD6 /
숫자 #B5CEA8 / 문자열 #CE9178 는 내장값 유지.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:25:00 +09:00
kimandClaude Opus 5 d0f698448d fix: 서버 모드에서 저장이 다른 경로로 새 파일을 만들던 문제
사용자 신고: "편집이 적용 안되는 듯 하던데?"

탭 이름에서 프로젝트 접두사를 떼는 쪽과 디스크에 쓸 때 도로 붙이는 쪽의 조건이
비대칭이었다.

  _clientBareFileName: if (!activeProjectName) return fname;            // 뗌
  _clientProjectPath : if (!localDirHandle || !activeProjectName) ...   // 안 붙임

서버 모드에는 localDirHandle이 없으므로 떼기만 하고 안 붙였다. 그래서
`solar/solar.ino`를 트리에서 열어 고치고 저장하면 루트의 `solar.ino`로 새로 써지고
원본은 그대로 남았다 — 편집이 사라진 것처럼 보인다. 실제로 워크스페이스에
27KB짜리 루트 사본이 만들어져 있었다.

두 함수는 대칭이어야 한다. 저장소 종류와 무관하게 activeProjectName만 본다.
codeNewFile에도 같은 비대칭이 있어 함께 정리했다(양쪽 분기가 같은 값이 되어
삼항연산자가 무의미해진 것도 걷어냄).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:19:40 +09:00
kimandClaude Opus 5 89ce7f3563 fix: 서버 모드에서 파일 트리가 동작하지 않던 문제
사용자 신고: "초록색인데 로컬폴더가 열리네".

앞 커밋에서 가드 9곳을 codeStorageReady()로 바꿨으나 파일 트리 쪽을 통째로
빠뜨렸다. codeSbRefresh()가 localDirHandle이 없으면 트리를 "로컬 폴더를
선택하세요 / 📁 폴더 열기" 화면으로 덮어버려서, 서버 모드로 바꿔도 그 화면이
뜨고 거기 버튼을 누르면 당연히 로컬 폴더가 열렸다.

같은 이유로 막혀 있던 것들을 함께 풀었다. csbOpenFile이 제일 치명적이었다 —
트리에서 파일을 눌러도 아무 일이 없었으니 서버 모드가 사실상 무용지물이었다.

  codeSbRefresh / csbOpenFile / csbDeleteFile / csbRenameExec /
  csbNewInDir / csbNewFolderAt / csbNewFolderRoot / csbMoveFile /
  codeOpenFromFolder / grep 도구

전부 이미 서버로 분기시킨 원시함수만 쓰고 있어서 조기 반환만 풀면 됐다.
폴더 생성(.gitkeep 저장)도 앞 커밋의 하위 경로 지원 덕에 그대로 동작한다.

예외: 폴더 이름 변경의 마지막 단계만 로컬 핸들로 통째 삭제하고 있었다.
서버에는 디렉토리 삭제 API가 없으므로 원본 파일을 하나씩 지운다 — 빈 디렉토리는
남지만 목록이 파일 기준이라 트리에서는 사라진다.

로컬 전용으로 남긴 것: Bash 도구(로컬 셸), 프로젝트 prefix 필터, 그리고 원시함수
안쪽의 로컬 구현부(서버 분기 뒤라 도달하지 않음).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 16:55:08 +09:00
kimandClaude Opus 5 3ffd5987ee fix: 저장 위치 토글을 이모지 대신 글자로
사용자 지적: "구름이 아니고 파일 버튼인데?"

같은 줄에 이미 💾(파일 저장)와 📁(새 폴더)가 있는데 로컬 모드를 💾로 표시해서,
바로 옆 저장 버튼과 똑같아 보였다. ☁️/💾 한 쌍만 보고 골랐지 그 줄에 뭐가 이미
있는지 안 봤다.

🖴 같은 다른 이모지로 바꾸는 대신 "로컬"/"서버" 글자로 현재 위치를 그대로 적는다.
아이콘 추측이 필요 없고 글꼴에 따라 안 보일 걱정도 없다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 16:50:21 +09:00
kimandClaude Opus 5 8f5b042886 feat: 코드 에디터에서 서버 워크스페이스로 작업 (업로드는 계속 로컬)
에디터는 File System Access API로 브라우저가 고른 로컬 폴더만 읽고 썼다. 그래서
서버에서 파일을 고쳐도 에디터에는 닿지 않는다 — 2026-08-15에 실제로 서버 사본을
고쳐놓고 보드에는 에디터의 다른 파일이 올라가, 같은 스케치가 두 벌로 갈린 채
한참을 헤맸다.

서버 API(/api/code/files, /api/code/file, /api/code/save)는 이미 다 구현돼 있었는데
프런트가 한 번도 부르지 않고 있었다. 코드에 "In server mode with activeProjectName"
같은 주석만 남아 있는 걸로 보아 예전에 만들려다 만 흔적이다.

호출부가 15곳이라 그쪽을 다 고치는 대신 localReadFile/localWriteFile/localListFiles/
localDeleteFile 네 개 원시함수만 서버로 분기시켰다. 로컬 폴더 유무로 막던 가드
9곳은 codeStorageReady()로 바꿔 서버 모드에서도 통과한다.

업로드는 그대로 로컬이다. Web Serial은 브라우저에서만 가능하지만, 굽는 대상이
에디터 버퍼(_cupCode)라 파일이 어디서 왔든 상관없다 — 저장 위치만 옮겨간다.

로컬 PTY 셸(1822행)과 Bash 도구(3140행)는 성격상 로컬 전용이라 제외했다.

함께 고친 백엔드 결함: POST /api/code/save가 path.basename()으로 경로를 납작하게
만들어 `projA/hello.ino`와 `projB/hello.ino`가 같은 `hello.ino`로 떨어졌다. 읽기는
이미 하위 경로를 지원하고 있어서 읽은 곳과 쓴 곳이 어긋나는 상태였다. resolve +
접두사 검사로 탈출만 막고 하위 경로는 허용하도록 수정.

실측: projA/projB에 같은 파일명으로 저장 → 서로 덮어쓰지 않음, 목록에 경로 그대로
노출, `../../../tmp/pwned.txt`는 403.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 16:44:30 +09:00
kimandClaude Opus 5 ade18395b6 feat: 코드앱 보드 선택기에 "최근 사용" 섹션
보드 목록이 202개인데 그중 144개가 rp2040 변형이다(2026-08-15 실측). 실제로 쓰는 건
한둘인데 매번 그 사이를 스크롤하거나 검색어를 타이핑해야 했다. 사용자 표현으로
"보드 종류가 너무 많다".

고른 보드를 최대 6개까지 기억해 목록 맨 위에 띄운다. 전체 목록은 그대로 두므로
새 보드를 찾는 길은 막지 않는다 — 목록을 접어 숨기는 방식도 검토했으나 그건
"찾는 비용"을 옮길 뿐이고, 이쪽은 평소 동선 자체를 없앤다.

- 같은 보드를 다시 고르면 중복으로 쌓이지 않고 자리만 앞으로 옮긴다
- 검색 중에는 최근 섹션을 숨긴다 — 목표가 분명한 상황에서 질의와 무관한 항목이
  맨 위에 끼면 방해가 된다
- localStorage가 깨져 있어도 빈 배열로 떨어진다(선택기 전체가 죽지 않게)

브라우저에서 실제 함수로 검증: 최신순 정렬, 재선택 시 중복 없이 이동, 6개 상한,
깨진 저장값 방어 — 전부 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 16:22:38 +09:00
kimandClaude Opus 5 7b722bc1c5 fix: OTA 인증 무응답을 "암호가 다릅니다"로 잘못 안내하던 문제
espota.py의 두 문구를 한 묶음으로 처리하고 있었다.

  Authentication Failed          → 암호가 틀림
  No Answer to our Authentication → 보드가 인증 패킷에 답을 못 함

뒤엣것은 암호와 무관하게 보드가 응답 불능일 때 나온다. 2026-08-15 실측:
스케치가 SoftwareSerial로 연속 송신하며 WiFi 스택을 굶겨 보드가 먹통이 됐는데,
OTA가 "스케치의 OTA_PASSWORD와 입력한 암호가 다릅니다"라고 안내했다. 암호는
멀쩡했고, 그대로 믿었으면 엉뚱한 곳을 뒤졌을 것이다.

이제 무응답은 별도 안내로 분리해서 실제 원인(블로킹 루프/재부팅)과 다음 조치
(전원 재인가 → 그래도 안 되면 USB)를 알려준다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 16:10:18 +09:00
kimandClaude Opus 5 f315f437b6 perf: OTA 업로드에 빌드 캐시 + 진행 상황 스트리밍
사용자 신고: "이후 너무 오래 조용하네", "뭐하는 지 알 수 있음 좋겠군".

두 가지 문제가 겹쳐 있었다.

1) 매 요청마다 새 임시 디렉토리에 컴파일해서 ESP8266 코어 전체를 처음부터 다시
   빌드했다. 실측: 21.7초(cold) vs 3.8초(build-path 재사용). 기다린 시간의 대부분이
   바뀌지도 않은 코드를 다시 컴파일하는 것이었다. 보드별로 유지되는 build-path를
   쓴다. 같은 디렉토리에 동시 컴파일이 겹치면 결과물이 조용히 깨지므로 업로드를
   직렬화한다 — 가정용이라 동시 업로드는 드물지만, 터졌을 때 증상이 "이상한
   바이너리"라 원인 추적이 어렵다.

2) 응답이 끝나야 한 번에 오는 구조라 30초 넘게 아무것도 안 보였다. 멈춘 진행바는
   먹통과 구분이 안 된다. 단계를 줄 단위로 흘려보내고 프런트가 읽어서 찍는다.
   espota는 \r로 한 줄을 덮어쓰며 1460바이트 chunk마다 진행률을 뱉으므로,
   10% 단위로 실제로 값이 바뀔 때만 내보내 로그 범람을 막았다.
   실패/성공은 마지막 __DONE__ 줄로 구분한다.

espota 호출을 execFile에서 spawn으로 바꿨다(진행률을 실시간으로 받으려면 필요).
인자는 여전히 배열로 넘겨 암호가 셸에 닿지 않는다.

실측(라우트 직접 호출, 도착 시각 확인):
  1회차 15:19:20 시작 → 21.6초 컴파일 → 전송 → 실패 힌트까지 단계별 도착
  2회차 캐시 적용 7.9초

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 15:20:42 +09:00
kimandClaude Opus 5 435c8f4e9c feat: 코드앱에서 ESP 보드 무선(OTA) 업로드
인버터 옆처럼 USB를 다시 꽂기 어려운 곳에 보드를 설치하면 시리얼 경로가 무용지물이
된다. 코드앱 업로드 패널은 지금까지 USB 전용이었다 — 플래싱을 브라우저에서
Web Serial(esptool-js/STK500/STM32 UART)로 직접 하는 구조라 무선 경로가 없었다.

OTA는 브라우저가 할 수 없는 일이다. 페이지에서 보드로 espota 핸드셰이크를 열 방법이
없어서, 이 업로드 경로만 서버를 경유한다.

- 백엔드: /api/arduino/upload의 port가 IP면 OTA로 분기 — 컴파일해서 .bin을 만들고
  코어에 들어있는 espota.py로 밀어넣는다. espota.py는 arduino-cli가 아니라 ESP 코어에
  딸려오고 코어를 올릴 때마다 버전 디렉토리가 바뀌므로 호출 시점에 최신 버전부터
  찾는다. 부트로더/파티션 이미지는 걸러내고 애플리케이션 바이너리만 보낸다.
  ESP32는 3232, ESP8266은 8266 포트.
- 암호는 exec가 아니라 execFile로 넘긴다 — 사용자가 넣는 값이라 셸에 닿으면 안 된다.
- 프런트: 패널에 📡 OTA 토글과 보드 IP/암호 입력줄 추가. OTA 모드에서는 시리얼 포트를
  안 골라도 업로드 버튼이 살아있어야 하므로 disabled 조건을 분리했다.

에러 힌트는 espota.py의 logging.error() 문구를 직접 읽고 맞췄다. 처음엔 "No response
from device"를 기준으로 짐작해서 썼는데, 실제로 응답 없는 보드는 **"No Answer"**를
뱉어서 힌트가 한 번도 안 떴다(실측 확인). 안 뜨는 힌트는 없느니만 못하다.
인증 실패/무응답/Listen 실패를 각각 다른 안내로 매핑.

실측: 실제 rems_solar_esp8266.ino로 엔드포인트 호출 → 컴파일 성공, .bin 탐색,
espota 실행, 응답 없는 IP에 대해 3단계 확인 순서 힌트까지 정상 출력.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:42:01 +09:00