Chroma 벡터검색만으로는 IP·모델명 같은 정확한 용어를 놓치는 경우가 있어
SQLite FTS5(trigram) 키워드검색을 병합하고, 애매한 경우(예: 클로서버 vs
지서버 GPU 스펙 혼동)만 LLM 재랭킹으로 오답을 걸러내도록 함. 재랭킹은
후보가 없거나 확실한 단일매치일 때는 건너뛰어 대부분의 대화에서는 지연시간
증가가 거의 없음. 기존 570개 기록은 scripts/backfill-fts.ts로 백필.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
이미지/동영상/사진편집/이모티콘세트 탭과 ComfyUI 탭 양쪽에 AI 프롬프트
도우미를 붙여, 한국어로 설명하면 SDXL/FLUX/LTX용 영어 프롬프트를 만들어줌.
생성 중에는 예상 소요시간 기준 진행률 바를 표시.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
diffusers venv 스크립트 방식 대신 상시구동 ComfyUI(systemd, GPU1)를 통해
SDXL/FLUX/PuLID 스타일변환/LTX 비디오를 생성하도록 전환. ComfyUI 자체 웹
UI를 리버스 프록시로 스튜디오 앱에 같은 도메인으로 임베드(routes-comfyui.ts)
하고, LTX-Video에 non-distilled "dev" 체크포인트 기반 고화질 옵션을 추가.
날씨 앱에는 Windy 제트기류/태풍 마커, 게이트웨이에는 wol-gate 웨이크 로그
조회 API도 함께 반영.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
해외 클라우드/스캐너 IP가 루트 경로 한 번 찔러보는 것만으로 매직패킷이
발사되는 사례(143.198.239.149 등)가 반복돼, 요청 IP가 APNIC KR 대역
(ipv4/ipv6, kr-cidrs.json)에 속할 때만 실제로 깨우도록 제한. LAN/루프백
대역은 홈 NAT 헤어핀으로 사설 IP로 잡히는 경우가 있어 항상 허용 유지.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
사이드바 사건 목록에서 ✎ 버튼으로 바로 이름 변경 가능하게 추가 (헤더까지
안 들어가도 됨). 처음엔 더블클릭 방식으로 짰다가 실제 클릭으로 테스트하니
안 먹혀서 원인을 봤더니 — 더블클릭은 실제로 click·click·dblclick 순으로
발생하는데, 앞의 두 click이 부모(사건 선택)로 버블링되면서 목록을
통째로 다시 그려버려 dblclick 핸들러가 붙어있던 엘리먼트가 바뀌는
경쟁 상태였음. 더블클릭 대신 별도 버튼(단일 클릭 + stopPropagation)으로
바꿔서 해결.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
실사용 중 발견된 버그:
- 첨부파일 위치: PDF/DOCX 등 문서 파일을 업로드하면 어느 탭 버튼을 눌렀든
무조건 "제출서류" 탭으로만 가버려서, "첨부자료" 탭에서 업로드한 파일이
사라진 것처럼 보였음(탐정 앱의 증거/서류 분류 로직을 그대로 가져온 부작용).
파일 확장자가 아니라 실제 업로드한 탭(attachments/submissions 폴더)
기준으로 분리하도록 수정 — 기존에 폴더 구분 없이 올라간 파일은 첨부자료
탭에 계속 보이게 하위호환 유지.
- 사건 자동 이름짓기: 메모 탭에 직접 타이핑해야만 작동했는데, 실사용자는
메모 대신 채팅으로만 사건을 상담해서 한 번도 안 뜬 것이었음. 채팅 한 턴이
끝날 때도(사건 선택 상태라면) 그 메시지로 이름짓기를 시도하도록 추가.
실제 사용자 데이터로 재현·검증 완료 (전세보증금 반환 사건 채팅 → "새 사건"이
"전세금반환소송"으로 자동 변경 확인).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
메모를 처음 작성하고 포커스를 벗어나면(blur), 제목이 아직 기본값 "새 사건"인
경우에 한해 AI가 메모 내용을 보고 8자 이내 사건명을 지어 채워줌. 채팅창에는
안 보이는 조용한 호출(quietChatCall)로 처리 — 이미 만든 generateChecklist용
callChatRaw(화면에 타이핑 표시)와 달리 UI에 아무 흔적을 안 남김. 사용자가 이미
직접 이름을 바꿨거나 다른 사건으로 넘어간 경우는 덮어쓰지 않음.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- 채팅창 파일 첨부 버튼 추가 (PDF/TXT, 회계사 앱과 같은 /api/upload/file 재사용,
pdf_read로 계약서 분석 — 스킬 프롬프트엔 이미 방법론이 있었는데 버튼만 없었음)
- 탐정 앱의 "사건" 관리를 법률 앱에 이식: 사건 목록(검색/정렬) + 사건별 메모·
체크리스트·타임라인·첨부자료·제출서류 탭 + 사건별 독립 AI 대화 세션
(다른 사건 컨텍스트가 안 섞임, 탐정 앱과 동일 패턴)
- PDF 편집기(Stirling-PDF)·Collabora 문서 편집 연동도 그대로 이식 — 사건 폴더에
자동 저장됨
- 백엔드는 탐정 앱 기존 코드를 건드리지 않고 새 공용 모듈(case-storage.ts)로
일반화 — appType 파라미터로 detective/lawyer 둘 다 같은 로직 재사용,
탐정 앱 데이터·동작은 그대로 유지(회귀 테스트로 확인)
- 사진 자동 날짜인식(탐정 앱 전용, 카톡 캡처 등에서 날짜 추출) 등 법률 사건과
무관한 기능은 이식 범위에서 제외
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Windy iframe이 실제 로딩보다 훨씬 먼저 스피너를 걷어 몇 초간 완전 블랙스크린으로
보이던 문제 수정 (반투명 오버레이 + 고정 타이머)
- 스트리밍 중 Windy URL이 아직 다 도착 안 한 상태에서 잘린 좌표로 지도를
이동시키고 잠가버리던 레이스 컨디션 수정
- Windy는 태풍 예상 진로(향후 경로)를 그리지 않는다는 걸 명확히 하고, 실제로
경로를 그리는 기상청 태풍 페이지 + NOAA/NHC 페이지를 지도에 임베드하는 기능 추가
(nhc_active_storms 신규 도구로 이름→공식 forecast-track URL 조회, 대양/슬롯
번호를 모델이 추측하지 않도록 함)
- 채팅창에 원문 도구호출(web_search{...} 등)이 그대로 노출되던 누출 수정 —
기존 정리 로직은 메시지 전체/끝부분만 잡아서 중간에 낀 누출은 못 걸렀음
- Windy/NHC URL 정규식이 마크다운 괄호·별표까지 삼켜 URL을 깨뜨리던 과매칭 버그 수정
- 모델이 NHC URL의 슬롯 번호를 잘못 베끼는 경우에 대비해, 도구가 찾은 검증된
URL을 직접 스트림에 실어보내 지도가 항상 정확한 폭풍을 가리키도록 함
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
index.html(인라인 0%)과 code.html이 이미 쓰던 방식을 나머지 큰 페이지에 적용.
각 파일이 거대 인라인 <script> 하나씩이라 순수 이동으로 처리됨.
pptx-wizard.html 3,988 → 820줄 (/js/pptx/pptx-wizard.js 3,174줄)
music-app.html 4,549 → 1,197줄 (/js/music/music-app.js 3,360줄)
arduino-emulator.html 8,026 → 849줄 (/js/arduino/arduino-emulator.js 7,186줄)
보존한 것:
- arduino의 type="module" 유지. 모듈 스코프에서는 최상위 선언이 전역이 되지
않으므로, onclick 핸들러가 의존하는 window.foo= 노출 방식이 그대로 성립해야 함
- music-app의 테마 설정 스크립트는 인라인 유지 — 페인트 전에 실행돼야 하고
외부화하면 네트워크 왕복 때문에 다크/라이트가 깜빡임
- 로드 위치는 셋 다 원래대로 </body> 직전
- document.currentScript / document.write / import.meta 사용 0건 확인
(인라인과 외부의 의미가 달라지는 패턴 없음)
브라우저 실측 검증 중 아두이노 페이지에서 기존 버그 2건 발견 — 이동이 원인이
아니라 원래 깨져 있던 것이 파일 위치가 바뀌며 드러남:
1) 동적 import 경로가 처음부터 틀려 있었음
import('./vendor/avr8js/index.js')는 /html/vendor/... 를 가리키는데 실제
파일은 /vendor/... 에 있음. 서버에 SPA 캐치올이 있어 없는 경로도 200 + HTML을
반환하는 탓에 404로 드러나지 않았고, JS 대신 HTML을 받아 import가 실패했음.
절대경로 /vendor/... 로 수정(avr8js, rp2040js 2건).
2) 실패 핸들러가 2차 예외를 던져 모듈 전체를 중단시킴
위 import 실패의 catch에서 showOverlayError()를 부르는데, 이 함수는 파일
2874줄에서 window에 할당되므로 그 시점에는 아직 없음 → ReferenceError로 모듈
실행이 통째로 멈추고, 아래쪽 window.* 노출이 전혀 일어나지 않아 onclick
핸들러 100개가 전부 죽어 있었음. 로드 실패보다 이 2차 실패가 훨씬 치명적.
console.error로 먼저 남기고 showOverlayError는 있을 때만 호출하도록 변경.
3) 전수 점검 중 window 노출이 누락된 핸들러 6개 발견(mobCloseBoard,
mobToggleBoard, _wifi* 4개) — 클릭해도 ReferenceError만 나던 상태. 명시적 노출 추가.
브라우저 실측(192.168.0.5:18789):
pptx-wizard 전역 함수 6/6, onclick 87개, 콘솔 에러 없음
music-app 전역 함수 6/6, onclick 103개, 인라인 테마 정상(data-theme=dark)
arduino onclick 이름 해석 0/35 → 35/35, avr8js 로드 성공, 콘솔 깨끗
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
앱 HTML 복붙 조사의 후속. getToken/authH는 12개 앱에 복사돼 있는데
dental-agent 한 곳만 다른 구현이었고, 두 가지 실제 동작 차이가 있었음.
1) getToken에 try/catch 없음
나머지 11개는 예외를 삼키고 빈 문자열을 반환하는데 dental-agent만 그대로
던짐. 시크릿 모드나 스토리지가 차단된 환경에서 sessionStorage.getItem이
SecurityError를 던지면 페이지 초기화가 그 자리에서 멈춤. 실증:
차단 상황 dental → 예외 전파 / 표준 → "" 반환
2) authH가 토큰 없을 때 입력 객체를 그대로 반환
`return t ? {...extra, Authorization} : extra` 형태라 토큰이 없으면 호출자가
넘긴 객체를 참조째 돌려줌. 호출자가 그 객체를 재사용하면 서로 오염될 수 있는
구조(표준은 항상 새 객체를 만듦). dental-agent의 실제 호출부는 대부분 객체
리터럴이라 현재 피해는 없었으나, 같은 이름의 함수가 페이지마다 다르게
동작한다는 점이 문제.
전 앱 스캔으로 이 두 결함이 dental-agent에만 있는 것을 확인한 뒤 표준으로 교체.
이제 getToken/authH 모두 저장소 전체에서 단일 버전.
검증: dental-agent가 쓰는 네 가지 호출 형태 authH() / authH({...}) /
authH(null) / authH(opts.headers||{}) 전부 정상 동작, 스토리지 차단 시 예외
없음, 배포 후 페이지 HTTP 200 및 /api/dental/status 인증 통과 확인.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
앱 HTML들의 복붙 현황을 조사하다 발견. 같은 이름의 escHtml이 4가지 버전으로
갈라져 있었고, 그 차이가 실제 동작 차이였음.
결함 1 — 속성 인젝션:
6개 앱(detective/investor/lawyer/mind/traffic/weather)과 2개 앱(nvr/writer)의
버전이 & < > 만 이스케이프하고 따옴표는 건드리지 않았음. 그런데 이 함수를 HTML
속성값 안에서 호출하는 곳이 8군데 있었음:
writer-app src="${escHtml(b.cover)}" href="${escHtml(it.url)}" href="${escHtml(b.link)}"
nvr-app name="${escHtml(...)}" x2
lawyer-app id=/name="${escHtml(...)}"
writer-app의 b.cover는 routes-writer.ts가 Google Books/구텐베르크/알라딘 응답을
그대로 담아 보내는 값(vi.imageLinks?.thumbnail 등) — 우리가 통제하지 않는 외부
데이터가 따옴표 미이스케이프 상태로 속성에 들어가고 있었음. 실증:
입력 x" onerror=alert(1) y="
이전 <img src="x" onerror=alert(1) y=""> ← 속성 탈출
이후 <img src="x" onerror=alert(1)...">
결함 2 — 숫자 0과 false가 화면에서 사라짐:
6개 앱이 String(s||'')를 쓰고 있어 escHtml(0)이 '', escHtml(false)도 ''를
반환했음. 재생수 0, 조회수 0 같은 값이 빈칸으로 렌더링되는 경로.
nvr/writer만 String(s==null?'':s)로 올바르게 처리하고 있었음.
11개 앱 전부를 language-app이 이미 쓰고 있던 가장 완전한 형태로 통일:
String(s ?? '') + [&<>"'] 전체 이스케이프
따옴표/홑따옴표를 엔티티로 바꿔도 텍스트 문맥에서는 브라우저가 동일하게
렌더링하므로 기존 화면 변화 없음. 배포 후 7개 앱 HTTP 200 및 writer-app의 실제
렌더 경로(악의적 커버 URL)로 검증함.
남은 중복은 이번에 수정하지 않음 — 조사 결과 callChat(7종)/mobToggle(10종)/
initSessionId(7종)은 발산 버그가 아니라 앱별 정당한 차이였음(세션 접두사, 패널
구조, Windy 자동로드 같은 훅). 통합하려면 훅 설계가 필요하고 지금 고장난 곳은
없으므로 보류.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
execute-tool.ts에 남은 순수 로직을 조사하다 발견. I/O가 전혀 없는 유일한 긴
블록(92줄)이었지만 dormantAgentTools에 들어 있어 모델에게 노출조차 되지 않는
죽은 코드였음.
감사 로그(tool_audit.log) 전체 기간 호출 2회:
2026-05-26 OK "daily at 8am"
2026-06-18 FAIL "every weekday at 9:30am"
실패 원인은 파서가 daily/weekly/cron 문자열만 지원하고 평일(weekday) 패턴이
아예 없어서. 즉 쓰이지도 않는 데다 흔한 표현에 구멍이 있는 상태였음.
삭제 범위(참조 0개 확인 후 진행):
- execute-tool.ts의 case 블록 90줄
- build-tools.ts의 도구 정의 15줄
- tool-scope.ts / code-chat.ts의 dormant·blocked 목록 항목
- tool-scope.ts의 dormant 설명 주석에서 해당 도구 언급 제거
웹UI·스킬·schedule_job 어디서도 참조하지 않는 것을 사전 확인했고, 배포 후
"예약된 스케줄 목록" 요청이 schedule_job으로 정상 처리되는 것도 확인.
도구 총 개수 84 → 83. 테스트 142개 통과.
스케줄 자연어 파싱이 다시 필요해지면 이 커밋을 되살리기보다, 평일/격주 등
실제 쓰는 패턴을 정하고 테스트와 함께 새로 만드는 편이 낫다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
단위 없는 주장(날짜·순위)이 numeric-grounding의 사각지대로 남아 있어 확장.
날짜 — 추가함. 단 형식 관용이 필수:
출처는 "Nov 10th, 2015" / "November 2015" / "2015-11-10" / "2015년 11월"을
제각각 쓰는데 답변은 한국어로 나온다. 실제로 모델이 영문 스펙 페이지를 보고
"2015년 11월 10일"이라 답했으므로, 단순 문자열 대조를 넣었다면 정답이 오탐으로
걸렸을 것. 연도 일치 + 월을 영문명/약어/ISO/한글 중 어떤 형태로든 연도 근처에서
찾는 방식으로 구현. 연도만 맞고 월이 다르면 걸린다.
날짜를 묻지 않은 질문에서는 답변에 연월이 섞여 있어도 판정하지 않음.
순위 — 넣지 않기로 결정. 근거:
순위표는 위치를 선행 맨숫자로 적는다("40 경상북도 구미시 402,726"). "위" 인접을
요구하면 정답인 답변이 오탐으로 걸리고(나무위키 실제 표로 확인), 단위를 떼고
맨숫자 "40"만 찾으면 어떤 문서에나 있어 검증이 무의미해진다. 오탐 아니면 무동작
둘 중 하나이므로 추가할 가치가 없음. 이 판단 근거를 테스트로 고정해 나중에
누가 다시 넣으려 할 때 이유가 보이도록 함.
배포 후 확인(둘 다 정답, 가드 미발동 = 오탐 없음):
- "Tesla M40 몇 년 몇 월 출시" → 2015년 11월
- "구미시 인구 전국 몇 위" → 402,726명 40위 (나무위키 원본 표와 정확히 일치,
모델이 web_fetch 후 python_eval로 직접 스크래핑해 산출)
테스트 142개 통과(날짜 4개, 순위 제외 근거 1개 신규).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
기존 세 가드가 모두 엉뚱한 질문을 하고 있었음:
- AUTO-RECOVER "근거 도구를 호출했는가?" → 예
- EMPTY-GROUNDING "결과가 비어있는가?" → 아니오
- looksLikeUnverifiedSpecClaim은 검색을 아예 안 했을 때만 발동
아무도 "그 숫자가 검색 결과에서 나왔는가?"를 묻지 않았음.
실사고(2026-07-29): 벤치마크가 존재하지 않는 조합(Tesla M40 2장 + Qwen3.6 35B)의
처리량을 묻자, 모델이 검색을 수행하고(이름이 비슷한 Apple M4 가이드가 걸림)
"초당 약 2~5 토큰"이라 단정. 세 가드 모두 통과시킴.
src/gateway/chat/numeric-grounding.ts 신설 — 답변의 (숫자, 단위) 주장이 이번 턴
도구 출력에 실제로 등장하는지 대조. 단위 근처(40자 이내)에 같은 숫자가 있어야
근거로 인정.
임계값을 실전에서 두 번 틀렸고, 두 번 다 실제 데이터가 잡아줌 — 둘 다 테스트로
고정:
1차 "모든 수치가 미근거일 때" → 답변이 질문의 "24GB"를 되뇐 것이 유일한 근거로
잡혀 발동 실패. 질문에 등장한 숫자는 전제의 반복이지 모델의 주장이 아니므로
제외하도록 수정
2차 여전히 실패 — 모델이 "157.66 tok/s on RTX 3090"을 페이지에서 정확히 인용
했고, 그 진짜 수치 하나가 옆의 날조를 가려줌. "어딘가 근거가 있으면 통과"는
안전한 규칙이 아니었음
3차 질문이 명시한 단위(몇 토큰/몇 GB/몇 와트/몇 퍼센트)로 범위를 좁히고, 그 안에서
질문에 직접 답하는 수치(=선두 주장)만 검증. 비교·파생 수치는 자유롭게 허용
배포 후 확인 — 같은 질문에 대한 답변이 바뀜:
이전: "초당 약 2~5 토큰 내외의 매우 낮은 속도가 예상됩니다"
이후: "검색 결과에는 해당 조합의 실측 성능 수치가 없어서 단정하기 어렵습니다.
다만 유사한 Tesla P40 1장에서 약 26.2 tokens per second ... M40은 이전
세대이므로 이보다 낮을 것으로 보입니다"
모른다고 말하면서 실제로 찾은 인접 데이터는 제시하고 방향만 정성적으로 설명함.
테스트 137개 통과(수치 대조 15개 신규).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"내일 날씨"는 일상 질문인데 9개 날씨 도구가 전부 meteorologist 스킬 뒤에 있어,
스킬이 꺼진 계정에서는 날씨 도구가 하나도 없이 web_search로 폴백하고 있었음.
두 단계로 분리:
- 기본(키워드만 요구, 스킬 불필요): weather_kma, weather_openmeteo — 합 446토큰,
날씨 키워드가 있을 때만 붙음. 둘이서 한국(관측 기반)과 해외(강수확률·시간별)를
모두 커버함
- 전문(스킬+키워드 유지): ERA5/CDS/CMIP6/NASA POWER 재분석·기후 아카이브,
대기질(airpollution/airkorea), 그리고 weather_search — openmeteo와 스키마
비용은 같은데(290토큰) 담는 데이터는 적은 중복 폴백이라 전문 쪽에 둠
배포 후 확인(스킬 여전히 꺼진 상태):
- "내일 구미 날씨" → weather_kma, 강수확률 20% 포함
- "내일 파리 날씨" → weather_openmeteo, 16시·18시 뇌우 등 시간별 예보 반영
어제 계획했던 한국=기상청 / 해외=openmeteo 라우팅이 스킬 토글 없이 성립함.
테스트 123개 통과(날씨 게이트 3종 신규/갱신).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
스키마 게이팅이 도구 실행을 전혀 막지 못하고 있었음. executeTool()은 이름으로
레지스트리를 조회할 뿐 스키마 포함 여부를 검사하지 않으므로, 프롬프트가 도구
이름을 알려주면 모델이 그대로 호출하고 그대로 실행됨.
실제 관측(2026-07-29): papa는 meteorologist 스킬이 꺼져 있어 tool-scope가 모든
weather_* 스키마를 정상적으로 제외했는데도(런타임 계측으로 weatherTools=(none)
확인), weather_kma가 매 날씨 턴마다 2회씩 실행되고 있었음. 모델이 파라미터
문서 없이 이름만 보고 호출한 것 — 날씨 도구 라우팅이 설명 문구를 두 번 고쳐도
계속 불안정했던 원인이 이것이었음.
누출 지점 3곳을 모두 실제 도구 목록 기반으로 전환:
- handle-chat 사전 리마인더: 하드코딩 "weather_kma/weather_search" → 보유한
도구만 나열
- handle-chat AUTO-RECOVER toolHint: 동일
- personality-context [TOOLS] 블록: 키워드로만 켜지고 보유 여부를 안 봐서
날씨 단어 하나에 9개 도구 사용법이 통째로 주입되던 것이 최대 누출원.
카테고리→도구 매핑을 두고 하나라도 보유한 경우에만 블록 포함(호출자가
목록을 안 주면 기존 동작 유지)
- system-prompt 날씨 라우팅 문장도 보유 도구에 따라 조건부 생성
배포 후 확인: 스킬이 꺼진 상태에서 날씨 질문 시 weather_* 호출이 완전히
사라지고 web_search로 폴백 — 의도한 동작.
함께 반영한 날씨 소스 우선순위(스킬이 켜졌을 때 적용):
한국은 weather_kma 우선(관측 기반, 강수확률 포함), 3일 초과·UV·시간별은
weather_openmeteo, 해외는 openmeteo, weather_search는 폴백. 구미 실측 비교에서
세 소스가 강수확률 20% vs 0%, 최고기온 34.0 vs 36.5로 갈렸음.
전체 121개 테스트 통과.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"내일 구미 날씨"가 75자 한 문장(기온+하늘상태)으로만 나오던 문제를 추적한 결과,
프롬프트가 아니라 도구 쪽에 원인이 세 개 있었음.
1) weather_search가 더 나은 도구를 밀어내고 있었음
설명에 "ALWAYS use this for weather/forecast/temperature queries"라는 절대
지시가 있어 모델이 매번 이걸 골랐는데, 이 도구는 기온 범위와 하늘 상태만
반환함(강수확률·습도·풍속 없음). 강수확률을 주는 weather_openmeteo가 있는데도
도달하지 못했음. 두 설명을 각자의 실제 능력에 맞게 정정하고 openmeteo를
PREFERRED로 표시.
2) 시스템 프롬프트가 없는 데이터를 요구하고 있었음
OUTPUT FORMAT이 "강수확률·평년대비를 서술하라"고 했으나 모델이 호출한 도구는
그 값을 주지 않음 — 지시를 따르려면 지어내는 수밖에 없는 구조였음. "받지
못했으면 지어내지도 말고 조용히 빼지도 말고 weather_openmeteo를 호출하라"로
변경.
3) ⚠ 구미가 엉뚱한 지역으로 해석되고 있었음 (가장 심각)
KR_CITY_COORDS에 구미가 없어 Open-Meteo 지오코더로 넘어갔는데, 한국 내 "구미"
결과 10건 중 실제 구미시(36.12, 128.34)가 하나도 없음. 게다가 모든 항목의
population 필드가 비어 있어 "인구 최대 선택" 로직이 results[0]으로 붕괴,
강원도의 작은 마을(38.16)이 선택됐음. 위도 2도 차이 = 다른 기후대인데 에러는
전혀 나지 않았음 — 사용자는 그동안 남의 동네 날씨를 받고 있었을 수 있음.
구미 포함 중견도시 25곳의 좌표를 테이블에 직접 추가.
data.go.kr 키는 vault(kma.api_key)에 암호화 저장 — 평문 커밋 없음. 배포 후 확인:
기상청 초단기실황이 구미 기온 32.2°C/습도 60%/풍향 북북서로 정상 응답, 예보
답변도 75자 → 167자로 강수확률·UV지수 포함.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
이전에는 순수 부분 문자열 매칭이라, 파일보다 얕게 들여쓴 SEARCH가 더 깊은 줄
안에서 매칭됐음(" deep()"가 " deep()"에 적중). 한 줄을 노린 편집이
다른 줄에 적용될 수 있는 경로였고, 조용히 잘못된 바이트를 쓰는 fail-open이었음.
"무조건 줄 단위 일치"로 바꾸면 줄 안의 조각을 고치는 정당한 사용
("return 1" → "return 42")이 전부 깨지므로, SEARCH가 스스로 무엇을 주장하는지에
따라 앵커를 조건부로 적용:
- 선행 공백이 있거나 여러 줄 → 줄 시작 정렬 필수
(들여쓰기/구조를 주장하고 있으므로 정렬돼야 함)
- 선행 공백 없는 한 줄 → 기존대로 부분 매칭
(줄 안의 조각일 뿐 들여쓰기를 주장한 적 없음)
거부는 fail-closed — 찾지 못함으로 보고돼 모델이 더 정확한 컨텍스트로 재시도함.
앵커에 걸린 등장은 건너뛰고 뒤쪽의 정렬된 등장을 계속 찾으므로, 같은 텍스트가
줄 중간과 줄 시작에 모두 있으면 후자를 고침.
부수 수정 — 오프셋 치환:
빠른 경로와 폴백 경로 모두 String.replace()를 쓰고 있었는데, replace()는 위치 0
부터 다시 스캔하므로 방금 내린 앵커 판정을 무효화하고 앞쪽의 정렬 안 된 등장을
고칠 수 있었음. 이미 알고 있는 오프셋으로 splice하도록 양쪽 다 변경.
테스트 121개 통과(앵커 규칙 4개 신규). 실디스크 검증: 8칸 들여쓰기 안의
value = 1 → 99 수정 정상, 들여쓰기 불일치 케이스는 파일 무변경으로 거부.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
execute-tool.ts에서 파일 편집 알고리즘을 순수 모듈로 추출. 1,529 → 1,498줄.
이 함수는 툴 레이어에서 가장 위험한 순수 로직임 — 사용자의 실제 소스 파일을
다시 쓰는데, 실패해도 예외를 던지지 않고 잘못된 바이트를 쓰거나 "3건 적용"이라
보고하고 2건만 적용한다. executeTool()의 1,349줄 본문 안에서 fs.writeFileSync
바로 옆에 인라인으로 있어서 디스크를 건드리지 않고는 검증할 방법이 없었음.
테스트가 고정한 성질(피해 큰 순):
1. 적용하지 않은 편집을 성공으로 세지 않는다
2. 엉뚱한 구간을 치환하지 않는다
3. 매칭 구간 밖의 바이트(들여쓰기·후행공백)를 보존한다
4. 실패를 보고해 모델이 재시도할 수 있게 한다
+ /g 정규식 lastIndex 오염으로 두 번째 호출이 편집을 건너뛰는 회귀 방지
추출 중 확인 — 계획 정정:
당초 "early return 평탄화"를 하려 했으나 실측해보니 이 파일의 깊은 중첩은
방어적 가드 피라미드가 아니라 알고리즘 자체의 깊이였음(공백 정규화 텍스트 검색,
Promise 래핑+파싱 루프 등). 평탄화해도 나아지지 않고 테스트 없는 I/O 코드를
기계적으로 건드리는 위험만 남으므로, 오늘 효과가 확인된 방식(파묻힌 순수 로직
추출)으로 방향을 바꿈.
알려진 날카로운 모서리를 문서화(수정하지 않음):
매칭이 줄 단위 앵커가 아니라 부분 문자열이라, 파일보다 얕게 들여쓴 SEARCH가
더 깊은 줄 안에서 매칭된다(" deep()"가 " deep()"에 적중). 대개 무해
하지만 한 줄을 노린 편집이 다른 줄에 갈 수 있는 경로임. 줄 앵커로 바꾸면 모든
호출자의 파일 편집 의미가 달라지므로 조용히 "고치지" 않고 테스트로 고정만 함.
전체 117개 테스트 통과. 실디스크 검증: coder_patch_file로 return 1 → return 42
수정이 들여쓰기·구조 보존한 채 정확히 반영됨.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
라운드 루프에 남아 있던 판단 로직 전부 추출. 세 검사 모두 "모델이 유창하게 답을
썼지만 그 답에 이르는 과정이 신뢰할 수 없다"를 잡는 같은 계열임:
- decideAutoRecover() 근거 도구 없이 검증 가능한 질문에 답한 경우
- shouldForceMessagingRetry() 전송 도구 없이 "보냈다"고 주장한 경우
- shouldForceEmptyGroundingRetry() 검색이 전부 비었는데 답한 경우
셋 다 fail-open이라 회귀해도 에러도 로그도 남지 않고 검증 안 된 답이 사실처럼
전달된다 — 정확히 이 저장소가 반복해서 겪은 실패 유형이라(07-29 로그 감사)
재시도해야 하는 경우와 하지 말아야 하는 경우를 양방향으로 고정함.
handle-chat.ts 2,908 → 2,872줄. 전체 102개 테스트 통과.
테스트 작성 중 확인한 경계(코드는 정상, 테스트가 틀렸던 케이스):
짧고 애매한 답변("음...")은 재시도 대상이 아님 — looksLikeReasoning은 300자
초과를 요구하고 거부 문구도 아니므로. 회복할 내용 자체가 없는 게 맞는 동작이라
그 경계를 별도 테스트로 남김.
배포 후 확인: "RTX 5090 VRAM 용량" 질문에서 web_search 선행 후 답변 정상.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
라운드 루프(1,938줄) 안의 순수 판단 로직 추출. 루프 자체는 옮기지 않음 — 판단
근거는 아래에 정리.
- src/gateway/chat/search-budget.ts (신규) — evaluateSearchBudget()가
(이전 쿼리들, 현재 쿼리, 차단횟수) → (허용여부, 사유, 도구차단, 하드캡도달)
을 반환하는 순수 함수. handle-chat.ts 2,936 → 2,908줄
- tests/search-budget.test.ts — 13개. 캡이 "반복에는 물리고 팬아웃에는 안
물린다"는 양방향을 모두 고정
테스트가 찾은 약점 — 한 단어 우회:
"어떤 미등장 토큰이라도 있으면 새 주제로 본다"는 규칙이 팬아웃(도시 8곳 날씨
확인 등)을 보호하려고 일부러 느슨한데, 그 탓에 "우크라이나 전황" → "우크라이나
전황 다시"가 완전히 새로운 주제로 통과해 재검색 루프를 그대로 허용했음. 이
모듈이 막으려던 바로 그 패턴. 규칙 자체를 조이면 정당한 쿼리까지 막히므로,
변별력 없는 단어(다시/추가/자세히/더/또/again/more/detail)를 불용어에 추가하는
쪽으로 해결.
라운드 루프를 통째로 옮기지 않은 이유:
루프는 1,938줄이면서 외부 가변 변수 22개(allThinking, toolSkipForcedRetries,
toolsDisabledForRestOfTurn, fileOpOwner 등)를 변경하고, 본질적으로 I/O
(모델 스트리밍·도구 실행·SSE 전송)라 순수 함수가 될 수 없음. 옮기려면 22개를
상태 객체로 묶어 넘겨야 하는데 회귀 위험만 크고 테스트 가능성은 늘지 않음 —
줄 수만 다른 파일로 이동할 뿐임. 오늘 tool-scope/system-prompt 추출이 값어치
있었던 건 순수 함수가 되어 테스트가 붙었기 때문이지 줄 수가 줄어서가 아니므로,
같은 기준으로 루프 안의 '판단'만 뽑는 방향을 택함.
전체 79개 테스트 통과. 배포 후 뉴스 요청에서 news_search 8회 팬아웃 정상 확인.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tool-scope.ts와 같은 이유로 추출. 시스템 메시지는 모델 동작을 가장 크게 좌우
하는 단일 문자열인데, 2,900줄 함수 안에서 인라인으로 조립돼 결과를 보려면 실제
채팅을 보내 SSE의 tool_overhead를 읽는 수밖에 없었음.
- src/gateway/chat/system-prompt.ts (신규) — buildChatSystemPrompt(input).
날짜/모델프로필/성격/스킬 컨텍스트는 호출자가 해결해 넘기므로 순수 함수
- tests/system-prompt.test.ts — 19개
- handle-chat.ts 2,992 → 2,936줄 (오늘 누적 3,131 → 2,936)
테스트가 고정한 핵심 불변식: 도구를 억제하는 규칙은 그 도구가 호출 가능한 한
반드시 함께 온다. image_edit이 스키마에 있으면 "함부로 편집하지 말라"가 없을
수 없다 — 이걸 사용자 문구로 게이팅했다면 정규식이 놓친 표현에서 규칙만 조용히
사라졌을 것. 반대로 tool-scope.ts의 키워드 게이팅은 최악의 경우가 "도구가 없어
못 한다"이므로 허용된다. 두 모듈 헤더에 이 구분을 명시해둠.
동작 무변경 검증: 리팩터 전후 tool_overhead가 "안녕" 3,574 / "RTX 5090 스펙
비교" 3,724로 바이트 단위 동일. 전체 66개 테스트 통과.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
handleChat()에서 도구 선택 로직(145줄)을 순수 모듈로 추출. 이 로직은 2,959줄
함수 안의 클로저라 테스트가 불가능했고, 게이트를 확인하려면 게이트웨이를 띄우고
실제 채팅을 보내 SSE의 토큰 수를 읽는 수밖에 없었음.
- src/gateway/chat/tool-scope.ts (신규) — I/O 없는 순수 함수.
buildSkillToolFilter()는 스키마 객체가 아니라 도구 '이름'을 받게 해서
테스트에서 문자열만으로 구동 가능하도록 함
- tests/tool-scope.test.ts — 17개. 비용(불필요한 도구 미노출)과
도달성(요청한 도구는 반드시 노출) 양쪽을 모두 고정
- handle-chat.ts 3,131 → 2,992줄
추출과 동시에 테스트가 버그 3종을 찾음:
1. 한글 뒤 \b 는 절대 매칭되지 않음 (죽은 패턴 5개)
\b는 ASCII 단어경계라 한글 음절 뒤에서는 성립하지 않음. `메일\b`가 그 예로,
"메일 확인해줘"가 게이트를 통과하지 못해 이메일 도구가 아예 노출되지 않았음
("이메일"이라고 써야만 동작). 같은 죽은 패턴이 hasCodeKeyword(함수/클래스/
버그), hasNewsKeyword(기사), hasPptxKeyword(덱)에도 있었음. 배포 후 확인:
"메일 확인해줘"가 email_list를 정상 호출함
2. pptx 게이트 조건 반전
`presenterEnabled && !hasPptxKeyword`라 스킬이 '꺼진' 경우 배제가 아예
동작하지 않았음. 스킬이 꺼졌으면 더 제한해야 하는데 반대로 열려 있었던 것.
실제 영향 범위(skills_state.json 대조): papa·cherry는 presenter=true라 무관,
jasmine만 매 턴 ~1,600토큰(create_presentation 833은 최대 스키마)을 지고
있었음. weather/legal과 같은 형태로 정정
3. (테스트 작성 중) 원문을 줄여 쓴 케이스가 실패 → 이전 커밋에서 수정한
SPEC_CONTEXT_KEYWORD 구멍과 동일 계열임을 재확인
전체 47개 테스트 통과. 실측 오버헤드는 papa 기준 3,574로 동일(위 2번이
papa에겐 원래 정상 동작했으므로 예상된 결과).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tests/ 디렉터리가 비어 있었고 package.json의 두 스크립트가 삭제된 파일을
가리킨 채 방치돼 있었음:
- test → tests/test-v2.ts (존재하지 않음)
- gateway → src/gateway/server-v2.ts (v4.3.5에서 삭제됨)
즉 npm test가 몇 달간 실행 자체가 불가능한 상태였고, 그 대가로 프롬프트 게이트
정규식을 고칠 때마다 node -e 임시 스크립트를 손으로 짜고 버리는 일이 반복됐음
(2026-07-29 하루에만 5회). 회귀 방지는 하나도 남지 않았음.
- Node 22 내장 러너(node:test) + tsx 사용, 테스트 프레임워크 의존성 없음
- tests/prompt-gates.test.ts (25) — 모델 동작을 강제/억제하는 정규식 게이트 전반
- tests/usage-log.test.ts (5) — Ollama 외 provider의 토큰 집계 단일 지점
- tests/README.md — 무엇을 왜 테스트하는지, 케이스 작성 규칙, 다음 확장 대상
- 전체 30개 통과, 0.6초
부수 성과 — 테스트가 실제 버그를 찾음:
케이스를 쓰면서 프로덕션 원문을 한 단어(GPU) 줄여 썼더니 실패했고, 그게 진짜
구멍이었음. "토큰 처리 속도 손실이 약 15%~25%"처럼 하드웨어 명사가 없는 처리량
날조는 SPEC_CONTEXT_KEYWORD에 걸리지 않아 그대로 통과하고 있었음 — 원문에
우연히 들어있던 "GPU" 덕에 잡히던 것. 토큰/대역폭/추론/처리속도를 키워드에
추가해 막고, 그 케이스를 테스트로 고정함(강수확률·할인율 오탐 없음 확인).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ollama_web_search / ollama_web_fetch가 매 턴 무조건 스키마에 포함되고 있었음.
두 도구는 자기 description에 "뉴스·정보 검색은 반드시 web_search를 사용. 이
툴은 web_search가 없는 환경에서만 fallback으로 사용"이라고 적혀 있어서, 모델
에게 "이 도구를 쓰지 말라"고 알리는 데 214토큰을 쓰던 셈. 게다가 그 폴백
체인은 이미 executeWebSearch 내부에서 서버가 처리하므로 모델이 직접 부를
이유가 없음.
삭제가 아니라 게이팅을 택함: personality-context.ts에 "올라마로 검색" 류
표현에만 뜨는 ollama_web 힌트 블록이 이미 있어서, 도구를 제거하면 그 힌트가
존재하지 않는 도구를 설명하게 됨. 같은 표현으로 게이트해 평소엔 0토큰,
명시 요청 시엔 정상 동작하도록 함.
실측: 3,788 → 3,574. "올라마로 검색해줘" 요청에서는 4,980으로 도구가 정상
복귀하는 것도 확인.
조사 중 정정: eonet_events/satellite_snapshot과 run_command는 이미
skillToolFilter에 게이트가 있었음(각각 hasSatelliteKeyword, hasCodeKeyword).
초기 분석에서 필터 전체를 보지 못해 "무조건 켜짐"으로 잘못 판단했던 것을
바로잡음 — 실제 always-on은 8개(1,109토큰)뿐이며 07-26 스코핑 작업이
이미 대부분을 처리해둔 상태.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
특정 도구를 제약하려고 존재하는 블록들이 도구 유무와 무관하게 매 턴 무조건
주입되고 있었음. 기존 BROWSER RULE이 쓰던 도구 목록 기반 게이팅을 그대로 확장.
조건부로 전환한 블록(측정 정적 비용):
- IMAGE EDITING RULE (90) → image_edit 있을 때만
- IMAGE/VIDEO GENERATION (225) → image_generate/video_generate 있을 때만
- CODE OUTPUT (154) → coder_* 있을 때만
- PACKAGE INSTALL (79) → shell/run_command 있을 때만
실측: tool_overhead 4,330 → 3,788 (-542, 12.5%). 서로 다른 두 질문에서
동일하게 542 감소 확인.
메시지 문구가 아니라 도구 존재 여부로 게이트한 것이 핵심. "업로드한 사진을
함부로 편집하지 말라" 같은 억제 규칙은 그 도구가 호출 가능한 동안 항상 떠
있어야 하며, 정규식 문구 매칭으로 게이트하면 정규식이 놓친 표현에서 규칙이
사라진다 — 오늘 환각 조사에서 확인한 것과 같은 실패 모드.
CHEMISTRY NOTATION(180)은 의도적으로 무조건 유지: 도구가 아니라 출력 형식을
제약하므로 키로 삼을 도구가 없고, 치과/의학 질문은 화학 키워드 없이도 모델이
구조식을 그리게 만들 수 있어 문구 기반 게이팅이 위험함.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
모델이 "클로서버.local"이나 ":3000" 같은 존재하지 않는 주소를 지어내던 것을
막기 위해, 실제 주소(192.168.0.5:18789 / localhost:18789)와 함께 "그 값들은
실재하지 않으니 답변에 쓰지 말 것"을 USER.md에 명시.
USER.md는 벡터 메모리(Chroma)로 대체된 것이 아니라 병행 사용됨 — 벡터 쪽은
관련도 기반이라 사용자가 주소를 명시적으로 묻지 않으면 유사도 컷을 못 넘고,
Chroma 조회 실패 시엔 조용히 빈 값이 되므로, 항상 떠 있어야 하는 이런 사실은
전량 주입되는 USER.md에 있어야 한다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
그동안 쌓여 있던 미커밋 파일들을 정리. 소스·자산만 올리고, 개인 데이터와
런타임 상태는 gitignore로 차단.
추가된 자산:
- web-ui/audiomass, web-ui/wavacity — 자체 호스팅 오디오 에디터
- web-ui/drums — 드럼 샘플
- wol-gate — WOL 프록시 컨테이너(Dockerfile + server.js)
- glm-1m-fix — GLM 1M 컨텍스트 설치 스크립트(linux/windows)
- RENODE_FIXES_SUMMARY*.md, CCTV정보_서울특별시.csv, .claude/plans
수정:
- edge_tts_synth.py: rate 인자 추가(말하기 속도 조절)
- .smallclaw/config.json: google(gemini) provider 등록 — 키는 vault 참조
- assets/SmallClaw*.png 삭제분 반영
gitignore (중요):
- .smallclaw/users/*/workspace/ 전체 차단. 기존 패턴이 memory/uploads와 일부
확장자만 막아서 detective/(고소장·통화녹취·의사소견서·가족관계증명서),
generated-media/, music/, pptx/ 등 앱 생성 개인 데이터 403MB가 노출돼 있었음
- .smallclaw/active-sessions.json 차단 — 살아있는 bearer 토큰과 admin 역할이
평문으로 들어있어 커밋 시 게이트웨이 관리자 자격증명이 그대로 공개됨
- google-usage.json, config.json.bak-*, workspace/memory·weather-maps·
task_result_*, voice/ 등 런타임 상태도 함께 차단
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
실사용 로그 감사(07-29 GPU/PCIe 대화)에서 발견한 문제들 일괄 수정.
환각 방어:
- looksLikeUnverifiedSpecClaim이 놓치던 패턴 2개 추가 — 단위 없는
"15토큰"(strict tier), 퍼센트 벤치마크 "약 5%"(ambiguous tier,
SPEC_CONTEXT_KEYWORD 게이트로 오탐 방지)
- forceToolChoiceOnLiveData를 모델별 opt-in → 전역 기본 ON + opt-out.
모델을 수시로 바꾸는데 profiles에 등재된 2개만 방어되던 구멍이 원인
(프로필 없던 gemini-3.5-flash-lite가 tok/s 날조 + 없는 제품명 창작)
답변 길이:
- "Keep responses SHORT" 1-2 → 2-4문장, 대화/실행 답변으로 범위 한정.
정보성 답변(뉴스·날씨·설명·비교)은 OUTPUT FORMAT을 따르도록 명시
- 불확실성 표현은 길이 제한 면제. 1-2문장 압박이 "모른다"고 말할 자리를
없애 환각을 확신형 한 줄로 포장하던 것이 실측으로 확인됨
- 뉴스는 도구가 반환한 기사 전부 표기(3건 체리픽 금지, 8-10행 목표)
토큰 로깅:
- logUsage가 ollama-adapter 내부 private이라 Ollama 경유만 기록되고,
openai-compat은 usage 필드를 추출조차 안 하고 버리던 문제. 비용 비교
대상인 Gemini가 통째로 누락돼 공짜처럼 보이는 상태였음
- providers/usage-log.ts로 공용화 → openai-compat(chat/generate)과
openai-codex(SSE response.completed)에서도 캡처·로깅·호출자 반환
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
기존 col.applecherry.net Collabora 서버를 재사용하되, NextCloud(WebDAV
업로드/가져오기 왕복) 없이 홈클로 게이트웨이가 직접 WOPI 호스트 역할
수행 (CheckFileInfo/GetFile/PutFile). hwp는 사건 폴더 안에서 odt로
1회 변환 후 그 자리에서 바로 편집 — 타이핑하는 대로 사건 폴더에 즉시
저장되어 별도 "가져오기" 버튼이 필요 없어짐(dtFetchFromNextcloud 제거).
config.json에 collabora.baseUrl/publicUrl 추가. Express 5의
res.sendFile 이슈(이미 known) 재발 → createReadStream으로 우회.
실기기(col.applecherry.net + 실제 사건 고소장.odt)로 전체 라운드트립
검증 완료.
부수: 사이드바 사건목록이 빠른조사/법률질문 버튼 늘어나며 눌리는 문제
— 잘 안쓰는 버튼 정리(19개→11개)로 완화 + .dt-cases min-height 안전장치.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
memory-vector.ts가 src/gateway/memory/ 하위로 옮겨진 뒤 이 스크립트만
경로가 안 고쳐져서 MODULE_NOT_FOUND로 매일 새벽 1시 systemd 타이머가
3일 연속 즉시 실패하고 있었음(daily_extracts Chroma 컬렉션이 07-24
이후로 안 쌓이던 원인). 경로 수정 후 밀린 07-25~27 백필 확인.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
멀티유저 전환 후 USER.md/SOUL.md가 유저별 워크스페이스로 옮겨졌는데
HeartbeatRunner는 여전히 글로벌 워크스페이스(파일 없음)를 보고 있어서
30분마다 memory_read(user/soul)가 매번 "not found"로 실패하던 문제.
workspacePath를 papa 워크스페이스로, handleChat에 username도 함께
전달하도록 수정.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
파일별 다운로드(download 속성), 폴더 전체 zip 다운로드(archiver 신규
의존성 + /api/detective/files/download-folder 라우트) 추가. 사이드바에
법률 서식(대한법률구조공단)·법원 소송 서식(전자소송포털)·대법원 판례
검색(사법정보공개포털)·경찰청 링크 블록 신설.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
hwp에서 가져온 odt 사본을 다시 편집하려 해도 버튼이 없었음 — 이제
.odt 파일도 hwp와 동일하게 🌐(NextCloud에서 열기)/⬇️(가져오기+PDF
변환) 대상이 됨. 백엔드는 이미 비-hwp 파일을 변환 없이 그대로
업로드/처리하도록 되어 있어서 프론트엔드 조건만 확장하면 됐음.
h2orestart는 hwp 내보내기(쓰기)를 지원 안 해서 편집본을 진짜 hwp로
되돌릴 방법이 없음(리눅스에서 근본적으로 불가) — 대신 odt→PDF는
특별한 필터 없이 표준 변환이라 이걸로 실질적 요구(제출용 PDF)를 충족.
⬇️ 가져오기 시 odt와 함께 PDF도 자동 생성. 파일명은 "{이름}
(NextCloud변환).pdf"로 접미사를 붙여서, 같은 폴더에 이미 있는 원본
증거자료 PDF(예: 고소장.pdf)를 덮어쓰지 않도록 함 — 실제로 이 충돌
가능성을 발견하고 배포 직전에 수정함.
주요 변경:
- 탐정 앱의 .hwp 파일에 🌐 버튼 추가: 서버가 자동으로 hwp→odt 변환(host의
h2orestart 필터 사용) → 사용자 소유 NextCloud(cloud.applecherry.net)의
homeclaw-detective/{caseId}/ 폴더로 WebDAV 업로드 → Collabora Online
편집화면으로 리다이렉트. 타이핑하는 대로 자동 저장, 다운로드/재업로드 불필요.
- NextCloud 앱 비밀번호는 vault에 저장(nextcloud.appPassword), config.json엔
평문 없음.
- (부수적, 별도 이슈) Stirling-PDF는 이 사건의 원본 PDF가 실제 폼필드가
없는 인쇄용 레이아웃이라 텍스트 스트림 편집이 계속 실패해서 우회 경로로
채택함.
추가로 이전 세션에서 커밋 안 하고 넘어간 weather-app.html의 지도탭
자동전환 fix(processWindyUrl 이동 시 mapLat/mapLon 동기화 + switchCenterTab)도
같이 반영됨.
원인: /pdf-editor-open은 caseId만 전달하고 파일명은 전혀 안 넘김 —
Stirling-PDF(외부 PDF 편집기)의 실제 프론트엔드 번들을 직접 열어봤는데
"token" 쿼리파라미터 하나만 읽고 원격 파일 로드 기능 자체가 없음(자체
storage API는 있지만 별도 로그인 필요, 우리는 미설정). 즉 특정 파일을
자동으로 여는 건 우리 쪽에서 고칠 수 있는 버그가 아니라 외부 툴의
한계임을 확인.
대신 ✏️ 클릭 시 편집기 새창 열기와 동시에 그 파일을 다운로드시켜서,
사용자가 편집기 창에 끌어다 놓기만 하면 되도록 마찰을 최소화. 툴팁도
실제 동작을 정확히 설명하도록 수정.
실사용 중 발견: 모델이 satellite_snapshot 결과의 /api/files/... 마크다운을
답변에 그대로 복사하지 않고 재작성하면서 /api/files/ 접두사를 빠뜨림 —
그러면 클라이언트 정규식이 매치 안 돼서 사진탭이 아예 안 열림.
weather_map_screenshot과 같은 패턴으로, 도구 결과의 마크다운을 그대로
SSE로 주입해서 모델의 재현 정확도에 의존하지 않게 함. 태풍 돌핀 실제
NASA 위성사진으로 재현·검증 완료.
실제 테스트에서 발견: "지도 이동해줘" 후속 요청에 모델(deepseek-v4-flash)이
도구를 재호출하지 않고 이전 응답을 그대로 반복하는 경우가 많아 프롬프트
가드만으로는 신뢰도가 부족했음. 근본 해결로 사진 뷰어에 "지도에서 위치
보기" 버튼 추가 — satellite_snapshot 파일명에 이미 박혀있는 좌표를
그대로 파싱해 지도를 이동시키므로 모델 개입이 전혀 필요 없음.
부수적으로 execute-tool.ts에도 weather_map_screenshot 성공 시 그 URL을
SSE로 직접 주입하는 방어선 추가(모델이 실제로 도구를 호출했을 때 대비).
실제 로그 분석 결과: "위치로 이동" 같은 후속 요청은 트리거 단어 목록에
없어서 모델이 Windy URL을 재출력하지 않고, 대신 weather_map_screenshot
(서빙 안 되는 모델 전용 스크린샷 도구) 결과를 [REDACTED-HE].jpg 같은
가짜 마크다운 이미지로 답변에 끼워넣음 — 실제 지도는 전혀 안 움직이고
깨진 이미지 링크만 표시됨. 후속 이동 요청도 트리거로 인정하고,
screenshot 도구 결과를 이미지로 삽입하지 말라는 가드 추가.
accountant/lawyer/investor-app의 SKILL_CONTENT가 모든 한글을 6자리
유니코드 이스케이프(\uXXXX)로 저장하고 있어 소스 읽기가 사실상 불가능했음.
디코드→재인코딩 스크립트로 실제 UTF-8 텍스트로 변환, 디코드된 내용은
변환 전후 완전히 동일함을 스크립트로 검증. 기능 변화 없음, 가독성만 개선.
날씨 스킬에서 태풍 번호 혼동 사고 확인 후, 같은 패턴(단일 소스에서
가져온 데이터를 다른 개체에 잘못 귀속) 위험이 큰 세 스킬에 가드 추가:
- investor: 종목코드/거래소/보통주-우선주/분기 대조
- lawyer: 법령 시행일·개정판, 판례 사건번호 전체 일치 대조
- detective: 동명이인·유사상호 — 추가 식별자 없이 귀속 금지
EFFIS(유럽 산불 전용 DB) 공개 WFS를 붙여볼까 테스트했으나 GetCapabilities조차
35초+ 걸리다 500 에러 — 신뢰성 부족으로 보류. 대신 EONET이 북미 편향이라는
사실을 도구 설명에 적어 모델이 유럽 등 이벤트는 자동으로 web_search 우회하게 함.
- eonet_events/satellite_snapshot 도구 추가 (키 불필요 공개 API)
- weather-app.html: 위성사진 채팅 이미지 렌더링, 지도/사진 멀티탭 센터패널,
확대/축소·드래그팬, localStorage+히스토리 이중 복원으로 새로고침 시
탭 유지(하드리셋으로 localStorage가 지워져도 대화 이력에서 재구성)