Cloudflare Pages Function + Claude API로 Wiki 기반 Q&A 봇 만들기
Properties3
description
Step-by-step guide for building a Q&A page (Cloudflare Pages Function + Claude API + KV) that answers questions grounded in a compiled Obsidian wiki, with automatic Query Result logging, markdown rendering, PDF export, near-instant local sync, a live Google Sheets query path for sales questions via service-account OAuth2 (plus a bounded tool-use loop for row-level drill-down and a second live path for the TA$/TADollar rebate ledger), a reusable recipe for adding further hand-authored pages (e.g. an embedded Power BI dashboard) with sidebar navigation buttons, a one-click manual sync trigger that reuses the registered Scheduled Task (with a VBScript locale-encoding pitfall and fix), a diagnosed reliability root cause for the sync automation (unpinned npx wrangler), a build-failure diagnosis pattern (Scheduled Task success vs. actual quartz-build success, and a markdown-table-escape-copied-into-YAML pitfall), a hidden 'archive block' pattern for non-Korean questions (Claude self-translates a title/body into Korean plus a third language inside a visitor-invisible metadata block, wired into the existing i18n title/body pipelines), a session-id based mechanism for merging follow-up questions into their original Query Result doc instead of splitting them into separate files, client-side pagination + topic filtering for the growing Answers hub, and a diagnosed root cause + fix for a hub/detail-page mismatch bug caused by re-sorted (non-permanent) document numbering combined with a slugify mismatch, and a raised input-length ceiling (2000/5000 to 10000 chars) for the ask and feedback endpoints after a real user hit the old ask.js limit, plus the migration from Basic Auth to Cloudflare Access (Zero Trust One-time PIN + email-domain policy) with a dashboard-navigation trail and a fix for Cf-Access-Authenticated-User-Email coming back empty by decoding the Cf-Access-Jwt-Assertion JWT instead, plus a local PowerShell script that emails the original asker via Resend (bypassing Access entirely, since Resend is a third-party API) whenever their answer doc is later updated.
Wiki 기반 Q&A 봇 구축 가이드, Cloudflare Pages Function Claude API Q&A
98 min read
Cloudflare Pages Function + Claude API로 Wiki 기반 Q&A 봇 만들기
Key Insight
이미 Obsidian 볼트를 Cloudflare Pages로 웹 발행하기로 정적 사이트를 발행했다면, 그 위에 “질문하면 컴파일된 Wiki를 근거로 답하는” 페이지를 얹는 데 새 인프라가 거의 필요 없다 — Cloudflare KV(컨텍스트 저장 + 질문 로그) + Pages Function(Claude API 호출) + 기존 재빌드 스크립트 확장만으로 충분하다. 핵심 설계 결정은 RAG(임베딩/벡터검색) 없이 컴파일된 Wiki 전체를 매 질문마다 컨텍스트로 그대로 전달하는 것 — RAG vs Compiled Wiki 철학과 직접 일치.
사용자 질문 (브라우저) → /ask 정적 페이지 → POST /api/ask (Pages Function)
↓
KV에서 context:wiki-bundle 읽기
↓
시스템 프롬프트 + 질문 → Claude API
↓
답변 반환 + KV에 query:{ts}:{uuid} 기록
↓
TAB-Wiki-QuerySync(경량 주기) → 30. Queries/*.md 로 동기화 + KV 삭제
↓
TAB-Wiki-Rebuild(무거운 주기) → 새 Query Result 포함해 컨텍스트 번들 재생성
질문이 늘수록 30. Queries/가 쌓이고, 다음 재빌드에서 그 답변들이 다시 컨텍스트에 편입된다 — compiled wiki가 스스로 성장하는 Karpathy 패턴. 다만 특정 도메인(예: 매출)의 질문은 이 정적 컨텍스트만으로 부족할 수 있다 — 그 경우의 확장 패턴은 §6 참고. 이 라이브 조회 경로는 이후 두 방향으로 더 확장됐다: §10은 사전 집계 표로 못 푸는 세부 조합을 위해 Claude가 원본 행을 직접 쿼리하는 tool-use 루프, §11은 같은 스프레드시트의 TA$(TADollar) 리베이트 탭을 위한 두 번째 독립 라이브 경로다. 또한 §12는 같은 대화의 후속 질문이 별도 문서로 쪼개지지 않도록 하는 세션 병합, §13은 /answers 허브의 페이지네이션·주제 필터를 다룬다.
Details
1. Cloudflare KV 설계 — 컨텍스트와 로그를 분리
하나의 KV 네임스페이스에 용도가 다른 두 종류의 키를 둔다:
context:wiki-bundle — 컴파일된 Wiki 전체 텍스트 번들. 재빌드마다 덮어쓰기.
query:{ISO-timestamp}:{uuid} — 질문·답변 임시 기록. 동기화 스크립트가 읽은 뒤 삭제(큐처럼 동작).
KV 네임스페이스는 wrangler kv namespace create로 만들고, Pages 프로젝트에 바인딩하는 CLI 플래그가 없으므로 Cloudflare REST API로 직접 PATCH해야 한다(/accounts/{id}/pages/projects/{project}의 deployment_configs.production.kv_namespaces).
2. Pages Function — 시스템 프롬프트 + 항상-200 패턴
functions/api/ask.js가 질문을 받아 Wiki 컨텍스트 + 규칙(확인된 사실/추정 구분, Wiki 외 내용 금지, 참고 문서 나열)을 시스템 프롬프트로 구성해 Claude API를 호출한다.
Cloudflare 엣지가 5xx 응답 본문을 자체 에러 페이지로 덮어씀
Function이 의도적으로 502를 반환해도(예: “AI 호출 실패”), Cloudflare 엣지가 JSON 바디를 자체 “error code: 502” 페이지로 교체해버려 진짜 에러 메시지가 클라이언트에 안 보인다. 해결: 애플리케이션 레벨 에러도 항상 HTTP 200으로 반환하고, 성공/실패를 JSON 바디의 ok: true/false로만 구분한다.
function okResponse(obj) { return new Response(JSON.stringify(obj), { status: 200, headers: { "Content-Type": "application/json; charset=utf-8" }, });}
Claude 응답에 "thinking" 블록이 섞여 나올 수 있음
content 배열의 첫 번째 요소가 {type:"thinking",...}이고 실제 답변은 {type:"text",...}가 그 뒤에 오는 경우가 있다. content[0].text로 가정하면 빈 답변을 받는다 — 반드시 .find(b => b.type === "text")로 명시적으로 찾을 것.
claude-sonnet-5는 thinking 파라미터를 생략하면 기본적으로 adaptive thinking이 켜지고, 그 thinking 토큰도 max_tokens 한도 안에 함께 카운트된다. 상세 분석처럼 긴 답변을 요구하는 질문에서 max_tokens를 여유 없이 잡으면(예: 2000) 실제 보이는 답변용 토큰이 그보다 훨씬 적어져 표 중간에서 잘리는 증상이 난다. 이 앱처럼 다단계 추론이 필요 없는 “위키 근거 종합” 용도라면 thinking: { type: "disabled" }를 명시하고 max_tokens를 충분히(수천~수만) 잡는 편이 예측 가능하고 비용도 아낀다.
body: JSON.stringify({ model: "claude-sonnet-5", max_tokens: 6000, // 표·목록 포함 상세 답변 기준 여유 있게 thinking: { type: "disabled" }, system: systemPrompt, messages: [{ role: "user", content: question }],}),
또한 Claude API는 한도에 도달하면 응답에 stop_reason: "max_tokens"를 포함해 알려준다(조용히 자르지 않음) — 이 값을 프론트엔드까지 전달해 잘림 발생 시 사용자에게 경고를 보여주는 것이 안전하다. max_tokens를 아주 크게(예: 50000) 잡을 경우, 이 구현이 스트리밍 없는 단일 요청이라 실제로 그만큼 길게 생성되면 Function/브라우저 fetch 타임아웃 위험이 이론상 남는다 — 스트리밍 전환 또는 상한 재조정으로 대응.
3. 정적 질문 페이지 — Quartz 파이프라인 밖에서 직접 관리
static-pages/ask.html은 Quartz가 빌드하지 않는 수제 HTML이라, 재빌드 스크립트가 매번 public/ask/index.html로 복사해야 한다. 세 가지 UX 요소:
마크다운 렌더링: 정규식으로 ##/**bold**만 처리하면 표(|)·목록이 그대로 깨져 보인다 — marked.js(CDN) 같은 실제 파서를 붙여야 함.
PDF 다운로드: html2pdf.js 등 추가 의존성 대신 window.print() + 인쇄 전용 #print-view div(@media print로 나머지 UI 숨김) 조합으로 충분하다.
토큰/비용 표시: Anthropic API 응답의 usage.input_tokens/output_tokens로 계산. 단, Anthropic API에는 계좌 잔액 조회 엔드포인트가 없다 — “남은 크레딧”은 표시 불가, 대신 KV에 자체 누적한 “이 도구의 추정 사용액”만 보여줄 수 있다(실제 청구액과는 다를 수 있음을 명시할 것).
4. 동기화 아키텍처 — 전체 재빌드와 로그 동기화를 분리
전체 사이트 재빌드(vault 미러 + quartz build + wrangler pages deploy)는 수십 초가 걸리므로, 매번 다 돌리면 “질문 직후 로컬 반영”이 늦어진다. KV → 30. Queries/ 동기화만 떼어낸 경량 스크립트를 만들어 짧은 주기로 따로 돌리고, 무거운 재빌드는 더 긴 주기를 유지한다.
주기를 얼마나 짧게 잡아야 할까 — 실측 기반 판단
Quartz는 증분 빌드가 아니라 매번 전체 페이지를 처음부터 다시 굽는다 — 바뀐 파일이 0개든 여러 개든 재빌드 1회 비용(실측 5570초)은 거의 고정이다. 이 비용을 기준으로:
무거운 재빌드(vault 미러+quartz build+wrangler pages deploy+KV 컨텍스트 번들 재생성)를 1분·5분처럼 짧게 잡으면, 실행 시간이 주기와 비슷하거나 길어져 MultipleInstances: IgnoreNew를 걸어도 사실상 거의 쉬지 않고 도는 상태가 된다 — CPU 점유율 상승, 배터리 소모, Cloudflare 배포/KV 쓰기 횟수도 월 단위로 급증(무료 티어 한도 초과 가능성).
경량 동기화(KV 읽기→파일 쓰기만, 수 초 이내)는 훨씬 짧은 주기(예: 1~10분)를 잡아도 부담이 적다 — 실제 병목은 “재빌드로 웹사이트에 반영되는 속도”이지 “로컬 파일에 쓰이는 속도”가 아니므로, 두 스크립트의 주기를 분리해서 판단하는 것이 핵심이다.
콘텐츠가 실제로 바뀌는 빈도(사람이 볼트를 편집/ingest하는 빈도)가 애초에 분 단위가 아니라면, 무거운 재빌드는 30분~1시간 정도로도 충분한 경우가 많다.
"예약 작업 = CLI 창이 계속 뜬다"는 오해
Windows Task Scheduler는 원래 완전 백그라운드 실행이다. 다만 powershell.exe -WindowStyle Hidden은 Windows에 따라 아주 짧은 콘솔 창 깜빡임이 있을 수 있고, 짧은 주기에서는 누적되어 거슬릴 수 있다. 완전히 없애려면wscript.exe + VBScript(WScript.Shell.Run(cmd, 0, True))로 감싼다 — 프로세스 생성 자체가 창을 만들지 않는 방식.
.vbs 파일은 .ps1과 정반대로 UTF-8 BOM이 있으면 안 된다..ps1은 BOM이 없으면 한글 리터럴이 깨지지만(→ BOM 있는 UTF-8로 저장해야 함, Windows PowerShell 5.1 CLI 트러블슈팅 패턴 모음 참고), .vbs는 BOM이 있으면 Windows Script Host가 "(1, 1) Microsoft VBScript compilation error: Invalid character"로 파싱 자체를 거부한다 — .vbs는 반드시 BOM 없는 UTF-8로 저장할 것 ([System.IO.File]::WriteAllText($path, $content, [System.Text.UTF8Encoding]::new($false))).
"지금 당장 반영해줘"용 수동 트리거 — 새 스크립트 대신 등록된 작업을 재사용
재빌드 주기를 아무리 짧게 잡아도(위 노트 참고) “지금 당장 반영하고 싶다”는 순간은 반드시 생긴다. 이때 재빌드 로직을 복사한 새 스크립트를 만들지 말고, 이미 등록된 Scheduled Task를 schtasks /run /tn "{TaskName}"으로 즉시 실행한다 — 로직이 한 곳(rebuild-site.ps1)에만 존재해 나중에 갱신할 때 두 곳을 맞출 필요가 없고, MultipleInstances: IgnoreNew 중복 실행 방지와 로그 기록도 예약 실행과 완전히 동일하게 적용된다.
Set objShell = CreateObject("WScript.Shell")objShell.Run "schtasks /run /tn ""TAB-Wiki-Rebuild""", 0, TrueMsgBox "동기화를 시작했습니다 (백그라운드, 완료까지 약 1분)."
이 .vbs를 바탕화면에 두면 더블클릭 한 번으로 즉시-동기화 “버튼”이 된다. 주의할 점: objShell.Run(cmd, 0, True)의 True(대기)는 schtasks.exe 명령 자체가 반환할 때까지만 기다리는 것이지, 트리거된 재빌드가 끝날 때까지 기다리는 게 아니다 — schtasks /run은 “시작 요청”만 하고 즉시 반환하므로, MsgBox는 “완료됐다”가 아니라 “시작했다”로 정확히 안내해야 사용자가 오해하지 않는다.
VBScript MsgBox로 한글(비ASCII)을 표시하면 파일 인코딩과 무관하게 깨질 수 있다
위 두 예제처럼 .vbs 안에 한글 문자열을 직접 넣으면, BOM 유무를 올바르게 맞춰도 여전히 깨질 수 있다. BOM 없는 .vbs는 실행 시 항상 시스템의 “유니코드가 아닌 프로그램의 언어”(비-Unicode 프로그램 로케일) 코드페이지로 텍스트를 해석하는데, 이 로케일이 한국어(CP949)가 아니라면(예: 영어 Windows에 한국어 사용자가 로그인해 쓰는 흔한 구성 — [System.Text.Encoding]::Default로 실제 로케일 확인 가능) UTF-8이든 CP949든 어떤 인코딩으로 저장해도 결국 잘못된 코드페이지로 해석되어 깨진다. 이건 “BOM 있어야 하나 없어야 하나”보다 한 단계 더 깊은 함정이다 — VBScript MsgBox 자체가 시스템 로케일에 종속된 ANSI 텍스트만 안정적으로 표시할 수 있는 구조적 한계.
해결: .vbs에는 non-ASCII 문자열을 아예 넣지 않고 트리거 역할만 맡긴다. 실제로 사용자에게 보여줄 텍스트는 .vbs가 hidden 실행하는 .ps1(UTF-8 BOM 있음)이 [System.Windows.Forms.MessageBox]::Show(...)(진짜 Win32 유니코드 API, 시스템 로케일 영향 없음)로 띄우게 위임한다.
# sync-now.ps1 — 실제 안내창(한글 정상 표시), UTF-8 BOM 저장Add-Type -AssemblyName System.Windows.Forms[System.Windows.Forms.MessageBox]::Show("동기화를 시작했습니다.", "TAB Wiki 동기화") | Out-Null
npx {tool}을 예약 자동화에 쓴다면 반드시 package.json에 고정할 것
sync-queries.ps1/rebuild-site.ps1은 둘 다 npx wrangler ...로 Cloudflare CLI를 호출하는데, 이 프로젝트의 package.json에 wrangler가 정식 devDependency로 고정되어 있지 않았던 시기가 있었다. 그 상태에서는 매 실행마다 npx가 로컬 캐시를 못 찾고 npm 레지스트리에서 새로 내려받으려 시도한다(npm warn exec ... will be installed 경고로 확인 가능) — 이 fetch 과정이 네트워크 상태에 따라 간헐적으로 실패하면서, 겉보기엔 “Cloudflare API 요청 실패”처럼 보이는 에러가 반복 발생했다(실제로는 wrangler 자체를 가져오는 단계에서 막힌 것). 10분 주기로 도는 자동 동기화 작업에서 이런 실패가 누적되면, 사용자가 사이트에 남긴 질문이 로컬 볼트에 며칠씩 반영되지 않는 문제로 이어질 수 있다.
해결: npm install --save-dev wrangler로 프로젝트에 정식 고정한다. 이후 npx wrangler는 로컬 node_modules의 설치본을 즉시 쓰므로 레지스트리 fetch 자체가 없어져 네트워크발 간헐적 실패가 사라진다. 일반화: Task Scheduler 등으로 반복 실행되는 자동화 스크립트가 npx로 호출하는 CLI 도구는 예외 없이 package.json에 버전 고정해야 한다 — “가끔 잘 되는” 정도로는 무인 자동화에서 원인 추적이 매우 어렵다.
KV 동기화 스크립트를 만들 때 파일명 슬러그에 PowerShell 와일드카드 문자([ ] { })를 제대로 제거하지 않으면, Set-Content -Path가 그 경로를 와일드카드 패턴으로 잘못 해석해 -Encoding 같은 동적 파라미터 바인딩 자체가 깨진다 — 상세: Windows PowerShell 5.1 CLI 트러블슈팅 패턴 모음.
"경량 스크립트로 분리"가 "완전히 분리"까지 갔는지 확인할 것 — 중복 사본이 남아있던 사고 (2026-08-26)
위 패턴(KV→vault 동기화를 경량 스크립트로 떼어냄) 자체는 옳았지만, rebuild-site.ps1에 예전 버전의 Q&A/피드백 동기화 로직이 지워지지 않고 그대로 남아있었다 — 새 기능(로그인 이메일 캡처, 세션 ID 기반 후속질문 병합, 비한국어 질문 자동 번역)은 전부 sync-queries.ps1 쪽에만 추가되고, rebuild-site.ps1의 사본은 업데이트되지 않은 채 방치됐다. 평소엔 sync-queries.ps1(10분 주기)이 항상 먼저 처리해 겉으로 드러나지 않다가, 어쩌다 rebuild-site.ps1(매시간)이 먼저 처리한 레코드 하나(Myra의 질문)가 로그인 이메일·세션 ID를 통째로 잃어버리는 사고로 이어졌다. 교훈: “무거운 작업과 가벼운 작업을 스크립트로 분리”할 때, 가벼운 스크립트를 새로 만드는 것에서 끝내지 말고 무거운 스크립트 쪽의 원래 로직을 반드시 제거할 것 — 두 사본이 같은 소스(KV)를 동시에 소비하며 계속 존재하면, 한쪽만 기능이 발전하는 드리프트가 필연적으로 생기고 어느 사본이 실제로 처리했는지도 결과물만 봐서는 알기 어렵다. 2026-08-26부로 rebuild-site.ps1에서 이 중복 로직을 완전히 제거, sync-queries.ps1이 query:/feedback: 두 KV prefix의 유일한 소비자다.
"동기화 버튼이 눌린다" ≠ "사이트가 갱신된다" — 먼저 rebuild.log의 Build exit code를 확인할 것
TAB-Wiki-Rebuild 작업의 Get-ScheduledTaskInfo → LastTaskResult: 0은 예약 작업이 트리거되고 완주했다는 뜻일 뿐, 그 작업 내부의 quartz build가 실제로 성공했다는 뜻이 아니다. rebuild-site.ps1은 빌드가 실패하면 배포 단계 없이 즉시 종료하도록 짜여 있어(의도된 동작), 빌드가 계속 실패해도 예약 작업 자체는 “성공”으로 기록된다 — 사용자가 “동기화 버튼을 눌러도 반영이 안 된다”고 보고하면, 버튼/작업 자체가 아니라 rebuild.log의 최근 Build exit code/Deploy exit code 라인부터 확인하는 것이 가장 빠른 1차 진단이다.
실제 사례: /ingest로 대량의 Raw Source를 배치 변환하는 과정에서, 원본 마크다운 표 안의 이스케이프(\|, 파이프 문자를 표 셀 안에서 이스케이프하는 문법)를 그대로 YAML aliases: 필드로 복사한 파일 5개가 섞여 들어갔다. 마크다운 문맥에서 유효한 이스케이프가 YAML 문맥에서는 무효 문법이라, Quartz의 프론트매터 파서가 unknown escape sequence 에러로 빌드 전체를 중단시켰다 — 그 순간부터 몇 시간 동안 로컬 변경사항이 전혀 사이트에 반영되지 않았지만, 예약 작업의 LastTaskResult는 계속 0(성공)으로 표시되고 있었다. /lint도 이런 YAML 파싱 유효성 자체는 검사하지 않아 사전에 걸러지지 않았다.
일반화: (1) 대량 배치 변환 스크립트를 짤 때 “이 값이 원래 어느 문법 문맥(마크다운 vs YAML vs JSON)의 문자열이었는지”를 항상 의식하고, 다른 문맥으로 복사할 때 그 문맥에 맞게 재이스케이프한다. (2) Bash에서 Node.js 정규식을 이중따옴표 문자열에 중첩시키는 배치 수정은 이스케이프 레이어가 2~3겹 겹쳐 의도와 다른 정규식이 만들어질 수 있다(예: \\\|가 bash를 거치며 \\|가 되고, JS에서 이는 \\(리터럴 백슬래시) |(정규식 alternation) 로 해석되어 조용히 no-op) — 파일이 여러 개면 Edit 도구로 파일별 직접 치환하고, 수정 직후 Read로 재확인하는 편이 안전하다.
5. Quartz 사이트에 새 진입점 링크 추가하기 — SPA 라우터 예외처리
Quartz의 enableSPA: true는 내부 링크 클릭을 가로채 micromorph로 DOM만 교체하고 <script>를 재실행하지 않는다. /ask처럼 Quartz 파이프라인 밖의 수제 페이지로 링크를 걸 때, 이 SPA 가로채기를 받으면 페이지의 JS가 아예 실행되지 않은 채로 “로드된 것처럼” 보인다(새로고침해야만 정상 동작).
해결: 링크에 data-router-ignore 속성을 추가한다 — Quartz의 spa.inline.ts가 "routerIgnore" in a.dataset이면 가로채기를 건너뛰고 일반 페이지 로드를 한다.
<a href="/ask" data-router-ignore>💬 질문하기</a>
6. 특정 도메인 질문에 라이브 외부 데이터 연결 — Google Sheets 예시
컴파일된 Wiki는 재빌드 주기(예: 1시간)만큼 항상 약간 stale하다. 대부분의 질문에는 문제없지만, 분·시간 단위로 바뀌는 원장성 데이터(이 프로젝트에서는 매출/판매실적, 영업판매실적 조회 절차 참고)는 캐시된 요약을 쓰면 안 된다는 정책이 이미 Wiki에 있었다 — 문제는 그 정책이 전제하는 “Google Drive 커넥터”가 Claude Desktop/Code 세션에만 있는 MCP 도구라, 순수 HTTPS로 Claude Messages API를 호출하는 Cloudflare Worker에는 애초에 그 경로가 없다는 점이었다.
해결 패턴: Worker가 자체적으로 외부 API에 인증하는 별도 경로를 만들고, 질문 키워드로 그 경로를 탈 지 판단한다.
질문 수신 → 키워드 매치(매출/판매/영업/딜러/sales/dealer/...)?
│
├─ Yes → 서비스 계정 OAuth2(JWT-bearer) → Google Sheets API 직접 fetch
│ → 서버사이드 필터+집계(Worker가 계산) → "LIVE SALES DATA" 블록 생성
│ → 실패 시에도 "조회 실패" 사실 자체를 프롬프트에 명시(침묵 폴백 금지)
│
└─ No → 기존 Wiki-only 컨텍스트 그대로 사용
↓
시스템 프롬프트 = WIKI CONTENT + (있다면) LIVE SALES DATA
Cloudflare Worker에서 Google 서비스 계정 인증하기 (외부 라이브러리 없이): Node의 googleapis 패키지 같은 것은 Workers 런타임에서 못 쓴다(Node API 의존) — 대신 Workers에도 있는 Web Crypto API(crypto.subtle)만으로 JWT-bearer OAuth2 플로우를 직접 구현한다: RS256 서명(RSASSA-PKCS1-v1_5/SHA-256)으로 JWT를 만들고, https://oauth2.googleapis.com/token에 grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer로 교환한다. 발급된 access token은 KV에 만료시각과 함께 캐시(Workers는 요청 간 상태를 못 들고 있으므로).
원본 데이터가 너무 크면 Worker가 먼저 집계하고 Claude에는 요약만 준다
이 프로젝트의 시트는 16,800행+ — 원본 그대로 Claude 컨텍스트에 넣으면 토큰 한도를 넘긴다. Worker가 fetch 직후 도메인 규칙(영업판매실적 조회 절차의 매출 인식 필터 — Inv No 발행 + Status ∈ {Credit, Invoiced, Confirmed} 화이트리스트, Inv Date 기준 귀속)대로 서버사이드에서 필터·집계한 압축된 요약 표만 프롬프트에 주입한다 — “Worker가 계산, Claude는 해석”이라는 역할 분리를 지킨다. 규칙이 바뀌면 이 가이드가 아니라 정본(영업판매실적 조회 절차 “계산 규칙”)과 functions/api/_google-sheets.js를 함께 고친다(2026-08-12 블랙리스트→화이트리스트 정정이 그 예).
조회 실패 시 침묵 폴백 금지 — 실패 자체를 프롬프트에 명시
라이브 조회가 실패했다고 해서 조용히 Wiki에 있는 과거 스냅샷 수치로 답하게 두면, 사용자는 그게 최신 수치인 줄 착각한다. 실패 사실과 이유를 담은 별도 블록을 시스템 프롬프트에 넣어, Claude가 “정확한 최신 수치를 제공할 수 없다”고 명시적으로 답하도록 강제하는 편이 안전하다(과거 스냅샷을 최신인 것처럼 제시하는 것보다 훨씬 낫다).
키워드 게이트는 질문 언어까지 커버해야 한다 — 중국어 질문 무응답 사고 (2026-08-26)
SALES_KEYWORDS가 한국어·영어 키워드만 담고 있어서, 중국어로 물어본 매출 질문(“Restaurant Equipment Online2026年的购买情况?” — “구매情况”에 매출/판매/dealer/sales 등 키워드가 하나도 없음)이 isSalesQuestion()을 통과하지 못해 LIVE SALES DATA도 query_sales_data 도구도 전혀 주어지지 않았다. Claude는 “데이터 블록이 없다”고 정직하게 답했지만, 실제로는 그 딜러가 시트에 존재했고(소문자 restaurant equipment online, 2026년 매출 $83,833) — 딜러 인식 실패가 아니라 질문 언어 감지 실패였다. 사이트가 ko/zh/en 3개 언어를 지원하는 이상, 이런 게이트 키워드 리스트는 지원하는 모든 언어로 채워야 한다. functions/api/_google-sheets.js의 SALES_KEYWORDS·DEALER_SEGMENTATION_KEYWORDS에 중국어 키워드(销售额/经销商/购买/订单 등)를 추가해 수정(상세 경위는 2026-08-26-Q-Restaurant-Equipment-Online2026年的购买情况? Query Result 참고). isRebateQuestion() 등 같은 패턴을 쓰는 다른 키워드 게이트도 새 언어를 추가할 때마다 함께 점검할 것.
Cloudflare Pages 시크릿은 소급 적용되지 않는다 — 새 배포가 있어야 반영된다
wrangler pages secret put으로 새 시크릿을 넣어도, 이미 떠 있던(live) 배포는 옛 값(비어있는 값)을 계속 참조한다. 새 시크릿은 그 이후에 새로 만들어지는 배포부터만 보인다. 시크릿을 등록한 직후 곧바로 테스트했는데 “설정 안 됨” 오류가 재현된다면, 코드가 아니라 재배포 여부부터 의심할 것 — rebuild-site.ps1(또는 해당 프로젝트의 배포 스크립트)을 한 번 더 실행해 새 배포를 만들면 해결된다. 같은 함정이 최초 구축 때 ANTHROPIC_API_KEY에서도 한 번 발생했었고(트러블슈팅 6번), 이번엔 Google 서비스 계정 시크릿에서 그대로 재현됐다 — Cloudflare Pages 시크릿을 새로 넣거나 바꿀 때는 습관적으로 재배포까지 세트로 묶어서 생각할 것.
7. Ask 이외의 커스텀 정적 페이지 추가하기 — Sales Dashboard 예시
/ask를 만들면서 확립한 패턴(§3, §5)은 Ask 전용이 아니라 Quartz 파이프라인 밖의 손수 작성 페이지를 몇 개든 추가할 수 있는 일반 레시피다. 두 번째 사례로 Power BI 판매실적 대시보드를 사이트 안에 추가한 과정을 기록한다.
레시피 (재사용 가능):
static-pages/{name}.html 작성.
rebuild-site.ps1에 복사 스텝 추가: public/{name}/index.html로 복사(§1 “2.5 Ask 페이지 복사”와 동일 패턴 반복 — Quartz가 모르는 파일이라 빌드마다 public/이 비워지면 매번 다시 넣어줘야 한다).
사이드바 버튼 injection 블록(§5)에 새 버튼 HTML을 추가 — 여러 버튼을 동시에 주입할 경우, 하나의 문자열 치환 패스로 한 번에 삽입해야 한다(버튼마다 별도 Replace() 호출을 순차로 걸면, 첫 번째 치환이 앵커 문자열 자체를 바꿔버려서 두 번째 치환의 앵커를 못 찾는 문제가 생길 수 있음).
새 버튼에도 data-router-ignore를 반드시 유지한다(§5의 SPA 가로채기 문제는 이 페이지가 <script>가 없어도 동일하게 적용될 수 있다 — 안전하게 항상 붙인다).
"웹에 게시(Publish to Web)" 링크를 iframe으로 감싸는 것은 진짜 보안이 아니다
Power BI의 “웹에 게시” 기능은 접근 제어가 전혀 없는 완전 공개 URL을 만든다. 이걸 이 사이트(Basic Auth 보호)의 <iframe src="...">에 넣어도, 브라우저 개발자도구로 그 src 값만 확인하면 누구나 Basic Auth 없이 원본 URL에 직접 접속할 수 있다 — 우리 사이트를 거치는 경로만 막히고, 원본 콘텐츠 자체는 여전히 공개 상태다. 진짜로 막으려면 Power BI “웹에 게시”를 끄고, Azure AD 앱 등록 + 서비스 주체(service principal)로 임베드 토큰을 서버(Worker)에서 발급하는 방식(Power BI Embedded, §6의 Google 서비스 계정 패턴과 유사한 구조)으로 전환해야 한다. 이번 구현은 사용자가 “완전한 보안”과 “빠른 임시 조치” 중 후자를 명시적으로 선택한 결과이며, 페이지 안에 그 사실을 알리는 경고 배너를 넣어 두었다.
Quartz 사이드바의 gap 은 형제 버튼 전체에 공유된다
Quartz의 .sidebar.left는 flexbox gap(기본 1.2rem)으로 모든 직계 자식(검색/다크모드 툴바, 주입한 버튼들, 파일 탐색기) 사이 간격을 일괄 조절한다. 버튼 개별 margin을 조절해도 이 gap이 추가로 더해지므로, 여러 버튼이 붙어 있을 때 “버튼 사이만” 좁히고 싶어도 CSS 구조상 “sidebar 상단 전체”가 함께 좁혀진다. 해결: 버튼 자체 margin은 0으로 비우고, .sidebar.left { gap: ... !important; }를 주입 <style>에서 오버라이드 — Quartz의 실제 셀렉터는 .page #quartz-body .sidebar.left처럼 컴파운드라 동일하거나 더 높은 특이도(specificity)로 걸어야 한다. !important가 안전한 이유: 우리 <style>은 문서상 Quartz 스타일시트 <link>들보다 뒤(</head> 직전)에 삽입되지만, 셀렉터 특이도가 같을 때 소스 순서만으로 이기는 것은 빌드마다 CSS 파일 해시가 바뀌는 구조에서 취약하다.
Wiki 페이지로 돌아가는 링크는 alias의 ASCII 슬러그를 쓴다
커스텀 페이지에서 특정 Wiki 노트로 되돌아가는 링크를 걸 때, Quartz가 실제로 서빙하는 정식 경로(/20.-wiki/24.-maps/moc-tab-프로젝트)는 한글이 그대로 URL에 남는다. 그 노트의 frontmatter aliases에 영문 별칭(예: MOC-TAB Project)이 있다면, Quartz의 alias 플러그인이 그 슬러그로 meta refresh 리다이렉트 페이지(/moc-tab-project → 정식 경로로 즉시 이동, noindex)를 자동 생성해준다 — 커스텀 페이지의 하드코딩 링크는 이 짧고 안정적인 ASCII 슬러그를 쓰는 편이 낫다.
§8. 방문자 피드백 폼 (사이트 → 이메일 + 로컬 옵시디언)
Q&A(§1~§4)와 완전히 같은 파이프라인을 폼 제출에 재사용해, 방문자가 운영자에게 오류 신고·수정 요청·제안을 보낼 수 있는 창구를 만든다. /ask가 query: KV → 30. Queries/였다면, 피드백은 feedback: KV → 50. Site Feedback/(신규 폴더)다.
Function (functions/api/feedback.js): always-HTTP-200 계약(§1), honeypot(website 숨김 필드로 봇을 조용히 accept-and-drop), 메시지 10000자 제한(2026-08-24, 5000자에서 상향 — 실사용자가 장문 피드백을 보내다 서버 거부를 만난 사례로 상향, static-pages/feedback.html의 <textarea maxlength>도 동일하게 10000으로 동기화). KV feedback:{ts}:{uuid} 저장을 최우선으로 하고 이메일은 best-effort — 이메일이 실패해도 메시지는 절대 유실되지 않고, 실패 사유는 emailError로 같은 KV 레코드에 기록돼 옵시디언에도 보인다. 성공/실패는 emailStatus로 UI에 전달.
폼 페이지 (static-pages/feedback.html → /feedback): 유형 드롭다운·이름·회신 이메일(선택)·메시지. pageUrl: document.referrer로 어느 페이지에서 보냈는지 함께 저장.
진입점 2곳: 루트 사이드바 ✉️ 피드백 버튼(rebuild-site.ps1이 모든 페이지에 주입, §5대로 data-router-ignore 필수) + 질문하기 페이지(ask.html) 하단 피드백 링크.
로컬 동기화: sync-queries.ps1(10분)·rebuild-site.ps1(1시간) 양쪽에 feedback: 처리 블록 추가 — Q&A 동기화와 같은 dedup·슬러그·KV 키 삭제 패턴. type: site-feedback(신규 운영 타입)으로 저장.
관리자 권한이 없으면 서비스 계정 Gmail 발송은 막힌다 — Resend로 우회
같은 Google 서비스 계정이라도 Sheets 읽기(§6) 와 Gmail로 사용자 대신 보내기는 권한 요구가 다르다. 후자는 도메인 전체 위임(domain-wide delegation) 이 필요하고, 이는 Google Workspace 관리자 콘솔에서만 승인된다 — 관리자 계정 접근이 없으면 코드가 아무리 맞아도 unauthorized_client로 실패한다. 해결: 관리자 권한이 전혀 필요 없는 Resend(resend.com)로 전환. turboairbrain.uk가 이미 Cloudflare DNS라 SPF/DKIM 도메인 인증이 Resend Auto configure로 자동 처리됐고, 코드는 JWT/OAuth 뭉치(_gmail.js, 폐기)에서 API 키 1개짜리 단순 fetch(_email.js) 로 줄었다. RESEND_API_KEY는 Cloudflare Pages secret(§6처럼 등록 후 반드시 재배포해야 반영), 발신 feedback@turboairbrain.uk, 수신 david@turboairinc.com.au.
게시 제외( /XD)로 볼트 안에 "로컬 전용" 경계 만들기
방문자 피드백은 개인 연락처·비판 등 공개돼선 안 되는 내용이 섞일 수 있다. 50. Site Feedback/를 rebuild-site.ps1의 robocopy 미러 단계에서 /XD로 제외하면, 같은 볼트 안에 있어도 사이트에는 게시되지 않고 로컬 옵시디언·이메일에서만 확인되는 수신함이 된다. 볼트-전체-미러 구조에서 특정 폴더만 비공개로 두는 재사용 패턴.
§9. 비한국어 질문의 볼트 가독성 + 사이트 언어 스위처 — 숨긴 “아카이브 블록” 패턴
실제로 발생한 버그 (2026-08-19 사용자 리포트)
/ask는 방문자 질문 언어(lang)로 그대로 답변하고(§2 시스템 프롬프트), sync-queries.ps1은 그 원문을 그대로 30. Queries/에 저장했다. 결과: (1) 한국어만 읽는 운영자(David)가 중국어/영어로 들어온 질문·답변을 볼트에서 이해할 수 없었고, (2) “답변보기” 상세 페이지의 KR/CN/EN 언어 버튼을 눌러도 항상 원래 언어 그대로였다(번역 인프라 자체가 없었음) — 언어 스위처가 있는데 실제로는 아무것도 안 바뀌는 상태.
해결책은 Claude 자신에게 번역을 겸하게 하는 것 — 별도 번역 API 호출 없이, 시스템 프롬프트 규칙 하나(§2의 규칙 11)로 답변 끝에 방문자에게는 안 보이는 메타데이터 블록을 추가하도록 지시한다:
비용을 실제로 필요한 곳에만 쓴다: 한국어 질문(대다수 트래픽)엔 TITLE_KO만 요청 — 답변보기 목록의 제목 생성용. 비한국어 질문에만 한국어 전체 번역(QUESTION_KO/ANSWER_KO) + 제3언어 번역(*_ALT)까지 요청해, 3배 비용이 실제 비한국어 질문에만 든다.
ask.js가 답변을 방문자에게 돌려주기 전에 이 블록을 정규식으로 파싱·제거(splitArchive()) — 방문자는 이 블록의 존재 자체를 모른다. 파싱된 필드는 KV 레코드에 titleKo/questionKo/answerKo/titleOriginal/titleAlt/questionAlt/answerAlt/lang/altLang로 저장.
sync-queries.ps1이 볼트 문서를 만들 때: 비한국어 레코드는 ## 한국어 요약 (Korean Summary) 섹션(전문 번역, CLAUDE.md “비한국어 소스 처리” 규칙과 결이 다름 — 저작권 걱정 없는 자사 AI 생성물이라 “요약”이 아니라 “전체 번역”을 택함)과 “질문(한국어 번역)” 콜아웃 줄을 원문 옆에 병기한다. 핵심 포인트: frontmatter query:(사이트 기본/KR 상태가 그대로 읽는 값)를 원문이 아니라 questionKo로 채운다 — 이걸 놓치면 KR 버튼을 눌러도 여전히 원문이 보이는 문제가 남는다(처음 설계 때 놓쳤다가 배포 직후 발견해 수정한 실수).
사이트 언어 스위처가 실제로 작동하게 하려면 두 갈래가 다 필요하다: (a) 제목/질문 한 줄은 기존 displayTitle/displayTitleZh/En·displayQuestionZh/En frontmatter 오버라이드(§CLAUDE.md v1.14~v1.16)를 그대로 재사용 — 새 메커니즘 불필요. (b) 본문 전체는 static-pages/i18n-src/answers/{base}.{lang}.md 파일(원본 그대로 + 제3언어 번역)을 만들어 기존 build-i18n-content.mjs → answers-body-i18n.json → generate-answers.ps1의 data-i18n-block 주입 파이프라인에 태운다. rebuild-site.ps1에 build-i18n-content.mjs 실행 단계를 generate-answers.ps1 직전에 추가해 두면, 앞으로는 이 번역 소스 파일을 만들기만 해도 다음 배포에서 자동으로 반영된다(이전엔 수동 실행이 필요했음).
재사용 체크리스트: 방문자 자동생성 컨텐츠에 다국어 게시가 필요할 때 — (1) 원본 콘텐츠를 만드는 LLM 호출 자체에 “숨긴 메타 블록”을 곁들이는 게 별도 번역 API보다 싸고 지연도 없다, (2) 서버가 그 블록을 응답 전에 반드시 벗겨낸다(방문자 유출 금지), (3) “기본 언어” 슬롯(이 사이트는 한국어)의 frontmatter/DB 필드를 원문이 아니라 번역본으로 채우는 것을 잊지 않는다 — 원문을 그대로 두면 “기본 상태”에서도 안 읽히는 문제가 남는다.
§10. 사전 집계 표로 안 되는 세부 조합 — Anthropic tool-use 루프로 원본 행 드릴다운 (2026-08-21)
§6의 라이브 조회는 Worker가 원본 행을 미리 필터·집계해 “[표1][표7]” 같은 고정된 요약표만 프롬프트에 넣는다. 이 설계는 “이번 달 매출” 같은 정형 질문엔 잘 맞지만, **딜러 1곳의 월별 추이·임의 날짜 범위(17월만)·딜러×브랜치 교차** 같은 질문은 애초에 어떤 표에도 없는 조합이라 구조적으로 “근거 없음”이 될 수밖에 없었다(2026-08-19 Nathan 매니저의 신규업체 질문이 실제 사례).
Key Insight
“Worker가 미리 계산해서 주는 표를 늘린다”가 아니라, 원본 행을 파싱해서 메모리에 들고 있다가, Claude가 필요할 때 스스로 쿼리하게 한다 — Anthropic Messages API의 tools 파라미터 + tool_use/tool_result 왕복이 정확히 이 용도다. 표를 무한히 늘리는 대신 “필터+그룹화 엔진 1개”를 도구로 준다.
질문 수신 → 매출 질문? → 원본 행 fetch + parseSalesRows()로 파싱(메모리 보관)
↓
Claude 호출 (tools: [query_sales_data], messages: [...])
↓
stop_reason === "tool_use"?
├─ Yes → queryRawRows(parsedRows, tool_input) 실행
│ → tool_result를 messages에 append → 다시 Claude 호출 (최대 4라운드)
└─ No → 최종 텍스트 답변으로 종료
parseSalesRows(rows): 원본 행을 Sales Data 테이블 명세 및 KPI 정의의 매출/수주 인식 규칙 그대로 파싱해 구조화 객체 배열로 반환. 요청당 1회만 실행(파싱된 결과를 라운드 간 재사용 — 매 tool 호출마다 재fetch/재파싱하면 라운드 수만큼 Google Sheets API 호출이 늘어난다).
queryRawRows(parsedRows, params): dateField(orderDate/invDate)·dateFrom/dateTo·branch·company·groupBy(최대 3개)·metric(count/sum_sales)·excludeRestocked·excludeCancelledHoldBlank 파라미터를 받는 범용 필터+그룹화 엔진. 그룹 수 500개 상한(무한정 세분화된 그룹핑으로 응답이 비대해지는 것 방지).
MAX_TOOL_ROUNDS = 4: 모델이 답을 찾을 때까지 무한히 도구를 호출할 수 없도록 상한. 각 라운드가 Anthropic Messages API 풀 호출 1회이므로(지연시간+비용), “1~2회 드릴다운 후 답변”이 일반적인 패턴이라는 전제로 여유 있게 잡은 값.
토큰/비용 계산도 라운드 전체 누적으로 바뀌었다 — 마지막 라운드의 usage만 보면 실제 청구액을 과소 계상한다.
시스템 프롬프트 문자열이 template literal이면 백틱을 조심할 것
buildSystemPromptPrefix()가 백틱(`) 템플릿 리터럴로 작성돼 있는데, 규칙 문구 안에 도구 이름을 `query_sales_data` 처럼 인라인 코드로 강조하려다 그 백틱이 바깥 템플릿 리터럴을 조기 종료시켜 node --check가 “Unexpected identifier” 구문 에러를 낸 적이 있다(2026-08-21). 템플릿 리터럴 문자열 안에서 코드 강조가 필요하면 백틱 대신 다른 강조(굵게, 그냥 텍스트)를 쓰거나 이스케이프(\`)한다.
§11. 두 번째 라이브 데이터 경로 — TA$(TADollar) 리베이트 원장 (2026-08-21)
§6이 “Sales Data” 탭을 라이브 조회하는 패턴이라면, 같은 스프레드시트의 “TADollar 2026” 탭(딜러별 Final TA$/Paid/Remains 원장 + 오더별 상세)에도 동일 패턴을 독립적으로 하나 더 만들었다. 컬럼 명세·계산 검증은 Sales Data 테이블 명세 및 KPI 정의 §6에, 여기서는 §6과 구조적으로 다른 점만 기록한다.
독립 게이팅: isRebateQuestion()은 isSalesQuestion()과 별도 키워드셋(리베이트/TA$/TAD/잔여 등)으로 게이팅된다 — “리베이트 잔여 딜러 리스트” 같은 질문은 SALES_KEYWORDS에 안 걸릴 수 있어, 매출 질문 게이트에 얹으면 놓치는 사례가 생긴다. 두 게이트가 동시에 켜지면 liveSalesBlock + liveTadBlock이 시스템 프롬프트에 나란히 주입된다.
전량 주입 vs 도구화: §10과 달리 TADollar 데이터는 tool-use 루프를 만들지 않고, 딜러 23곳 + 오더 199건 전체를 매번 통째로 포맷해서 프롬프트에 넣는다 — 데이터 규모가 작아(수천 토큰 이내) 굳이 tool-use의 왕복 지연/복잡도를 감수할 이유가 없었다(YAGNI). 이 탭이 앞으로 수천 행 규모로 커지면 §10과 같은 tool-use 패턴으로 전환을 고려할 것.
탭 안에 표 2개, 고정 행 번호 없음: 하나의 시트 탭에 “거래처 요약”·“오더 상세” 두 표가 위아래로 있고 행이 늘며 시작 위치가 밀린다 — parseTadData()는 고정 오프셋이 아니라 헤더 텍스트 매칭(row[3]==="Company" && row[4]==="Final TA$", row[2]==="Order No")으로 각 표의 시작을 찾는다. 이 접근은 §6(Sales Data)처럼 표가 하나뿐인 시트에는 불필요하지만, 한 탭에 표가 여러 개 있는 시트를 다룰 때 재사용 가능한 패턴이다.
도메인 사실을 "다른 프로그램"이라 단정하기 전에 기존 Wiki부터 대조할 것
처음엔 TA/TAD가 위키에 이미 문서화된 "리베이트 35%/41%" RRP 구간별 지급율 스킴과 **완전히 다른 프로그램**이라고 코드 주석·프롬프트에 적었다 — 새 데이터를 발견했을 때 "이건 새로운 개념이다"라고 성급히 단정한 것. 배포 직전 [[영업판매실적 조회 절차]]를 다시 읽다가, Sales Data의 `TA/RRP/TA$ Exc` 컬럼과 그 35%/41% 스킴이 바로 이 리베이트의 계산 방식이라는 게 이미 기록돼 있음을 발견해 정정했다(사용자 확인으로 같은 프로그램임이 최종 확정). 일반화: 새로 발견한 외부 데이터를 기존 Wiki 지식과의 관계까지 코드/프롬프트에 단정적으로 적기 전에, 관련 기존 문서를 먼저 훑어 모순이 없는지 대조한다 — 틀린 단정은 검증 없이 그대로 방문자 답변에 흘러간다.
§12. 후속 질문이 별도 파일로 쪼개지는 문제 — 세션 ID 기반 병합 (2026-08-21)
증상: /ask는 요청(질문) 1개당 KV에 독립 레코드(query:{timestamp}:{uuid})를 저장하고, sync-queries.ps1(§4의 경량 동기화)은 레코드 1개당 30. Queries/ 파일 1개를 만든다. 클라이언트가 history(대화 맥락)를 함께 보내 Claude에게는 문맥이 전달되지만, 같은 대화인지 식별할 값 자체가 KV 레코드에 없어서 같은 스레드의 후속 질문 2~3개가 매번 새 파일로 쪼개졌다(예: “멜버른 발주 감소 딜러” 질문 + 후속 질문 2개 → 문서 3개, David 리포트로 발견·재통합).
해결: 대화 경계를 식별할 최소한의 값 하나(브라우저 페이지 로드)만 추가한다.
static-pages/ask.html: const askSessionId = crypto.randomUUID(); // 페이지 로드당 1회
(매 fetch body에 sessionId로 동봉)
↓
functions/api/ask.js: body.sessionId를 그대로 KV 레코드에 저장(구버전 캐시 클라이언트는 빈 문자열 폴백)
↓
sync-queries.ps1: 레코드의 sessionId로 기존 30. Queries/*.md의 frontmatter askSessionId: 를 검색
├─ 매치 있음 → 새 파일 대신 "## 후속 질문 — {timestamp}" 섹션을
│ "## Referenced Pages" 직전에 삽입 + date modified만 갱신
└─ 매치 없음 → 기존처럼 새 파일 생성, frontmatter에 askSessionId 신규 기록
새 프론트매터 키 askSessionId(camelCase, CLAUDE > 새 YAML 키는 camelCase 원칙)는 Query Result 문서 전용이며, 다음 후속 질문이 자신을 찾을 수 있도록 하는 용도 외에는 의미가 없다.
비한국어 후속 질문의 i18n 본문(§9)도 덮어쓰지 않고 append한다(Add-Content) — 안 그러면 첫 턴의 번역 본문이 후속 턴으로 조용히 사라진다.
대화 경계 = 브라우저 페이지 로드다. 사용자가 새로고침하거나 새 탭을 열면 askSessionId가 새로 생성되므로, “진짜 같은 대화”라도 페이지를 벗어났다 돌아오면 별도 문서로 다시 쪼개진다 — 완벽한 스레드 추적이 아니라 “같은 브라우저 세션 안에서 이어지는 후속 질문”이라는 실용적 근사치다.
/answers 허브(generate-answers.ps1이 정적으로 생성)에 쌓이는 문서 수가 늘면서 질문을 찾으려면 스크롤이 계속 길어지는 문제가 생겼다. 빌드 스텝을 늘리는 대신(예: 페이지당 별도 정적 HTML 생성) 순수 클라이언트사이드 JS로 해결 — 서버는 여전히 전체 목록을 한 번에 정적 HTML로 굽고(무-JS/SEO 크롤러에게도 전체가 보임), 브라우저에서만 12개씩 나눠 보여주고 주제로 필터링한다.
각 .item(질문 카드)에 data-topic="{{TOPIC}}" 속성을 추가하고, 기존 범례(.legend)의 정적 <span> 칩을 클릭 가능한 <button class="topic-filter" data-topic="...">로 교체(“전체” 버튼 추가, 사용되는 주제만 렌더 — 문서가 하나도 없는 주제는 필터 버튼 자체를 만들지 않음).
인라인 <script>가 data-topic 매칭으로 배열을 필터링하고, PAGE_SIZE = 12로 슬라이스해 .is-hidden 클래스로 나머지를 숨긴다. 페이지/필터 전환 시 state.page를 1로 리셋.
i18n(§9 언어 스위처)과는 독립적으로 동작 — 필터/페이지네이션은 텍스트가 아니라 DOM 표시 여부만 바꾸므로 i18n.js의 data-i18n 치환과 서로 간섭하지 않는다.
규모가 커지면 페이지 번호 UI부터 재검토할 것
현재는 전체 페이지 번호를 다 그린다(1 2 3 ...) — 문서가 수십 개인 지금 규모에선 괜찮지만, 페이지 수가 두 자리로 늘면 앞/뒤 일부 + 생략(…) 축약이 필요해진다. render() 함수의 페이지 버튼 생성 루프만 교체하면 되는 구조로 짜여 있다.
§14. TAB-QA-NNN 번호는 반드시 영구 고정값이어야 한다 — 재정렬형 번호매김 버그 (2026-08-21)
Contradiction (실제 발생한 버그, 2026-08-21 수정)
“답변보기” 허브에서 카드를 클릭했더니 해당 카드의 제목과 전혀 다른 문서(예: “Turbo Air 회사 및 제품 소개” 카드를 눌렀는데 “2026년 브랜치별 출고 수량 예측” 문서가 열림)가 뜨는 버그가 리포트됐다. generate-answers.ps1이 매 빌드마다 30. Queries/*.md를 정렬 후 1부터 다시 번호 매기는($n++) 구조였던 것이 원인 — 사이트의 /ask가 새 질문을 자동으로 볼트에 동기화하거나(무인, 매시 정각) 문서가 삭제·병합되면(§12) 그 뒤 모든 문서의 TAB-QA-NNN/qa-NNN 폴더명이 통째로 밀린다.
두 버그가 겹쳐서 증상이 나타났다:
슬러그 불일치로 상세 페이지 렌더 자체가 스킵됨: 스크립트는 소스 파일명을 단순 ToLower()해서 Quartz가 빌드한 HTML을 찾는데, Quartz 자체 슬러그 변환기는 &를 리터럴 문자열 and로 바꾼다(...소개-&-제품... → 빌드 결과 ...소개--and--제품...). 파일명에 &(또는 향후 다른 특수문자)가 있으면 Test-Path가 실패해 그 문서의 qa-NNN/index.html이 아예 안 만들어진다(로그에 [answers] skip (no built html): ...로 남음).
번호가 재정렬형이라 같은 URL이 빌드마다 다른 문서를 가리킴: 위 스킵 때문에 새 문서의 페이지가 안 만들어지는 동안에도, 번호는 계속 재계산되므로 다른 문서가 그 사이 그 번호를 차지했다가 다시 밀려난다. 결과적으로 그 번호의 디스크 폴더에는 “예전 어느 시점엔 그 번호였던” 완전히 다른 문서의 정적 HTML이 남아있고, 매번 새로 생성되는 허브만 최신 문서를 그 번호로 안내하는 불일치가 발생한다.
수정 (quartz-site/generate-answers.ps1):
영구 번호 매핑: static-pages/answers-numbering.json에 {파일명(Base): 번호} 매핑을 저장·커밋한다. 이미 번호가 있는 문서는 그 번호를 영원히 유지하고, 새 문서만 기존 최댓값 + 1부터 순서대로 배정한다(문서가 삭제되면 그 번호는 결번으로 영구히 남는다 — GitHub 이슈 번호와 동일한 철학). $docs | Sort-Object Sort, Base 뒤 1..N 재배정 로직을 완전히 대체.
슬러그 매칭 폴백: Find-BuiltHtmlPath 헬퍼가 (1) 원래 슬러그 그대로 → (2) &→-and- 치환 → (3) 영숫자+한글만 남긴 정규화 비교로 빌드 디렉터리 전체를 탐색, 순서로 시도. 향후 다른 특수문자가 같은 문제를 일으켜도 (3)이 안전망이 된다.
방어적 정리: 매 빌드 시작 시 기존 public/answers/qa-*/ 폴더를 전부 삭제하고 새로 생성 — 정말로 못 찾는 문서가 생겨도 낡은 오답 페이지가 조용히 남는 대신 404가 뜨게 한다(오답보다 404가 낫다).
Key Insight — "표시용 번호"와 "영속 식별자"를 같은 값으로 쓰면 안 된다
이 버그의 본질은 “정렬해서 매긴 순번”과 “외부에 링크·캐시·북마크되는 영구 URL 조각”을 같은 값으로 취급한 것이다. 목록 순서(허브의 “최신순” 정렬)는 매번 새로 계산해도 되지만, 한 번 외부에 노출된 식별자는 그 문서가 존재하는 한 절대 바뀌면 안 된다 — 이번처럼 “재정렬 + 매번 재계산”과 “안정적 permalink”를 혼동하면, 데이터가 자동으로 늘어나는 시스템(이 사이트의 /ask 동기화처럼 무인으로 계속 새 문서가 쌓이는 구조)에서는 필연적으로 재발한다. 비슷한 패턴(폴더/번호를 정렬 위치로 결정하는 코드)이 있으면 이 원칙을 우선 적용해서 점검할 것.
§15. 질문/피드백 입력 글자수 제한 상향 — 2000/5000자 → 10000자 (2026-08-24)
실사용자 리포트 (2026-08-24)
Kevin 사장님이 /ask(질문하기)에 Sushi Hub 관련 원래 장문 요청사항을 그대로 붙여넣으려다 “질문이 너무 깁니다 (2000자 이하로 입력해주세요)” 오류로 제출 실패. 같은 내용이 /feedback 폼(5000자 제한)에는 들어갔던 것과 대비됨.
원인: functions/api/ask.js의 question.length > 2000 서버측 하드코딩 검증. 프런트엔드 ask.html의 <textarea>에는 maxlength가 없어 입력 자체는 끝까지 되고 제출 시점에만 서버가 거부하는 구조라, 사용자 입장에서는 원인을 알기 어려운 실패였다.
수정: functions/api/ask.js(질문, 2000→10000자)와 functions/api/feedback.js(메시지, 5000→10000자) 양쪽 서버측 상한을 통일해 상향, static-pages/feedback.html의 <textarea maxlength>도 5000→10000으로 동기화(ask.html은 애초에 클라이언트 상한이 없었으므로 변경 없음). Claude API 호출 자체는 대화 히스토리를 turn당 최대 20000자까지 이미 보내고 있어(§12) 10000자는 여유 있는 상한.
검토한 대안: 파일 업로드(문서 첨부 → 서버에서 텍스트 추출 → 프롬프트 주입)도 논의했으나, 이번 실사용 사례(약 2,700자)는 글자수 상향만으로 충분히 해결돼 더 큰 기능 빌드는 보류. 향후 필요성이 계속 나오면 별도 스코프로 재검토.
§16. Cloudflare Access(Zero Trust) 도입 — Basic Auth 대체 + 로그인 이메일 캡처 (2026-08-25)
Basic Auth(공유 아이디/비번)를 Cloudflare Access(Zero Trust)의 이메일 코드 로그인(One-time PIN)으로 교체하고, 그 인증된 이메일을 /ask·/feedback 제출 기록에 남기는 작업.
Access 앱 구성:
Identity provider: One-time PIN(이메일 입력 → 코드 발송 → 코드 입력, Google OAuth 설정 불필요)
Application: Self-hosted, Destination turboairbrain.uk(전체 도메인, path 제한 없음)
Policy: Include — Emails ending in @turboairinc.com.au (도메인 추가는 이 규칙만 늘리면 됨)
앱의 Login methods에서 “Accept all available identity providers” Off + One-time PIN만 선택 + Apply instant authentication On (로그인 방식 선택 화면 생략)
정상 작동 확인 후 기존 functions/_middleware.js(Basic Auth) 삭제 — 이중 로그인 제거
Key Insight — Cloudflare Zero Trust 대시보드 메뉴 위치는 문서/기억을 믿지 말 것
이 계정 기준(2026-08 시점) **Identity providers 관리 화면은 좌측 메뉴 “Integrations → Identity providers”**에 있었다 — “Settings → Authentication”도, “Reusable components”도 아니었다. 또한 앱별 “Choose available identity providers” 드롭다운에 기본으로 뜨는 ”- cloudflare” 항목은 One-time PIN이 아니라 **“Cloudflare.com 계정으로 로그인”**이다(이걸 선택하면 방문자에게 dash.cloudflare.com 계정 로그인 화면이 뜬다 — 실제로 이 증상으로 여러 턴 헤맴). Cloudflare Pages 프로젝트 Settings의 “Preview access(Access policy)“도 프리뷰 배포(*.pages.dev) 전용이며 프로덕션 커스텀 도메인엔 영향이 없다. Zero Trust UI는 자주 개편되므로, 다음에 비슷한 설정을 할 때도 메뉴 위치를 다시 확인할 것 — 이 기록은 “그 시점 그 계정”의 스냅샷일 뿐 영구 참조가 아니다.
로그인 이메일 캡처 (/ask, /feedback):
functions/api/whoami.js(신규) — 현재 방문자의 Access 인증 이메일을 반환하는 GET 엔드포인트. ask.html/feedback.html이 페이지 로드 시 호출해 “이름” 필드 아래 배지로 표시(3개국어).
처음엔 문서화된 대로 Cf-Access-Authenticated-User-Email 헤더만 읽었는데, 실사용자 테스트에서 계속 {"email":null}이 반환됨(로그아웃 후 재로그인해도 재현 — 세션 문제 아님, 브라우저 개발자도구 Network 탭으로 직접 확인). Cloudflare 공식 문서(Pages Functions Cloudflare Access 플러그인) 조사 결과, 이 헤더가 일부 로그인 방식에서 비어있을 수 있고 대신 Cf-Access-Jwt-Assertion(서명된 JWT) 헤더의 email claim을 직접 디코드하는 게 Cloudflare 자체 플러그인이 쓰는 더 안정적인 방식임을 확인.
functions/api/_access.js에 공용 헬퍼 getAccessEmail(request) 신규 작성:
function base64UrlDecode(str) { str = str.replace(/-/g, "+").replace(/_/g, "/"); while (str.length % 4) str += "="; return atob(str);}export function getAccessEmail(request) { const direct = request.headers.get("Cf-Access-Authenticated-User-Email"); if (direct) return direct; const jwt = request.headers.get("Cf-Access-Jwt-Assertion"); if (!jwt) return ""; try { const payload = JSON.parse(base64UrlDecode(jwt.split(".")[1])); return payload.email || ""; } catch (e) { return ""; }}
서명 검증은 생략했다 — Cloudflare 엣지가 이미 Access 정책을 통과시킨 요청만 이 Function까지 도달하므로, JWT가 여기 존재한다는 사실 자체가 이미 검증된 상태라는 신뢰 경계를 이용한 것(직접 Cf-Access-Authenticated-User-Email를 신뢰하던 것과 동일한 신뢰 모델). whoami.js/ask.js/feedback.js 전부 이 헬퍼로 교체 후, 실제 로그인 브라우저에서 {"ok":true,"email":"..."} 정상 반환 확인.
§17. 답변 업데이트 시 질문자 자동 알림 메일 — Access 뒤의 로컬 스크립트가 Resend를 직접 호출 (2026-08-25)
30. Queries/*.md 문서(질문자 이메일이 askedByEmail로 캡처된 것, §16)가 나중에 내용이 수정되면(예: 마켓 스캔으로 답변 보강) 질문자에게 자동으로 안내 메일을 보내는 기능. quartz-site/notify-answer-updates.ps1 신규, rebuild-site.ps1 스텝 “2.546”(2.545 generate-answers.ps1 직후 — TAB-QA 번호가 그 실행에서 최종 확정된 뒤라야 안정적인 URL을 만들 수 있음)으로 등록.
Key Insight — Access로 도메인 전체를 보호하면, 그 도메인의 "내부용" API도 로컬 스크립트가 못 부른다
functions/api/*.js는 Cloudflare Access(§16)로 도메인 전체가 보호되고 있어, 브라우저(이미 Access 로그인된 세션)가 호출하는 건 문제없지만 David PC에서 로컬로 도는 rebuild-site.ps1이 같은 엔드포인트를 HTTP로 호출하면 Access가 차단한다 — Access 세션 쿠키가 없는 요청이기 때문. 정공법은 Access Service Token 발급 + 해당 경로 우회 정책 추가지만, 이번 세션 내내 Zero Trust 대시보드 메뉴 하나 찾는 데도 여러 턴이 걸렸던 경험(§16 트러블슈팅 기록)에 비춰 더 간단한 길을 택했다: 알림 발송은 제3자 API(Resend, api.resend.com)를 스크립트가 직접 호출 — Resend는 turboairbrain.uk 도메인이 아니므로 애초에 Access의 영향권 밖이다. 유일한 준비물은 로컬 전용 Resend 키 하나(Cloudflare Pages의 RESEND_API_KEY는 등록 후 값을 다시 조회할 수 없어 재사용 불가 — resend.com에서 Sending-access 권한만으로 별도 발급). 자기 도메인을 Access로 잠그면, 그 도메인에 기대고 있던 로컬/서버 간 통합도 함께 막힌다는 것을 놓치기 쉬우니, 유사한 “로컬 스크립트 ↔ 사이트 API” 통합을 또 만들 때는 이 패턴(제3자 API 직접 호출, 또는 Service Token)부터 검토할 것.
구현 로직:
상태 파일 static-pages/answer-update-notified.json(Base → 마지막으로 확인한 date modified)과 문서의 현재 date modified를 비교.
문서를 이 스크립트가 처음 보는 경우 → 베이스라인만 기록, 메일 안 보냄(질문자는 /ask 챗에서 최초 답변을 이미 봤음).
date modified가 기록값보다 늦어진 경우 → 실제 내용 업데이트로 간주해 메일 발송(제목·문서 URL만 담은 간결한 안내).
resend-local-key.txt(신규 로컬 파일, .gitignore 등록)가 없으면 조용히 스킵 — 발송 기능이 꺼져 있어도 사이트 빌드 자체는 절대 안 깨지게 설계.
generate-answers.ps1의 간헐적 "Format specifier was invalid" 크래시 — null이 아닌 값으로도 재현됨
§14 문서화 시점엔 $numbering[$d.Base]가 $null을 반환하는 경우만 방어했는데, 이번 작업 검증 중 null이 아닌 다른 값으로도 같은 크래시가 재현되는 걸 확인했다(정확한 원인은 미특정 — 동시 실행 race로 추정). 방어 범위를 $n -isnot [int]까지 넓히고, "{0:D3}" -f $n 포맷 호출 자체도 try/catch로 감싸 실패 시 그 자리에서 새 번호를 발급하도록 이중 방어로 강화했다.
검증: 임시 테스트 문서(askedByEmail: david@turboairinc.com.au)로 (1) 최초 실행 시 베이스라인만 기록되고 메일 안 감, (2) date modified를 수동으로 늦춘 뒤 재실행하면 실제로 Resend를 통해 발송됨(Resend id 확인)을 직접 검증 후 테스트 문서·상태 파일 항목 정리.
§18. 답할 자료가 있는데 “근거 없음”이 나오는 실패 — 도구 공백 + 전부-아니면-전무 거절 (2026-09-28)
2026-09-28 NSW 영업담당(Siwoo)이 /ask에 **“K-Master NSW 딜러 5개사 선정 — 추가 2곳 추천 및 백업”**을 물었고 봇은 지식공백(근거 없음)으로 답했다. 그런데 그 질문이 요구한 판단 기준 대부분은 이미 시트에 있었다. 봇이 게을렀던 게 아니라, 있는 자료에 닿을 손이 없었다.
질문이 요구한 기준
실제 자료 위치
2026-09-28 이전 접근 가능?
온라인 판매 여부(가격 노출 회피)
Turbo Air 시트 Dealer 탭 Type = Online
❌ 도구 없음
딜러별 할인 티어
같은 탭 DC 열
❌ 도구 없음
이미 K-Master를 취급 중인 딜러
KM_Database > Sales Data
❌ 원장 미연결
딜러별 완제품 매출 실적
Sales Data
✅ query_sales_data
매장 규모·오너 성향
어디에도 없음
❌ (진짜 공백)
query_sales_data의 groupBy는 company/branch/model/month/year, metric은 count/sum_sales뿐이라 딜러의 “속성”은 구조적으로 조회할 수 없었다 — 실적(얼마나 샀는가)만 알고 정체(어떤 곳인가)는 몰랐던 셈이다.
Key Insight — 진짜 원인은 도구 공백이 아니라 "기준 하나가 비면 전부 포기"였다
도구 2개를 추가하는 것만으로는 같은 실패가 다른 주제에서 반복된다. 이 답변이 무너진 결정적 지점은 확인 불가능한 기준 2개(매장 규모·오너 성향) 때문에 확인 가능한 기준 4개까지 통째로 포기한 것이다. 경영 질문은 거의 항상 여러 기준의 묶음이고, 절반만 근거가 있어도 그 절반은 의사결정에 쓸 수 있다. 그래서 시스템 프롬프트에 **규칙 18(부분 답변 원칙)**을 최우선 규칙으로 신설했다 — 판정 기준은 한 줄이다: “이 답변을 받은 사람이 아무것도 못 하고 되돌아오는가?”
구현 (3건):
구성요소
위치
내용
query_dealer_master
functions/api/_google-sheets.js
Dealer 탭(263곳) — 채널 유형, dcPercent, 여신 계좌·한도, 결제조건, 상태, 등록일, 주. 매출질문 또는 isDealerMasterQuestion()에서 제공
query_km_sales_data
같은 파일
KM_Database 원장. 질문에 K-Master/케이마스터가 명시된 경우에만 로드 — 브랜드 기본값 규칙(§CLAUDE.md) 유지. 헤더만 정규화해 parseSalesRows/queryRawRows를 그대로 재사용하므로 두 브랜드의 KPI 규칙이 갈라질 수 없다
규칙 18·19·20
functions/api/ask.js 시스템 프롬프트
18=부분 답변 원칙(+GAP_KIND: partial), 19=딜러 질문에 도구 호출 의무, 20=K-Master 원장 사용 및 본브랜드와 합산 금지
배포 전 실측에서 드러난 파싱 버그 3건 — 시트를 직접 찍어보지 않았으면 전부 프로덕션에 나갔다:
Amount 열은 숫자가 아니라 “20K” / “220K” / “30K ” 형식의 문자열이다. 단순히 숫자가 아닌 문자를 지우면 "30K" → 30 이 되어 여신 한도 30,000을 30으로 1000배 축소 보고한다. parseCreditAmount()가 K/M 접미사를 해석하고, 원문 문자열(creditLimitRaw)도 함께 남겨 미지의 형식이 조용히 null이 되지 않게 한다.
Credit 열은 "Yes" / 빈칸의 플래그다. 숫자로 읽어 263행 전부 null이 됐고, “여신 계좌 보유 26곳”이라는 멀쩡한 정보가 통째로 사라졌다.
기본 limit 80이 NSW Active 164곳을 시트 순서로 잘라냈다. 검증에서 GHS(DC 48%, 이미 K-Master 취급 중)가 창 밖으로 떨어져, 이 변경이 고치려던 바로 그 질문에 *“NSW 40%+ 딜러 0곳 / K-Master 취급 0곳”*이라는 자신 있는 오답이 나왔다. 기본값을 마스터 전체(300)로 올리고, 대신 행 수가 아니라 행당 부피를 줄였다(빈 필드 생략 → 16.6k → 10.7k 토큰). 잘릴 때는 truncated: true 불리언에 더해 경고 문장을 결과에 싣는다.
Bias Check
Counter-argument: 규칙 18은 반대 방향의 위험을 만든다 — “부분이라도 답하라”가 근거 빈약한 추측을 부추길 수 있다. 그래서 규칙 18(c)에 *“근거 없는 기준을 추측으로 메워 단정하지 않는다”*를 명시하고, 부분 답변에도 GAP 블록을 partial로 남겨 관리자가 공백을 계속 추적하도록 했다. 이 균형이 실제로 유지되는지는 다음 실패/성공 사례를 몇 건 더 봐야 안다.
Data gap: Dealer 탭의 여신 정보는 263곳 중 26곳에만 기재돼 있고, Type은 38곳이 공백이다. 즉 이 도구는 “미기재”를 자주 돌려준다 — 규칙 19(b-2)가 미기재를 “여신 없음/한도 0”으로 읽지 말라고 막고 있지만, 시트 자체의 입력 완결성이 올라가기 전까지 딜러 선정 답변의 상한은 이 커버리지다.
검증: 실 시트 대상 읽기전용 하네스로 (1) Dealer 파싱(헤더 자동 탐색·빈 Type 보존·DC 퍼센트), (2) 필터 5종, (3) 워커의 spreadsheets.readonly 토큰으로 KM_Database가 실제 읽히는지(스코프 확인), (4) 2026-09-28 질문의 근거 3종이 실제로 계산되는지, (5) 브랜드 격리 회귀(브랜드 미명시 질문에 KM 미로드)를 모두 통과시킨 뒤 배포했다.
§19. 모든 질문을 사후 재분석하고, 실패의 원인이 된 도구·규칙을 스스로 고치는 루프 (2026-09-29)
§18 에서 “답할 자료가 있는데 근거 없음이 나오는” 실패를 한 건 고쳤지만, 그 다음 날 같은 유형이 다른 주제에서 또 났다(서비스 SLA 질문 — 도구에 소요시간 개념이 없었다). 개별 수정으로는 따라잡을 수 없다는 뜻이므로, 사후 검토 자체를 자동화했다.
Key Insight — 라이브 답변과 검토 답변은 제약이 다르다
/ask 의 답변은 방문자가 기다리는 동안 고정된 도구 집합으로 만들어진다. 시간이 없고, 계산 스크립트를 쓸 수 없고, 도구가 표현 못 하는 조합은 포기할 수밖에 없다. 반면 사후 검토는 마감이 없고 에이전트 하네스 전체(스크립트 작성·실행, 웹검색, 볼트 전수 검색, 코드 수정)를 쓸 수 있다. 같은 모델이라도 후자가 훨씬 멀리 간다 — 2026-09-28·09-29 두 건 모두 “근거 없음” 이었지만 재분석에서는 수치가 있는 답이 나왔다. 그래서 라이브 답변을 그대로 최종본으로 두지 않는다.
파이프라인에서의 위치:
순서
구성요소
스케줄 작업
하는 일
1
functions/api/ask.js
—
방문자에게 즉시 답변, KV 기록
2
sync-queries.ps1
TAB-Wiki-QuerySync
KV → 30. Queries/*.md
3
run-answer-review.ps1
TAB-Answer-Review (매시 :40)
재분석·재작성 + 시스템 자가수리 + 배포
4
notify-answer-updates.ps1
(3이 호출한 rebuild 안에서)
date modified 변화 감지 → 질문자 메일(David 참조)
검토 스킬/review-answer {문서} 는 문서 1건을 받아 (a) 원답변을 failed/partial/thin/wrong/ok 로 분류하고, (b) 질문을 처음부터 다시 풀고(원답변을 손보지 않는다 — 그러면 오류를 물려받는다), (c) 답변을 교체하고 date modified 를 올리고, (d) ok 가 아니면 원인을 tool-gap/rule-gap/data-gap/data-quality/model-error 중 하나로 진단하고, (e) 코드로 고칠 수 있는 원인이면 도구·규칙을 직접 수정한다.
무인 에이전트가 프로덕션 코드를 고치게 하려면 게이트가 먼저다
2026-09-29 오전에 내가 직접 깨진 프롬프트를 프로덕션에 배포했다(§18 하단 사고). 사람도 그러는 일을 무인 루프에 맡기려면 되돌릴 수 있는 장치가 선행돼야 한다. 그래서 이 시스템은 scripts/verify-worker.mjs 게이트를 통과한 변경만 남긴다:
워커 모듈 6개를 실제로 import — 문법 오류를 잡는다.
buildSystemPromptPrefix() 를 ko/zh/en 세 번 실제로 실행 — node --check 가 통과시키는 결함(잘못된 ${...} 보간, 미정의 변수)을 잡는다. 실측으로 확인: 미정의 변수를 주입하면 node --check 는 통과하지만 이 게이트는 실패한다.
프롬프트 본문에 백틱이 남았는지 검사 — 09-29 사고의 직접 원인이다.
도구 정의 6개의 스키마 유효성 + JSON 직렬화 가능성.
합성 데이터로 집계 엔진 동작(서비스 turnaround, 딜러 필터·잘림 경고, 브랜드 게이트).
러너는 에이전트 실행 전에 워커 파일을 복사해 두고, 실행 후 변경이 있으면 게이트를 돌려 실패하면 사본으로 되돌린 뒤 되돌림이 유효한지 다시 게이트를 돌린다. 또한 실행 전부터 게이트가 실패하던 상태였다면 에이전트의 워커 수정을 무조건 되돌린다 — 그 상태에서는 게이트의 판정 자체를 믿을 수 없기 때문이다.
게이트는 러너가 아니라 배포 경로에 있어야 한다 (2026-09-29 당일 보강)
위 설계에는 구멍이 있었다. 10:40 예약 실행이 _google-sheets.js 를 고친 직후(10:46) 그 실행을 중단시켰는데, 러너의 게이트·되돌리기는 실행 후반부에 있어서 검사를 거치지 않은 워커 변경이 그대로 남았다(이번엔 우연히 멀쩡했지만 — 사후 확인 19/19 통과). 러너만 게이트를 들고 있으면 러너가 죽는 순간 게이트도 같이 죽는다.
그래서 rebuild-site.ps1 의 wrangler 배포 직전에도 같은 게이트를 넣었다. 에이전트가 고쳤든, 죽은 러너가 남겼든, 사람이 손으로 고쳤든 — 프로덕션으로 가려면 여기를 통과해야 한다. 실패하면 배포를 중단한다(사이트가 한 회차 안 나가는 것보다 /ask 가 전원에게 깨진 채 나가는 쪽이 나쁘다). 게이트 스크립트가 없으면 경고만 남기고 통과시킨다 — 도구 부재로 배포가 막히는 건 과하다.
이것이 verify-worker.mjs 주석에 적어둔 원칙의 실제 적용이다: 사람이(또는 특정 스크립트가) 기억해야만 도는 게이트는 게이트가 아니다.
에이전트에게 허용한 범위(.claude/settings.json 의 permissions.allow, --permission-mode dontAsk 로 강제):
허용: 30. Queries/** 편집, functions/api/**·scripts/** 편집, node/python 실행, 스크래치패드 쓰기
금지(의도적): 배포 스크립트·wrangler.toml·비밀키 편집, 10. Raw Sources/**(불변층) 편집, git 쓰기 명령. 배포는 러너가 전담하고, 워커 되돌리기도 러너가 파일 사본으로 처리하므로 에이전트에게 git 이 필요 없다.
스킬 본문에도 “기존 도구의 삭제나 기존 파라미터의 의미 변경 금지, 확장만” 을 명시 — 기존 답변들이 그 의미에 기대고 있다.
구성상 잡은 버그 2건 (설계만으로는 안 보이고 실제로 엮어 봐야 드러난 것들):
알림이 조용히 안 나가는 경합: notify-answer-updates.ps1 은 처음 보는 문서는 기준선만 잡고 메일을 보내지 않는다(질문자가 이미 /ask 화면에서 봤으므로). 그런데 검토가 그 문서의 첫 rebuild 보다 먼저 돌면 notify 가 처음 보는 것이 이미 재작성된 버전이라 기준선만 잡고 끝난다 — 요청의 핵심인 안내 메일이 한 통도 안 나간다. 스케줄 순서로 맞추는 건 한 번 늦으면 다시 깨지므로, 러너가 검토 직전에 그 문서의 현재 date modified 를 notify 상태 파일에 직접 기록해 순서 의존을 없앴다.
PowerShell 5.1 의 BOM 의존: Get-Content -Raw 는 BOM 이 없으면 ANSI 로 읽는다. sync-queries.ps1 이 쓴 문서에는 BOM 이 있고 사람이 쓴 문서에는 없어서, 같은 폴더의 문서인데 한글 제목이 어떤 건 정상이고 어떤 건 깨졌다. 두 스크립트의 읽기에 -Encoding UTF8 을 명시해 의존을 끊었다. (.ps1 파일 자체도 BOM 이 없으면 본문 한글이 깨져 파싱 에러가 난다 — 저장 시 BOM 필수.)
켤 때의 판단 — 백로그는 기준선만 잡고 시작: 도입 시점에 미검토 답변이 31건 있었고 전부 과거 질문이었다. 그대로 돌리면 8월 질문까지 재작성되며 질문자들에게 무더기로 메일이 나간다. David 의 요청은 “새로운 질문과 답변이 등록되면” 이므로 -BaselineAll 로 기존분을 검토완료로 기록하고 시작했다. 특정 과거 문서를 검토하고 싶으면 -Doc {파일명} 으로 지목하면 상태를 무시하고 돈다.
운영 명령:
명령
용도
run-answer-review.ps1 -WhatIf
무엇이 검토 대상인지만 출력, 에이전트 미실행
run-answer-review.ps1 -Max 1
이번 실행에서 1건만
run-answer-review.ps1 -Doc {파일명}
특정 문서를 상태 무시하고 재검토
run-answer-review.ps1 -BaselineAll
현재 전부를 검토완료로 기록(에이전트 미실행)
node scripts/verify-worker.mjs
워커 안전 검사 — 이제 rebuild-site.ps1 이 배포 직전에 자동으로 돌리므로 수동 실행은 선택이지만, 코드를 고친 직후 바로 확인하고 싶을 때 쓴다
Counter-argument: 이 루프는 “라이브 답변은 어차피 나중에 고쳐진다” 는 태도를 만들 수 있다 — 그러면 /ask 자체의 품질 개선 동기가 약해진다. 그래서 검토는 답변만 고치는 게 아니라 원인이 된 도구·규칙을 고치도록 설계했다(Step 4~5). 잘 돌면 재작성 빈도 자체가 줄어드는 것이 정상이며, answer-review-state.json 의 verdict 분포가 그 지표다 — failed/wrong 비율이 시간이 지나도 안 줄면 자가수리가 작동하지 않는다는 뜻이다.
Data gap: 도입 직후라 실제 운영 데이터가 없다. 에이전트가 오판해 맞는 답을 틀리게 고치는 경우의 빈도는 아직 모른다 — 초기에는 answer-review.log 의 판정과 실제 문서를 대조해 보는 것이 필요하다. 현재 안전장치는 코드 변경에만 걸려 있고(게이트), 답변 내용 자체에 대한 게이트는 없다.
Cloudflare 엣지가 정확히 어떤 조건에서 5xx 응답 본문을 덮어쓰는지(모든 5xx인지, 특정 상태코드만인지) 확인되지 않았다 — 이번엔 항상 200 반환으로 우회했을 뿐, 근본 원인은 미상.
Open Question
반복 질문 시 4만+ 토큰 컨텍스트를 매번 새로 계산해서 과금되는 구조 — Anthropic prompt caching 도입 시 비용 절감 폭이 얼마나 될지 실측 필요.
Open Question
max_tokens를 매우 크게(예: 50000) 잡았을 때, 스트리밍 없는 단일 요청 구조에서 실제로 Cloudflare Pages Function이나 브라우저 fetch 타임아웃에 걸리는 임계값이 어디인지 아직 실측되지 않았다.
Open Question
§6의 Google Sheets 라이브 조회는 매 요청마다 원본 시트 전체를 fetch한다 — 매출 질문이 짧은 시간에 몰릴 경우 Google Sheets API 자체 쿼터(할당량)에 걸리는지, 걸린다면 몇 요청/분부터인지 아직 실측되지 않았다.
Open Question
§7의 Sales Dashboard는 사용자가 “빠른 임시 조치”를 명시적으로 선택해 Power BI “웹에 게시” 공개 링크를 그대로 iframe에 쓰고 있다 — Azure AD 앱 등록 + 서비스 주체 임베드 토큰 방식(진짜 보안)으로 언제 전환할지, Power BI 라이선스(Pro 이상)가 필요한지는 아직 결정되지 않았다.
Open Question
§10(query_sales_data 도구)은 배포 직후 라이브 /api/ask에 직접 curl/PowerShell로 스모크 테스트를 시도했으나 Cloudflare Access 인증(“Authentication required”)에 막혀 에이전트 쪽에서는 확인하지 못했다(node --check 구문 검증만 완료) — 브라우저에서 딜러×월 교차 같은 질문으로 실제 도구 호출 여부(답변 “참고 문서”에 “query_sales_data” 명시 여부)를 사람이 직접 확인해야 한다.
Open Question
§12(세션 ID 기반 후속 질문 병합)도 마찬가지로 이 세션엔 새 KV 레코드가 없어 로직을 라이브로 트리거해 보지 못했다(구문 검증만 완료) — 다음 실제 /ask 대화에서 후속 질문을 던져 병합이 실제로 동작하는지 확인 필요.
Bias Check
Counter-argument: 이 가이드는 단일 프로젝트(TAB, Wiki 규모 약 4.3만 토큰)의 성공 경로만 기록했다 — Wiki가 훨씬 커지면(예: 수십만 토큰) “매 질문마다 전체 컨텍스트 전달” 설계가 비용·지연 양쪽에서 한계에 부딪힐 수 있으며, 그 임계점은 검증되지 않았다.
Data gap: 실제 질문당 API 비용의 장기 실측 데이터(Anthropic Console 청구액 기준), Basic Auth 외 추가 접근제어(rate limit 등)의 필요성 여부.