diff --git a/apps/web/e2e/session-review.spec.ts b/apps/web/e2e/session-review.spec.ts index 70d6a87..2b4e9ce 100644 --- a/apps/web/e2e/session-review.spec.ts +++ b/apps/web/e2e/session-review.spec.ts @@ -623,6 +623,8 @@ test.describe("session review", () => { ...response.turns[0], text: "저는 [NAME]이고 [ORG]에서 상담받고 있어요.", }; + response.summary = + "평가 AI는 [NAME]가 [ORG]에서 보인 조심스러운 반응을 분석했습니다."; if (!response.caseWorksheet) { throw new Error("filled review fixture must include a worksheet"); } @@ -653,6 +655,12 @@ test.describe("session review", () => { "저는 익명 내담자이고 소속 기관에서 상담받고 있어요.", ); await expect(transcript).not.toContainText("[NAME]"); + const summary = page.locator(".sr-summary"); + await expect(summary).toContainText( + "평가 AI는 익명 내담자가 소속 기관에서 보인 조심스러운 반응을 분석했습니다.", + ); + await expect(summary).not.toContainText("[NAME]"); + await expect(summary).not.toContainText("[ORG]"); await openReviewTab(page, "워크시트"); await expect(page.locator(".sr-ws-evidence").filter({ hasText: "[NAME]" })).toBeVisible(); }); diff --git a/apps/web/src/lib/piiDisplay.ts b/apps/web/src/lib/piiDisplay.ts index 1d40d46..95a3f3e 100644 --- a/apps/web/src/lib/piiDisplay.ts +++ b/apps/web/src/lib/piiDisplay.ts @@ -25,7 +25,9 @@ function replacePlaceholderWithNaturalParticle( label: string, ) { const batchim = hasHangulBatchim(label); - let next = text; + // `이고`를 단일 조사 `이`보다 먼저 처리하지 않으면 + // `[NAME]이고`가 `익명 내담자가고`로 깨진다. + let next = text.split(`${placeholder}이고`).join(`${label}이고`); for (const [variants, particle] of [ [["으로", "로"], batchim ? "으로" : "로"], [["은", "는"], batchim ? "은" : "는"], diff --git a/apps/web/src/pages/session-review/model.ts b/apps/web/src/pages/session-review/model.ts index f0ee0e6..6aed9c5 100644 --- a/apps/web/src/pages/session-review/model.ts +++ b/apps/web/src/pages/session-review/model.ts @@ -125,7 +125,7 @@ export function displayReviewSummary(summary: string) { ) { return "저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. AI 평가 재시도가 필요합니다."; } - return summary; + return displayPiiSafeText(summary); } export function displayEvaluationRetryError(message: string) { diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 1b9ebca..30d1cd1 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -685,10 +685,8 @@ route fixture는 90건이므로 전체 숫자를 실제 회기 폐루프로 부 전용 engine/DB/API/Web을 만들고 같은 학습자의 SSE→review→G4/G5 0→1→1과 returned-practice desktop/mobile 4건을 검증한 뒤 sentinel resource·PID·listener·temp 잔여 0을 영수증으로 남긴다. 공개 8001/55432/9099와 NAS는 금지다. 최초 세 full run은 SSE body replay, durable turn 전 종료, 비종료 SSE `response.finished()` 대기를 차례로 찾아 모두 -fail-closed했다. 4차 clean run은 6.3분에 동일 학습자 SSE·review ready·G4 0→1까지 통과했지만 full reload 뒤 persisted -episode를 카드가 hydrate하지 않아 이미 기록된 원장을 `학습자만 실행`으로 표시하는 제품 결함을 검출했다. 네 run 모두 -public/NAS/active DB 접촉 0, cleanup GREEN이다. UI는 현재 session+prescription episode를 `현재 원장 상태`로 hydrate하도록 -수정했고 route fixture desktop/mobile 6/6·typecheck를 통과했다. full runner는 재시도하지 않았으므로 첫 GREEN receipt는 아직 없다. +fail-closed했다. 4차 clean run은 reload 뒤 persisted episode를 `학습자만 실행`으로 표시하는 제품 결함을 검출했다. +5차 clean run은 핵심 동일 학습자 SSE·review·G4/G5 `0→1→1`을 처음 통과했지만, 같은 fixture를 후속 desktop/mobile 회귀가 재사용해 2/4 실패했다. 다섯 run 모두 public/NAS/active DB 접촉 0, cleanup GREEN이다. UI ledger hydration은 route fixture 6/6을 통과했고, runner는 핵심 폐루프와 후속 회귀에 별도 zero-state fixture를 준비한다(unit 12/12). 리뷰 요약 literal privacy 토큰과 `익명 내담자가고` 조사도 표시층에서 수정했다. 아직 **전체 receipt GREEN은 없으므로** 자동 full runner 등록은 금지다. ## 9. 핵심 변경 파일 diff --git a/docs/TODO.md b/docs/TODO.md index f1def2a..34cea70 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -141,12 +141,12 @@ > 과거 `6030a677…c611`의 localhost 108/108·평문 origin UUID 24건 실패와 후속 verified rollback은 이력으로 보존한다. > 매시간 `vignette-e2e` heartbeat 자동화는 ACTIVE다. 공개/NAS 화면과 health는 읽기 전용으로 보여주고, > 소스 material milestone에서만 전용 disposable 회기 E2E를 실행한다. runner의 clean HEAD/tree·로컬 npipe Docker· -> loopback 동적 포트·sentinel·exact cleanup 계약과 unit 11/11은 완료됐다. 최초 세 full run은 각각 SSE body 재읽기, +> loopback 동적 포트·sentinel·exact cleanup 계약과 unit 12/12는 완료됐다. 최초 세 full run은 각각 SSE body 재읽기, > durable turn 전 종료, 비종료 SSE의 `response.finished()` 대기라는 harness 결함을 fail-closed로 잡았다. 4차 clean run은 > 6.3분 안에 동일 학습자 SSE·review ready·G4 원장 0→1까지 통과한 뒤, full reload가 persisted episode를 화면에 hydrate하지 > 못해 이미 기록된 상태를 `학습자만 실행`으로 되돌리는 제품 결함을 잡았다. 네 번 모두 public/NAS/active DB 접촉 0·자원 잔여 0이다. > 현재 UI는 현재 session+prescription의 persisted episode를 `현재 원장 상태`로 복원하며 route fixture desktop/mobile 6/6을 통과했다. -> full runner는 재시도하지 않았으므로 다음 clean single run의 첫 GREEN receipt가 남았다. +> 5차 clean run은 핵심 동일 학습자 SSE·review·G4/G5 `0→1→1`을 처음 통과했지만, 같은 fixture를 재사용한 후속 returned-practice 회귀가 이미 적재된 1건을 초기 0으로 오인해 2/4 실패했다. 전체 receipt는 FAILED이며 public/NAS/active DB 접촉 0·cleanup GREEN이다. runner는 두 브라우저 게이트마다 별도 zero-state fixture를 만들도록 수정했고 unit 12/12·Ruff·compile을 통과했다. 실패 화면에서 리뷰 요약 literal `[NAME]`/`[ORG]`과 `익명 내담자가고`도 발견해 표시 자연어화·복합 조사 보정을 추가했다. 다음 clean single run의 **전체 GREEN receipt** 전에는 자동 full runner를 등록하지 않는다. > 증거: [배포 증거](./ops/nas-preview-deployment-evidence-2026-08-07.md), [기계 판독 증거](./ops/evidence/nas-preview-current-deploy-2026-08-07.json), [브라우저 증거](./ops/evidence/nas-preview-live-turn-2026-08-07.png). 이는 G7 물리 마이크·독립 사람 평가 증거를 대체하지 않는다. - [ ] **claude_cli ↔ Anthropic API live 동일성** — provider 라우팅·모델 탐색·설정 저장 경로는 구현 완료. 남은 게이트는 연구팀/기관 `ANTHROPIC_API_KEY`를 게이트웨이 호스트에 주입한 뒤 같은 프롬프트의 live 응답·계량·오류 표면화를 비교하는 것이다. diff --git a/docs/dev_dashboard.html b/docs/dev_dashboard.html index 3f95076..207dfc0 100644 --- a/docs/dev_dashboard.html +++ b/docs/dev_dashboard.html @@ -273,6 +273,9 @@ .mchip{flex:0 0 auto;} } @media (max-width:460px){ + main{padding-top:10px;} + .pulse{margin-top:4px;gap:10px;} + .pulse-main{padding:16px;} .kpis{grid-template-columns:1fr;} h1{font-size:22px;} .stamp{display:none;} @@ -288,7 +291,7 @@
Vignette 커맨드센터팀장 리뷰 · dev status
내 차례 0 - 2026-08-09 KST + 2026-08-10 KST
@@ -297,11 +300,12 @@
project pulse -

지금 위치: G0~G6 · G8 DONE.
G7 external proof를 닫는 중.

-

현재: G8 clean-head NAS-origin은 candidate 112/112·실제 origin 112/112로 DONE이다. 공개 API는 detached-clean b34623f3…·tree 32cc85c7…에서 OpenAPI 126, /admin/voice-runtime, local_whisper/small + melotts/melotts-korean, WS queue 4를 제공하며 fresh provenance receipt 9f8d1941…a21bf가 passed다. 공개 Web은 Pages 7bd59055에서 literal [NAME] 0·시간 만료·모바일 44px/자동 스크롤 보정을 실제 회기에 노출한다. 마이크를 열지 않은 G7 rehearsal도 인증 WSS·runtime·Windows topology 세 leg 7-sample로 통과했다. 다음: 사용자의 장치 선택·명시 동의 → 물리 마이크 3,120초·공통 3,000초 high-water → 독립 human pack → canonical checker exit 0 순서다.

+

지금 위치: G0~G6 · G8 DONE. G7 external proof를 닫는 중.

+

현재: G8·공개 앱은 정상이다. 주기 실회기 핵심 SSE→review→G4/G5 0→1→1은 통과했지만 회귀 fixture 오염으로 전체 receipt는 FAILED다. 다음: 격리 보정 후 clean single run 전체 GREEN → G7 물리 마이크·human pack·checker exit 0 순서로 닫는다.

운영 복구·watchdog·배포 상세 보기 -

2026-08-07 공개 DB는 recovered named volume으로 전환했고 원본 container·dump·volume을 보존했다. 2026-08-09 public DB incident 뒤 fresh custom dump f1fd569c…5f64를 만들고, 누락된 forward migration 14만 단일 트랜잭션으로 적용해 runtime schema 8/8을 복구했다. 공개 API·cloudflared는 clean release b34623f3…로 fresh 교체했고 public health 3/3·db/engine true·OpenAPI 126·local voice exact를 통과했다. 로그온·5분 watchdog 두 task도 같은 root/commit/tree/script SHA에 pin해 명시 실행 결과 0을 확인했다. 공개 runtime receipt는 9f8d1941…a21bf, 무마이크 rehearsal evidence는 voice 61af2e98…·runtime c6e8670e…·topology 89f1cb86…이며 UUID/email literal 0이다. G7은 배포가 아니라 실제 mic·동시 high-water·human evidence만 남은 external GATE다.

+

G8 clean-head NAS-origin은 candidate 112/112·실제 origin 112/112로 DONE이다. 공개 API는 detached-clean b34623f3…·tree 32cc85c7…에서 OpenAPI 126, /admin/voice-runtime, local_whisper/small + melotts/melotts-korean, WS queue 4를 제공하며 fresh provenance receipt 9f8d1941…a21bf가 passed다. 공개 Web은 Pages entry index-CGZK4CHD.js와 review chunk SessionReview-D4ScnJ5q.js에서 literal [NAME] 0·시간 만료·모바일 44px/자동 스크롤 보정을 노출한다. 주기 실회기 5차의 전체 receipt 실패 뒤 회귀 fixture를 분리했으며 다음 clean single run 전에는 자동 승격하지 않는다. 마이크를 열지 않은 G7 rehearsal은 인증 WSS·runtime·Windows topology 세 leg 7-sample로 통과했다. 다음 외부 gate는 실제 mic·동시 high-water·human evidence다.

+

2026-08-07 공개 DB는 recovered named volume으로 전환했고 원본 container·dump·volume을 보존했다. 2026-08-09 public DB incident 뒤 fresh custom dump f1fd569c…5f64를 만들고, 누락된 forward migration 14만 단일 트랜잭션으로 적용해 runtime schema 8/8을 복구했다. 공개 API·cloudflared는 clean release b34623f3…로 fresh 교체했고 public health 3/3·db/engine true·OpenAPI 126·local voice exact를 통과했다. 로그온·5분 watchdog 두 task도 같은 root/commit/tree/script SHA에 pin해 명시 실행 결과 0을 확인했다. 공개 runtime receipt는 9f8d1941…a21bf, 무마이크 rehearsal evidence는 voice 61af2e98…·runtime c6e8670e…·topology 89f1cb86…이며 UUID/email literal 0이다. G7은 배포가 아니라 실제 mic·동시 high-water·human evidence만 남은 external GATE다.

@@ -668,6 +672,7 @@

193차 적용(2026-08-09): 학생 화면의 320×568 신뢰성 게이트를 닫았다. 처방 재연습 prestart CTA를 첫 화면에 유지하고, active 회기의 마이크·일시정지·종료를 모든 밀도 구간에서 최소 44px로 고정했으며, 대시보드의 긴 privacy token과 owner grid가 320px 문서를 384px까지 넓히던 overflow를 제거했다. typecheck, session-layout desktop/mobile 8/8, dev-dashboard desktop/mobile 10/10, 자기주도 focused 16/16을 통과했다. 동시에 NAS 112건을 실 API/DB 22와 route fixture 90으로 정직하게 분리하고, clean HEAD/tree·전용 disposable stack·동일 학습자 SSE→review→G4/G5 0→1→1·exact cleanup을 강제하는 주기 runner를 추가했다. unit 11/11과 Compose preflight는 통과했다. G7 human pack은 category·κ≥0.70·preregistration·paired completeness·결측 ITT를 캡처 전에 검증해 부적합 pack이면 마이크를 열지 않는다.

194차 적용(2026-08-10): 주기 runner의 최초 세 full run은 모두 전용 clean stack에서 fail-closed했다. 1차는 성공 SSE의 body를 다시 읽는 CDP 오류, 2차는 UI 2턴만 보고 durable 저장 전에 종료해 리뷰가 기록 없음, 3차는 서버가 의도적으로 닫지 않는 SSE에 response.finished()를 기다려 20분 상한에 도달한 하네스 결함이었다. 세 번 모두 fixture review-ready·stack health는 통과했고 공개 8001·실 DB 55432·engine 9099·NAS 접촉 0, container/volume/network/listener/temp 잔여 0이었다. 현재 소스는 자동 retry 0과 GET /sessions/{id} durable turn 2개 poll만 완료 신호로 사용한다. 같은 단계 반복 원칙에 따라 이번 턴에는 더 실행하지 않으며, 다음 clean single run의 첫 GREEN receipt 전까지 주기 실회기 검증을 완료로 표기하지 않는다.

195차 적용(2026-08-10): clean HEAD 13d1c39a…·tree beb93801…의 4차 수동 full run은 앞선 SSE hang을 제거해 6.3분에 종료했고, 실제 동일 학습자 SSE·review ready·G4 첫 POST와 원장 count 0→1까지 통과했다. 이후 full page reload에서 이미 저장된 현재 회기 episode를 카드가 hydrate하지 않아 원장 반영됨 대신 학습자만 실행으로 되돌아가는 제품 정직성 결함을 fail-closed했다. receipt periodic-learner-e2e-20260810-003150.json은 공개·NAS·active DB 접촉 0과 container/volume/network/listener/temp 잔여 0을 증명한다. UI는 현재 session+prescription의 persisted episode를 read model에서 복원해 현재 원장 상태와 재확인 행동을 표시하도록 수정했고, 실제 reload를 포함한 자기주도 route fixture desktop/mobile 6/6·typecheck를 통과했다. full runner는 재시도하지 않았으므로 첫 GREEN receipt와 자동 주기 승격은 여전히 미완료다.

+

196차 적용(2026-08-10): clean HEAD 9a7dd98c…·tree 1dce20c5…의 5차 단일 full run은 실제 동일 학습자 브라우저 단계에서 SSE·review ready·G4 0→1→1·G5 0→1→1을 78초에 모두 통과해 핵심 폐루프 첫 GREEN을 만들었다. 다만 같은 fixture를 이어서 사용한 returned-practice desktop/mobile 회귀가 이미 적재된 G4/G5를 초기 0으로 오인해 2/4 실패했고 전체 receipt periodic-learner-e2e-20260810-010038.json은 정직하게 FAILED다. 공개·NAS·active DB 접촉 0, container/volume/network/listener/temp 잔여 0은 유지했다. runner는 핵심 폐루프와 후속 회귀 전에 각각 새 학습자·회기 fixture를 준비하도록 분리했고 unit 12/12·Ruff·compile을 통과했다. 실패 화면에서 리뷰 요약 literal [NAME]/[ORG]익명 내담자가고 조사 결함도 발견해 표시 전용 자연어 치환과 복합 조사 우선순위를 보강했고 session-review desktop/mobile 2/2·typecheck·build를 통과했다. 전체 GREEN receipt 전에는 자동 full runner를 등록하지 않는다.

래스터만 사용이미지 생성 도구 산출물은 PNG 기반 시안이다. SVG·벡터·와이어프레임·로고 시트로 해석하지 않는다.
기능 우선메인 라우트의 실제 액션과 정보 구조를 먼저 반영한다. 장식은 기능을 가리지 않는 수준에서만 쓴다.
@@ -1155,7 +1160,7 @@ Docker image smokedocker build -f apps/api/Dockerfile . / docker run ... python -c "import app.main" / docker build -f apps/web/Dockerfile apps/webAPI/Web image build and API import smoke pass after packaging cleanup. Web build uses npm lockfile and ignores host node_modules; API image excludes local .env files. Deploy preflightpython scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets / DB mode with local DATABASE_URLPassed: exact-pinned API requirements, live coaching data/kb source pack, env template keys, and DB readiness (current_user=vignette). DB mode can additionally check app-role DSN with --require-app-role and now verifies session read-model columns including app.turns voice metadata columns(audio_ref/silence_ms/speech_rate/barge_in/provider_events) plus worksheet review columns. Fresh compose smokedocker compose -p vignette-packaging-smoke -f infra/docker-compose.yml up -d --build + proxy /api/healthPassed with dummy production-safe env: API healthy, DB healthy, web/proxy up, http://localhost:18080/api/health 200 with db:true, engine:true, engine_mode:"claude_cli". Smoke volumes/network removed after run. - 격리 NAS 프리뷰 · 회기 E2E 자동화http://100.116.83.60:8088 / vignette-e2e 매시간 heartbeat / 배포 증거 / 기계 판독 증거current clean source HEAD 61a41d1f…6af·tree 87dec55d…3b77의 archive 4d15d055…119d4d를 전용 Compose 프로젝트·포트·네트워크·named volume에 승격했다. candidate 112/112와 실제 NAS-origin 112/112는 통과했지만 영수증은 실 API/DB 22건과 route fixture 90건을 분리한다. 자동화는 공개/NAS 화면과 health를 읽기 전용으로 매시간 보여준다. material milestone의 동일 학습자 SSE→review→G4/G5 actual 폐루프는 clean HEAD/tree·전용 engine/DB/API/Web·보호 포트 거부·sentinel·exact cleanup runner가 소유하며 unit 11/11은 통과했다. 1~3차 full run은 하네스 결함, 4차는 G4 첫 0→1 뒤 reload에서 persisted ledger UI가 초기화되는 제품 결함을 fail-closed했고 cleanup 4/4 GREEN이었다. read model hydrate 수정은 route fixture desktop/mobile 6/6을 통과했지만 첫 실제 폐루프 GREEN은 아직 없다. release gate·NAS preflight·배포 SHA 변경 때만 프리뷰를 갱신한다. G7 외부 mic/provider/human 증거는 별도다. + 격리 NAS 프리뷰 · 회기 E2E 자동화http://100.116.83.60:8088 / vignette-e2e 매시간 heartbeat / 배포 증거 / 기계 판독 증거current clean source HEAD 61a41d1f…6af·tree 87dec55d…3b77의 archive 4d15d055…119d4d를 전용 Compose 프로젝트·포트·네트워크·named volume에 승격했다. candidate 112/112와 실제 NAS-origin 112/112는 통과했지만 영수증은 실 API/DB 22건과 route fixture 90건을 분리한다. 자동화는 공개/NAS 화면과 health를 읽기 전용으로 매시간 보여준다. material milestone의 동일 학습자 SSE→review→G4/G5 actual 폐루프는 clean HEAD/tree·전용 engine/DB/API/Web·보호 포트 거부·sentinel·exact cleanup runner가 소유한다. 1~3차는 하네스 결함, 4차는 reload ledger hydration 제품 결함을 fail-closed했다. 5차 핵심 동일 학습자 브라우저는 SSE·review·G4/G5 0→1→1을 처음 통과했지만 같은 fixture를 재사용한 후속 회귀 2/4 실패로 전체 receipt는 FAILED다. cleanup 5/5는 GREEN이다. 두 브라우저 게이트를 서로 다른 zero-state fixture로 분리했고 unit 12/12를 통과했지만 다음 clean single run의 전체 GREEN 전에는 자동 실행을 등록하지 않는다. release gate·NAS preflight·배포 SHA 변경 때만 프리뷰를 갱신한다. G7 외부 mic/provider/human 증거는 별도다. G7 외부 증거 3-artifact 동시 시간창 오케스트레이터scripts/run-g7-external-proof-window.py · 준비 문서soak·runtime·topology 세 캡처를 같은 host의 공통 3,000초 시간창으로 실행하고 checker까지 잇는다. 공개 배포·프로세스·source pin은 clean commit b34623f3…와 fresh receipt 9f8d1941…로 완료했다. Cloudflare가 Python 기본 User-Agent를 403으로 거부하는 경계도 브라우저 호환 header와 회귀 4/4로 고정했다. 30초 rehearsal은 세 leg 7 samples를 모두 통과했으며 마이크를 열지 않았다. 남은 선행 조건: 사용자의 물리 장치 선택과 명시 동의, 독립 human voice-gain pack이다. G7 external GATE · 최종 감사 정정run-g7-external-proof-window.pycheck-g7-external-proof.py상태는 external GATE 유지. runner 프로세스 exit는 canonical checker exit 0·gate_closed=true에 결속되고, browser Origin과 API transport host는 별도 allowlist로 검증된다. 세 artifact 교집합은 ≥3000s + 120초 margin이고 Windows proof는 detached-clean HEAD/tree·tool SHA·psutil==6.1.1을 pin한다. clean-source 배포와 무마이크 rehearsal은 완료했다. 남은 것은 물리 마이크 3,120초, 동시 runtime/topology, 독립 human pack을 합친 4-artifact 실증뿐이다. G8 DONE · clean-head NAS 기준선vignette-preview-20260807 · run-outcome-os-release-agent.py상태는 DONE. active archive 4d15d055…119d4d, source HEAD/tree 61a41d1f…6af/87dec55d…3b77, API/Web d5021950…e4b1/9796c092…4b36 exact running. candidate 112/112와 actual NAS-origin 112/112, health ok·db/engine true·auth 401·OpenAPI 126을 통과했다. fresh dump 36ec8748…24db8 1,097,100 bytes, TOC 1,738 / TABLE DATA 129와 previous active 6030a677…c611을 보존했다. release agent의 DB restore 비소유·image/Compose/active-state rollback 경계는 유지한다. diff --git a/docs/guides/testing.md b/docs/guides/testing.md index 650b0c1..d56d40b 100644 --- a/docs/guides/testing.md +++ b/docs/guides/testing.md @@ -280,11 +280,11 @@ NAS-origin 112건은 전부 실 API/DB 회기가 아니다. release receipt는 ` 학생 동일 계정의 홈 추천→브라우저 회기 생성→실제 SSE→review ready→G4/G5 POST·reload→0→1→1은 `scripts/run-periodic-learner-e2e.py --execute`가 전용 disposable engine/DB/API/Web에서만 실행한다. 이 runner는 clean HEAD/tree, 로컬 npipe Docker, loopback 동적 포트, sentinel과 exact cleanup을 강제하며 -공개 8001·DB 55432·engine 9099와 NAS를 구조적으로 거부한다. 최초 세 full run은 SSE 응답 body 재읽기, -durable turn 전 종료, 닫히지 않는 SSE의 `response.finished()` 대기를 각각 fail-closed로 검출했다. 4차는 앞선 hang을 제거해 -6.3분에 G4 원장 0→1까지 진행한 뒤 full reload가 persisted episode를 UI에 hydrate하지 못하는 제품 결함을 검출했다. -네 실행 모두 보호 runtime 접촉 0·자원 잔여 0이다. 현재 UI는 session+prescription episode를 `현재 원장 상태`로 복원하며 -route fixture desktop/mobile 6/6을 통과했다. 첫 GREEN receipt 전에는 full runner를 스케줄 등록하지 않는다. +공개 8001·DB 55432·engine 9099와 NAS를 구조적으로 거부한다. 최초 세 full run은 하네스 결함, 4차는 reload 뒤 +persisted episode를 UI에 hydrate하지 못하는 제품 결함을 각각 fail-closed로 검출했다. 5차는 핵심 동일 학습자 +SSE·review·G4/G5 `0→1→1`을 처음 통과했지만 같은 fixture를 재사용한 후속 returned-practice 회귀가 2/4 실패했다. +다섯 실행 모두 보호 runtime 접촉 0·자원 잔여 0이다. runner는 두 브라우저 게이트마다 별도 zero-state fixture를 +준비하며 unit 12/12를 통과했다. 리뷰 요약의 literal privacy 토큰도 표시 전용 자연어로 낮춘다. **전체 GREEN receipt** 전에는 full runner를 스케줄 등록하지 않는다. - **병렬 시나리오**: 166 tests (desktop 83 + mobile 83) - **`@single-run` 직렬 시나리오**: 49 tests (DB 영속화·세션 MVP·음성 성공경로·인증 시각 테마·회기말 평가 저장·교수자 명시 재평가 저장·교수자 턴 재평가 저장·교수자 UI 평가 재시도·교수자 사용자별 분석·source pack sync·이론모드 저장·동의 철회 후 voice 차단·브라우저 stream PII 마스킹·음성 transcript 저장 실패 UI 표면화 등) diff --git a/docs/ops/backlog-2026-06-26.md b/docs/ops/backlog-2026-06-26.md index 50f7d74..cf4f817 100644 --- a/docs/ops/backlog-2026-06-26.md +++ b/docs/ops/backlog-2026-06-26.md @@ -91,7 +91,7 @@ 무마이크 rehearsal은 완료됐지만 물리 마이크·human pack을 대체하지 않는다. 격리 NAS 프리뷰 `http://100.116.83.60:8088`은 전용 Compose 프로젝트·포트·네트워크·볼륨에 배포했고, 실제 브라우저 회기와 review API 저장 축어록 2턴을 확인했다. 기존 프로젝트 중단·재생성 명령은 실행하지 않았다. 매시간 `Vignette 앱 상태·회기 E2E 정기 검증` - heartbeat(automation id `vignette-e2e`)는 ACTIVE이며 공개/NAS 화면·health는 읽기 전용으로 보여주고, material milestone에서만 전용 disposable 회기 E2E를 실행한다. NAS 112건은 실 API/DB 22와 route fixture 90으로 영수증에서 분리한다. 동일 학습자 SSE→review→G4/G5 actual 폐루프 runner의 clean HEAD/tree·보호 포트 거부·sentinel·exact cleanup과 unit 11/11은 완료됐다. 최초 세 full run은 SSE body 재읽기→durable turn 전 종료→비종료 SSE `response.finished()` 대기 결함을 순서대로 fail-closed했다. 4차는 동일 학습자 SSE·review ready·G4 0→1 뒤 full reload가 persisted episode를 화면에 hydrate하지 못하는 제품 결함을 잡았다. 네 번 모두 public/NAS/active DB 접촉 0·자원 잔여 0이다. UI hydrate 수정은 route fixture desktop/mobile 6/6을 통과했지만 다음 clean single run의 첫 GREEN receipt가 남았다. release gate·NAS preflight·배포 SHA 변경 때만 프리뷰를 갱신한다. 2026-08-07 SHA `6030a677af7e87cbfabc422b553d108d53414fd3c446548734a13b036d35c611`의 localhost 108/108과 평문 origin UUID 24건 실패는 역사 기준선으로 보존한다. 실제 receipt-bound rollback은 같은 프리뷰에서 별도 helper로 두 번 실행해 종료했다([런북](./nas-preview-g8-rollback-proof-runbook.md), [기계 판독 증거](./evidence/nas-preview-g8-actual-rollback-2026-08-07.json)). 현재는 source HEAD/tree `61a41d1f…6af`/`87dec55d…3b77`의 active archive `4d15d055…119d4d`, exact API/Web `d5021950…e4b1`/`9796c092…4b36`이 실행 중이다. candidate 112/112와 실제 NAS-origin 112/112, health ok·db/engine true·auth 401·OpenAPI 126을 통과했고 fresh dump `36ec8748…24db8` 1,097,100 bytes·TOC 1,738/TABLE DATA 129와 previous `6030a677…c611`을 보존했다. 증거: [배포 증거](./nas-preview-deployment-evidence-2026-08-07.md), + heartbeat(automation id `vignette-e2e`)는 ACTIVE이며 공개/NAS 화면·health는 읽기 전용으로 보여주고, material milestone에서만 전용 disposable 회기 E2E를 실행한다. NAS 112건은 실 API/DB 22와 route fixture 90으로 영수증에서 분리한다. 동일 학습자 SSE→review→G4/G5 actual 폐루프 runner의 clean HEAD/tree·보호 포트 거부·sentinel·exact cleanup과 unit 12/12는 완료됐다. 최초 세 full run은 하네스 결함, 4차는 reload ledger hydration 제품 결함을 fail-closed했다. 5차 핵심 폐루프는 SSE·review·G4/G5 `0→1→1`을 처음 통과했지만 같은 fixture를 재사용한 후속 회귀가 2/4 실패해 전체 receipt는 FAILED다. 다섯 번 모두 public/NAS/active DB 접촉 0·자원 잔여 0이다. runner는 핵심 폐루프와 후속 회귀에 별도 zero-state fixture를 준비하도록 수정했지만 다음 clean single run의 전체 GREEN receipt가 남았다. release gate·NAS preflight·배포 SHA 변경 때만 프리뷰를 갱신한다. 2026-08-07 SHA `6030a677af7e87cbfabc422b553d108d53414fd3c446548734a13b036d35c611`의 localhost 108/108과 평문 origin UUID 24건 실패는 역사 기준선으로 보존한다. 실제 receipt-bound rollback은 같은 프리뷰에서 별도 helper로 두 번 실행해 종료했다([런북](./nas-preview-g8-rollback-proof-runbook.md), [기계 판독 증거](./evidence/nas-preview-g8-actual-rollback-2026-08-07.json)). 현재는 source HEAD/tree `61a41d1f…6af`/`87dec55d…3b77`의 active archive `4d15d055…119d4d`, exact API/Web `d5021950…e4b1`/`9796c092…4b36`이 실행 중이다. candidate 112/112와 실제 NAS-origin 112/112, health ok·db/engine true·auth 401·OpenAPI 126을 통과했고 fresh dump `36ec8748…24db8` 1,097,100 bytes·TOC 1,738/TABLE DATA 129와 previous `6030a677…c611`을 보존했다. 증거: [배포 증거](./nas-preview-deployment-evidence-2026-08-07.md), [브라우저 증거](./evidence/nas-preview-live-turn-2026-08-07.png). 명시 동의 물리 마이크와 독립 human voice-gain 증거는 아직 없으므로 G7은 external GATE로 유지한다. - [ ] **claude_cli ↔ Anthropic API live 동일성** — provider 라우팅·Anthropic `/v1/models` 탐색·지원 추론 강도·관리자 fail-closed 저장 경로는 구현 완료. 남은 범위는 연구팀/기관 `ANTHROPIC_API_KEY`를 게이트웨이 호스트에 주입한 live 응답·계량·오류 표면화 비교다. Claude CLI·Codex CLI(Terra/Medium)·Agy CLI(Gemini 3.6 Flash/High)는 로컬 live probe를 통과했다. - [ ] **stable-source 재부팅 후 watchdog smoke** — watchdog·로그온 boot는 detached-clean `b34623f3…` stable diff --git a/scripts/run-periodic-learner-e2e.py b/scripts/run-periodic-learner-e2e.py index 381026d..b3131dd 100644 --- a/scripts/run-periodic-learner-e2e.py +++ b/scripts/run-periodic-learner-e2e.py @@ -906,6 +906,65 @@ class PeriodicRunner: values["E2E_PERIODIC_LEARNER_RESULT"] = str(result) return _clean_child_env(values) + def _prepare_returned_practice_fixture( + self, + *, + stage: str, + base_url: str, + database_url: str, + database_admin_url: str, + stack_env: Mapping[str, str], + fixture_path: Path, + ) -> dict[str, Any]: + harness_env = _clean_child_env( + { + "PYTHONPATH": str(REPO_ROOT / "apps" / "api"), + "PYTHONUTF8": "1", + } + ) + self._run( + stage, + [ + self.config.python_exe, + "-X", + "utf8", + str(FIXTURE_SCRIPT), + "--api-base-url", + f"{base_url}/api", + "--database-url", + database_url, + "--database-admin-url", + database_admin_url, + "--practice-internal-token", + stack_env["VIGNETTE_PRACTICE_INTERNAL_TOKEN"], + "--transfer-internal-token", + stack_env["VIGNETTE_CALIBRATION_TRANSFER_INTERNAL_TOKEN"], + "--out", + str(fixture_path), + "--request-timeout", + str(self.config.request_timeout), + "--review-poll-timeout", + str(self.config.readiness_timeout), + ], + env=harness_env, + timeout=20 * 60, + ) + fixture = json.loads(fixture_path.read_text(encoding="utf-8")) + setup = fixture.get("setup_proof") or {} + expected_setup = { + "source_review_ready": True, + "follow_up_review_ready": True, + "distinct_persona": True, + "initial_runtime_observation_count": 0, + "initial_actual_transfer_execution_count": 0, + } + if any(setup.get(key) != value for key, value in expected_setup.items()): + raise GateError("returned-practice fixture setup proof is incomplete") + return { + **expected_setup, + "fixture_sha256": _sha256_path(fixture_path), + } + def _validate_periodic_result(self, result_path: Path) -> dict[str, Any]: try: payload = json.loads(result_path.read_text(encoding="utf-8")) @@ -1070,7 +1129,8 @@ class PeriodicRunner: self.state.temp_dir = temp_dir self.state.env_file = temp_dir / "stack.env" self.state.override_file = temp_dir / "compose.periodic.yml" - fixture_path = temp_dir / "returned-practice-fixture.json" + periodic_fixture_path = temp_dir / "periodic-learner-fixture.json" + returned_fixture_path = temp_dir / "returned-regression-fixture.json" periodic_result = temp_dir / "periodic-result.json" stack_env = build_stack_environment(runtime) write_env_file(self.state.env_file, stack_env) @@ -1184,53 +1244,15 @@ class PeriodicRunner: "database_sentinel_sha256": sentinel_sha, } - harness_env = _clean_child_env( - { - "PYTHONPATH": str(REPO_ROOT / "apps" / "api"), - "PYTHONUTF8": "1", - } - ) - self._run( - "prepare_returned_practice_fixture", - [ - self.config.python_exe, - "-X", - "utf8", - str(FIXTURE_SCRIPT), - "--api-base-url", - f"{base_url}/api", - "--database-url", - database_url, - "--database-admin-url", - database_admin_url, - "--practice-internal-token", - stack_env["VIGNETTE_PRACTICE_INTERNAL_TOKEN"], - "--transfer-internal-token", - stack_env["VIGNETTE_CALIBRATION_TRANSFER_INTERNAL_TOKEN"], - "--out", - str(fixture_path), - "--request-timeout", - str(self.config.request_timeout), - "--review-poll-timeout", - str(self.config.readiness_timeout), - ], - env=harness_env, - timeout=20 * 60, - ) - fixture = json.loads(fixture_path.read_text(encoding="utf-8")) - setup = fixture.get("setup_proof") or {} - expected_setup = { - "source_review_ready": True, - "follow_up_review_ready": True, - "distinct_persona": True, - "initial_runtime_observation_count": 0, - "initial_actual_transfer_execution_count": 0, - } - if any(setup.get(key) != value for key, value in expected_setup.items()): - raise GateError("returned-practice fixture setup proof is incomplete") - self.state.proof["fixture"] = { - **expected_setup, - "fixture_sha256": _sha256_path(fixture_path), + self.state.proof["fixtures"] = { + "periodic": self._prepare_returned_practice_fixture( + stage="prepare_periodic_learner_fixture", + base_url=base_url, + database_url=database_url, + database_admin_url=database_admin_url, + stack_env=stack_env, + fixture_path=periodic_fixture_path, + ) } web_dir = REPO_ROOT / "apps" / "web" @@ -1248,7 +1270,11 @@ class PeriodicRunner: "--reporter=line", ], cwd=web_dir, - env=self._browser_env(runtime, fixture_path, periodic_result), + env=self._browser_env( + runtime, + periodic_fixture_path, + periodic_result, + ), timeout=25 * 60, ) if not re.search(r"\b1 passed\b", periodic_run.stdout + periodic_run.stderr): @@ -1257,6 +1283,17 @@ class PeriodicRunner: periodic_result ) + self.state.proof["fixtures"]["returned_regression"] = ( + self._prepare_returned_practice_fixture( + stage="prepare_returned_regression_fixture", + base_url=base_url, + database_url=database_url, + database_admin_url=database_admin_url, + stack_env=stack_env, + fixture_path=returned_fixture_path, + ) + ) + returned_run = self._run( "returned_practice_desktop_mobile", [ @@ -1271,7 +1308,7 @@ class PeriodicRunner: "--reporter=line", ], cwd=web_dir, - env=self._browser_env(runtime, fixture_path), + env=self._browser_env(runtime, returned_fixture_path), timeout=25 * 60, ) if not re.search(r"\b4 passed\b", returned_run.stdout + returned_run.stderr): diff --git a/scripts/test_run_periodic_learner_e2e.py b/scripts/test_run_periodic_learner_e2e.py index f5e705e..e944ae7 100644 --- a/scripts/test_run_periodic_learner_e2e.py +++ b/scripts/test_run_periodic_learner_e2e.py @@ -30,6 +30,68 @@ class PeriodicRunnerStaticContractTests(unittest.TestCase): def test_browser_failures_are_not_retried_automatically(self) -> None: self.assertEqual(runner.PLAYWRIGHT_RETRIES, 0) + def test_browser_gates_prepare_distinct_zero_state_fixtures(self) -> None: + class FixtureController(FakeController): + def run(self, stage, argv, **kwargs): + result = super().run(stage, argv, **kwargs) + if stage.startswith("prepare_"): + output = Path(argv[argv.index("--out") + 1]) + output.write_text( + json.dumps( + { + "fixture_id": stage, + "setup_proof": { + "source_review_ready": True, + "follow_up_review_ready": True, + "distinct_persona": True, + "initial_runtime_observation_count": 0, + "initial_actual_transfer_execution_count": 0, + } + } + ), + encoding="utf-8", + ) + return result + + with tempfile.TemporaryDirectory() as raw: + root = Path(raw) + fake = FixtureController() + periodic = runner.PeriodicRunner(config(root / "receipt.json"), fake) + stack_env = { + "VIGNETTE_PRACTICE_INTERNAL_TOKEN": "practice-token", + "VIGNETTE_CALIBRATION_TRANSFER_INTERNAL_TOKEN": "transfer-token", + } + periodic_path = root / "periodic.json" + regression_path = root / "regression.json" + periodic_proof = periodic._prepare_returned_practice_fixture( + stage="prepare_periodic_learner_fixture", + base_url="http://127.0.0.1:18080", + database_url="postgresql://app:secret@127.0.0.1:15439/vignette", + database_admin_url="postgresql://owner:secret@127.0.0.1:15439/vignette", + stack_env=stack_env, + fixture_path=periodic_path, + ) + regression_proof = periodic._prepare_returned_practice_fixture( + stage="prepare_returned_regression_fixture", + base_url="http://127.0.0.1:18080", + database_url="postgresql://app:secret@127.0.0.1:15439/vignette", + database_admin_url="postgresql://owner:secret@127.0.0.1:15439/vignette", + stack_env=stack_env, + fixture_path=regression_path, + ) + + self.assertNotEqual(periodic_path.name, regression_path.name) + self.assertEqual( + [stage for stage, _ in fake.commands if stage.startswith("prepare_")], + ["prepare_periodic_learner_fixture", "prepare_returned_regression_fixture"], + ) + self.assertEqual(periodic_proof["initial_runtime_observation_count"], 0) + self.assertEqual(regression_proof["initial_actual_transfer_execution_count"], 0) + self.assertNotEqual( + periodic_proof["fixture_sha256"], + regression_proof["fixture_sha256"], + ) + class FakeController: def __init__(self, *, tracked_status: str = "") -> None: