Commit Graph
530 Commits
Author SHA1 Message Date
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
kimandClaude Opus 5 6df49c11a5 feat: 지서버 모델을 드롭다운으로 선택 (muse-glimmer / gemma4:26b)
지서버에 muse-glimmer와 gemma4:26b를 둘 다 두고 골라 쓰기 위해 되살린다.
86063d3에 있었으나 그 커밋이 통째로 되돌려졌던 UI 부분이다. 되돌린 사유였던
에러는 서버 로그상 8시간 앞선 별건(config.json 손상 + 지서버 엔드포인트 .99)으로
확인됐다.

설정에서 지서버 모델은 자유입력 텍스트 박스라 모델명을 손으로 정확히 적어야 했다.
정작 refreshProviderModels는 ollama_local일 때도 목록을 가져오고 있었는데, 그
결과를 항상 ollama 패널의 select에 써넣어서 — 지서버를 고르면 그 패널이 숨겨지므로 —
가져온 목록이 화면에 한 번도 안 보이고 버려지고 있었다.

- 지서버 모델 필드를 select로 바꾸고 "모델 새로고침" 버튼 추가
- refreshProviderModels가 provider별로 맞는 select와 상태줄을 쓰도록 수정
- lm_studio/llama_cpp의 모델 필드는 아직 input이라 SELECT일 때만 option을 채운다
  (input에 <option>을 넣으면 사용자가 입력한 값이 날아간다)

onProviderChange는 ollama_local만 자동 새로고침에서 빼둔다 — 드롭다운을 고른 것만으로
WOL 패킷을 쏘고 45초를 기다리면 안 되기 때문이다. 그래서 새로고침 없이 저장하면
select가 placeholder(값 "")뿐이라 설정된 모델명이 빈 값으로 덮어써진다.
seedModelSelect로 저장된 모델을 항상 실제 option으로 심어 막는다(빈 select에
.value를 넣으면 조용히 무시된다는 점도 이유).

app.js 쪽 호출은 typeof 가드로 감쌌다. seedModelSelect는 settings-provider.js에
있으므로, 브라우저가 그 파일의 캐시본을 든 채 새 app.js만 받으면 ReferenceError로
설정 로딩이 통째로 죽는다 — 지난번 "에러가 너무 나네"의 가장 유력한 경로다.

검증(실제 index.html 패널 마크업 + 실제 함수를 그대로 불러오고 네트워크 경계만 스텁):
새로고침 전 저장값이 실제 option으로 심어지고 저장 payload가 그 값을 읽는지,
새로고침 후 저장 모델이 목록 맨 앞에 오는지, 다른 패널 select를 안 건드리는지,
사용자가 고른 값이 저장 payload에 실리는지 — 전부 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 09:27:06 +09:00
kimandClaude Opus 5 faee483cf1 fix: 뉴스 검색이 2분 넘게 걸리던 원인 두 가지
사용자 신고: "지금 뉴스검색을 2분 넘게 하고 있는데?"
"오늘 미국 주요 뉴스" 한 턴에서 도구를 11번 불렀다. 라운드마다 muse-glimmer가
통째로 한 번씩 생성해야 하는데 22 tok/s dense라 라운드 수가 그대로 시간이 된다.

1) 1면 RSS가 제목과 링크만 넘겨서, 모델이 요약을 쓰려고 기사를 하나씩
   web_fetch 했다(6번, 그중 NYT는 403). 정작 피드엔 description이 이미 실려
   있었다 — NYT 138자, NPR 293자 — 파서가 지나치고 버리고 있었을 뿐이다.
   Headline에 summary를 넣고 formatHeadlines가 함께 내보낸다. 실측: us 피드
   12건 전부 요약 확보.

   NPR은 &lt;em&gt; 식으로 이중 인코딩해 보내므로 디코드→태그제거→디코드를
   한 번 더 돈다. 한 번만 돌면 <em>이 그대로 남는다.

2) 가드 두 개가 서로 물려 헛바퀴 3회를 돌았다. 모델이 category를 붙여 다시
   부르면 → breadth 가드가 "요청 안 한 category"라며 떼어냄 → 인자가 앞 호출과
   같아짐 → 중복으로 스킵 → 모델은 데이터를 못 받았으니 다른 category로 또 시도.

   중복 스킵에는 원래 replay 경로가 있는데 대상이 coder_list_files와
   coder_read_file 둘뿐이라, 뉴스·검색은 데이터 대신 "Already ran this exact
   call"이라는 문구만 받았다. 결과가 없으니 변형해서 또 부르는 게 당연하다.
   news_search/web_search/web_fetch를 replay 대상에 넣어 이전 결과를 그대로
   돌려준다 — 모델이 원하던 걸 받으면 반복이 끝난다.

테스트 8개 추가(요약 추출, Atom summary, 이중 인코딩, CDATA, 요약 없는 항목,
길이 제한, 출력 포함, 빈 줄 미발생) — 354개 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 08:39:45 +09:00
kimandClaude Opus 5 554b66d299 fix: 명시적 think:false는 재시도 사다리를 오르지 않는다
지서버를 muse-glimmer:latest nothink 모드로 붙이면서 다시 넣는다.
(86063d3에 함께 있었으나 그 커밋이 통째로 되돌려졌고, 되돌린 사유였던 에러는
서버 로그상 8시간 앞선 별건으로 확인됐다. UI 드롭다운 변경은 제외하고
think 사다리 부분만 복원한다.)

사다리는 glm-5.1:cloud처럼 think를 생략하면 생각만 하고 content를 안 내놓는
모델을 건지려고 만든 것이다. 하지만 false는 성격이 다르다. "이 모델은 생각에
예산을 낭비한다"는 운영자의 결정이라, 빈 응답 한 번에 thinking을 도로 켜면
그 결정이 조용히 뒤집힌다.

08-12 muse-glimmer 실측: 생각비중 85%, 체감 3.1 tok/s. think:false로 20.9 tok/s
(7배)에 답변은 오히려 길어지고 2000토큰 캡 잘림도 사라졌으며 20문항 빈 답변
0건이었다. 사다리가 이걸 되돌리면 의도한 7배가 말없이 느린 경로로 돌아간다.

이제 false는 [false, undefined]까지만 — 모델 기본값 폴백은 남겨서 빈 응답이
막다른 길이 되지는 않게 한다.

테스트 목적으로 모듈 레벨 export로 추출(346개 통과).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 08:25:36 +09:00
kimandClaude Opus 5 0a477f14cd fix: 모바일에서 헤더 앱 드롭다운이 안 열리던 문제
사용자 신고: "모바일에서 앱모음 탭이 풀다운 되지 않던데?"

드롭다운은 원래도 열리고 있었다. 보이지 않게 잘려 있었을 뿐이다.
모바일(max-width:1024px)에서 앱 버튼 스트립은 가로 스크롤 컨테이너가 된다.

  header .mode-toggle { overflow-x: auto; mask-image: linear-gradient(...); }

여기서 두 가지가 다 자른다. overflow-x를 visible이 아닌 값으로 두면 명세상
overflow-y도 auto가 되고, mask는 자손까지 함께 잘라낸다. 메뉴는
top:calc(100% + 4px)로 스트립 박스 바깥에 놓이므로 통째로 잘려나갔다.
z-index:2000은 무용지물 — 조상의 클리핑은 z-index로 못 넘는다.

처음엔 position:fixed만 주면 될 줄 알았으나 틀렸다. 브라우저에서 재보니
fixed를 줘도 여전히 안 보였다(메뉴 중심점에 body가 그려짐). fixed는 overflow
클리핑은 벗어나지만 mask는 못 벗어난다. 세 방법을 나란히 측정한 결과:

  A. fixed만              -> 안 보임
  B. fixed + mask 제거    -> 보임
  C. 메뉴를 body로 이동   -> 보임 (mask 유지)

오른쪽 페이드 힌트를 살리려고 C를 택했다. 열릴 때 body로 옮기고 dd-floating
클래스로 fixed 좌표를 주며, 닫힐 때 원래 .mode-dropdown 아래로 되돌린다.
되돌리지 않으면 다음 열기에서 previousElementSibling이 버튼을 못 찾는다.

데스크탑은 그대로 — 거기선 스트립에 스크롤도 mask도 없어서 기존 absolute
배치가 이미 정상이다.

검증(실제 스타일시트와 실제 함수를 그대로 불러온 페이지, 브레이크포인트만 이동):
열기 가시성, 다른 드롭다운 전환 시 이전 것 DOM 복원, 바깥 클릭 후 복원,
재열기 시 버튼 재탐색, 우측 끝 버튼의 화면 밖 이탈 방지 — 전부 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:50:30 +09:00
kim 3b5c93df8e Revert "feat: 지서버 모델을 드롭다운으로 선택 + think:false가 조용히 뒤집히지 않게"
This reverts commit 86063d3ef5.
2026-08-12 23:08:27 +09:00
kimandClaude Opus 5 86063d3ef5 feat: 지서버 모델을 드롭다운으로 선택 + think:false가 조용히 뒤집히지 않게
지서버(ollama_local)에 muse-glimmer를 추가로 올려두고 gemma4:26b와 골라 쓰려 했으나
설정 화면에서 지서버 모델은 자유입력 텍스트 박스라 모델명을 손으로 정확히 적어야 했다.
정작 refreshProviderModels는 ollama_local일 때도 목록을 가져오고 있었는데, 그 결과를
항상 ollama 패널의 select에 써넣어서 — 지서버를 고르면 그 패널이 숨겨지므로 —
가져온 목록이 화면에 한 번도 안 보이고 버려지고 있었다.

- 지서버 모델 필드를 select로 바꾸고 "모델 새로고침" 버튼 추가
- refreshProviderModels가 provider별로 맞는 select와 상태줄을 쓰도록 수정
- lm_studio/llama_cpp의 모델 필드는 아직 input이라, SELECT일 때만 option을 채운다
  (input에 <option>을 넣으면 사용자가 입력한 값이 날아간다)

여기서 새로 생기는 함정 하나를 같이 막았다. onProviderChange는 ollama_local만
자동 새로고침에서 빼두는데, 드롭다운을 고른 것만으로 WOL 패킷을 쏘고 45초를 기다리면
안 되기 때문이다. 그러면 새로고침 없이 저장할 때 select가 placeholder(값 "")뿐이라
설정된 모델명이 빈 값으로 덮어써진다. seedModelSelect로 저장된 모델을 항상 실제
option으로 심어둬서 막았다(빈 select에 .value를 넣으면 조용히 무시된다는 점도 이유).

buildThinkCandidates: 명시적 think:false는 재시도 사다리를 오르지 않는다.
사다리는 glm-5.1:cloud처럼 think를 생략하면 생각만 하고 content를 안 내는 모델을
건지려고 만든 것인데, false는 성격이 다르다. "이 모델은 생각에 예산을 낭비한다"는
운영자의 결정이라, 빈 응답 한 번에 thinking을 도로 켜면 그 결정이 조용히 뒤집힌다.
08-12 muse-glimmer 실측: 생각비중 85%, 체감 3.1 tok/s → think:false로 20.9 tok/s(7배)에
답변은 오히려 길어지고 잘림도 사라짐(20문항 빈 답변 0건). 사다리가 이걸 되돌리면
의도한 7배가 말없이 느린 경로로 돌아간다. 이제 false는 모델 기본값까지만 물러선다
— 빈 응답이 막다른 길이 되지는 않게.

테스트용으로 모듈 레벨 export로 추출(346개 통과).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 23:02:08 +09:00
kimandClaude Opus 5 6449126b46 feat: 넓은 뉴스 요청은 주요 매체 1면(RSS)에서 가져온다
사용자 지적: "뭔가 이상한데" — 매체를 좁힌 뒤에도 함부르크 아파트 균열,
패러글라이딩 실종자 수색, "하비에르 바르뎀은 친근한 배우" 같은 기사가
"주요 뉴스"로 올라왔다.

원인은 매체가 아니라 정렬이었다. NewsData /latest는 시간 역순이고, 주요지도
하루에 수백 건을 내므로 **가장 최근이 가장 중요한 것은 아니다.** 매체 제한은
바닥(물병 광고)은 올렸지만 천장은 못 올렸다. API 자체 손잡이(category=top,
prioritydomain=top)는 앞서 전부 실측했고 소용없었다.

신문 1면이 빠진 신호다. 사람 편집자가 이미 하루를 정렬해 놓은 유일한 곳이고,
주요지는 전부 RSS로 공개한다. 같은 시각 같은 요청의 결과:

  NewsData(매체제한)  함부르크 아파트 균열 / 카탈루냐 패러글라이딩 / 바르뎀 호평
  1면 RSS             콜롬비아 지진 180명 사망 / 트럼프 앙카라 비밀 탈출 /
                      미네소타 경선 결과 / 스페인의 대이탈리아 국경 검문

- 넓은 요청(query 없고 category 없거나 top)에만 적용. 주제를 지정한 요청은
  NewsData 전체 풀 그대로다 — 실측 확인: query="crime"은 여전히 지역지 기사까지
  들어온다. 1면은 정의상 그날의 톱이라 특정 주제를 물으면 답이 안 나온다
- 다국가는 라운드로빈으로 병합. 발행량 많은 1면 하나가 나머지를 밀어내면 안 된다
- 피드 실패는 조용히 건너뛰고, 전부 실패하면 NewsData로 폴백한다
- 피드는 전부 라이브 확인 후 추가. 한국은 제외했다 — 연합/한겨레/경향/JTBC를
  다 확인했으나 전부 시간순이고 편집 정렬된 1면 피드가 없어, kr은 기존
  주요매체 경로를 그대로 쓴다

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 13:57:02 +09:00
kimandClaude Opus 5 4764ccd5b3 fix: 유럽 다국가 뉴스 요청이 빈손으로 돌아오던 문제 (+ bbc.co.uk 오류)
사용자 지적: "유럽은 여전한데?" 로그를 보니 모델은 유럽 요청을 이렇게 보낸다.

  news_search({"category":"top","country":"gb,de,fr,it,es"})

직전 커밋(13e2f79)의 매체 제한이 두 이유로 빠져나갔다:
1. category가 있으면 건너뛰게 했는데 "top"은 분야가 아니라 전체 피드다.
   correctNewsCategoryForBreadth는 이미 top을 통과시키고 있었으니, 두 곳의
   판단이 어긋나 있었던 셈이다
2. country가 "gb,de,fr,it,es" 콤마 목록이라 나라별 조회 자체가 실패했다

- de/fr/it/es 목록 추가(전부 라이브 확인). 멜로니 개각설, 트럼프 비자 취소
  17만5천 건 같은 실제 뉴스가 나온다
- 다국가는 라운드로빈으로 5개를 채운다. 앞에서부터 채우면 영국 매체만 다섯이
  되어 대륙의 나머지가 통째로 빠진다
- category=top은 넓은 요청으로 취급한다

그리고 내가 만든 버그 하나를 같이 고쳤다: gb 목록의 bbc.co.uk는 NewsData DB에
없는 도메인이라(정답은 bbc.com) 요청 전체가 HTTP 422로 죽었다. **도메인 하나가
잘못되면 나머지 넷도 같이 실패한다.** us/kr 목록은 라이브로 확인하고 넣었는데
gb만 검증 없이 추가했다가 유럽 요청이 통째로 터졌다. 목록에 도메인을 추가할 땐
반드시 확인할 것 — 주석으로 남겼다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 13:47:47 +09:00
kimandClaude Opus 5 13e2f795fe fix: 넓은 뉴스 요청을 주요 매체로 좁혀 지엽적 기사 대신 실제 주요 뉴스를 준다
사용자 지적: "중요한 정치 경제 외교 이런 것이 없는 듯한데, 지엽적이고 세세한
내용들이네." 실측한 country=us 응답이 정확히 그랬다 — Owala 물병 신제품,
New Gateway High School 교장 부임, Local Lime의 같은 상가 내 호실 이전,
오랄비 칫솔 할인.

원인: NewsData.io의 /latest는 중요도 정렬이 아니라 시간 역순이고, 인덱싱된
수천 개 지역 매체의 최신 기사가 그대로 올라온다.

API 자체 손잡이로는 안 됐다 — category=top, prioritydomain=top, 둘의 조합까지
전부 실측했으나 여전히 칫솔 할인과 자라 원피스가 나왔다.

매체를 좁히니 해결됐다. 같은 요청을 Reuters/AP/NYT/WaPo/NPR로 제한하니
트럼프 백신 행정명령, 호르무즈 해협, 레바논 사형제 폐지, 에트나 화산이 나왔다.
한국도 뉴시스/중앙/조선/한겨레/동아로 삼성전자·SK하이닉스 전망, 이란 정보당국
분석이 나온다.

- 5개 제한은 편집 판단이 아니라 API 제약이다(6개부터 UnsupportedQueryLength)
- 도메인은 NewsData가 실제로 인덱싱하는 것으로 골랐다. yna.co.kr은 거부되고
  en.yna.co.kr을 제안한다 — newsis/joongang/chosun/hani/donga는 정상 동작
- **넓은 요청에만 적용한다**(query도 category도 없을 때). 주제를 지정한 요청을
  다섯 매체로 좁히면 바로 그 주제의 보도가 가려진다. 지역 사건은 지역지가
  유일한 출처인 경우도 많다
- 빈 결과 재시도로 q가 붙으면 더 이상 넓은 요청이 아니므로 매체 제한을 푼다

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 13:39:45 +09:00
kimandClaude Opus 5 08ff1c4429 feat: 사용자가 명시한 뉴스 분야를 모델이 빠뜨리면 채워 넣는다
d0db91d로 crime/education을 추가했지만 실전에서 이득이 안 났다. 격리 조건에선
모델이 "미국 범죄 뉴스"에 {"category":"crime"}을 정확히 골랐는데, 실전(도구
30여 개 + 전체 시스템 프롬프트)에선 {"category":"top"}을 고르고 web_search로
메웠다. 오늘 내내 확인한 제약 경쟁 패턴 그대로다.

fillNewsCategoryWhenExplicit은 넓히기 보정의 거울상이지만 훨씬 엄격하다.
저쪽은 넓히기만 하므로 오작동해도 범위가 늘 뿐이지만, 이쪽은 **좁히므로**
잘못 채우면 사용자가 원한 범위를 잃는다. 그래서 세 겹으로 막았다:

1. 모델이 분야를 아예 안 붙였을 때만. 붙인 것은 넓히기 보정의 몫이다
2. 정확히 하나만 걸릴 때만. "AI 기술 관련 범죄 뉴스"는 둘이 걸리는데 한쪽을
   고르면 나머지 절반이 조용히 사라진다
3. **현재 메시지만** 본다. 넓히기가 쓰는 최근 발화 창을 여기 쓰면 "미국 범죄
   뉴스" 다음 "그럼 전체 뉴스는?"에 crime을 계속 채운다. 주제를 물려받는 것은
   안전한 오류지만 단정하는 것은 아니다

테스트가 실제 동작 하나를 잡았다: "교육 정책 뉴스"는 교육(education)과
정책(politics) 둘 다에 걸려 모호 판정으로 안 채워진다. 사람은 education을
떠올리겠지만 "X 정책에서 X가 이긴다"는 규칙은 이 함수 범위를 넘는다.
안 채우면 전체 뉴스로 남으므로 안전한 실패 — 기대값 쪽을 고쳤다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 13:05:33 +09:00
kimandClaude Opus 5 d0db91d063 feat: news_search에 crime·education 카테고리 추가
라이브 API 확인 결과 NewsData.io는 17개를 지원하는데 우리 스키마엔 12개만
있었다. 다섯 개 중 둘만 골라 넣는다.

넣는 근거 — 지서버 모델에 실제로 시켜서 고르는지 확인했다:
  "미국 범죄 뉴스" → {"category":"crime","country":"us"}   정확히 고름
단어가 1:1로 대응되는 분야는 잘 고른다.

빼는 근거:
- domestic: "미국 국내 뉴스만 보여줘"에도 모델이 고르지 못했다. 더 결정적으로,
  직접 받아보니 이름과 달리 국내 필터가 아니라 지역/로컬 분류다 — country=us에
  category=domestic을 걸었더니 카슈미르 기사와 스페인어 기사가 그대로 나왔다.
  콩고 같은 국제 뉴스 섞임의 해법이 될 거라 기대했는데 아니었다
- lifestyle/other: 사용자가 그 말을 쓸 일이 드물어 CATEGORY_TOPIC 정규식을
  쓸 수가 없다. 안전망 없이 스키마에만 넣으면 모델이 붙인 분야가 그대로 통과한다

추가가 위험하지 않은 이유는 보정(8a8c81e)이 안전망이기 때문이다 — 사용자가
"범죄"라고 하지 않았는데 모델이 crime을 붙이면 제거된다. 단 CATEGORY_TOPIC에
함께 넣는다는 전제에서만 그렇다. 그래서 스키마와 보정 맵이 어긋나면 실패하는
드리프트 테스트를 같이 넣었다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 13:00:23 +09:00
kimandClaude Opus 5 bf07a15112 fix: 새어나온 바이트 폴백 토큰을 원래 글자로 복원
실사고(2026-08-12): 뉴스 요약 답변이 이렇게 나왔다.

  "정치적 갈등과 사회적 사건들이 눈에 <0xEB><0x9D><0x95>니다."
                                    EB 9D 95 = U+B755 = 띕

GGUF 토크나이저에 통글자 항목이 없는 글자는 바이트 단위로 쪼개지고, 디토크나이저가
다시 붙여야 하는데 실패하면 바이트 토큰의 "표기"가 그대로 출력에 남는다. 모델은
정확한 바이트를 냈다 — `띕`(ㄸ+ㅢ+ㅂ)이 토큰 테이블에 없을 만큼 드문 음절일 뿐이다.
잃은 정보가 없고 정답이 완전히 결정되는 무손실 복원이라, 코드로 갈 자리다.

처음 발견했을 땐 로그 전체에 3건이라 빈도가 낮다고 보고 미뤘는데, 바로 다음
답변에서 또 나왔다. "눈에 띕니다"가 뉴스 요약에서 흔한 표현이라 체감 빈도가
계산보다 높다 — 미룬 판단이 틀렸다.

복원이 스스로 해를 끼치지 않도록 두 가지를 지킨다:
- 유효한 UTF-8로 디코드되는 것만 바꾼다. 홀로 나온 <0xFF>는 잘린 글자가 아니므로
  U+FFFD로 바꾸면 복원이 아니라 정보 파괴다
- 코드블록·인라인코드 안은 건드리지 않는다. 거기 있는 <0xEB>는 내용이다
  (헥스 덤프, 토크나이저 예제, 이 기능 자체의 문서 등)

조립된 전체 텍스트에 적용한다 — 스트리밍 청크에 걸면 여러 청크에 걸쳐 도착한
바이트 열을 놓친다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 12:51:50 +09:00
kimandClaude Opus 5 2f6872b026 fix: 루프 감지를 인자 보정 전 원본으로 판정한다
실사고(2026-08-12): "오늘 미국 주요 뉴스"에 모델이 분야를 나눠 세 번 불렀다.

  news_search({category:"technology", country:"us"})
  news_search({category:"business",   country:"us"})
  news_search({category:"world",      country:"us"})

category 보정(8a8c81e)이 셋을 전부 {"country":"us"}로 접었고, 그 뒤에 도는
루프 감지가 이를 "같은 호출 3회"로 읽어 LOOP WARN x3을 찍었다. 모델은 루프에
빠진 적이 없다 — 서로 다른 세 호출을 우리가 같게 만든 것이다.

동작에는 지장이 없었다(중복 제거가 받아 1회만 실행, 같은 초 안에 끝났고 답변
품질은 오히려 개선). 문제는 경고가 거짓이라는 것이다. 이 소음이 쌓이면 진짜
루프를 디버깅할 때 묻힌다.

두 판정의 질문이 다르므로 인자도 달라야 한다:
- 루프 감지 = "모델이 같은 행동을 반복하는가" → 보정 전 원본 인자
- 중복 실행 방지 = "이 실행이 불필요한가"     → 보정 후 인자(접힌 뒤 같아진
  호출은 실제로 불필요하므로 그대로 둔다)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 12:47:06 +09:00
kimandClaude Opus 5 bc2ae25a34 feat: 날씨·뉴스 도구 게이트가 직전 사용자 발화까지 본다
인자 보정에 적용한 것(dbe34ba)과 같은 문제가 도구 선택 게이트에도 있었다.
날씨 대화 중에 "그럼 모레는?"이라고 하면 그 메시지에 날씨 단어가 없어서
weather_* 가 스키마에서 빠진다 — 어제 "향후 비소식" 사고와 같은 결말이 된다.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 12:11:46 +09:00
kimandClaude Opus 5 8a8c81ea91 fix: news_search가 사용자가 지정하지 않은 분야로 뉴스를 좁히던 문제
실사고(2026-08-12): "오늘 미국 주요 뉴스"에 모델이 category를 스스로 붙였다.

  news_search({"category":"technology","country":"us","language":"en"})
  news_search({"category":"business","country":"us","language":"en"})

그래서 답변이 "미국 내 기술 및 비즈니스 분야의 주요 뉴스"가 됐다. 사용자는
분야를 지정한 적이 없고, 정치·사회·국제 뉴스는 통째로 빠졌다.

스키마 설명에는 이미 "특정 국가 뉴스면 category를 빼거나 top을 쓰라"고 적혀
있었다. 이틀간 여섯 번째로 프롬프트가 무시된 사례다.

correctNewsCategoryForBreadth: 사용자 메시지에 해당 분야를 가리키는 말이
없으면 category를 뺀다. **오직 넓히기만 한다** — 뺀 결과는 그 나라의 일반
헤드라인이고, 그게 분야를 말하지 않은 요청의 뜻이다. 좁힐 수는 없으므로
오작동해도 사용자가 잃겠다고 하지 않은 범위를 잃는 쪽으로만 틀린다.
"top"은 분야가 아니라 전체 피드이므로 건드리지 않는다.

호출 지점 세 곳(일반/합성/텍스트파싱) 모두에 적용하되, 도구 이름으로
게이트하는 얇은 래퍼를 한 곳에 두어 분기를 모았다 — 바로 아래 image_edit
차단과 같은 자리, 같은 방식이다.

알려진 한계: 현재 메시지만 본다. 분야 요청 후 "더 보여줘" 같은 후속에선
분야가 풀려 일반 뉴스가 나온다. 넓어지는 쪽이라 안전한 실패 방향이다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 12:00:59 +09:00
kimandClaude Opus 5 f042a4cb4e feat: 검색 결과를 다 읽지 않고 "못 찾았다"고 하면 재프롬프트
실사고(2026-08-10, "태풍 15호 현재 위치는 어디지?"): web_search가 5건을
돌려줬는데 1번이 기상청 쿠키 설정 안내 조각이었다. 모델은 1번만 읽고 검색이
아무것도 못 찾았다고 결론냈다. 같은 응답의 4~5번에 태풍 이름과 진행 방향이
있었고, 동일한 결과를 받은 클라우드 대형 모델은 그걸 제대로 썼다.

이건 원래 프롬프트로 넣었던 것이다 — 지서버 모델 프로필의 1,531자 안에 이
사고를 쿼리와 쿠키 결과까지 명시해 적어놨는데, 08-12에 또 났다. 이틀간
다섯 번째로 프롬프트가 코드에 진 사례이고, 오늘 실험이 이유를 설명한다:
격리 조건에선 지시를 다 지키지만 제약이 경쟁하면 주 목표만 남기고 버린다.
가드는 주의력을 놓고 경쟁하지 않는다.

**핵심 난점은 "못 찾았다"가 자주 맞는 말이라는 것.** 같은 로그의 정상 답변은
태풍을 찾아내고 좌표만 없다고 밝혔다 — 이걸 재시도시키면 순수 낭비다.
구분은 첫 문장에 있다: 포기는 실패로 시작하고, 진짜 답변은 발견으로 시작한 뒤
빈 곳을 뒤에 붙인다. 그래서 판정은 lead sentence에서만 한다.

- countSubstantiveResults: 스니펫 40자 미만은 세지 않는다. 쓰레기 5건이
  재시도 근거가 되면 결과가 정말 부실할 때도 발동한다
- 임계값 3건: 2건이면 "여기 쓸 게 없다"가 정직한 독해인 경우가 잦다.
  3건 이상인데 못 찾았다면 목록 위쪽만 본 것이 거의 확실하다
- 재시도 1회 상한

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 11:16:28 +09:00
kimandClaude Opus 5 2afa76d262 fix: "기상현상 + 소식/예보/전망"을 한 패턴으로 일반화
사용자 지적: "비소식, 눈소식, 태풍소식 이런 것도 넣어야겠네."

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 10:59:26 +09:00
kimandClaude Opus 5 58c01b73ea fix: "비소식"·"기상청"이 날씨 키워드에 없어 날씨 도구가 통째로 빠지던 문제
실사고(2026-08-12): "향후 비소식은 없나?"에 날씨 도구가 하나도 안 붙었다.
스코프 게이트의 hasWeatherKeyword 목록(날씨|기온|강수|미세먼지|...)에 "비"도
"기상청"도 없어서 weather_kma·weather_openmeteo가 스키마에서 빠졌고, 모델은
쓸 도구가 없으니 web_search로 갔다가 2019년 기사를 물어와 "예보를 확인할 수
없다"고 답했다.

이어서 사용자가 "기상청에 알아봐"라고 명시했는데 **그 말조차 목록에 없어서**
두 번째 턴도 web_search로 갔다. 기상청 홈페이지를 검색한 것이지 기상청 API를
부른 게 아니다. 대화 전체가 날씨 도구 없이 돌았다.

이 게이트의 비용 비대칭은 프롬프트 게이트와 반대다 — 오탐은 도구 스키마가
몇백 토큰 늘 뿐이지만 미탐은 답변 경로 자체가 틀린다. 넉넉하게 잡는 쪽이 맞다.

실사용 메시지 103건으로 교정: "기상청" 신규 2건(전부 진짜 날씨),
"비" 문맥형 신규 2건(전부 진짜 날씨), 오탐 0건.

- 기상청|예보|기상 상황
- 비 문맥형(비소식/비예보/비가 오/소나기/호우/폭우/우산) — "비" 단독은 넣지
  않는다. 비용·비교·준비에 걸린다. 테스트로 박아둠
- 눈 문맥형(눈 소식/눈이 오/눈 올) — "눈" 단독은 신체 부위와 충돌
- 무더위|더위|추위|쌀쌀|습도|풍속|체감온도

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 10:50:59 +09:00
kimandClaude Opus 5 68bfba1e4d fix: 해석 실패한 컨텍스트 창 추측값을 브라우저가 캐시해 세션창이 8K에 박히던 문제
사용자 제보: "지서버 모델 연결됐는데 컨텍스트가 세션창에서만 8킬로", 그리고
"웹을 리로드하면 256킬로가 된다".

서버 쪽 마지막 폴백(8192)은 이미 캐시하지 않게 돼 있어서 매번 다시 조회한다
(그 사고를 겪고 고친 주석이 남아 있다). 문제는 브라우저였다 — app.js가

  if (_appCtxMax > 0 && !force) return;   // 한 번 받으면 끝

로 한 번 받은 값을 페이지 수명 내내 들고 있어서, 지서버가 자고 있을 때 열면
8192가 그대로 박혔다. 리로드가 고친 게 아니라 리로드로 캐시가 비워진 것.

메인 채팅 게이지는 SSE usage로 라이브 값을 받아서 멀쩡했고 세션창만 틀렸던
이유가 이것 — 두 화면이 서로 다른 경로로 같은 숫자를 구하고 있었다.

어댑터 쪽(bfbf04e)과 같은 원칙으로 고친다: **추측을 측정처럼 다루지 않는다.**

- resolveNumCtxInfo()가 value와 known(진짜 해석 결과인지)을 함께 반환
- /api/model-context가 provisional 플래그를 실어 보낸다. /api/show 실패나
  마지막 폴백이면 true
- app.js: provisional이면 화면에는 보여주되 _appCtxMax에 저장하지 않아 다음
  호출에서 다시 묻는다
- code.js: provisional이면 모델별 1회 래치(_codeAiCtxMaxFetchedFor)를 풀어
  다시 조회하게 한다

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 10:28:10 +09:00
kimandClaude Opus 5 bfbf04e0f5 fix: 컨텍스트 조회 실패를 사실처럼 캐시해 fixedNumCtx가 8K로 깎이던 문제
사용자 제보: "지서버 모델 처음 로딩하면 컨텍스트가 항상 8킬로인데, 웹을
리로드하면 256킬로가 된다."

getModelCtx()가 /api/show 실패 시 8192를 반환하면서 그 값을 캐시에 넣었다.
그리고 _resolveCtx가 요청값을 native로 깎는다:

  if (requested && requested > 0) return Math.min(requested, native);
  → Math.min(262144, 8192) = 8192

지서버가 자고 있을 때 첫 조회가 실패하면 8192가 캐시에 박히고, 캐시는
updateEndpoint()에서 엔드포인트 문자열이 바뀔 때만 비워진다. 그래서 한 번
오염되면 계속 8K로 돌았고, wol-gate 직결/게이트 전환이 일어나 캐시가 비워질
때 비로소 정상으로 돌아왔다 — 리로드하면 고쳐지는 것처럼 보인 이유가 이것.

추측한 값을 사실처럼 캐시하고, 그걸로 사용자 설정을 덮어쓴 게 버그다.
두 가지로 나눠 고쳤다:

- getModelCtxInfo()가 native와 함께 known(진짜 조회 결과인지)을 반환한다.
  **조회에 성공한 값만 캐시한다** — 지금 모델에 못 닿는다는 사실은 그 모델의
  컨텍스트 창에 대해 아무것도 말해주지 않으므로, 다음 호출에서 다시 조회한다.
  Modelfile num_ctx는 선언된 실제 값이라 캐시 대상으로 유지
- 명시적으로 요청된 창(models.profiles의 fixedNumCtx)은 **ceiling을 실제로 알 때만**
  깎는다. 폴백 추측값으로 깎는 게 262144를 8192로 만든 경로다

검증: 응답 없는 엔드포인트로 재현 → 수정 전 8192, 수정 후 262144 유지.
실제 지서버 조회는 262144 정상, 동적 사이징의 8192 폴백은 그대로 동작.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:20:50 +09:00
kimandClaude Opus 5 2e6530db6b feat: 다나와 판매 목록에서 하드웨어 스펙·국내 시세를 가져온다
공개 데이터셋 조사 결과 GPU 말고는 쓸 물건이 없었다. pc-part-dataset의
motherboard.json은 4,973건인데 필드가 7개뿐이고(칩셋·PCIe·M.2·VRM 없음,
색깔은 있음) 13개월 전이 마지막 갱신, CPU 쪽은 1~4년 낡았거나 인텔 전용
이거나 AGPL, 미니PC는 데이터셋 자체가 없다.

"광고에서 찾는 게 빠르겠다"는 사용자 지적이 맞았다. 파는 물건이라 낡을
수가 없고(낡으면 목록에서 사라진다), 데이터셋이 없는 미니PC도 상품 페이지는
반드시 있다. 같은 메인보드 질의 실측 비교:

  pc-part-dataset  name/price/socket/form_factor/max_memory/slots/colour
  다나와           AMD(소켓AM5) / AMD B650 / DDR5 / PCIe5.0 x16 /
                   M-ATX (24.4x24.4cm) / M.2 : 3개 / DrMOS / 전원부 방열판

라벨링은 GPU DB와 다르게 갔다. 저건 TechPowerUp 계측치라 "확정값"이지만
이건 판매처 표기이고 가격은 움직이는 값이라, 헤더에 그렇게 못박았다.
오늘 하루 주제가 근거에 정확한 라벨 붙이기였으니 출처를 부풀리지 않는다.

정중함은 선택이 아니라 제약으로 넣었다. robots.txt가 /dsearch.php는
허용하지만 Crawl-delay: 10이 걸려 있어서, 요청을 큐로 직렬화하고 10초
간격을 강제하며 30분 캐시를 둔다. 오늘 만든 비교 검색 쪼개기처럼 병렬로
3번 때리는 방식을 여기 쓰면 안 된다.

GPU에서 둘 다 걸릴 때 우선순위는 사용자 결정에 따라 다나와가 앞이고 스펙
DB는 폴백이다. 다만 버리지는 않고 출시일 한 줄은 남긴다 — 판매 목록엔 절대
없는 필드이고, 이 사태의 출발점인 "아직 출시 안 됨" 환각을 못 쓰게 만드는
게 정확히 그 필드다.

- parseDanawaHtml은 순수 함수로 분리해 저장된 페이지로 테스트한다. 스크래핑
  마크업은 언젠가 깨지는데 진짜 위험은 "조용히" 깨지는 것이라, 0건 파싱은
  전부 데이터 없음으로 처리하고 회귀는 테스트가 잡는다
- 가격은 price_sect 안의 <strong>만 인정한다. 블록 첫 <strong>은 평점일 수
  있어 실제 페이지에서 "65"·"2"가 가격으로 잡히는 걸 확인하고 좁혔다
- isPcPartQuery는 부품 신호 + 스펙/가격 의도가 함께 있을 때만 참이다.
  10초 딜레이가 있으니 조회를 아껴 쓴다. "메인보드에 CPU 끼우는 법"은 안 잡음
- 테스트가 구멍을 하나 잡았다: 카테고리 명사만 넣었더니 "RTX 5060 최저가"가
  통과 못 했다. 사람은 부품을 모델명으로 부른다 — 모델명 표기를 추가

실측: "B650 메인보드 스펙" 1.7초, "미니PC 가격" 10.1초(크롤 딜레이 작동 확인)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 14:50:18 +09:00
kimandClaude Opus 5 231ecbfce6 feat: GPU 스펙 구조화 DB를 검색 결과에 자동 주입
"시어엔진에 이런 데이터 전용 API가 있나" → 253개 엔진 중 하드웨어 스펙
엔진은 0개였다(geizhals는 403 봇차단, ddg definitions는 GPU 모델명에 빈
응답). 대신 GitHub에서 RightNow-AI/RightNow-GPU-Database(Apache 2.0,
TechPowerUp 기반, 2,824개 GPU, 55필드)를 찾았고, README엔 언급이 없지만
실물엔 RTX 50 시리즈가 들어있는 것을 데이터를 받아 확인했다.

이게 지금까지의 우회로보다 나은 이유:
- nvidia.com 공식 페이지는 스펙 키워드 0개(JS 렌더링), techpowerup은
  봇 차단. HTML 스크래핑으로 살아있는 출처가 nanoreview 하나뿐이었다
- 봇차단·JS·파싱실패·죽은링크가 전부 없고, 캐시 워밍 후 64ms
- 무엇보다 모든 레코드에 releaseDate가 있다. 5060은 2025-05-19다.
  이 사태의 출발점이던 "5060은 미출시" 환각은 데이터와 만나는 순간
  성립하지 않는다 — 가드는 쓰인 거짓말을 잡지만, 이건 쓸 이유를 없앤다

신선도가 진짜 리스크라 거기에 설계를 집중했다. 호스팅 API가 아니라 GitHub
저장소라서, 저쪽이 멈추면 우리도 멈추고 그러면 지금 고치는 실패가 데이터
계층에서 재현된다. 그래서 주간 갱신 + 결과에 캐시 나이를 항상 같이 실어
낡은 답이 조용히 틀리는 대신 눈에 띄게 낡도록 했다. 갱신은 백그라운드라
질문을 느리게 만들지 않고, 실패해도 옛 캐시로 계속 답한다.

도구가 아니라 주입으로 넣은 이유: 모델이 도구를 안 부른다. 로그 3,300줄
에서 web_fetch 호출 1번, GPU 질문엔 0번이었다(오늘 같은 결론 네 번째).

- extractGpuMentions: 맥락이 있을 때만 맨숫자를 모델명으로 본다.
  "4080이 5060보다"는 잡고 "2024년 매출 3800억"은 안 잡는다 — 무관한
  답변에 스펙이 끼어들면 그 자체가 오염이다
- findGpu: "RTX 4080"은 SUPER/Mobile 이름에도 부분일치하므로 변형에
  가중치를 줘 기본 카드를 고른다. 데스크탑 질문에 노트북 칩 수치를
  물려주면 조용히 틀린 답이 된다
- 캐시 쓰기는 write-then-rename (config.json 3회 손상과 같은 실패 방지)

실측: RTX 4080 FP32 48.74 TFLOPS / 716.8 GB/s vs RTX 5060 19.18 TFLOPS /
448 GB/s — 2.54배 차이를 근거를 갖고 말할 수 있게 됐다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 14:34:34 +09:00
kimandClaude Opus 5 de5407b701 feat: 비교 검색에서 스펙이 실린 페이지 본문을 자동으로 가져온다
쪼개기(9f31d76)로 좋은 출처가 결과 목록엔 들어왔는데도 답변이 여전히
일반론이었다. 원인은 모델이 결과를 안 펼쳐본다는 것이다 — 프로덕션
로그 3,300줄에서 web_fetch 호출은 딱 1번, 그것도 기상청 페이지였고
GPU 스펙 질문에선 0번이다. 도구 설명에 "결과 URL을 web_fetch로 읽어라"라고
써 있는데도 그렇다. 스니펫엔 제품 이름만 있고 수치는 본문에 있으니,
스니펫만 보고 쓰면 "80 라인업이 60 라인업보다 빠르다"에서 멈춘다.

사용자 질문("스펙을 제조사에서 찾기가 어려운가?")에 답하려고 상위 세
출처를 실제로 가져와 재보니, 제조사 공식 페이지가 셋 중 가장 나빴다:

  nvidia.com    6016자, 스펙 키워드 0개 — 표가 JS 렌더링이라 본문은
                "This site requires Javascript" + "Game Changer" 홍보 문구
  techpowerup    275자, 스펙 키워드 0개 — 봇 차단
  nanoreview    Cores/TMUs/Boost Clock/Bandwidth/TFLOPS 전부 있음

그래서 가져오되 거르는 구조로 했다. hasSpecContent가 통과시킨 첫 페이지
하나만 대상별로 붙인다 — 이게 없으면 "Game Changer" 6KB가 컨텍스트를
차지하고, 여러 장을 붙이면 모델이 읽고 지나가야 할 양만 늘어난다.

실측(같은 쿼리): nanoreview 4080·5060 본문 2장 자동 확보, 9.4초,
RTX 4080 Cores 9728 / 게이밍 76 vs RTX 5060 Cores 3840 / 게이밍 43 —
"얼마나 빠른지"에 실제로 답할 수 있는 수치가 처음으로 들어왔다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 13:47:42 +09:00
kimandClaude Opus 5 9f31d76edc fix: "A vs B" 비교 검색을 코드에서 대상별로 쪼개 실행
어제(08-10) "A vs B로 합쳐 검색하지 말고 각각 따로 검색하라"를 web_search
설명에 넣었는데, 오늘 프로덕션 로그에서 모델이 세 턴 연속(3149/3287/3303)
정확히 금지된 형태를 그대로 날렸다:

  web_search({"query":"RTX 4080 vs RTX 5060 performance comparison specs"})

매번 technical.city 링크가 죽어 있었고(link-validator 2/5 dead), 모델
손에 남은 건 제목뿐이라 답변은 수치 0건의 일반론이 됐다. 사용자 평가는
"자료가 부실하고 요점도 안 맞는다".

오늘 하루 네 번째 같은 패턴이다 — news_search country 파라미터, 카테고리
단어, 다도시 날씨 배치, 그리고 이번 건. 도구 설명으로 쿼리 구성을 강제할
수 없다는 게 반복 확인됐으므로 코드에서 쪼갠다.

- splitComparisonQuery: 비교 동사(comparison/vs/비교/차이)는 버리고
  속성어(performance/specs/대역폭/성능)는 양쪽에 붙여 둘로 나눈다
- 합친 쿼리도 그대로 실행한다. 비교 페이지가 살아있을 땐 그게 최선의
  출처라서, 빼면 실패 모드를 다른 실패 모드로 바꾸는 것에 불과하다.
  쪼갠 검색은 추가분이고 결과 수를 3개로 더 조인다
- 오탐 쪽을 비싸게 본다: 속성어도 모델번호 꼴도 없으면 쪼개지 않는다
  ("Lakers vs Celtics"를 두 검색으로 만들면 질문 자체가 사라진다).
  "검색결과 스펙"처럼 과/와로 끝나는 단어에 걸리는 것도 길이로 막았다

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 13:13:05 +09:00
kimandClaude Opus 5 23cd4886af fix: "아직 출시되지 않았다" 같은 미검증 부재 주장도 근거 검증 대상에 포함
RTX 4080 vs 5060 비교 요청에서 모델이 답을 못 주는 이유로
"RTX 5060의 미출시 상태"를 단정했다. 실제로는 이미 출시돼 사용자
지서버에 꽂혀 있는 카드다.

기존 가드 셋이 전부 통과시킨 이유:
- AUTO-RECOVER: 검색을 하긴 했다 (도구 호출 있음)
- EMPTY-GROUNDING: 결과가 비어있지 않았다
- NUMERIC-GROUNDING: 답변에 수치가 0건이라 검증할 주장 자체가 없었다

세 가드 모두 숫자를 전제로 하는데, 이 답변의 유일한 거짓말에는
단위가 없었다.

부재 주장은 별도로 검증할 값어치가 있다. 모델은 "학습 데이터 시점
기준으로 출시 안 됨"만 알 수 있을 뿐 출시 여부 자체를 알 수 없다
(5060은 이 모델 컷오프 4개월 뒤 출시). 기억에 없는 것을 세상에 없는
것으로 바꿔 말하는 건 사실 판단이 아니라 범주 오류다. 검증 방법도
날짜 검증과 같다 — 출처가 정말 미출시라고 하면 그 표현이 출처 텍스트에
있고, 없으면 모델이 지어낸 것이다.

- checkNumericGrounding에 nonexistenceClaim/nonexistenceUnsupported 추가
- 출처 지지 판정(NONEXISTENCE_SUPPORT)은 느슨하게 잡았다. 느슨한 쪽이
  재시도를 억제할 뿐 만들어내지는 않는 안전한 실패 방향이다
- 사용자가 질문에 적은 전제("그거 아직 안 나왔지?")를 되풀이한 건
  숫자와 동일하게 검증 대상에서 제외
- 재프롬프트 문구를 분리했다. 기존 "출처에 있는 숫자만 써서 다시 써라"는
  이 경우 헛도는 지시다 — 고칠 숫자가 없고, 문제는 정반대(지어낸 사실을
  구실로 답변을 거절)이기 때문

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 13:05:15 +09:00
kimandClaude Opus 5 9ab4da34e5 fix: "A보다 빠른가" 캐주얼 비교 표현을 isFactualInfoRequest가 못 잡던 문제
실제 사고(2026-08-11): "5060 보다 얼마나 빠르지 4080?"에 도구 호출
0건, 모델이 "RTX 5060은 아직 출시되지 않은 차세대 모델"이라고
완전히 지어냈다 — 실제로는 이미 출시돼 지서버가 쓰고 있는 카드
(RTX 5060 Ti)다. "성능"/"비교" 등 기존 키워드를 하나도 안 써서
이 파일 자체 주석이 경고하던 "casual paraphrases slip through
unmatched"가 실제로 뚫렸다.

"A보다 (얼마나) 빠른/느린/좋은/나은" 형태의 캐주얼 비교 표현을
형용사 목록으로 추가. 구현 중 발견: 빠르다/느리다는 러-불규칙
활용이라 관형형이 "빠르ㄴ"이 아니라 "빠른"으로 어간 자체가
바뀐다(느리다→느린도 동일) — 어간만 넣으면 이 활용형을 놓쳐서
테스트가 그대로 잡아냈다("이게 저것보다 느린가?"가 최초 버전에서
빠짐), 두 형태 다 등록해서 고쳤다.

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