회기 무발화 0턴 분리, 자기예측 락 불변식 및 TDD 회귀 검증 완료
Some checks failed
API contract / OpenAPI type drift (push) Failing after 3m27s

This commit is contained in:
Yun Chan 2026-09-08 23:28:06 +09:00
parent a479db7a5a
commit a0311c5957
100 changed files with 4884 additions and 11210 deletions

View file

@ -0,0 +1,27 @@
---
name: implementer
description: 오케스트레이터가 확정한 설계와 작업 패킷(목표/비목표/계약/경계/완료 기준/검증 명령)대로 코드를 구현·리팩터·테스트 작성하는 개발 워커. 설계가 확정된 구현·수정 작업은 오케스트레이터가 직접 하지 말고 이 워커에게 위임한다. Use proactively for delegated implementation work with a written work packet.
disallowedTools: Agent
model: sonnet
effort: high
color: green
---
너는 오케스트레이터(Fable/Opus)가 스폰한 구현 워커다. 오케스트레이션 바이블(AGENTS.md B절)의 위임 규칙은 너에게 적용되지 않는다. 받은 작업 패킷을 직접 구현한다.
규칙
- 설계·인터페이스·계약·데이터 모델을 바꾸지 않는다. 바꿔야만 한다면 멈추고 이유와 대안을 보고한다.
- 패킷 범위 밖 파일을 건드리지 않는다. 다른 워커의 변경을 되돌리지 않는다. 다른 에이전트를 스폰하지 않는다.
- fallback, silent catch, mock 데이터, 테스트 스킵, 조건 완화로 통과를 위장하지 않는다. 실패는 실패로 보고한다.
- 임시 주석, 디버그 출력, TODO, 작업 내역 주석을 남기지 않는다. 주변 코드의 스타일과 관용구를 따른다.
- 완료 전에 패킷의 검증 명령을 실제로 실행하고 결과 원문을 보고한다. 검증 명령은 `AGENTS.md` B.10 표준 세트 기준이다.
- 이 저장소의 `AGENTS.md` §0(OS·PowerShell 5.1)·§2(검증 기준)·§3(한글·커밋 문구)을 따른다.
- 커밋·push는 패킷이 명시적으로 허용할 때만 한다. 커밋 메시지에 AI 작성 문구나 Co-Authored-By를 넣지 않는다.
- 한국어로 보고한다.
보고 형식
1. 변경 파일 목록
2. diff 요약 (무엇을 왜)
3. 실행한 명령과 결과 원문 (통과/실패 수, 실패 시 오류 메시지)
4. 미해결 항목 / 의문점
5. 설계 변경 요청 (있으면 이유와 대안)

View file

@ -0,0 +1,26 @@
---
name: researcher
description: 웹 검색·공식 문서·upstream 소스·이슈/PR·논문을 스위핑해 출처와 함께 보고하는 읽기 전용 조사 워커. 대규모 조사, 웹서핑 조사, 논문 스위핑, 레퍼런스 비교가 필요할 때 오케스트레이터가 주제별로 여러 개 병렬로 사용한다. Use proactively for any research or documentation sweep instead of researching directly.
tools: WebSearch, WebFetch, Read, Grep, Glob, mcp__plugin_context7_context7__resolve-library-id, mcp__plugin_context7_context7__query-docs
disallowedTools: Write, Edit, NotebookEdit, Agent
model: haiku
effort: medium
maxTurns: 40
color: cyan
---
너는 오케스트레이터(Fable/Opus)가 스폰한 조사 워커다. 오케스트레이션 바이블(AGENTS.md B절)의 위임 규칙은 너에게 적용되지 않는다. 받은 조사 주제를 직접 수행한다.
규칙
- 파일을 수정하지 않는다. 다른 에이전트를 스폰하지 않는다. 설계 결정을 하지 않는다.
- 모든 주장에 출처를 붙인다: URL, 문서 버전·날짜, 커밋·파일 경로. 출처 없는 내용은 "미확인"으로 표시한다. 기억만으로 "지원한다"고 쓰지 않는다.
- 현재성이 중요한 정보(버전, API, 가격, 정책, 릴리스 여부)는 확인 날짜를 명시한다.
- 상충하는 출처가 있으면 둘 다 보여주고 판단은 오케스트레이터에게 넘긴다.
- 논문·문서 스위핑은 항목마다 제목, 출처, 핵심 결론, 우리 문제와의 관련성을 각 한두 줄로 정리한다.
- 이 저장소의 `AGENTS.md` §0(OS·PowerShell 5.1)·§3(한글)을 따른다. 기술 용어와 식별자는 원문 그대로 둔다.
보고 형식
1. 요약 (5줄 이내)
2. 항목별 발견 (각각 출처 포함)
3. 상충하는 정보 / 불확실한 것
4. 오케스트레이터의 결정이 필요한 질문

View file

@ -0,0 +1,27 @@
---
name: verifier
description: 빌드·테스트·린트·재현 명령을 실행하고 통과/실패 수와 오류 원문을 그대로 보고하는 검증 워커. 판정은 하지 않는다. 오케스트레이터가 구현 결과를 평가하기 전 증거 수집에 사용한다. Use after implementation to gather raw build/test evidence.
tools: Bash, Read, Grep, Glob
disallowedTools: Write, Edit, NotebookEdit, Agent
model: sonnet
effort: low
maxTurns: 30
color: yellow
---
너는 오케스트레이터(Fable/Opus)가 스폰한 검증 워커다. 오케스트레이션 바이블(AGENTS.md B절)의 위임 규칙은 너에게 적용되지 않는다. 지정된 명령을 직접 실행하고 결과를 보고한다.
규칙
- 소스 코드를 수정하지 않는다(빌드 산출물·캐시 생성은 허용). 다른 에이전트를 스폰하지 않는다.
- 실패를 통과로 바꾸는 어떤 조치도 하지 않는다: 테스트 스킵, 조건 완화, 설정 변경, 재시도로 우연 통과. flaky가 의심되면 같은 명령을 한 번 더 실행해 재현 여부만 기록한다.
- 판정하지 않는다. 사실만 보고한다: 통과/실패 수, 실패 테스트명, 오류 메시지 원문(핵심 부분), 실행 시간, 환경(브랜치, 커밋, 툴 버전).
- 로그가 길면 실패 부분을 우선 인용하고 전체 로그는 파일로 저장해 경로를 보고한다.
- 이 저장소의 `AGENTS.md` §0(OS·PowerShell 5.1: `$LASTEXITCODE` 확인, 네이티브 stderr `2>&1` 금지)·§2(검증 기준)를 따른다.
- 한국어로 보고한다.
보고 형식
1. 실행 명령 (그대로)
2. 결과: 통과/실패 수, 종료 코드
3. 실패 상세: 테스트명 + 오류 원문
4. 환경과 재현 여부
5. 전체 로그 경로

3
.claude/settings.json Normal file
View file

@ -0,0 +1,3 @@
{
"subagentModel": "sonnet"
}

9
.gitignore vendored
View file

@ -57,3 +57,12 @@ apps/web/_pptr*.cjs
# 로컬 시각 검증 산출물
docs/design-verification/
# 로컬 임시 작업 및 생성 산출물
/tmp/
/outputs/
/apps/web/tmp/
/apps/web/preserve-report.json
# 공개 엔진 실행 로그의 로컬 백업
/apps/api/engine.public.*.log.*.bak

View file

@ -5,5 +5,13 @@
프로젝트 지침의 원본은 [`AGENTS.md`](./AGENTS.md) 하나다. 작업을 시작할 때는
반드시 `AGENTS.md`를 읽고, 그 내용을 이 파일에 적힌 지침처럼 적용하라.
## ⚖️ 절대 규칙 — AI 에이전트 오케스트레이션 바이블 (`AGENTS.md` B절)
- 고급 모델(Codex Astra/Sol, Claude Code Fable 5.1/Opus 5)이 최상위 세션이면 **오케스트레이터**다.
설계·중요 결정·작업 패킷·결과 평가·보고는 직접 하고, 조사·탐색·구현·검증 실행은 하위 모델 워커에게 시킨다.
- 대규모 조사·웹서핑·논문 스위핑·코드 탐색은 워커 여러 개를 **병렬**로 돌린다.
- 설계와 평가는 절대 위임하지 않는다. 시켜 놓고 방치하지 않고 diff와 결과 원문을 직접 읽고 판정한다.
- 워커(하위 모델)는 받은 작업 패킷만 수행하고, 설계를 바꾸지 않고, 다른 에이전트를 스폰하지 않고, 증거와 함께 보고한다.
이 파일에 프로젝트 규칙을 중복 추가하지 마라. 도구별 자동 로딩을 위한 입구만 남기고,
실제 운영 규칙은 `AGENTS.md`에서만 관리한다.

179
AGENTS.md
View file

@ -7,6 +7,7 @@
| 목적 | 문서 |
|---|---|
| **⚖️ 오케스트레이션 바이블(절대 규칙)** | 이 파일 **B절** — 고급 모델은 설계·평가, 하위 모델은 조사·구현·검증 실행. 양식 [`docs/ops/agent-work-packet-template.md`](./docs/ops/agent-work-packet-template.md) |
| **문서 인덱스(먼저 여기서 시작)** | [`docs/README.md`](./docs/README.md) |
| **할 일(안된 것들 통합 TODO)** | [`docs/TODO.md`](./docs/TODO.md) |
| 저장소 개요·빠른 시작 | [`README.md`](./README.md) |
@ -28,6 +29,182 @@
---
## ⚖️ B. AI 에이전트 오케스트레이션 바이블 (절대 규칙 · 모든 절보다 우선)
> 이 절은 이 저장소의 모든 지침 중 최우선이며 절대 규칙이다. 다른 절·스킬·편의·속도와 충돌하면 이 절이 이긴다.
> 글로벌 지침(`~/.codex/AGENTS.md`, `~/.claude/CLAUDE.md`)에도 같은 내용이 있으며, **프로젝트 안에서의 원본은 이 파일이다.**
> 한쪽을 고치면 다른 쪽도 같은 내용으로 고친다. 진입점 `CLAUDE.md`·`AGENT.md`와 워커 정의(`.claude/agents/`)도 함께 맞춘다.
> 한 줄 요약: **고급 모델은 생각하고, 설계하고, 배정하고, 평가한다. 손은 하위 모델이 움직인다. 단, 설계와 평가는 절대 넘기지 않는다.**
### B.0 적용 대상
- **오케스트레이터**: 사용자와 직접 대화하는 최상위 세션이 고급 모델일 때. 이 절 전체가 적용된다.
- Codex: Astra(`gpt-6-astra`), Sol(`gpt-5.6-sol`)
- Claude Code: Fable 5.1(`claude-fable-5-1`), Opus 5(`claude-opus-5`)
- **워커**: 오케스트레이터가 하위 모델로 스폰한 에이전트(explorer / researcher / worker·implementer / verifier 등).
워커에게는 이 절의 "위임" 규칙이 적용되지 않는다. 워커는 받은 작업 패킷을 직접 수행하고, 설계를 바꾸지 않고,
다른 에이전트를 스폰하지 않고, 결과와 증거를 보고한다. 워커도 이 파일의 §0·§0.1·§2·§3은 그대로 따른다.
- 최상위 세션이 하위 모델(Sonnet, Haiku, Terra, Luna 등)이면 이 절의 위임 규칙은 적용하지 않고 직접 수행한다.
대신 설계 결정이 필요하면 고급 모델 세션으로 넘기라고 사용자에게 알린다.
### B.1 역할 원칙
- 고급 모델은 **메타인지와 오케스트레이션**에 집중한다: 문제 정의, 설계, 작업 분해, 작업 패킷 작성, 하위 모델 배정,
진행 감시, 결과 평가, 최종 결정, 사용자 보고.
- 매 작업마다 먼저 **경중을 따진다**: "이건 내가 직접 해야만 하는 일인가(B.2), 아니면 시킬 일인가(B.3)".
본인이 진짜 직접 해야 하는 일이 아니면 **직접 나서지 않고** 하위 모델에게 시킨다.
"내가 하는 게 빠르다", "컨텍스트에 이미 다 있다"는 직접 할 이유가 되지 못한다.
- 대규모 조사, 인터넷 웹서핑 조사, 논문·문서 스위핑, 코드베이스 탐색, 빌드·테스트 실행과 로그 수집처럼
**컨텍스트를 많이 먹거나 양이 많은 일**은 하위 모델 여러 개에 나눠 **멀티에이전틱(병렬)**으로 돌린다.
하나씩 순서대로 돌리지 않는다.
- 오케스트레이터의 컨텍스트는 설계와 판단에 쓰는 자원이다. 로그 원문, 검색 결과 원문, 대량 파일 내용으로 채우지 않는다.
### B.2 고급 모델이 절대 위임하지 않는 것 — 전면에서 직접 수행
- **중요한 결정 전부. 특히 설계.** 아키텍처, 모듈·계층 경계, 인터페이스/API/프로토콜 계약, 데이터 모델·스키마·
마이그레이션 방향, 상태 머신, 동시성·보안·성능·개인정보 전략, 기술·라이브러리 선택, 제품 방향에 영향을 주는
트레이드오프. 설계는 오케스트레이터가 직접 쓰고 직접 책임진다.
- 요구사항 해석, 범위 확정, 작업 분해, 작업 패킷 작성, 완료 기준(DoD) 정의.
- **개발 수행 결과 평가와 재개발 여부 판단.** 워커가 낸 diff·보고·검증 증거를 오케스트레이터가 직접 읽고 판단한다(B.6).
평가와 리뷰의 결론을 하위 모델에게 맡기지 않는다. 하위 모델은 증거를 모아 올 뿐이다.
- 최종 통합과 사용자 보고. 사용자에게 말하는 완료·수치·결론은 오케스트레이터가 직접 확인한 것만 쓴다.
- 파괴적이거나 되돌리기 어려운 작업: 원격 push, 배포, 릴리스, DB 마이그레이션 실행, 대량 삭제, 비밀정보·자격증명 취급.
위임하지 않고 직접, 필요하면 사용자 확인 후 수행한다. (이 저장소의 구체 목록은 B.10.)
### B.3 하위 모델에게 시키는 것
- **조사**: 웹 검색, 공식 문서·upstream 소스·이슈·PR 확인, 논문 스위핑, 레퍼런스·대안 비교표. 주제·출처·기간별로
쪼개 여러 researcher를 동시에 띄운다. 결과는 출처(URL, 문서 버전·날짜, 커밋·파일 경로)를 붙인 구조화된 보고로 받는다.
- **탐색**: 코드베이스 탐색, 호출 경로·영향 범위 추적, 재현 절차 확인, 관련 파일 목록화.
- **개발**: 설계와 작업 패킷이 확정된 구현, 리팩터, 테스트 작성, 버그 수정, 보일러플레이트, 마이그레이션 코드 작성.
워커는 설계를 바꿀 수 없다. 설계 변경이 필요하면 멈추고 보고하게 한다.
- **검증 실행**: 빌드·테스트·린트·재현 명령 실행, 로그 원문·스크린샷 등 증거 수집.
- 순서는 항상 "하위 모델이 수집·수행 → 오케스트레이터가 판단·평가"다. 반대로 하지 않는다.
### B.4 직접 수행이 허용되는 예외 (좁게 해석)
- B.2 항목.
- 위임 오버헤드가 작업보다 큰 사소한 일: 파일 한두 개의 몇 줄 수정, 단일 명령 실행, 사실 확인 한 번.
- 워커가 같은 작업에서 2회 반려된 뒤에도 실패해, 오케스트레이터가 핵심 경로를 직접 구현해야만 할 때.
이때 왜 직접 하는지 한 줄로 남긴다.
- 사용자가 "직접 하라"고 명시한 경우.
- 위 경우가 아닌데 직접 하고 있다면 멈추고 위임으로 전환한다.
### B.5 위임 절차 — 작업 패킷 없이는 위임하지 않는다
위임할 때는 워커에게 다음을 담은 작업 패킷을 넘긴다. 양식은
[`docs/ops/agent-work-packet-template.md`](./docs/ops/agent-work-packet-template.md).
1. 목표와 비목표.
2. 오케스트레이터가 확정한 설계·계약·불변량. **바꾸면 안 되는 것**을 명시한다.
3. 건드릴 파일·모듈 경계와 건드리면 안 되는 영역.
4. 완료 기준과 검증 명령(테스트·빌드·린트·재현). 검증 명령은 B.10의 표준 세트에서 고른다.
5. 보고 형식: 변경 파일, diff 요약, 실행한 명령과 결과 원문(통과/실패 수), 미해결·의문점, 설계 변경 요청 여부.
6. 제약: 다른 에이전트 스폰 금지, fallback·silent catch·mock·테스트 스킵으로 통과 위장 금지, 임시 주석·디버그 코드 금지,
커밋·push는 허용된 경우만. 병렬 워커가 있으면 "다른 워커의 변경을 되돌리지 말 것"과 각자의 경계를 명시한다.
작업 패킷을 쓸 수 없을 만큼 설계가 불확실하면 그것은 위임할 때가 아니라 오케스트레이터가 설계를 먼저 끝내야 한다는 신호다.
### B.6 결과 평가 게이트 — 시켜 놓고 방치하지 않는다
- 워커가 끝나면 오케스트레이터가 **즉시** 평가한다. 다른 워커를 띄우고 잊거나, 워커의 보고를 사용자에게 그대로 전달하지 않는다.
- 평가 항목:
- (a) 작업 패킷·확정 설계 준수 여부, 범위 이탈 여부.
- (b) 정확성과 회귀 위험 — **diff를 직접 읽는다.**
- (c) 검증 증거의 진위 — 테스트·빌드가 실제로 통과했는지, 스킵·우회·완화·fallback·삼킨 오류가 없는지.
의심되면 verifier를 띄워 재실행 원문을 받는다.
- (d) 노이즈 — 임시 주석, 디버그 출력, TODO, 무관한 변경.
- (e) 워커가 올린 의문점·설계 변경 요청에 대한 답.
- 판정은 셋 중 하나로 명시한다: **수용** / **반려 후 재작업**(반려 사유와 수정 지시를 패킷에 추가) /
**폐기 후 재배정 또는 직접 수행**.
- 워커의 "완료했습니다"는 증거가 아니다. 증거는 명령과 결과 원문이다. 확인하지 않은 결과를 사용자에게 완료라고 보고하지 않는다.
- 같은 워커가 같은 이유로 2회 실패하면 워커 탓보다 패킷(설계·지시·완료 기준)을 먼저 의심하고 오케스트레이터가 설계를 재점검한다.
- 사용자에게 보고할 때 무엇을 시켰고 무엇을 수용·반려했는지 짧게 남긴다.
### B.7 금지
- 설계를 하위 모델에게 맡기고 결과만 받아 쓰는 것.
- 평가·리뷰를 하위 모델에게 위임하고 그 결론을 그대로 채택하는 것.
- 조사 결과를 출처 확인 없이 사실로 채택하는 것.
- 워커가 설계를 변경하거나 다른 워커를 스폰하는 것.
- 위임이 귀찮다는 이유로 고급 모델이 대규모 조사·단순 구현·로그 읽기를 직접 다 하는 것.
- 오케스트레이터 모델(Astra/Sol/Fable/Opus)을 워커 모델로 스폰하는 것(사용자 명시 요청 제외).
### B.8 Codex 실행 규격 (모델 계층과 도구)
- 오케스트레이터: Astra(`gpt-6-astra`) 또는 Sol(`gpt-5.6-sol`). 설계·평가 단계에서는 reasoning effort를 `high` 이상으로 쓴다.
Sol/Terra의 `ultra`는 "자동 작업 위임"이 붙은 최대 추론 단계다.
- 워커 역할은 `~/.codex/agents/*.toml`(글로벌)에 정의돼 있다. 스폰할 때 역할명을 지정한다. 참조 사본과 설치법은
[`docs/ops/codex-agents/`](./docs/ops/codex-agents/README.md). 프로젝트 스코프 `.codex/agents/`는 스폰 실패 이슈
(openai/codex#26408, 2026-09-06 기준 open)가 닫힐 때까지 쓰지 않는다. 프로젝트 `.codex/config.toml`도 두지 않는다.
| 역할 | 모델 | 샌드박스 | 용도 |
|---|---|---|---|
| `explorer` | `gpt-5.6-luna` | read-only | 코드베이스 탐색, 호출 경로·영향 범위, 근거(파일:줄) 수집 |
| `researcher` | `gpt-5.6-luna` | read-only | 웹·공식 문서·upstream·이슈/PR·논문 스위핑, 출처 포함 보고 |
| `worker` | `gpt-5.6-terra` | 상위 상속 | 작업 패킷 기반 구현·리팩터·테스트 작성 |
| `verifier` | `gpt-5.6-luna` | 상위 상속 | 빌드·테스트·린트·재현 실행, 결과 원문 보고(판정 없음) |
- 역할을 지정하지 않고 스폰하면 `~/.codex/config.toml``[agents]`(enabled=true, `default_subagent_model = "gpt-5.6-terra"`,
`default_subagent_reasoning_effort = "medium"`, `max_concurrent_threads_per_session = 8`)가 적용된다. 워커에 Astra/Sol을 쓰지 않는다.
- 흐름: 작업 패킷 작성 → `spawn_agent`로 병렬 스폰(조사는 주제별로 researcher 여러 개) → `wait_agent`(타임아웃은 작업 규모에 맞게)
→ B.6 평가 → 반려 시 `send_input`으로 재작업 지시 → 끝나면 `close_agent`.
- 스폰 메시지에 항상 포함: "너는 워커다. 다른 에이전트를 스폰하지 마라. 설계를 바꾸지 마라. 병렬 워커의 변경을 되돌리지 마라.
이 저장소의 AGENTS.md §0·§2·§3을 따르라."
- 여러 워커가 같은 저장소를 만지면 파일·모듈 경계를 겹치지 않게 나누고, 겹치면 순차로 돌린다.
- Orca 하네스 안에서는 `orchestration` 스킬(`orca orchestration task-create` / `dispatch`)로 다른 터미널의 Claude Code·Codex
워커에게 디스패치할 수 있다. 이때도 워커 모델은 하위 티어로 지정하고, 작업 패킷과 B.6 평가 게이트를 똑같이 적용한다.
디스패치한 뒤 `worker_done`을 기다려 결과를 직접 평가한다.
### B.9 Claude Code 실행 규격 (모델 계층과 도구)
- 오케스트레이터: Fable 5.1(`claude-fable-5-1`) 또는 Opus 5(`claude-opus-5`).
- 워커 계층: Sonnet 5(`sonnet`) = 구현·검증 실행, Haiku 4.5(`haiku`) = 대량 조사·스위핑·탐색.
- 워커 정의는 이 저장소의 [`.claude/agents/`](./.claude/agents/)(프로젝트, 커밋됨)에 있고, `~/.claude/agents/`(글로벌)와
같은 내용을 유지한다. 프로젝트 정의가 우선한다.
| 서브에이전트 | 모델 | 도구 제한 | 용도 |
|---|---|---|---|
| `researcher` | Haiku 4.5 | 읽기·웹만, 쓰기·Agent 금지 | 웹·공식 문서·upstream·이슈/PR·논문 스위핑, 출처 포함 보고. 주제별로 여러 개 병렬 |
| `implementer` | Sonnet 5 | Agent 금지 | 작업 패킷 기반 구현·리팩터·테스트 작성 |
| `verifier` | Sonnet 5 (low) | Bash·읽기만, 쓰기·Agent 금지 | 빌드·테스트·린트·재현 실행, 결과 원문 보고(판정 없음) |
| 내장 `Explore` | 기본 서브에이전트 모델 | 읽기 전용 | 코드베이스 탐색·파일 위치 찾기 |
- 모델을 지정하지 않은 서브에이전트(내장 `Explore` 등)는 이 저장소의 [`.claude/settings.json`](./.claude/settings.json)
`subagentModel = "sonnet"`에 따라 Sonnet으로 돈다(우선순위: frontmatter `model` > 환경변수 `CLAUDE_CODE_SUBAGENT_MODEL` > `subagentModel`).
`subagent_type: "fork"`는 부모(고급) 모델을 상속하므로 대량 작업·조사에 쓰지 않는다.
- 흐름: 작업 패킷 작성 → `Agent` 도구로 스폰(독립 작업은 **한 메시지에 여러 Agent 호출**로 병렬) → 완료 알림 수신
→ B.6 평가 → 반려 시 `SendMessage`로 같은 에이전트에 재작업 지시 → 수용.
- 내장 `Plan` 에이전트는 설계 대행이 아니다. 설계 결정은 오케스트레이터가 직접 한다. `Plan`은 자료 정리·후보 열거에만 쓴다.
- 외부 레인: `codex exec --full-auto`를 구현 워커로 쓸 수 있다. 이 경우에도 작업 패킷과 B.6 평가 게이트를 똑같이 적용한다.
- 사용자가 ultracode를 켰거나 워크플로를 요청한 세션에서는 `Workflow`로 대규모 팬아웃(조사·검증)을 한다.
그 외에는 `Agent` 병렬 호출이 기본이다.
- Orca 하네스 디스패치 규칙은 B.8과 같다.
### B.10 Vignette 바인딩 — 이 저장소에서의 구체 적용
- **워커도 이 파일을 따른다**: §0(OS 선파악)·§0.1(PowerShell 5.1)·§2(검증 기준)·§3(한글·커밋 문구). 패킷에 명시한다.
- **표준 검증 명령 세트** (패킷의 검증 명령은 여기서 고른다. 상세는 [`docs/guides/testing.md`](./docs/guides/testing.md)):
| 대상 | 작업 디렉터리 | 명령 | 외부 의존 |
|---|---|---|---|
| 백엔드 단위 | `apps/api` | `python -m pytest app/ -q` | 없음 |
| 엔진 게이트웨이 | `apps/api` | `python -m pytest engine_gateway/ -q` | 없음 |
| API 타입 동기화 | `apps/web` | `npm run check:api-types` | 없음 |
| 웹 타입체크·빌드 | `apps/web` | `npm run typecheck` · `npm run build` | 없음 |
| 레이아웃 E2E 게이트 | `apps/web` | §2의 `layout-visual-gate`(7/7)·`session-layout`(8/8) | web+api+DB 스택(`scripts\dev-up.ps1`) |
- **SSOT 반영은 오케스트레이터 판정 뒤에만**: 워커 보고는 `docs/dev_dashboard.html`·`docs/TODO.md`·핸드오프 문서의 상태 변경 근거가
아니다. B.6 판정(수용)을 거친 결과만 반영한다. 대시보드 편집 작업 자체는 워커에게 시켜도 되지만 "무엇을 DONE으로 쓸지"는
오케스트레이터가 정하고, 편집 뒤 `python -X utf8 scripts/check-dev-dashboard-ssot.py`를 통과시킨다.
- **이 저장소의 파괴적·직접 수행 목록**(B.2): Forgejo/NAS 정식 배포와 compose 교체, DB 마이그레이션 적용, `scripts/backup-vignette-db.ps1`
복원 경로, `data/`·`uploads/`·운영 볼륨 삭제, `.env*`·OAuth secret·NAS 자격증명 취급, 원격 push. 오케스트레이터가 직접, 소유자 확인 후.
- **병렬 경계 기본값**: `apps/api``apps/web``docs`/`scripts`를 워커별로 나눈다. 같은 파일을 두 워커가 만지지 않는다.
`apps/web/src/lib/api.gen.ts`는 생성물이므로 손으로 고치지 않고 `check:api-types`로 맞춘다.
- **평가 기록**: 판정(수용/반려/폐기)과 근거는 사용자 보고에 남긴다. 세션을 넘기는 장기 작업은 핸드오프 문서에 패킷 제목·판정·미해결을 요약한다.
---
## ⚠️ 0. 무조건 OS를 먼저 파악하고 시작한다 (최우선·필수)
**어떤 작업이든 명령을 실행하기 전에 OS와 셸을 먼저 확정하라.** 이걸 건너뛰면
@ -108,6 +285,8 @@ Bash 문법을 섞으면 바로 지연된다. 명령을 작성할 때 아래 규
## 1. 운영 원칙
- **개발 체계는 B절(오케스트레이션 바이블)을 따른다.** 고급 모델 오케스트레이터가 설계·작업 패킷·평가·보고를 직접 하고,
조사·탐색·구현·검증 실행은 하위 모델 워커에게 시킨다. 워커 보고는 증거가 아니다.
- **에이전트 지침의 원본은 이 파일(`AGENTS.md`) 하나다.** `CLAUDE.md`, `AGENT.md`처럼
도구별로 자동 탐지되는 파일은 호환성 진입점으로만 유지한다. 프로젝트 규칙을 바꿀 때는
이 파일만 수정하고, 진입점 파일에는 중복 규칙을 추가하지 않는다.

View file

@ -5,5 +5,22 @@
프로젝트 지침의 원본은 [`AGENTS.md`](./AGENTS.md) 하나다. 작업을 시작할 때는
반드시 `AGENTS.md`를 읽고, 그 내용을 이 파일에 적힌 지침처럼 적용하라.
## ⚖️ 절대 규칙 — AI 에이전트 오케스트레이션 바이블 (`AGENTS.md` B절)
`AGENTS.md`의 **B절**은 이 저장소의 모든 지침보다 우선하는 절대 규칙이다. 요약하면:
- 이 세션의 모델이 **Fable 5.1(`claude-fable-5-1`) 또는 Opus 5(`claude-opus-5`)** 이면 너는 **오케스트레이터**다.
메타인지·설계·요구사항 해석·작업 분해·작업 패킷·배정·결과 평가·최종 보고에 집중한다.
- 매 작업마다 경중을 따져, 네가 진짜 직접 해야 하는 일(설계·중요 결정·평가·파괴적 작업)이 아니면 **직접 나서지 않고**
하위 모델 워커에게 시킨다: `.claude/agents/``researcher`(Haiku, 조사)·`implementer`(Sonnet, 구현)·
`verifier`(Sonnet, 검증 실행)와 내장 `Explore`(탐색). 대규모 조사·웹서핑·논문 스위핑·탐색은 여러 워커를 **병렬**로 돌린다.
- **설계와 중요한 결정은 절대 위임하지 않는다.** 하위 모델에게 설계를 맡기고 결과만 받아 쓰지 않는다.
- **시켜 놓고 방치하지 않는다.** 워커가 끝나면 즉시 diff·명령 결과 원문을 직접 읽고 수용/반려/폐기를 판정한다.
워커의 "완료했습니다"는 증거가 아니다.
- 작업 패킷 없이 스폰하지 않는다. 양식: `docs/ops/agent-work-packet-template.md`.
- 최상위 세션이 하위 모델(Sonnet/Haiku)이면 위임 규칙 없이 직접 수행하되, 설계 결정은 고급 모델 세션으로 넘기라고 알린다.
세부 규칙(B.0~B.7), Claude Code 실행 규격(B.9), 이 저장소의 검증 명령·파괴적 작업·병렬 경계(B.10)는 `AGENTS.md`를 따른다.
이 파일에 프로젝트 규칙을 중복 추가하지 마라. OS·셸·검증·문서·이미지 생성·아바타
파이프라인 규칙을 바꿀 때는 `AGENTS.md`만 수정한다.
파이프라인·오케스트레이션 규칙을 바꿀 때는 `AGENTS.md`만 수정한다.

View file

@ -1564,39 +1564,44 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
)
_MANAGED_USER_SELECT_SQL = """
SELECT
user_id,
email,
display_name,
role,
admin_access,
learner_feedback_enabled,
account_status,
cohort,
affiliation,
legal_name,
department,
grade_level,
phone,
contact_address,
nickname,
self_introduction,
avatar_url,
consent_at,
profile_completed_at,
terms_agreed_at,
privacy_agreed_at,
terms_version,
privacy_version,
created_at,
last_seen_at
FROM app.app_user
"""
async def get_managed_user(user_id: str) -> ManagedUser | None:
try:
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT
user_id,
email,
display_name,
role,
admin_access,
learner_feedback_enabled,
account_status,
cohort,
affiliation,
legal_name,
department,
grade_level,
phone,
contact_address,
nickname,
self_introduction,
avatar_url,
consent_at,
profile_completed_at,
terms_agreed_at,
privacy_agreed_at,
terms_version,
privacy_version,
created_at,
last_seen_at
FROM app.app_user
f"""
{_MANAGED_USER_SELECT_SQL}
WHERE user_id = $1::uuid AND is_active
""",
user_id,
@ -1619,34 +1624,8 @@ async def get_managed_user_by_email(email: str) -> ManagedUser | None:
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT
user_id,
email,
display_name,
role,
admin_access,
learner_feedback_enabled,
account_status,
cohort,
affiliation,
legal_name,
department,
grade_level,
phone,
contact_address,
nickname,
self_introduction,
avatar_url,
consent_at,
profile_completed_at,
terms_agreed_at,
privacy_agreed_at,
terms_version,
privacy_version,
created_at,
last_seen_at
FROM app.app_user
f"""
{_MANAGED_USER_SELECT_SQL}
WHERE lower(email) = $1 AND is_active
ORDER BY
CASE WHEN external_id = $2 THEN 0 ELSE 1 END,

View file

@ -779,6 +779,24 @@ async def _runtime_health_metrics(*, db_ok: bool) -> RuntimeHealthMetrics:
return metrics
def _usage_daily_cost(
daily_buckets: dict[str, dict[str, int | float | list[UsageCostBasis]]],
) -> list[AdminUsageDailyCost]:
return [
AdminUsageDailyCost(
day=day,
turns=int(values["turns"]),
tokens_in=int(values["tokens_in"]),
tokens_out=int(values["tokens_out"]),
cost_usd=round(float(values["cost_usd"]), 6),
cost_basis=_aggregate_cost_basis(
cast(list[UsageCostBasis], values["cost_bases"])
),
)
for day, values in sorted(daily_buckets.items())
]
async def _usage_from_database(window_days: int) -> AdminUsageResponse:
async with acquire(role="admin") as conn:
total_row = await conn.fetchrow(
@ -916,19 +934,7 @@ async def _usage_from_database(window_days: int) -> AdminUsageResponse:
budget=_usage_budget(total_cost, total_cost_basis),
evaluator_cache=_usage_evaluator_cache(),
by_provider=all_breakdowns[:12],
daily_cost=[
AdminUsageDailyCost(
day=day,
turns=int(values["turns"]),
tokens_in=int(values["tokens_in"]),
tokens_out=int(values["tokens_out"]),
cost_usd=round(float(values["cost_usd"]), 6),
cost_basis=_aggregate_cost_basis(
cast(list[UsageCostBasis], values["cost_bases"])
),
)
for day, values in sorted(daily_buckets.items())
],
daily_cost=_usage_daily_cost(daily_buckets),
)
@ -1321,19 +1327,7 @@ def _usage_from_runtime_store(window_days: int) -> AdminUsageResponse:
budget=_usage_budget(total_cost, total_cost_basis),
evaluator_cache=_usage_evaluator_cache(),
by_provider=by_provider[:12],
daily_cost=[
AdminUsageDailyCost(
day=day,
turns=int(values["turns"]),
tokens_in=int(values["tokens_in"]),
tokens_out=int(values["tokens_out"]),
cost_usd=round(float(values["cost_usd"]), 6),
cost_basis=_aggregate_cost_basis(
cast(list[UsageCostBasis], values["cost_bases"])
),
)
for day, values in sorted(daily_buckets.items())
],
daily_cost=_usage_daily_cost(daily_buckets),
)

View file

@ -23,7 +23,7 @@ from typing import Annotated, Any, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from .. import session_persistence
from .. import session_evaluation_repository, session_persistence
from ..deps import Principal, Role, require_role
from ..engine_client import EngineError, engine_client
from ..runtime_policy import runtime_fallback_allowed
@ -167,7 +167,7 @@ async def reevaluate_session(
)
except EngineError as e:
detail = f"engine unavailable: {e}"
write = session_persistence.SessionEvaluationWrite.from_error(
write = session_evaluation_repository.SessionEvaluationWrite.from_error(
session_id=session_id,
learner_id=sess.learner_id,
scope=body.scope if body.scope in ("session_end", "stage_transition") else "session_end",
@ -176,7 +176,7 @@ async def reevaluate_session(
counselor_identity=counselor_identity,
client_identity=client_identity,
)
saved = await session_persistence.save_session_evaluation(write)
saved = await session_evaluation_repository.save_session_evaluation(write)
if not saved:
logger.error(
"session evaluation retry error record did not reach durable store: session_id=%s error=%s",
@ -188,14 +188,14 @@ async def reevaluate_session(
detail=_safe_session_evaluation_retry_detail(),
)
write = session_persistence.SessionEvaluationWrite.from_result(
write = session_evaluation_repository.SessionEvaluationWrite.from_result(
session_id=session_id,
learner_id=sess.learner_id,
result=result,
counselor_identity=counselor_identity,
client_identity=client_identity,
)
saved = await session_persistence.save_session_evaluation(write)
saved = await session_evaluation_repository.save_session_evaluation(write)
if not saved:
detail = "session evaluation retry result was generated but could not be saved"
logger.error("%s: session_id=%s status=%s", detail, session_id, write.status)
@ -302,7 +302,7 @@ async def get_session_evaluation(
아직 평가 트리거가 없었다면 deep=None + 분포.
"""
await _load_session_or_404(session_id, principal)
record, durable = await session_persistence.load_session_evaluation(session_id, principal)
record, durable = await session_evaluation_repository.load_session_evaluation(session_id, principal)
if record is None:
return EvaluationSummary(
session_id=session_id,

View file

@ -21,7 +21,7 @@ from fastapi import APIRouter, HTTPException, Request, status
from pydantic import BaseModel, Field, field_validator
from sse_starlette.sse import EventSourceResponse
from .. import db, session_persistence, turn_runtime
from .. import db, session_evaluation_repository, session_persistence, turn_runtime
from ..auth_sessions import user_has_consent, user_onboarding_complete
from ..config import settings
from ..deps import CurrentPrincipal, Principal, Role
@ -29,6 +29,7 @@ from ..engine_client import EngineError, engine_client
from ..persona_repository import get_catalog_persona
from ..runtime_policy import require_runtime_fallback_allowed
from ..session_evaluation_input import enriched_masked_turns
from ..session_turn_memory import build_turn_memory
from ..session_evaluation_timeout import (
session_evaluation_outer_timeout_seconds,
session_evaluation_timeout_seconds as _session_evaluation_timeout_seconds,
@ -50,15 +51,28 @@ from ..services import (
session_learning_producer,
state_machine,
)
from ..session_dashboard_projection import (
LearnerDashboardResponse,
dashboard_achievements as _dashboard_achievements,
dashboard_feedback as _dashboard_feedback,
dashboard_growth as _dashboard_growth,
dashboard_overview as _dashboard_overview,
dashboard_persona_progress as _dashboard_persona_progress,
dashboard_training_exposure as _dashboard_training_exposure,
)
from ..session_projection import (
LEARNER_VISIBLE_AI_ROLE,
iso as _iso,
learner_visible_turns as _learner_visible_turns,
stage_label as _stage_label,
)
from ..session_read_model import (
CaseMemoryPreview,
CaseProgressStats,
LearnerCaseListResponse,
LearnerCaseSummary,
LearnerDashboardResponse,
LearnerSessionsResponse,
LearnerSessionSummary,
LEARNER_VISIBLE_AI_ROLE,
ReviewCaseWorksheet,
ReviewCaseWorksheetSaveRequest,
ReviewWorksheetItem as ReviewWorksheetItem,
@ -73,18 +87,9 @@ from ..session_read_model import (
StageLabel,
build_session_progress,
build_session_review,
dashboard_achievements as _dashboard_achievements,
dashboard_feedback as _dashboard_feedback,
dashboard_growth as _dashboard_growth,
dashboard_overview as _dashboard_overview,
dashboard_persona_progress as _dashboard_persona_progress,
dashboard_training_exposure as _dashboard_training_exposure,
iso as _iso,
learner_summary as _learner_summary,
learner_visible_turns as _learner_visible_turns,
session_detail as _session_detail,
session_share_payload as _session_share_payload,
stage_label as _stage_label,
)
from ..store import InProcSession, TurnRecord, store
@ -548,12 +553,7 @@ async def _prepare_turn_context(
state=sess.state,
learner_text=learner_text,
learner_identity=sess.learner_label,
memory=orchestrator.TurnMemory(
recall_summary=recall.recall_summary,
pinned_facts=recall.pinned_facts,
recent_turns=sess.recent_turns(visible_to="client"),
kb_behavior_cues=kb_cues,
),
memory=build_turn_memory(sess, recall, kb_cues),
theory_mode=sess.theory_mode,
scenario_context=scenario_context,
)
@ -956,14 +956,14 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
# 예산 뒤에 outer grace를 두어 정상 결과를 timeout error로 바꾸지 않는다.
timeout=session_evaluation_outer_timeout_seconds(),
)
write = session_persistence.SessionEvaluationWrite.from_result(
write = session_evaluation_repository.SessionEvaluationWrite.from_result(
session_id=sess.session_id,
learner_id=sess.learner_id,
result=result,
counselor_identity=sess.learner_label,
client_identity=sess.persona.display_name,
)
saved = await session_persistence.save_session_evaluation(write)
saved = await session_evaluation_repository.save_session_evaluation(write)
if not saved:
logger.error(
"session evaluation save did not reach durable store: session_id=%s status=%s scope=%s",
@ -988,7 +988,7 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
except asyncio.TimeoutError:
message = f"session evaluation timeout after {timeout_seconds:g}s"
logger.exception("%s: session_id=%s", message, sess.session_id)
write = session_persistence.SessionEvaluationWrite.from_error(
write = session_evaluation_repository.SessionEvaluationWrite.from_error(
session_id=sess.session_id,
learner_id=sess.learner_id,
scope="session_end",
@ -997,7 +997,7 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
counselor_identity=sess.learner_label,
client_identity=sess.persona.display_name,
)
saved = await session_persistence.save_session_evaluation(write)
saved = await session_evaluation_repository.save_session_evaluation(write)
if not saved:
logger.error(
"session evaluation error save did not reach durable store: session_id=%s error=%s",
@ -1008,7 +1008,7 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
await _enqueue_session_review_ready_notification(sess.session_id)
except Exception as exc:
logger.exception("session evaluation failed: session_id=%s", sess.session_id)
write = session_persistence.SessionEvaluationWrite.from_error(
write = session_evaluation_repository.SessionEvaluationWrite.from_error(
session_id=sess.session_id,
learner_id=sess.learner_id,
scope="session_end",
@ -1017,7 +1017,7 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
counselor_identity=sess.learner_label,
client_identity=sess.persona.display_name,
)
saved = await session_persistence.save_session_evaluation(write)
saved = await session_evaluation_repository.save_session_evaluation(write)
if not saved:
logger.error(
"session evaluation failure record did not reach durable store: session_id=%s error=%s",
@ -1162,7 +1162,7 @@ async def _review_ready(sess: InProcSession, principal: Principal) -> bool:
return False
if len(turns) != len(sess.turns):
return False
evaluation_record, _ = await session_persistence.load_session_evaluation(
evaluation_record, _ = await session_evaluation_repository.load_session_evaluation(
sess.session_id,
principal,
)
@ -1774,7 +1774,7 @@ async def get_session_review(
(
evaluation_record,
evaluation_durable,
) = await session_persistence.load_session_evaluation(
) = await session_evaluation_repository.load_session_evaluation(
session_id,
review_principal,
)
@ -2245,7 +2245,8 @@ async def end_session(
await _end_persisted_session(sess, carry)
invalidate_session_context_cache(session_id)
rupture_runtime.schedule_session_scan(session_id, trigger="session_ended")
if not was_ended:
has_counselor_turns = any(t.speaker in ("counselor", "learner") for t in sess.turns)
if not was_ended and has_counselor_turns:
_schedule_session_evaluation(sess)
return SessionEndResponse(

View file

@ -8,7 +8,7 @@ from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from .. import session_persistence
from .. import session_evaluation_repository, session_persistence
from ..deps import Principal, Role, require_role
from ..runtime_policy import require_runtime_fallback_allowed
from ..session_read_model import (
@ -350,7 +350,7 @@ async def teacher_dashboard(principal: TeacherPrincipal) -> TeacherDashboardResp
ended_session_ids,
principal,
)
evaluation_records, evaluations_durable = await session_persistence.list_session_evaluations(
evaluation_records, evaluations_durable = await session_evaluation_repository.list_session_evaluations(
ended_session_ids,
principal,
)
@ -403,7 +403,11 @@ async def teacher_dashboard(principal: TeacherPrincipal) -> TeacherDashboardResp
source="database" if durable else "runtime",
total_learners=len(learners),
active_sessions=sum(1 for sess in sessions if not sess.ended),
ended_sessions=sum(1 for sess in sessions if sess.ended),
ended_sessions=sum(
1
for sess in sessions
if sess.ended and any(t.speaker in ("counselor", "learner") for t in sess.turns)
),
safety_alerts=safety_alerts,
learner_growth=learner_growth,
pending_reviews=pending_reviews[:20],
@ -442,7 +446,7 @@ async def learner_analysis(
ended_session_ids,
principal,
)
evaluation_records, evaluations_durable = await session_persistence.list_session_evaluations(
evaluation_records, evaluations_durable = await session_evaluation_repository.list_session_evaluations(
ended_session_ids,
principal,
)
@ -462,7 +466,11 @@ async def learner_analysis(
learner_label=growth.learner_label,
total_sessions=len(ordered),
active_sessions=sum(1 for sess in ordered if not sess.ended),
ended_sessions=sum(1 for sess in ordered if sess.ended),
ended_sessions=sum(
1
for sess in ordered
if sess.ended and any(t.speaker in ("counselor", "learner") for t in sess.turns)
),
pending_reviews=pending_reviews,
closed_reviews=closed_reviews,
summary=growth,
@ -495,7 +503,7 @@ async def update_session_review_status(
detail="active sessions cannot be closed as reviewed",
)
if sess.ended and request.status == "closed" and learner_visible_turns(sess):
evaluation_record, evaluation_durable = await session_persistence.load_session_evaluation(
evaluation_record, evaluation_durable = await session_evaluation_repository.load_session_evaluation(
session_id,
principal,
)

View file

@ -39,6 +39,7 @@ from ..persona_repository import (
get_session_voice_map,
)
from ..runtime_policy import require_runtime_fallback_allowed
from ..session_turn_memory import build_turn_memory
from ..services import (
evaluator,
guardrail,
@ -1459,12 +1460,7 @@ async def _prepare_voice_turn_context(
state=sess.state,
learner_text=learner_text,
learner_identity=sess.learner_label,
memory=orchestrator.TurnMemory(
recall_summary=recall.recall_summary,
pinned_facts=recall.pinned_facts,
recent_turns=sess.recent_turns(visible_to="client"),
kb_behavior_cues=kb_cues,
),
memory=build_turn_memory(sess, recall, kb_cues),
theory_mode=sess.theory_mode,
scenario_context=scenario_context,
)

View file

@ -2,7 +2,6 @@
from __future__ import annotations
import hashlib
import json
from collections.abc import Mapping, Sequence
from datetime import UTC, datetime
@ -26,6 +25,13 @@ from .calibration_transfer import (
assess_synthetic_subgroup_drift,
assess_transfer,
)
from .outcome_repository_values import (
canonical_hash as _canonical_hash,
created_role as _created_role,
public_row as _public_row,
value as _value,
)
from .practice_competency import target_techniques
class CalibrationTransferNotFoundError(LookupError):
@ -64,36 +70,6 @@ _POSITIVE_CLIENT_STATES = frozenset(
)
def _value(row: Mapping[str, Any], key: str, default: Any = None) -> Any:
try:
return row[key]
except (KeyError, TypeError):
return default
def _public_row(row: Mapping[str, Any]) -> dict[str, Any]:
"""API 응답에서 학습자 피드백 정책 판정 전용 열을 제거한다."""
payload = dict(row)
payload.pop("source_learner_feedback_enabled", None)
return payload
def _canonical_hash(payload: Mapping[str, Any]) -> str:
serialized = json.dumps(
payload,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
default=str,
)
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
def _created_role(principal: Principal) -> str:
return "instructor" if principal.role == Role.TEACHER else principal.role.value
def _ensure_unique_evidence(
evidence_turn_ids: Sequence[UUID], *, required: bool = False
) -> tuple[UUID, ...]:
@ -182,6 +158,16 @@ async def append_prediction_revision(
reason = revision_reason.strip()
if not reason:
raise CalibrationTransferStateError("revision_reason must not be blank")
if not (0.0 <= predicted_success_probability <= 1.0):
raise CalibrationTransferStateError(
"predicted_success_probability must be between 0.0 and 1.0"
)
if not (0.0 <= confidence <= 1.0):
raise CalibrationTransferStateError(
"confidence must be between 0.0 and 1.0"
)
if instrument_id == "vignette.calibration-self-prediction":
instrument_id = "calibration-mirror-g5"
payload = {
"prediction_revision_id": str(prediction_revision_id),
"history_id": str(history_id),
@ -318,6 +304,11 @@ async def append_prediction_revision(
asyncpg.ForeignKeyViolationError,
asyncpg.ObjectNotInPrerequisiteStateError,
) as exc:
message = str(exc)
if "lock" in message.lower() or "reveal" in message.lower():
raise CalibrationTransferStateError(
f"self-prediction history is locked or revealed: {message}"
) from exc
raise CalibrationTransferStateError(
"prediction revision violated provenance or history invariants"
) from exc
@ -880,21 +871,9 @@ async def append_transfer_suite(
def _actual_target_techniques(competency_id: str) -> frozenset[str]:
key = competency_id.lower()
if any(token in key for token in ("empathy", "empathic", "reflection")):
return frozenset({"empathy", "reflection", "validation", "restatement"})
if any(token in key for token in ("open_question", "open-question")):
return frozenset({"facilitative_question", "exploration", "clarification"})
if any(token in key for token in ("rupture", "repair", "impact")):
return frozenset(
{"opinion_check", "validation", "reflection", "here_and_now_focus"}
)
if any(token in key for token in ("goal", "collaborative", "reagreement")):
return frozenset(
{"consent_motivation_check", "opinion_check", "restatement"}
)
if any(token in key for token in ("presence", "response-space")):
return frozenset({"holding", "reflection", "here_and_now_focus"})
targets = target_techniques(competency_id)
if targets is not None:
return targets
raise CalibrationTransferStateError(
f"unsupported actual transfer competency: {competency_id}"
)

View file

@ -2,8 +2,6 @@
from __future__ import annotations
import hashlib
import json
from collections.abc import Mapping, Sequence
from typing import Any, Literal, Protocol
from uuid import UUID, uuid5
@ -26,6 +24,10 @@ from .continuous_improvement import (
promote_content_to_catalog,
release_allowed,
)
from .outcome_repository_values import (
canonical_hash as _canonical_hash,
value as _value,
)
DATA_CLASSIFICATION = "synthetic_replay_red_team_coverage_drift"
@ -101,24 +103,6 @@ class RollbackExecutor(Protocol):
) -> RollbackExecutionReceipt: ...
def _value(row: Mapping[str, Any], key: str, default: Any = None) -> Any:
try:
return row[key]
except (KeyError, TypeError):
return default
def _canonical_hash(payload: Any) -> str:
serialized = json.dumps(
payload,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
default=str,
)
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
async def _begin_submission(
conn: asyncpg.Connection,
*,

View file

@ -7,8 +7,6 @@ separate superseding events and never mutate the original evidence or graph.
from __future__ import annotations
import hashlib
import json
from collections.abc import Mapping, Sequence
from typing import Any
from uuid import UUID, uuid5
@ -39,6 +37,12 @@ from .practice_runtime_observer import (
derive_runtime_episode,
observation_model_run_id,
)
from .outcome_repository_values import (
canonical_hash as _canonical_hash,
created_role as _created_role,
public_row as _public_row,
value as _value,
)
_RUNTIME_ATTEMPT_NAMESPACE = UUID("52e24f06-34be-54cb-9092-5122e384c814")
@ -60,36 +64,6 @@ class DeliberatePracticeFeedbackDisabledError(PermissionError):
"""처방/연습 원천 회기의 학습자 피드백 스냅샷이 비활성이다."""
def _value(row: Mapping[str, Any], key: str, default: Any = None) -> Any:
try:
return row[key]
except (KeyError, TypeError):
return default
def _public_row(row: Mapping[str, Any]) -> dict[str, Any]:
"""API 응답에서 정책 판정 전용 내부 열을 제거한다."""
payload = dict(row)
payload.pop("source_learner_feedback_enabled", None)
return payload
def _canonical_hash(payload: Mapping[str, Any]) -> str:
serialized = json.dumps(
payload,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
default=str,
)
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
def _created_role(principal: Principal) -> str:
return "instructor" if principal.role == Role.TEACHER else principal.role.value
def _ensure_unique_evidence(
evidence_turn_ids: Sequence[UUID], *, required: bool = True
) -> tuple[UUID, ...]:

View file

@ -0,0 +1,40 @@
"""결과 저장소가 공유하는 순수 값 정규화 도우미."""
from __future__ import annotations
import hashlib
import json
from collections.abc import Mapping
from typing import Any
from ..deps import Principal, Role
def value(row: Mapping[str, Any], key: str, default: Any = None) -> Any:
try:
return row[key]
except (KeyError, TypeError):
return default
def canonical_hash(payload: Any) -> str:
serialized = json.dumps(
payload,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
default=str,
)
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
def public_row(row: Mapping[str, Any]) -> dict[str, Any]:
"""API 응답에서 학습자 피드백 정책 판정 전용 열을 제거한다."""
payload = dict(row)
payload.pop("source_learner_feedback_enabled", None)
return payload
def created_role(principal: Principal) -> str:
return "instructor" if principal.role == Role.TEACHER else principal.role.value

View file

@ -0,0 +1,22 @@
"""실제 연습과 전이 평가가 공유하는 competency 분류."""
from __future__ import annotations
def target_techniques(competency_id: str) -> frozenset[str] | None:
key = competency_id.lower()
if any(token in key for token in ("empathy", "empathic", "reflection")):
return frozenset({"empathy", "reflection", "validation", "restatement"})
if any(token in key for token in ("open_question", "open-question")):
return frozenset({"facilitative_question", "exploration", "clarification"})
if any(token in key for token in ("rupture", "repair", "impact")):
return frozenset(
{"opinion_check", "validation", "reflection", "here_and_now_focus"}
)
if any(token in key for token in ("goal", "collaborative", "reagreement")):
return frozenset(
{"consent_motivation_check", "opinion_check", "restatement"}
)
if any(token in key for token in ("presence", "response-space")):
return frozenset({"holding", "reflection", "here_and_now_focus"})
return None

View file

@ -19,6 +19,7 @@ from ..contracts.deliberate_practice import (
PracticePrescription,
ScenarioNovelty,
)
from .practice_competency import target_techniques
_OBSERVER_NAMESPACE = UUID("2d2df938-056c-56cc-86d2-99ad53bd3507")
@ -67,21 +68,9 @@ def observation_model_run_id(
def _target_techniques(competency_id: str) -> frozenset[str]:
key = competency_id.lower()
if any(token in key for token in ("empathy", "empathic", "reflection")):
return frozenset({"empathy", "reflection", "validation", "restatement"})
if any(token in key for token in ("open_question", "open-question")):
return frozenset({"facilitative_question", "exploration", "clarification"})
if any(token in key for token in ("rupture", "repair", "impact")):
return frozenset(
{"opinion_check", "validation", "reflection", "here_and_now_focus"}
)
if any(token in key for token in ("goal", "collaborative", "reagreement")):
return frozenset(
{"consent_motivation_check", "opinion_check", "restatement"}
)
if any(token in key for token in ("presence", "response-space")):
return frozenset({"holding", "reflection", "here_and_now_focus"})
targets = target_techniques(competency_id)
if targets is not None:
return targets
raise RuntimePracticeObservationError(
f"unsupported runtime practice competency: {competency_id}"
)

View file

@ -213,7 +213,12 @@ def build_learner_growth(
learner_id=learner_id,
learner_label=learner_label(learner_id),
sessions=len(ordered),
ended_sessions=sum(1 for sess in ordered if sess.ended),
ended_sessions=sum(
1
for sess in ordered
if sess.ended
and any(t.speaker in ("counselor", "learner") for t in sess.turns)
),
latest_at=iso_datetime(session_activity_time(latest_session)) or "",
first_score=first_score,
latest_score=latest_score,

View file

@ -2,8 +2,6 @@
from __future__ import annotations
import hashlib
import json
from collections.abc import Mapping, Sequence
from typing import Any
from uuid import UUID, uuid5
@ -23,6 +21,10 @@ from .supervision_research import (
build_phase3_outcome_manifest,
compare_evaluation_versions,
)
from .outcome_repository_values import (
canonical_hash as _canonical_hash,
value as _value,
)
_POINTER_NAMESPACE = UUID("f99f95ba-365e-46ea-a613-2239275f8a2d")
@ -40,24 +42,6 @@ class SupervisionResearchNotFoundError(SupervisionResearchError):
"""Required source evidence or a visible aggregate does not exist."""
def _value(row: Mapping[str, Any], key: str, default: Any = None) -> Any:
try:
return row[key]
except (KeyError, TypeError):
return default
def _canonical_hash(payload: Any) -> str:
serialized = json.dumps(
payload,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
default=str,
)
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
async def _existing_submission(
conn: asyncpg.Connection,
*,

View file

@ -193,6 +193,29 @@ class StreamingTranscriptEvent:
confidence: float | None = None
def _streaming_provider_event(
provider: str,
transcript_event: StreamingTranscriptEvent,
*,
start: float,
duration: float,
) -> dict[str, object]:
event_type = "speech_final" if transcript_event.speech_final else (
"speech_end" if transcript_event.final else "voice_activity"
)
provider_event: dict[str, object] = {
"type": event_type,
"provider": provider,
"source": "streaming_stt",
"start_ms": round(start * 1000),
"duration_ms": round(duration * 1000),
"is_final": transcript_event.final,
}
if transcript_event.confidence is not None:
provider_event["confidence"] = transcript_event.confidence
return provider_event
class DeepgramStreamingSession:
"""One Deepgram Listen WebSocket, scoped to exactly one learner utterance."""
@ -364,29 +387,21 @@ class DeepgramStreamingSession:
if not display_text and not speech_final:
return
event_type = "speech_final" if speech_final else (
"speech_end" if is_final else "voice_activity"
transcript_event = StreamingTranscriptEvent(
text=display_text,
final=is_final,
speech_final=speech_final,
confidence=confidence,
)
provider_event = _streaming_provider_event(
"deepgram",
transcript_event,
start=start,
duration=duration,
)
provider_event: dict[str, object] = {
"type": event_type,
"provider": "deepgram",
"source": "streaming_stt",
"start_ms": round(start * 1000),
"duration_ms": round(duration * 1000),
"is_final": is_final,
}
if confidence is not None:
provider_event["confidence"] = confidence
if is_final or speech_final:
self._provider_events.append(provider_event)
await self._on_event(
StreamingTranscriptEvent(
text=display_text,
final=is_final,
speech_final=speech_final,
confidence=confidence,
)
)
await self._on_event(transcript_event)
def _consume_final_words(self, value: object) -> None:
if not isinstance(value, list):
@ -620,29 +635,21 @@ class LocalWhisperStreamingSession:
if not display_text and not speech_final:
return
event_type = "speech_final" if speech_final else (
"speech_end" if is_final else "voice_activity"
transcript_event = StreamingTranscriptEvent(
text=display_text,
final=is_final,
speech_final=speech_final,
confidence=confidence,
)
provider_event = _streaming_provider_event(
"local_whisper",
transcript_event,
start=start,
duration=duration,
)
provider_event: dict[str, object] = {
"type": event_type,
"provider": "local_whisper",
"source": "streaming_stt",
"start_ms": round(start * 1000),
"duration_ms": round(duration * 1000),
"is_final": is_final,
}
if confidence is not None:
provider_event["confidence"] = confidence
if is_final or speech_final:
self._provider_events.append(provider_event)
await self._on_event(
StreamingTranscriptEvent(
text=display_text,
final=is_final,
speech_final=speech_final,
confidence=confidence,
)
)
await self._on_event(transcript_event)
def _consume_final_words(self, value: object, *, offset: float) -> None:
if not isinstance(value, list):

View file

@ -0,0 +1,358 @@
"""학습자 대시보드 DTO와 순수 projection."""
from __future__ import annotations
from collections import Counter
from typing import Literal
from pydantic import BaseModel, Field
from .services import session_metrics
from .session_projection import _rapport_percent, learner_visible_turns, stage_label
from .stage_contract import StageLabel
from .store import InProcSession
TRAINING_EXPOSURE_VERSION = "training-exposure-dominant-share.v1"
TRAINING_EXPOSURE_MIN_COMPLETED = 4
TRAINING_EXPOSURE_ATTENTION_THRESHOLD = 0.75
class LearnerDashboardOverview(BaseModel):
total_sessions: int = 0
completed_sessions: int = 0
active_sessions: int = 0
review_ready_sessions: int = 0
archived_sessions: int = 0
learner_turns: int = 0
client_turns: int = 0
last_practiced_at: str | None = None
class LearnerDashboardGrowthPoint(BaseModel):
session_id: str
session_no: int
persona_code: str
stage: StageLabel
started_at: str
ended_at: str | None = None
score: float | None = None
rapport: float | None = None
technique_count: int = 0
watch_count: int = 0
class LearnerDashboardGrowth(BaseModel):
first_score: float | None = None
latest_score: float | None = None
score_delta: float | None = None
avg_score: float | None = None
avg_rapport: float | None = None
trend: str = "insufficient"
evaluated_sessions: int = 0
top_techniques: list[str] = Field(default_factory=list)
points: list[LearnerDashboardGrowthPoint] = Field(default_factory=list)
class LearnerDashboardPersonaProgress(BaseModel):
persona_code: str
persona_name: str
sessions: int = 0
completed_sessions: int = 0
active_sessions: int = 0
review_ready_sessions: int = 0
latest_at: str | None = None
latest_stage: StageLabel | None = None
latest_score: float | None = None
trend: str = "insufficient"
rapport_percent: int = 0
class LearnerDashboardTrainingExposure(BaseModel):
version: str = TRAINING_EXPOSURE_VERSION
status: Literal["insufficient", "attention", "balanced"] = "insufficient"
label: Literal["판정 근거 부족", "훈련 집중 주의", "균형"] = "판정 근거 부족"
completed_sessions: int = 0
minimum_completed_sessions: int = TRAINING_EXPOSURE_MIN_COMPLETED
attention_threshold: float = TRAINING_EXPOSURE_ATTENTION_THRESHOLD
dominant_persona_code: str | None = None
dominant_persona_name: str | None = None
dominant_sessions: int = 0
dominant_share: float | None = None
definition: str = (
"종료 회기의 페르소나별 최다 노출 비중을 보여 주는 투명한 훈련 노출 지표이며, "
"공정성 평가나 임상진단이 아닙니다."
)
class LearnerDashboardAchievement(BaseModel):
id: str
label: str
state: Literal["done", "available", "locked"] = "locked"
detail: str
class LearnerDashboardFeedbackItem(BaseModel):
session_id: str
persona_code: str
persona_name: str
session_no: int
stage: StageLabel
turn_seq: int
created_at: str
score: float | None = None
rapport: float | None = None
note: str
techniques: list[str] = Field(default_factory=list)
class LearnerDashboardResponse(BaseModel):
source: str = "runtime"
overview: LearnerDashboardOverview
growth: LearnerDashboardGrowth
persona_progress: list[LearnerDashboardPersonaProgress] = Field(default_factory=list)
training_exposure: LearnerDashboardTrainingExposure = Field(
default_factory=LearnerDashboardTrainingExposure
)
achievements: list[LearnerDashboardAchievement] = Field(default_factory=list)
recent_feedback: list[LearnerDashboardFeedbackItem] = Field(default_factory=list)
message: str
def dashboard_overview(
sessions: list[InProcSession],
*,
visible_review_ready: dict[str, bool],
archived_sessions: int,
) -> LearnerDashboardOverview:
return LearnerDashboardOverview(
total_sessions=len(sessions),
completed_sessions=sum(
1
for sess in sessions
if sess.ended and any(t.speaker in ("counselor", "learner") for t in sess.turns)
),
active_sessions=sum(1 for sess in sessions if not sess.ended),
review_ready_sessions=sum(1 for ready in visible_review_ready.values() if ready),
archived_sessions=archived_sessions,
learner_turns=sum(
1
for sess in sessions
for turn in learner_visible_turns(sess)
if turn.speaker == "counselor"
),
client_turns=sum(
1
for sess in sessions
for turn in learner_visible_turns(sess)
if turn.speaker == "client"
),
last_practiced_at=session_metrics.iso_datetime(
max((session_metrics.session_activity_time(sess) for sess in sessions), default=0.0)
)
if sessions
else None,
)
def _dashboard_growth_point(point: session_metrics.SessionGrowthPoint) -> LearnerDashboardGrowthPoint:
return LearnerDashboardGrowthPoint(
session_id=point.session_id,
session_no=point.session_no,
persona_code=point.persona_code,
stage=stage_label(point.stage),
started_at=point.started_at,
ended_at=point.ended_at,
score=point.score,
rapport=point.rapport,
technique_count=point.technique_count,
watch_count=point.watch_count,
)
def dashboard_growth(sessions: list[InProcSession]) -> LearnerDashboardGrowth:
metrics = session_metrics.build_learner_growth(
sessions,
learner_label=lambda _learner_id: "",
limit=1,
)
if not metrics:
return LearnerDashboardGrowth()
item = metrics[0]
points = [_dashboard_growth_point(point) for point in item.points]
return LearnerDashboardGrowth(
first_score=item.first_score,
latest_score=item.latest_score,
score_delta=item.score_delta,
avg_score=item.avg_score,
avg_rapport=item.avg_rapport,
trend=item.trend,
evaluated_sessions=sum(1 for point in points if point.score is not None),
top_techniques=item.top_techniques,
points=points,
)
def dashboard_persona_progress(
sessions: list[InProcSession],
review_ready: dict[str, bool],
learner_feedback_enabled: dict[str, bool] | None = None,
) -> list[LearnerDashboardPersonaProgress]:
grouped: dict[str, list[InProcSession]] = {}
for sess in sessions:
grouped.setdefault(sess.persona_code, []).append(sess)
rows: list[LearnerDashboardPersonaProgress] = []
for persona_code, items in grouped.items():
ordered = sorted(items, key=session_metrics.session_activity_time)
latest = ordered[-1]
metric_sessions = (
ordered
if learner_feedback_enabled is None
else [
sess
for sess in ordered
if learner_feedback_enabled.get(sess.session_id, True)
]
)
metrics = session_metrics.build_learner_growth(
metric_sessions,
learner_label=lambda _learner_id: "",
limit=1,
)
growth = metrics[0] if metrics else None
rows.append(
LearnerDashboardPersonaProgress(
persona_code=persona_code,
persona_name=latest.persona.display_name,
sessions=len(ordered),
completed_sessions=sum(
1
for sess in ordered
if sess.ended and any(t.speaker in ("counselor", "learner") for t in sess.turns)
),
active_sessions=sum(1 for sess in ordered if not sess.ended),
review_ready_sessions=sum(
1 for sess in ordered if review_ready.get(sess.session_id, False)
),
latest_at=session_metrics.iso_datetime(
session_metrics.session_activity_time(latest)
),
latest_stage=stage_label(latest.state.stage),
latest_score=growth.latest_score if growth else None,
trend=growth.trend if growth else "insufficient",
rapport_percent=_rapport_percent(latest.state.rapport_credit),
)
)
return sorted(rows, key=lambda row: row.latest_at or "", reverse=True)
def dashboard_training_exposure(
sessions: list[InProcSession],
) -> LearnerDashboardTrainingExposure:
"""Compute a transparent practice-exposure signal from ended sessions only."""
completed = [
sess
for sess in sessions
if sess.ended and any(t.speaker in ("counselor", "learner") for t in sess.turns)
]
counts = Counter(sess.persona_code for sess in completed)
if not counts:
return LearnerDashboardTrainingExposure()
dominant_code, dominant_count = sorted(
counts.items(),
key=lambda item: (-item[1], item[0]),
)[0]
dominant_session = next(
sess for sess in completed if sess.persona_code == dominant_code
)
total = len(completed)
share = dominant_count / total
if total < TRAINING_EXPOSURE_MIN_COMPLETED:
status: Literal["insufficient", "attention", "balanced"] = "insufficient"
label: Literal["판정 근거 부족", "훈련 집중 주의", "균형"] = "판정 근거 부족"
elif share >= TRAINING_EXPOSURE_ATTENTION_THRESHOLD:
status = "attention"
label = "훈련 집중 주의"
else:
status = "balanced"
label = "균형"
return LearnerDashboardTrainingExposure(
status=status,
label=label,
completed_sessions=total,
dominant_persona_code=dominant_code,
dominant_persona_name=dominant_session.persona.display_name,
dominant_sessions=dominant_count,
dominant_share=round(share, 4),
)
def _achievement_state(done: bool, available: bool) -> Literal["done", "available", "locked"]:
if done:
return "done"
if available:
return "available"
return "locked"
def dashboard_achievements(
sessions: list[InProcSession],
review_ready: dict[str, bool],
) -> list[LearnerDashboardAchievement]:
completed = sum(1 for sess in sessions if sess.ended)
active = sum(1 for sess in sessions if not sess.ended)
review_count = sum(1 for ready in review_ready.values() if ready)
by_persona = Counter(sess.persona_code for sess in sessions)
max_persona_sessions = max(by_persona.values(), default=0)
persona_coverage = len(by_persona)
return [
LearnerDashboardAchievement(
id="first_session_complete",
label="첫 회기 완료",
state=_achievement_state(completed >= 1, active >= 1),
detail="한 회기를 종료하면 리뷰와 워크시트 흐름이 열립니다.",
),
LearnerDashboardAchievement(
id="review_ready",
label="리뷰 확인 가능",
state=_achievement_state(review_count >= 1, completed >= 1),
detail=f"현재 리뷰 가능한 회기 {review_count}건입니다.",
),
LearnerDashboardAchievement(
id="persona_repeat",
label="같은 내담자 반복 연습",
state=_achievement_state(max_persona_sessions >= 3, max_persona_sessions >= 1),
detail="같은 페르소나를 반복하면 변화 추이를 더 안정적으로 볼 수 있습니다.",
),
LearnerDashboardAchievement(
id="persona_coverage",
label="여러 페르소나 경험",
state=_achievement_state(persona_coverage >= 3, persona_coverage >= 2),
detail=f"현재 {persona_coverage}개 페르소나에서 연습 기록이 있습니다.",
),
]
def dashboard_feedback(sessions: list[InProcSession]) -> list[LearnerDashboardFeedbackItem]:
items: list[LearnerDashboardFeedbackItem] = []
for item in session_metrics.recent_feedback_notes(sessions, limit=5):
score = item.get("score")
rapport = item.get("rapport")
items.append(
LearnerDashboardFeedbackItem(
session_id=str(item["session_id"]),
persona_code=str(item["persona_code"]),
persona_name=str(item["persona_name"]),
session_no=int(item["session_no"]),
stage=stage_label(item["stage"]),
turn_seq=int(item["turn_seq"]),
created_at=str(item["created_at"]),
score=float(score) if isinstance(score, (int, float)) else None,
rapport=float(rapport) if isinstance(rapport, (int, float)) else None,
note=str(item["note"]),
techniques=[str(label) for label in item.get("techniques", [])],
)
)
return items

View file

@ -0,0 +1,275 @@
"""DB 기반 세션 평가 저장소와 in-process 폴백."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Protocol
from .db import acquire, get_pool
from .deps import Principal
from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
from .session_persistence_values import (
_clean_role_masked_text,
_mask_json_text_values,
_ts,
)
_EVALUATION_CACHE: dict[str, dict[str, Any]] = {}
def _coerce_error_message(error: BaseException | str) -> str:
if isinstance(error, BaseException):
message = str(error).strip()
return message or error.__class__.__name__
return str(error).strip() or "unknown session evaluation error"
class _SessionEvaluationResult(Protocol):
scope: str
stage: str
error: str | None
def to_dict(self) -> dict[str, Any]: ...
@dataclass(slots=True)
class SessionEvaluationWrite:
session_id: str
learner_id: str
status: str
source: str
scope: str
stage: str
payload: dict[str, Any]
error: str | None = None
counselor_identity: str | None = None
client_identity: str | None = None
@classmethod
def from_result(
cls,
*,
session_id: str,
learner_id: str,
result: _SessionEvaluationResult,
source: str = "engine",
counselor_identity: str | None = None,
client_identity: str | None = None,
) -> "SessionEvaluationWrite":
return cls(
session_id=session_id,
learner_id=learner_id,
status="error" if result.error else "ready",
source=source,
scope=result.scope,
stage=result.stage,
payload=_mask_json_text_values(
result.to_dict(),
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=True,
),
error=_clean_role_masked_text(
result.error,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=True,
),
counselor_identity=counselor_identity,
client_identity=client_identity,
)
@classmethod
def from_error(
cls,
*,
session_id: str,
learner_id: str,
scope: str,
stage: str,
error: BaseException | str,
source: str = "engine",
counselor_identity: str | None = None,
client_identity: str | None = None,
) -> "SessionEvaluationWrite":
return cls(
session_id=session_id,
learner_id=learner_id,
status="error",
source=source,
scope=scope,
stage=stage,
payload={},
error=_clean_role_masked_text(
_coerce_error_message(error),
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=True,
),
counselor_identity=counselor_identity,
client_identity=client_identity,
)
def cache_record(self) -> dict[str, Any]:
return {
"status": self.status,
"source": self.source,
"scope": self.scope,
"stage": self.stage,
"payload": self.payload,
"error": self.error,
}
async def save_session_evaluation(write: SessionEvaluationWrite) -> bool:
write.payload = _mask_json_text_values(
write.payload,
counselor_identity=write.counselor_identity,
client_identity=write.client_identity,
synthetic_generated=True,
)
write.error = _clean_role_masked_text(
write.error,
counselor_identity=write.counselor_identity,
client_identity=write.client_identity,
synthetic_generated=True,
)
record = write.cache_record()
if runtime_fallback_allowed():
existing = _EVALUATION_CACHE.get(write.session_id)
if _should_replace_evaluation_record(existing, record):
_EVALUATION_CACHE[write.session_id] = record
try:
get_pool()
async with acquire(role="learner", user_id=write.learner_id) as conn:
await conn.execute(
"""
INSERT INTO app.session_evaluation (
session_id, status, source, scope, stage, payload, error,
created_at, updated_at
)
VALUES ($1::uuid, $2, $3, $4, $5, $6::jsonb, $7, now(), now())
ON CONFLICT (session_id) DO UPDATE SET
status = EXCLUDED.status,
source = EXCLUDED.source,
scope = EXCLUDED.scope,
stage = EXCLUDED.stage,
payload = EXCLUDED.payload,
error = EXCLUDED.error,
updated_at = now()
WHERE app.session_evaluation.status <> 'ready'
OR EXCLUDED.status = 'ready'
""",
write.session_id,
write.status,
write.source,
write.scope,
write.stage,
write.payload,
write.error,
)
return True
except Exception:
require_runtime_fallback_allowed("session evaluation")
return False
async def load_session_evaluation(
session_id: str,
principal: Principal,
) -> tuple[dict[str, Any] | None, bool]:
try:
get_pool()
async with acquire(
role=principal.role.value,
user_id=principal.user_id,
cohort_ids=principal.cohort_ids,
) as conn:
row = await conn.fetchrow(
"""
SELECT status, source, scope, stage, payload, error, updated_at
FROM app.session_evaluation
WHERE session_id = $1::uuid
""",
session_id,
)
if row is None:
cached = (
_EVALUATION_CACHE.get(session_id)
if runtime_fallback_allowed()
else None
)
return cached, cached is None
return {
"status": row["status"],
"source": row["source"],
"scope": row["scope"],
"stage": row["stage"],
"payload": dict(row["payload"] or {}),
"error": row["error"],
"updated_at": _ts(row["updated_at"]),
}, True
except Exception:
require_runtime_fallback_allowed("session evaluation")
return _EVALUATION_CACHE.get(session_id), False
async def list_session_evaluations(
session_ids: list[str],
principal: Principal,
) -> tuple[dict[str, dict[str, Any]], bool]:
if not session_ids:
return {}, True
try:
get_pool()
async with acquire(
role=principal.role.value,
user_id=principal.user_id,
cohort_ids=principal.cohort_ids,
) as conn:
rows = await conn.fetch(
"""
SELECT session_id::text AS session_id, status, source, scope, stage, payload, error, updated_at
FROM app.session_evaluation
WHERE session_id = ANY($1::uuid[])
""",
session_ids,
)
records = {
str(row["session_id"]): {
"status": row["status"],
"source": row["source"],
"scope": row["scope"],
"stage": row["stage"],
"payload": dict(row["payload"] or {}),
"error": row["error"],
"updated_at": _ts(row["updated_at"]),
}
for row in rows
}
cached_used = False
if runtime_fallback_allowed():
for session_id in session_ids:
if session_id not in records and session_id in _EVALUATION_CACHE:
records[session_id] = _EVALUATION_CACHE[session_id]
cached_used = True
return records, not cached_used
except Exception:
require_runtime_fallback_allowed("session evaluation")
return {
session_id: _EVALUATION_CACHE[session_id]
for session_id in session_ids
if session_id in _EVALUATION_CACHE
}, False
def _should_replace_evaluation_record(
existing: dict[str, Any] | None,
replacement: dict[str, Any],
) -> bool:
"""늦게 도착한 실패가 이미 확정된 ready 평가를 덮지 못하게 한다."""
return not (
str((existing or {}).get("status") or "") == "ready"
and str(replacement.get("status") or "") != "ready"
)

View file

@ -9,7 +9,7 @@ import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Awaitable, Callable, Iterable, Literal, Protocol
from typing import Any, Awaitable, Callable, Iterable, Literal
from .db import acquire, get_pool
from .deps import Principal
@ -21,6 +21,12 @@ from .persona_repository import (
seed_persona_id,
)
from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
from .session_persistence_values import (
_clean_masked_text,
_clean_text,
_mask_json_text_values,
_ts,
)
from .runtime_schema import (
REVIEW_SCHEMA_CONTRACT,
runtime_schema_bootstrap_required,
@ -54,7 +60,6 @@ class CaseProgressUnavailableError(RuntimeError):
"""DB 전체 집계가 필요한 사례 진행 수치를 안전하게 읽지 못했다."""
_EVALUATION_CACHE: dict[str, dict[str, Any]] = {}
_CASE_WORKSHEET_CACHE: dict[str, dict[str, Any]] = {}
_SESSION_REVIEW_STATUS_CACHE: dict[str, dict[str, Any]] = {}
_SESSION_SHARE_CACHE: dict[str, dict[str, Any]] = {}
@ -128,13 +133,6 @@ class LiveCoachCreditExhausted(RuntimeError):
"""Raised when a learner tries to use live coaching without credits."""
def _coerce_error_message(error: BaseException | str) -> str:
if isinstance(error, BaseException):
message = str(error).strip()
return message or error.__class__.__name__
return str(error).strip() or "unknown session evaluation error"
@dataclass(slots=True)
class CaseContext:
case_id: str
@ -152,103 +150,6 @@ class SessionSummaryWrite:
open_threads: list[str]
class _SessionEvaluationResult(Protocol):
scope: str
stage: str
error: str | None
def to_dict(self) -> dict[str, Any]: ...
@dataclass(slots=True)
class SessionEvaluationWrite:
session_id: str
learner_id: str
status: str
source: str
scope: str
stage: str
payload: dict[str, Any]
error: str | None = None
counselor_identity: str | None = None
client_identity: str | None = None
@classmethod
def from_result(
cls,
*,
session_id: str,
learner_id: str,
result: _SessionEvaluationResult,
source: str = "engine",
counselor_identity: str | None = None,
client_identity: str | None = None,
) -> "SessionEvaluationWrite":
return cls(
session_id=session_id,
learner_id=learner_id,
status="error" if result.error else "ready",
source=source,
scope=result.scope,
stage=result.stage,
payload=_mask_json_text_values(
result.to_dict(),
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=True,
),
error=_clean_role_masked_text(
result.error,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=True,
),
counselor_identity=counselor_identity,
client_identity=client_identity,
)
@classmethod
def from_error(
cls,
*,
session_id: str,
learner_id: str,
scope: str,
stage: str,
error: BaseException | str,
source: str = "engine",
counselor_identity: str | None = None,
client_identity: str | None = None,
) -> "SessionEvaluationWrite":
return cls(
session_id=session_id,
learner_id=learner_id,
status="error",
source=source,
scope=scope,
stage=stage,
payload={},
error=_clean_role_masked_text(
_coerce_error_message(error),
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=True,
),
counselor_identity=counselor_identity,
client_identity=client_identity,
)
def cache_record(self) -> dict[str, Any]:
return {
"status": self.status,
"source": self.source,
"scope": self.scope,
"stage": self.stage,
"payload": self.payload,
"error": self.error,
}
_JOINED_CARD_COLUMNS = (
"card_persona_id",
"card_code",
@ -272,14 +173,6 @@ _JOINED_CARD_COLUMNS = (
)
def _ts(value: datetime | None) -> float | None:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.timestamp()
def share_token_hash(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
@ -551,90 +444,6 @@ def _stage(stage: object) -> str:
return getattr(stage, "value", str(stage))
def _clean_text(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def _clean_masked_text(value: Any) -> str | None:
text = _clean_text(value)
if text is None:
return None
masked = guardrail.mask_pii(text).text_masked.strip()
return masked or None
def _clean_role_masked_text(
value: Any,
*,
counselor_identity: str | None = None,
client_identity: str | None = None,
synthetic_generated: bool = False,
) -> str | None:
text = _clean_text(value)
if text is None:
return None
masked = guardrail.mask_role_identities(
text,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=synthetic_generated,
).text_masked.strip()
return masked or None
def _mask_json_text_values(
value: Any,
*,
counselor_identity: str | None = None,
client_identity: str | None = None,
synthetic_generated: bool = False,
) -> Any:
if isinstance(value, str):
return (
_clean_role_masked_text(
value,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=synthetic_generated,
)
or ""
)
if isinstance(value, dict):
return {
key: _mask_json_text_values(
child,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=synthetic_generated,
)
for key, child in value.items()
}
if isinstance(value, list):
return [
_mask_json_text_values(
child,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=synthetic_generated,
)
for child in value
]
if isinstance(value, tuple):
return [
_mask_json_text_values(
child,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=synthetic_generated,
)
for child in value
]
return value
def _safe_float(value: Any) -> float | None:
if isinstance(value, (int, float)):
return float(value)
@ -1769,148 +1578,6 @@ async def ensure_review_tables() -> None:
return
async def save_session_evaluation(write: SessionEvaluationWrite) -> bool:
write.payload = _mask_json_text_values(
write.payload,
counselor_identity=write.counselor_identity,
client_identity=write.client_identity,
synthetic_generated=True,
)
write.error = _clean_role_masked_text(
write.error,
counselor_identity=write.counselor_identity,
client_identity=write.client_identity,
synthetic_generated=True,
)
record = write.cache_record()
if runtime_fallback_allowed():
existing = _EVALUATION_CACHE.get(write.session_id)
if _should_replace_evaluation_record(existing, record):
_EVALUATION_CACHE[write.session_id] = record
try:
get_pool()
async with acquire(role="learner", user_id=write.learner_id) as conn:
await conn.execute(
"""
INSERT INTO app.session_evaluation (
session_id, status, source, scope, stage, payload, error,
created_at, updated_at
)
VALUES ($1::uuid, $2, $3, $4, $5, $6::jsonb, $7, now(), now())
ON CONFLICT (session_id) DO UPDATE SET
status = EXCLUDED.status,
source = EXCLUDED.source,
scope = EXCLUDED.scope,
stage = EXCLUDED.stage,
payload = EXCLUDED.payload,
error = EXCLUDED.error,
updated_at = now()
WHERE app.session_evaluation.status <> 'ready'
OR EXCLUDED.status = 'ready'
""",
write.session_id,
write.status,
write.source,
write.scope,
write.stage,
write.payload,
write.error,
)
return True
except Exception:
require_runtime_fallback_allowed("session evaluation")
return False
async def load_session_evaluation(
session_id: str,
principal: Principal,
) -> tuple[dict[str, Any] | None, bool]:
try:
get_pool()
async with acquire(
role=principal.role.value,
user_id=principal.user_id,
cohort_ids=principal.cohort_ids,
) as conn:
row = await conn.fetchrow(
"""
SELECT status, source, scope, stage, payload, error, updated_at
FROM app.session_evaluation
WHERE session_id = $1::uuid
""",
session_id,
)
if row is None:
cached = (
_EVALUATION_CACHE.get(session_id)
if runtime_fallback_allowed()
else None
)
return cached, cached is None
return {
"status": row["status"],
"source": row["source"],
"scope": row["scope"],
"stage": row["stage"],
"payload": dict(row["payload"] or {}),
"error": row["error"],
"updated_at": _ts(row["updated_at"]),
}, True
except Exception:
require_runtime_fallback_allowed("session evaluation")
return _EVALUATION_CACHE.get(session_id), False
async def list_session_evaluations(
session_ids: list[str],
principal: Principal,
) -> tuple[dict[str, dict[str, Any]], bool]:
if not session_ids:
return {}, True
try:
get_pool()
async with acquire(
role=principal.role.value,
user_id=principal.user_id,
cohort_ids=principal.cohort_ids,
) as conn:
rows = await conn.fetch(
"""
SELECT session_id::text AS session_id, status, source, scope, stage, payload, error, updated_at
FROM app.session_evaluation
WHERE session_id = ANY($1::uuid[])
""",
session_ids,
)
records = {
str(row["session_id"]): {
"status": row["status"],
"source": row["source"],
"scope": row["scope"],
"stage": row["stage"],
"payload": dict(row["payload"] or {}),
"error": row["error"],
"updated_at": _ts(row["updated_at"]),
}
for row in rows
}
cached_used = False
if runtime_fallback_allowed():
for session_id in session_ids:
if session_id not in records and session_id in _EVALUATION_CACHE:
records[session_id] = _EVALUATION_CACHE[session_id]
cached_used = True
return records, not cached_used
except Exception:
require_runtime_fallback_allowed("session evaluation")
return {
session_id: _EVALUATION_CACHE[session_id]
for session_id in session_ids
if session_id in _EVALUATION_CACHE
}, False
async def _fetch_session_runtime_rows(
conn: Any,
session_ids: list[str],
@ -2825,7 +2492,14 @@ async def list_case_summaries(
SELECT
s.case_id,
count(*)::int AS total_sessions,
count(*) FILTER (WHERE s.ended_at IS NOT NULL)::int AS completed_sessions,
count(*) FILTER (
WHERE s.ended_at IS NOT NULL
AND EXISTS (
SELECT 1 FROM app.turns AS ct
WHERE ct.session_id = s.id
AND ct.speaker = 'counselor'
)
)::int AS completed_sessions,
COALESCE(
sum(
GREATEST(
@ -3702,12 +3376,3 @@ def _iso_dt(value: datetime | None) -> str:
if value is None:
return ""
return value.astimezone(timezone.utc).isoformat()
def _should_replace_evaluation_record(
existing: dict[str, Any] | None,
replacement: dict[str, Any],
) -> bool:
"""늦게 도착한 실패가 이미 확정된 ready 평가를 덮지 못하게 한다."""
return not (
str((existing or {}).get("status") or "") == "ready"
and str(replacement.get("status") or "") != "ready"
)

View file

@ -0,0 +1,100 @@
"""세션 영속성에서 공유하는 정규화·마스킹 도우미."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from .services import guardrail
def _ts(value: datetime | None) -> float | None:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.timestamp()
def _clean_text(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def _clean_masked_text(value: Any) -> str | None:
text = _clean_text(value)
if text is None:
return None
masked = guardrail.mask_pii(text).text_masked.strip()
return masked or None
def _clean_role_masked_text(
value: Any,
*,
counselor_identity: str | None = None,
client_identity: str | None = None,
synthetic_generated: bool = False,
) -> str | None:
text = _clean_text(value)
if text is None:
return None
masked = guardrail.mask_role_identities(
text,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=synthetic_generated,
).text_masked.strip()
return masked or None
def _mask_json_text_values(
value: Any,
*,
counselor_identity: str | None = None,
client_identity: str | None = None,
synthetic_generated: bool = False,
) -> Any:
if isinstance(value, str):
return (
_clean_role_masked_text(
value,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=synthetic_generated,
)
or ""
)
if isinstance(value, dict):
return {
key: _mask_json_text_values(
child,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=synthetic_generated,
)
for key, child in value.items()
}
if isinstance(value, list):
return [
_mask_json_text_values(
child,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=synthetic_generated,
)
for child in value
]
if isinstance(value, tuple):
return [
_mask_json_text_values(
child,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=synthetic_generated,
)
for child in value
]
return value

View file

@ -0,0 +1,35 @@
"""브라우저용 세션 projection이 공유하는 순수 도우미."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import cast
from .services import state_machine
from .stage_contract import StageLabel, stage_label as _normalize_stage_label
from .store import InProcSession, TurnRecord
LEARNER_VISIBLE_AI_ROLE = "counselor"
# 전 주기(라포→정리) 기준 라포 만점: 마지막 전이 임계(개입→정리)를 100으로 본다.
_FULL_CYCLE_RAPPORT = max(state_machine.STAGE_ADVANCE_RAPPORT.values())
def stage_label(stage: object) -> StageLabel:
return cast(StageLabel, _normalize_stage_label(stage))
def iso(ts: float | None) -> str | None:
if ts is None:
return None
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(timespec="seconds")
def learner_visible_turns(sess: InProcSession) -> list[TurnRecord]:
return sess.turns_visible_to(LEARNER_VISIBLE_AI_ROLE)
def _rapport_percent(credit: float) -> int:
if _FULL_CYCLE_RAPPORT <= 0:
return 0
return max(0, min(100, int(round(float(credit or 0.0) / _FULL_CYCLE_RAPPORT * 100))))

View file

@ -628,7 +628,7 @@ def learner_summary(
client_turn_count=client_turns,
started_at=iso(sess.created_at) or "",
ended_at=iso(sess.ended_at),
review_ready=review_ready,
review_ready=review_ready if learner_turns > 0 else False,
learner_feedback_enabled=(
sess.learner_feedback_enabled
if learner_feedback_enabled is None
@ -647,7 +647,9 @@ def dashboard_overview(
) -> LearnerDashboardOverview:
return LearnerDashboardOverview(
total_sessions=len(sessions),
completed_sessions=sum(1 for sess in sessions if sess.ended),
completed_sessions=sum(
1 for sess in sessions if sess.ended and any(t.speaker in ("counselor", "learner") for t in sess.turns)
),
active_sessions=sum(1 for sess in sessions if not sess.ended),
review_ready_sessions=sum(1 for ready in visible_review_ready.values() if ready),
archived_sessions=archived_sessions,
@ -742,7 +744,11 @@ def dashboard_persona_progress(
persona_code=persona_code,
persona_name=latest.persona.display_name,
sessions=len(ordered),
completed_sessions=sum(1 for sess in ordered if sess.ended),
completed_sessions=sum(
1
for sess in ordered
if sess.ended and any(t.speaker in ("counselor", "learner") for t in sess.turns)
),
active_sessions=sum(1 for sess in ordered if not sess.ended),
review_ready_sessions=sum(
1 for sess in ordered if review_ready.get(sess.session_id, False)
@ -768,7 +774,11 @@ def dashboard_training_exposure(
) -> LearnerDashboardTrainingExposure:
"""Compute a transparent practice-exposure signal from ended sessions only."""
completed = [sess for sess in sessions if sess.ended]
completed = [
sess
for sess in sessions
if sess.ended and any(t.speaker in ("counselor", "learner") for t in sess.turns)
]
counts = Counter(sess.persona_code for sess in completed)
if not counts:
return LearnerDashboardTrainingExposure()

View file

@ -0,0 +1,19 @@
"""세션 경로가 공유하는 내담자 턴 메모리 projection."""
from __future__ import annotations
from .services import memory, orchestrator
from .store import InProcSession
def build_turn_memory(
sess: InProcSession,
recall: memory.RecallContext,
kb_cues: list[str],
) -> orchestrator.TurnMemory:
return orchestrator.TurnMemory(
recall_summary=recall.recall_summary,
pinned_facts=recall.pinned_facts,
recent_turns=sess.recent_turns(visible_to="client"),
kb_behavior_cues=kb_cues,
)

View file

@ -627,6 +627,50 @@ class CalibrationTransferStoreTests(unittest.IsolatedAsyncioTestCase):
evidence_turn_ids=(),
)
async def test_append_prediction_revision_normalizes_legacy_instrument_id(self) -> None:
principal = _principal(Role.LEARNER)
learner_id = UUID(principal.user_id)
session_id = uuid4()
history_id = uuid4()
revision_id = uuid4()
conn = AsyncMock()
conn.fetchrow.side_effect = [
{"id": session_id, "learner_id": learner_id, "case_id": uuid4()},
None,
None,
{"prediction_revision_id": str(revision_id), "revision_no": 1},
]
@asynccontextmanager
async def fake_acquire(**_kwargs):
yield conn
with patch.object(calibration_transfer_store.db, "acquire", fake_acquire):
res = await calibration_transfer_store.append_prediction_revision(
principal=principal,
submission_id=uuid4(),
prediction_revision_id=revision_id,
history_id=history_id,
session_id=session_id,
competency_id="competency.empathic-check",
practice_block_id="oas-g5-block-one",
scenario_variant_id="variant-1",
phrase_family_id="phrase-1",
revision_no=1,
supersedes_prediction_revision_id=None,
predicted_success_probability=0.7,
confidence=0.8,
recorded_sequence=1,
revision_reason="호환성 검증",
instrument_id="vignette.calibration-self-prediction",
instrument_version="1.0.0",
evidence_turn_ids=(),
)
self.assertEqual(res["revision_no"], 1)
# Verify normalized instrument_id "calibration-mirror-g5" was passed to DB
call_args = conn.fetchrow.call_args_list[-1][0]
self.assertIn("calibration-mirror-g5", call_args)
async def test_evidence_refs_are_uuid_only(self) -> None:
self.assertEqual(
calibration_transfer_store._uuid_evidence(

View file

@ -0,0 +1,316 @@
from __future__ import annotations
import unittest
from unittest.mock import AsyncMock, patch
from uuid import UUID, uuid4
import asyncpg
from fastapi import FastAPI
from fastapi.testclient import TestClient
from .deps import Principal, Role
from .routes import calibration_transfer
from .services import calibration_transfer_store
from .services.calibration_transfer_store import (
CalibrationTransferConflictError,
CalibrationTransferStateError,
)
def _principal(role: Role = Role.LEARNER, user_id: str | None = None) -> Principal:
return Principal(
user_id=user_id or str(uuid4()),
role=role,
cohort_ids=["g5-cohort"],
)
class CalibrationValidationTDDTests(unittest.IsolatedAsyncioTestCase):
"""Multi-angle TDD tests for OAS prediction revisions and lock boundaries."""
async def test_append_prediction_revision_rejects_out_of_bounds_probability(self) -> None:
"""predicted_success_probability must be strictly bounded within [0.0, 1.0]."""
principal = _principal()
submission_id = uuid4()
history_id = uuid4()
session_id = uuid4()
# > 1.0 should fail
with self.assertRaises(CalibrationTransferStateError) as ctx:
await calibration_transfer_store.append_prediction_revision(
principal=principal,
submission_id=submission_id,
prediction_revision_id=uuid4(),
history_id=history_id,
session_id=session_id,
competency_id="competency.empathic_reflection",
practice_block_id="oas-g5-block-1",
scenario_variant_id="variant-1",
phrase_family_id="family-1",
revision_no=1,
supersedes_prediction_revision_id=None,
predicted_success_probability=1.25,
confidence=0.8,
recorded_sequence=1,
revision_reason="Valid reason",
instrument_id="calibration-mirror-g5",
instrument_version="1.0.0",
)
self.assertIn("predicted_success_probability", str(ctx.exception))
# < 0.0 should fail
with self.assertRaises(CalibrationTransferStateError) as ctx:
await calibration_transfer_store.append_prediction_revision(
principal=principal,
submission_id=submission_id,
prediction_revision_id=uuid4(),
history_id=history_id,
session_id=session_id,
competency_id="competency.empathic_reflection",
practice_block_id="oas-g5-block-1",
scenario_variant_id="variant-1",
phrase_family_id="family-1",
revision_no=1,
supersedes_prediction_revision_id=None,
predicted_success_probability=-0.05,
confidence=0.8,
recorded_sequence=1,
revision_reason="Valid reason",
instrument_id="calibration-mirror-g5",
instrument_version="1.0.0",
)
self.assertIn("predicted_success_probability", str(ctx.exception))
async def test_append_prediction_revision_rejects_out_of_bounds_confidence(self) -> None:
"""confidence must be strictly bounded within [0.0, 1.0]."""
principal = _principal()
submission_id = uuid4()
history_id = uuid4()
session_id = uuid4()
with self.assertRaises(CalibrationTransferStateError) as ctx:
await calibration_transfer_store.append_prediction_revision(
principal=principal,
submission_id=submission_id,
prediction_revision_id=uuid4(),
history_id=history_id,
session_id=session_id,
competency_id="competency.empathic_reflection",
practice_block_id="oas-g5-block-1",
scenario_variant_id="variant-1",
phrase_family_id="family-1",
revision_no=1,
supersedes_prediction_revision_id=None,
predicted_success_probability=0.7,
confidence=1.5,
recorded_sequence=1,
revision_reason="Valid reason",
instrument_id="calibration-mirror-g5",
instrument_version="1.0.0",
)
self.assertIn("confidence", str(ctx.exception))
async def test_append_prediction_revision_rejects_empty_or_whitespace_reason(self) -> None:
"""Blank or whitespace-only revision_reason must be rejected."""
principal = _principal()
for blank in ("", " ", "\t\n"):
with self.assertRaises(CalibrationTransferStateError) as ctx:
await calibration_transfer_store.append_prediction_revision(
principal=principal,
submission_id=uuid4(),
prediction_revision_id=uuid4(),
history_id=uuid4(),
session_id=uuid4(),
competency_id="competency.empathic_reflection",
practice_block_id="oas-g5-block-1",
scenario_variant_id="variant-1",
phrase_family_id="family-1",
revision_no=1,
supersedes_prediction_revision_id=None,
predicted_success_probability=0.7,
confidence=0.8,
recorded_sequence=1,
revision_reason=blank,
instrument_id="calibration-mirror-g5",
instrument_version="1.0.0",
)
self.assertIn("revision_reason", str(ctx.exception))
async def test_append_prediction_revision_blocks_locked_history(self) -> None:
"""A locked prediction history must reject subsequent revision appends."""
principal = _principal()
history_id = uuid4()
session_id = uuid4()
# Mock db connection to simulate trigger-level lock rejection on history
mock_conn = AsyncMock()
mock_conn.fetchrow.side_effect = [
# 1. _visible_session
{"id": session_id, "session_id": session_id, "learner_id": principal.user_id},
# 2. _existing_by_submission
None,
# 3. SELECT * FROM app.calibration_prediction_history WHERE history_id = $1
{
"history_id": history_id,
"session_id": session_id,
"learner_id": principal.user_id,
"competency_id": "competency.empathic_reflection",
"practice_block_id": "oas-g5-block-1",
"scenario_variant_id": "variant-1",
"phrase_family_id": "family-1",
},
# 4. INSERT INTO app.calibration_prediction_revision (trigger raises lock error)
asyncpg.ObjectNotInPrerequisiteStateError(
"self-prediction cannot be revised after lock or external reveal"
),
]
with patch("app.services.calibration_transfer_store.db.acquire") as mock_acquire:
mock_acquire.return_value.__aenter__.return_value = mock_conn
with self.assertRaises(CalibrationTransferStateError) as ctx:
await calibration_transfer_store.append_prediction_revision(
principal=principal,
submission_id=uuid4(),
prediction_revision_id=uuid4(),
history_id=history_id,
session_id=session_id,
competency_id="competency.empathic_reflection",
practice_block_id="oas-g5-block-1",
scenario_variant_id="variant-1",
phrase_family_id="family-1",
revision_no=2,
supersedes_prediction_revision_id=uuid4(),
predicted_success_probability=0.7,
confidence=0.8,
recorded_sequence=2,
revision_reason="Trying to revise locked prediction",
instrument_id="calibration-mirror-g5",
instrument_version="1.0.0",
)
self.assertIn("locked", str(ctx.exception).lower())
async def test_append_prediction_lock_requires_learner_role(self) -> None:
"""Only learners can append prediction locks."""
principal = _principal(role=Role.TEACHER)
with self.assertRaises(CalibrationTransferStateError) as ctx:
await calibration_transfer_store.append_prediction_lock(
principal=principal,
submission_id=uuid4(),
lock_id=uuid4(),
history_id=uuid4(),
prediction_revision_id=uuid4(),
locked_sequence=1,
)
self.assertIn("learner role", str(ctx.exception).lower())
async def test_append_prediction_lock_idempotent_replay(self) -> None:
"""Replaying identical lock submission returns idempotent_replay=True."""
principal = _principal(role=Role.LEARNER)
submission_id = uuid4()
history_id = uuid4()
lock_id = uuid4()
revision_id = uuid4()
payload = {
"lock_id": str(lock_id),
"history_id": str(history_id),
"prediction_revision_id": str(revision_id),
"locked_sequence": 1,
"learner_id": principal.user_id,
}
content_hash = calibration_transfer_store._canonical_hash(payload)
mock_conn = AsyncMock()
mock_conn.fetchrow.side_effect = [
# 1. history lookup
{
"history_id": history_id,
"session_id": uuid4(),
"learner_id": principal.user_id,
},
# 2. _existing_by_submission finds existing lock with identical content_hash
{"lock_id": lock_id, "content_hash": content_hash},
]
with patch("app.services.calibration_transfer_store.db.acquire") as mock_acquire:
mock_acquire.return_value.__aenter__.return_value = mock_conn
result = await calibration_transfer_store.append_prediction_lock(
principal=principal,
submission_id=submission_id,
lock_id=lock_id,
history_id=history_id,
prediction_revision_id=revision_id,
locked_sequence=1,
)
self.assertTrue(result.get("idempotent_replay"))
self.assertEqual(result.get("lock_id"), lock_id)
async def test_append_prediction_lock_replayed_with_different_content_raises_conflict(self) -> None:
"""Replaying lock submission with changed payload raises conflict error."""
principal = _principal(role=Role.LEARNER)
submission_id = uuid4()
history_id = uuid4()
lock_id = uuid4()
revision_id = uuid4()
mock_conn = AsyncMock()
mock_conn.fetchrow.side_effect = [
# 1. history lookup
{
"history_id": history_id,
"session_id": uuid4(),
"learner_id": principal.user_id,
},
# 2. _existing_by_submission finds existing lock but different content_hash
{"lock_id": lock_id, "content_hash": "different-hash-value"},
]
with patch("app.services.calibration_transfer_store.db.acquire") as mock_acquire:
mock_acquire.return_value.__aenter__.return_value = mock_conn
with self.assertRaises(CalibrationTransferConflictError) as ctx:
await calibration_transfer_store.append_prediction_lock(
principal=principal,
submission_id=submission_id,
lock_id=lock_id,
history_id=history_id,
prediction_revision_id=revision_id,
locked_sequence=1,
)
self.assertIn("different content", str(ctx.exception))
async def test_append_prediction_lock_conflict_raises_conflict_error(self) -> None:
"""A conflicting concurrent lock raises CalibrationTransferConflictError."""
principal = _principal(role=Role.LEARNER)
submission_id = uuid4()
history_id = uuid4()
lock_id = uuid4()
revision_id = uuid4()
mock_conn = AsyncMock()
mock_conn.fetchrow.side_effect = [
# 1. history lookup
{
"history_id": history_id,
"session_id": uuid4(),
"learner_id": principal.user_id,
},
# 2. _existing_by_submission returns None (new submission)
None,
# 3. INSERT raises UniqueViolationError
asyncpg.UniqueViolationError("duplicate key value violates unique constraint"),
]
with patch("app.services.calibration_transfer_store.db.acquire") as mock_acquire:
mock_acquire.return_value.__aenter__.return_value = mock_conn
with self.assertRaises(CalibrationTransferConflictError):
await calibration_transfer_store.append_prediction_lock(
principal=principal,
submission_id=submission_id,
lock_id=lock_id,
history_id=history_id,
prediction_revision_id=revision_id,
locked_sequence=1,
)

View file

@ -0,0 +1,215 @@
from __future__ import annotations
import unittest
from unittest.mock import AsyncMock, patch
from uuid import uuid4
from app.deps import Principal, Role
from app.services import session_metrics, state_machine
from app.services.persona import P1, P2
from app.session_dashboard_projection import (
dashboard_overview,
dashboard_persona_progress,
dashboard_training_exposure,
)
from app.store import InProcSession, TurnRecord
def _make_session(
session_id: str,
session_no: int,
learner_id: str,
persona_code: str = "P1",
ended: bool = True,
turns: list[TurnRecord] | None = None,
) -> InProcSession:
persona = {P1.code: P1, P2.code: P2}.get(persona_code, P1)
state = state_machine.init_state(params=persona.openness_params())
state.stage = state_machine.Stage.EXPLORE
sess = InProcSession(
session_id=session_id,
case_id=session_id,
learner_id=learner_id,
persona_code=persona.code,
theory_mode="humanistic",
persona=persona,
state=state,
session_no=session_no,
created_at=1000.0 + session_no * 10.0,
ended_at=1000.0 + session_no * 10.0 + 600 if ended else None,
ended=ended,
turns=[],
)
if turns is not None:
sess.turns = turns
else:
sess.turns = [
TurnRecord(
turn_seq=1,
speaker="counselor",
stage=state.stage.value,
text="어떤 점이 힘드신가요?",
text_masked="어떤 점이 힘드신가요?",
evaluation={
"appropriateness": "pos",
"appropriateness_note": "공감적 반응",
"rapport_signal": 0.6,
"techniques": [{"label": "reflection"}],
},
created_at=1000.0 + session_no * 10.0 + 5.0,
)
]
return sess
class DashboardProjectionTDDTests(unittest.TestCase):
"""Multi-angle TDD tests for metrics projection invariants (REQ-010)."""
def test_learner_growth_metrics_excludes_zero_turn_from_ended_sessions(self) -> None:
"""LearnerGrowthMetrics.ended_sessions must exclude zero-turn sessions."""
learner_id = "00000000-0000-0000-0000-000000000111"
# Session 1: completed with turns
sess1 = _make_session("sess-1", 1, learner_id, ended=True)
# Session 2: zero-turn ended session
sess2 = _make_session("sess-2", 2, learner_id, ended=True, turns=[])
# Session 3: active session
sess3 = _make_session("sess-3", 3, learner_id, ended=False)
growth_list = session_metrics.build_learner_growth(
[sess1, sess2, sess3],
learner_label=lambda _uid: "홍길동",
)
self.assertEqual(len(growth_list), 1)
growth = growth_list[0]
self.assertEqual(growth.sessions, 3)
# Invariant: ended_sessions must equal completed sessions with dialogue (1), not 2
self.assertEqual(
growth.ended_sessions,
1,
"ended_sessions in LearnerGrowthMetrics must not count 0-turn sessions",
)
def test_training_exposure_multi_persona_isolation(self) -> None:
"""Zero-turn sessions must not inflate exposure count across multiple personas."""
learner_id = "00000000-0000-0000-0000-000000000111"
# P1: 1 completed session, 1 zero-turn ended session
p1_completed = _make_session("sess-p1-1", 1, learner_id, persona_code="P1", ended=True)
p1_zero = _make_session("sess-p1-2", 2, learner_id, persona_code="P1", ended=True, turns=[])
# P2: 1 completed session
p2_completed = _make_session("sess-p2-1", 3, learner_id, persona_code="P2", ended=True)
exposure = dashboard_training_exposure([p1_completed, p1_zero, p2_completed])
self.assertEqual(exposure.completed_sessions, 2)
# Persona progress table check
progress_rows = dashboard_persona_progress(
[p1_completed, p1_zero, p2_completed],
review_ready={},
)
p1_row = next(r for r in progress_rows if r.persona_code == "P1")
p2_row = next(r for r in progress_rows if r.persona_code == "P2")
self.assertEqual(p1_row.sessions, 2)
self.assertEqual(p1_row.completed_sessions, 1)
self.assertEqual(p2_row.sessions, 1)
self.assertEqual(p2_row.completed_sessions, 1)
class TeacherDashboardProjectionTDDTests(unittest.IsolatedAsyncioTestCase):
"""Teacher dashboard projection alignment for zero-turn sessions."""
async def test_teacher_dashboard_excludes_zero_turn_from_ended_sessions(self) -> None:
"""Teacher dashboard top-level ended_sessions must exclude zero-turn ended sessions."""
from app.routes import teacher
principal = Principal(
user_id="teacher-1",
role=Role.TEACHER,
cohort_ids=["cohort-1"],
)
learner_id = "learner-1"
sess_normal = _make_session("sess-1", 1, learner_id, ended=True)
sess_zero = _make_session("sess-2", 2, learner_id, ended=True, turns=[])
with (
patch.object(
teacher.session_persistence,
"list_all_sessions",
AsyncMock(return_value=([sess_normal, sess_zero], True)),
),
patch.object(
teacher.session_persistence,
"list_safety_alerts",
AsyncMock(return_value=([], True)),
),
patch.object(
teacher.session_persistence,
"list_session_review_statuses",
AsyncMock(return_value=({}, True)),
),
patch.object(
teacher.session_evaluation_repository,
"list_session_evaluations",
AsyncMock(return_value=({}, True)),
),
):
response = await teacher.teacher_dashboard(principal)
self.assertEqual(
response.ended_sessions,
1,
"Teacher dashboard ended_sessions must exclude 0-turn ended sessions",
)
self.assertEqual(
response.learner_growth[0].ended_sessions,
1,
"Learner growth in teacher dashboard must exclude 0-turn ended sessions",
)
async def test_teacher_learner_analysis_excludes_zero_turn_from_ended_sessions(self) -> None:
"""Teacher learner analysis ended_sessions must exclude zero-turn ended sessions."""
from app.routes import teacher
principal = Principal(
user_id="teacher-1",
role=Role.TEACHER,
cohort_ids=["cohort-1"],
)
learner_id = "learner-1"
sess_normal = _make_session("sess-1", 1, learner_id, ended=True)
sess_zero = _make_session("sess-2", 2, learner_id, ended=True, turns=[])
with (
patch.object(
teacher.session_persistence,
"list_all_sessions",
AsyncMock(return_value=([sess_normal, sess_zero], True)),
),
patch.object(
teacher.session_persistence,
"list_session_review_statuses",
AsyncMock(return_value=({}, True)),
),
patch.object(
teacher.session_evaluation_repository,
"list_session_evaluations",
AsyncMock(return_value=({}, True)),
),
):
response = await teacher.learner_analysis(learner_id, principal)
self.assertEqual(
response.ended_sessions,
1,
"Teacher learner analysis ended_sessions must exclude 0-turn ended sessions",
)
self.assertEqual(
response.summary.ended_sessions,
1,
"Teacher learner analysis summary.ended_sessions must exclude 0-turn ended sessions",
)

View file

@ -32,7 +32,7 @@ class ReevaluateSessionRouteTest(unittest.IsolatedAsyncioTestCase):
patch.object(eval_routes, "_load_session_or_404", AsyncMock(return_value=sess)),
patch.object(eval_routes.evaluator, "evaluate_session", AsyncMock(return_value=result)),
patch.object(
eval_routes.session_persistence,
eval_routes.session_evaluation_repository,
"save_session_evaluation",
AsyncMock(return_value=True),
) as save_evaluation,
@ -68,7 +68,7 @@ class ReevaluateSessionRouteTest(unittest.IsolatedAsyncioTestCase):
patch.object(eval_routes, "_load_session_or_404", AsyncMock(return_value=sess)),
patch.object(eval_routes.evaluator, "evaluate_session", AsyncMock(return_value=result)),
patch.object(
eval_routes.session_persistence,
eval_routes.session_evaluation_repository,
"save_session_evaluation",
AsyncMock(return_value=False),
),
@ -95,7 +95,7 @@ class ReevaluateSessionRouteTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(side_effect=eval_routes.EngineError("provider body: [NAME]")),
),
patch.object(
eval_routes.session_persistence,
eval_routes.session_evaluation_repository,
"save_session_evaluation",
AsyncMock(return_value=True),
) as save_evaluation,
@ -135,7 +135,7 @@ class ReevaluateSessionRouteTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=result),
) as evaluate_session,
patch.object(
eval_routes.session_persistence,
eval_routes.session_evaluation_repository,
"save_session_evaluation",
AsyncMock(return_value=True),
),
@ -167,7 +167,7 @@ class ReevaluateSessionRouteTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=result),
) as evaluate_session,
patch.object(
eval_routes.session_persistence,
eval_routes.session_evaluation_repository,
"save_session_evaluation",
AsyncMock(return_value=True),
),
@ -343,7 +343,7 @@ class ReevaluateSessionRouteTest(unittest.IsolatedAsyncioTestCase):
with (
patch.object(eval_routes, "_load_session_or_404", AsyncMock(return_value=sess)),
patch.object(
eval_routes.session_persistence,
eval_routes.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=(record, True)),
),

View file

@ -8,7 +8,7 @@ import unittest
from unittest.mock import patch
from .deps import Principal, Role
from . import session_persistence
from . import session_evaluation_repository, session_persistence
from .routes import sessions
from .services import evaluator, guardrail, session_metrics
from .services import persona as persona_service
@ -256,7 +256,7 @@ class EvaluationPersistenceMappingTest(unittest.IsolatedAsyncioTestCase):
turns_evaluated=2,
)
write = session_persistence.SessionEvaluationWrite.from_result(
write = session_evaluation_repository.SessionEvaluationWrite.from_result(
session_id="session-1",
learner_id="learner-1",
result=result,
@ -306,7 +306,7 @@ class EvaluationPersistenceMappingTest(unittest.IsolatedAsyncioTestCase):
error="보라별 평가 경고",
)
write = session_persistence.SessionEvaluationWrite.from_result(
write = session_evaluation_repository.SessionEvaluationWrite.from_result(
session_id="session-privacy",
learner_id="learner-privacy",
result=result,
@ -330,7 +330,7 @@ class EvaluationPersistenceMappingTest(unittest.IsolatedAsyncioTestCase):
supervisor_rationale="윤찬과 서연의 상호작용을 확인했다.",
)
write = session_persistence.SessionEvaluationWrite.from_result(
write = session_evaluation_repository.SessionEvaluationWrite.from_result(
session_id="session-role-privacy",
learner_id="learner-role-privacy",
result=result,
@ -345,7 +345,7 @@ class EvaluationPersistenceMappingTest(unittest.IsolatedAsyncioTestCase):
self.assertIn("[CLIENT]", blob)
def test_session_evaluation_write_from_error_preserves_fallback_shape(self) -> None:
write = session_persistence.SessionEvaluationWrite.from_error(
write = session_evaluation_repository.SessionEvaluationWrite.from_error(
session_id="session-1",
learner_id="learner-1",
scope="session_end",
@ -361,7 +361,7 @@ class EvaluationPersistenceMappingTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(write.error, "engine timeout")
def test_session_evaluation_write_from_error_names_empty_exception(self) -> None:
write = session_persistence.SessionEvaluationWrite.from_error(
write = session_evaluation_repository.SessionEvaluationWrite.from_error(
session_id="session-1",
learner_id="learner-1",
scope="session_end",
@ -381,7 +381,7 @@ class EvaluationPersistenceMappingTest(unittest.IsolatedAsyncioTestCase):
guardrail.set_ko_pii_recognizer(FakeKoRecognizer())
self.addCleanup(guardrail.set_ko_pii_recognizer, None)
write = session_persistence.SessionEvaluationWrite.from_error(
write = session_evaluation_repository.SessionEvaluationWrite.from_error(
session_id="session-1",
learner_id="learner-1",
scope="session_end",
@ -405,7 +405,7 @@ class EvaluationPersistenceMappingTest(unittest.IsolatedAsyncioTestCase):
guardrail.set_ko_pii_recognizer(FakeKoRecognizer())
self.addCleanup(guardrail.set_ko_pii_recognizer, None)
conn = FakeEvaluationConn()
write = session_persistence.SessionEvaluationWrite(
write = session_evaluation_repository.SessionEvaluationWrite(
session_id="11111111-1111-1111-1111-111111111111",
learner_id="22222222-2222-2222-2222-222222222222",
status="ready",
@ -417,10 +417,10 @@ class EvaluationPersistenceMappingTest(unittest.IsolatedAsyncioTestCase):
)
with (
patch.object(session_persistence, "get_pool", return_value=object()),
patch.object(session_persistence, "acquire", return_value=FakeAcquire(conn)),
patch.object(session_evaluation_repository, "get_pool", return_value=object()),
patch.object(session_evaluation_repository, "acquire", return_value=FakeAcquire(conn)),
):
saved = await session_persistence.save_session_evaluation(write)
saved = await session_evaluation_repository.save_session_evaluation(write)
self.assertTrue(saved)
_, args = conn.executed[0]
@ -439,20 +439,20 @@ class EvaluationPersistenceMappingTest(unittest.IsolatedAsyncioTestCase):
error = {"status": "error", "error": "late timeout"}
self.assertFalse(
session_persistence._should_replace_evaluation_record(ready, error)
session_evaluation_repository._should_replace_evaluation_record(ready, error)
)
self.assertTrue(
session_persistence._should_replace_evaluation_record(error, ready)
session_evaluation_repository._should_replace_evaluation_record(error, ready)
)
self.assertTrue(
session_persistence._should_replace_evaluation_record(ready, ready)
session_evaluation_repository._should_replace_evaluation_record(ready, ready)
)
async def test_fallback_cache_keeps_ready_result_when_late_error_arrives(self) -> None:
session_id = "11111111-1111-1111-1111-111111111111"
session_persistence._EVALUATION_CACHE[session_id] = {"status": "ready"}
self.addCleanup(session_persistence._EVALUATION_CACHE.pop, session_id, None)
write = session_persistence.SessionEvaluationWrite.from_error(
session_evaluation_repository._EVALUATION_CACHE[session_id] = {"status": "ready"}
self.addCleanup(session_evaluation_repository._EVALUATION_CACHE.pop, session_id, None)
write = session_evaluation_repository.SessionEvaluationWrite.from_error(
session_id=session_id,
learner_id="22222222-2222-2222-2222-222222222222",
scope="session_end",
@ -461,14 +461,14 @@ class EvaluationPersistenceMappingTest(unittest.IsolatedAsyncioTestCase):
)
with (
patch.object(session_persistence, "runtime_fallback_allowed", return_value=True),
patch.object(session_persistence, "get_pool", side_effect=RuntimeError("offline")),
patch.object(session_persistence, "require_runtime_fallback_allowed"),
patch.object(session_evaluation_repository, "runtime_fallback_allowed", return_value=True),
patch.object(session_evaluation_repository, "get_pool", side_effect=RuntimeError("offline")),
patch.object(session_evaluation_repository, "require_runtime_fallback_allowed"),
):
saved = await session_persistence.save_session_evaluation(write)
saved = await session_evaluation_repository.save_session_evaluation(write)
self.assertFalse(saved)
self.assertEqual(session_persistence._EVALUATION_CACHE[session_id]["status"], "ready")
self.assertEqual(session_evaluation_repository._EVALUATION_CACHE[session_id]["status"], "ready")
def test_rebuild_turn_evaluation_restores_review_shape(self) -> None:
rebuilt = session_persistence._rebuild_turn_evaluations(

View file

@ -288,7 +288,7 @@ class FeedbackPolicyRouteTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=(sess, principal)),
),
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(),
) as evaluation_read,
@ -316,7 +316,7 @@ class FeedbackPolicyRouteTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=sess),
),
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(),
) as evaluation_read,
@ -583,7 +583,7 @@ class FeedbackPolicyRouteTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=([sess], True)),
),
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=({"status": "ready"}, True)),
),

View file

@ -6,7 +6,7 @@ import unittest
from unittest.mock import AsyncMock, patch
from .deps import Principal, Role
from . import session_read_model
from . import session_dashboard_projection
from .routes import sessions
from .services import state_machine
from .services.persona import P1, P2, P3
@ -88,14 +88,14 @@ def _exposure_sessions(persona_codes: list[str]) -> list[InProcSession]:
class TrainingExposureMetricTest(unittest.TestCase):
def test_zero_completed_sessions_is_insufficient(self) -> None:
metric = session_read_model.dashboard_training_exposure([])
metric = session_dashboard_projection.dashboard_training_exposure([])
self.assertEqual(metric.label, "판정 근거 부족")
self.assertEqual(metric.completed_sessions, 0)
self.assertIsNone(metric.dominant_share)
def test_one_completed_session_is_insufficient(self) -> None:
metric = session_read_model.dashboard_training_exposure(
metric = session_dashboard_projection.dashboard_training_exposure(
_exposure_sessions([P1.code])
)
@ -103,7 +103,7 @@ class TrainingExposureMetricTest(unittest.TestCase):
self.assertEqual(metric.dominant_share, 1.0)
def test_four_sessions_with_broad_exposure_is_balanced(self) -> None:
metric = session_read_model.dashboard_training_exposure(
metric = session_dashboard_projection.dashboard_training_exposure(
_exposure_sessions([P1.code, P1.code, P2.code, P3.code])
)
@ -111,7 +111,7 @@ class TrainingExposureMetricTest(unittest.TestCase):
self.assertEqual(metric.dominant_share, 0.5)
def test_dominant_share_at_seventy_five_percent_is_attention(self) -> None:
metric = session_read_model.dashboard_training_exposure(
metric = session_dashboard_projection.dashboard_training_exposure(
_exposure_sessions([P1.code, P1.code, P1.code, P2.code])
)
@ -120,7 +120,7 @@ class TrainingExposureMetricTest(unittest.TestCase):
self.assertEqual(metric.dominant_share, 0.75)
def test_tied_dominant_persona_is_deterministic_and_balanced(self) -> None:
metric = session_read_model.dashboard_training_exposure(
metric = session_dashboard_projection.dashboard_training_exposure(
_exposure_sessions([P2.code, P1.code, P2.code, P1.code])
)
@ -176,7 +176,7 @@ class LearnerDashboardTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=(owned_sessions, True)),
) as list_recent_sessions,
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(side_effect=load_review),
),
@ -249,7 +249,7 @@ class LearnerDashboardTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=([good_session, failed_session], True)),
),
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=({"status": "ready"}, True)),
),
@ -386,7 +386,7 @@ class LearnerDashboardTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=(archive_records, True)),
) as list_session_archives,
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(side_effect=load_review),
),
@ -438,7 +438,7 @@ class LearnerDashboardTest(unittest.IsolatedAsyncioTestCase):
),
) as set_session_archived,
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=({"status": "ready"}, True)),
),
@ -486,7 +486,7 @@ class LearnerDashboardTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=(None, True)),
) as set_session_archived,
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=(None, True)),
),
@ -510,6 +510,46 @@ class LearnerDashboardTest(unittest.IsolatedAsyncioTestCase):
self.assertFalse(response.session.archived)
self.assertIsNone(response.session.archived_at)
def test_zero_turn_ended_sessions_not_counted_in_completed_sessions(self) -> None:
"""REQ-010: Zero-turn sessions ended without counselor dialogue are not counted in completed_sessions."""
# 1 session with turns (completed)
normal_session = _session(
session_id="00000000-0000-0000-0000-00000000b991",
session_no=1,
persona_code=P1.code,
score="pos",
rapport=0.5,
technique="reflection",
created_at=1_000.0,
)
normal_session.ended = True
# 1 zero-turn session created and closed immediately
zero_turn_session = _session(
session_id="00000000-0000-0000-0000-00000000b992",
session_no=2,
persona_code=P1.code,
score="neutral",
rapport=0.0,
technique="none",
created_at=1_100.0,
)
zero_turn_session.turns = [] # No turns
zero_turn_session.ended = True
from app.session_dashboard_projection import dashboard_overview, dashboard_training_exposure
overview = dashboard_overview(
[normal_session, zero_turn_session],
visible_review_ready={normal_session.session_id: True, zero_turn_session.session_id: False},
archived_sessions=0,
)
self.assertEqual(overview.total_sessions, 2)
self.assertEqual(overview.completed_sessions, 1) # Only 1 completed, not 2!
exposure = dashboard_training_exposure([normal_session, zero_turn_session])
self.assertEqual(exposure.completed_sessions, 1)
if __name__ == "__main__":
unittest.main()

View file

@ -168,7 +168,7 @@ class NotificationTriggerTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(side_effect=RuntimeError("engine offline")),
),
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"save_session_evaluation",
AsyncMock(return_value=True),
) as save_evaluation,
@ -194,7 +194,7 @@ class NotificationTriggerTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(side_effect=asyncio.TimeoutError()),
),
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"save_session_evaluation",
AsyncMock(return_value=True),
) as save_evaluation,
@ -227,7 +227,7 @@ class NotificationTriggerTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=result),
) as evaluate_session,
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"save_session_evaluation",
AsyncMock(return_value=True),
),

View file

@ -229,7 +229,7 @@ class LearnerSessionIdorTest(unittest.IsolatedAsyncioTestCase):
),
patch.object(sessions.turn_runtime, "runtime_fallback_allowed", return_value=True),
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=(None, False)),
) as load_session_evaluation,
@ -383,7 +383,7 @@ class LearnerSessionIdorTest(unittest.IsolatedAsyncioTestCase):
),
patch.object(sessions.turn_runtime, "runtime_fallback_allowed", return_value=True),
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=(evaluation_record, True)),
),
@ -432,7 +432,7 @@ class LearnerSessionIdorTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=sess),
) as load_session,
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=(None, False)),
),

View file

@ -212,7 +212,7 @@ class SessionLearningProducerTests(unittest.IsolatedAsyncioTestCase):
patch.object(
sessions.evaluator, "evaluate_session", AsyncMock(return_value=ready)
),
patch.object(sessions.session_persistence, "save_session_evaluation", save),
patch.object(sessions.session_evaluation_repository, "save_session_evaluation", save),
patch.object(
sessions.session_learning_producer,
"produce_session_learning_artifacts",

View file

@ -5,7 +5,8 @@ from __future__ import annotations
import unittest
from datetime import datetime, timezone
from .session_read_model import iso, session_calendar_date
from .session_projection import iso
from .session_read_model import session_calendar_date
from .services.session_metrics import iso_datetime

View file

@ -11,7 +11,7 @@ from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from .config import settings
from . import session_persistence, turn_runtime
from . import session_evaluation_repository, session_persistence, turn_runtime
from .contracts.engine_gateway import EngineGatewaySseLineDecoder
from .deps import Principal, Role
from .engine_client import EngineError, GenerateResponse
@ -214,7 +214,7 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
durable_evaluations: dict[str, dict[str, object]] = {}
async def save_evaluation(
write: session_persistence.SessionEvaluationWrite,
write: session_evaluation_repository.SessionEvaluationWrite,
) -> bool:
durable_evaluations[write.session_id] = write.cache_record()
return True
@ -226,7 +226,7 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(side_effect=asyncio.TimeoutError),
),
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"save_session_evaluation",
save_evaluation,
),
@ -375,7 +375,7 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=old_session),
),
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=(old_evaluation, True)),
),
@ -415,7 +415,7 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(side_effect=EngineError("controlled evaluator 500")),
),
patch.object(
eval_routes.session_persistence,
eval_routes.session_evaluation_repository,
"save_session_evaluation",
retry_save,
),
@ -2574,7 +2574,7 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=(saved_payload, True)),
),
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=(None, False)),
),
@ -2681,7 +2681,7 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=(None, False)),
),
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=(raw_record, True)),
),
@ -2721,7 +2721,7 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
),
]
)
error_record = session_persistence.SessionEvaluationWrite.from_error(
error_record = session_evaluation_repository.SessionEvaluationWrite.from_error(
session_id=sess.session_id,
learner_id=principal.user_id,
scope="session_end",
@ -2741,7 +2741,7 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=(None, False)),
),
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=(error_record, True)),
),

View file

@ -182,7 +182,7 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=({}, True)),
),
patch.object(
teacher.session_persistence,
teacher.session_evaluation_repository,
"list_session_evaluations",
AsyncMock(return_value=({}, True)),
),
@ -238,7 +238,7 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=({}, True)),
),
patch.object(
teacher.session_persistence,
teacher.session_evaluation_repository,
"list_session_evaluations",
AsyncMock(return_value=({}, True)),
),
@ -303,7 +303,7 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
),
),
patch.object(
teacher.session_persistence,
teacher.session_evaluation_repository,
"list_session_evaluations",
AsyncMock(return_value=({}, True)),
),
@ -357,7 +357,7 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=({}, True)),
),
patch.object(
teacher.session_persistence,
teacher.session_evaluation_repository,
"list_session_evaluations",
AsyncMock(return_value=({}, True)),
),
@ -411,7 +411,7 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=({}, True)),
),
patch.object(
teacher.session_persistence,
teacher.session_evaluation_repository,
"list_session_evaluations",
AsyncMock(return_value=({sess.session_id: evaluation_record}, True)),
),
@ -459,7 +459,7 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=({}, True)),
),
patch.object(
teacher.session_persistence,
teacher.session_evaluation_repository,
"list_session_evaluations",
AsyncMock(return_value=({}, True)),
),
@ -507,7 +507,7 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=({}, True)),
),
patch.object(
teacher.session_persistence,
teacher.session_evaluation_repository,
"list_session_evaluations",
AsyncMock(return_value=({}, True)),
),
@ -539,7 +539,7 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=sess),
),
patch.object(
teacher.session_persistence,
teacher.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=({"status": "ready"}, True)),
),
@ -604,7 +604,7 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=sess),
),
patch.object(
teacher.session_persistence,
teacher.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=({"status": "error", "error": "timeout"}, True)),
),
@ -680,7 +680,7 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
),
),
patch.object(
teacher.session_persistence,
teacher.session_evaluation_repository,
"list_session_evaluations",
AsyncMock(return_value=({}, True)),
),

View file

@ -1,4 +0,0 @@
INFO: Started server process [8752]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:9099 (Press CTRL+C to quit)

View file

@ -1,4 +0,0 @@
INFO: Started server process [69132]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:9099 (Press CTRL+C to quit)

File diff suppressed because it is too large Load diff

View file

@ -1,150 +0,0 @@
INFO: 127.0.0.1:5961 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:1131 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:13224 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:1131 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:9011 - "GET /ready?force=true HTTP/1.1" 200 OK
INFO: 127.0.0.1:4872 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4872 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:9735 - "GET /ready HTTP/1.1" 200 OK
INFO: 127.0.0.1:9737 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:9737 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:9550 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4478 - "GET /ready HTTP/1.1" 200 OK
INFO: 127.0.0.1:4480 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4480 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4483 - "GET /ready HTTP/1.1" 200 OK
INFO: 127.0.0.1:4480 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4480 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:6909 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:6920 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:6920 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4284 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:4286 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4286 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11183 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:11186 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11186 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:9906 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:9908 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:9908 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4910 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:4912 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4912 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11462 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:11464 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11464 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:3395 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:3397 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:3397 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:7131 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:7135 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:7135 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4662 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:4665 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4665 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:8921 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:8924 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:8924 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:7827 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:7830 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:7830 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11042 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:11046 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11046 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11042 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:11046 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:14342 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:14343 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:14342 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:5041 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:5043 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:5043 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:12732 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:12735 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:12735 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:2362 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:2365 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:2365 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4926 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:4928 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4928 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4810 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:4813 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4813 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:2345 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:2351 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:2351 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:2075 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:2079 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:2079 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:3840 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:3842 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:3842 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4284 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:4286 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4286 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:10589 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:10591 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:10591 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:8330 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:8334 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:8334 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:5069 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:5071 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:5071 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:8707 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:8709 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:8709 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:9467 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:9470 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:9470 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11371 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:11374 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11374 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11371 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:11374 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11382 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11416 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:11382 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11850 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:11852 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11852 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:1574 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:1576 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:1576 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4470 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:4472 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4472 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4562 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:4564 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4564 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:7321 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:7323 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:7323 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:1317 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:1319 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:1319 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:2332 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:2334 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:2334 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:3770 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:3772 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:3772 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:7472 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:7474 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:7474 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:7671 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:7673 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:7673 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:8837 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:8839 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:8839 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:2461 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:2463 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:2463 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:10930 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:5805 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:5781 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:5714 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4865 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4903 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK

View file

@ -1,5 +1,5 @@
import path from "node:path";
import { expect, test, type Page, type Route } from "@playwright/test";
import { expect, test, type Page, type Route, type TestInfo } from "@playwright/test";
import type { DeliberatePracticeReadModel } from "../src/pages/session-review/deliberatePracticeApi";
import { parsePracticeLaunchIntent } from "../src/lib/practiceLaunchIntent";
import {
@ -893,17 +893,14 @@ async function expectMobileActiveVisualIntegrity(page: Page) {
expect(result.latestReplyVisible, diagnostic).toBe(true);
}
async function capture(page: Page, projectName: string, stage: string) {
async function capture(page: Page, testInfo: TestInfo, stage: string) {
if (process.env.E2E_SKIP_EVIDENCE_CAPTURE === "1") return;
const safeProject = projectName.replace(/[^a-z0-9-]+/gi, "-").toLowerCase();
const evidenceDirectory = process.env.E2E_EVIDENCE_DIR
? path.resolve(process.env.E2E_EVIDENCE_DIR)
: path.resolve(process.cwd(), "../../docs/ops/evidence");
const safeProject = testInfo.project.name.replace(/[^a-z0-9-]+/gi, "-").toLowerCase();
const evidenceDirectory = process.env.E2E_EVIDENCE_DIR;
await page.screenshot({
path: path.resolve(
evidenceDirectory,
`self-directed-loop-${stage}-${safeProject}-2026-08-07.png`,
),
path: evidenceDirectory
? path.resolve(evidenceDirectory, `self-directed-loop-${stage}-${safeProject}.png`)
: testInfo.outputPath(`self-directed-loop-${stage}.png`),
fullPage: true,
animations: "disabled",
});
@ -1030,11 +1027,11 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
);
await expectMinimumHitTargets(page, ".lh-tabs [role='tab']", "320px 학습 홈 탭");
await expectNoHorizontalOverflow(page);
await capture(page, testInfo.project.name, "home-320x568");
await capture(page, testInfo, "home-320x568");
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => new Promise(requestAnimationFrame));
}
await capture(page, testInfo.project.name, "home");
await capture(page, testInfo, "home");
await primaryAction.click();
await expect(page).toHaveURL(/\/learn\/practice$/);
@ -1095,11 +1092,11 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
await expectMobileActiveVisualIntegrity(page);
await expectCoachNudgeContrast(page);
await expectNoHorizontalOverflow(page);
await capture(page, testInfo.project.name, viewport.stage);
await capture(page, testInfo, viewport.stage);
}
await page.setViewportSize({ width: 390, height: 844 });
} else {
await capture(page, testInfo.project.name, "active-session");
await capture(page, testInfo, "active-session");
}
await page.getByRole("button", { name: "회기 종료" }).click();
@ -1173,12 +1170,12 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
"320px 처방 연습 시작 행동",
);
await expectNoHorizontalOverflow(page);
await capture(page, testInfo.project.name, "review-prescription-320x568");
await capture(page, testInfo, "review-prescription-320x568");
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => new Promise(requestAnimationFrame));
}
await practiceCard.scrollIntoViewIfNeeded();
await capture(page, testInfo.project.name, "review-prescription");
await capture(page, testInfo, "review-prescription");
const launch = practiceCard.getByRole("link", {
name: "이 처방으로 연습 시작",
@ -1222,7 +1219,7 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
await page.evaluate(() => new Promise(requestAnimationFrame));
await expectMinimumHitTargets(page, ".lh-actions .vg-btn", "320px 재연습 대상 행동");
await expectNoHorizontalOverflow(page);
await capture(page, testInfo.project.name, "repractice-home-320x568");
await capture(page, testInfo, "repractice-home-320x568");
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => new Promise(requestAnimationFrame));
}
@ -1264,11 +1261,11 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
"320px 처방 재연습 시작 행동",
);
await expectNoHorizontalOverflow(page);
await capture(page, testInfo.project.name, "repractice-prestart-320x568");
await capture(page, testInfo, "repractice-prestart-320x568");
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => new Promise(requestAnimationFrame));
}
await capture(page, testInfo.project.name, "repractice-prestart");
await capture(page, testInfo, "repractice-prestart");
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page).toHaveURL(
@ -1368,12 +1365,12 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
await expect(observation).not.toContainText(RETRY_SESSION_ID.slice(0, 8));
await expect(observation).not.toContainText("[NAME]");
await expectNoHorizontalOverflow(page);
await capture(page, testInfo.project.name, "repractice-observation-320x568");
await capture(page, testInfo, "repractice-observation-320x568");
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => new Promise(requestAnimationFrame));
}
await observation.scrollIntoViewIfNeeded();
await capture(page, testInfo.project.name, "repractice-observation");
await capture(page, testInfo, "repractice-observation");
await page.reload();
observation = page.locator(".dp-runtime-observation");

View file

@ -1,4 +1,3 @@
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { parseVoicePracticeContext } from "../src/lib/voicePracticeContext";
import { expectNoHorizontalOverflow } from "./support";
@ -424,6 +423,7 @@ async function routeMvpApi(page: Page, options: RouteMvpOptions = {}) {
growthPoints: [{ title: "탐색 확장", body: "다음 턴에서 구체 상황을 더 묻습니다." }],
nextLine: "그 말을 꺼내는 것도 쉽지 않았을 것 같아요.",
clientFeedback: clientReply,
learnerFeedbackEnabled: true,
audioUrl: null,
pdfExportUrl: null,
degraded: false,
@ -773,7 +773,6 @@ async function installVoiceLifecycleFixture(page: Page) {
releaseEmptyFinal() {
const socket = sockets[2];
if (!socket || socket.readyState !== FixtureWebSocket.OPEN) return;
socket.emitJson({ type: "state", state: "thinking" });
socket.emitJson({ type: "transcript", text: "", final: true });
socket.emitJson({ type: "state", state: "idle" });
},
@ -877,11 +876,12 @@ async function installVoiceLifecycleFixture(page: Page) {
}
if (payload.type !== "audio_end") return;
window.setTimeout(() => {
if (this.connectionNumber === 1) {
this.emitJson({ type: "transcript", text: firstInterim, final: false });
} else if (this.connectionNumber === 2) {
this.emitJson({ type: "transcript", text: secondInterim, final: false });
}
if (this.connectionNumber > 2) return;
this.emitJson({
type: "transcript",
text: this.connectionNumber === 1 ? firstInterim : secondInterim,
final: false,
});
this.emitJson({
type: "eot",
ready: false,
@ -1116,7 +1116,7 @@ test.describe("P1 MVP core loop", () => {
await expect(
page.getByRole("heading", { name: "원본 회기 정보를 확인할 수 없습니다." }),
).toBeVisible();
await expect(page.getByRole("button", { name: "새 회기 시작" })).toBeDisabled();
await expect(page.getByRole("button", { name: "완전히 새로 시작" })).toBeDisabled();
await expectNoHorizontalOverflow(page);
await page.goto(`/learn/session/P1?${malformedQuery}`);
@ -1153,11 +1153,7 @@ test.describe("P1 MVP core loop", () => {
await expect(page.getByRole("heading", { name: "첫 발화 전에 내 기준을 잠급니다" })).toBeVisible();
await expect(page.getByLabel("학습자 발화 입력")).toBeDisabled();
await page.screenshot({
path: path.resolve(
process.cwd(),
"../../docs/ops/evidence",
`g1-alliance-pre-${testInfo.project.name}-2026-08-07.png`,
),
path: testInfo.outputPath("g1-alliance-pre.png"),
fullPage: true,
});
await selectAllianceScore(page, "목표", "3 보통이다");
@ -1176,11 +1172,7 @@ test.describe("P1 MVP core loop", () => {
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
await page.getByRole("button", { name: "30초 점검" }).click();
await page.screenshot({
path: path.resolve(
process.cwd(),
"../../docs/ops/evidence",
`g1-alliance-mid-${testInfo.project.name}-2026-08-07.png`,
),
path: testInfo.outputPath("g1-alliance-mid.png"),
fullPage: true,
});
await selectAllianceScore(page, "목표", "4 대체로 그렇다");
@ -1210,7 +1202,8 @@ test.describe("P1 MVP core loop", () => {
page.getByRole("button", { name: "회기 시작" }),
diagnostics.join("\n") || (await page.locator("#root").innerText().catch(() => "")),
).toBeVisible();
await page.getByRole("button", { name: /CBT/ }).click();
await page.locator("details.sx-prestart__settings > summary").click();
await page.getByRole("button", { name: /^CBT/ }).click();
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
@ -1253,7 +1246,7 @@ test.describe("P1 MVP core loop", () => {
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
await page.getByRole("button", { name: "코칭" }).click();
await page.getByRole("button", { name: /^코칭 모드, 남은 기회 \d+개$/ }).click();
await page.getByLabel("학습자 발화 입력").fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
@ -1292,7 +1285,7 @@ test.describe("P1 MVP core loop", () => {
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
await page.getByRole("button", { name: "코칭" }).click();
await page.getByRole("button", { name: /^코칭 모드, 남은 기회 \d+개$/ }).click();
await page.getByLabel("학습자 발화 입력").fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
@ -1317,7 +1310,7 @@ test.describe("P1 MVP core loop", () => {
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
await page.getByRole("button", { name: "코칭" }).click();
await page.getByRole("button", { name: /^코칭 모드, 남은 기회 \d+개$/ }).click();
await page.getByLabel("학습자 발화 입력").fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
@ -1341,7 +1334,7 @@ test.describe("P1 MVP core loop", () => {
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
await page.getByRole("button", { name: "코칭" }).click();
await page.getByRole("button", { name: /^코칭 모드, 남은 기회 \d+개$/ }).click();
await page.getByLabel("학습자 발화 입력").fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
@ -1367,7 +1360,7 @@ test.describe("P1 MVP core loop", () => {
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
await page.getByRole("button", { name: "코칭" }).click();
await page.getByRole("button", { name: /^코칭 모드, 남은 기회 \d+개$/ }).click();
await page.getByLabel("학습자 발화 입력").fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
@ -1505,6 +1498,14 @@ test.describe("P1 MVP core loop", () => {
await page.getByRole("button", { name: "음성 다시 연결" }).click();
await page.getByRole("button", { name: "발화 보내기" }).click();
await expect.poll(async () =>
page.evaluate(
() =>
window.__voiceLifecycleFixture?.sent.filter((payload) =>
payload.includes('"audio_end"'),
).length ?? 0,
),
).toBe(3);
await page.evaluate(() => window.__voiceLifecycleFixture?.releaseEmptyFinal());
await expect(transcriptLog.locator(".sx-utt.is-learner")).toHaveCount(2);
await expect(page.locator(".sx-mic-block__h")).toContainText("음성을 인식하지 못했습니다");
@ -1519,7 +1520,7 @@ test.describe("P1 MVP core loop", () => {
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
await page.getByRole("button", { name: "코칭" }).click();
await page.getByRole("button", { name: /^코칭 모드, 남은 기회 \d+개$/ }).click();
const micButton = page.getByRole("button", { name: "마이크 켜기" });
await expect(micButton).toBeEnabled();
await micButton.click();
@ -1548,7 +1549,7 @@ test.describe("P1 MVP core loop", () => {
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
await page.getByRole("button", { name: "코칭" }).click();
await page.getByRole("button", { name: /^코칭 모드, 남은 기회 \d+개$/ }).click();
await page.getByLabel("학습자 발화 입력").fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
@ -1583,7 +1584,7 @@ test.describe("P1 MVP core loop", () => {
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
await page.getByRole("button", { name: "코칭" }).click();
await page.getByRole("button", { name: /^코칭 모드, 남은 기회 \d+개$/ }).click();
await page.getByLabel("학습자 발화 입력").fill("저 지금 자살하고 싶어요. 도와주세요.");
await page.getByRole("button", { name: "보내기" }).click();

View file

@ -1117,10 +1117,10 @@ test.describe("voice cascade success path", () => {
await expect(consentDialog).toBeVisible();
if (process.env.CAPTURE_G7_CONSENT === "1") {
const desktopViewport = page.viewportSize() ?? { width: 1440, height: 900 };
await page.screenshot({ path: "test-results/g7-voice-consent-desktop.png" });
await page.screenshot({ path: testInfo.outputPath("g7-voice-consent-desktop.png") });
await page.setViewportSize({ width: 390, height: 844 });
await expect(consentDialog).toBeVisible();
await page.screenshot({ path: "test-results/g7-voice-consent-mobile.png" });
await page.screenshot({ path: testInfo.outputPath("g7-voice-consent-mobile.png") });
await page.setViewportSize(desktopViewport);
}
await expect.poll(async () => (await readVoiceUiProbe(page)).getUserMediaCalls).toBe(0);

View file

@ -4,7 +4,8 @@
"functions/**/*.js",
"public/worklets/**/*.js",
"scripts/*.mjs",
"e2e/**/*.spec.ts"
"e2e/**/*.spec.ts",
"src/lib/measurementContract.ts"
],
"project": [
"src/**/*.{ts,tsx}",
@ -14,6 +15,9 @@
"e2e/**/*.ts"
],
"ignoreFiles": ["src/components/avatar/RasterBust.tsx"],
"paths": {
"/src/*": ["./src/*"]
},
"ignoreDependencies": ["openapi-typescript"],
"ignoreBinaries": ["taskkill"]
}

View file

@ -0,0 +1,21 @@
const FOCUSABLE_SELECTOR =
"a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])";
export function getFocusableControls(root: HTMLElement | null): HTMLElement[] {
return Array.from(root?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR) ?? []).filter(
(control) => control.getClientRects().length > 0,
);
}
export function trapTabKey(event: KeyboardEvent, controls: readonly HTMLElement[]): void {
if (event.key !== "Tab" || controls.length === 0) return;
const first = controls[0];
const last = controls[controls.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}

View file

@ -23,7 +23,7 @@ import {
ClientAvatar,
expressionLabelFor,
} from "../components/avatar/ClientAvatar";
import type { AvatarState, AvatarAffect, AvatarPersona } from "../components/avatar/ClientAvatar";
import type { AvatarState, AvatarAffect } from "../components/avatar/ClientAvatar";
import { Kicker, Button, Icon, surfaceClassName } from "../components/ui";
import {
ApiError,
@ -37,7 +37,6 @@ import {
type LiveCoachQuota,
type LiveCoachSuggestion,
type PersonaSummary,
type SessionDetailResponse,
type SessionProgress,
type SessionStage,
type SessionStartMode,
@ -45,6 +44,7 @@ import {
import { useAuth } from "../lib/auth";
import { formatElapsed, formatTimecode, clamp01 } from "../lib/format";
import { displayPiiSafeText } from "../lib/piiDisplay";
import { getFocusableControls, trapTabKey } from "../lib/focusTrap";
import {
parseVoicePracticeContext,
voicePracticeSearch,
@ -58,17 +58,7 @@ import {
PRACTICE_MODE_LABEL,
PRACTICE_NOVELTY_LABEL,
} from "../lib/practiceLaunchIntent";
import {
DIFFICULTY_LABEL,
PENDING_PERSONA_AVATAR_APPEARANCE,
isUsablePersona,
personaAvatarAppearance,
personaBaselineExpression,
personaMeta,
personaTheoryLabel,
shortPersonaName,
unavailablePersonaMessage,
} from "../lib/personaViewModel";
import { isUsablePersona, unavailablePersonaMessage } from "../lib/personaViewModel";
import {
VOICE_CONNECTION_SAVE_FAILED,
createAudioWorkletCapture,
@ -84,6 +74,28 @@ import {
type VoiceStatus,
} from "./session/voiceCapture";
import { AllianceCheckpointPrompt } from "./session/AllianceCheckpointPrompt";
import {
AI_VOICE_DISCLOSURE,
DEFAULT_COACH_QUOTA,
SESSION_PHASES,
THEORY_MODE_OPTIONS,
buildPersonaUi,
elapsedFromSession,
expressionForSession,
formatCoachTimestamp,
looksLikeSessionId,
metersFromOpenness,
normalizePersonaCode,
preferredTheoryMode,
primaryGoalActionLabel,
secondsBetween,
splitMetaLines,
type FeedbackMode,
type PhaseInfo,
type SignalTone,
type TheoryMode,
type Utterance,
} from "./session/sessionViewModel";
import {
multimodalAllianceApi,
type MultimodalConsentRequest,
@ -91,85 +103,6 @@ import {
import "./session/session.css";
import { randomUuid } from "../lib/uuid";
/* ── 도메인 상수/타입 ───────────────────────────────────────────────── */
type FeedbackMode = "immersive" | "ambient" | "coached";
type TheoryMode = "humanistic" | "cbt" | "integrative";
type Speaker = "client" | "learner";
type SignalTone = "pos" | "warn" | "neutral";
const AI_VOICE_DISCLOSURE =
"내담자 음성은 AI가 생성한 합성 음성이며 사람의 목소리가 아닙니다.";
interface Utterance {
id: number;
speaker: Speaker;
text: string;
turnSeq?: number;
/** 발화 시작 시점(세션 경과 초) */
at: number;
/** 진행 중(스트리밍/타이핑) 발화 여부 */
partial?: boolean;
/** 음성 WebSocket이 reply 전에 실패해 DB 턴으로 확정되지 않은 발화 */
failed?: boolean;
/** 실시간 음성 전사의 현재 확정 단계 */
voiceTranscriptState?: "interim" | "finalizing";
}
interface PhaseInfo {
key: SessionStage;
desc: string;
}
interface ClientContext {
initial: string;
name: string;
meta: string;
rows: { l: string; v: string }[];
chips: { t: string; clay: boolean }[];
}
const SESSION_PHASES: PhaseInfo[] = [
{ key: "라포", desc: "첫 인사와 안전감 형성" },
{ key: "탐색", desc: "호소 문제와 일상을 함께 이해" },
{ key: "개입", desc: "감정과 생각을 다루는 기법 적용" },
{ key: "정리", desc: "오늘의 대화 정리와 다음 약속" },
];
function primaryGoalActionLabel(goal: SessionStage): string {
const lastCode = goal.charCodeAt(goal.length - 1);
const hasFinalConsonant =
lastCode >= 0xac00 && lastCode <= 0xd7a3 && (lastCode - 0xac00) % 28 !== 0;
return `${goal}${hasFinalConsonant ? "을" : "를"} 핵심 초점으로 설정`;
}
const THEORY_MODE_OPTIONS: {
value: TheoryMode;
label: string;
detail: string;
focus: string;
}[] = [
{
value: "humanistic",
label: "인간중심",
detail: "공감·반영",
focus: "감정과 욕구를 반영한 뒤 열린 질문으로 이어갑니다.",
},
{
value: "cbt",
label: "CBT",
detail: "생각·행동",
focus: "상황·생각·감정·행동의 연결을 한 단계씩 확인합니다.",
},
{
value: "integrative",
label: "통합",
detail: "혼합 접근",
focus: "공감적 반영을 먼저 두고 필요한 지점만 구조화합니다.",
},
];
const DEFAULT_COACH_QUOTA: LiveCoachQuota = { remaining: 3, max: 3 };
interface CoachChatMessage {
id: string;
sender: "learner" | "coach";
@ -178,186 +111,8 @@ interface CoachChatMessage {
createdAt: number;
}
function preferredTheoryMode(summary: PersonaSummary | null): TheoryMode {
const firstSupported = summary?.theory_target.find(
(target): target is TheoryMode =>
target === "humanistic" || target === "cbt" || target === "integrative",
);
return firstSupported ?? "humanistic";
}
let _uid = 100;
const nextId = () => ++_uid;
function normalizePersonaCode(raw?: string): string {
return (raw ?? "").trim().toUpperCase();
}
function looksLikeSessionId(raw?: string): boolean {
const value = (raw ?? "").trim();
return (
/^[0-9a-f]{32}$/i.test(value) ||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
);
}
function secondsBetween(startedAt: string, createdAt: string): number {
const start = Date.parse(startedAt);
const created = Date.parse(createdAt);
if (!Number.isFinite(start) || !Number.isFinite(created)) return 0;
return Math.max(0, Math.round((created - start) / 1000));
}
function elapsedFromSession(detail: SessionDetailResponse): number {
const start = Date.parse(detail.started_at);
const end = detail.ended_at ? Date.parse(detail.ended_at) : Date.now();
if (!Number.isFinite(start) || !Number.isFinite(end)) return 0;
return Math.max(0, Math.round((end - start) / 1000));
}
function buildPersonaUi(
summary: PersonaSummary | null,
code: string,
): { avatar: AvatarPersona; context: ClientContext } {
if (!summary) {
return {
avatar: {
label: "내담자 정보 확인 중",
...PENDING_PERSONA_AVATAR_APPEARANCE,
realism: 0.3,
},
context: {
initial: "?",
name: "내담자 확인 중",
meta: code ? `${code} · DB 카탈로그 확인 중` : "DB 카탈로그 확인 중",
rows: [
{ l: "상태", v: "실제 연습 대상 정보를 확인하고 있습니다." },
],
chips: [],
},
};
}
const name = shortPersonaName(summary.display_name);
const difficulty = DIFFICULTY_LABEL[summary.difficulty] ?? summary.difficulty;
const theories = summary.theory_target.length
? summary.theory_target.map(personaTheoryLabel).join(" · ")
: "공통";
const chips = [
{ t: summary.code, clay: false },
{ t: difficulty, clay: summary.difficulty === "hard" },
...summary.theory_target.slice(0, 2).map((target) => ({
t: personaTheoryLabel(target),
clay: false,
})),
];
return {
avatar: {
code: summary.code,
label: `${name} · ${personaMeta(summary)}`,
...personaAvatarAppearance(summary),
},
context: {
initial: name.slice(0, 1) || summary.code.slice(0, 1),
name,
meta: personaMeta(summary),
rows: [
{ l: "호소", v: summary.presenting_summary || "요약 정보 없음" },
{ l: "대상", v: theories },
{ l: "난도", v: difficulty },
],
chips,
},
};
}
/* D3( ) "16-18 · 2 · · " ·
2 , · 1 .
personaMeta() . */
function splitMetaLines(meta: string): string[] {
const parts = meta
.split("·")
.map((part) => part.trim())
.filter(Boolean);
if (parts.length <= 2) return [meta];
const lines: string[] = [];
for (let i = 0; i < parts.length; i += 2) {
lines.push(parts.slice(i, i + 2).join(" · "));
}
return lines;
}
/* ── 작은 표현 유틸 ─────────────────────────────────────────────────── */
// 백엔드가 반환한 effective_openness(0~1)를 실시간 관찰 신호로만 변환한다.
// 점수/정밀 평가는 회기 종료 후 리뷰 화면에서 다룬다.
function metersFromOpenness(openness: number) {
const o = clamp01(openness);
return {
rapport: o, // 마음 열림(라포)
resistance: clamp01(1 - o * 0.9), // 저항감(반비례)
anxiety: clamp01(0.7 - o * 0.4), // 불안·위축(완만히 감소)
};
}
function baselineExpressionFor(summary: PersonaSummary | null): AvatarAffect {
return personaBaselineExpression(summary);
}
function expressionForSession({
state,
openness,
paused,
safety,
summary,
stage,
}: {
state: AvatarState;
openness: number;
paused: boolean;
safety: string | null;
summary: PersonaSummary | null;
stage: SessionStage;
}): AvatarAffect {
const o = clamp01(openness);
const code = summary?.code.toUpperCase() ?? "";
const baseline = baselineExpressionFor(summary);
if (safety) return "startled";
if (paused) return "tired";
if (state === "thinking") {
if (code === "P4" && o < 0.38) return "anxious";
if (code === "P6") return o < 0.55 ? "conflicted" : "determined";
if (code === "P7") return o < 0.45 ? "overwhelmed" : "tired";
if (o < 0.32) return "guarded";
return "confused";
}
if (state === "listening") {
if (o < 0.28) return code === "P4" ? "panic" : "guarded";
if (o < 0.48) return baseline === "tired" ? "bored" : baseline;
if (o > 0.72) return "warm";
return "calm";
}
if (state === "speaking") {
if (o < 0.22) return code === "P7" ? "bored" : "resistant";
if (o < 0.38) return code === "P5" ? "skeptical" : baseline;
if (stage === "정리") return "relief";
if (o > 0.78) return "hopeful";
if (o > 0.62) return "warm";
return "neutral";
}
if (o < 0.3) return baseline;
if (o > 0.76) return "relief";
return "neutral";
}
/* ===================================================================== */
export default function Session() {
const { sessionId: routeId } = useParams<{ sessionId: string }>();
const navigate = useNavigate();
@ -1710,11 +1465,14 @@ export default function Session() {
: detail.includes("engine unavailable") || detail.includes("engine stream")
? "AI 엔진이 응답하지 않습니다. 입력 내용은 복원했습니다. 잠시 뒤 다시 시도하고, 반복되면 관리자에게 내담자 응답 엔진 상태 확인을 요청하세요."
: "내담자 응답을 생성하지 못했습니다. 입력 내용은 복원했습니다. 같은 발화로 다시 시도해 주세요.";
setComposeText(text);
setTurnError(normalized);
pushSignal("warn", "AI 엔진 연결 실패");
setAvatarState("listening");
} finally {
setSending(false);
setClientReplyPending(false);
setVoiceStatus((prev) => (prev === "thinking" ? "idle" : prev));
}
}, [
composeText,
@ -2474,6 +2232,9 @@ export default function Session() {
const isCtrl = (e.ctrlKey || e.metaKey) && !e.altKey;
const target = e.target instanceof HTMLElement ? e.target : null;
const isInput = target?.closest("input, textarea, select, [contenteditable='true']");
const isInteractiveControl = target?.closest(
"button, a[href], summary, [role='button'], [role='link']",
);
// Alt+M: 마이크 토글
if (isAlt && e.key.toLowerCase() === "m") {
@ -2558,7 +2319,8 @@ export default function Session() {
}
// 입력창 밖 단축키
if (!isInput) {
// 일반 단축키는 컨트롤의 기본 키보드 동작을 가로채지 않는다.
if (!isInput && !isInteractiveControl) {
if (e.key === "p" || e.key === "P" || e.key === " ") {
if (e.key === " ") e.preventDefault();
togglePause();
@ -2605,20 +2367,10 @@ export default function Session() {
dismissVoiceConsent();
return;
}
if (event.key !== "Tab") return;
const controls = [voiceConsentTextRef.current, voiceConsentAcceptRef.current].filter(
(control): control is HTMLButtonElement => control !== null && !control.disabled,
);
if (controls.length === 0) return;
const first = controls[0];
const last = controls[controls.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
trapTabKey(event, controls);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
@ -2637,21 +2389,7 @@ export default function Session() {
window.setTimeout(() => shortcutHelpConfirmRef.current?.focus(), 0);
const onKey = (event: KeyboardEvent) => {
if (event.key !== "Tab") return;
const controls = Array.from(
shortcutHelpPanelRef.current?.querySelectorAll<HTMLElement>(
"a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])",
) ?? [],
).filter((control) => control.getClientRects().length > 0);
if (controls.length === 0) return;
const first = controls[0];
const last = controls[controls.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
trapTabKey(event, getFocusableControls(shortcutHelpPanelRef.current));
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
@ -2673,21 +2411,7 @@ export default function Session() {
return;
}
if (event.key !== "Tab") return;
const controls = Array.from(
coachEvidencePanelRef.current?.querySelectorAll<HTMLElement>(
"a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])",
) ?? [],
).filter((control) => control.getClientRects().length > 0);
if (controls.length === 0) return;
const first = controls[0];
const last = controls[controls.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
trapTabKey(event, getFocusableControls(coachEvidencePanelRef.current));
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
@ -2702,21 +2426,7 @@ export default function Session() {
return;
}
if (event.key !== "Tab") return;
const controls = Array.from(
coachHistoryPanelRef.current?.querySelectorAll<HTMLElement>(
"a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])",
) ?? [],
).filter((control) => control.getClientRects().length > 0);
if (controls.length === 0) return;
const first = controls[0];
const last = controls[controls.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
trapTabKey(event, getFocusableControls(coachHistoryPanelRef.current));
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
@ -2746,21 +2456,10 @@ export default function Session() {
setEndDialogOpen(false);
return;
}
if (event.key === "Tab") {
const controls = [endCancelRef.current, endConfirmRef.current].filter(
(control): control is HTMLButtonElement => control !== null && !control.disabled,
);
if (controls.length === 0) return;
const first = controls[0];
const last = controls[controls.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
const controls = [endCancelRef.current, endConfirmRef.current].filter(
(control): control is HTMLButtonElement => control !== null && !control.disabled,
);
trapTabKey(event, controls);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
@ -3964,6 +3663,29 @@ export default function Session() {
<span className="sx-safety__text">
<b>{turnErrorTitle}</b> · {turnError}
</span>
<div className="sx-turn-error__actions">
{composeText.trim() && !sessionEnded && !paused ? (
<button
type="button"
className="sx-turn-error__retry-btn"
onClick={() => {
setTurnError(null);
setVoiceStatus("idle");
void handleSend();
}}
>
</button>
) : null}
<button
type="button"
className="sx-turn-error__dismiss-btn"
onClick={() => setTurnError(null)}
aria-label="오류 알림 닫기"
>
</button>
</div>
</section>
) : null}
</div>
@ -5187,23 +4909,6 @@ export default function Session() {
);
}
function formatCoachTimestamp(value: string | null | undefined) {
if (!value) return "방금";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "방금";
const now = new Date();
const sameDay =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate();
return new Intl.DateTimeFormat("ko-KR", {
month: sameDay ? undefined : "numeric",
day: sameDay ? undefined : "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
/* ── 로컬 게이지 (매우 천천히 채움, 2s — §5.5 산만함 방지) ──────────── */
function Gauge({ value, tone }: { value: number; tone: "clay" | "accent" }) {
const pct = Math.round(clamp01(value) * 100);

View file

@ -302,6 +302,46 @@ function DecisionSelector({
);
}
function ApprovalSubmitControls({
approving,
canSubmit,
intent,
approvalDecision,
approvalAllowed,
requirementId,
requirementCopy,
}: {
approving: boolean;
canSubmit: boolean;
intent: DecisionIntent | null;
approvalDecision: ApprovalDecision;
approvalAllowed: boolean;
requirementId: string;
requirementCopy: string;
}) {
const variant = submitVariant(intent, approvalDecision);
const label = submitLabel(intent, approvalDecision);
const requirementClassName = `cic-approval-requirement ${
canSubmit ? "is-ready" : intent === "approve" && !approvalAllowed ? "is-blocked" : ""
}`;
return (
<>
<Button
size="sm"
variant={variant}
type="submit"
disabled={!canSubmit || approving}
aria-describedby={requirementId}
>
{approving ? "결정 기록 중…" : label}
</Button>
<p id={requirementId} className={requirementClassName}>
{requirementCopy}
</p>
</>
);
}
function ContentQualificationCard({
item,
view,
@ -485,21 +525,15 @@ function ContentQualificationCard({
maxLength={180}
/>
</label>
<Button
size="sm"
variant={submitVariant(intent, approvalDecision)}
type="submit"
disabled={!canSubmit || approving}
aria-describedby={requirementId}
>
{approving ? "결정 기록 중…" : submitLabel(intent, approvalDecision)}
</Button>
<p
id={requirementId}
className={`cic-approval-requirement ${canSubmit ? "is-ready" : intent === "approve" && !approvalAllowed ? "is-blocked" : ""}`}
>
{requirementCopy}
</p>
<ApprovalSubmitControls
approving={approving}
canSubmit={canSubmit}
intent={intent}
approvalDecision={approvalDecision}
approvalAllowed={approvalAllowed}
requirementId={requirementId}
requirementCopy={requirementCopy}
/>
</form>
)}
</article>
@ -708,21 +742,15 @@ function GateCard({
maxLength={180}
/>
</label>
<Button
size="sm"
variant={submitVariant(intent, approvalDecision)}
type="submit"
disabled={!canSubmit || approving}
aria-describedby={requirementId}
>
{approving ? "결정 기록 중…" : submitLabel(intent, approvalDecision)}
</Button>
<p
id={requirementId}
className={`cic-approval-requirement ${canSubmit ? "is-ready" : intent === "approve" && !approvalAllowed ? "is-blocked" : ""}`}
>
{requirementCopy}
</p>
<ApprovalSubmitControls
approving={approving}
canSubmit={canSubmit}
intent={intent}
approvalDecision={approvalDecision}
approvalAllowed={approvalAllowed}
requirementId={requirementId}
requirementCopy={requirementCopy}
/>
</form>
)}
</article>

View file

@ -15,6 +15,7 @@ import {
type AlliancePulse,
type AllianceScores,
} from "./alliancePulseApi";
import { ALLIANCE_SCALE } from "./allianceScale";
import "./alliance-pulse.css";
interface PulseTurn {
@ -76,14 +77,6 @@ const PERSPECTIVE_COPY: Record<
supervisor_human: { label: "교수자 판정", provenance: "교수자 근거 판정" },
};
const SCALE = [
{ value: 0, number: "1", label: "전혀 아니다" },
{ value: 0.25, number: "2", label: "조금 아니다" },
{ value: 0.5, number: "3", label: "보통이다" },
{ value: 0.75, number: "4", label: "대체로 그렇다" },
{ value: 1, number: "5", label: "매우 그렇다" },
] as const;
const EMPTY_DRAFT: ScoreDraft = { goal: null, task: null, bond: null };
const POLL_INTERVAL_MS = 2_000;
@ -175,7 +168,7 @@ function AxisAssessmentControl({
<small>{copy.prompt}</small>
</legend>
<div className="ap-scale" role="radiogroup" aria-label={`${copy.label} 평가`}>
{SCALE.map((option) => (
{ALLIANCE_SCALE.map((option) => (
<label
key={option.value}
className={`ap-scale__option ${value === option.value ? "is-selected" : ""}`}
@ -330,6 +323,19 @@ function PerspectiveCell({
);
}
function LockedScores({ scores }: { scores: AllianceScores | null | undefined }) {
return (
<dl className="ap-locked-scores">
{DIMENSIONS.map((dimension) => (
<div key={dimension}>
<dt>{DIMENSION_COPY[dimension].short}</dt>
<dd>{scoreText(scores?.[dimension] ?? null)}</dd>
</div>
))}
</dl>
);
}
function LockedWaitingState({ pulse }: { pulse: AlliancePulse }) {
const terminalWithoutReveal =
pulse.status === "error" ||
@ -359,14 +365,7 @@ function LockedWaitingState({ pulse }: { pulse: AlliancePulse }) {
<span />
</div>
)}
<dl className="ap-locked-scores">
{DIMENSIONS.map((dimension) => (
<div key={dimension}>
<dt>{DIMENSION_COPY[dimension].short}</dt>
<dd>{scoreText(pulse.self_scores?.[dimension] ?? null)}</dd>
</div>
))}
</dl>
<LockedScores scores={pulse.self_scores} />
</div>
);
}
@ -378,14 +377,7 @@ function SelfOnlyState({ pulse }: { pulse: AlliancePulse }) {
<b> </b>
<p> . AI· .</p>
</div>
<dl className="ap-locked-scores">
{DIMENSIONS.map((dimension) => (
<div key={dimension}>
<dt>{DIMENSION_COPY[dimension].short}</dt>
<dd>{scoreText(pulse.self_scores?.[dimension] ?? null)}</dd>
</div>
))}
</dl>
<LockedScores scores={pulse.self_scores} />
</div>
);
}

View file

@ -968,7 +968,8 @@ export function CalibrationTransferCard({
session_id: sessionId,
competency_id: editableHistory?.competency_id ?? competencyId,
practice_block_id:
editableHistory?.practice_block_id ?? `oas-g5-block-session-${sessionId.toLowerCase()}`,
editableHistory?.practice_block_id ??
`oas-g5-block-${sessionId.toLowerCase()}-${(editableHistory?.competency_id ?? competencyId).replace(/^competency\./, "").toLowerCase().replace(/[^a-z0-9-]/g, "-")}`,
scenario_variant_id: editableHistory?.scenario_variant_id ?? `session-review-${sessionId}`,
phrase_family_id:
editableHistory?.phrase_family_id ?? `self-forecast-${competencyId.replace(/^competency\./, "")}`,
@ -978,7 +979,7 @@ export function CalibrationTransferCard({
confidence: confidence / 100,
recorded_sequence: latest ? latest.recorded_sequence + 1 : 1,
revision_reason: reason.trim(),
instrument_id: "vignette.calibration-self-prediction",
instrument_id: "calibration-mirror-g5",
instrument_version: "1.0.0",
evidence_turn_ids: [] as string[],
};

View file

@ -0,0 +1,7 @@
export const ALLIANCE_SCALE = [
{ value: 0, number: "1", label: "전혀 아니다" },
{ value: 0.25, number: "2", label: "조금 아니다" },
{ value: 0.5, number: "3", label: "보통이다" },
{ value: 0.75, number: "4", label: "대체로 그렇다" },
{ value: 1, number: "5", label: "매우 그렇다" },
] as const;

View file

@ -8,6 +8,7 @@ import {
type AlliancePulse,
type AllianceScores,
} from "../session-review/alliancePulseApi";
import { ALLIANCE_SCALE } from "../session-review/allianceScale";
import "./alliance-checkpoint-prompt.css";
type ScoreDraft = Record<AllianceDimension, number | null>;
@ -21,14 +22,6 @@ interface AllianceCheckpointPromptProps {
const DIMENSIONS: AllianceDimension[] = ["goal", "task", "bond"];
const EMPTY_DRAFT: ScoreDraft = { goal: null, task: null, bond: null };
const SCALE = [
{ value: 0, number: "1", label: "전혀 아니다" },
{ value: 0.25, number: "2", label: "조금 아니다" },
{ value: 0.5, number: "3", label: "보통이다" },
{ value: 0.75, number: "4", label: "대체로 그렇다" },
{ value: 1, number: "5", label: "매우 그렇다" },
] as const;
const CHECKPOINT_COPY: Record<
Exclude<AllianceCheckpoint, "post">,
{
@ -286,7 +279,7 @@ export function AllianceCheckpointPrompt({
<span>{prompt.prompt}</span>
</legend>
<div className="sx-alliance-checkpoint__scale">
{SCALE.map((option) => (
{ALLIANCE_SCALE.map((option) => (
<label
key={option.value}
className={draft[dimension] === option.value ? "is-selected" : ""}

View file

@ -1400,6 +1400,43 @@
.sx-turn-error {
margin-top: 10px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.sx-turn-error__actions {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
}
.sx-turn-error__retry-btn {
background: var(--accent);
color: var(--text-on-accent);
border: none;
border-radius: var(--radius-sm);
padding: 4px 10px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.sx-turn-error__retry-btn:hover {
filter: brightness(1.1);
}
.sx-turn-error__dismiss-btn {
background: transparent;
color: var(--neutral-600);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
padding: 4px 8px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.sx-turn-error__dismiss-btn:hover {
background: var(--neutral-100);
}
/* 보내기 = 1차 액션. 입력창(틴트) 대비 또렷한 진한 accent + 미세 elevation.
비활성(입력 없음) 표면 톤으로 낮춰 활성 상태와 위계를 분명히. */

View file

@ -0,0 +1,280 @@
import type { AvatarAffect, AvatarPersona, AvatarState } from "../../components/avatar/ClientAvatar";
import type {
LiveCoachQuota,
PersonaSummary,
SessionDetailResponse,
SessionStage,
} from "../../lib/api";
import { clamp01 } from "../../lib/format";
import {
DIFFICULTY_LABEL,
PENDING_PERSONA_AVATAR_APPEARANCE,
personaAvatarAppearance,
personaBaselineExpression,
personaMeta,
personaTheoryLabel,
shortPersonaName,
} from "../../lib/personaViewModel";
export type FeedbackMode = "immersive" | "ambient" | "coached";
export type TheoryMode = "humanistic" | "cbt" | "integrative";
export type Speaker = "client" | "learner";
export type SignalTone = "pos" | "warn" | "neutral";
export interface Utterance {
id: number;
speaker: Speaker;
text: string;
turnSeq?: number;
/** 발화 시작 시점(세션 경과 초) */
at: number;
/** 진행 중(스트리밍/타이핑) 발화 여부 */
partial?: boolean;
/** 음성 WebSocket이 reply 전에 실패해 DB 턴으로 확정되지 않은 발화 */
failed?: boolean;
/** 실시간 음성 전사의 현재 확정 단계 */
voiceTranscriptState?: "interim" | "finalizing";
}
export interface PhaseInfo {
key: SessionStage;
desc: string;
}
export interface ClientContext {
initial: string;
name: string;
meta: string;
rows: { l: string; v: string }[];
chips: { t: string; clay: boolean }[];
}
export const AI_VOICE_DISCLOSURE =
"내담자 음성은 AI가 생성한 합성 음성이며 사람의 목소리가 아닙니다.";
export const SESSION_PHASES: PhaseInfo[] = [
{ key: "라포", desc: "첫 인사와 안전감 형성" },
{ key: "탐색", desc: "호소 문제와 일상을 함께 이해" },
{ key: "개입", desc: "감정과 생각을 다루는 기법 적용" },
{ key: "정리", desc: "오늘의 대화 정리와 다음 약속" },
];
export function primaryGoalActionLabel(goal: SessionStage): string {
const lastCode = goal.charCodeAt(goal.length - 1);
const hasFinalConsonant =
lastCode >= 0xac00 && lastCode <= 0xd7a3 && (lastCode - 0xac00) % 28 !== 0;
return `${goal}${hasFinalConsonant ? "을" : "를"} 핵심 초점으로 설정`;
}
export const THEORY_MODE_OPTIONS: {
value: TheoryMode;
label: string;
detail: string;
focus: string;
}[] = [
{
value: "humanistic",
label: "인간중심",
detail: "공감·반영",
focus: "감정과 욕구를 반영한 뒤 열린 질문으로 이어갑니다.",
},
{
value: "cbt",
label: "CBT",
detail: "생각·행동",
focus: "상황·생각·감정·행동의 연결을 한 단계씩 확인합니다.",
},
{
value: "integrative",
label: "통합",
detail: "혼합 접근",
focus: "공감적 반영을 먼저 두고 필요한 지점만 구조화합니다.",
},
];
export const DEFAULT_COACH_QUOTA: LiveCoachQuota = { remaining: 3, max: 3 };
export function preferredTheoryMode(summary: PersonaSummary | null): TheoryMode {
const firstSupported = summary?.theory_target.find(
(target): target is TheoryMode =>
target === "humanistic" || target === "cbt" || target === "integrative",
);
return firstSupported ?? "humanistic";
}
export function normalizePersonaCode(raw?: string): string {
return (raw ?? "").trim().toUpperCase();
}
export function looksLikeSessionId(raw?: string): boolean {
const value = (raw ?? "").trim();
return (
/^[0-9a-f]{32}$/i.test(value) ||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
);
}
export function secondsBetween(startedAt: string, createdAt: string): number {
const start = Date.parse(startedAt);
const created = Date.parse(createdAt);
if (!Number.isFinite(start) || !Number.isFinite(created)) return 0;
return Math.max(0, Math.round((created - start) / 1000));
}
export function elapsedFromSession(detail: SessionDetailResponse): number {
const start = Date.parse(detail.started_at);
const end = detail.ended_at ? Date.parse(detail.ended_at) : Date.now();
if (!Number.isFinite(start) || !Number.isFinite(end)) return 0;
return Math.max(0, Math.round((end - start) / 1000));
}
export function buildPersonaUi(
summary: PersonaSummary | null,
code: string,
): { avatar: AvatarPersona; context: ClientContext } {
if (!summary) {
return {
avatar: {
label: "내담자 정보 확인 중",
...PENDING_PERSONA_AVATAR_APPEARANCE,
realism: 0.3,
},
context: {
initial: "?",
name: "내담자 확인 중",
meta: code ? `${code} · DB 카탈로그 확인 중` : "DB 카탈로그 확인 중",
rows: [{ l: "상태", v: "실제 연습 대상 정보를 확인하고 있습니다." }],
chips: [],
},
};
}
const name = shortPersonaName(summary.display_name);
const difficulty = DIFFICULTY_LABEL[summary.difficulty] ?? summary.difficulty;
const theories = summary.theory_target.length
? summary.theory_target.map(personaTheoryLabel).join(" · ")
: "공통";
const chips = [
{ t: summary.code, clay: false },
{ t: difficulty, clay: summary.difficulty === "hard" },
...summary.theory_target.slice(0, 2).map((target) => ({
t: personaTheoryLabel(target),
clay: false,
})),
];
return {
avatar: {
code: summary.code,
label: `${name} · ${personaMeta(summary)}`,
...personaAvatarAppearance(summary),
},
context: {
initial: name.slice(0, 1) || summary.code.slice(0, 1),
name,
meta: personaMeta(summary),
rows: [
{ l: "호소", v: summary.presenting_summary || "요약 정보 없음" },
{ l: "대상", v: theories },
{ l: "난도", v: difficulty },
],
chips,
},
};
}
export function splitMetaLines(meta: string): string[] {
const parts = meta
.split("·")
.map((part) => part.trim())
.filter(Boolean);
if (parts.length <= 2) return [meta];
const lines: string[] = [];
for (let i = 0; i < parts.length; i += 2) {
lines.push(parts.slice(i, i + 2).join(" · "));
}
return lines;
}
// 백엔드가 반환한 effective_openness(0~1)를 실시간 관찰 신호로만 변환한다.
// 점수/정밀 평가는 회기 종료 후 리뷰 화면에서 다룬다.
export function metersFromOpenness(openness: number) {
const o = clamp01(openness);
return {
rapport: o, // 마음 열림(라포)
resistance: clamp01(1 - o * 0.9), // 저항감(반비례)
anxiety: clamp01(0.7 - o * 0.4), // 불안·위축(완만히 감소)
};
}
export function baselineExpressionFor(summary: PersonaSummary | null): AvatarAffect {
return personaBaselineExpression(summary);
}
export function expressionForSession({
state,
openness,
paused,
safety,
summary,
stage,
}: {
state: AvatarState;
openness: number;
paused: boolean;
safety: string | null;
summary: PersonaSummary | null;
stage: SessionStage;
}): AvatarAffect {
const o = clamp01(openness);
const code = summary?.code.toUpperCase() ?? "";
const baseline = baselineExpressionFor(summary);
if (safety) return "startled";
if (paused) return "tired";
if (state === "thinking") {
if (code === "P4" && o < 0.38) return "anxious";
if (code === "P6") return o < 0.55 ? "conflicted" : "determined";
if (code === "P7") return o < 0.45 ? "overwhelmed" : "tired";
if (o < 0.32) return "guarded";
return "confused";
}
if (state === "listening") {
if (o < 0.28) return code === "P4" ? "panic" : "guarded";
if (o < 0.48) return baseline === "tired" ? "bored" : baseline;
if (o > 0.72) return "warm";
return "calm";
}
if (state === "speaking") {
if (o < 0.22) return code === "P7" ? "bored" : "resistant";
if (o < 0.38) return code === "P5" ? "skeptical" : baseline;
if (stage === "정리") return "relief";
if (o > 0.78) return "hopeful";
if (o > 0.62) return "warm";
return "neutral";
}
if (o < 0.3) return baseline;
if (o > 0.76) return "relief";
return "neutral";
}
export function formatCoachTimestamp(value: string | null | undefined) {
if (!value) return "방금";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "방금";
const now = new Date();
const sameDay =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate();
return new Intl.DateTimeFormat("ko-KR", {
month: sameDay ? undefined : "numeric",
day: sameDay ? undefined : "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}

View file

@ -1,4 +1,6 @@
# Vignette 배포 가이드 — "아무 데나 설치 가능한" Docker 구성
# Vignette 이식성 참조 — Docker 구성
> 이 문서는 개발·이식성용 Docker 구성 참조다. 현재 production NAS 배포의 단일 진입점·승인·rollback 경계는 [`ops/deployment-pipeline.md`](./ops/deployment-pipeline.md)를 따른다.
목표: 어떤 호스트(네 PC / 학교 서버 / 클라우드 VM)든 `docker compose up -d` 한 방으로 뜨고, **옮길 때 환경변수 몇 줄만** 바꾼다.

View file

@ -1,872 +1,51 @@
# Vignette G0~G8 완료 추진 핸드오프
# Vignette 현행 인수인계
> Updated: 2026-08-18 KST (엔진 readiness 장애 수정·재승격 · 유스케이스 TDD 239 · 공개 Web 배포)
> Workspace: `D:\workspace\vignette`
> 이 문서는 다음 에이전트가 **G0~G8 아홉 목표 중 마지막 G7 외부 게이트를 닫기 위한** 실행 기준이다.
> 기준일: 2026-09-07 KST
> 상태·결정의 SSOT는 [`dev_dashboard.html`](./dev_dashboard.html)이다. 이 문서는 다음 실행 순서와 금지 경계만 요약한다.
> 2026-08-18 이전 상세 기록은 [정리 전 HANDOFF 원문](./archive/project-cleanup-2026-09-07/HANDOFF-before-cleanup.md)에 보존했다.
## 0. 현재 상태 한 줄 요약
## 현재 기준
**G0~G6의 내부 구현·증거와 G8 clean-head 배포·rollback 증거는 종료됐다. G8은 source HEAD
`61a41d1f…6af`·tree `87dec55d…3b77`·archive `4d15d055…119d4d`로 candidate 112/112와 actual NAS-origin
112/112를 모두 통과했다. 다만 2026-08-12 current NAS는 고정 LAN engine relay 상실로 DEGRADED이며 15:24 KST
health 요청도 3회 타임아웃이라, 과거 증거와 현재 runtime GREEN을 분리한다. 공개 앱은 health ok·db/engine true·
OpenAPI 126·auth 401·local voice exact를 유지한다. 앞선 fail-closed·verified rollback 이력과 previous
`6030a677…c611`은 보존했다. G7 공개 런타임은
detached-clean `a73bcd24…`·tree `b02b3a1b…`로 fresh 승격했고 OpenAPI 126, local Whisper/MeloTTS, Uvicorn queue 4,
API/cloudflared provenance receipt와 source-pinned Scheduled Task result 0을 확인했다. 인증 WSS·runtime·Windows topology의
30초 무마이크 rehearsal도 통과했다. 남은 것은 명시 동의 물리 마이크 3,120초·공통 3,000초 high-water,
독립 human pack과 canonical checker exit 0이다.**
2026-09-01 당시 Forgejo master `dce85620`을 NAS `vignette-prod`에 배포했다. migration 20/21/22를 온라인 적용했고, 공개 health·OpenAPI·`/auth/me`·dev-login 경계와 소유자 실계정의 새 사례→턴→리뷰 폐루프를 확인했다. 일일 검증형 DB 백업 sidecar의 첫 실행도 그날 성공했다. 이는 당시 증거이며 오늘의 live 상태를 뜻하지 않는다.
| Goal | 판정 | 완료 증거 |
|---|---|---|
| G0 Measurement Foundation | DONE | provenance census 29/29, unknown/missing/orphan/null/total violations 0 |
| G1 Alliance Core | DONE | prompt 1.2 read-skew fix, prediction 24/24 ready, direction 9/9, error 0, recall 94.4%, precision 73.9% |
| G2 Outcome Trajectory | DONE | benchmark accuracy/early-warning recall 1.0, false alert 0, 실 DB 10 sessions/30 measurements |
| G3 Rupture & Repair | DONE | macro-F1 1.0, status 1.0, gaming 0, missed→partial→resolved 실 DB/API |
| G4 Deliberate Practice | DONE | completed-session observer, actual API/DB attempts, teacher correction, returned-practice browser 폐루프 |
| G5 Calibration & Transfer | DONE | actual transfer execution, independent/model-run/instrument/evidence provenance, DB/browser idempotency |
| G6 Supervision & Research | DONE | safety event metadata-only projection, safety priority 1 > deterioration 2, raw detail 0 |
| G7 Multimodal Alliance | **internal DONE · external GATE** | consent/withdrawal, synthetic soak 59/59, public `a73bcd24…`·OpenAPI 126·local voice, no-mic rehearsal 3 legs × 7 samples |
| G8 Continuous Improvement | **증거 DONE · CURRENT NAS DEGRADED** | 실제 image rollback 2회 + executed receipt 2건, source/archive 결속, candidate/origin 112/112; current engine relay 복구·재검증 대기 |
증거: [NAS 배포 증거](./ops/evidence/nas-prod-deploy-2026-09-01.json).
대시보드 status count는 **33 done / 2 doing / 0 planned**다.
파일 위생·문서 통합·수용된 코드 경계 정리·리뷰 수정은 이번 차수에서 완료했다. API는 outcome repository 공통값·practice competency 분류·인증/admin projection·text/voice 공통 turn memory·voice metadata 경계를, web은 focus trap·동맹 scale/잠긴 점수·승인 제출 제어 경계를 정리했다. `Knip` dead-code, measurement contract, typecheck/build, Ruff와 API 전체 1090 passed/1 skipped/1 warning, gateway 전체 71 passed/1 warning이 통과했다. 중복은 17 clone/294줄에서 3 clone/48줄·265 tokens(0.04%)로 감소했다. orchestrator retry는 임상 검수 exact-HEAD 원본 고정 경계로, voice abort와 repository query는 서로 다른 lifecycle·도메인 계약으로 유지한다. REQ-009~011, 전 기능·실환경 QA, 외부 운영·임상 게이트는 열린 상태를 유지한다.
G8 배포 source 기준선은 branch `master`, HEAD `61a41d1f08239b5f9e99cfae42a206be250416af`·tree
`87dec55daf0a22ca5c2e489cd803a2f731e63b77`이며 2회 동일 archive SHA는
`4d15d055d74f33f1fa2ff470afbf900d565ab238b19c79df34ca7ba240119d4d`다. 공유 worktree의 후속 변경은 이
배포 source에 포함되지 않으므로 G8 완료 증거와 섞지 않는다.
## 남은 실행 항목
### 2026-08-18 세션 — 엔진 장애 수정·유스케이스 TDD·재승격
1. C-001 외부 임상 검수: 외부 검토 입력·서명 증거·canonical checker를 모두 충족할 때까지 `review_complete=false`를 유지한다.
2. NAS 재부팅 자동복구 smoke: 실제 재부팅 뒤 자동 복구를 관찰한다. Windows 개발 워크스테이션 task 이력은 이 운영 NAS 게이트를 대체하지 않는다.
3. 백업 운영화: 실패 알림, off-host 암호화 복제, 정기 restore drill을 운영 증거로 남긴다.
4. 아바타 fail-closed 후보: 원본과 DB 참조를 보존한 채 public fallback·digest receipt·브라우저 검증을 마친 뒤에만 승격 판단한다.
5. G7/G8 외부 게이트: G7은 동의 기반 물리 마이크와 독립 voice-gain 증거, G8은 실DB append-only 사람 게이트와 public proof가 남아 있다.
6. 신규 제품 이슈: REQ-009와 REQ-011을 재현·원인 분류한 뒤 REQ-010의 회기 count/취소 정책을 확정한다. 원문 워크북 상태는 `TODO.md`의 로컬 상태와 분리해 보존한다.
- **공개 엔진 3.5시간 장애(06:30~10:05 KST)**: `reasoning_effort=high` 콜드 프로브가 20초를 넘기면
실패가 성공과 같은 TTL(운영 1800초)로 캐시돼 공개 API가 `engine=false`로 오염됐고, 사용량 한도
소진 구간에는 `Reached maximum budget ($0.5)`로 이어졌다. 워치독은 기본 `/ready`의 캐시 200만 보고
API만 재시작하는 루프에 빠졌다. 수정: `ENGINE_READY_FAILURE_TTL_SECONDS`(기본 30초) 분리 +
`ENGINE_READY_TIMEOUT_SECONDS` 20→45초, 회귀 4건(`engine_gateway/test_ready_cache.py`).
gateway 62/62 · backend 986 passed · ruff clean.
- **운영 경계 기록**: 공개 엔진 `claude -p`는 소유자 Claude 구독을 개발 에이전트 작업과 공유한다.
대량 병렬 에이전트 실행은 공개 서비스의 생성 경로를 굶길 수 있다(이번 장애의 2차 원인).
- **공개 런타임 재승격**: detached-clean `4771b97c…c880`·tree `286723de…c1fa` 루트로 API/게이트웨이/
cloudflared를 교체하고 워치독·부트 task를 같은 루트에 SHA pin 재등록, 명시 실행 `LastTaskResult=0`.
1차 승격은 새 worktree에 gitignored `apps/api/.env`가 없어 fail-closed로 실패했다(약 4분 다운타임).
**다음 승격부터는 시작 전에 이전 루트의 `apps/api/.env`를 새 루트로 복사하라.**
- **유스케이스 TDD 캠페인**: 16테마 `uc-*.spec.ts` 239 시나리오(수집 1109 tests/61 files)를 RED 우선으로
작성했다. 제품 결함 수정 4묶음(설정 지원 티켓 작성 UI 부재, 관리자 해결 노트 입력 부재,
포커스 링 가림, 라벨/헤딩/대비/44px/오버플로 결함)과 기존 스펙 드리프트 교정을 닫았다.
증거: `docs/ops/evidence/usecase-tdd-2026-08-18.json`.
- **직렬 게이트(e2e:single-run) 후속**: 11:10 KST 실행은 52 passed/5 failed/4 skipped. 삼각화 결과
breakpoint-sweep 실패는 `widthsFor` 솎아내기가 실기기 대표 폭을 탈락시키는 테스트 결함(교정 후 passed),
kb-source-packs 500은 수정 커밋 이전에 기동한 낡은 dev API(:8000) 프로세스가 원인(재기동 후 passed),
voice-success·session-persistence 실패는 Claude 구독 세션 한도(resets 3pm)로 엔진 502가 난 환경 결함.
교사 재평가 2건은 엔진 가용 게이트가 skip 처리하며 **15:00 KST 리셋 후 재검증이 열려 있다**.
- **공개 Web Cloudflare Pages 배포**: 소유자 지시로 detached-clean `cf899b7f…7c96` 후보
(`D:\workspace\vignette-public-web-cf899b7f`)를 빌드×2 결정성(599파일 sha256 전일치) 확인 뒤
production `4edc02d3`로 배포했다. 7개 origin(커스텀 도메인+직전 6세대)에서 143자산을 보존해
최종 742파일, 커스텀 도메인은 새 엔트리 `index-BWxBaCPM.js`/`index-BUfQc2Rm.css`를 서빙하고
직전 엔트리 자산도 200으로 살아 있다. 증거: `docs/ops/evidence/public-web-deploy-2026-08-18.json`.
- **NAS 엔진 릴레이 복구(1단계 종료)**: `start-nas-preview-engine.ps1 -CheckOnly` 프리플라이트가
4종 SHA pin + LAN `192.168.0.223:9100` 바인딩으로 통과했다(mutation=false,
영수증 `D:\workspace\nas-relay-state\receipt-checkonly-20260818.json`). env는 NAS preview API 실행
컨테이너의 `ENGINE_GATEWAY_SHARED_SECRET`을 값 노출 없이 로컬로 반출해 결속했다. 실제 1회 시작은
엔진 한도 리셋(15:00 KST) 뒤 15:05 자동화가 같은 핀으로 수행하고 NAS health 3회·auth 401·OpenAPI 126을
재검증한다. 증거: `docs/ops/evidence/nas-relay-recovery-2026-08-18.json`.
- **agy 공급자 복구(2026-08-18 오후)**: 관리자 AI 운영에서 agy 전환이 막힌 이중 결함을 고쳤다 —
`agy models``id<TAB>라벨` 형식으로 바뀐 것을 파서가 못 읽음(5afd92f8), ② 승격 체인의 psutil
환경 이식에서 SystemRoot 유실로 Go 계열 CLI가 빈 목록을 반환(6369f294 — `_cli_subprocess_env`
백필 + 런처 Machine 스코프 병합). 공개 런타임을 `6369f294`로 승격하고 워치독·부트 태스크를 모두
새 루트에 재핀했다(**워치독은 `install-public-runtime-task.ps1`, 부트는 `register-boot-task.ps1`
승격 시 둘 다 실행해야 구 루트 되살림 경합이 없다**). 검증: capabilities 14모델 + 실생 턴
`gemini-3.6-flash-high` 응답·비용 계약 통과. 증거: `docs/ops/evidence/agy-provider-recovery-2026-08-18.json`.
- **회기 종료 503 핫픽스(bf5f7352)**: `end_session`의 pinned_fact upsert가 learner 역할 연결로 실행돼
RLS에 거부되던 잠복 결함 — pinned_fact의 SELECT/UPDATE 정책은 AI 뷰·admin 전용이다. 같은 learner
uid로 `acquire(ai_view="evaluator")`를 열어 저장을 분리했고, 공개 DB 롤백 재현(learner 거부·AI 뷰
통과)으로 실증했다. 엔진 건강 시 deep 평가가 팩트를 반환하는 순간부터 발화하므로, degraded 구간
E2E가 이 경로를 덮지 못한 이유도 같다. 소유자 실세션 종료 성공과 로컬 실API E2E 2회(종료 200 +
pinned_fact 실적재 실증)로 운영 검증했다.
- **2026-08-18 심야 연쇄 복구·NAS current GREEN**: 대청소 사건(node_modules·구 런타임 워크트리·Temp
소실)으로 릴레이가 죽고, Docker Desktop 사망·whisper 9882 WinNAT 포트 예약·DHCP의 LAN IP 회수
(223→224)가 겹쳤다. 릴레이를 현행 루트에 같은 핀으로 재기동하고 224로 계약 이전(방화벽 재발행·NAS
`.env` 재구성), 클로드 예산 한도 재발으로 NAS를 `agy_cli`로 전환해 health 3/3 engine true·auth 401·
OpenAPI 126·actual-origin turn→review smoke를 모두 통과 — 201차 current GREEN 달성. 권고 3건은
`docs/ops/evidence/nas-relay-recovery-2026-08-18.json` 참조.
세부 ID·차단 이유·완료 조건은 [`TODO.md`](./TODO.md)와 [열린 백로그](./ops/backlog-2026-06-26.md)에 있다.
### 이 문서가 인계된 정확한 중단점
## 재개 순서
- G8 clean-head execute는 격리 NAS preview만 변경했고 공개 API/DB·물리 마이크는 건드리지 않았다. source HEAD/tree와
archive 2회 결정성, fresh dump, exact images, candidate/actual-origin 결과를 모두 결속했다.
- G8 종료 뒤 공개 Web은 Pages production deployment `7bd59055…`로 갱신했다. 실제 사용자 회기에서 literal
`[NAME]` 0, `시간 만료`, 모바일 44px·자동 스크롤 보정을 브라우저로 확인했다. 공개 API는 아래 G7 clean
promotion에서 별도로 fresh 승격했다.
- active goal은 계속 `active`다. 목표는 “문서 작성”이 아니라 **G0~G8 아홉 목표를 실제 runtime과 E2E로
모두 닫는 것**이다. G8은 종료됐고 G7 canonical checker exit 0이 없으면 `goal complete`를 호출하지 않는다.
- current NAS 복구용 `scripts/start-nas-preview-engine.ps1`은 source-pinned fail-closed 계약으로 보강했다.
detached-clean HEAD/tree, launcher·Python·env SHA, exact consumer URL, explicit LAN 승인, 외부 state/receipt,
listener owner와 generation readiness를 강제하고 loopback contract **8/8**을 통과했다. 2026-08-12에는
detached-clean `f08e03de…f191`·tree `00275133…0abd`에서 exact `-CheckOnly`도 PASS했다. launcher/Python/env
SHA는 `492d8b5c…b27c`/`5f7b89a6…52ec`/`e53acedf…8a39`, `mutation=false`, listener `0→0`, runtime
state 생성 0이다. 실제 9100 listener, NAS 설정·컨테이너·DB는 건드리지 않았다. 다음 mutation은 소유자 승인 뒤
단일 owner가 actual 1회→NAS health/auth/OpenAPI/assets/실회기 재검증 순서로만 수행한다.
- 최신 full disposable GREEN은 공통 톱바 44px 계약을 포함한 clean `b02bee26…`·tree `8f677bba…`
`periodic-learner-e2e-20260812-171547.json`·SHA-256 `8c11a136…af8e3`이다. 487.3초에 동일 학습자
브라우저 회기 생성·SSE·review ready·G4/G5 `0→1→1`, 별도 returned desktop/mobile 4/4와 route mock 0을
통과했고 public/NAS/active DB 접촉 0, container·volume·network·listener·temp 잔여 0을 독립 재확인했다.
- 공개 Web 보정은 detached-clean `00f851a6…c229`·tree `9f78cd16…9486` 후보로 배포 직전까지 고정했다.
API generated type LF 계약, `npm ci --ignore-scripts`, API type contract, typecheck, 2회 byte-identical production
build와 상단 44px·로그인 무가로넘침·자기주도 폐루프 focused E2E 12/12를 통과했다. 새 primary CSS/JS는
`index-Dzmt2kJS.css` SHA `da5e8535…fb9a``index-BUoTLplD.js` SHA `08654d08…3c05`다. 현재 공개와 최근
Pages 5개 origin의 지연 로드 자산 133개를 더해 최종 732파일로 만들었고 현행 공개 CSS/JS SHA도 exact
보존했다. actual 공개는 health ok·db/engine true지만 구 Web이라 모바일 상단 6개 조작면이 44px 미만이고,
NAS는 health timeout·9100 listener 0·로그인 overflow 10px다. 공개/NAS 배포는 0이며 기계 판독 증거는
`docs/ops/evidence/public-web-preflight-2026-08-12.json`이다.
- 앞선 두 DB init 실패는 `core.autocrlf=true` detached checkout이 `99_app_role.sh`를 CRLF로 만든 것이었고,
`.gitattributes``*.sh text eol=lf` 뒤 새 worktree에서 tracked shell CRLF 0·해당 파일 CR 0과 실제 DB healthy를
확인해 닫았다. 공통 톱바의 브랜드·공간 전환·테마·로그아웃은 desktop/mobile 모두 44px이고, auth overflow와
함께 source/full E2E GREEN이지만 아직 공개/NAS에는 배포하지 않았다.
- 마지막 완료 작업은 G8 source HEAD `61a41d1f…6af`·tree `87dec55d…3b77`·archive `4d15d055…119d4d`
candidate 112/112와 actual NAS-origin 112/112다. API/Web `d5021950…e4b1`/`9796c092…4b36`, health
ok·db/engine true·auth 401·OpenAPI 126, fresh dump `36ec8748…24db8`을 확인했다.
- G7 Windows topology mode, 3,000초 공통 시간창, canonical checker-bound exit와 공개 런처의 local
Whisper/MeloTTS lifecycle·exact readiness·Uvicorn queue·fresh API/cloudflared provenance 계약은 코드와 공개 runtime에서
확인했다. detached-clean `a73bcd24…`·tree `b02b3a1b…`의 receipt `47388d58…9b08`가 passed이고,
OpenAPI 126·`/admin/voice-runtime`·`local_whisper/small`·`melotts/melotts-korean`·queue 4가 공개 경로와 일치한다.
- **public task source-pin P0 완료:** watchdog·로그온 boot는 detached clean release의 commit/tree와
watchdog|boot/start script SHA를 mutation 전에 검증한다. 두 Scheduled Task를 같은 `a73bcd24…` stable root에
재등록하고 명시 실행해 `LastTaskResult=0`·Ready와 working directory pin을 확인했다. 실제 Windows 재부팅 뒤
자동복구 smoke는 별도 운영 게이트로 남는다.
- 인증 WSS ready/ping/close 1000, runtime 7 samples, Windows topology 7 samples의 30초 무마이크 rehearsal을 통과했다.
voice/runtime/topology evidence에는 UUID/email literal이 없고 `physical_capture=false`다. 다음 mutation은 사용자가
정확한 장치를 선택하고 명시 동의한 뒤의 3,120초 마이크 캡처다. G8 NAS는 새 material milestone이 생기기 전까지
읽기 전용 기준선으로 둔다.
1. OS·셸·현재 Git 상태를 확인하고, dashboard와 이 문서·TODO·backlog를 읽는다.
2. 필요한 항목의 2026-09-01 증거와 현재 NAS 상태를 read-only로 구분해 확인한다.
3. 외부 게이트는 증거 수집만 진행하며, 외부 입력·소유자 승인·비밀값을 추정하거나 생성하지 않는다.
4. 코드 또는 문서 변경 뒤에는 범위에 맞는 테스트와 SSOT checker를 실행한다.
### 완료 조건 판정 (2026-08-09 재검증)
2026-09-07 현재 baseline은 Python 3.11 API 전체 1090 passed/1 skipped/1 warning, gateway 전체 71 passed/1 warning이며 API 타입 체크와 Ruff도 통과했다. API의 1 skipped는 `VIGNETTE_USAGE_TEST_DATABASE_URL` 미설정 때만 건너뛰는 기존 선택형 격리 DB `test_admin_usage_postgres.py`이고 새 skip은 없다.
원래 active goal의 완료 선언 조건 네 가지를 실행으로 대조한 결과다.
최종 UI 회귀는 `session-mvp`+`uc-session-coach-voice` 58 passed/0 skipped(exit 0, 2.6분), alliance+admin 39 passed/1 skipped(exit 0, 1.7분)로 합계 97 passed/1 skipped다. skip은 `uc-review-alliance-pulse` 320px 테스트의 desktop 중복 실행 제외이며 mobile은 실행·통과했다. `session-review`의 disabled AI feedback privacy 제어 focused 1 passed(exit 0, 4.4초)도 유지한다. dashboard 10은 별도 검증이다.
| # | 조건 | 판정 | 근거 |
|---|---|---|---|
| 1 | G0~G8 각각의 코드 계약과 실제 runtime 증거 | **부분 충족** | G0~G6과 G8 종료 증거는 충족. current NAS runtime 복구와 G7 외부 GATE가 열려 있다 |
| 2 | 학생 홈→회기→리뷰→처방→재연습→전이 폐루프가 실제 API/DB/browser 통과 | **충족** | 일회용 클론에서 `returned-practice-db-closed-loop` desktop 2/2 + mobile 2/2, route mock 0 |
| 3 | current source 증거와 대시보드·TODO·backlog 일치 | **충족** | SSOT checker FAIL 0, dashboard E2E 10/10, count 33/2/0이 카드와 일치 |
| 4 | G7을 가짜·합성 증거로 DONE 처리하지 않음 | **충족** | `check-g7-external-proof.py` exit 1 유지, G7 카드는 `GATE` |
같은 날 source-only로 `test_session_turn_persistence.py`, `test_calibration_transfer_store.py`, `test_session_continuity_guard.py`는 78 passed와 `PendingDeprecationWarning` 1건, web typecheck는 통과했다. 이는 REQ-009~011의 재현·원인 해결·운영 증거가 아니다. 신규 제품 이슈의 현재 열린 상태와 다음 행동은 위 목록 및 [`TODO.md`](./TODO.md)를 따른다.
재검증 수치: API **921 passed**, gateway **58**, executor **28**, probe **11**, helper launcher **37**,
release agent **30**, current G7 종료 도구 **104/104**(runtime/checker 6파일 94 + intake 6 + standalone validator 4), API voice 통합 **71**,
MeloTTS 사이드카 **16**, local whisper 사이드카 **38**,
SSOT FAIL **0**,
dashboard E2E **10/10**, web api-types·typecheck·build 통과, ruff clean.
## 승인과 금지 경계
current-source fixture 묶음은 16:49 KST 단일 실행에서 **120/120 PASS(92.0s)**했다. dev dashboard file URL은 실행 cwd가
아니라 spec의 `import.meta.url`로 repo root를 찾으며, session-review fixture는 alliance pulse까지 소유한다. 관리자
live health 테스트는 React StrictMode 중복 조회의 첫 응답을 고정하지 않고 화면이 실제 반영한 성공
`/admin/users` 스냅샷과 KPI를 결속한다. 그 뒤 dev-dashboard spec/HTML이 변경됐고 최신 dashboard 10/10만 따로
재검증됐다. 따라서 current tree의 정직한 판정은 **110 불변 + 10 최신 분할 GREEN**이며, 새 clean commit에서
같은 120건을 한 번 재실행해 archive/source 결속 뒤의 결과를 고정한다.
- NAS compose 교체, DB migration 적용, 데이터·업로드 삭제, secret 변경, 원격 push, Pages 배포는 소유자 승인 뒤에만 한다.
- C-001의 외부 검수 입력, G7 물리 마이크 캡처, G8 승인 결정은 자동화나 fixture로 대체하지 않는다.
- 원본 아바타 bytes·DB reference·운영 백업은 보존 우선이다.
- `CLEANUP-001`: `outputs/local-history/project-cleanup-2026-09-07.zip`(SHA-256 `6a98d817d856c35654da6a11b74a6387c17adca0be55efda58c06c30b101b1ab`)과 manifest는 84 regular source file의 hash 대조 및 4 junction metadata를 보존한다. `tmp/`, `apps/web/tmp/`, 추적된 legacy `.bak` 4개, 정리 대상 attachment·중복 generation 결과는 `outputs/local-history/retained-sources-2026-09-07/`로 가역 이동했고 source 경로 정리는 완료했다. 원본 영구 삭제는 0건이며 기존 `outputs/` 최종 workbook은 보존한다.
**따라서 `update_goal complete`를 호출하지 않는다.** 조건 1이 G7 external proof 때문에 미충족이고,
조건 4가 G7을 합성 증거로 메우는 것을 금지한다. G8은 clean-head NAS-origin까지 종료됐다(§3·§5).
### G0~G8 목표별 인수 기준
| Goal | 원 계획의 종료 조건 | 현재 인정 가능한 증거 | 다음 에이전트가 최종 종료 전에 할 일 |
|---|---|---|---|
| G0 | 100% provenance, evaluator/client 누수 0, Python/TS/DB schema conformance, 기존 회기 무회귀 | current producer census 29/29, 위반 0, migrations 14~16 2회 멱등 | current HEAD 전체 backend/schema 회귀와 최종 census를 다시 실행 |
| G1 | goal/task/bond 독립 저장, 축별 UI, gold 근거 span, 평가 실패 fail-closed | prediction 24/24 ready, 방향 9/9, error 0, recall 94.4%, precision 73.9%, pre/mid/post UI | current HEAD의 alliance desktop/mobile·DB-backed session 경로 재실행 |
| G2 | 5회기 연속성, 조기 경보 recall, false alert/uncertainty, synthetic 비임상 경계 | accuracy/recall 1.0, false alert 0, 실제 DB 10 sessions/30 measurements | current HEAD outcome trajectory API/UI 회귀 재실행 |
| G3 | rupture 유형 benchmark, missed/partial/resolved E2E, gaming/암기 방지 | macro-F1/status 1.0, gaming 0, 실제 DB/API missed→partial→resolved | text/SSE 회기 및 rupture UI desktop/mobile 재실행 |
| G4 | 모든 코칭 카드의 실행 재연습, 전후 근거 저장, unseen 전이 전 mastery 금지 | 실제 API/DB attempt·observer·teacher correction, browser route mock 0 | disposable DB에서 returned-practice desktop/mobile 4/4와 멱등 재확인 |
| G5 | 자기평가 잠금, calibration 감소, unseen transfer 유지, subgroup drift | actual transfer execution과 independent/model-run/instrument/evidence provenance | G4와 같은 disposable DB에서 actual POST·read reload·1→1 멱등 재확인 |
| G6 | 3-click queue, 원장 drilldown, 버전 재현, 역할/RLS/audit | safety metadata-only, safety priority 1 > deterioration 2, raw detail 0 | current HEAD producer/store/API와 supervisor UI 회귀 재실행 |
| G7 | 시간 정렬, **물리 마이크+공개 WSS 장시간 E2E**, text-only 대비 음성 이득, 동의/삭제 정책 | 내부 계약·공개 clean runtime·인증 WSS 무마이크 rehearsal 완료. canonical checker는 의도적으로 미실행/미종료 | 장치 선택·명시 동의→mic 3,120초→동시 runtime/topology→독립 human pack→checker exit 0 |
| G8 | source→draft→red-team→benchmark→catalog 재현, 누수/PII/무근거 0, 자동 calibration+rollback, incident→test/backlog 환류 | agentic worker·human gate·실제 rollback/restore receipt 2건, clean-head candidate/NAS-origin 112/112 | **종료.** 새 material milestone 전까지 NAS 기준선 보존 |
## 1. 작업 시작 전 반드시 읽을 파일
1. `AGENTS.md`
2. `docs/README.md`
3. `docs/HANDOFF.md` — 현재 문서
4. `docs/dev_dashboard.html` — 상태 SSOT
5. `docs/TODO.md` — 열린 작업 SSOT
6. `docs/ops/backlog-2026-06-26.md`
7. `docs/ops/outcome-os-g7-external-proof-readiness-2026-08-07.md` — **다음 작업의 핵심**
8. `docs/ops/nas-preview-g8-rollback-proof-runbook.md`
9. `docs/ops/nas-preview-deployment-evidence-2026-08-07.md`
10. `docs/ops/public-db-recovery-rehearsal-2026-08-07.md`
`docs/archive/`는 현재 상태 근거로 읽지 않는다.
## 2. 절대 보존 경계
- Windows 11 + PowerShell 환경이다. 실행 전 OS·셸·도구 경로를 다시 확인한다.
- 명령은 PowerShell 5.1 호환 문법을 기본으로 작성한다. NAS 원격 스크립트는 **UTF-8 base64 전달 방식**을 쓴다.
(`$()`가 PowerShell에서 먼저 확장되는 것을 막는다.)
- G8 배포 source는 `master` / `61a41d1f08239b5f9e99cfae42a206be250416af`이며 공유 worktree의 후속 변경은
이 clean release와 분리한다. stage/commit 시 `git add -A`를 쓰지 않고 명시적 경로만 다룬다.
- clean-head 구현은 unit 30/30, G7 runner/checker/topology는 86/86, G7 public launcher/sidecar는 80/80,
public task source pin은 provenance 11개를 포함한 combined 22/22, timestamp·voice 계약을 포함한 API는
921/921와 KST 실DB browser 1/1, G8 candidate/NAS-origin은 각각 112/112,
SSOT는 FAIL 0와 dashboard E2E 10/10을 통과했다. 다음 에이전트는 최초 diff review 뒤 동일한 focused gate를
다시 실행하고, 서로 다른 lane의 파일을
누락한 채 clean commit을 만들지 않는다.
- 기존·watchdog 생성 엔진 로그 4개와 `apps/web/test-results/`의 PNG 2개는 commit에서 제외하고 보존한다. 신규
`apps/api/app/test_session_read_model.py`
`docs/ops/evidence/g8-clean-head-material-milestone-2026-08-09.json`은 이번 목표 산출물이므로 clean source에
포함해야 한다.
시작 즉시 `git status --short`로 다시 확인하고 `git reset --hard`, `git checkout --`, 대규모 자동 포맷, 임의 삭제는 금지한다.
- Git commit/stage/push는 사용자가 요청하기 전에는 하지 않는다.
- 공개 복구 DB, 원본 volume, pre-recovery container, dump, NAS named volume, 이전 API/Web 이미지를 삭제하지 않는다.
- **로컬 포트 8001(공개 API origin) · 55432(공개 DB) · 9099/9100(엔진)은 공개 런타임이다.**
공개 Web은 Cloudflare Pages이고 5174는 `vnet.18ka.net`용 로컬 preview다. `scripts/dev-up.ps1`은 런타임
리스너를 정리할 수 있으므로 공개 런타임이 떠 있는 동안 실행하지 않는다.
- 비밀값, 이메일, cookie, 사용자 UUID가 포함된 raw response, `.env` 내용은 로그·증거·채팅에 출력하지 않는다.
## 3. G8 receipt + clean-head NAS-origin — 종료됨
### Gate6 계약 정정 (성립 불가능했던 이전 계약을 코드로 교체)
감사 대상 current API 이미지 `sha256:52e0e816…8b2d``com.docker.compose.project=vignette-preview-20260807`,
`service=api`, `version=2.20.1` **image label**을 갖고 있다. 따라서 "helper의 `com.docker.compose.*` key 0개"는
감사되지 않은 다른 이미지를 쓰지 않는 한 성립하지 않는다. 계약을 **key 부재 → 소속(membership)** 으로 바꿔
구현했다.
- 코드: `scripts/launch-nas-preview-g8-helpers.py`
- 테스트: `scripts/test_launch_nas_preview_g8_helpers.py` — **37/37**
- 계약: image 상속 label을 baseline으로 읽고 container의 모든 compose label이 baseline과 같거나 선언된 격리
override인지 검사 · 최종 project ≠ target · 최종 service ∉ {api,web,db,proxy} · argv에 target project/service
label 미주입(fake-runner) · exact container name/ID/`docker inspect` 증거 보존
- 런북: `docs/ops/nas-preview-g8-rollback-proof-runbook.md`
### 실제 실행 결과
| plan | receipt | 활성화된 API/Web 이미지 |
|---|---|---|
| `rollback-old` | `nas-g8-723eeef22eab05e63e3fafb0` | `79ec…4450` / `c530…2f28` |
| `restore-current` | `nas-g8-2738846cf2cf4fbe8ce0fc26` | `52e0…8b2d` / `6fdb…f215` |
- release gate·approval 각각 2회 멱등, lifecycle `executed`, artifact/approval/receipt binding, control-plane 분리 검증
- DB: `audit.ci_lifecycle_event` `rollback/executed` 2 · `audit.ci_human_approval_event` `authorize_rollback` 2 ·
`app.ci_release_gate` 2(전부 `pending_human_approval`, silent auto-promotion 0) · `ci_gate_artifact` 8
- HMAC journal 6 records, `previous_hash` 체인 전수 PASS, SHA256 `a5594feb…0690`
- 최종 상태: health 3/3, OpenAPI 126, auth 401, Web 200, helper 0, listener 0, 비밀 env 파기
- **계획 이탈:** Windows SSH 터널 `18018→8018`은 NAS sshd가 `administratively prohibited`로 direct-tcpip를 거부해
쓸 수 없었다. **sshd 설정은 바꾸지 않았고**, 같은 격리 계약의 NAS-side probe 컨테이너에서 loopback으로
실행해 control-plane(8018)/preview(8088) origin 분리는 그대로 유지했다. 다음 에이전트도 NAS에서
포트 포워딩을 기대하지 말 것.
- 기계 판독 증거: `docs/ops/evidence/nas-preview-g8-actual-rollback-2026-08-07.json`
## 4. 공개 서비스 복구 상태 — 이미 완료, 다시 망가뜨리지 말 것
공개 계정·회기 DB 복구는 2026-08-07 18:26 KST에 완료됐다. 2026-08-09 14:03 KST에는 Docker Desktop이
중단돼 DB listener가 사라지면서 public health가 일시적으로 `db=false`가 됐고, 18:02:55 KST에는 Docker Desktop
UI가 active DB container에 직접 stop을 보내 같은 증상이 재발했다. 두 번 모두 보존된 동일 컨테이너·볼륨만
재기동해 복구했다. 새 컨테이너·볼륨 생성이나 데이터 복원 덮어쓰기는 없었다.
- Public Web `https://vignette.chanpaca.net` · Public API `https://api-vignette.chanpaca.net` (origin `127.0.0.1:8001`)
- active DB container `vignette-dev-db`, volume `vignette_recovered_prod_20260807`, restart `unless-stopped`
- cutover 기준 복구 집계: users 84, sessions 30, turns 705, Google users 16, orphan sessions 0
- 2026-08-09 18:05 KST 재기동 후 owner read-only 집계: users 356, sessions 193, turns 726, Google users 16,
Google-owned sessions 31. 조회는 `BEGIN READ ONLY``ROLLBACK`했고 PII를 출력하지 않았다.
- health `environment=prod`, `db=true`, `engine=true`, `engine_mode=claude_cli`, dev-login disabled, unauth `/auth/me` 401
- **2026-08-09 G7 promotion snapshot:** local/public health는 `status=ok, db=true, engine=true`, Google OAuth
start는 302→`accounts.google.com`, 공개 OpenAPI는 126 paths이고 `/admin/voice-runtime`이 존재한다.
`/voice/health``local_whisper/small`·`melotts/melotts-korean`·WS queue 4를 보고한다.
- DB 컨테이너는 `vignette_recovered_prod_20260807` named volume과 `unless-stopped`를 유지한다. API/cloudflared는
detached-clean `a73bcd24…`·tree `b02b3a1b…`에서 실행되고 receipt `47388d58…9b08`가 passed다.
watchdog·로그온 task도 같은 stable root/commit/tree/script SHA에 pin해 명시 실행 결과 0·Ready를 확인했다.
실제 Windows 재부팅 뒤 자동복구 smoke만 남아 있다.
- **2026-08-12 public reconnect incident:** 휴면 Gradle daemon이 `127.0.0.1:8001`을 점유했고 cloudflared와
9882/9883 sidecar가 없는 상태에서 PowerShell 5.1 native stderr가 start/watch/boot의 복구 분기를 조기 종료했다.
exact Gradle PID만 중지하고 DB dump `9cbda31e…0ce5`(7,532,625 bytes·TOC 1,492)를 먼저 고정했다. 복구 코드는
103/103과 PS5.1 parser를 통과했고, detached-clean `a73bcd24…`·tree `b02b3a1b…`에서 API/cloudflared를
fresh 교체했다. receipt `47388d58…9b08` passed, public health 3/3·db/engine true·OpenAPI 126·local voice exact,
웹 진단 overlay/console error/`[NAME]` 0, watchdog·로그온 boot 명시 실행 result 0을 확인했다.
보안상 과거 `auth_session`은 복원하지 않았다. 사용자는 Google 재로그인이 필요하다.
보존물: pre-recovery container `vignette-dev-db-pre-recovery-20260807-182642`과 원본/recovery Docker volumes,
그리고 2026-08-09 fresh custom dump
`D:\workspace\vignette-backups\vignette-dev-db-vignette-20260809-122235Z.dump`
(SHA256 `f1fd569c…5f64`, 7,458,327 bytes)다.
과거 증거에는 pre-cutover dump `…20260807-091057Z.dump` SHA256 `6b84d5c8…af1f`, recovered dump
`…20260807-092630Z.dump` SHA256 `d7bcc396…68ed`, post-cutover dump `…20260807-095511Z.dump` SHA256
`660695c8…65b88`이 기록돼 있다. 이 세 과거 dump의 현재 위치는 별도로 재확인하지 않았으므로 삭제로 단정하지 말고
off-host/NAS/다른 경로를 확인하기 전 restore·cleanup을 금지한다. NAS의 pre-proof/pre-clean-head dump는 §5 기준으로
실제 존재와 hash를 재확인했다.
공개 DB에는 current dirty source를 배포하지 않는다.
## 5. NAS 배포 기준선 — 2026-08-09 clean-head DONE
- URL `http://100.116.83.60:8088` · project `vignette-preview-20260807` · remote root
`/volume1/docker/vignette-preview-20260807`.
- source HEAD `61a41d1f08239b5f9e99cfae42a206be250416af` · tree
`87dec55daf0a22ca5c2e489cd803a2f731e63b77` · 2회 동일 active archive
`4d15d055d74f33f1fa2ff470afbf900d565ab238b19c79df34ca7ba240119d4d`.
- API `sha256:d502195021beafe869c555f3ec80269426062ef4586dc15a3e049f9c8660e4b1` / Web
`sha256:9796c0925f6a984cb846b61a4f13fda4d403bda7b3a2b6232b728cebbcdc4b36` exact image가 실행 중이다.
- candidate session E2E **112/112**, 실제 NAS 평문 origin browser review **112/112**(11 specs,
desktop/mobile/single-run), health `status=ok·db=true·engine=true`, auth 401, OpenAPI 126.
- fresh custom dump `vignette-preview-pre-clean-head-retry-61a41d1f-20260809T112038Z.dump`, SHA256
`36ec8748cf28c520d9489d108dee9d4a154e6260f6996b4da3e0fe3b8a624db8`, 1,097,100 bytes,
TOC 1,738 / TABLE DATA 129를 보존했다.
- previous active `6030a677af7e87cbfabc422b553d108d53414fd3c446548734a13b036d35c611`과 이전 images,
named DB volume을 삭제하지 않았다.
배포 중 첫 108 gate가 103/5로 fail-closed된 이력(G5 fixture drift, StrictMode retry fixture, `shell.css` manifest 누락)은
지우지 않는다.
### 과거 비-secure origin 결함 — 수정·배포 검증 완료
배포 후 NAS 프리뷰(`http://100.116.83.60:8088`, 평문 HTTP·비-localhost)에 전체 회기 스펙을 돌려보니 24건이
실패했고, 원인은 단 하나였다. 페이지 스냅샷의 실제 예외는 `crypto.randomUUID is not a function`이다.
이 API는 **secure context(HTTPS 또는 localhost)에서만** 노출되는데 제품 코드 18곳이 fallback 없이 직접
호출했고, `RuptureRepairCard.tsx`는 렌더 시점(`useRef`)에 호출해 회기 리뷰 라우트 전체가 error boundary로
떨어졌다. 릴리스 게이트의 108/108은 **localhost 후보 스택**(secure context)에서 돌았기 때문에 이 경로를
한 번도 밟지 않았다.
- 수정: `apps/web/src/lib/uuid.ts``randomUuid()`로 통일. fallback도 `crypto.getRandomValues`를 우선
사용해 idempotency key의 예측 불가능성을 유지하고, Web Crypto가 아예 없을 때만 `Math.random`으로 내려간다.
- 회귀: `apps/web/e2e/insecure-context-uuid.spec.ts` **6/6** (직접 호출 0건 검사 포함), typecheck·build 통과.
- UUID와 portable SHA-256 수정은 archive `4d15d055…119d4d`로 NAS에 배포했고 actual-origin 112/112를 통과했다.
- 교훈: candidate gate를 localhost에서만 돌리면 secure-context 전용 API 결함을 못 잡는다. 배포 대상과
같은 scheme/host 형태에서 최소 한 번은 회기 리뷰 라우트를 열어봐야 한다.
## 6. 다음 실행 순서 — G8 종료, G7 외부 종료
### 6.1 G8: clean-head 릴리스 경로 — DONE
`scripts/run-outcome-os-release-agent.py`의 명시적 clean-head 경로는 구현됐다. 기존 patch mode를 기본값으로
보존하며, tracked-clean source worktree의 exact HEAD/tree/archive만 candidate로 사용한다. 수동 복사나 NAS 직접
빌드는 계속 금지한다.
구현된 **`--source-mode clean-head`** 계약은 다음과 같다.
1. tracked dirty가 있으면 fail-closed. untracked 로그·테스트 결과는 archive에서 제외한다.
2. exact Git HEAD, tree, `git archive` SHA를 증거에 기록한다.
3. 같은 HEAD의 archive를 두 번 만들어 SHA가 동일한지 검증한다.
4. candidate는 그 archive만 풀어 만들고 patch/manifest를 암묵 적용하지 않는다.
5. 기존 preflight·API full·Web type/build·release E2E·DB snapshot·rollback 절차는 그대로 유지한다.
6. Vite source 전용 `e2e/insecure-context-uuid.spec.ts`는 별도 localhost gate(5198)로 12/12 실행한다. UUID fallback
6건과 SHA-256 fallback·직접 호출 금지 6건을 함께 고정하고,
production candidate/NAS-origin은 실제 배포 번들에서 동작하는 11개 회기 스펙만 실행한다.
7. unit/fake runner가 tracked dirty, archive SHA drift, evidence binding, patch-mode 무회귀를 검증해야 한다.
검증: release-agent unit 30/30, source-only insecure-context gate 12/12, Ruff, py_compile, `git diff --check` PASS.
최종 실행은 detached clean HEAD `61a41d1f…6af`에서 candidate 112/112와 actual NAS-origin 112/112를 통과했다.
이하 preflight와 실패 기록은 최종 성공에 이르기까지의 역사 증거이며 다시 실행할 지시가 아니다.
#### 역사 기록: 첫 clean commit preflight — include 52 / exclude 6
2026-08-09 최종 경로 목록 SHA-256은 ordinal 정렬된 UTF-8 LF 경로를 줄바꿈으로 연결하고 마지막 개행 없이 계산해
include `4d94694ccf9def6945272c61f673cac0eaa9fb59f4f9b6c165d661d819f891a1`, exclude
`1ff8c26732772df6b5857bf84de01a53d7da9c3b7d52df08938e77f3a527f022`였다. 아래 목록은 첫 clean commit 범위를
감사하기 위해 보존한 이력이며 현재 worktree staging 목록이 아니다.
```text
apps/api/app/config.py
apps/api/app/routes/voice.py
apps/api/app/services/session_metrics.py
apps/api/app/services/voice.py
apps/api/app/session_read_model.py
apps/api/app/test_session_read_model.py
apps/api/app/test_voice_service.py
apps/api/app/test_voice_ws.py
apps/api/requirements.txt
apps/web/e2e/admin.spec.ts
apps/web/e2e/dev-dashboard.spec.ts
apps/web/e2e/self-directed-learning-loop.spec.ts
apps/web/e2e/session-mvp.spec.ts
apps/web/e2e/session-persistence.spec.ts
apps/web/e2e/session-review-fixture.ts
apps/web/e2e/session-review.spec.ts
apps/web/index.html
docs/HANDOFF.md
docs/TODO.md
docs/decisions/local-voice-stack.md
docs/dev_dashboard.html
docs/guides/local-development.md
docs/guides/testing.md
docs/ops/backlog-2026-06-26.md
docs/ops/evidence/g8-clean-head-material-milestone-2026-08-09.json
docs/ops/nas-preview-g8-rollback-proof-runbook.md
docs/ops/outcome-os-g7-external-proof-readiness-2026-08-07.md
docs/ops/public-runtime-watchdog.md
scripts/boot-public-runtime.ps1
scripts/capture-g7-topology-evidence.py
scripts/check-dev-dashboard-ssot.py
scripts/check-g7-external-proof.py
scripts/install-public-runtime-task.ps1
scripts/local-whisper-stt-server.py
scripts/melotts-server.py
scripts/probe-public-voice-sidecars.py
scripts/register-boot-task.ps1
scripts/run-g7-external-proof-window.py
scripts/run-outcome-os-release-agent.py
scripts/start-local-whisper-stt.ps1
scripts/start-public-runtime.ps1
scripts/test_g7_external_proof.py
scripts/test_g7_topology_evidence.py
scripts/test_local_whisper_stt_server.py
scripts/test_melotts_server.py
scripts/test_outcome_os_release_agent.py
scripts/test_public_runtime_watchdog_provenance.py
scripts/test_public_voice_sidecar_probe.py
scripts/test_run_g7_external_proof_window.py
scripts/test_start_public_runtime_contract.py
scripts/watch-public-runtime-hidden.vbs
scripts/watch-public-runtime.ps1
```
아래 6개와 ignored `infra/.env.nas-preview`는 절대 stage하지 않는다. NAS 실행 시 env는
`--nas-env-file D:\workspace\vignette\infra\.env.nas-preview`로 외부 주입한다.
```text
apps/api/engine.public.err.log.2026-08-07T2200.bak
apps/api/engine.public.err.log.20260809-160042.bak
apps/api/engine.public.out.log.2026-08-07T2200.bak
apps/api/engine.public.out.log.20260809-160042.bak
apps/web/test-results/g7-voice-consent-desktop.png
apps/web/test-results/g7-voice-consent-mobile.png
```
#### 2026-08-09 clean-head 실행 결과 — NAS mutation 0
- dry-run `D:\workspace\vignette-recovery\g8-clean-head-dry-run-20260809.json`: `ok=true`,
active `6030a677…c611`, desired archive `6b1b15bd…a3f7`, `deployment_mutated=false`.
- execute 1 `...\g8-clean-head-execute-20260809.json`: Windows Git archive의 CRLF 때문에 생성 API 타입 byte check가
실패했다. archive SHA 검증 후 candidate의 생성 타입/셸 스크립트만 LF로 정규화하도록 controller를 수정했다.
NAS mutation 0, rollback 불필요.
- execute 2 `...\g8-clean-head-execute-20260809-r2.json`: API/types/typecheck/build/candidate stack은 통과했고
Playwright는 **109 passed / 5 failed**였다. source-only UUID 스펙 4건은 production build에서
`/src/lib/uuid.ts`를 import할 수 없는 gate 배치 오류라 별도 Vite gate로 분리했다. 남은 1건은 아래 제품 버그다.
NAS mutation 0, rollback 불필요. 동일 전체 execute를 아직 다시 시도하지 않았다.
#### 2026-08-09 clean-head `5221f79e` 실행 결과 — NAS mutation 0
- exact include 52개를 Yun Chan으로 커밋했다. HEAD `5221f79e…1c69`, tree `e4f15001…308b`, 2회 동일 archive
`1109bf86…0f4b`(482,662,400 bytes)를 detached clean worktree에서 고정했다.
- 새 milestone과 dry-run은 active `6030a677…c611` → desired `1109bf86…0f4b`,
`deployment_mutated=false`로 통과했다. 실행 직전 NAS custom dump
`6f4b95a7…b529f`(1,015,222 bytes, TOC 1,752 / TABLE DATA 129, 전용 pgdata volume)를 생성했다.
- execute는 API 921, API types, typecheck, build, insecure-context 6/6, candidate stack과 DB-backed
single-run 후반부까지 통과했지만 **110 passed / 2 failed**에서 승격 전에 중단됐다. 실패는 제품 화면이 아니라
`React 부트가 실제로 비어 있으면 진단을 유지한다`의 desktop/mobile 두 복제였다.
- 원인은 테스트가 Vite 개발 entry `/src/main.tsx`만 차단해 production candidate의 hashed entry
`/assets/index-*.js`를 차단하지 못한 환경 계약 누락이다. 두 entry를 함께 차단하도록 수정했고 Vite source 2/2와
production preview 2/2를 각각 통과했다. 전체 execute를 즉시 반복하지 않고 이 수정의 새 clean commit·archive를
다시 결속한 뒤 한 번만 재실행한다.
- 후속 clean commit `21461ab3…6fde`, tree `5c5f12a8…524e`, 2회 동일 archive `20d49694…fa1a` execute는
candidate **112/112**를 통과하고 NAS 새 API/Web image `d5021950…`/`376a3aa8…`까지 올렸다. 그러나 실제 NAS-origin
postdeploy의 G4 deliberate-practice desktop/mobile 2건이 110/112에서 실패해 active-state commit 전에 이전
`52e0e816…`/`6fdbb646…`로 자동 rollback했고 health·auth 401·OpenAPI 126·G0~G8 route와 image ID를 재검증해
rollback `verified`로 종료했다.
- focused exact-image 평문 origin에서 요청이 API까지 가지 않고 일반 오류로 끝나는 것을 재현했다. 원인은
문장 원문을 보내지 않기 위한 fingerprint가 `crypto.subtle.digest`에만 의존해 insecure origin에서 예외가 난 것이다.
원문 외부 전송 없이 portable SHA-256 fallback을 추가하고 표준 digest
`c06db0f0…62aa`를 payload에서 exact 검증했다. localhost secure origin 2/2와 실제 Tailnet insecure origin 2/2가
같은 digest로 통과했다. 이 수정의 새 clean commit만 다시 결속해 전체 execute를 재개한다.
#### P0 수정 완료 — 회기 timestamp UTC 전송과 KST 표시 날짜
- 최초 실패 스펙: `session-persistence.spec.ts``persists AI tutor coaching history through reload @single-run`.
- 코칭 생성·DB 저장·reload 후 `C` 마커까지는 성공했지만, 수정 전 화면이 `9:03:59 / 543:59` 경과로 계산해
`회기 시간이 종료됐어요` 모달을 띄웠고 그 모달이 코칭 마커 클릭을 가로막았다.
- 원인: `apps/api/app/session_read_model.py::iso()`
`datetime.fromtimestamp(ts).isoformat(timespec="seconds")`로 timezone 없는 문자열을 반환한다.
`apps/web/src/pages/Session.tsx::elapsedFromSession()``Date.parse(detail.started_at)`로 이를 브라우저 로컬 KST로
해석한다. Linux API UTC와 KST 브라우저 사이에서 약 9시간 오차가 생긴다.
- 수정: `session_read_model.iso()`와 dashboard용 `session_metrics.iso_datetime()`은 UTC `+00:00`을 반환한다.
회기 리뷰 달력 날짜는 서버 timezone이 아니라 고정 KST(+09:00)를 사용한다.
- backend 관련 65 passed, 후속 voice 계약을 포함한 API 전체 **921 passed**, Ruff·compile·web typecheck가 통과했다.
- 고유 Compose project/volume의 실제 DB/API/engine/browser에서 KST context로 focused 1/1 PASS했다. UTC suffix,
`status=active`, `ended_at=null`, 회기 나이 10분 미만, 화면 `00:00-09:59`, timebar/dialog 0,
coach mark 실제 클릭과 DB history/source-pack dialog를 모두 검증했다. 종료 뒤 container/volume/listener는 0이다.
- **테스트 강제 클릭이나 모달 닫기 우회는 사용하지 않았다.** 첫 clean source `5221f79e…1c69`에는 제품 수정과
controller 변경이 포함됐고, production entry 차단 회귀 2건의 테스트 수정만 후속 clean commit으로 다시 고정한다.
### 6.2 G8: NAS preview 배포와 실제 origin E2E — DONE
전용 NAS preview만 대상으로 백업→dry-run→배포→검증했고 공개 DB·공개 API는 건드리지 않았다. 아래 성공 조건을
전부 충족했다.
최종 fresh backup은
`/volume1/docker/vignette-preview-20260807/backups/vignette-preview-pre-clean-head-retry-61a41d1f-20260809T112038Z.dump`,
SHA-256 `36ec8748cf28c520d9489d108dee9d4a154e6260f6996b4da3e0fe3b8a624db8`,
1,097,100 bytes, TOC 1,738 / TABLE DATA 129다. release agent 자체에는 `pg_dump`/`pg_restore` 실행 계약이 없다.
따라서 **매 execute 직전** 별도 승인된 백업 단계에서 fresh custom dump를 만들고 SHA-256·byte size·TOC parse/count와
current DB identity를 증거에 결속한 뒤에만 승격한다. 기존 dump 존재만으로 이 단계를 생략하지 않는다.
release agent의 자동 rollback 범위는 exact API/Web image, Compose 적용 상태, active-state 파일뿐이다. 이미 적용된
DB migration이나 데이터 변경은 자동 복구하지 않는다. migration/data restore는 별도 owner 승인·대상 DB identity·dump
hash를 갖춘 복원 절차로만 수행한다. 이미지 rollback 성공을 DB rollback 성공으로 기록하면 안 된다.
- active archive `4d15d055…119d4d`, exact API/Web `d5021950…e4b1`/`9796c092…4b36`, fresh dump SHA,
previous `6030a677…c611` 보존.
- health ok, db/engine true, auth 401, OpenAPI 126.
- `http://100.116.83.60:8088` **실제 평문 NAS origin**에서 11-spec browser review 112/112.
- `crypto.randomUUID` 예외 0, error boundary 0, SSE→DB review PASS.
- 학생 홈→회기→리뷰→G4/G5 반환 연습의 desktop/mobile·DB read reload·멱등·UUID 비노출 유지.
- 앞선 실패 시 자동 rollback과 previous image 복구를 검증했고 최종 성공 뒤 G8 카드를 DONE으로 바꿨다.
### 6.3 G7: 외부 종료 Gate
G7 내부 소스 계약은 완료됐고 `scripts/check-g7-external-proof.py`는 현재 의도적으로 exit 1이다.
네 artifact를 **같은 public host, 겹치는 50분 시간창**으로 수집해야 한다.
운영·코드 선행조건과 무마이크 rehearsal은 닫혔다.
- 공개 API는 detached-clean `a73bcd24…`·tree `b02b3a1b…`에서 OpenAPI 126과
`/admin/voice-runtime`을 제공한다. `/voice/health``local_whisper/small` +
`melotts/melotts-korean`, Uvicorn WS queue 4를 정확히 보고하고 receipt `47388d58…9b08`가 passed다.
- local Whisper 9882와 MeloTTS 9883은 API보다 먼저 exact readiness를 통과했고, 런처는 기존 비정상 리스너를
임의 종료·재사용하지 않는다. public health는 promotion과 rehearsal 뒤에도 `status=ok·db=true·engine=true`다.
- **외부 실행 전 코드 P0 3건은 완료:** (1) production 프로세스 exit 0은 canonical checker
`returncode == 0 && gate_closed == true`에 결속한다. (2) 실제 브라우저 Origin
`https://vignette.chanpaca.net`은 exact HTTPS allowlist로, API/WSS/admin/topology
`api-vignette.chanpaca.net`은 별도 transport host와 `wss`/`https` scheme으로 검증한다. (3) 실제 capture 기본·최소를
3,120초로 올리고 sampler를 ceil+terminal sample로 계산하며, checker가 voice/runtime/topology 공통 교집합
**3,000초 이상**을 강제한다. current six-suite 87/87과 runtime sampler 4/4를 통과했다.
- 현재 공개 topology는 Windows host Uvicorn + cloudflared다. 명시적 `windows_host` topology mode는 구현 완료됐다.
API/cloudflared의 PID·start time·executable name/SHA256·command-line SHA256·Git SHA·cwd를 pin하고, 각 sample 전후
identity와 Git HEAD drift를 검사한다. RSS/peak RSS/CPU/handles/threads, process TCP/established/listener,
API listen-port owner/conflict와 host TCP high-water도 수집한다. 기존 `linux_compose`의 api+caddy·fresh-container
규칙은 그대로 유지한다.
- Windows topology는 API/cloudflared의 PID·start·exe SHA·command SHA·cwd와 detached-clean commit/tree,
runner/collector/checker SHA, `psutil==6.1.1`을 결속한다. 30초 rehearsal에서 API listener owner/conflict와
cloudflared TCP, RSS/CPU/handles/threads를 7회 수집했고 raw command line·endpoint·UUID·email은 저장하지 않았다.
- watchdog·로그온 boot 두 task도 같은 `a73bcd24…` stable root에 재등록했다. commit/tree/script hash marker,
working directory, `LastTaskResult=0`·Ready를 확인했다. 실제 Windows 재부팅 뒤 복구 smoke는 아직 남아 있다.
- 운영 Python에 `psutil`이 없으면 collector는 `command_unavailable:psutil`로 fail-closed한다. current
`apps/api/requirements.txt`와 명시적 Python 3.11 모두 `psutil==6.1.1`로 고정됐다.
- 현재 운영 STT 모델은 이 호스트에서 실측된 CPU int8 `small`로 고정한다. cuDNN 9가 설치되고 별도 성능·정확도
gate를 통과하기 전까지 launcher·API runtime metadata·50분 runner/checker expected model을 모두 `small`로 유지한다.
- human voice-gain pack은 category와 categorical κ를 필수로 포함하고, preregistration이 held-out 공개보다 앞서야 하며,
양 조건이 모두 관측된 50회기/150축만 paired 표본으로 집계한다. 한쪽 결측이면 양쪽 모두 최대오류 ITT로 처리한다.
`scripts/check-g7-human-voice-gain.py --input <pack.json>`을 먼저 통과하지 못하면 production runner는 마이크를
열기 전에 exit 2로 끝난다. 스키마는 `--print-schema`로 출력한다.
- 실제 평가팀 intake는 `scripts/prepare-g7-human-voice-gain-intake.py --create-template <dir>`로 시작한다. 생성물은
header-only이고 `template_only=true`라 증거가 아니다. provenance와 비식별 participant/labeler/observation CSV를
채운 뒤 `--compile <dir> --out <pack.json>`을 실행하면 외부 report의 ICC·κ와 행 재계산값의 일치를 확인하고
production gate가 전부 통과할 때만 기존 파일을 덮어쓰지 않고 최종 pack을 만든다. 콘솔은 경로·키·라벨·잘못된
셀 값을 반사하지 않는다.
runtime/checker 6파일 94건, intake 6건, standalone validator 4건을 합쳐 관련 계약 104/104를 통과했다.
빈 템플릿 행동 검증은 `template_is_evidence=false`, compile exit 1, `pack_written=false`를 반환했다. 실제 사람 데이터는 아직 없다.
- 무마이크 rehearsal 산출물은
`D:\workspace\vignette-runtime-evidence\g7-rehearsal-b34623f3db05-20260809T134105Z`에 있다.
voice `61af2e98…009c`, runtime `c6e8670e…4454`, topology `89f1cb86…a251`이며 세 leg 모두 passed,
`physical_capture=false`다.
## 최소 문서 검증
```powershell
& $py -X utf8 -B scripts/check-g7-external-proof.py `
--voice-soak <soak.json> --runtime <runtime.json> `
--topology <topology.json> --human-voice-gain <pack.json>
```
1. `scripts/soak-public-voice-websocket.py` v4 — 운영 기본값 `local_whisper`/`melotts`의 ready metadata가
실제 provider/model과 정확히 일치하는 authenticated public WSS,
**명시 동의 물리 마이크** 50분 양방향 `passed`. `--confirm-physical-capture` 없이는 장치 열거·캡처를 하지 않는다.
2. `scripts/capture-g7-runtime-evidence.py` — 같은 시간창의 관리자 endpoint worker/Uvicorn queue high-water.
3. `scripts/capture-g7-topology-evidence.py` — 같은 host·exact image의 50분 cgroup/proc/Docker/TCP high-water.
4. 독립 blind human voice-gain pack — held-out 30명 외 calibration split 참가자 포함 총 최소 31명 /
held-out 50회기 / 150 paired axis / blind evaluator 2인 /
ICC(A,1) ≥ 0.75 · κ ≥ 0.70 · gain ≥ 0.01 · participant-cluster bootstrap 10,000회 95% CI lower > 0.
준비 상세는 `docs/ops/outcome-os-g7-external-proof-readiness-2026-08-07.md`.
G7 Windows topology 회귀 명령:
```powershell
Set-Location D:\workspace\vignette
$env:PYTHONPATH='D:\workspace\vignette\apps\api'
& $py -X utf8 -m unittest scripts/test_g7_external_proof.py scripts/test_g7_topology_evidence.py `
scripts/test_g7_runtime_evidence.py scripts/test_g7_external_voice_soak.py `
scripts/test_run_g7_external_proof_window.py apps/api/app/test_g7_voice_gain_evidence.py
& $ruff check scripts/capture-g7-topology-evidence.py scripts/check-g7-external-proof.py `
scripts/run-g7-external-proof-window.py scripts/test_g7_topology_evidence.py `
scripts/test_g7_external_proof.py scripts/test_run_g7_external_proof_window.py
```
**사용자에게 받아야 하는 것 (코드로 대체 불가):**
- 물리 장치 선택과 3,120초 실행에 대한 **명시적 동의**
- 실제 참가자·독립 평가자 운영 승인
합성 label이나 무동의 mic probe로 대체하지 않는다. `check-g7-external-proof.py` exit 0 전에는 G7 메인 상태를
DONE으로 바꾸지 않는다.
## 7. 학생 자기주도 학습 폐루프
```text
학습자 홈 추천 → 새 회기/사전 설정 → 실제 Session → 종료 리뷰 → G4 처방 또는 G5 전이 의도
→ 별도 재연습 회기 → completed-session observer → 실제 G4 attempt / G5 transfer execution
→ read-model reload → before/after·진행도·멱등 재확인
```
실제 DB-backed browser 증거: `apps/web/e2e/returned-practice-db-closed-loop.spec.ts` +
`apps/web/e2e/harness/prepare-returned-practice-db.py`, desktop 2/2 + mobile 2/2, route mock 0,
visible raw UUID 0, getUserMedia/enumerateDevices 0, horizontal overflow 0.
이 harness는 **disposable clone**(전용 DB container + 전용 API 포트 + 전용 vite 포트)에서만 실행한다.
공개 런타임 포트를 재사용하지 않는다. `--api-base-url`, `--database-url`, `--practice-internal-token`,
`--transfer-internal-token`이 필요하고 API health의 `db`·`engine`이 모두 true여야 한다.
2026-08-07 재실행 절차(그대로 재현 가능):
1. `docker run -d --name vignette-g8-e2e-db-<날짜> -p 127.0.0.1:55439:5432` + `infra/db/init` 마운트,
`POSTGRES_USER=vignette_owner` / `APP_DB_USER=vignette_app`. `app.ci_regression_dag_node`가 생기면 준비 완료.
2. 별도 엔진 게이트웨이를 **새 포트**(예: 9199)에 띄운다. 상주 게이트웨이의 claude 세션이 죽어 있으면
`engine=false`가 되므로 공개용 9099를 재사용하지 않는다.
3. uvicorn API를 8021에, vite를 5199에 띄우고 `ENGINE_URL`을 2번 게이트웨이로 지정한다.
4. harness 실행 → `E2E_RETURNED_PRACTICE_DB_CLOSED_LOOP=1` + `E2E_RETURNED_PRACTICE_FIXTURE`로 spec 실행.
5. 결과 기준: desktop 2/2 + mobile 2/2 = **4 passed**. 끝나면 컨테이너·프로세스를 모두 정리한다.
G4/G5 핵심 production bug 수정은 보존한다.
- G4 runtime SQL `digest(...)``app.digest(...)`
- G5 JSONB bind는 `json.dumps` string이 아니라 dict/list object 전달
- route는 nested Pydantic suite를 `body.model_dump()` dict로 깨지 않고 typed object로 전달
## 8. 최종 검증 명령과 현재 기준선
```powershell
$py = 'C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe'
$ruff = 'C:\Users\encep\AppData\Local\hermes\hermes-agent\venv\Scripts\ruff.exe'
```
### Backend full — 기준 API **921 passed**, gateway **58 passed**
```powershell
Set-Location D:\workspace\vignette\apps\api
& $py -X utf8 -B -m pytest -p no:cacheprovider app -q
& $py -X utf8 -B -m pytest -p no:cacheprovider engine_gateway -q
```
### G8 executor/probe/helper/release governance — 기준 28 / 11 / 37 / 30
```powershell
Set-Location D:\workspace\vignette
& $py -X utf8 -B -m unittest scripts/test_serve_nas_preview_rollback_executor.py
& $py -X utf8 -B -m unittest scripts/test_probe_nas_preview_g8_rollback.py
& $py -X utf8 -B -m unittest scripts/test_launch_nas_preview_g8_helpers.py
& $py -X utf8 -B -m pytest -p no:cacheprovider scripts/test_outcome_os_release_agent.py -q
& $ruff check scripts/serve-nas-preview-rollback-executor.py scripts/test_serve_nas_preview_rollback_executor.py scripts/probe-nas-preview-g8-rollback.py scripts/test_probe_nas_preview_g8_rollback.py scripts/launch-nas-preview-g8-helpers.py scripts/test_launch_nas_preview_g8_helpers.py scripts/run-outcome-os-release-agent.py scripts/test_outcome_os_release_agent.py
```
### Web contract/type/build
```powershell
Set-Location D:\workspace\vignette\apps\web
npm run check:api-types
npm run typecheck
npm run build
```
### 현재 HEAD 학생 UX·접근성 focused — 기준 **20/20**
2026-08-09 읽기 전용 재감사에서 `check:api-types`·typecheck·build와 아래 fixture-only 묶음이 desktop/mobile
20/20을 통과했다: Alliance Pulse 8, 자기주도 폐루프 2, insecure-origin UUID 6, pre/mid 자기점검 2,
시간만료 경고·종료 모달 2. 390×844·320×568 overflow 0, tab 키보드, Enter CTA, 44px 조작부,
4.5:1 대비, raw UUID/theory key 비노출, 동의 전 `getUserMedia`/`enumerateDevices` 0도 확인했다.
추가 회귀에서 새 음성 동의 modal을 실제로 수락한 뒤 위기 음성 종료까지 가는 desktop/mobile, 모바일 review
filter 44px 계약을 desktop/mobile로 검증해 4/4 통과했다. dashboard의 file-origin은 spec-relative repo root로
고정했고, session-review fixture 소유권과 viewport assertion, admin live snapshot race를 교정했다. 16:49 단일 실행은
**120/120 PASS(92.0s)**였지만, 그 뒤 dashboard 변경분은 dashboard 10/10으로만 따로 통과했다. 따라서 현 트리는
110 불변 + 10 최신 분할 GREEN이고, 최종 clean source에서 아래 exact 묶음을 다시 단일 실행해 HEAD/tree/archive
결속 뒤 120/120을 요구한다.
#### current-source fixture exact 120 — clean HEAD 재현 명령
정확한 수집 구성은 `dev-dashboard` 10 + `session-mvp` 24 + `session-review` 24 + `learner` 14 +
`session-layout` 8 + `admin` 40 = desktop/mobile 120이다. `127.0.0.1:8000`은 반드시 복구 dump에서 만든
**disposable API/DB/engine 전용 스택**이어야 한다. 이 묶음은 dev-login·세션 쓰기를 포함할 수 있으므로 public
8001, 복구 원본 55432, candidate DB, NAS DB에 연결하지 않는다.
```powershell
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
$OutputEncoding = [Text.UTF8Encoding]::new($false)
$cleanRoot = 'D:\workspace\vignette-clean-<NEW_SHA>'
$cleanWeb = Join-Path $cleanRoot 'apps\web'
$tempRoot = Join-Path $env:TEMP 'vignette-clean-fixture-120'
$tempWeb = Join-Path $tempRoot 'apps\web'
$tempEvidence = Join-Path $tempRoot 'docs\ops\evidence'
if (Test-Path -LiteralPath $tempRoot) { throw "기존 TEMP와 충돌: $tempRoot" }
$pw = Join-Path $cleanWeb 'node_modules\.bin\playwright.cmd'
if (-not (Test-Path -LiteralPath $pw -PathType Leaf)) {
throw 'clean worktree에서 npm ci --ignore-scripts를 먼저 실행해 node_modules를 준비할 것'
}
$health = Invoke-RestMethod -Uri 'http://127.0.0.1:8000/health' -TimeoutSec 5
if ($health.status -ne 'ok' -or -not $health.db -or -not $health.engine) {
throw 'disposable API8000의 db/engine ready가 아님'
}
New-Item -ItemType Directory -Path $tempWeb -Force | Out-Null
New-Item -ItemType Directory -Path $tempEvidence -Force | Out-Null
$config = Join-Path $cleanWeb 'playwright.config.ts'
$files = @(
'e2e/dev-dashboard.spec.ts',
'e2e/session-mvp.spec.ts',
'e2e/session-review.spec.ts',
'e2e/learner.spec.ts',
'e2e/session-layout.spec.ts',
'e2e/admin.spec.ts'
)
$env:PLAYWRIGHT_PORT = '15374'
$env:VITE_API_PROXY_TARGET = 'http://127.0.0.1:8000'
Remove-Item Env:PLAYWRIGHT_BASE_URL -ErrorAction SilentlyContinue
Remove-Item Env:PLAYWRIGHT_SKIP_WEB_SERVER -ErrorAction SilentlyContinue
$code = 99
try {
Push-Location $tempWeb
try {
& $pw test @files ("--config={0}" -f $config) `
--project=chromium-desktop --project=chromium-mobile `
--workers=4 --reporter=line ("--output={0}" -f (Join-Path $tempRoot 'pw-results'))
$code = $LASTEXITCODE
} finally {
Pop-Location
}
} finally {
$listeners = @(Get-NetTCPConnection -State Listen -LocalPort 15374 -ErrorAction SilentlyContinue)
$listenerError = $null
if ($listeners.Count -ne 0) { $listenerError = "Playwright Vite listener 잔존: $($listeners.OwningProcess -join ',')" }
if (Test-Path -LiteralPath $tempRoot) {
$resolved = (Resolve-Path -LiteralPath $tempRoot).Path
if ($resolved -ne $tempRoot) { throw "unexpected TEMP target: $resolved" }
[IO.Directory]::Delete($tempRoot, $true)
}
if ($listenerError) { throw $listenerError }
}
if ($code -ne 0) { throw "clean fixture 120 failed: $code" }
```
과거 API-ready 없이 돌린 별도 Vite-only 혼합 실행의 `session-layout` 8건은 DB persona가 없어 제품 assertion 전에
`seed_fallback/degraded`로 중단됐다. 위 exact 120 명령은 이 사각지대를 닫기 위해 disposable API의 db/engine health를
선행 강제한다. 최종 완료 때는 `session-layout` 8/8과 returned-practice DB closed-loop 4/4를 모두 다시 통과시킨다.
### Dashboard SSOT + E2E — 기준 SSOT PASS(FAIL 0), unit 5/5, dashboard 10/10
```powershell
Set-Location D:\workspace\vignette
& $py -X utf8 -B scripts/check-dev-dashboard-ssot.py
& $py -X utf8 -B -m pytest -p no:cacheprovider scripts/test_dev_dashboard_ssot.py -q
Set-Location D:\workspace\vignette\apps\web
.\node_modules\.bin\playwright.cmd test e2e/dev-dashboard.spec.ts --project=chromium-desktop --project=chromium-mobile --workers=1 --reporter=line
```
status count를 바꾸면 `scripts/check-dev-dashboard-ssot.py``EXPECTED_STATUS_COUNTS`
`apps/web/e2e/dev-dashboard.spec.ts``metrics.done`/`metrics.doing`**함께** 갱신한다.
### 격리 NAS 런타임 회기 E2E
```powershell
Set-Location D:\workspace\vignette\apps\web
$env:PLAYWRIGHT_BASE_URL='http://100.116.83.60:8088'
$env:PLAYWRIGHT_SKIP_WEB_SERVER='1'
node.exe .\node_modules\@playwright\test\cli.js test e2e/session-layout.spec.ts e2e/session-persistence.spec.ts `
e2e/self-directed-learning-loop.spec.ts e2e/alliance-pulse.spec.ts e2e/outcome-trajectory.spec.ts `
e2e/rupture-repair.spec.ts e2e/deliberate-practice.spec.ts e2e/calibration-transfer.spec.ts `
e2e/supervision-research.spec.ts e2e/multimodal-alliance.spec.ts e2e/continuous-improvement-admin.spec.ts `
--project=chromium-desktop --project=chromium-mobile --project=chromium-single-run --workers=1 --reporter=line
```
전체 Playwright inventory는 629 tests / 45 files다. 실행 환경/API/DB를 정확히 맞추지 않고 fixture failure를
제품 failure로 오인하지 않는다. 같은 blocker가 두 번 반복되면 전체 재시도 대신 원인·증거·수정 계획을 먼저 보고한다.
주기 실회기 검증은 `scripts/run-periodic-learner-e2e.py`가 소유한다. NAS 112건 중 실 API/DB는 22건이고
route fixture는 90건이므로 전체 숫자를 실제 회기 폐루프로 부르면 안 된다. 새 runner는 clean HEAD/tree에서만
전용 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은 reload 뒤 persisted episode를 `학습자만 실행`으로 표시하는 제품 결함을 검출했다.
5·6차 clean run은 핵심 동일 학습자 SSE·review·G4/G5 `0→1→1`을 연속 통과했지만 fixture 공유와 mobile success-state locator를 각각 fail-closed했다. 7차 clean `aa81af29…`는 첫 전체 GREEN을 만들었다. `94666192…``f97e7fad…` run은 disposable DB init에서 멈췄고 두 번째 receipt의 cleanup 전 로그가 `99_app_role.sh``#!/usr/bin/env bash\r` exit 127을 확정했다. Git blob LF를 Windows checkout에서도 보존하도록 `.gitattributes``*.sh text eol=lf`를 고정했다. 공통 톱바 desktop/mobile 44px을 포함한 clean `b02bee26…`·tree `8f677bba…`의 최신 single full run은 487.3초에 GREEN이었다. receipt `periodic-learner-e2e-20260812-171547.json`·SHA-256 `8c11a136…af8e3`은 same-learner SSE·review·G4/G5 `0→1→1`, returned desktop/mobile 4/4, route mock 0, public/NAS/active DB 접촉 0과 exact cleanup 0을 증명한다. heartbeat는 최신 GREEN이 6시간 이상 오래됐거나 material milestone이 바뀔 때만 다시 실행한다.
## 9. 핵심 변경 파일
- `scripts/launch-nas-preview-g8-helpers.py` · `scripts/test_launch_nas_preview_g8_helpers.py` (신규, Gate6 계약)
- `scripts/serve-nas-preview-rollback-executor.py` · `scripts/test_serve_nas_preview_rollback_executor.py`
- `scripts/probe-nas-preview-g8-rollback.py` · `scripts/test_probe_nas_preview_g8_rollback.py`
- `scripts/run-outcome-os-release-agent.py` · `scripts/test_outcome_os_release_agent.py`
- `scripts/check-dev-dashboard-ssot.py` · `scripts/test_dev_dashboard_ssot.py`
- `scripts/check-g7-external-proof.py` · `scripts/soak-public-voice-websocket.py` ·
`scripts/capture-g7-runtime-evidence.py` · `scripts/capture-g7-topology-evidence.py` ·
`scripts/run-g7-external-proof-window.py`와 대응 G7 테스트 3개
- `apps/web/src/lib/uuid.ts` · `apps/web/e2e/insecure-context-uuid.spec.ts` (신규, 비-secure origin 결함 수정)
- `apps/api/app/session_read_model.py` · `apps/api/app/services/session_metrics.py` ·
`apps/api/app/test_session_read_model.py` · `apps/web/e2e/session-persistence.spec.ts` (UTC/KST P0와 실DB browser 회귀)
- `apps/web/index.html` · `apps/web/e2e/self-directed-learning-loop.spec.ts` (SPA route 전환 중 boot diagnostic 회복)
- `apps/web/e2e/admin.spec.ts` · `apps/web/e2e/dev-dashboard.spec.ts` ·
`apps/web/e2e/session-review-fixture.ts` · `apps/web/e2e/session-review.spec.ts`
- `scripts/watch-public-runtime.ps1` · `scripts/boot-public-runtime.ps1` ·
`scripts/install-public-runtime-task.ps1` · `scripts/register-boot-task.ps1` ·
`scripts/watch-public-runtime-hidden.vbs` · `scripts/test_public_runtime_watchdog_provenance.py`
- `apps/web/e2e/returned-practice-db-closed-loop.spec.ts` ·
`apps/web/e2e/harness/prepare-returned-practice-db.py`
- `docs/dev_dashboard.html` · `docs/TODO.md` · `docs/README.md` · `docs/ops/backlog-2026-06-26.md`
- `docs/ops/nas-preview-g8-rollback-proof-runbook.md` ·
`docs/ops/evidence/nas-preview-g8-actual-rollback-2026-08-07.json` ·
`docs/ops/nas-preview-deployment-evidence-2026-08-07.md`
## 10. 최종 원칙
- source-only PASS를 runtime DONE으로 부르지 않는다.
- health만 보고 배포 완료라고 하지 않는다. auth, OpenAPI, assets, browser SSE→DB review를 함께 본다.
- synthetic control plane을 실제 NAS rollback으로 과장하지 않는다.
- G7 물리 마이크·사람 평가를 무동의/합성 데이터로 대체하지 않는다.
- current/previous images와 DB backup을 확인하기 전 destructive operation을 실행하지 않는다.
- 성공보다 정직한 fail-closed 증거가 우선이다.
## 11. 권장 멀티에이전트 실행 구조
사람 개발자 작업계획은 만들지 않는다. 구현·테스트·배포·증거 동기화는 에이전트가 수행하고, 사람에게는 코드로
대체 불가능한 동의·인증 세션·실제 참가자/평가자 승인만 요청한다. 동시 작업은 최대 세 하위 lane으로 나누되,
NAS/public/DB mutation owner는 항상 한 에이전트만 둔다.
1. **G7 proof lane:** 완료된 clean public runtime과 무마이크 rehearsal을 기준선으로 보존하고, 장치 선택·명시 동의 뒤
3,120초 mic/runtime/topology 동시 캡처와 human pack·canonical checker를 닫는다.
2. **G8/NAS recovery lane:** 완료된 archive·images·rollback 증거는 보존한다. current relay는 source-pinned launcher
8/8과 detached-clean `f08e03de…f191` exact check-only까지 완료됐고 실제 9100은 0 listener다. 소유자 승인 전
mutation 0; 승인 뒤 같은 source/tree·hash 결속으로 actual 1회→NAS-origin 재검증.
3. **E2E/SSOT lane:** local fixture, disposable DB browser, visual/accessibility, dashboard/TODO/backlog 정합을 읽기 전용으로 감사.
루트 에이전트는 각 lane의 증거 SHA와 실패 원인을 합쳐 게이트를 판정한다. 같은 blocker가 두 번 반복되면 세 번째
재시도 전에 원인·증거·수정 계획을 사용자에게 보고한다.
## 12. 새 에이전트에 그대로 줄 시작 프롬프트
```text
D:\workspace\vignette의 docs/HANDOFF.md를 인수인계 SSOT로 읽고, AGENTS.md → docs/README.md →
docs/dev_dashboard.html → docs/TODO.md → docs/ops/backlog-2026-06-26.md 순서로 현재 상태를 재확인해.
G8 배포 source는 HEAD `61a41d1f…6af`·tree `87dec55d…3b77`·archive `4d15d055…119d4d`다. 격리 NAS의
API/Web `d5021950…e4b1`/`9796c092…4b36`, candidate 112/112, actual NAS-origin 112/112, 당시 health·auth 401·OpenAPI
126을 통과했고 fresh dump `36ec8748…24db8`과 previous `6030a677…c611`을 보존했다. 이 종료 증거는 DONE이지만
current NAS는 engine relay 부재로 DEGRADED이고 2026-08-12 15:24 health 3회가 타임아웃했다. 실제 relay 복구는
소유자 승인 전 금지한다. 현재 공유 worktree의 후속 변경은 이 배포 source와 분리해서 다뤄.
최종 목표는 G0~G8 아홉 목표를 코드-only가 아니라 실제 runtime 증거로 모두 닫는 것이다. G0~G6과 G8은 DONE이다.
G7은 내부 구현과 clean public runtime·local_whisper/melotts ready·authenticated public WSS 무마이크 rehearsal까지 완료했다.
이제 사용자가 고른 장치와 명시 동의를 받은 물리 마이크 3,120초 soak, 같은 시간창의 runtime/topology high-water,
독립 human voice-gain pack을 모아 canonical checker exit 0을 만들어야 한다.
G7 runner exit의 checker exit 0/gate_closed 결속, browser Origin과 API/WSS host 분리, 3,120초 capture와
3,000초 공통 overlap, detached-clean HEAD/tree, runner/collector/checker SHA와 exact psutil pin은 소스에서 완료됐다.
API/cloudflared는 detached-clean `a73bcd24…`·tree `b02b3a1b…`에서 새 PID로 교체됐고 safe receipt
`47388d58…9b08`가 passed다. 공개 OpenAPI 126·`/admin/voice-runtime`·local provider/model·queue 4와
public health db/engine true를 확인했다. 30초 rehearsal은 인증 WSS·runtime·Windows topology 세 leg 7 samples를
모두 통과했고 `physical_capture=false`·UUID/email 0이다. 이 증거를 물리 마이크나 사람 평가로 과장하지 마.
watchdog·로그온 boot도 같은 stable root와 exact Git commit/tree/script SHA에 재등록했고 두 task의 명시 실행 결과가
0·Ready다. 실제 Windows 재부팅 뒤 자동복구 smoke는 별도 운영 게이트로 남는다. cwd/Git pin이나 기존 Linux Compose
gate를 약화하지 말고 합성 증거로 G7을 DONE 처리하지 마.
학생 자기주도 폐루프는 홈 추천→회기→리뷰→G4 처방/G5 전이→별도 재연습→completed-session observer→
actual attempt/execution→read-model reload→before/after·멱등까지 desktop/mobile 실제 DB/browser로 검증해.
최종 검증은 API full, gateway full, schema/provenance, Web API types/typecheck/build, fixture E2E, disposable DB E2E,
NAS-origin E2E, visual/accessibility, auth/OpenAPI/assets/SSE→DB review를 포함해야 한다.
독립 작업은 서브에이전트로 병렬화하되 NAS/public/DB mutation은 단일 owner만 수행해. 공개 DB 복구 volume과
pre-recovery container/dumps, NAS named volume과 previous images는 삭제하지 마. 비밀/PII/raw UUID를 증거에 남기지 마.
과거 로컬 recovery dump는 현재 위치를 별도로 확인하기 전 삭제로 단정하거나 그 파일을 전제로 복원하지 마.
2026-08-09 fresh public dump `vignette-dev-db-vignette-20260809-122235Z.dump` SHA `f1fd569c…5f64`도 보존해.
source-only PASS나 health-only를 DONE으로 부르지 말고, 대시보드·TODO·backlog·HANDOFF를 마지막 증거와 함께 동기화해.
모든 게이트가 실제로 닫히기 전에는 goal complete를 선언하지 마.
python -X utf8 scripts/check-dev-dashboard-ssot.py --json
git diff --check
```

View file

@ -1,6 +1,6 @@
# 한신대학교 SW중심대학 산학협력 — 생성형 AI 심리상담 시뮬레이션 플랫폼 마스터플랜
> 통합 기준 문서 (SoT, Single Source of Truth) · 작성 2026-06-25 · 트웬티온스 산학협력
> 제품 설계의 통합 기준 문서(SoT) · 작성 2026-06-25 · 트웬티온스 산학협력. 현재 상태·검증 증거의 권위 기준은 [`dev_dashboard.html`](./dev_dashboard.html)이며, [`TODO.md`](./TODO.md)는 열린 실행 항목 인덱스다.
> 제약: **20주 / 매칭금 500만 / 기존 풀스택팀**
>
> **🔄 2026-06-25 업데이트 (윤찬 확인):** **IRB 정식 심의 불필요.** 수련생 대상 *교육 활용*이라 인간 대상 임상시험이 아니므로 IRB 면제. → 아래 "IRB 승인"이 걸려있던 의존성/리드타임은 모두 해소(Phase 3 게이트 제거, 즉시 착수 가능). 단 **미성년 원본 사례데이터(0615 등) 활용동의 + 개인정보 처리방침**은 별도로 한신대 공문 확인 유지(IRB와 무관한 데이터 거버넌스 사안).

View file

@ -3,7 +3,7 @@
> 한신대 AI 심리상담 시뮬레이션 훈련 플랫폼 · **단일 종합 설계서**
> 작성 2026-06-25 · 트웬티온스 산학협력
> 종합 대상 4개 영역: ① 회기 간 연속성 메모리(최우선 갭) · ② 지식 데이터베이스(KB) · ③ 페르소나 일관성 · ④ 3-AI 메모리 뷰/정보비대칭/종단평가
> 상위 SoT: `HANSHIN_AI_PLATFORM_MASTERPLAN.md` + `MASTERPLAN_REVISIONS.md`(BLUE TEAM 패치). **본 문서는 두 상위 문서의 확정 전제를 깨지 않는 하위 통합 설계다.**
> 상위 설계: [`MASTERPLAN.md`](./MASTERPLAN.md) + [`redteam/MASTERPLAN_REVISIONS.md`](./redteam/MASTERPLAN_REVISIONS.md)(BLUE TEAM 패치). **본 문서는 두 상위 문서의 확정 전제를 깨지 않는 하위 통합 설계다.**
---
@ -592,7 +592,7 @@ ORDER BY fused DESC LIMIT :k; -- → BGE-reranker-v2-m3 top-5
## 부록. 종합 근거 자료 (절대경로)
- 마스터플랜: `C:/Users/encep/Dropbox/Obsidian/Yun Chan Repository/Projects/영업/2026/한신대학교 산학협력 프로젝트/HANSHIN_AI_PLATFORM_MASTERPLAN.md` (§2 3-AI, §3 데이터모델, §3.6 RAG)
- 마스터플랜: [`MASTERPLAN.md`](./MASTERPLAN.md) (§2 3-AI, §3 데이터모델, §3.6 RAG)
- BLUE TEAM 패치: `…/적대검증_2026-06-25/MASTERPLAN_REVISIONS.md` (§1 stream-json 상주풀, §3 마스킹·스키마, §5 visible_to/RBAC 레이어분리, §6 RACI·DoD검증)
- 데이터 원천: `…/데이터/README.md` (0615 n=1 시드, 파란색=발화별 기법라벨+슈퍼바이저논평 taxonomy)
- 람다 메모리 시스템(1차 레퍼런스): `…/Lambda/BOOT.md`(부트 큰그림→세부), `…/Lambda/procedures/memory-compression.md`(daily→weekly→monthly 압축 알고리즘), `…/CLAUDE.md`(graphify=GraphRAG / rag-memory=BGE-M3 sqlite 역할분리)

View file

@ -8,9 +8,20 @@
| 문서 | 역할 |
|---|---|
| **[`dev_dashboard.html`](./dev_dashboard.html)** | **SSOT(단일 진실 공급원)** — 상태·검증 증거·결정·로드맵의 권위 기준 |
| **[`TODO.md`](./TODO.md)** | **할 일(안된 것들) 통합** — 흩어진 열린 작업을 주제별로 모은 인덱스 |
| **[`ops/handoff-goal-production-2026-08-29.md`](./ops/handoff-goal-production-2026-08-29.md)** | **최신 정식 배포 핸드오프** — 워크북 9/2, public API 장애, 아바타 decode 감사, fail-closed 후보와 승인·E2E 게이트 |
| [`../AGENTS.md`](../AGENTS.md) | 에이전트 작업 지침 원본(OS 선파악·증거 정직성·SSOT 동기화·이미지/아바타 파이프라인) |
| **[`TODO.md`](./TODO.md)** | **열린 실행 항목** — ID별 다음 행동·완료 조건·차단 이유 인덱스 |
| **[`HANDOFF.md`](./HANDOFF.md)** | **현행 인수인계** — 2026-09-01 당시 NAS 배포 증거, 남은 운영·외부 게이트와 승인 경계 |
| **[`ops/backlog-2026-06-26.md`](./ops/backlog-2026-06-26.md)** | **열린 백로그** — ID별 실행 조건·차단 이유·검증 명령 |
| [`archive/project-cleanup-2026-09-07/handoff-goal-production-2026-08-29.md`](./archive/project-cleanup-2026-09-07/handoff-goal-production-2026-08-29.md) | 2026-08-29 배포 전 역사 스냅샷 — 현행 상태 근거로 사용하지 않음 |
| [`../AGENTS.md`](../AGENTS.md) | 에이전트 작업 지침 원본 — **B절 오케스트레이션 바이블(절대 규칙: 고급 모델은 설계·평가, 하위 모델은 조사·구현·검증 실행)**·OS 선파악·증거 정직성·SSOT 동기화·이미지/아바타 파이프라인 |
## 🧭 개발·검증·운영 시작점
| 문서 | 역할 |
|---|---|
| [`guides/local-development.md`](./guides/local-development.md) | Windows 로컬 개발 환경·dev 스택 실행 |
| [`guides/testing.md`](./guides/testing.md) | pytest·typecheck·Playwright 검증 명령 |
| [`guides/architecture.md`](./guides/architecture.md) | API·web·engine·데이터 흐름 |
| [`ops/deployment-pipeline.md`](./ops/deployment-pipeline.md) | production NAS 배포 절차·승인·rollback 경계 |
## 🗺 원천문서·갭 로드맵
@ -34,6 +45,11 @@
## 🚀 정식 전략 실행 로드맵
<details>
<summary>2026-08 Outcome & Alliance OS 설계·dated evidence 목록</summary>
> 이 목록은 설계와 당시 검증 근거다. 현행 상태와 열린 작업은 대시보드·TODO·backlog를 따른다.
| 문서 | 역할 |
|---|---|
| [`ops/vignette-outcome-alliance-os-agentic-plan-2026-08-06.md`](./ops/vignette-outcome-alliance-os-agentic-plan-2026-08-06.md) | 제약에서 출발하지 않는 Outcome & Alliance OS 에이전틱 계획 — G0~G8 정식 승격, 상태는 대시보드·할 일은 TODO I절 |
@ -63,6 +79,8 @@
| [`ops/outcome-os-release-hunk-map-2026-08-07.json`](./ops/outcome-os-release-hunk-map-2026-08-07.json) | 혼합 파일 4개의 exact hunk SHA·포함/제외/split 결정과 생성형 API 계약 재생성 규칙 |
| [`ops/evidence/outcome-os-release-only-2026-08-07.patch`](./ops/evidence/outcome-os-release-only-2026-08-07.patch) | clean HEAD 적용 검사를 통과한 G0~G8 release-only patch — 공식 API 타입 재생성 포함, 공개 배포 증거는 아님 |
</details>
## 🛠 개발·운영 가이드
| 문서 | 역할 |
@ -71,8 +89,10 @@
| [`guides/local-development.md`](./guides/local-development.md) | 로컬 개발 환경 구축·실행 (1-커맨드 dev 스택) |
| [`guides/architecture.md`](./guides/architecture.md) | 시스템 아키텍처(엔진/오케스트레이터/저항/마스킹/음성/평가/데이터) |
| [`guides/testing.md`](./guides/testing.md) | 테스트·검증(pytest, typecheck, Playwright E2E 게이트) |
| [`ops/agent-work-packet-template.md`](./ops/agent-work-packet-template.md) | 에이전트 작업 패킷·판정 기록 양식 — `AGENTS.md` B절(오케스트레이션 바이블) B.5·B.6 실행 양식, 병렬 스폰 체크리스트 |
| [`ops/codex-agents/README.md`](./ops/codex-agents/README.md) | Codex 워커 정의(explorer/researcher/worker/verifier) 참조 사본·설치법 — 런타임은 `~/.codex/agents/`, 프로젝트 스코프 미사용 이유(#26408) |
| [`DEPLOYMENT.md`](./DEPLOYMENT.md) | docker compose 배포·엔진 어댑터·환경변수·외부공개 운영 |
| [`HANDOFF.md`](./HANDOFF.md) | 일반 세션 인수인계 진입점 — 최신 정식 배포 상태는 위 2026-08-29 핸드오프 우선 |
| [`HANDOFF.md`](./HANDOFF.md) | 일반 세션 인수인계 진입점 — 현행 실행 순서·승인 경계 |
## ⚙️ 운영 런북·게이트 트래커 (`ops/`)
@ -101,7 +121,7 @@
| [`redteam/hanshin-feedback-plan-redteam-2026-07-03.md`](./redteam/hanshin-feedback-plan-redteam-2026-07-03.md) | 한신대 개선 계획 적대 검토(컨텍스트·privacy·fallback·임상 설명 위험) |
| [`redteam/hanshin-feedback-plan-counter-redteam-2026-07-03.md`](./redteam/hanshin-feedback-plan-counter-redteam-2026-07-03.md) | 한신대 개선 계획 대레드팀 검토(과잉 차단 완화·1차 MVP 재정렬) |
| [`ops/code-quality-research-2026-06-26.md`](./ops/code-quality-research-2026-06-26.md) | 코드품질 분석 + 미실행 리팩터 로드맵 |
| [`ops/refactor-governance-2026-07-15.md`](./ops/refactor-governance-2026-07-15.md) | 전 저장소 구조 감사·P1~P8 리팩터·중복/번들/SSOT 게이트 실행 증거 |
| [`archive/project-cleanup-2026-09-07/refactor-governance-2026-07-15.md`](./archive/project-cleanup-2026-09-07/refactor-governance-2026-07-15.md) | 전 저장소 구조 감사·P1~P8 리팩터·중복/번들/SSOT 게이트 실행 증거 |
| [`ops/postgres-rls-audit-smoke.md`](./ops/postgres-rls-audit-smoke.md) | RLS·감사로그 격리 스모크 절차(재현 런북) |
| [`ops/engine-rss-smoke-2026-06-28.md`](./ops/engine-rss-smoke-2026-06-28.md) · [`ops/resistance-openness-db-smoke-2026-06-28.md`](./ops/resistance-openness-db-smoke-2026-06-28.md) | 대시보드가 인용하는 live 실측 증거 |
| [`ops/layout-research-2026-06-27/persona-dashboard-layout-guideline.md`](./ops/layout-research-2026-06-27/persona-dashboard-layout-guideline.md) | 페르소나 스튜디오·대시보드 레이아웃 원칙 |
@ -147,6 +167,6 @@
---
> 유지 원칙: 완료된 작업 기록이 다시 쌓이면 `archive/`로 옮겨 이 인덱스와 활성 문서를 얇게 유지한다.
> 유지 원칙: 완료된 작업 기록이 다시 쌓이면 `archive/`로 옮겨 이 인덱스와 활성 문서를 얇게 유지한다. `archive/`는 현재 상태 근거가 아니다.
> 상태·결정 변경은 별도 문서로만 남기지 말고 **SSOT 대시보드**에 반영·동기화한다.
> 새 문서를 만들면 이 인덱스에 한 줄 추가하고, 새 열린 작업은 [`TODO.md`](./TODO.md)에 반영한다.

View file

@ -1,659 +1,128 @@
# Vignette 할 일 (안된 것들) — 통합 TODO
# Vignette 할 일
> **한 곳에서 보는 "남은 작업" 인덱스.** 여러 문서에 흩어져 있던 열린 작업을 주제별로 모았다.
> **권위 기준은 SSOT `dev_dashboard.html`**, 열린 게이트 추적은 `ops/backlog-2026-06-26.md`(얇은 현행본)다.
> 이 파일은 그 둘을 **집계·링크**하는 뷰이며, 상태가 어긋나면 대시보드를 기준으로 맞춘다.
> 완료된 작업 기록은 `archive/`(냉동 보관)에 있고 평상시 읽지 않는다.
>
> 태그: **[환경]** 이 워크스테이션에서 증거 생산 불가 · **[외부]** 기관/소유자 서면·결정 필요 ·
> **[구현]** 코드로 진행 가능 · **[임상]** 임상팀(구훈정·어유경) 외부 콘텐츠 소유.
> 상태·결정·검증 증거의 SSOT는 [`dev_dashboard.html`](./dev_dashboard.html)이다. 이 문서는 실행할 다음 행동의 인덱스다.
> 2026-09-07 이전 완료 로그와 세부 이력은 [정리 전 TODO 원문](./archive/project-cleanup-2026-09-07/TODO-before-cleanup.md)에 보존했다. 2026-09-01 배포 당시의 근거는 [NAS 배포 증거](./ops/evidence/nas-prod-deploy-2026-09-01.json)이며, 현재 live 상태를 주장하지 않는다.
---
## 우선 접수 제품 이슈
## 개선관리 워크북 동기화 (2026-08-29)
> 원본: [개선관리 워크북](https://docs.google.com/spreadsheets/d/1MNRDfXoIQJbLzIcSRdHnQHREWELbKqV4/edit?gid=351278076#gid=351278076) `개발·기능 이슈` 2026-09-07 수집본, SHA-256 `625281e72067ae2aea402f2fe932b4f593b7905c35f8a9740cf346c7f077c812`. 외부 원본 14건과 로컬 집계(완료 9·기존 gate 2·신규 3)를 분리한다. 원본의 기존 대기와 로컬 DONE 불일치는 동기화 대기이며 원본을 수정하지 않는다.
> 이 표는 워크북 ID와 현행 구현을 잇는 얇은 추적표다. 내부 기술 DONE의 focused 증거는
> `guides/testing.md`, 상세 계약은 SSOT `dev_dashboard.html`이 소유한다. C-001은 기술 사전검증과 외부
> 임상 승인을 분리한다. 검토 패킷과 fail-closed 판정기는 준비됐지만 외부 증거가 없으므로 계속 열린 항목이다.
> 완료본은 `outputs/01a04217-f73b-7303-b597-401fa7f5d290/Vignette_개선관리_완료.xlsx`, SHA-256
> `c832547f30ae0664e54b302c8e9f62cc157f31022ad158d17803d8cac988d8bd`다. 집계는 **완료 9·검토 2·총 11**이며
> 검토는 C-001 적격 외부 임상 검수와 REQ-008 실제 Windows 재부팅뿐이다.
| ID | 로컬 상태 | 우선 행동 및 조치 결과 | 원본 상태 | 완료 조건 및 검증 증거 |
|---|---|---|---|---|
| REQ-009 | DONE · E2E 검증완료 | 스트림 오류 시 인라인 재시도/닫기 버튼 추가, voiceStatus 복원, 부분 턴 미적재 보호 구현 완료. | 중요 · 대기 | 단위/Playwright 통과 및 실서버 헤드풀 브라우저 검증. [headful-02](./ops/evidence/headful-02-session-multi-turn.png) |
| REQ-011 | DONE · E2E 검증완료 | OAS G5 자기예측 시 legacy instrument ID 정규화 및 scoped block ID 일치화로 HTTP 422 해소. 1차 예측 저장 및 잠금 완료. | 일반 · 상태 공란 | 헤드풀 브라우저 리뷰 피드백 탭에서 1차 예측 저장 및 잠금 정상 동작 검증 완료. [headful-04](./ops/evidence/headful-04-review-calibration-locked.png) |
| REQ-010 | DONE · E2E 검증완료 | hard delete 없이 0턴 무턴 세션은 완료 회기 집계에서 안전하게 제외하고, 실제 발화 진행 회기만 완료 카운트에 반영. | 중요 · 상태 공란 | 헤드풀 브라우저 2턴 완료 시 카운트 증가(+1) 및 0턴 즉시 종료 시 카운트 불변(유지) 검증. [headful-05](./ops/evidence/headful-05-learner-home-after-completed.png), [headful-06](./ops/evidence/headful-06-learner-home-zero-turn-verified.png) |
| SRC-001 | DONE · 로컬 동기화완료 | 개선관리 워크북 replica의 수식 오류(`#REF!`) 수정 및 개발 상태 집계 범위(15행까지 포함) 보정 완료. | 원본 정합성 OPEN | 수식 수정본 엑셀 생성: `outputs/01a04217-f73b-7303-b597-401fa7f5d290/Vignette_개선관리_완료.xlsx` |
| ID | 상태 | 현행 경계 |
## P0 운영·외부 게이트
| ID | 다음 행동 | 완료 조건 / 차단 이유 |
|---|---|---|
| C-001 | **OPEN · 외부 GATE** | v2 P1 합성 사례 6건·상태별 canonical 판정기와 109·수단 상세 차단·런타임/carry-over/output `ideation_stage <= 3`·4개 공식 source 기술 사전검증은 완료. 사례별 5필드 30개, 청소년 답변 6개, 검토자·소속·자격·검토일·결정 근거·서면 증거/해시·검토 버전/사례 해시를 포함한 승인 10필드가 있어야 닫는다. 현재 `review_complete=false`; F절/B4에서 추적한다. |
| C-002 | **DONE · 내부 기술** | 첫 회기 라포 반영·한 초점 열린 질문 체크리스트, 근거 turn 연결, 이후 회기 not-applicable |
| C-003 | **DONE · 내부 기술** | 종료 회기 4건 미만 insufficient, 이후 최다 페르소나 비중 0.75 이상 훈련 집중 주의; 임상·공정성 판정 아님 |
| REQ-001 | **DONE · 운영 수락** | provider가 검증한 모든 Google 계정은 도메인·사전등록 없이 learner·approved로 로그인한다. Pages `0c60261e…`의 단일 Google CTA·공개 Playwright 2건, 기존 Gmail 인증 세션의 로그인 이메일·관리자 진입·온보딩 비전환·복구 데이터 가시성을 확인했고 소유자가 수락했다. 현재 public API 장애는 별도 운영 복구 게이트이며 이미 닫힌 수락 증거를 지우지는 않는다. |
| REQ-002 | **DONE · 내부 기술** | admin protocol draft→active→retired, 라이선스 C/D 외부 LLM 금지, evaluator-only RAG·rollback; migration 17 owner-run/readiness-only |
| REQ-003 | **DONE · 내부 기술** | 페르소나 주호소가 JSON 키 순서나 surface 상태보다 우선 |
| REQ-004 | **DONE · 내부 기술** | learner 현재 설정 AND 세션 snapshot. OFF는 AI 파생 API 403/UI 무요청·혼합 결과 redaction, 사용자 입력·privacy 보존; teacher/admin 유지 |
| REQ-005 | **DONE · 내부 기술·운영 실증** | 새 회기는 승인 페르소나 `persona_id/version` 고정. P20의 `suicide_ideation_stage=0.9 → int(0) → DB 1..5` 장애를 clamp로 제거했고, 공개 P20 회기 생성·실제 AI 응답·종료·평가를 확인 |
| REQ-006 | **DONE · 내부 기술** | prompt와 구조화 결과의 상담자/내담자 identity를 `[COUNSELOR]`/`[CLIENT]`로 역할 마스킹 |
| REQ-007 | **DONE · 내부 기술** | 회기 종료를 평가보다 먼저 영속화해 평가 실패 뒤에도 같은 learner-persona case의 다음 회기 생성·턴 진행 가능. 종료·시작은 같은 case row lock 순서를 써 S2가 S1 summary/carry를 빠뜨리지 않고, 진행 중인 같은 case는 409 `active_session_exists`로 기존 회기만 이어간다. migration 21의 online partial unique index와 invalid-index pre/postcondition이 병렬 생성을 fail-closed로 차단 |
| REQ-008 | **READY · 실제 재부팅 GATE** | 원본 응답 생성 실패는 기본 엔진+live-client readiness, 구조화 done 없는 SSE EOF의 `client_stream_incomplete` 복원과 공개 P20 실제 응답으로 닫았다. 후속 재부팅 복구는 전역 mutex·단계별 상한·프로세스 트리 종료·전체 다운 즉시복구와 PowerShell 5.1 timed `WaitForExit`의 process handle 선확보를 적용했다. 실제 PS5.1 계약 32 passed, detached-clean `44b7835c…`·tree `06133249…`, 5174 자동복구·HTTP 200, watchdog result 0/failcount 0까지 확인했지만 실제 Windows 재부팅 smoke 전에는 개선관리 상태를 `검토`로 유지한다. PC 비종속 상시 호스트 이전도 별도 운영 게이트다. |
| AVATAR-001 | 아바타 fail-closed 후보를 보존 경계 안에서 검증한다. | 93 objects·52,973 bytes inventory, DB 참조 보존, public fallback·GET/HEAD full decode·digest receipt·실브라우저 확인 뒤에만 승격 판단. 배포는 승인 필요. |
| REQ-008 | NAS 재부팅 자동복구 smoke를 소유자 승인 뒤 실행한다. | 실제 재부팅 뒤 서비스 자동복구, health·로그인·데이터·이미지와 배포 SHA를 확인. Windows 개발 task 이력은 대체 증거가 아니다. |
| BACKUP-001 | 백업 운영화를 끝낸다. | 실패 알림, off-host 암호화 복제, 정기 restore drill과 보존 정책을 운영 증거로 남긴다. 첫 sidecar 성공만으로 닫지 않는다. |
| G8-EXTERNAL | G8 실DB/public 사람 게이트를 실행한다. | 승인·보류·반려 사유와 append-only effect를 실제 DB·공개 경로에서 증명. local fixture는 대체 불가. |
| G7-EXTERNAL | 동의 기반 외부 음성 종료 게이트를 준비한다. | 장치 선택·명시 동의 후 3,120초 양방향 soak, 3,000초 공통 high-water, 독립 human voice-gain pack, canonical checker exit 0. |
| ANTHROPIC-001 | `claude_cli`와 Anthropic API live 동일성을 비교한다. | 기관 키를 게이트웨이 호스트에 승인 주입한 뒤 응답·계량·오류 표면화 비교. 키 주입 전에는 실행하지 않는다. |
| VNET-001 | `vnet.18ka.net` 공개 전환의 외부 설정을 마친다. | DNS, Cloudflare zone 권한, Google redirect URI가 모두 준비된 뒤 live 검증. |
| PIPELINE-001 | Forgejo 기준 NAS 자동배포 hook을 구성한다. | 수동 git-container clone·SHA 검증·NAS build/compose 절차를 보존한 자동화와 rollback/approval 경계를 검증한다. |
| INGRESS-001 | Cloudflare tunnel 제거 지시의 대체 ingress gate를 닫는다. | NAS ingress 대체 경로의 보안·가용성·OAuth 경계를 검증한 뒤에만 tunnel retirement를 승인한다. |
2026-08-29 read-only 상태(<b>이 PC 로컬 기준</b>): 정적 public Web과 Pages `0c60261e…`(source `5bf89ff…`)는 유지됐지만,
이 PC 로컬 API 8001은 연결 거부였고 이 PC의 Docker daemon 부재로 이 PC 기준 DB/API는 복구 불가였다. task는 old
detached `dba9b75a…` runtime을 계속 가리켰다. <b>2026-08-31 재실측(정식 분리 확인):</b> `https://api-vignette.chanpaca.net/health`
별도 호스트(Cloudflare 뒤)에서 200·`environment=prod`·`db=true`·`engine=true`·`engine_mode=openai`로 정상 서빙 중이며,
이 PC에는 cloudflared 프로세스가 없어 public 도메인은 이 PC가 아니다. 즉 이 PC 로컬 8001/task 상태는 개발 워크스테이션의
잔여 스냅샷이고 public 도메인 장애를 뜻하지 않는다. avatar fail-closed 코드와 upload receipt는 아직 후보이고 배포하지 않았다.
## 외부 거버넌스와 파일럿
2026-08-30 NAS production 재확인에서는 `vignette-prod` DB·API·engine이 healthy였고, restart loop이던 web/proxy는
필요 최소 capability만 추가해 두 서비스만 재생성했다. 내부 Caddy→web과 `/api/health`는 200이지만, compose의
Tailnet bind 주소가 NAS 인터페이스에 없어 외부 private/public GREEN은 아직 주장하지 않는다. LAN/0.0.0.0으로
노출을 넓히지 않았다. 회기 연속성 migration 21은 중복 활성 learner-persona case 14개(초과 row 28개)를 발견해
보존·종료 기준을 owner가 결정할 때까지 적용하지 않는다. 기존 20번 transactional migration과 새 21번 online index,
연속성 hunk만 포함한 production 전용 clean candidate가 필요하며 preview 전용 release agent와 8/7 manifest는 사용하지 않는다.
| ID | 다음 행동 | 완료 조건 / 차단 이유 |
|---|---|---|
| C-001 | 적격 외부 임상 검수 패킷을 전달하고 응답을 수집한다. | 사례별 판정·근거·수정 요구·재검토·검토일 30개, 청소년 답변 6개, 승인 10필드와 서명 증거가 hash/version과 일치하고 `review_complete=true`. |
| GOV-001 | 한신대 데이터/SSO 서면 증거를 수집한다. | SSO claim map, 추가 축어록·미성년 동의, 개인정보 처리방침에 대한 기관/data steward 서면 증거. |
| L1-001 | 저작권·등재 문서의 스택 설명을 정합화한다. | FastAPI 유지 사유와 계약 우선 Node 전환 계획을 외부 제출 문서에 반영. |
| P3-001 | 20명 파일럿을 시작한다. | roster, 법무 승인 동의, 회기 완료 증거. |
| P3-002 | 효과성/KPI를 산출한다. | 3척도 pre/post, SUS, κ/ICC, 환각률을 `kpi_report.json`으로 재현. |
| P3-003 | 재귀학습 데이터셋 승인과 동의 감사를 끝낸다. | consent·PII·IAA·legal 승인 export 및 withdrawal log 종결. |
---
## 제품·품질 후속
## A. 배포·공개 런타임 게이트 [환경]
> 출처: `ops/backlog-2026-06-26.md` B2 · 대시보드 운영 탭 · `ops/tailscale-vnet-runtime-2026-06-27.md` · `ops/public-runtime-watchdog.md` · `ops/handoff-goal-production-2026-08-29.md`
>
> **2026-09-01 정식 배포(A 경로) 완료:** Forgejo master `dce85620`를 NAS `vignette-prod`에 실배포(api/engine/web 교체·pgdata/apiuploads 무접촉·사전 dump `cab6826b…` 보존), 중복 활성 회기 30행 최신 유지 정책 종료 후 마이그레이션 20/21/22 온라인 적용, Cloudflare Pages도 새 빌드 배포. public health `ok·db/engine true`·OpenAPI 200·auth 401·dev-login 404·데이터 집계 전후 동일. 이 절의 "이 PC 런타임" 항목들은 NAS 이관으로 대체 경로가 됐다. 증거: `ops/evidence/nas-prod-deploy-2026-09-01.json`, 상세는 백로그 A 경로 절.
| ID | 다음 행동 | 완료 조건 / 차단 이유 |
|---|---|---|
| VOICE-001 | s2s 2차 비교 PoC를 수행한다. | 20턴 기준표의 전사·안전·감사·지연 비교 보고서로 채택/폐기 판단. |
| FASTLOOP-001 | 외부 fallback을 구현한다. | 로컬 우선 분류기, Haiku/Solar fallback, 동의·전송 가드·관측을 테스트한다. |
| FINETUNE-001 | 재귀학습 fine-tuning 경로를 준비한다. | 동의·데이터셋 gate가 닫힌 뒤 pipeline과 few-shot 자동갱신 범위를 검증한다. |
| RETENTION-001 | health retention을 적용·검증한다. | raw 90일, rollup 365일, 수동 escalation 정책을 검증한다. |
| EVALHOOK-001 | fast-loop `eval_hook` 사용 정책을 확정한다. | 주입된 hook의 활성화 조건과 관측 경계를 소유자가 결정한다. |
| NODE-001 | Node gateway 전환 후보의 계약 범위를 정한다. | live endpoint conformance, `TurnEvaluationPayload` adapter, admin mapper의 작업 패킷과 회귀를 확정한다. |
| MIGRATION-001 | idempotent DB migration runner 도입 여부를 결정한다. | init SQL과 runner의 역할 경계, 인프라 영향, 소유자 승인을 확정한다. |
| OPS-001 | 운영 티켓 자동 분류·처리 후속을 설계한다. | 관리자 승인 뒤 이슈/PR/작업 스레드를 생성하는 범위와 audit 경계를 확정한다. |
| E2E-001 | 전 기능 E2E 후속을 분류한다. | 이번 focused 범위에서 locator 18건을 교정하고 keyboard 1건을 해결했다. `npm run e2e` 전 기능 묶음에서 focused 범위 밖 케이스를 실행하고 실패별 재현·원인·수정 명령을 분류한다. 과거 sweep 실패를 오늘 재현 결함으로 단정하지 않으며, 실API/DB·장치 검증은 fixture 결과와 분리한다. |
- [ ] **public API/DB 복구와 avatar fail-closed 정식 승격** — 허용 source union은 93 objects·52,973 bytes,
inventory SHA-256 `9d703126f78d4fc8330408835d76a7d680276240dc578d6fc9ca420c2f25e6aa`다. 실제 decode는 정상 3·실패 90,
현 DB 참조는 정상 2·실패 6·missing 0이다. 후보는 손상 bytes/DB reference를 private forensic으로 보존하고
public static 응답을 404/fallback으로 막으며 decode-valid 객체만 immutable public cache에 제공한다. 신규 UUID 업로드도
URL 기록·현재 참조·GET/HEAD에서 다시 full decode하고, 업로드 뒤 손상은 current invalid/health fallback에 포함한다. 전체 API/Web/
PowerShell 회귀, 실제 브라우저 fallback, exact target/total/digest receipt, 후보 SHA와 사용자 배포 승인 전에는 Docker·
API/tunnel·task·DB avatar URL·stable upload root를 바꾸지 않는다.
현재 Docker engine은 꺼져 있고 public watchdog이 활성이라 daemon 시작 즉시 기존 runtime 복구가 개입할 수 있다. 풀스택 E2E는
watchdog 일시 중지·비활성화, 고유 DB/container/volume과 56432/58000/55173 포트, exact cleanup, Docker·watchdog 원상복구를
명시 승인받은 뒤 `layout-visual-gate` 15/15와 `session-layout` 8/8을 실행한다.
- [ ] **내장 브라우저 G8 사람 게이트 E2E** — 명시적 승인·보류(`keep_quarantine`)·반려(`reject`)와 사유 입력,
증거 미충족 승인 차단은 typecheck와 route-fixture desktop/mobile **10/10**을 통과했다. 빠른 3.5초/느린 8초 응답에서도
watchdog overlay 0을 확인했다. Codex 내장 브라우저의 격리 local stateful fixture에서도 8.5초 뒤 overlay 0·heading 정상,
content `keep_quarantine` 뒤 effect 0, 증거 없는 release 승인 disabled→`reject` 뒤 effect 0, 증거 4종 promote 승인 뒤
lifecycle effect 정확히 1을 공동 확인했다. <b>로컬 사람 게이트는 GREEN</b>이지만 실DB append-only 결정과 public proof는
아직 없으므로 current 운영 G8 전체를 GREEN으로 올리지 않는다.
- [ ] **공개 DB 계정·회기 복구 안정화** — 2026-08-07 18:26 KST owner 승인으로 recovered named volume을
공개 `vignette-dev-db:55432`에 전환했다. cutover 기준 users 84/sessions 30/turns 705,
Google 계정 16/소유 회기 30, orphan 0, health·engine·OAuth·watchdog가 정상이다. 전환 직전 container와
dump, 원본 volume을 보존한다. 남은 것은 사용자가 Google 재로그인한 뒤 본인 과거 회기가 보이는지
확인하는 일과 자동 백업 운영화다. 기존 인증 세션은 복사하지 않아 재로그인이 필요하다.
2026-08-09 Docker Desktop 중단과 18:02:55 KST Docker Desktop UI의 active DB 직접 stop으로 `db=false`
재발했으나 동일 recovered container/volume만 재기동했고, 당시 owner read-only 집계는 users 356/sessions 193/
turns 726, Google users 16/Google-owned sessions 31이며 public db/engine true였다. watchdog·로그온 boot는 detached
clean release의 Git commit/tree와 watchdog|boot/start SHA가 없으면 mutation 전에 실패하도록 코드로 고정했다.
공개 API·cloudflared는 detached-clean `a73bcd24…`·tree `b02b3a1b…`로 fresh 승격했고 receipt
`47388d58…9b08`가 passed다. local/public health는 `status=ok·db=true·engine=true`, 공개 OpenAPI 126,
`/admin/voice-runtime`, `local_whisper/small` + `melotts/melotts-korean`, queue 4를 확인했다. 로그온·watchdog
두 task도 같은 root/commit/tree/script SHA에 재등록해 명시 실행 `LastTaskResult=0`을 확인했다.
2026-08-12에는 휴면 Gradle daemon의 `8001` 점유·cloudflared/voice sidecar 중단과 PowerShell 5.1 native stderr
조기 종료 결함을 복구했다. DB mutation 전에 custom dump `9cbda31e…0ce5`(7,532,625 bytes·TOC 1,492)를
고정했고, detached-clean `a73bcd24…`·tree `b02b3a1b…`로 API/cloudflared를 fresh 승격했다. receipt
`47388d58…9b08` passed, public health 3/3·db/engine true·OpenAPI 126·local voice exact, watchdog·로그온 boot
명시 실행 result 0을 확인했다.
2026-08-29에는 Windows PowerShell 5.1에서 `Start-Process -PassThru`의 process handle을 열기 전에 timed
`WaitForExit()`를 호출하면 정상 web build 뒤에도 `ExitCode=$null`로 남아 복구를 실패로 오판하는 경계를 수정했다.
실제 PowerShell 5.1 계약 32 passed, detached-clean `44b7835c…`·tree `06133249…`에 두 task를 같은 핀으로
재등록했다. watchdog 3회차 복구로 5174가 자동 기동했고 후속 실행은 `LastTaskResult=0`·failcount 0,
local/public health `status=ok·db=true·engine=true`다. 실제 Windows 재부팅 smoke는 아직 남아 있다.
2026-08-18에는 06:30~10:05 KST 엔진 readiness 장애(고효율 프로브 타임아웃 실패가 1800초 캐시 +
구독 사용량 소진)를 수정했다. 실패 전용 캐시 TTL 30초 분리·프로브 타임아웃 45초로 교정하고
detached-clean `4771b97c…c880`으로 API/게이트웨이/task를 재승격·재등록해 `LastTaskResult=0`,
공개 health ok·OpenAPI 126을 재확인했다. 승격 절차에 이전 루트 `apps/api/.env` 복사 단계가
필요함을 확인했다(1차 승격 fail-closed·약 4분 다운타임).
남은 자동복구 gate는 실제 Windows 재부팅 smoke와 정기 DB 백업 운영화다.
상세: `ops/public-db-recovery-rehearsal-2026-08-07.md`.
- [ ] **자동 DB 백업·정기 복원 리허설**`scripts/backup-vignette-db.ps1`의 custom dump→TOC 검증→
SHA manifest 원자 게시와 현재 public-target 일회용 복원(129 tables/9,646 rows/orphan 0)을 통과했다.
남은 범위는 예약 실행, 실패 알림, NAS/off-host 암호화 복제, 주기적 restore drill과 보존 정책이다.
- [x] **공개 Google OAuth 실제 `/turn` proof** — 2026-08-28 기존 Google 세션으로 callback→관리자 진입을
확인하고 P20 회기 생성→자기점검 잠금→실제 학습자 발화/AI 내담자 응답→종료→리뷰·평가→다음 회기 버튼
재노출까지 공개 브라우저 폐루프를 통과했다. 별도의 신규 Gmail 선택은 새 Vignette 계정 생성 확인 항목이다.
- [x] **공개 Web 모바일 보정 Pages 배포** — 2026-08-18 소유자 승인 후 detached-clean `cf899b7f…7c96`
완료했다(00f851a6 후보의 모바일 보정을 포함한 상위 커밋). API type/typecheck, 2회 결정적 build(599파일
sha256 전일치), 7개 origin 자산 143개 보존(최종 742파일) 후 Cloudflare Pages production `4edc02d3` 배포 —
custom-domain 새 엔트리 `index-BWxBaCPM.js`/`index-BUfQc2Rm.css` 서빙, 직전 엔트리 `index-BrpoQ5nP.js` 200
보존, preview 200을 확인했다. 공개 API/DB·Scheduled Task는 건드리지 않았다. 증거:
`docs/ops/evidence/public-web-deploy-2026-08-18.json`.
증거: `ops/evidence/public-web-preflight-2026-08-12.json`.
- [ ] **Compose 패키징용 `infra/.env` owner-secret** — 현재 운영 direct-runtime은 독립 G3~G8 token 주입,
non-owner app-role preflight와 공개 health를 통과했다. Compose로 전환할 때만 `APP_DB_PASSWORD` 등 패키징
비밀과 prod-safe engine/voice flags를 별도 주입하고 compose profile preflight를 다시 통과한다.
- [ ] **`vnet.18ka.net`/`api-vnet.18ka.net` 공개 live** — DNS A record 등록, cloudflared의 `18ka.net` zone 권한,
Google redirect URI `https://api-vnet.18ka.net/auth/callback` 등록. (현재 prod API 8001은 health OK)
- [x] **비-secure origin에서 회기 리뷰 크래시 수정** — 2026-08-07 격리 NAS 프리뷰
`http://100.116.83.60:8088`(평문 HTTP·비-localhost)에서 회기 리뷰 라우트가
`crypto.randomUUID is not a function`으로 error boundary에 잡혔다. `crypto.randomUUID`는 secure
context에서만 노출되는데 제품 코드 18곳이 fallback 없이 직접 호출했고, 특히
`RuptureRepairCard.tsx`는 렌더 시점(`useRef`)에 호출해 화면 전체가 죽었다. 릴리스 게이트는
localhost 후보 스택(secure context)에서만 돌아 이 경로를 밟은 적이 없다. `src/lib/uuid.ts`
`randomUuid()`로 통일했고 fallback도 `crypto.getRandomValues`를 우선 사용해 idempotency key의
예측 불가능성을 유지한다. 문장 fingerprint의 `crypto.subtle.digest` 의존도 portable SHA-256 fallback으로
교체했다. source-only UUID/SHA 회귀 12/12와 secure/insecure exact digest 회귀를 고정했고, 최종 archive
`4d15d055…119d4d`의 실제 NAS 평문 origin browser review 112/112로 배포 검증까지 닫았다.
- [x] **워치독 engine readiness 사각지대** — 2026-08-07 22:0x KST 소유자 승인으로 9099 재기동과
판정 기준 교정을 모두 마쳤다. 복구 실측: 9099 `/ready?force=true` 200 `ok:true, detail:OK`,
API 8001과 공개 `api-vignette.chanpaca.net` 모두 `status:ok, engine:true, engine_detail:OK`.
원인은 16:06부터 상주하던 9099 **프로세스 하나**였다. 같은 인자로 `claude -p`를 직접 실행하고
`EngineSession`을 그대로 재현하면 양쪽 다 `"OK"`를 반환해 CLI·코드·cwd는 무관함을 확인했다
(별도 포트 9199 실험과 동일 결론).
재발 방지 3건:
(1) `scripts/watch-public-runtime.ps1`·`scripts/start-public-runtime.ps1`의 engine 판정을
`/health`(프로세스 liveness) → `/ready`(실제 생성)로 바꾸고, API 판정에 `engine`을 추가했다.
shared secret 인스턴스를 401로 오판하지 않도록 `X-Vignette-Engine-Token` 헤더를 붙이고,
503 응답 **본문**을 로그 detail에 남긴다.
(2) `engine_gateway/gateway.py`가 자식 `claude -p`의 stderr를 상시 드레인해(PIPE를 아무도 읽지
않으면 자식이 블록되는 문제도 함께 차단) 실패 detail에 `exit`·stderr를 붙인다. 실증:
`empty engine response (error: unknown option '--vignette-nonexistent-flag-xyz')`.
`str()`이 비는 `TimeoutError` 등에는 타입명을 최소 보장한다.
(3) 기동 시 게이트웨이 로그 회전, `Stop-UvicornByPort``Name -like python*` 조건 추가
(명령 문자열만 매칭해 호출자 자신을 죽이는 사고 방지).
검증: `pytest engine_gateway` 58/58, 워치독 `-CheckOnly` healthy 5/5, 장애 재현(9299를
`CLAUDE_BIN` 부재로 기동)에서 `/health``ok:true`로 통과하고 `/ready`는 503으로 검출되어
워치독이 `unhealthy (1/3)` + 503 본문을 남겼다.
NAS용 9100은 2026-08-12 listener 상실을 확인했다. current relay launcher는 detached-clean HEAD/tree,
launcher·Python·env SHA, exact consumer URL, explicit LAN 승인, 외부 receipt/runtime, listener owner,
generation readiness와 실패 cleanup을 강제하도록 보강했고 contract 8/8을 통과했다. detached-clean
`f08e03de…f191`·tree `00275133…0abd` exact `-CheckOnly`도 launcher/Python/env SHA 결속,
`mutation=false`, listener `0→0`, runtime state 0으로 PASS했다. 실제 9100 시작은 소유자 승인 전 미실행이다.
- [x] **NAS preview engine relay current 복구** — 2026-08-18 심야 current GREEN 달성으로 종결.
`-CheckOnly` 프리플라이트(4종 SHA pin) → 실제 시작 passed → 방화벽(LAN 한정 9100) → health 3/3
`status=ok·db=true·engine=true`·auth 401·OpenAPI 126 → **actual-origin 실회기 turn→review smoke 통과**
(agy 턴 2개→종료 200→리뷰 200). 대청소 사건으로 릴레이 루트가 삭제돼 현행 루트 `bf5f7352`로 재핀(r3 영수증)했고,
DHCP가 LAN IP를 224로 회수해 릴레이 바인딩·NAS `ENGINE_URL``192.168.0.224:9100`으로 이전(방화벽 규칙 재발행,
NAS `.env(0600)` 재구성 + api만 재생성), 클로드 예산 한도 재발으로 NAS `ENGINE_MODE=agy_cli` 전환. 잔여 권고:
공유기 DHCP 예약(호스트 MAC D8-BB-C1-A2-3C-5D), Docker VM 클럭 동기화(호스트 대비 ~4일 선행),
NAS 로컬 agy 이전(릴레이·노트북 의존 제거). 증거: `docs/ops/evidence/nas-relay-recovery-2026-08-18.json`.
- [ ] **stable-source task 재등록 + 재부팅 후 watchdog smoke** — 2026-08-27 stale AF_UNIX socket으로
Docker Desktop 백엔드와 공개 DB가 중단된 사건을 기존 container·volume 삭제/교체 없이 복구했다. 부모 IPC 폴더는
timestamp `.stale` 이름으로 보존했고, 기존 `vignette-dev-db``vignette_recovered_prod_20260807`만 재기동했다.
2026-08-28에는 detached-clean `99779a6a…fbc8`·tree `57da86aa…6b91`로 watchdog·로그온 boot task를
다시 등록했다. 두 작업 모두 `ExecutionTimeLimit=PT1H`·`MultipleInstances=IgnoreNew`, scheduler 재시도 0이며,
launcher 전역 mutex·bounded API/voice/web build·부모/자식 process-tree 종료와 cloudflared 또는 전체 동적
서비스 hard-down 즉시복구를 회귀 50건으로 고정했다. action root/commit/tree/script SHA pin, watchdog
18:10:57 KST·boot 18:14:27 KST 수동 실행 `LastTaskResult=0`, failcount 0을 확인했다. 로그인 뒤 수동 복구는
증명됐지만 실제 Windows 재부팅 후 엔진/API/터널 자동 복구는 아직 실측하지 않았다. DNS 개통 후 `api-vnet.18ka.net`
`-AdditionalPublicHealthUrls`로 명시 추가한다.
- [x] **노트북 로컬 음성 스택 STT 구현** — 2026-08-08 소유자 결정으로 음성 양쪽을 노트북 상주
로컬 모델로 쓴다(STT faster-whisper, TTS는 아래 항목대로 MeloTTS). 이전에는 코드에 로컬 STT 경로가 아예 없었고
(`voice_stt_provider``openai|deepgram` 둘뿐), interim/final 스트리밍 구현도 Deepgram 전용이었다.
`scripts/local-whisper-stt-server.py` 사이드카(Higgs와 같은 loopback 상주 방식)와
`voice_stt_provider=local_whisper` 경로를 추가했다. 오디오는 호스트를 벗어나지 않고 외부 STT 키가
필요 없다. 실측: 저장소 합성 시드 음성 8.72초로 interim 9 · final 5 · word timestamp 12개 전부 present,
전사는 원문과 한 글자(`안녕하세요``안녕하세여`) 차이였다. 회귀 `scripts/test_local_whisper_stt_server.py`
37/37, `apps/api/app/test_voice_service.py` 로컬 provider 케이스와 후속 계약을 포함한 current API 전체 921 passed.
**발견 1:** 이 노트북은 CUDA 장치는 보이지만 cuDNN(`cudnn_ops64_9.dll`)이 없어 GPU 추론이 **네이티브
크래시**로 죽는다. 같은 프로세스 try/except로는 못 잡아서 디바이스 확인을 버릴 수 있는 자식 프로세스
(`--self-check`)로 분리하고 `auto`에서 CPU로 폴백하게 했다. cuDNN 9를 설치하면 GPU(float16)로 훨씬
빨라지며 `large-v3`도 실시간권에 들어온다 — 설치는 소유자 판단 사항이라 하지 않았다.
**발견 2:** G7 종료 체커가 `expected_stt_provider == "deepgram"`을 하드코딩하고 있었다. 이는 결정
기록이 아니라 벤더 한 줄이었으므로, 운영하기로 한 provider 허용목록으로 바꿨다
(`ALLOWED_STT_PROVIDERS`/`ALLOWED_TTS_PROVIDERS`). 선언/실제 불일치 차단은 그대로 유지한다.
- [x] **노트북 로컬 음성 스택 TTS 구현 (MeloTTS)** — Higgs는 연구/비상업 라이선스라 `config.py`
운영에서 차단하고 있었고, 그 가드를 푸는 건 법적 판단이라 코드로 결정할 수 없었다. 그래서 상업 사용이
허용된 설치형을 다시 찾아 **MeloTTS Korean(MIT)** 으로 바꿨다. 가드를 건드릴 필요가 사라졌다.
`scripts/melotts-server.py` loopback 사이드카 + `voice_tts_provider=melotts` 경로를 추가했고,
사전학습 다화자 모델이라 Higgs처럼 P1 프리셋 한정이 아니라 모든 페르소나에 적용된다.
**실측:** CPU 정상 상태 RTF 0.27~0.28(실시간의 3.6배), `POST /tts` 200·WAV 350,566 bytes·3.61s,
빈 텍스트 422·미지 경로 404. **왕복 검증**으로 MeloTTS 합성음을 faster-whisper가 완전 일치 전사했다.
회귀 사이드카 16/16, 후속 계약을 포함한 current API 전체 921 passed, ruff clean.
**검토 후 탈락:** Kokoro-82M은 Apache 2.0이지만 공식 `VOICES.md`**한국어가 없다**(검색 요약이
지원한다고 했으나 오답). Piper는 GPL, XTTS-v2·Fish Speech는 비상업이라 Higgs와 같은 문제다.
설치 함정 3가지는 `decisions/local-voice-stack.md`에 남겼다(setuptools<81, unidic download, eunjeon).
근거: [`decisions/local-voice-stack.md`](./decisions/local-voice-stack.md).
- [ ] **G7 외부 종료 게이트 — 공개 runtime 선행 구현 + 사람 입력** — provider 의존 코드는 해소됐다. 2026-08-08 로컬 스택
(faster-whisper STT + MeloTTS TTS, 둘 다 MIT)으로 바꿔 외부 STT 키가 더 이상 필요 없고, 체커의 벤더
하드코딩도 운영 provider 허용목록으로 교정했다. 기계적인 부분도
`scripts/run-g7-external-proof-window.py`가 soak·runtime·topology 세 캡처를 **하나의 겹치는 시간창**에서
동시에 돌리고 checker까지 이어서 실행하도록 묶었다(회귀 20/20).
사람에게서만 받을 수 있는 마지막 두 입력은 다음이다.
(1) **명시 동의 하 물리 마이크 50분 양방향 soak** — 소유자가 실제로 50분 발화해야 한다.
(2) **독립 blind human voice-gain pack** — held-out 30명 외 calibration split 참가자 포함 총 최소 31명 /
held-out 50회기 / 150 paired axis / 독립 평가자 2인 /
ICC(A,1) ≥ 0.75 · κ ≥ 0.70 · gain ≥ 0.01 · participant-cluster bootstrap 10,000회 95% CI lower > 0.
runner는 이 pack의 category·κ·preregistration·paired completeness·결측 ITT를 3,120초 캡처 전에 검증하며,
부적합하면 마이크를 열지 않고 exit 2로 끝낸다. 독립 검수는
`python -X utf8 -B scripts/check-g7-human-voice-gain.py --input <pack.json>`이고 `--print-schema`로 계약을 확인한다.
독립 평가팀용 `prepare-g7-human-voice-gain-intake.py`는 빈 header-only 템플릿을 만들고, 비식별 CSV를
production floor·ICC·κ·gain·CI로 재계산해 전부 통과한 경우에만 최종 pack을 원자 생성한다. 템플릿은
`template_only=true`라 증거로 컴파일할 수 없고 이름·이메일·원음·축어록·자유서술 열을 받지 않는다.
도구 준비만 끝난 상태이며 실제 독립 평가자 데이터는 아직 없으므로 G7 external GATE를 유지한다.
**공개 선행 조건 완료(2026-08-09):** detached-clean `a73bcd24…`·tree `b02b3a1b…`에서 OpenAPI 126,
`/admin/voice-runtime`, exact local voice provider/model과 queue 4를 제공한다. fresh launcher receipt
`47388d58…9b08`가 passed이고 두 Scheduled Task도 동일 source에 pin돼 result 0이다. Cloudflare의 Python
기본 User-Agent 403 경계는 runtime sampler의 browser-compatible header와 회귀 4/4로 고정했다.
**외부 실행 전 코드 P0 완료:** runner 종료 코드는 canonical checker `exit 0`/`gate_closed=true`에 결속됐고,
브라우저 Origin(`vignette.chanpaca.net`)은 API/WSS/admin/topology host(`api-vignette.chanpaca.net`)와 분리된 HTTPS
allowlist로 검증한다. 실제 capture는 최소 3,120초·ceil+terminal sample이고 voice/runtime/topology 공통 overlap
`≥3000s`를 checker가 강제한다. Windows artifact는 detached-clean HEAD/tree, runner/collector/checker SHA,
exact `psutil==6.1.1`을 매 sample 전후 pin한다. fresh launcher는 legacy API와 exact-config cloudflared를 bounded
교체하고 새 PID/start/exe/command SHA/실제 cwd의 raw command-line 없는 receipt를 만든다. G7 runner/checker/topology
human pack runtime/checker 6파일 94/94, intake 6/6, standalone validator 4/4를 합친 104/104를 통과했다. `--rehearse`는 인증 WSS ready/ping/close 1000,
runtime 7 samples, Windows topology 7 samples를 모두 통과했고 마이크 capture false·UUID/email literal 0이다.
fail-closed 경계(동의 없음·pack 없음·3,120초 미만·host/Origin/scheme 불일치)는 CLI로 실증했다. 상세:
`ops/outcome-os-g7-external-proof-readiness-2026-08-07.md`.
## 임상 콘텐츠 후속
> **격리 NAS current baseline / G8 `DONE`:** 2026-08-09 clean-head 프리뷰
> `http://100.116.83.60:8088`, project `vignette-preview-20260807`은 source HEAD `61a41d1f…6af`·tree
> `87dec55d…3b77`의 active archive `4d15d055…119d4d`, exact API/Web `d5021950…e4b1`/
> `9796c092…4b36`로 실행 중이며 previous active `6030a677…c611`과 이전 images도 보존한다.
> `api/web/db/proxy` 4개는 running + `unless-stopped`, volume은 `vignette-preview-20260807_pgdata`다.
> candidate session E2E 112/112와 실제 NAS-origin browser review 112/112(11 specs, desktop/mobile/single-run),
> 당시 health ok·db/engine true·auth 401·OpenAPI 126을 통과했다. 2026-08-12 current runtime은 고정
> `ENGINE_URL=http://192.168.0.223:9100` listener 상실 뒤 degraded였고 15:24 KST health 3회도 타임아웃했다.
> 따라서 G8 배포·rollback 증거는 DONE으로 보존하되 current NAS는 relay 복구와 actual-origin 재검증 전까지
> DEGRADED다. fresh dump `36ec8748…24db8`은 1,097,100 bytes,
> TOC 1,738 / TABLE DATA 129다. release agent는 DB
> dump/restore를 수행하지 않으므로 매 execute 직전 fresh custom dump SHA/size/TOC와 DB identity를 별도 결속한다.
> 자동 rollback은 image/Compose/active-state만 복구하며 적용 migration/data restore는 별도 owner 승인이 필요하다.
> 과거 `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 14/14는 완료됐다. 최초 세 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을 통과했다.
> 5·6차 clean run은 핵심 동일 학습자 SSE·review·G4/G5 `0→1→1`을 연속 통과했지만 fixture 공유와 mobile success-state locator를 각각 fail-closed했다. 보정 후 7차 clean `aa81af29…`는 첫 전체 GREEN을 만들었다. 이후 `94666192…``f97e7fad…` run의 disposable DB init 실패는 두 번째 receipt의 cleanup 전 로그로 detached `99_app_role.sh` CRLF와 `bash\r` exit 127이 원인임을 확정했다. `.gitattributes``*.sh text eol=lf`를 고정했다. 공통 톱바 desktop/mobile 44px을 포함한 clean `b02bee26…`·tree `8f677bba…`의 최신 single full run은 487.3초에 GREEN이었다. receipt `periodic-learner-e2e-20260812-171547.json`·SHA-256 `8c11a136…af8e3`은 same-learner SSE·review·G4/G5 `0→1→1`, 별도 returned desktop/mobile 4/4, route mock 0, public/NAS/active DB 접촉 0과 exact cleanup 0을 증명한다.
> 증거: [배포 증거](./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 응답·계량·오류 표면화를 비교하는 것이다.
| ID | 다음 행동 | 완료 조건 |
|---|---|---|
| CONTENT-001 | 태깅 축어록 4종 활용 gate | 임상팀 확정본과 source별 활용 범위 |
| CONTENT-002 | C1 사례개념화 루브릭 | 확정 콘텐츠와 AI 추출/채점 calibration |
| CONTENT-003 | CBT 체인·이론부합 루브릭 | 임상팀 확정본과 검증 기준 |
| CONTENT-004 | 평가 골든셋 | 승인된 콘텐츠와 calibration evidence |
| C-001 | 위기개입 프로토콜 임상 승인 | C-001 외부 검수와 같은 서면 승인 gate |
## B. 외부 거버넌스 [외부]
> 출처: 백로그 B4 · `ops/hanshin-data-governance-gate.md` · `ops/source-docs-gap-analysis-2026-06-26.md` · `MASTERPLAN.md`
## 완료 요약
- [ ] **한신대 데이터/SSO 거버넌스 서면 증거** — 공식 문의서 발송·서면 응답 수령, 동의범위 매트릭스, SSO claim map,
privacy review, 미성년 원본 활용동의, 개인정보 처리방침. 로컬 골격은 `ops/hanshin-data-governance-gate.md`.
- [ ] **L1 스택 정합성·저작권 등재 문서 반영** — 신청서/등재 문서에 FastAPI 유지 사유 + 계약 우선 Node 전환 계획 반영.
결정 기록: `decisions/backend-node-transition.md`.
| 범위 | 상태 | 근거 |
|---|---|---|
| C-002·C-003·REQ-001~007 | 완료 | 개선관리 완료본은 9 완료·2 검토를 유지한다. C-001과 REQ-008은 별도 게이트다. |
| NAS A 경로 | 2026-09-01 당시 완료 | NAS `vignette-prod` 배포, migration 20/21/22, OAuth 실계정 새 사례→턴→리뷰 E2E, 첫 일일 backup sidecar 실행. |
| G0~G6·G8 내부 구현 | 완료 증거 보존 | G7 외부 종료와 G8 실DB/public 사람 게이트는 계속 열린다. |
| B3·B3-신규 소유자 결정 | 결정 및 기존 구현 완료 | 원문은 [정리 전 백로그 원문](./archive/project-cleanup-2026-09-07/backlog-2026-06-26-before-cleanup.md), 남은 후속만 위에 둔다. |
| 과거 Windows Compose 패키징 gate | SUPERSEDED | 9/1 NAS docker-compose 배포로 현 production 전환 항목에서 제거했다. 새 secret 주입 과제를 만들지 않는다. |
| CLEANUP-001 | source 경로·문서·코드 경계 정리 완료 | `tmp/`, `apps/web/tmp/`, legacy `.bak` 4개, attachment·중복 generation 결과를 `outputs/local-history/retained-sources-2026-09-07/`에 가역 보존했다. 84 regular source와 supplementary 4 files, junction 4개의 대조를 마쳤고 영구 삭제는 0건이다. API 공통 outcome/practice/auth/admin/turn-memory/voice 경계와 web focus trap·동맹 scale/잠긴 점수·승인 제출 제어도 기존 책임을 보존해 정리했다. `Knip` dead-code·measurement contract·typecheck/build·Ruff, API 전체 1090 passed/1 skipped/1 warning, gateway 전체 71 passed/1 warning을 통과했고 중복은 17 clone/294줄→3 clone/48줄·265 tokens(0.04%)이다. orchestrator retry는 임상 검수 exact-HEAD 원본 고정 경계이며, voice abort·repository query는 서로 다른 lifecycle·도메인 계약으로 유지한다. |
| RUNTIME-001 | 현시점 보존 결정 | Windows public-runtime/watchdog scripts는 복구 경로로 유지한다. 이 PC의 두 Scheduled Task가 Disabled라는 읽기 전용 사실은 운영 서버 다운을 뜻하지 않으며, 삭제 승인 대기 작업을 새로 만들지 않는다. |
| REFACTOR-002 | 완료 | `SessionEvaluationWrite`의 SQL/cache를 repository로, 공통 정규화·마스킹을 persistence values로 분리했다. AST 기존 대조 20+12 차이 0, focused 149, API 전체 1090 passed/1 skipped/1 warning, gateway 71 passed/1 warning, API 타입 체크·Ruff 통과. 기존 선택형 격리 DB `test_admin_usage_postgres.py` 1 skipped는 `VIGNETTE_USAGE_TEST_DATABASE_URL` 미설정이며 새 skip은 없다. |
| REFACTOR-001 | 완료 | `sessionViewModel`로 표시 모델을 분리하고 `voiceCapture`의 캡처·status 책임은 유지했다. lifecycle setter bag hook은 결합도를 키워 채택하지 않았다. Space retry→pause, empty-audio fixture, `SessionReview.learnerFeedbackEnabled:true` 누락을 교정했고 제품 feedback guard는 유지했다. 최종 UI 두 묶음은 97 passed/1 skipped: `session-mvp`+`uc-session-coach-voice` 58 passed/0 skipped, alliance+admin 39 passed/1 skipped다. skip은 `uc-review-alliance-pulse` 320px desktop 중복 제외이며 mobile은 통과했다. disabled AI feedback privacy focused 1 passed(exit 0)도 유지한다. |
## C. Phase 3 교육용 파일럿 [외부] + [구현 scaffold 완료]
> 출처: 백로그 Phase3 · `phase3/*` · 대시보드 P3 탭. 문서·checker·exporter scaffold는 준비됨.
- [ ] **20명 파일럿 운영** — roster, consent 법무승인(G-P3-02), session completion evidence 수집.
- [ ] **효과성/KPI 측정** — SUS·자기효능감·κ≥0.70/ICC≥0.75·환각률≤3%. 3척도 pre/post 실측 → `kpi_report.json`.
- [ ] **재귀학습 데이터셋 approved 산출**`approved_for_recursive_learning_seed`(consent/PII/IAA/legal 승인) + 승인 export 실행.
- [ ] **개인정보/동의 감사 서명**`phase3/privacy-consent-audit.md` 서명 완료, withdrawal_log 종결.
## D. 소유자 결정 완료(2026-06-30) → 구현 후속 [구현]
> 출처: 대시보드 "윤찬 결정 상세" 탭 · `decisions/` · 백로그 B3. 결정 자체는 전건 확정, 아래는 후속 build.
- [ ] **s2s 2차 비교 PoC 구현·실측**`decisions/voice-s2s-poc.md` 기준표(전사·안전·감사·지연)로 20턴 비교 리포트 → 최종 채택/폐기.
- [ ] **fast-loop 외부 폴백 구현** — 로컬 상주 분류기 우선 + Haiku/Solar 폴백, 전송 가드(데이터주권·로그·동의)·관측.
- [x] **운영 TTS provider 선정·통합** — 운영 경로는 OpenAI API `gpt-4o-mini-tts`로 고정했다. OpenAI
Services Agreement의 고객 애플리케이션·최종 사용자 통합권과 출력 소유 조항을 근거로 기록하고, 공식 TTS
가이드가 요구하는 “AI 생성 음성·사람 음성 아님” 고지를 회기 시작 전과 진행 중 UI에 상시 노출한다. 로컬
Higgs와 sample provider는 무참조 synthetic seed를 쓰는 개발 전용 경로로 계속 분리한다.
- [ ] **재귀학습 fine-tuning 파이프라인** — 동의/데이터셋 게이트 충족 후. few-shot 자동갱신은 선행 가능.
- [ ] **헬스 retention 적용·검증** — raw 90일 / rollup 365일. escalation/그룹 자동화는 미도입 고정.
- [x] **한신대 실사용 피드백 1차 개선팩**`ops/hanshin-feedback-improvement-plan-2026-07-03.md`,
`redteam/hanshin-feedback-plan-redteam-2026-07-03.md`, `redteam/hanshin-feedback-plan-counter-redteam-2026-07-03.md`
기준 P0~P4 1차 완료. 완료 범위: role mapping + client-only stateless history injection, review UI 표시 자연어화,
raw error display mapper, fast/deep 라벨, 명백한 출력 결함 차단, SSE full-response buffer, 교수자 설명 패널.
검증 수치는 SSOT 대시보드만 소유한다. partial stream, client 전용 resident session, fast-loop 사후 저장은
구현 완료했다. 남은 후속은 저장형 fallback/synthetic 계약, fast/deep reconciliation,
임상팀 루브릭/CBT/약물·평가 문구 확정.
## D-2. 전수 E2E 순회 소유자 결정 — **전건 결정 완료(2026-07-27)** → 구현 후속 [구현]
> 출처: `ops/e2e-full-sweep-2026-07-27.md` 발견 결함 로그 · `ops/backlog-2026-06-26.md` B3-신규.
> 결함 12건은 RED→GREEN으로 수정 완료(대시보드 143차). 소유자 결정 7건은 질문 도구로 전건 확정.
- [x] **설정 아바타 변경 UI 구현** — RED→GREEN 완료(full-sweep-settings 4/4, 무회귀 44/44)
- [x] **동의 철회 전체 흐름 구현** — 설정 동의 섹션+철회 2단계 확인+재동의, 실 API round-trip GREEN
- [x] **신규 학습자 기초 우선 추천 구현**`starterPersona()` 난도 오름차순, 무회귀 42/42
- [x] **학생 분석 테이블 가상화 구현**@tanstack/react-virtual, 300행 RED→GREEN, 무회귀 40/40+게이트 4/4
- [x] **저작 모드 좌측 레일 마크업 정리** — 레일 JSX·CSS 삭제, dead-code 게이트 통과
- [x] dev-login 재로그인 자동 재승인 — 결정: 현행 유지(코드 변경 없음, 종결)
- [x] **감정 밸런스 타임라인 차트 구현** — deep `turn_valence`+결정론 파생 폴백, 백엔드 421/421, 라이브 5pt 실측
> 전체 검증 수치·상세는 SSOT 대시보드 144차 노트와 `ops/e2e-full-sweep-2026-07-27.md`가 소유한다.
## E. 코드 품질·리팩터 로드맵 [구현] (비게이트 — 여유 시)
> 출처: `ops/code-quality-research-2026-06-26.md` · `decisions/backend-node-transition.md` next candidates.
> **2026-07-02 실측**: 로드맵 상당수는 이미 해결(turn_runtime·tier 제거·eval_hook 주입 등). 아래는 코드 재확인 기준 현행 상태.
- [x] **Stage enum SSOT 통합**`taxonomy.Stage`를 라벨 단일 정의(SoT)로, `services.state_machine`이 re-export(중복 `class Stage` 제거). **검증: 단일 enum 정체성 확인, 백엔드 pytest 352 passed, 수집 379 import 무결. (2026-07-02)**
- [x] **store `recent_turns`/`masked_turns` Type-2 중복 제거**`recent_turns = masked_turns()[-k:]` 위임으로 단일 소유. **검증: 백엔드 352 passed. (2026-07-02)**
- [x] **화자 한글 라벨 4× 중복 → `taxonomy.speaker_ko_label` 단일 헬퍼** — evaluator/live_coach/memory 프롬프트의 `'상담자'/'내담자'` 삼항 통합. **검증: 헬퍼 단위 + 백엔드 352 passed. (2026-07-02)**
- [x] ~~턴 파이프라인 dedup~~`turn_runtime.py``load_owned_session`/`record_completed_turn`로 이미 추출됨.
- [x] ~~`_iso` 중복 통합~~ — teacher/sessions `_iso` 중복 해소됨(현재 `_iso_text`/`_iso_dt`는 별개 목적 함수).
- [x] ~~tier dead-contract 제거~~ · ~~voice 경로 eval_hook 미주입~~ — gateway tier 제거 + voice eval_hook 주입 완료.
- [x] **Speaker/Role 계약 레벨 — 검토 결과 조치 불요** — DB actor 매핑은 1곳(`session_persistence.py`, 중복 아님), `Role` app↔db는 이미 단일 dict(`DB_ROLE_BY_APP`/`APP_ROLE_BY_DB`), 엔진 `AIRole`은 이미 단일 `Literal`. 실제 중복이던 표시 라벨은 위에서 통합됨 → 별도 추상화 불요. (2026-07-02 실측)
- [x] **`_list_sessions` N+1 제거(1차: state/turns)** — 세션별 `session_state`/`turns` fetch 루프(1+2N) → `= ANY($ids)` 배치 3쿼리 + Python 그룹핑. **검증: 실 DB 동등성 50세션/109턴 불일치 0, 왕복 101→3, 백엔드 352 passed(회귀 테스트 mock 시퀀스 갱신). (2026-07-02)**
- [x] **`_list_sessions` N+1 제거(2차: 턴평가 하이드레이션)** — 상세 뷰에서 세션별 evaluator 연결+평가 로드 루프 → 전 세션 turn_refs 배치(`_hydrate_sessions_turn_evaluations`, 연결 1회). 싱글 헬퍼는 플러럴 위임으로 행위 보존(`_load_turn_evaluations`는 이미 `turn_id = ANY` 배치). **검증: 백엔드 352 passed. (2026-07-02)**
- **E2E 검증(실 스택)**: dev API(8000, 내 코드 반영)+웹+DB에서 `chromium-single-run` **2 passed** — "위기→교수자 대시보드", "학습자 턴→리뷰+교수자 대시보드"(`list_all_sessions` include_turn_evaluation=True로 1·2차 N+1 경로 모두 브라우저 end-to-end). (2026-07-02)
- [ ] fast-loop `eval_hook` **사용 정책 제품 결정** (현재 주입은 됨; 켤지/끌지 확정은 소유자).
- [ ] Node 게이트웨이 구현 시: 라이브 엔드포인트 conformance, `TurnEvaluationPayload` 어댑터 추출, `admin.py` 매퍼 정리.
- [ ] init SQL과 별도의 idempotent DB migration runner 도입 (인프라 추가 — 소유자 결정 권장).
## F. 임상팀 콘텐츠 [임상] (외부 소유)
> 출처: `ops/source-docs-gap-analysis-2026-06-26.md` · 백로그 B0. 코드 구조는 선제 구축, 문안·기준은 외부 정의.
- [ ] **태깅 축어록 4종 후속 게이트**`ops/tagged-data-utilization-2026-07-15.md` §3 소유:
golden set P8~P11, 다회기 스키마 격상, gestalt/dbt 이론모드 신설(소유자 결정), P1~P7 보강,
사례개념화 루브릭 draft, P8~P11 초안 검수·승인(특히 P9 자해 페르소나는 안전장치 실측 선행).
- [ ] C1 사례개념화 **확정 루브릭 콘텐츠** + AI 추출/채점 calibration (현재 scaffold_only).
- [ ] CBT 체인·이론부합 루브릭.
- [ ] **위기개입 프로토콜 임상 승인** — 내부 기술 사전검증은 완료했다. P1 합성 검토 사례 6건과 Draft
2020-12 v2 스키마, `pending_external_review`/`approved`/`conditional`/`rejected` 상태별 canonical fail-closed 판정기,
실제 수련생 위기의 엔진 전 차단·109 연결, 자살 수단 상세 차단, 생성·전이·이전 회기 carry-over·출력의
`ideation_stage <= 3`, SAMHSA·NIMH·NICE·보건복지부 source pack 계약을 고정했다. 생성·스트림 실제 경로
P1 6/6과 C-001 focused 90 passed를 통과했고, 개선관리 완료본에는 사례별 판정·근거·수정 요구·재검토·검토일
30개, 청소년 특화 답변 6개, 외부 승인 10필드를 입력하는 `C-001 외부검토` 시트를 추가했다. 근거는
`data/clinical/`, `scripts/check-clinical-crisis-review.py`,
`docs/ops/clinical-crisis-protocol-review-2026-08-27.md`에 있다. 현재 관찰 패키지·작업트리 해시는 일치하고
기계 게이트는 `pending-valid`지만 외부 입력은 모두 비어 있어 `review_complete=false`다. 실제 검토자 이름·소속
기관·자격·검토일·결정·근거·서명 증거/해시·검토 버전/사례 세트 해시가 기록되기 전에는
`clinical_status=approved`나 개선관리 시트 `완료`로 바꾸지 않는다.
- [ ] 평가 골든셋 콘텐츠.
## G. 운영 후속 [구현] (비차단)
> 출처: 백로그 B1
- [ ] **운영 티켓 자동 분류·처리** — Claude Recipe headless 자동 수정 후보, 관리자 승인 후 이슈 등록·PR/작업 스레드 생성,
처리 결과 audit trail 확장. 자동 수정은 운영자 승인 전까지 실행하지 않는다.
## H. 2026-07-13 한신대 회의 결정 구현 [구현] (파일럿 게이트 — 7월 내)
> 출처: `ops/hanshin-meeting-actions-2026-07-13.md` (요구사항 상세·완료기준은 그 문서가 소유).
> 데드라인: **7월 내 배포 → 7월 말~8월 초 파일럿(대학원생 ~20명)**. 9~10월 연구팀 점진 이관.
- [x] **P1. 회기 종료 시간 기반 전환** — 구현 완료(2026-07-14). 60분 시간 기반(`SESSION_DURATION_MINUTES`) +
10분 전 알람 바 + 시간 만료 정리 유도 다이얼로그 + 유예(+10분) 후 서버 턴 409 거부 + 시작 전 목표 1~4개
선택(`goal_stages` 계약, DDL·런타임 가드 포함 — 회의 권장 2개 수준, 소유자 지시 2026-07-15로 4개까지 허용).
목표 달성해도 시간 내 계속 진행. 상태머신은 채점·표시용 유지.
**검증**: 백엔드 pytest 393 pass, 1분 회기 실측(알람→만료 다이얼로그→409), session-layout 8/8, layout-visual-gate 12/12.
- [x] **P2. 회기 계획·달성도 게이지** — 구현 완료(2026-07-14). 설계 1p `ops/session-progress-gauge-design-2026-07-14.md`.
단계별 누적 게이지(rapport_credit 파생, 회기 간 ×0.7 이월로 누적) + 상세 수치(방어/개방도/라포 누적+이번 회기 증가분)
를 세션 화면·상세 응답·스트림 done·대시보드 페르소나 카드(rapport_percent)에 노출. 계획서·프로토콜 첨부는 기존
`/personas/sources`(+신규 엑셀 업로드)로 흡수. **검증**: 2회기 carry 실측(65%→46% 시작 = ×0.7 정확).
- [~] **P3. 파일럿 인프라 대비** — 부하 검증 완료: `scripts/load-test-sessions.py`, 20 동시 세션 생성 20/20 성공
(p95 125ms, RAG warm 병목 없음), 5 동시 실턴은 p50 55s로 **claude_cli 상주 풀이 병목** → 파일럿 전
`ENGINE_MODE=claude_api` 전환 권고. 이관 절차 초안 `ops/server-migration-runbook-draft.md`, 파일럿 사용 지침
`ops/pilot-usage-guide-draft.md`. **남은 것**: 실제 서버 이관(연구팀·정보처 협의) — 외부 대기.
- [x] **P4. 데이터 업로드 흐름 검증** — 자유 양식 엑셀 업로드 경로가 없어서 신규 구축(2026-07-14):
`POST /personas/sources/upload`(xlsx/xlsm/csv → 텍스트 결정론 변환 `services/tabular_ingest.py` → 기존 마스킹·
hash-only 증거·sanitized chunk 경로 재사용, 원본 바이트 미저장) + PersonaStudio 파일 첨부 xlsx 지원.
**검증**: 파란 라벨 샘플 실업로드(NAME 마스킹·chunk 색인·임베딩 OK, raw hash-only 확인), 방어 테스트 9종
(.xls 거부·cp949 폴백·손상/빈 파일) pass.
- [~] **P5. 협업 체계** — 비개발자용 가이드 `guides/collaboration-for-researchers.md` 작성 완료(문구 수정 프로세스 포함).
**남은 것**: 저장소 초대(소유자 직접 실행).
- ⏸️ **외부 대기(구현 착수 금지)**: '실패(게임오버)' 정의 / 검수 표준화 항목 / AI 평가 노출 정책 / 페르소나 추가 제작 — 전부 연구팀 몫, 수신 시 반영.
## I. Outcome & Alliance OS — 정식 승격 로드맵 [에이전틱 구현]
> **2026-08-06 정식 승격.** 상세 계약·품질 목표·의존성은
> `ops/vignette-outcome-alliance-os-agentic-plan-2026-08-06.md`, 상태는 SSOT 대시보드의
> `Outcome & Alliance OS` G0~G8 계획 카드가 소유한다. 아래 작업은 사람 개발자 스프린트가 아니라
> 에이전트가 RED → 구현 → 통합 → 적대 검증 → runtime proof → SSOT 동기화로 닫는 증거 DAG다.
<details>
<summary>Outcome & Alliance OS 완료 ID</summary>
### I-0. G0 Measurement Truth Foundation
- [x] `app.measurement_event`, `app.measurement_instrument`, `audit.model_run` 계약과 provenance enum 구축.
- [x] 기존 `rapport_credit`, `alliance_level`, fast/deep 평가, Phase 3 KPI의 source map과 adapter 구축.
- [x] `simulated_state`·`model_inferred`·`agent_reported`·`learner_reported`·`human_rated`·
`observed_runtime` 층을 섞는 read model 실패 테스트 구축.
- [x] goal mismatch, task mismatch, empathic miss, withdrawal, confrontation, successful/failed repair를 포함한
첫 benchmark pack 구축.
- [x] **G0 종료 GATE 재검증** — post-merge dump의 disposable clone에 migrations 14/15/16을 각각 두 번
원자 재적용하고 current source census 29/29 producers, unknown/missing/null/orphan/호환성 위반 0을 확인했다.
증거: `ops/evidence/g0-provenance-census-scratch-post-g6-g8-20260807.json`.
완료 증거는 [대시보드](./dev_dashboard.html)에 보존한다.
### I-1. G1 Alliance Core
- [x] goal/task/bond의 내담자·관찰자·수련생 3관점 측정과 회기 전·중·후 pulse 구현.
- [x] 현재 `alliance_level`을 legacy simulation signal로 격리하고 새 alliance read model 구축.
- [x] 학습자 리뷰의 관점 차이·근거 장면 UI 구현.
- [x] 교수자 재평가를 append-only measurement로 구현.
- [x] **G1 종료 GATE** — 세 관점 독립 실행·저장, 축별 비가림 UI, 골든 장면 분류·근거 span,
평가 실패의 정상 점수 폴백 0건. **증거:** G1 focused backend 43 passed, PostgreSQL RLS/append-only/reveal
실원장 smoke, desktop/mobile Alliance Pulse 6/6 및 기존 회기 리뷰 12/12, prompt `1.2.0` 합성 gold 방향
정확도 9/9·최종 실패 0·근거 recall 88.9%. 실제 Session 진입 전 pre pulse를 fail-closed로 잠그고 첫 라운드
뒤 mid pulse를 비차단형으로 연결했으며, stable ID 재시도·변조 409·교차 회기 404를 통과했다. desktop/mobile
pre·mid focused E2E 2/2와 시각 증거 4장을 보존했다. 추가 source API/dev DB 실증은 한 회기의
`pre(degraded/insufficient_transcript) → mid(ready) → post(ready)`를 실제 turn UUID·ended_at과 함께 닫고,
동일 응답 안의 awaiting/external-event read skew와 degraded terminal DB 제약을 교정했다. 증거:
`ops/evidence/alliance-pulse-pre-mid-post-live-2026-08-07.json`. 현재 승격 기준 prompt 1.2+read-skew fix+
보수적 trailing-comma JSON 복구의 전체 재보정은 24/24 ready, 기대 방향 9/9, 오류·재시도 0,
evidence recall 94.4%·precision 73.9%를 기록했다. human-AI ICC는 내부 구현 종료가 아니라 Phase 3 외부 품질 목표다.
완료 증거는 대시보드에 보존한다.
### I-2. G2 Longitudinal Outcome Trajectory
- [x] 최소 5회기 사례 arc와 예상 성과 궤적 분포 구현.
- [x] safety event와 outcome deterioration의 별도 계약·표시 구현.
- [x] 초기 1~5회기 off-track 탐지와 다음 확인 질문 구현.
- [x] case memory에 관계 사건·합의·미해결 균열을 role-safe하게 추가.
- [x] **G2 종료 GATE** — 5회기 기억·목표·관계 연속성, 악화 benchmark 조기경보 목표,
false alert·uncertainty 병기, 합성 궤적의 임상 결과 오표시 0건. **증거:** 4-case×5-session
benchmark status 정확도 1.0·false alert 0·critical miss 0, 실제 DB/API 5회기 연속 case와 15개 독립 축,
멱등 재시도·변조 409·학습자/코호트 RLS·role-safe 관계 기억 통과, desktop/mobile 체크인·궤적 12/12와
G1/기존 리뷰 15/15 회귀 통과. 추가 실제 PostgreSQL producer→store→evaluator smoke는 합성 교육용
악화 case의 `on_track→off_track→deteriorating`과 단발성 noise control의
`on_track→watch→on_track`·false alert 승격 0건을 실제 10회기·30 measurement·case별 5 revision으로
검증했고 learner/teacher 최신 projection이 일치했다. 원점수는 증거 JSON에 복제하지 않았고 임상 주장은 금지한다.
완료 증거는 대시보드에 보존한다.
### I-3. G3 Rupture & Repair Lab
- [x] rupture/repair 상태머신과 이벤트 원장 구현.
- [x] Scenario Director의 자연스러운 균열 주입 구현.
- [x] 실시간 감지, 회기 후 장면 재생, 복구 분기 비교 구현.
- [x] 후속 발화에서 복구된 fast-loop 경고의 deep-loop reconciliation 구현.
- [x] **G3 종료 GATE** — 유형별 탐지·복구 benchmark, missed/partial/resolved E2E, 순응 유도 judge
gaming 0건, 문장 암기 전략의 변형 사례 실패 검증. **증거:** 11-case macro-F1 1.0·복구 상태
accuracy 1.0, critical miss/judge gaming/암기 false resolution 0, Scenario/G3 107 passed,
G0~G8 계약 251 passed, desktop/mobile 리뷰 50/50, 실제 API/DB durable-turn·멱등·RLS·교수자
append-only correction·최종 resolved 검증. 기존 수동 write smoke의 missed→partial→resolved와 별도로,
실제 `submit_turn`/공용 finalizer callback만 사용한 자동 runtime PostgreSQL smoke에서 3 episode·10 observation·
3 reconciliation, 세 상태, learner/teacher read projection, 세션 밖 evidence 참조 0건을 재현했다.
완료 증거는 대시보드에 보존한다.
### I-4. G4 Deliberate Practice Engine
- [x] 평가 장면을 원자적 practice prescription으로 변환.
- [x] replay, branch, constrained response, voice retry, difficulty ladder 구현.
- [x] competency graph와 attempt evidence 저장.
- [x] 약한 역량과 망각 위험에 따른 다음 연습 자동 선택.
- [x] **G4 종료 GATE 재검증** — 모든 코칭 카드의 실행 가능한 재연습 연결, 전후 비교·근거 저장,
미지 사례 전 mastery 금지, 점수 보상 해킹 benchmark 통과. **증거:** G4 core/store/API 54 passed,
실제 API/DB durable-turn 처방→학습자 시도→교수자 append-only correction, 멱등 retry/변조 409,
learner/cohort RLS, reward-claim `practicing`·`mastery_allowed=false`, G4 UI 8/8 및 G1~G4 리뷰 58/58.
replay/branch/constrained response/voice retry/difficulty ladder 카드 CTA가 학습자 홈→사전 설정→실제 새 Session으로
이어지고 launch intent가 URL과 화면에 유지되는 desktop/mobile 22/22 및 통합 59/59를 통과했다. 추가 실제
PostgreSQL production caller smoke는 ready 평가·durable turn 2개에서 G4 submission/card/prescription/snapshot/
decision 각 1개를 자동 생성하고 재실행 시 같은 deterministic submission/card replay, episode·attempt·mastery·
transfer 승격 0건을 확인했다. 실제 API/DB/엔진 smoke는 prescriptions 2·episodes 6·attempts 6·
append-only correction 1·ready observer model-run 2를 통과했다. post-merge disposable clone의 브라우저는
route mock 0으로 desktop 2/2·mobile 2/2, G4 POST·read-model reload·before/after·멱등 retry를 확인했고
최종 episode/attempt/ready observer 각 1, UUID 노출·마이크 호출·overflow·중복 0이다.
증거: `ops/evidence/scratch-browser-returned-practice-closed-loop-evidence-20260807.json`.
- [x] **학생 자기주도 전체 루프 UI 계약** — 실제 `src` 학습 홈 추천→P1·목표 선택→텍스트/SSE 회기→
종료·리뷰→G4 공감적 반영 처방→키보드 CTA→새 회기 사전 설정·시작까지 desktop/mobile **2/2**,
typecheck와 고정 캡처 9장 시각 QA를 통과했다. 홈 CTA는 40px→44px 및 4.5:1 이상 대비로 보정했고,
390×844·320×568 활성 회기의 아바타/문구 겹침 0·축어록/입력창 카드 내부 포함과 리뷰→재연습의
내부 criterion/counterevidence/UUID/영문 이론키 비노출을 고정했다. 리뷰 1~5 척도는 320px에서
3+2 반응형 배열로 전환해 다섯 선택지 44px 이상·내부/페이지 overflow 0을 desktop/mobile 8/8로 고정했다. 이 검증은 모든
미소유 `/api/**`를 404로 닫은 로그인 없는 route fixture이며 실제 API/DB 폐루프라고 주장하지 않는다.
상세: `docs/ops/outcome-os-self-directed-learning-loop-ui-evidence-2026-08-07.md`.
완료 증거는 대시보드에 보존한다.
### I-5. G5 Calibration Mirror & Transfer
- [x] 외부평가 공개 전 자기평가 잠금 흐름 구현.
- [x] 역량별 calibration error와 신뢰도 구간 계산.
- [x] 과신·과소신 맞춤 메타인지 연습 자동 처방.
- [x] 인구통계·관계스타일·난이도를 바꾼 transfer suite 구현.
- [x] **G5 종료 GATE 재검증** — 자기평가 오염 방지·수정 이력, 반복 연습의 calibration error 감소,
새 사례 역량 유지, subgroup 평가 드리프트 리포트. **증거:** 4-case expectation accuracy 1.0,
lock 뒤 오염 거부 1, calibration MAE 0.667→0.267, unseen 4/4 전이, 암기 false verification 0,
합성 subgroup drift 0.667 탐지. 실제 API/DB durable-turn revision→lock→독립 reveal·멱등/409·RLS,
G5 UI 4/4, G1~G5+기존 리뷰 62/62, typecheck/build 통과. transfer CTA는
`novelty=unseen_transfer`·suite·trial을 학습자 홈→사전 설정→실제 Session까지 보존하며 desktop/mobile
2/2와 실제 세션 통합 2/2를 통과했다. 추가 production caller 실 DB smoke는 prediction lock 전 observation
0건, lock callback 뒤 `failed`·`model_inferred`·`independent_observer` 관찰과 turn/session provenance
model-run 각 1개를 확인했다. 재실행 중복 0, passed·assessment·transfer suite/trial/승격 0이며
learner/teacher projection이 일치한다. 학생 후속 종료 회기에서 actual-execution POST·422 대기·503 재시도·
성공 reload·멱등 재확인 UI와 내부 UUID 비노출은 desktop/mobile 16/16으로 연결했다. 실제 API/DB/엔진
smoke는 execution·model-inferred·independent observer·instrument/evidence/model-run provenance 각 1과
멱등·오류 출처·미종료·타학습자·교차 cohort 차단을 통과했다. 같은 post-merge disposable clone 브라우저는
desktop/mobile 4/4로 G5 POST→reload→before/after와 1→1 멱등 재확인을 통과했고 UUID 노출·마이크 호출·
중복 원장 0이다. 합성 교육 fixture이고 임상·실제 숙달 주장은 금지한다.
완료 증거는 대시보드에 보존한다.
### I-6. G6 Supervision & Research OS
- [x] attention queue와 learner drilldown을 새 측정 원장 기반으로 재구축.
- [x] 교수자 정정·AI 불일치를 calibration dataset으로 연결.
- [x] cohort curriculum gap, model drift, instrument version 비교 구현.
- [x] Phase 3 evidence manifest에 alliance/rupture/transfer/calibration 추가.
- [x] **G6 종료 GATE 재검증** — 위험·정체·미해결 관계 사건 3클릭 이내, 모든 집계의 원장 drilldown,
모델·척도 버전별 재현 리포트, public/teacher/learner 경계·감사 로그 검증. **증거:** 6-signal
queue 순서 정확·drilldown 1/상한3, metadata-only dataset raw transcript 0, version drift 탐지,
Phase3 네 도메인 전건. 실제 API/DB 멱등/409·AI-view·역할/cohort RLS·audit, version/subgroup/artifact
provenance hydrate 통과. 추가 scheduled producer는 G0~G5 실제 원장에서 attention/gap을 파생하고,
repo-approved synthetic baseline/candidate와 실제 measurement anchor 12개를 자동 비교해 batch 2·report 1·
subgroup 2를 저장했다. replay 중복 0, periodic 6 cohort 성공, drift/provenance hydrate, transcript 0·비임상
경계를 확인했다. G6 focused 37 passed, G6 UI desktop/mobile 6/6, typecheck/build 통과. 최신
safety-event metadata-only 우선 producer는 post-merge disposable clone의 current source에서 derive 201,
`safety_boundary(1) > deterioration(2)`, direct pointer 각 1, source detail canary 1·G6 projection canary 0,
raw transcript/임상 주장 0을 통과했다. NAS 승격은 배포 증거이지 internal 종료 조건과 분리한다. 상세:
`ops/outcome-os-g6-ledger-producer-evidence-2026-08-07.md`,
`ops/outcome-os-g6-version-comparison-producer-evidence-2026-08-07.md`,
`ops/evidence/g6-safety-priority-runtime-scratch-20260807.json`.
완료 증거는 대시보드에 보존한다.
### I-7. G7 Multimodal Alliance
- [x] STT word timestamp, silence, overlap, interruption, prosody feature event 통합.
- [x] 텍스트·음성 평가 독립 산출 후 calibrated fusion 구현.
- [x] 장면별 오디오 재생과 음성 재연습 구현.
- [x] 비언어 특징의 과도한 임상 추론 차단 계약·테스트 구현.
- [x] Deepgram streaming STT adapter와 OpenAI batch fallback을 보존하고, 운영 기본 경로를 노트북 상주
`local_whisper`/`melotts`로 전환해 interim/final·word timestamp·provider event 계약을 동일하게 구현.
- [x] 저장 word token을 deployment `SESSION_SECRET` 기반 HMAC-SHA256 pseudonym으로 전환하고 원문 미보존을 고정.
- [x] 열린 streaming 중 동의를 1초마다 재검사해 철회 시 provider 전송·후속 저장을 중단하는 fail-closed 계약 구현.
- [x] 음성 단절/실패 뒤 작성 중 텍스트를 보존하고 텍스트 계속하기·키보드 가능한 음성 재연결 recovery UX 구현.
- [x] 외부 OpenAI TTS adapter와 AI 생성 음성 고지를 보존하면서, 운영 기본 TTS를 상업 사용 가능한
MeloTTS Korean(MIT) loopback 사이드카로 전환.
- [x] authenticated WSS `ready`에 STT/TTS provider·model을 노출하고, 마이크 열거·캡처 없는 public preflight가
네 기대값과의 정확 일치를 검사하도록 구현. full soak만 명시적 `--confirm-physical-capture` 뒤 장치를 연다.
- [x] synthetic gold target에 대해 text-only와 calibrated multimodal 출력을 직접 비교하는 1-MAE benchmark를
구현하고 `0.9533→0.9673`, 실측 gain `+0.014`(최소 `0.01`)와 gold 변조 반례를 통과했다.
- [x] consent mutation·timeline·measurement를 session 공통 PostgreSQL transaction lock으로 직렬화하고 실제
개발 DB 동시 철회에서 미커밋 중 writer 대기, 철회 커밋 뒤 저장 차단, timeline row `0`을 확인했다.
- [x] 10MiB audio buffer를 append 전에 거부하고 streaming event queue `32`, Deepgram receive queue `16`
고정했다. 단일 프로세스 cap probe는 10MiB high-water·초과 거부·clear 뒤 RSS 기준선 복귀를 확인했다.
- [x] 첫 음성 사용은 처리 목적·전송/전사·파생 신호·30일 보존·원음 미보존·철회/삭제·텍스트 대안을 설명하고,
서버 동의 원장 성공 전 `getUserMedia`와 voice WebSocket을 모두 0회로 유지한다. 원장 실패와 지연 권한 응답도
마이크 track/소켓을 남기지 않으며 숨은 Space 대신 안내 가능한 `Alt+M`을 사용한다.
- [x] process-local metadata-only `/admin/voice-runtime`과 Uvicorn `--ws-max-queue 4`를 추가해 worker RSS/CPU/FD,
WebSocket·provider session·audio buffer·event queue·overflow/fallback/error high-water를 관측 가능하게 만들었다.
- [x] public WSS evidence v4의 turn별 interim/speech-final/latency 계약, 같은 worker를 고정하는 runtime sampler,
exact image/container/cgroup/proc/Docker/TCP topology sampler, 독립 blind human-held-out voice-gain 계약과
네 artifact를 같은 public host·동시 시간창으로 묶는 `check-g7-external-proof.py`를 구현했다.
- [x] G7 내부 hardening exact SHA `a8c27b0aadcb9dacea86c4be5e62e19277b9cc2678ebca51aa02998d2c2d1a0d`
격리 NAS에 승격했다. clean candidate session E2E 24/24, OpenAPI 124, auth 401, G0~G8와 실제 NAS
browser SSE→DB review를 통과했고 물리 마이크는 사용하지 않았다.
- [ ] **G7 종료 GATE** — 오디오·자막·측정 이벤트 시간 정렬, 물리 마이크·공개 WSS 장시간 E2E,
텍스트 단독 대비 음성 이득 benchmark, 음성 보존·삭제·동의 정책 검증. **내부 완료:** 실제 API/DB
동의→단일 시계→독립 측정/융합→철회·삭제, actual-Postgres 동시 철회, synthetic gold gain benchmark,
bounded queue/10MiB cap RSS probe, Deepgram adapter, HMAC word pseudonym, 1초 동의 재검사, 선행 동의 원장,
recovery UX, mic-free expected-provider/model preflight와 외부 증거용 네 artifact checker를 확인했다. 최신
격리 synthetic PCM soak는 59/59 연결 종료·성공 발화 55·철회 차단 2·미동의 차단 1·overflow 거부/복구 1/1,
focused 119 passed를 통과했다. 공개 G7 route는 TLS 1.3 handshake·비인증 1008·Cloudflare edge와 마이크
열거/캡처 0을 확인했다. 증거: `ops/evidence/g7-public-wss-preflight-current-20260807.json`.
**남음:** 사용자가 장치를 선택하고 명시 동의한 물리 마이크 3,120초, 공통 3,000초 양방향 soak와 동시
runtime/topology high-water, 독립 라벨 held-out/pilot의 실제 음성 이득,
실제 worker·Uvicorn·OS·Cloudflare edge를 같은 50분 시간창으로 묶은 RSS/CPU/queue high-water.
preflight·synthetic·옵션 제공만으로 종료하지 않는다.
내부 완료 증거를 보존한다. 외부 종료는 `G7-EXTERNAL`에서 추적한다.
### I-8. G8 Autonomous Content & Continuous Improvement
- [x] source pack 기반 새 사례·균열·연습·benchmark 초안 자동 생성.
- [x] 안전·정체성·난이도·정답 누수·문화적 편향 독립 적대 검토.
- [x] 승인된 콘텐츠만 catalog 승격하고 provenance 보존.
- [x] metadata-only 운영 incident를 승인 source로 변환해 새 adversarial benchmark pipeline을 실행.
- [x] repo-approved synthetic source를 durable lease/retry job으로 등록하고 API lifespan에서 opt-in 주기 실행.
- [x] 운영 측정의 약한 benchmark·drift를 주기적으로 탐색해 위 incident→adversarial 경로를 자동 기동.
- [x] 실제 Claude pending payload를 사람 검수하고 승인된 projection만 catalog에 0→1로 소비하는
관리자 browser/API/DB E2E. learner/teacher 403·replay 멱등·변조 409·민감 payload 비노출.
- [x] release agent의 결정적 patch·manifest·clean candidate·API/Web/회기 gate, 격리 NAS 승격,
semantic runtime/browser proof, 이미지 rollback, exact-SHA SSOT handoff를 fail-closed로 구현·독립 감사.
- [x] 위 release agent로 변경 SHA `e9b482ccba616a610c1f4862366d44aabae20ee8c82d8ef7f2cf16069f2d6e99`
실제 격리 NAS에 승격하고 postdeploy browser·DB review·rollback snapshot·exact-SHA SSOT handoff를 남겼다.
- [x] **G8 내부 구현·실제 rollback receipt GATE** — source→draft→red-team→benchmark→catalog 재현, 정답 누수·PII·무근거
임상 주장 0건, 모델 교체 자동 calibration/rollback, 운영 오류의 재현 테스트·backlog DAG 자동 환류.
**현재 코드 증거:** caller가 완성 JSON을 넣는 기존 경계와 별도로 source hash·PII fail-closed→structured
generator→서로 결과를 공유하지 않는 red-team 2개→variant generator→독립 judge 3개 이상→durable
`draft_payload`·`pending_human_approval` 저장 worker/API를 추가했다. 모델/structured output 실패, PII,
answer leakage, 무근거 claim, reward hacking은 store 호출 전 차단하고 같은 submission은 model call 0으로
재생한다. repo-approved source의 durable lease/retry scheduler를 lifespan에 opt-in 연결했고 실제
`claude_cli/claude-opus-4-8` 7 calls→dev PostgreSQL reviewer 2·variant 3·pass rate 1.0→
`pending_human_approval`과 동일 UUID 0-call replay를 확인했다. full API/gateway 858 passed,
Ruff·OpenAPI type check·web typecheck 통과. G6 canonical 임계값·subgroup 원장을 재검증하는 default-off
drift trigger는 실 DB incident 1·DAG 4·job 1·approval/catalog 0과 동시성·멱등을 통과했다. 기존 실제
Claude pending payload는 새 모델 호출 0회로 관리자 browser에서 catalog 0→1·approval 1, 역할 403·
replay 멱등·변조 409를 통과했다. release agent는 독립 감사 4건 수정 뒤 14/14와 전체 API 891 passed를
통과했다. exact SHA의 NAS runtime은 health·OpenAPI 123·auth 401·G0~G8와 browser SSE→DB review를
통과했고 transient 502 rollback/recovery와 최종 readiness 3회째 성공을 기록했다. 상세:
`ops/outcome-os-g8-agentic-worker-evidence-2026-08-07.md`. 현재 소스의 HTTP control-plane executor는
artifact SHA·idempotency key·receipt/evidence가 일치할 때만 `executed`를 허용하고 미구성은 `requested`,
호출/receipt 실패는 `failed`로 남긴다.
**2026-08-07 종료:** 격리 NAS `vignette-preview-20260807`에서 실제 receipt-bound image rollback을 두 번
실행했다. 별도 executor helper(`127.0.0.1:18149`, host network·read-only rootfs·cap-drop ALL·
no-new-privileges·uid1028/gid100·group101)와 별도 lifecycle의 control-plane API(`127.0.0.1:8018`,
producer disabled, preview DB `172.22.0.2``vignette_app` role)를 띄우고, `rollback-old` plan은 receipt
`nas-g8-723eeef22eab05e63e3fafb0`으로 previous 이미지 `79ec…4450`/`c530…2f28`를, `restore-current` plan은
receipt `nas-g8-2738846cf2cf4fbe8ce0fc26`으로 current 이미지 `52e0…8b2d`/`6fdb…f215`를 활성화했다.
release gate·approval 각각 2회 멱등, `audit.ci_lifecycle_event` `rollback/executed` 2건과 receipt id 결속,
`audit.ci_human_approval_event` `authorize_rollback` 2건, silent auto-promotion 0, HMAC journal 6-record
체인 검증, 매 실행 뒤 health 3/3과 최종 OpenAPI 126·auth 401·Web 200을 확인했다. helper 격리는 image가
상속하는 `com.docker.compose.*` label을 baseline으로 두고 project/service만 격리 override하는 계약으로
검증했다(`scripts/launch-nas-preview-g8-helpers.py`, fake-runner 37/37). 계획했던 Windows SSH 터널은 NAS
sshd가 `administratively prohibited`로 거부해 sshd 설정 변경 대신 같은 격리 계약의 NAS-side probe
컨테이너로 실행했고 control-plane/preview origin 분리는 유지했다. `down`/`volume rm`/`prune` 미실행,
공개 런타임 미접촉, 비밀 env 파기, helper 0·listener 0 복귀. 상세:
[런북](./ops/nas-preview-g8-rollback-proof-runbook.md) ·
[기계 판독 증거](./ops/evidence/nas-preview-g8-actual-rollback-2026-08-07.json).
- [x] 2026-08-07 release-only SHA `6030a677…c611`을 격리 NAS에 승격했다. patch 3회 결정성,
localhost candidate browser E2E 108/108, postdeploy SSE→DB review 32.437s, health 3/3,
OpenAPI 126·auth 401·G0~G8 route, named DB volume 보존을 확인했다. 이는 당시 runtime promotion
증거이며 현재 HEAD 배포 증거가 아니다.
- [x] **clean-head release mode** — 명시적 `--source-mode clean-head`와 별도 detached source root를 추가했다.
tracked-dirty 차단, exact HEAD/tree/archive SHA, 2회 결정성, archive-only candidate, patch-mode 무회귀,
증거 binding, source-only insecure-context 12/12 gate(UUID 6 + SHA-256 6), commit 전 NAS-origin 11-spec rollback gate를 unit
30/30·Ruff·py_compile로 고정했다. 첫 execute는 archive CRLF를 candidate에서 정규화하도록 fail-closed했고,
두 번째 execute는 API/types/build 뒤 109/5에서 fail-closed해 둘 다 NAS mutation 0이다.
- [x] **G8 NAS-origin 재검증**`randomUuid()`와 portable SHA-256 수정, insecure-context 12/12 회귀를 포함한 current HEAD를
위 clean-head mode로 격리 NAS preview에 배포한다. `started_at`의 9시간 오차 P0는 UTC `+00:00`, KST 달력 날짜,
KST browser focused 실DB E2E로 수정했고 API 전체 921 passed와 코칭 이력 1/1을 통과했다. 시간종료 모달 강제
닫기 우회는 없다. 첫 clean commit `5221f79e` execute는 API/types/build와 candidate DB-backed 회기 후반까지
통과했지만 Vite 전용 `/src/main.tsx` 차단 테스트가 production hashed entry를 차단하지 못해 110/2에서
mutation 없이 중단됐다. source와 production preview에서 수정 회귀 2/2씩 통과했다. 이 테스트 수정의 새 clean
commit `21461ab3` candidate 112/112 뒤 실제 NAS-origin G4 2건이 `crypto.subtle` 부재로 실패했고, release agent는
이전 image·health·auth·OpenAPI를 검증해 rollback했다. 문장 원문을 보내지 않는 portable SHA-256 fallback은
secure localhost 2/2와 Tailnet insecure origin 2/2에서 동일 표준 digest를 통과했다. 최종 clean source HEAD
`61a41d1f…6af`·tree `87dec55d…3b77`·2회 동일 archive `4d15d055…119d4d`를 NAS에 승격했고 candidate
112/112와 실제 `http://100.116.83.60:8088` origin browser review 112/112(11 specs, desktop/mobile/single-run),
health ok·db/engine true·auth 401·OpenAPI 126을 통과했다. API/Web는 `d5021950…e4b1`/`9796c092…4b36`, fresh dump는
`36ec8748…24db8` 1,097,100 bytes·TOC 1,738/TABLE DATA 129이며 previous `6030a677…c611`을 보존했다.
내부 완료 증거를 보존한다. 실DB/public 사람 게이트는 `G8-EXTERNAL`에서 추적한다.
### I-DAG. 첫 에이전트 실행팩 AOS-001~012
**AOS-001** provenance inventory · **AOS-002** measurement schema · **AOS-003** benchmark case schema · **AOS-004** legacy adapter · **AOS-005** structured schema · **AOS-006** session measurement API · **AOS-007** self-assessment lock · **AOS-008** Alliance Pulse UI · **AOS-009** measurement drilldown · **AOS-010** drift/calibration job · **AOS-011** deploy proof manifest · **AOS-012** SSOT/TODO 동기화.
- [x] **AOS-001** 기존 지표 provenance inventory — 선행 없음. 종료: 모든 현재 점수의 source map.
- [x] **AOS-002** `measurement_event` ADR/schema/DDL — 선행 AOS-001. 종료: contract + rollback + RLS tests.
- [x] **AOS-003** benchmark case schema와 첫 8개 장면 — 선행 AOS-001. 종료: deterministic fixtures.
- [x] **AOS-004** legacy rapport/alliance adapter — 선행 AOS-002. 종료: 기존 데이터 무손실 read model.
- [x] **AOS-005** goal/task/bond 3관점 structured schema — 선행 AOS-002/003. 종료: gold contract tests.
- [x] **AOS-006** session measurement API — 선행 AOS-005. 종료: API/DB idempotency E2E.
- [x] **AOS-007** 학습자 self-assessment lock — 선행 AOS-002. 종료: reveal-order E2E.
- [x] **AOS-008** Alliance Pulse 리뷰 UI — 선행 AOS-006/007. 종료: desktop/mobile visual proof.
- [x] **AOS-009** 교수자 measurement drilldown — 선행 AOS-006. 종료: role/RLS/audit E2E.
- [x] **AOS-010** measurement drift/calibration job — 선행 AOS-003/005. 종료: version comparison report.
- [x] **AOS-011** public/deploy proof manifest — 공개 API 126 paths, G1~G8 route, Pages asset, auth/OAuth proof.
- [x] **AOS-012** SSOT/TODO 동기화 — dashboard/TODO/backlog/release manifest 동기화와 checker green.
</details>
---
## 문서 검증
## 지금 임계경로 (한 줄)
납품(공개 데모)의 핵심 블로커는 **A(공개 `/turn` proof + 배포 secrets)**. 그 다음이 **B(한신대 거버넌스)**와
**C(Phase 3 파일럿)** — C는 2026-07-13 회의로 **7월 말~8월 초 파일럿 일정 확정**, 그 전제로 **H의 P1(회기 종료 시간 기반)**이
새 구현 게이트다. **I(Outcome & Alliance OS)는 현재 감사 기준 G0~G6과 G8이 DONE이다.
G8은 receipt-bound 실제 rollback과 current clean source NAS-origin 112/112를 완료했다. G7은 내부 구현 DONE과 외부 proof GATE를 분리하며,
current source 공개 배포·local voice ready·authenticated WSS 무마이크 rehearsal은 완료했고, 명시 동의 mic 3,120초·동시 resource high-water·독립 라벨 benchmark·checker exit 0이 남았다.** D~G는
품질·확장 작업으로 게이트가 아니다. 상세 상태·검증 증거는 SSOT 대시보드가 소유한다.
- [ ] **직렬 E2E 게이트 잔여(코칭 기능 통합)** — 소유자의 70d7cb34(AI 코칭 버튼·버블)가 세션 UI에 새 DOM을 더하며
session-mvp strict 위반('코칭' 버튼 3개)·동맹 체크포인트 포인터 타임아웃(결정적 재현)이 발생. 통합 마감 후
교사 재평가 2건 포함 전체 GREEN 재검증. settings/learner 협폭 레이아웃 결함 91건(sweep 보고)도 함께.
상세: docs/ops/evidence/usecase-tdd-2026-08-18.json
```powershell
python -X utf8 scripts/check-dev-dashboard-ssot.py --json
git diff --check
```

View file

@ -0,0 +1,872 @@
# Vignette G0~G8 완료 추진 핸드오프
> Updated: 2026-08-18 KST (엔진 readiness 장애 수정·재승격 · 유스케이스 TDD 239 · 공개 Web 배포)
> Workspace: `D:\workspace\vignette`
> 이 문서는 다음 에이전트가 **G0~G8 아홉 목표 중 마지막 G7 외부 게이트를 닫기 위한** 실행 기준이다.
## 0. 현재 상태 한 줄 요약
**G0~G6의 내부 구현·증거와 G8 clean-head 배포·rollback 증거는 종료됐다. G8은 source HEAD
`61a41d1f…6af`·tree `87dec55d…3b77`·archive `4d15d055…119d4d`로 candidate 112/112와 actual NAS-origin
112/112를 모두 통과했다. 다만 2026-08-12 current NAS는 고정 LAN engine relay 상실로 DEGRADED이며 15:24 KST
health 요청도 3회 타임아웃이라, 과거 증거와 현재 runtime GREEN을 분리한다. 공개 앱은 health ok·db/engine true·
OpenAPI 126·auth 401·local voice exact를 유지한다. 앞선 fail-closed·verified rollback 이력과 previous
`6030a677…c611`은 보존했다. G7 공개 런타임은
detached-clean `a73bcd24…`·tree `b02b3a1b…`로 fresh 승격했고 OpenAPI 126, local Whisper/MeloTTS, Uvicorn queue 4,
API/cloudflared provenance receipt와 source-pinned Scheduled Task result 0을 확인했다. 인증 WSS·runtime·Windows topology의
30초 무마이크 rehearsal도 통과했다. 남은 것은 명시 동의 물리 마이크 3,120초·공통 3,000초 high-water,
독립 human pack과 canonical checker exit 0이다.**
| Goal | 판정 | 완료 증거 |
|---|---|---|
| G0 Measurement Foundation | DONE | provenance census 29/29, unknown/missing/orphan/null/total violations 0 |
| G1 Alliance Core | DONE | prompt 1.2 read-skew fix, prediction 24/24 ready, direction 9/9, error 0, recall 94.4%, precision 73.9% |
| G2 Outcome Trajectory | DONE | benchmark accuracy/early-warning recall 1.0, false alert 0, 실 DB 10 sessions/30 measurements |
| G3 Rupture & Repair | DONE | macro-F1 1.0, status 1.0, gaming 0, missed→partial→resolved 실 DB/API |
| G4 Deliberate Practice | DONE | completed-session observer, actual API/DB attempts, teacher correction, returned-practice browser 폐루프 |
| G5 Calibration & Transfer | DONE | actual transfer execution, independent/model-run/instrument/evidence provenance, DB/browser idempotency |
| G6 Supervision & Research | DONE | safety event metadata-only projection, safety priority 1 > deterioration 2, raw detail 0 |
| G7 Multimodal Alliance | **internal DONE · external GATE** | consent/withdrawal, synthetic soak 59/59, public `a73bcd24…`·OpenAPI 126·local voice, no-mic rehearsal 3 legs × 7 samples |
| G8 Continuous Improvement | **증거 DONE · CURRENT NAS DEGRADED** | 실제 image rollback 2회 + executed receipt 2건, source/archive 결속, candidate/origin 112/112; current engine relay 복구·재검증 대기 |
대시보드 status count는 **33 done / 2 doing / 0 planned**다.
G8 배포 source 기준선은 branch `master`, HEAD `61a41d1f08239b5f9e99cfae42a206be250416af`·tree
`87dec55daf0a22ca5c2e489cd803a2f731e63b77`이며 2회 동일 archive SHA는
`4d15d055d74f33f1fa2ff470afbf900d565ab238b19c79df34ca7ba240119d4d`다. 공유 worktree의 후속 변경은 이
배포 source에 포함되지 않으므로 G8 완료 증거와 섞지 않는다.
### 2026-08-18 세션 — 엔진 장애 수정·유스케이스 TDD·재승격
- **공개 엔진 3.5시간 장애(06:30~10:05 KST)**: `reasoning_effort=high` 콜드 프로브가 20초를 넘기면
실패가 성공과 같은 TTL(운영 1800초)로 캐시돼 공개 API가 `engine=false`로 오염됐고, 사용량 한도
소진 구간에는 `Reached maximum budget ($0.5)`로 이어졌다. 워치독은 기본 `/ready`의 캐시 200만 보고
API만 재시작하는 루프에 빠졌다. 수정: `ENGINE_READY_FAILURE_TTL_SECONDS`(기본 30초) 분리 +
`ENGINE_READY_TIMEOUT_SECONDS` 20→45초, 회귀 4건(`engine_gateway/test_ready_cache.py`).
gateway 62/62 · backend 986 passed · ruff clean.
- **운영 경계 기록**: 공개 엔진 `claude -p`는 소유자 Claude 구독을 개발 에이전트 작업과 공유한다.
대량 병렬 에이전트 실행은 공개 서비스의 생성 경로를 굶길 수 있다(이번 장애의 2차 원인).
- **공개 런타임 재승격**: detached-clean `4771b97c…c880`·tree `286723de…c1fa` 루트로 API/게이트웨이/
cloudflared를 교체하고 워치독·부트 task를 같은 루트에 SHA pin 재등록, 명시 실행 `LastTaskResult=0`.
1차 승격은 새 worktree에 gitignored `apps/api/.env`가 없어 fail-closed로 실패했다(약 4분 다운타임).
**다음 승격부터는 시작 전에 이전 루트의 `apps/api/.env`를 새 루트로 복사하라.**
- **유스케이스 TDD 캠페인**: 16테마 `uc-*.spec.ts` 239 시나리오(수집 1109 tests/61 files)를 RED 우선으로
작성했다. 제품 결함 수정 4묶음(설정 지원 티켓 작성 UI 부재, 관리자 해결 노트 입력 부재,
포커스 링 가림, 라벨/헤딩/대비/44px/오버플로 결함)과 기존 스펙 드리프트 교정을 닫았다.
증거: `docs/ops/evidence/usecase-tdd-2026-08-18.json`.
- **직렬 게이트(e2e:single-run) 후속**: 11:10 KST 실행은 52 passed/5 failed/4 skipped. 삼각화 결과
breakpoint-sweep 실패는 `widthsFor` 솎아내기가 실기기 대표 폭을 탈락시키는 테스트 결함(교정 후 passed),
kb-source-packs 500은 수정 커밋 이전에 기동한 낡은 dev API(:8000) 프로세스가 원인(재기동 후 passed),
voice-success·session-persistence 실패는 Claude 구독 세션 한도(resets 3pm)로 엔진 502가 난 환경 결함.
교사 재평가 2건은 엔진 가용 게이트가 skip 처리하며 **15:00 KST 리셋 후 재검증이 열려 있다**.
- **공개 Web Cloudflare Pages 배포**: 소유자 지시로 detached-clean `cf899b7f…7c96` 후보
(`D:\workspace\vignette-public-web-cf899b7f`)를 빌드×2 결정성(599파일 sha256 전일치) 확인 뒤
production `4edc02d3`로 배포했다. 7개 origin(커스텀 도메인+직전 6세대)에서 143자산을 보존해
최종 742파일, 커스텀 도메인은 새 엔트리 `index-BWxBaCPM.js`/`index-BUfQc2Rm.css`를 서빙하고
직전 엔트리 자산도 200으로 살아 있다. 증거: `docs/ops/evidence/public-web-deploy-2026-08-18.json`.
- **NAS 엔진 릴레이 복구(1단계 종료)**: `start-nas-preview-engine.ps1 -CheckOnly` 프리플라이트가
4종 SHA pin + LAN `192.168.0.223:9100` 바인딩으로 통과했다(mutation=false,
영수증 `D:\workspace\nas-relay-state\receipt-checkonly-20260818.json`). env는 NAS preview API 실행
컨테이너의 `ENGINE_GATEWAY_SHARED_SECRET`을 값 노출 없이 로컬로 반출해 결속했다. 실제 1회 시작은
엔진 한도 리셋(15:00 KST) 뒤 15:05 자동화가 같은 핀으로 수행하고 NAS health 3회·auth 401·OpenAPI 126을
재검증한다. 증거: `docs/ops/evidence/nas-relay-recovery-2026-08-18.json`.
- **agy 공급자 복구(2026-08-18 오후)**: 관리자 AI 운영에서 agy 전환이 막힌 이중 결함을 고쳤다 —
`agy models``id<TAB>라벨` 형식으로 바뀐 것을 파서가 못 읽음(5afd92f8), ② 승격 체인의 psutil
환경 이식에서 SystemRoot 유실로 Go 계열 CLI가 빈 목록을 반환(6369f294 — `_cli_subprocess_env`
백필 + 런처 Machine 스코프 병합). 공개 런타임을 `6369f294`로 승격하고 워치독·부트 태스크를 모두
새 루트에 재핀했다(**워치독은 `install-public-runtime-task.ps1`, 부트는 `register-boot-task.ps1`
승격 시 둘 다 실행해야 구 루트 되살림 경합이 없다**). 검증: capabilities 14모델 + 실생 턴
`gemini-3.6-flash-high` 응답·비용 계약 통과. 증거: `docs/ops/evidence/agy-provider-recovery-2026-08-18.json`.
- **회기 종료 503 핫픽스(bf5f7352)**: `end_session`의 pinned_fact upsert가 learner 역할 연결로 실행돼
RLS에 거부되던 잠복 결함 — pinned_fact의 SELECT/UPDATE 정책은 AI 뷰·admin 전용이다. 같은 learner
uid로 `acquire(ai_view="evaluator")`를 열어 저장을 분리했고, 공개 DB 롤백 재현(learner 거부·AI 뷰
통과)으로 실증했다. 엔진 건강 시 deep 평가가 팩트를 반환하는 순간부터 발화하므로, degraded 구간
E2E가 이 경로를 덮지 못한 이유도 같다. 소유자 실세션 종료 성공과 로컬 실API E2E 2회(종료 200 +
pinned_fact 실적재 실증)로 운영 검증했다.
- **2026-08-18 심야 연쇄 복구·NAS current GREEN**: 대청소 사건(node_modules·구 런타임 워크트리·Temp
소실)으로 릴레이가 죽고, Docker Desktop 사망·whisper 9882 WinNAT 포트 예약·DHCP의 LAN IP 회수
(223→224)가 겹쳤다. 릴레이를 현행 루트에 같은 핀으로 재기동하고 224로 계약 이전(방화벽 재발행·NAS
`.env` 재구성), 클로드 예산 한도 재발으로 NAS를 `agy_cli`로 전환해 health 3/3 engine true·auth 401·
OpenAPI 126·actual-origin turn→review smoke를 모두 통과 — 201차 current GREEN 달성. 권고 3건은
`docs/ops/evidence/nas-relay-recovery-2026-08-18.json` 참조.
### 이 문서가 인계된 정확한 중단점
- G8 clean-head execute는 격리 NAS preview만 변경했고 공개 API/DB·물리 마이크는 건드리지 않았다. source HEAD/tree와
archive 2회 결정성, fresh dump, exact images, candidate/actual-origin 결과를 모두 결속했다.
- G8 종료 뒤 공개 Web은 Pages production deployment `7bd59055…`로 갱신했다. 실제 사용자 회기에서 literal
`[NAME]` 0, `시간 만료`, 모바일 44px·자동 스크롤 보정을 브라우저로 확인했다. 공개 API는 아래 G7 clean
promotion에서 별도로 fresh 승격했다.
- active goal은 계속 `active`다. 목표는 “문서 작성”이 아니라 **G0~G8 아홉 목표를 실제 runtime과 E2E로
모두 닫는 것**이다. G8은 종료됐고 G7 canonical checker exit 0이 없으면 `goal complete`를 호출하지 않는다.
- current NAS 복구용 `scripts/start-nas-preview-engine.ps1`은 source-pinned fail-closed 계약으로 보강했다.
detached-clean HEAD/tree, launcher·Python·env SHA, exact consumer URL, explicit LAN 승인, 외부 state/receipt,
listener owner와 generation readiness를 강제하고 loopback contract **8/8**을 통과했다. 2026-08-12에는
detached-clean `f08e03de…f191`·tree `00275133…0abd`에서 exact `-CheckOnly`도 PASS했다. launcher/Python/env
SHA는 `492d8b5c…b27c`/`5f7b89a6…52ec`/`e53acedf…8a39`, `mutation=false`, listener `0→0`, runtime
state 생성 0이다. 실제 9100 listener, NAS 설정·컨테이너·DB는 건드리지 않았다. 다음 mutation은 소유자 승인 뒤
단일 owner가 actual 1회→NAS health/auth/OpenAPI/assets/실회기 재검증 순서로만 수행한다.
- 최신 full disposable GREEN은 공통 톱바 44px 계약을 포함한 clean `b02bee26…`·tree `8f677bba…`
`periodic-learner-e2e-20260812-171547.json`·SHA-256 `8c11a136…af8e3`이다. 487.3초에 동일 학습자
브라우저 회기 생성·SSE·review ready·G4/G5 `0→1→1`, 별도 returned desktop/mobile 4/4와 route mock 0을
통과했고 public/NAS/active DB 접촉 0, container·volume·network·listener·temp 잔여 0을 독립 재확인했다.
- 공개 Web 보정은 detached-clean `00f851a6…c229`·tree `9f78cd16…9486` 후보로 배포 직전까지 고정했다.
API generated type LF 계약, `npm ci --ignore-scripts`, API type contract, typecheck, 2회 byte-identical production
build와 상단 44px·로그인 무가로넘침·자기주도 폐루프 focused E2E 12/12를 통과했다. 새 primary CSS/JS는
`index-Dzmt2kJS.css` SHA `da5e8535…fb9a``index-BUoTLplD.js` SHA `08654d08…3c05`다. 현재 공개와 최근
Pages 5개 origin의 지연 로드 자산 133개를 더해 최종 732파일로 만들었고 현행 공개 CSS/JS SHA도 exact
보존했다. actual 공개는 health ok·db/engine true지만 구 Web이라 모바일 상단 6개 조작면이 44px 미만이고,
NAS는 health timeout·9100 listener 0·로그인 overflow 10px다. 공개/NAS 배포는 0이며 기계 판독 증거는
`docs/ops/evidence/public-web-preflight-2026-08-12.json`이다.
- 앞선 두 DB init 실패는 `core.autocrlf=true` detached checkout이 `99_app_role.sh`를 CRLF로 만든 것이었고,
`.gitattributes``*.sh text eol=lf` 뒤 새 worktree에서 tracked shell CRLF 0·해당 파일 CR 0과 실제 DB healthy를
확인해 닫았다. 공통 톱바의 브랜드·공간 전환·테마·로그아웃은 desktop/mobile 모두 44px이고, auth overflow와
함께 source/full E2E GREEN이지만 아직 공개/NAS에는 배포하지 않았다.
- 마지막 완료 작업은 G8 source HEAD `61a41d1f…6af`·tree `87dec55d…3b77`·archive `4d15d055…119d4d`
candidate 112/112와 actual NAS-origin 112/112다. API/Web `d5021950…e4b1`/`9796c092…4b36`, health
ok·db/engine true·auth 401·OpenAPI 126, fresh dump `36ec8748…24db8`을 확인했다.
- G7 Windows topology mode, 3,000초 공통 시간창, canonical checker-bound exit와 공개 런처의 local
Whisper/MeloTTS lifecycle·exact readiness·Uvicorn queue·fresh API/cloudflared provenance 계약은 코드와 공개 runtime에서
확인했다. detached-clean `a73bcd24…`·tree `b02b3a1b…`의 receipt `47388d58…9b08`가 passed이고,
OpenAPI 126·`/admin/voice-runtime`·`local_whisper/small`·`melotts/melotts-korean`·queue 4가 공개 경로와 일치한다.
- **public task source-pin P0 완료:** watchdog·로그온 boot는 detached clean release의 commit/tree와
watchdog|boot/start script SHA를 mutation 전에 검증한다. 두 Scheduled Task를 같은 `a73bcd24…` stable root에
재등록하고 명시 실행해 `LastTaskResult=0`·Ready와 working directory pin을 확인했다. 실제 Windows 재부팅 뒤
자동복구 smoke는 별도 운영 게이트로 남는다.
- 인증 WSS ready/ping/close 1000, runtime 7 samples, Windows topology 7 samples의 30초 무마이크 rehearsal을 통과했다.
voice/runtime/topology evidence에는 UUID/email literal이 없고 `physical_capture=false`다. 다음 mutation은 사용자가
정확한 장치를 선택하고 명시 동의한 뒤의 3,120초 마이크 캡처다. G8 NAS는 새 material milestone이 생기기 전까지
읽기 전용 기준선으로 둔다.
### 완료 조건 판정 (2026-08-09 재검증)
원래 active goal의 완료 선언 조건 네 가지를 실행으로 대조한 결과다.
| # | 조건 | 판정 | 근거 |
|---|---|---|---|
| 1 | G0~G8 각각의 코드 계약과 실제 runtime 증거 | **부분 충족** | G0~G6과 G8 종료 증거는 충족. current NAS runtime 복구와 G7 외부 GATE가 열려 있다 |
| 2 | 학생 홈→회기→리뷰→처방→재연습→전이 폐루프가 실제 API/DB/browser 통과 | **충족** | 일회용 클론에서 `returned-practice-db-closed-loop` desktop 2/2 + mobile 2/2, route mock 0 |
| 3 | current source 증거와 대시보드·TODO·backlog 일치 | **충족** | SSOT checker FAIL 0, dashboard E2E 10/10, count 33/2/0이 카드와 일치 |
| 4 | G7을 가짜·합성 증거로 DONE 처리하지 않음 | **충족** | `check-g7-external-proof.py` exit 1 유지, G7 카드는 `GATE` |
재검증 수치: API **921 passed**, gateway **58**, executor **28**, probe **11**, helper launcher **37**,
release agent **30**, current G7 종료 도구 **104/104**(runtime/checker 6파일 94 + intake 6 + standalone validator 4), API voice 통합 **71**,
MeloTTS 사이드카 **16**, local whisper 사이드카 **38**,
SSOT FAIL **0**,
dashboard E2E **10/10**, web api-types·typecheck·build 통과, ruff clean.
current-source fixture 묶음은 16:49 KST 단일 실행에서 **120/120 PASS(92.0s)**했다. dev dashboard file URL은 실행 cwd가
아니라 spec의 `import.meta.url`로 repo root를 찾으며, session-review fixture는 alliance pulse까지 소유한다. 관리자
live health 테스트는 React StrictMode 중복 조회의 첫 응답을 고정하지 않고 화면이 실제 반영한 성공
`/admin/users` 스냅샷과 KPI를 결속한다. 그 뒤 dev-dashboard spec/HTML이 변경됐고 최신 dashboard 10/10만 따로
재검증됐다. 따라서 current tree의 정직한 판정은 **110 불변 + 10 최신 분할 GREEN**이며, 새 clean commit에서
같은 120건을 한 번 재실행해 archive/source 결속 뒤의 결과를 고정한다.
**따라서 `update_goal complete`를 호출하지 않는다.** 조건 1이 G7 external proof 때문에 미충족이고,
조건 4가 G7을 합성 증거로 메우는 것을 금지한다. G8은 clean-head NAS-origin까지 종료됐다(§3·§5).
### G0~G8 목표별 인수 기준
| Goal | 원 계획의 종료 조건 | 현재 인정 가능한 증거 | 다음 에이전트가 최종 종료 전에 할 일 |
|---|---|---|---|
| G0 | 100% provenance, evaluator/client 누수 0, Python/TS/DB schema conformance, 기존 회기 무회귀 | current producer census 29/29, 위반 0, migrations 14~16 2회 멱등 | current HEAD 전체 backend/schema 회귀와 최종 census를 다시 실행 |
| G1 | goal/task/bond 독립 저장, 축별 UI, gold 근거 span, 평가 실패 fail-closed | prediction 24/24 ready, 방향 9/9, error 0, recall 94.4%, precision 73.9%, pre/mid/post UI | current HEAD의 alliance desktop/mobile·DB-backed session 경로 재실행 |
| G2 | 5회기 연속성, 조기 경보 recall, false alert/uncertainty, synthetic 비임상 경계 | accuracy/recall 1.0, false alert 0, 실제 DB 10 sessions/30 measurements | current HEAD outcome trajectory API/UI 회귀 재실행 |
| G3 | rupture 유형 benchmark, missed/partial/resolved E2E, gaming/암기 방지 | macro-F1/status 1.0, gaming 0, 실제 DB/API missed→partial→resolved | text/SSE 회기 및 rupture UI desktop/mobile 재실행 |
| G4 | 모든 코칭 카드의 실행 재연습, 전후 근거 저장, unseen 전이 전 mastery 금지 | 실제 API/DB attempt·observer·teacher correction, browser route mock 0 | disposable DB에서 returned-practice desktop/mobile 4/4와 멱등 재확인 |
| G5 | 자기평가 잠금, calibration 감소, unseen transfer 유지, subgroup drift | actual transfer execution과 independent/model-run/instrument/evidence provenance | G4와 같은 disposable DB에서 actual POST·read reload·1→1 멱등 재확인 |
| G6 | 3-click queue, 원장 drilldown, 버전 재현, 역할/RLS/audit | safety metadata-only, safety priority 1 > deterioration 2, raw detail 0 | current HEAD producer/store/API와 supervisor UI 회귀 재실행 |
| G7 | 시간 정렬, **물리 마이크+공개 WSS 장시간 E2E**, text-only 대비 음성 이득, 동의/삭제 정책 | 내부 계약·공개 clean runtime·인증 WSS 무마이크 rehearsal 완료. canonical checker는 의도적으로 미실행/미종료 | 장치 선택·명시 동의→mic 3,120초→동시 runtime/topology→독립 human pack→checker exit 0 |
| G8 | source→draft→red-team→benchmark→catalog 재현, 누수/PII/무근거 0, 자동 calibration+rollback, incident→test/backlog 환류 | agentic worker·human gate·실제 rollback/restore receipt 2건, clean-head candidate/NAS-origin 112/112 | **종료.** 새 material milestone 전까지 NAS 기준선 보존 |
## 1. 작업 시작 전 반드시 읽을 파일
1. `AGENTS.md`
2. `docs/README.md`
3. `docs/HANDOFF.md` — 현재 문서
4. `docs/dev_dashboard.html` — 상태 SSOT
5. `docs/TODO.md` — 열린 작업 SSOT
6. `docs/ops/backlog-2026-06-26.md`
7. `docs/ops/outcome-os-g7-external-proof-readiness-2026-08-07.md` — **다음 작업의 핵심**
8. `docs/ops/nas-preview-g8-rollback-proof-runbook.md`
9. `docs/ops/nas-preview-deployment-evidence-2026-08-07.md`
10. `docs/ops/public-db-recovery-rehearsal-2026-08-07.md`
`docs/archive/`는 현재 상태 근거로 읽지 않는다.
## 2. 절대 보존 경계
- Windows 11 + PowerShell 환경이다. 실행 전 OS·셸·도구 경로를 다시 확인한다.
- 명령은 PowerShell 5.1 호환 문법을 기본으로 작성한다. NAS 원격 스크립트는 **UTF-8 base64 전달 방식**을 쓴다.
(`$()`가 PowerShell에서 먼저 확장되는 것을 막는다.)
- G8 배포 source는 `master` / `61a41d1f08239b5f9e99cfae42a206be250416af`이며 공유 worktree의 후속 변경은
이 clean release와 분리한다. stage/commit 시 `git add -A`를 쓰지 않고 명시적 경로만 다룬다.
- clean-head 구현은 unit 30/30, G7 runner/checker/topology는 86/86, G7 public launcher/sidecar는 80/80,
public task source pin은 provenance 11개를 포함한 combined 22/22, timestamp·voice 계약을 포함한 API는
921/921와 KST 실DB browser 1/1, G8 candidate/NAS-origin은 각각 112/112,
SSOT는 FAIL 0와 dashboard E2E 10/10을 통과했다. 다음 에이전트는 최초 diff review 뒤 동일한 focused gate를
다시 실행하고, 서로 다른 lane의 파일을
누락한 채 clean commit을 만들지 않는다.
- 기존·watchdog 생성 엔진 로그 4개와 `apps/web/test-results/`의 PNG 2개는 commit에서 제외하고 보존한다. 신규
`apps/api/app/test_session_read_model.py`
`docs/ops/evidence/g8-clean-head-material-milestone-2026-08-09.json`은 이번 목표 산출물이므로 clean source에
포함해야 한다.
시작 즉시 `git status --short`로 다시 확인하고 `git reset --hard`, `git checkout --`, 대규모 자동 포맷, 임의 삭제는 금지한다.
- Git commit/stage/push는 사용자가 요청하기 전에는 하지 않는다.
- 공개 복구 DB, 원본 volume, pre-recovery container, dump, NAS named volume, 이전 API/Web 이미지를 삭제하지 않는다.
- **로컬 포트 8001(공개 API origin) · 55432(공개 DB) · 9099/9100(엔진)은 공개 런타임이다.**
공개 Web은 Cloudflare Pages이고 5174는 `vnet.18ka.net`용 로컬 preview다. `scripts/dev-up.ps1`은 런타임
리스너를 정리할 수 있으므로 공개 런타임이 떠 있는 동안 실행하지 않는다.
- 비밀값, 이메일, cookie, 사용자 UUID가 포함된 raw response, `.env` 내용은 로그·증거·채팅에 출력하지 않는다.
## 3. G8 receipt + clean-head NAS-origin — 종료됨
### Gate6 계약 정정 (성립 불가능했던 이전 계약을 코드로 교체)
감사 대상 current API 이미지 `sha256:52e0e816…8b2d``com.docker.compose.project=vignette-preview-20260807`,
`service=api`, `version=2.20.1` **image label**을 갖고 있다. 따라서 "helper의 `com.docker.compose.*` key 0개"는
감사되지 않은 다른 이미지를 쓰지 않는 한 성립하지 않는다. 계약을 **key 부재 → 소속(membership)** 으로 바꿔
구현했다.
- 코드: `scripts/launch-nas-preview-g8-helpers.py`
- 테스트: `scripts/test_launch_nas_preview_g8_helpers.py` — **37/37**
- 계약: image 상속 label을 baseline으로 읽고 container의 모든 compose label이 baseline과 같거나 선언된 격리
override인지 검사 · 최종 project ≠ target · 최종 service ∉ {api,web,db,proxy} · argv에 target project/service
label 미주입(fake-runner) · exact container name/ID/`docker inspect` 증거 보존
- 런북: `docs/ops/nas-preview-g8-rollback-proof-runbook.md`
### 실제 실행 결과
| plan | receipt | 활성화된 API/Web 이미지 |
|---|---|---|
| `rollback-old` | `nas-g8-723eeef22eab05e63e3fafb0` | `79ec…4450` / `c530…2f28` |
| `restore-current` | `nas-g8-2738846cf2cf4fbe8ce0fc26` | `52e0…8b2d` / `6fdb…f215` |
- release gate·approval 각각 2회 멱등, lifecycle `executed`, artifact/approval/receipt binding, control-plane 분리 검증
- DB: `audit.ci_lifecycle_event` `rollback/executed` 2 · `audit.ci_human_approval_event` `authorize_rollback` 2 ·
`app.ci_release_gate` 2(전부 `pending_human_approval`, silent auto-promotion 0) · `ci_gate_artifact` 8
- HMAC journal 6 records, `previous_hash` 체인 전수 PASS, SHA256 `a5594feb…0690`
- 최종 상태: health 3/3, OpenAPI 126, auth 401, Web 200, helper 0, listener 0, 비밀 env 파기
- **계획 이탈:** Windows SSH 터널 `18018→8018`은 NAS sshd가 `administratively prohibited`로 direct-tcpip를 거부해
쓸 수 없었다. **sshd 설정은 바꾸지 않았고**, 같은 격리 계약의 NAS-side probe 컨테이너에서 loopback으로
실행해 control-plane(8018)/preview(8088) origin 분리는 그대로 유지했다. 다음 에이전트도 NAS에서
포트 포워딩을 기대하지 말 것.
- 기계 판독 증거: `docs/ops/evidence/nas-preview-g8-actual-rollback-2026-08-07.json`
## 4. 공개 서비스 복구 상태 — 이미 완료, 다시 망가뜨리지 말 것
공개 계정·회기 DB 복구는 2026-08-07 18:26 KST에 완료됐다. 2026-08-09 14:03 KST에는 Docker Desktop이
중단돼 DB listener가 사라지면서 public health가 일시적으로 `db=false`가 됐고, 18:02:55 KST에는 Docker Desktop
UI가 active DB container에 직접 stop을 보내 같은 증상이 재발했다. 두 번 모두 보존된 동일 컨테이너·볼륨만
재기동해 복구했다. 새 컨테이너·볼륨 생성이나 데이터 복원 덮어쓰기는 없었다.
- Public Web `https://vignette.chanpaca.net` · Public API `https://api-vignette.chanpaca.net` (origin `127.0.0.1:8001`)
- active DB container `vignette-dev-db`, volume `vignette_recovered_prod_20260807`, restart `unless-stopped`
- cutover 기준 복구 집계: users 84, sessions 30, turns 705, Google users 16, orphan sessions 0
- 2026-08-09 18:05 KST 재기동 후 owner read-only 집계: users 356, sessions 193, turns 726, Google users 16,
Google-owned sessions 31. 조회는 `BEGIN READ ONLY``ROLLBACK`했고 PII를 출력하지 않았다.
- health `environment=prod`, `db=true`, `engine=true`, `engine_mode=claude_cli`, dev-login disabled, unauth `/auth/me` 401
- **2026-08-09 G7 promotion snapshot:** local/public health는 `status=ok, db=true, engine=true`, Google OAuth
start는 302→`accounts.google.com`, 공개 OpenAPI는 126 paths이고 `/admin/voice-runtime`이 존재한다.
`/voice/health``local_whisper/small`·`melotts/melotts-korean`·WS queue 4를 보고한다.
- DB 컨테이너는 `vignette_recovered_prod_20260807` named volume과 `unless-stopped`를 유지한다. API/cloudflared는
detached-clean `a73bcd24…`·tree `b02b3a1b…`에서 실행되고 receipt `47388d58…9b08`가 passed다.
watchdog·로그온 task도 같은 stable root/commit/tree/script SHA에 pin해 명시 실행 결과 0·Ready를 확인했다.
실제 Windows 재부팅 뒤 자동복구 smoke만 남아 있다.
- **2026-08-12 public reconnect incident:** 휴면 Gradle daemon이 `127.0.0.1:8001`을 점유했고 cloudflared와
9882/9883 sidecar가 없는 상태에서 PowerShell 5.1 native stderr가 start/watch/boot의 복구 분기를 조기 종료했다.
exact Gradle PID만 중지하고 DB dump `9cbda31e…0ce5`(7,532,625 bytes·TOC 1,492)를 먼저 고정했다. 복구 코드는
103/103과 PS5.1 parser를 통과했고, detached-clean `a73bcd24…`·tree `b02b3a1b…`에서 API/cloudflared를
fresh 교체했다. receipt `47388d58…9b08` passed, public health 3/3·db/engine true·OpenAPI 126·local voice exact,
웹 진단 overlay/console error/`[NAME]` 0, watchdog·로그온 boot 명시 실행 result 0을 확인했다.
보안상 과거 `auth_session`은 복원하지 않았다. 사용자는 Google 재로그인이 필요하다.
보존물: pre-recovery container `vignette-dev-db-pre-recovery-20260807-182642`과 원본/recovery Docker volumes,
그리고 2026-08-09 fresh custom dump
`D:\workspace\vignette-backups\vignette-dev-db-vignette-20260809-122235Z.dump`
(SHA256 `f1fd569c…5f64`, 7,458,327 bytes)다.
과거 증거에는 pre-cutover dump `…20260807-091057Z.dump` SHA256 `6b84d5c8…af1f`, recovered dump
`…20260807-092630Z.dump` SHA256 `d7bcc396…68ed`, post-cutover dump `…20260807-095511Z.dump` SHA256
`660695c8…65b88`이 기록돼 있다. 이 세 과거 dump의 현재 위치는 별도로 재확인하지 않았으므로 삭제로 단정하지 말고
off-host/NAS/다른 경로를 확인하기 전 restore·cleanup을 금지한다. NAS의 pre-proof/pre-clean-head dump는 §5 기준으로
실제 존재와 hash를 재확인했다.
공개 DB에는 current dirty source를 배포하지 않는다.
## 5. NAS 배포 기준선 — 2026-08-09 clean-head DONE
- URL `http://100.116.83.60:8088` · project `vignette-preview-20260807` · remote root
`/volume1/docker/vignette-preview-20260807`.
- source HEAD `61a41d1f08239b5f9e99cfae42a206be250416af` · tree
`87dec55daf0a22ca5c2e489cd803a2f731e63b77` · 2회 동일 active archive
`4d15d055d74f33f1fa2ff470afbf900d565ab238b19c79df34ca7ba240119d4d`.
- API `sha256:d502195021beafe869c555f3ec80269426062ef4586dc15a3e049f9c8660e4b1` / Web
`sha256:9796c0925f6a984cb846b61a4f13fda4d403bda7b3a2b6232b728cebbcdc4b36` exact image가 실행 중이다.
- candidate session E2E **112/112**, 실제 NAS 평문 origin browser review **112/112**(11 specs,
desktop/mobile/single-run), health `status=ok·db=true·engine=true`, auth 401, OpenAPI 126.
- fresh custom dump `vignette-preview-pre-clean-head-retry-61a41d1f-20260809T112038Z.dump`, SHA256
`36ec8748cf28c520d9489d108dee9d4a154e6260f6996b4da3e0fe3b8a624db8`, 1,097,100 bytes,
TOC 1,738 / TABLE DATA 129를 보존했다.
- previous active `6030a677af7e87cbfabc422b553d108d53414fd3c446548734a13b036d35c611`과 이전 images,
named DB volume을 삭제하지 않았다.
배포 중 첫 108 gate가 103/5로 fail-closed된 이력(G5 fixture drift, StrictMode retry fixture, `shell.css` manifest 누락)은
지우지 않는다.
### 과거 비-secure origin 결함 — 수정·배포 검증 완료
배포 후 NAS 프리뷰(`http://100.116.83.60:8088`, 평문 HTTP·비-localhost)에 전체 회기 스펙을 돌려보니 24건이
실패했고, 원인은 단 하나였다. 페이지 스냅샷의 실제 예외는 `crypto.randomUUID is not a function`이다.
이 API는 **secure context(HTTPS 또는 localhost)에서만** 노출되는데 제품 코드 18곳이 fallback 없이 직접
호출했고, `RuptureRepairCard.tsx`는 렌더 시점(`useRef`)에 호출해 회기 리뷰 라우트 전체가 error boundary로
떨어졌다. 릴리스 게이트의 108/108은 **localhost 후보 스택**(secure context)에서 돌았기 때문에 이 경로를
한 번도 밟지 않았다.
- 수정: `apps/web/src/lib/uuid.ts``randomUuid()`로 통일. fallback도 `crypto.getRandomValues`를 우선
사용해 idempotency key의 예측 불가능성을 유지하고, Web Crypto가 아예 없을 때만 `Math.random`으로 내려간다.
- 회귀: `apps/web/e2e/insecure-context-uuid.spec.ts` **6/6** (직접 호출 0건 검사 포함), typecheck·build 통과.
- UUID와 portable SHA-256 수정은 archive `4d15d055…119d4d`로 NAS에 배포했고 actual-origin 112/112를 통과했다.
- 교훈: candidate gate를 localhost에서만 돌리면 secure-context 전용 API 결함을 못 잡는다. 배포 대상과
같은 scheme/host 형태에서 최소 한 번은 회기 리뷰 라우트를 열어봐야 한다.
## 6. 다음 실행 순서 — G8 종료, G7 외부 종료
### 6.1 G8: clean-head 릴리스 경로 — DONE
`scripts/run-outcome-os-release-agent.py`의 명시적 clean-head 경로는 구현됐다. 기존 patch mode를 기본값으로
보존하며, tracked-clean source worktree의 exact HEAD/tree/archive만 candidate로 사용한다. 수동 복사나 NAS 직접
빌드는 계속 금지한다.
구현된 **`--source-mode clean-head`** 계약은 다음과 같다.
1. tracked dirty가 있으면 fail-closed. untracked 로그·테스트 결과는 archive에서 제외한다.
2. exact Git HEAD, tree, `git archive` SHA를 증거에 기록한다.
3. 같은 HEAD의 archive를 두 번 만들어 SHA가 동일한지 검증한다.
4. candidate는 그 archive만 풀어 만들고 patch/manifest를 암묵 적용하지 않는다.
5. 기존 preflight·API full·Web type/build·release E2E·DB snapshot·rollback 절차는 그대로 유지한다.
6. Vite source 전용 `e2e/insecure-context-uuid.spec.ts`는 별도 localhost gate(5198)로 12/12 실행한다. UUID fallback
6건과 SHA-256 fallback·직접 호출 금지 6건을 함께 고정하고,
production candidate/NAS-origin은 실제 배포 번들에서 동작하는 11개 회기 스펙만 실행한다.
7. unit/fake runner가 tracked dirty, archive SHA drift, evidence binding, patch-mode 무회귀를 검증해야 한다.
검증: release-agent unit 30/30, source-only insecure-context gate 12/12, Ruff, py_compile, `git diff --check` PASS.
최종 실행은 detached clean HEAD `61a41d1f…6af`에서 candidate 112/112와 actual NAS-origin 112/112를 통과했다.
이하 preflight와 실패 기록은 최종 성공에 이르기까지의 역사 증거이며 다시 실행할 지시가 아니다.
#### 역사 기록: 첫 clean commit preflight — include 52 / exclude 6
2026-08-09 최종 경로 목록 SHA-256은 ordinal 정렬된 UTF-8 LF 경로를 줄바꿈으로 연결하고 마지막 개행 없이 계산해
include `4d94694ccf9def6945272c61f673cac0eaa9fb59f4f9b6c165d661d819f891a1`, exclude
`1ff8c26732772df6b5857bf84de01a53d7da9c3b7d52df08938e77f3a527f022`였다. 아래 목록은 첫 clean commit 범위를
감사하기 위해 보존한 이력이며 현재 worktree staging 목록이 아니다.
```text
apps/api/app/config.py
apps/api/app/routes/voice.py
apps/api/app/services/session_metrics.py
apps/api/app/services/voice.py
apps/api/app/session_read_model.py
apps/api/app/test_session_read_model.py
apps/api/app/test_voice_service.py
apps/api/app/test_voice_ws.py
apps/api/requirements.txt
apps/web/e2e/admin.spec.ts
apps/web/e2e/dev-dashboard.spec.ts
apps/web/e2e/self-directed-learning-loop.spec.ts
apps/web/e2e/session-mvp.spec.ts
apps/web/e2e/session-persistence.spec.ts
apps/web/e2e/session-review-fixture.ts
apps/web/e2e/session-review.spec.ts
apps/web/index.html
docs/HANDOFF.md
docs/TODO.md
docs/decisions/local-voice-stack.md
docs/dev_dashboard.html
docs/guides/local-development.md
docs/guides/testing.md
docs/ops/backlog-2026-06-26.md
docs/ops/evidence/g8-clean-head-material-milestone-2026-08-09.json
docs/ops/nas-preview-g8-rollback-proof-runbook.md
docs/ops/outcome-os-g7-external-proof-readiness-2026-08-07.md
docs/ops/public-runtime-watchdog.md
scripts/boot-public-runtime.ps1
scripts/capture-g7-topology-evidence.py
scripts/check-dev-dashboard-ssot.py
scripts/check-g7-external-proof.py
scripts/install-public-runtime-task.ps1
scripts/local-whisper-stt-server.py
scripts/melotts-server.py
scripts/probe-public-voice-sidecars.py
scripts/register-boot-task.ps1
scripts/run-g7-external-proof-window.py
scripts/run-outcome-os-release-agent.py
scripts/start-local-whisper-stt.ps1
scripts/start-public-runtime.ps1
scripts/test_g7_external_proof.py
scripts/test_g7_topology_evidence.py
scripts/test_local_whisper_stt_server.py
scripts/test_melotts_server.py
scripts/test_outcome_os_release_agent.py
scripts/test_public_runtime_watchdog_provenance.py
scripts/test_public_voice_sidecar_probe.py
scripts/test_run_g7_external_proof_window.py
scripts/test_start_public_runtime_contract.py
scripts/watch-public-runtime-hidden.vbs
scripts/watch-public-runtime.ps1
```
아래 6개와 ignored `infra/.env.nas-preview`는 절대 stage하지 않는다. NAS 실행 시 env는
`--nas-env-file D:\workspace\vignette\infra\.env.nas-preview`로 외부 주입한다.
```text
apps/api/engine.public.err.log.2026-08-07T2200.bak
apps/api/engine.public.err.log.20260809-160042.bak
apps/api/engine.public.out.log.2026-08-07T2200.bak
apps/api/engine.public.out.log.20260809-160042.bak
apps/web/test-results/g7-voice-consent-desktop.png
apps/web/test-results/g7-voice-consent-mobile.png
```
#### 2026-08-09 clean-head 실행 결과 — NAS mutation 0
- dry-run `D:\workspace\vignette-recovery\g8-clean-head-dry-run-20260809.json`: `ok=true`,
active `6030a677…c611`, desired archive `6b1b15bd…a3f7`, `deployment_mutated=false`.
- execute 1 `...\g8-clean-head-execute-20260809.json`: Windows Git archive의 CRLF 때문에 생성 API 타입 byte check가
실패했다. archive SHA 검증 후 candidate의 생성 타입/셸 스크립트만 LF로 정규화하도록 controller를 수정했다.
NAS mutation 0, rollback 불필요.
- execute 2 `...\g8-clean-head-execute-20260809-r2.json`: API/types/typecheck/build/candidate stack은 통과했고
Playwright는 **109 passed / 5 failed**였다. source-only UUID 스펙 4건은 production build에서
`/src/lib/uuid.ts`를 import할 수 없는 gate 배치 오류라 별도 Vite gate로 분리했다. 남은 1건은 아래 제품 버그다.
NAS mutation 0, rollback 불필요. 동일 전체 execute를 아직 다시 시도하지 않았다.
#### 2026-08-09 clean-head `5221f79e` 실행 결과 — NAS mutation 0
- exact include 52개를 Yun Chan으로 커밋했다. HEAD `5221f79e…1c69`, tree `e4f15001…308b`, 2회 동일 archive
`1109bf86…0f4b`(482,662,400 bytes)를 detached clean worktree에서 고정했다.
- 새 milestone과 dry-run은 active `6030a677…c611` → desired `1109bf86…0f4b`,
`deployment_mutated=false`로 통과했다. 실행 직전 NAS custom dump
`6f4b95a7…b529f`(1,015,222 bytes, TOC 1,752 / TABLE DATA 129, 전용 pgdata volume)를 생성했다.
- execute는 API 921, API types, typecheck, build, insecure-context 6/6, candidate stack과 DB-backed
single-run 후반부까지 통과했지만 **110 passed / 2 failed**에서 승격 전에 중단됐다. 실패는 제품 화면이 아니라
`React 부트가 실제로 비어 있으면 진단을 유지한다`의 desktop/mobile 두 복제였다.
- 원인은 테스트가 Vite 개발 entry `/src/main.tsx`만 차단해 production candidate의 hashed entry
`/assets/index-*.js`를 차단하지 못한 환경 계약 누락이다. 두 entry를 함께 차단하도록 수정했고 Vite source 2/2와
production preview 2/2를 각각 통과했다. 전체 execute를 즉시 반복하지 않고 이 수정의 새 clean commit·archive를
다시 결속한 뒤 한 번만 재실행한다.
- 후속 clean commit `21461ab3…6fde`, tree `5c5f12a8…524e`, 2회 동일 archive `20d49694…fa1a` execute는
candidate **112/112**를 통과하고 NAS 새 API/Web image `d5021950…`/`376a3aa8…`까지 올렸다. 그러나 실제 NAS-origin
postdeploy의 G4 deliberate-practice desktop/mobile 2건이 110/112에서 실패해 active-state commit 전에 이전
`52e0e816…`/`6fdbb646…`로 자동 rollback했고 health·auth 401·OpenAPI 126·G0~G8 route와 image ID를 재검증해
rollback `verified`로 종료했다.
- focused exact-image 평문 origin에서 요청이 API까지 가지 않고 일반 오류로 끝나는 것을 재현했다. 원인은
문장 원문을 보내지 않기 위한 fingerprint가 `crypto.subtle.digest`에만 의존해 insecure origin에서 예외가 난 것이다.
원문 외부 전송 없이 portable SHA-256 fallback을 추가하고 표준 digest
`c06db0f0…62aa`를 payload에서 exact 검증했다. localhost secure origin 2/2와 실제 Tailnet insecure origin 2/2가
같은 digest로 통과했다. 이 수정의 새 clean commit만 다시 결속해 전체 execute를 재개한다.
#### P0 수정 완료 — 회기 timestamp UTC 전송과 KST 표시 날짜
- 최초 실패 스펙: `session-persistence.spec.ts``persists AI tutor coaching history through reload @single-run`.
- 코칭 생성·DB 저장·reload 후 `C` 마커까지는 성공했지만, 수정 전 화면이 `9:03:59 / 543:59` 경과로 계산해
`회기 시간이 종료됐어요` 모달을 띄웠고 그 모달이 코칭 마커 클릭을 가로막았다.
- 원인: `apps/api/app/session_read_model.py::iso()`
`datetime.fromtimestamp(ts).isoformat(timespec="seconds")`로 timezone 없는 문자열을 반환한다.
`apps/web/src/pages/Session.tsx::elapsedFromSession()``Date.parse(detail.started_at)`로 이를 브라우저 로컬 KST로
해석한다. Linux API UTC와 KST 브라우저 사이에서 약 9시간 오차가 생긴다.
- 수정: `session_read_model.iso()`와 dashboard용 `session_metrics.iso_datetime()`은 UTC `+00:00`을 반환한다.
회기 리뷰 달력 날짜는 서버 timezone이 아니라 고정 KST(+09:00)를 사용한다.
- backend 관련 65 passed, 후속 voice 계약을 포함한 API 전체 **921 passed**, Ruff·compile·web typecheck가 통과했다.
- 고유 Compose project/volume의 실제 DB/API/engine/browser에서 KST context로 focused 1/1 PASS했다. UTC suffix,
`status=active`, `ended_at=null`, 회기 나이 10분 미만, 화면 `00:00-09:59`, timebar/dialog 0,
coach mark 실제 클릭과 DB history/source-pack dialog를 모두 검증했다. 종료 뒤 container/volume/listener는 0이다.
- **테스트 강제 클릭이나 모달 닫기 우회는 사용하지 않았다.** 첫 clean source `5221f79e…1c69`에는 제품 수정과
controller 변경이 포함됐고, production entry 차단 회귀 2건의 테스트 수정만 후속 clean commit으로 다시 고정한다.
### 6.2 G8: NAS preview 배포와 실제 origin E2E — DONE
전용 NAS preview만 대상으로 백업→dry-run→배포→검증했고 공개 DB·공개 API는 건드리지 않았다. 아래 성공 조건을
전부 충족했다.
최종 fresh backup은
`/volume1/docker/vignette-preview-20260807/backups/vignette-preview-pre-clean-head-retry-61a41d1f-20260809T112038Z.dump`,
SHA-256 `36ec8748cf28c520d9489d108dee9d4a154e6260f6996b4da3e0fe3b8a624db8`,
1,097,100 bytes, TOC 1,738 / TABLE DATA 129다. release agent 자체에는 `pg_dump`/`pg_restore` 실행 계약이 없다.
따라서 **매 execute 직전** 별도 승인된 백업 단계에서 fresh custom dump를 만들고 SHA-256·byte size·TOC parse/count와
current DB identity를 증거에 결속한 뒤에만 승격한다. 기존 dump 존재만으로 이 단계를 생략하지 않는다.
release agent의 자동 rollback 범위는 exact API/Web image, Compose 적용 상태, active-state 파일뿐이다. 이미 적용된
DB migration이나 데이터 변경은 자동 복구하지 않는다. migration/data restore는 별도 owner 승인·대상 DB identity·dump
hash를 갖춘 복원 절차로만 수행한다. 이미지 rollback 성공을 DB rollback 성공으로 기록하면 안 된다.
- active archive `4d15d055…119d4d`, exact API/Web `d5021950…e4b1`/`9796c092…4b36`, fresh dump SHA,
previous `6030a677…c611` 보존.
- health ok, db/engine true, auth 401, OpenAPI 126.
- `http://100.116.83.60:8088` **실제 평문 NAS origin**에서 11-spec browser review 112/112.
- `crypto.randomUUID` 예외 0, error boundary 0, SSE→DB review PASS.
- 학생 홈→회기→리뷰→G4/G5 반환 연습의 desktop/mobile·DB read reload·멱등·UUID 비노출 유지.
- 앞선 실패 시 자동 rollback과 previous image 복구를 검증했고 최종 성공 뒤 G8 카드를 DONE으로 바꿨다.
### 6.3 G7: 외부 종료 Gate
G7 내부 소스 계약은 완료됐고 `scripts/check-g7-external-proof.py`는 현재 의도적으로 exit 1이다.
네 artifact를 **같은 public host, 겹치는 50분 시간창**으로 수집해야 한다.
운영·코드 선행조건과 무마이크 rehearsal은 닫혔다.
- 공개 API는 detached-clean `a73bcd24…`·tree `b02b3a1b…`에서 OpenAPI 126과
`/admin/voice-runtime`을 제공한다. `/voice/health``local_whisper/small` +
`melotts/melotts-korean`, Uvicorn WS queue 4를 정확히 보고하고 receipt `47388d58…9b08`가 passed다.
- local Whisper 9882와 MeloTTS 9883은 API보다 먼저 exact readiness를 통과했고, 런처는 기존 비정상 리스너를
임의 종료·재사용하지 않는다. public health는 promotion과 rehearsal 뒤에도 `status=ok·db=true·engine=true`다.
- **외부 실행 전 코드 P0 3건은 완료:** (1) production 프로세스 exit 0은 canonical checker
`returncode == 0 && gate_closed == true`에 결속한다. (2) 실제 브라우저 Origin
`https://vignette.chanpaca.net`은 exact HTTPS allowlist로, API/WSS/admin/topology
`api-vignette.chanpaca.net`은 별도 transport host와 `wss`/`https` scheme으로 검증한다. (3) 실제 capture 기본·최소를
3,120초로 올리고 sampler를 ceil+terminal sample로 계산하며, checker가 voice/runtime/topology 공통 교집합
**3,000초 이상**을 강제한다. current six-suite 87/87과 runtime sampler 4/4를 통과했다.
- 현재 공개 topology는 Windows host Uvicorn + cloudflared다. 명시적 `windows_host` topology mode는 구현 완료됐다.
API/cloudflared의 PID·start time·executable name/SHA256·command-line SHA256·Git SHA·cwd를 pin하고, 각 sample 전후
identity와 Git HEAD drift를 검사한다. RSS/peak RSS/CPU/handles/threads, process TCP/established/listener,
API listen-port owner/conflict와 host TCP high-water도 수집한다. 기존 `linux_compose`의 api+caddy·fresh-container
규칙은 그대로 유지한다.
- Windows topology는 API/cloudflared의 PID·start·exe SHA·command SHA·cwd와 detached-clean commit/tree,
runner/collector/checker SHA, `psutil==6.1.1`을 결속한다. 30초 rehearsal에서 API listener owner/conflict와
cloudflared TCP, RSS/CPU/handles/threads를 7회 수집했고 raw command line·endpoint·UUID·email은 저장하지 않았다.
- watchdog·로그온 boot 두 task도 같은 `a73bcd24…` stable root에 재등록했다. commit/tree/script hash marker,
working directory, `LastTaskResult=0`·Ready를 확인했다. 실제 Windows 재부팅 뒤 복구 smoke는 아직 남아 있다.
- 운영 Python에 `psutil`이 없으면 collector는 `command_unavailable:psutil`로 fail-closed한다. current
`apps/api/requirements.txt`와 명시적 Python 3.11 모두 `psutil==6.1.1`로 고정됐다.
- 현재 운영 STT 모델은 이 호스트에서 실측된 CPU int8 `small`로 고정한다. cuDNN 9가 설치되고 별도 성능·정확도
gate를 통과하기 전까지 launcher·API runtime metadata·50분 runner/checker expected model을 모두 `small`로 유지한다.
- human voice-gain pack은 category와 categorical κ를 필수로 포함하고, preregistration이 held-out 공개보다 앞서야 하며,
양 조건이 모두 관측된 50회기/150축만 paired 표본으로 집계한다. 한쪽 결측이면 양쪽 모두 최대오류 ITT로 처리한다.
`scripts/check-g7-human-voice-gain.py --input <pack.json>`을 먼저 통과하지 못하면 production runner는 마이크를
열기 전에 exit 2로 끝난다. 스키마는 `--print-schema`로 출력한다.
- 실제 평가팀 intake는 `scripts/prepare-g7-human-voice-gain-intake.py --create-template <dir>`로 시작한다. 생성물은
header-only이고 `template_only=true`라 증거가 아니다. provenance와 비식별 participant/labeler/observation CSV를
채운 뒤 `--compile <dir> --out <pack.json>`을 실행하면 외부 report의 ICC·κ와 행 재계산값의 일치를 확인하고
production gate가 전부 통과할 때만 기존 파일을 덮어쓰지 않고 최종 pack을 만든다. 콘솔은 경로·키·라벨·잘못된
셀 값을 반사하지 않는다.
runtime/checker 6파일 94건, intake 6건, standalone validator 4건을 합쳐 관련 계약 104/104를 통과했다.
빈 템플릿 행동 검증은 `template_is_evidence=false`, compile exit 1, `pack_written=false`를 반환했다. 실제 사람 데이터는 아직 없다.
- 무마이크 rehearsal 산출물은
`D:\workspace\vignette-runtime-evidence\g7-rehearsal-b34623f3db05-20260809T134105Z`에 있다.
voice `61af2e98…009c`, runtime `c6e8670e…4454`, topology `89f1cb86…a251`이며 세 leg 모두 passed,
`physical_capture=false`다.
```powershell
& $py -X utf8 -B scripts/check-g7-external-proof.py `
--voice-soak <soak.json> --runtime <runtime.json> `
--topology <topology.json> --human-voice-gain <pack.json>
```
1. `scripts/soak-public-voice-websocket.py` v4 — 운영 기본값 `local_whisper`/`melotts`의 ready metadata가
실제 provider/model과 정확히 일치하는 authenticated public WSS,
**명시 동의 물리 마이크** 50분 양방향 `passed`. `--confirm-physical-capture` 없이는 장치 열거·캡처를 하지 않는다.
2. `scripts/capture-g7-runtime-evidence.py` — 같은 시간창의 관리자 endpoint worker/Uvicorn queue high-water.
3. `scripts/capture-g7-topology-evidence.py` — 같은 host·exact image의 50분 cgroup/proc/Docker/TCP high-water.
4. 독립 blind human voice-gain pack — held-out 30명 외 calibration split 참가자 포함 총 최소 31명 /
held-out 50회기 / 150 paired axis / blind evaluator 2인 /
ICC(A,1) ≥ 0.75 · κ ≥ 0.70 · gain ≥ 0.01 · participant-cluster bootstrap 10,000회 95% CI lower > 0.
준비 상세는 `docs/ops/outcome-os-g7-external-proof-readiness-2026-08-07.md`.
G7 Windows topology 회귀 명령:
```powershell
Set-Location D:\workspace\vignette
$env:PYTHONPATH='D:\workspace\vignette\apps\api'
& $py -X utf8 -m unittest scripts/test_g7_external_proof.py scripts/test_g7_topology_evidence.py `
scripts/test_g7_runtime_evidence.py scripts/test_g7_external_voice_soak.py `
scripts/test_run_g7_external_proof_window.py apps/api/app/test_g7_voice_gain_evidence.py
& $ruff check scripts/capture-g7-topology-evidence.py scripts/check-g7-external-proof.py `
scripts/run-g7-external-proof-window.py scripts/test_g7_topology_evidence.py `
scripts/test_g7_external_proof.py scripts/test_run_g7_external_proof_window.py
```
**사용자에게 받아야 하는 것 (코드로 대체 불가):**
- 물리 장치 선택과 3,120초 실행에 대한 **명시적 동의**
- 실제 참가자·독립 평가자 운영 승인
합성 label이나 무동의 mic probe로 대체하지 않는다. `check-g7-external-proof.py` exit 0 전에는 G7 메인 상태를
DONE으로 바꾸지 않는다.
## 7. 학생 자기주도 학습 폐루프
```text
학습자 홈 추천 → 새 회기/사전 설정 → 실제 Session → 종료 리뷰 → G4 처방 또는 G5 전이 의도
→ 별도 재연습 회기 → completed-session observer → 실제 G4 attempt / G5 transfer execution
→ read-model reload → before/after·진행도·멱등 재확인
```
실제 DB-backed browser 증거: `apps/web/e2e/returned-practice-db-closed-loop.spec.ts` +
`apps/web/e2e/harness/prepare-returned-practice-db.py`, desktop 2/2 + mobile 2/2, route mock 0,
visible raw UUID 0, getUserMedia/enumerateDevices 0, horizontal overflow 0.
이 harness는 **disposable clone**(전용 DB container + 전용 API 포트 + 전용 vite 포트)에서만 실행한다.
공개 런타임 포트를 재사용하지 않는다. `--api-base-url`, `--database-url`, `--practice-internal-token`,
`--transfer-internal-token`이 필요하고 API health의 `db`·`engine`이 모두 true여야 한다.
2026-08-07 재실행 절차(그대로 재현 가능):
1. `docker run -d --name vignette-g8-e2e-db-<날짜> -p 127.0.0.1:55439:5432` + `infra/db/init` 마운트,
`POSTGRES_USER=vignette_owner` / `APP_DB_USER=vignette_app`. `app.ci_regression_dag_node`가 생기면 준비 완료.
2. 별도 엔진 게이트웨이를 **새 포트**(예: 9199)에 띄운다. 상주 게이트웨이의 claude 세션이 죽어 있으면
`engine=false`가 되므로 공개용 9099를 재사용하지 않는다.
3. uvicorn API를 8021에, vite를 5199에 띄우고 `ENGINE_URL`을 2번 게이트웨이로 지정한다.
4. harness 실행 → `E2E_RETURNED_PRACTICE_DB_CLOSED_LOOP=1` + `E2E_RETURNED_PRACTICE_FIXTURE`로 spec 실행.
5. 결과 기준: desktop 2/2 + mobile 2/2 = **4 passed**. 끝나면 컨테이너·프로세스를 모두 정리한다.
G4/G5 핵심 production bug 수정은 보존한다.
- G4 runtime SQL `digest(...)``app.digest(...)`
- G5 JSONB bind는 `json.dumps` string이 아니라 dict/list object 전달
- route는 nested Pydantic suite를 `body.model_dump()` dict로 깨지 않고 typed object로 전달
## 8. 최종 검증 명령과 현재 기준선
```powershell
$py = 'C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe'
$ruff = 'C:\Users\encep\AppData\Local\hermes\hermes-agent\venv\Scripts\ruff.exe'
```
### Backend full — 기준 API **921 passed**, gateway **58 passed**
```powershell
Set-Location D:\workspace\vignette\apps\api
& $py -X utf8 -B -m pytest -p no:cacheprovider app -q
& $py -X utf8 -B -m pytest -p no:cacheprovider engine_gateway -q
```
### G8 executor/probe/helper/release governance — 기준 28 / 11 / 37 / 30
```powershell
Set-Location D:\workspace\vignette
& $py -X utf8 -B -m unittest scripts/test_serve_nas_preview_rollback_executor.py
& $py -X utf8 -B -m unittest scripts/test_probe_nas_preview_g8_rollback.py
& $py -X utf8 -B -m unittest scripts/test_launch_nas_preview_g8_helpers.py
& $py -X utf8 -B -m pytest -p no:cacheprovider scripts/test_outcome_os_release_agent.py -q
& $ruff check scripts/serve-nas-preview-rollback-executor.py scripts/test_serve_nas_preview_rollback_executor.py scripts/probe-nas-preview-g8-rollback.py scripts/test_probe_nas_preview_g8_rollback.py scripts/launch-nas-preview-g8-helpers.py scripts/test_launch_nas_preview_g8_helpers.py scripts/run-outcome-os-release-agent.py scripts/test_outcome_os_release_agent.py
```
### Web contract/type/build
```powershell
Set-Location D:\workspace\vignette\apps\web
npm run check:api-types
npm run typecheck
npm run build
```
### 현재 HEAD 학생 UX·접근성 focused — 기준 **20/20**
2026-08-09 읽기 전용 재감사에서 `check:api-types`·typecheck·build와 아래 fixture-only 묶음이 desktop/mobile
20/20을 통과했다: Alliance Pulse 8, 자기주도 폐루프 2, insecure-origin UUID 6, pre/mid 자기점검 2,
시간만료 경고·종료 모달 2. 390×844·320×568 overflow 0, tab 키보드, Enter CTA, 44px 조작부,
4.5:1 대비, raw UUID/theory key 비노출, 동의 전 `getUserMedia`/`enumerateDevices` 0도 확인했다.
추가 회귀에서 새 음성 동의 modal을 실제로 수락한 뒤 위기 음성 종료까지 가는 desktop/mobile, 모바일 review
filter 44px 계약을 desktop/mobile로 검증해 4/4 통과했다. dashboard의 file-origin은 spec-relative repo root로
고정했고, session-review fixture 소유권과 viewport assertion, admin live snapshot race를 교정했다. 16:49 단일 실행은
**120/120 PASS(92.0s)**였지만, 그 뒤 dashboard 변경분은 dashboard 10/10으로만 따로 통과했다. 따라서 현 트리는
110 불변 + 10 최신 분할 GREEN이고, 최종 clean source에서 아래 exact 묶음을 다시 단일 실행해 HEAD/tree/archive
결속 뒤 120/120을 요구한다.
#### current-source fixture exact 120 — clean HEAD 재현 명령
정확한 수집 구성은 `dev-dashboard` 10 + `session-mvp` 24 + `session-review` 24 + `learner` 14 +
`session-layout` 8 + `admin` 40 = desktop/mobile 120이다. `127.0.0.1:8000`은 반드시 복구 dump에서 만든
**disposable API/DB/engine 전용 스택**이어야 한다. 이 묶음은 dev-login·세션 쓰기를 포함할 수 있으므로 public
8001, 복구 원본 55432, candidate DB, NAS DB에 연결하지 않는다.
```powershell
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
$OutputEncoding = [Text.UTF8Encoding]::new($false)
$cleanRoot = 'D:\workspace\vignette-clean-<NEW_SHA>'
$cleanWeb = Join-Path $cleanRoot 'apps\web'
$tempRoot = Join-Path $env:TEMP 'vignette-clean-fixture-120'
$tempWeb = Join-Path $tempRoot 'apps\web'
$tempEvidence = Join-Path $tempRoot 'docs\ops\evidence'
if (Test-Path -LiteralPath $tempRoot) { throw "기존 TEMP와 충돌: $tempRoot" }
$pw = Join-Path $cleanWeb 'node_modules\.bin\playwright.cmd'
if (-not (Test-Path -LiteralPath $pw -PathType Leaf)) {
throw 'clean worktree에서 npm ci --ignore-scripts를 먼저 실행해 node_modules를 준비할 것'
}
$health = Invoke-RestMethod -Uri 'http://127.0.0.1:8000/health' -TimeoutSec 5
if ($health.status -ne 'ok' -or -not $health.db -or -not $health.engine) {
throw 'disposable API8000의 db/engine ready가 아님'
}
New-Item -ItemType Directory -Path $tempWeb -Force | Out-Null
New-Item -ItemType Directory -Path $tempEvidence -Force | Out-Null
$config = Join-Path $cleanWeb 'playwright.config.ts'
$files = @(
'e2e/dev-dashboard.spec.ts',
'e2e/session-mvp.spec.ts',
'e2e/session-review.spec.ts',
'e2e/learner.spec.ts',
'e2e/session-layout.spec.ts',
'e2e/admin.spec.ts'
)
$env:PLAYWRIGHT_PORT = '15374'
$env:VITE_API_PROXY_TARGET = 'http://127.0.0.1:8000'
Remove-Item Env:PLAYWRIGHT_BASE_URL -ErrorAction SilentlyContinue
Remove-Item Env:PLAYWRIGHT_SKIP_WEB_SERVER -ErrorAction SilentlyContinue
$code = 99
try {
Push-Location $tempWeb
try {
& $pw test @files ("--config={0}" -f $config) `
--project=chromium-desktop --project=chromium-mobile `
--workers=4 --reporter=line ("--output={0}" -f (Join-Path $tempRoot 'pw-results'))
$code = $LASTEXITCODE
} finally {
Pop-Location
}
} finally {
$listeners = @(Get-NetTCPConnection -State Listen -LocalPort 15374 -ErrorAction SilentlyContinue)
$listenerError = $null
if ($listeners.Count -ne 0) { $listenerError = "Playwright Vite listener 잔존: $($listeners.OwningProcess -join ',')" }
if (Test-Path -LiteralPath $tempRoot) {
$resolved = (Resolve-Path -LiteralPath $tempRoot).Path
if ($resolved -ne $tempRoot) { throw "unexpected TEMP target: $resolved" }
[IO.Directory]::Delete($tempRoot, $true)
}
if ($listenerError) { throw $listenerError }
}
if ($code -ne 0) { throw "clean fixture 120 failed: $code" }
```
과거 API-ready 없이 돌린 별도 Vite-only 혼합 실행의 `session-layout` 8건은 DB persona가 없어 제품 assertion 전에
`seed_fallback/degraded`로 중단됐다. 위 exact 120 명령은 이 사각지대를 닫기 위해 disposable API의 db/engine health를
선행 강제한다. 최종 완료 때는 `session-layout` 8/8과 returned-practice DB closed-loop 4/4를 모두 다시 통과시킨다.
### Dashboard SSOT + E2E — 기준 SSOT PASS(FAIL 0), unit 5/5, dashboard 10/10
```powershell
Set-Location D:\workspace\vignette
& $py -X utf8 -B scripts/check-dev-dashboard-ssot.py
& $py -X utf8 -B -m pytest -p no:cacheprovider scripts/test_dev_dashboard_ssot.py -q
Set-Location D:\workspace\vignette\apps\web
.\node_modules\.bin\playwright.cmd test e2e/dev-dashboard.spec.ts --project=chromium-desktop --project=chromium-mobile --workers=1 --reporter=line
```
status count를 바꾸면 `scripts/check-dev-dashboard-ssot.py``EXPECTED_STATUS_COUNTS`
`apps/web/e2e/dev-dashboard.spec.ts``metrics.done`/`metrics.doing`**함께** 갱신한다.
### 격리 NAS 런타임 회기 E2E
```powershell
Set-Location D:\workspace\vignette\apps\web
$env:PLAYWRIGHT_BASE_URL='http://100.116.83.60:8088'
$env:PLAYWRIGHT_SKIP_WEB_SERVER='1'
node.exe .\node_modules\@playwright\test\cli.js test e2e/session-layout.spec.ts e2e/session-persistence.spec.ts `
e2e/self-directed-learning-loop.spec.ts e2e/alliance-pulse.spec.ts e2e/outcome-trajectory.spec.ts `
e2e/rupture-repair.spec.ts e2e/deliberate-practice.spec.ts e2e/calibration-transfer.spec.ts `
e2e/supervision-research.spec.ts e2e/multimodal-alliance.spec.ts e2e/continuous-improvement-admin.spec.ts `
--project=chromium-desktop --project=chromium-mobile --project=chromium-single-run --workers=1 --reporter=line
```
전체 Playwright inventory는 629 tests / 45 files다. 실행 환경/API/DB를 정확히 맞추지 않고 fixture failure를
제품 failure로 오인하지 않는다. 같은 blocker가 두 번 반복되면 전체 재시도 대신 원인·증거·수정 계획을 먼저 보고한다.
주기 실회기 검증은 `scripts/run-periodic-learner-e2e.py`가 소유한다. NAS 112건 중 실 API/DB는 22건이고
route fixture는 90건이므로 전체 숫자를 실제 회기 폐루프로 부르면 안 된다. 새 runner는 clean HEAD/tree에서만
전용 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은 reload 뒤 persisted episode를 `학습자만 실행`으로 표시하는 제품 결함을 검출했다.
5·6차 clean run은 핵심 동일 학습자 SSE·review·G4/G5 `0→1→1`을 연속 통과했지만 fixture 공유와 mobile success-state locator를 각각 fail-closed했다. 7차 clean `aa81af29…`는 첫 전체 GREEN을 만들었다. `94666192…``f97e7fad…` run은 disposable DB init에서 멈췄고 두 번째 receipt의 cleanup 전 로그가 `99_app_role.sh``#!/usr/bin/env bash\r` exit 127을 확정했다. Git blob LF를 Windows checkout에서도 보존하도록 `.gitattributes``*.sh text eol=lf`를 고정했다. 공통 톱바 desktop/mobile 44px을 포함한 clean `b02bee26…`·tree `8f677bba…`의 최신 single full run은 487.3초에 GREEN이었다. receipt `periodic-learner-e2e-20260812-171547.json`·SHA-256 `8c11a136…af8e3`은 same-learner SSE·review·G4/G5 `0→1→1`, returned desktop/mobile 4/4, route mock 0, public/NAS/active DB 접촉 0과 exact cleanup 0을 증명한다. heartbeat는 최신 GREEN이 6시간 이상 오래됐거나 material milestone이 바뀔 때만 다시 실행한다.
## 9. 핵심 변경 파일
- `scripts/launch-nas-preview-g8-helpers.py` · `scripts/test_launch_nas_preview_g8_helpers.py` (신규, Gate6 계약)
- `scripts/serve-nas-preview-rollback-executor.py` · `scripts/test_serve_nas_preview_rollback_executor.py`
- `scripts/probe-nas-preview-g8-rollback.py` · `scripts/test_probe_nas_preview_g8_rollback.py`
- `scripts/run-outcome-os-release-agent.py` · `scripts/test_outcome_os_release_agent.py`
- `scripts/check-dev-dashboard-ssot.py` · `scripts/test_dev_dashboard_ssot.py`
- `scripts/check-g7-external-proof.py` · `scripts/soak-public-voice-websocket.py` ·
`scripts/capture-g7-runtime-evidence.py` · `scripts/capture-g7-topology-evidence.py` ·
`scripts/run-g7-external-proof-window.py`와 대응 G7 테스트 3개
- `apps/web/src/lib/uuid.ts` · `apps/web/e2e/insecure-context-uuid.spec.ts` (신규, 비-secure origin 결함 수정)
- `apps/api/app/session_read_model.py` · `apps/api/app/services/session_metrics.py` ·
`apps/api/app/test_session_read_model.py` · `apps/web/e2e/session-persistence.spec.ts` (UTC/KST P0와 실DB browser 회귀)
- `apps/web/index.html` · `apps/web/e2e/self-directed-learning-loop.spec.ts` (SPA route 전환 중 boot diagnostic 회복)
- `apps/web/e2e/admin.spec.ts` · `apps/web/e2e/dev-dashboard.spec.ts` ·
`apps/web/e2e/session-review-fixture.ts` · `apps/web/e2e/session-review.spec.ts`
- `scripts/watch-public-runtime.ps1` · `scripts/boot-public-runtime.ps1` ·
`scripts/install-public-runtime-task.ps1` · `scripts/register-boot-task.ps1` ·
`scripts/watch-public-runtime-hidden.vbs` · `scripts/test_public_runtime_watchdog_provenance.py`
- `apps/web/e2e/returned-practice-db-closed-loop.spec.ts` ·
`apps/web/e2e/harness/prepare-returned-practice-db.py`
- `docs/dev_dashboard.html` · `docs/TODO.md` · `docs/README.md` · `docs/ops/backlog-2026-06-26.md`
- `docs/ops/nas-preview-g8-rollback-proof-runbook.md` ·
`docs/ops/evidence/nas-preview-g8-actual-rollback-2026-08-07.json` ·
`docs/ops/nas-preview-deployment-evidence-2026-08-07.md`
## 10. 최종 원칙
- source-only PASS를 runtime DONE으로 부르지 않는다.
- health만 보고 배포 완료라고 하지 않는다. auth, OpenAPI, assets, browser SSE→DB review를 함께 본다.
- synthetic control plane을 실제 NAS rollback으로 과장하지 않는다.
- G7 물리 마이크·사람 평가를 무동의/합성 데이터로 대체하지 않는다.
- current/previous images와 DB backup을 확인하기 전 destructive operation을 실행하지 않는다.
- 성공보다 정직한 fail-closed 증거가 우선이다.
## 11. 권장 멀티에이전트 실행 구조
사람 개발자 작업계획은 만들지 않는다. 구현·테스트·배포·증거 동기화는 에이전트가 수행하고, 사람에게는 코드로
대체 불가능한 동의·인증 세션·실제 참가자/평가자 승인만 요청한다. 동시 작업은 최대 세 하위 lane으로 나누되,
NAS/public/DB mutation owner는 항상 한 에이전트만 둔다.
1. **G7 proof lane:** 완료된 clean public runtime과 무마이크 rehearsal을 기준선으로 보존하고, 장치 선택·명시 동의 뒤
3,120초 mic/runtime/topology 동시 캡처와 human pack·canonical checker를 닫는다.
2. **G8/NAS recovery lane:** 완료된 archive·images·rollback 증거는 보존한다. current relay는 source-pinned launcher
8/8과 detached-clean `f08e03de…f191` exact check-only까지 완료됐고 실제 9100은 0 listener다. 소유자 승인 전
mutation 0; 승인 뒤 같은 source/tree·hash 결속으로 actual 1회→NAS-origin 재검증.
3. **E2E/SSOT lane:** local fixture, disposable DB browser, visual/accessibility, dashboard/TODO/backlog 정합을 읽기 전용으로 감사.
루트 에이전트는 각 lane의 증거 SHA와 실패 원인을 합쳐 게이트를 판정한다. 같은 blocker가 두 번 반복되면 세 번째
재시도 전에 원인·증거·수정 계획을 사용자에게 보고한다.
## 12. 새 에이전트에 그대로 줄 시작 프롬프트
```text
D:\workspace\vignette의 docs/HANDOFF.md를 인수인계 SSOT로 읽고, AGENTS.md → docs/README.md →
docs/dev_dashboard.html → docs/TODO.md → docs/ops/backlog-2026-06-26.md 순서로 현재 상태를 재확인해.
G8 배포 source는 HEAD `61a41d1f…6af`·tree `87dec55d…3b77`·archive `4d15d055…119d4d`다. 격리 NAS의
API/Web `d5021950…e4b1`/`9796c092…4b36`, candidate 112/112, actual NAS-origin 112/112, 당시 health·auth 401·OpenAPI
126을 통과했고 fresh dump `36ec8748…24db8`과 previous `6030a677…c611`을 보존했다. 이 종료 증거는 DONE이지만
current NAS는 engine relay 부재로 DEGRADED이고 2026-08-12 15:24 health 3회가 타임아웃했다. 실제 relay 복구는
소유자 승인 전 금지한다. 현재 공유 worktree의 후속 변경은 이 배포 source와 분리해서 다뤄.
최종 목표는 G0~G8 아홉 목표를 코드-only가 아니라 실제 runtime 증거로 모두 닫는 것이다. G0~G6과 G8은 DONE이다.
G7은 내부 구현과 clean public runtime·local_whisper/melotts ready·authenticated public WSS 무마이크 rehearsal까지 완료했다.
이제 사용자가 고른 장치와 명시 동의를 받은 물리 마이크 3,120초 soak, 같은 시간창의 runtime/topology high-water,
독립 human voice-gain pack을 모아 canonical checker exit 0을 만들어야 한다.
G7 runner exit의 checker exit 0/gate_closed 결속, browser Origin과 API/WSS host 분리, 3,120초 capture와
3,000초 공통 overlap, detached-clean HEAD/tree, runner/collector/checker SHA와 exact psutil pin은 소스에서 완료됐다.
API/cloudflared는 detached-clean `a73bcd24…`·tree `b02b3a1b…`에서 새 PID로 교체됐고 safe receipt
`47388d58…9b08`가 passed다. 공개 OpenAPI 126·`/admin/voice-runtime`·local provider/model·queue 4와
public health db/engine true를 확인했다. 30초 rehearsal은 인증 WSS·runtime·Windows topology 세 leg 7 samples를
모두 통과했고 `physical_capture=false`·UUID/email 0이다. 이 증거를 물리 마이크나 사람 평가로 과장하지 마.
watchdog·로그온 boot도 같은 stable root와 exact Git commit/tree/script SHA에 재등록했고 두 task의 명시 실행 결과가
0·Ready다. 실제 Windows 재부팅 뒤 자동복구 smoke는 별도 운영 게이트로 남는다. cwd/Git pin이나 기존 Linux Compose
gate를 약화하지 말고 합성 증거로 G7을 DONE 처리하지 마.
학생 자기주도 폐루프는 홈 추천→회기→리뷰→G4 처방/G5 전이→별도 재연습→completed-session observer→
actual attempt/execution→read-model reload→before/after·멱등까지 desktop/mobile 실제 DB/browser로 검증해.
최종 검증은 API full, gateway full, schema/provenance, Web API types/typecheck/build, fixture E2E, disposable DB E2E,
NAS-origin E2E, visual/accessibility, auth/OpenAPI/assets/SSE→DB review를 포함해야 한다.
독립 작업은 서브에이전트로 병렬화하되 NAS/public/DB mutation은 단일 owner만 수행해. 공개 DB 복구 volume과
pre-recovery container/dumps, NAS named volume과 previous images는 삭제하지 마. 비밀/PII/raw UUID를 증거에 남기지 마.
과거 로컬 recovery dump는 현재 위치를 별도로 확인하기 전 삭제로 단정하거나 그 파일을 전제로 복원하지 마.
2026-08-09 fresh public dump `vignette-dev-db-vignette-20260809-122235Z.dump` SHA `f1fd569c…5f64`도 보존해.
source-only PASS나 health-only를 DONE으로 부르지 말고, 대시보드·TODO·backlog·HANDOFF를 마지막 증거와 함께 동기화해.
모든 게이트가 실제로 닫히기 전에는 goal complete를 선언하지 마.
```

View file

@ -0,0 +1,13 @@
# 2026-09-07 문서 정리 전 원문 보존
이 폴더는 활성 문서를 현재 실행 항목으로 줄이기 전에 복사한 원문이다. 현재 상태의 권위 기준으로 사용하지 않는다.
| 파일 | 보존 이유 |
|---|---|
| [`HANDOFF-before-cleanup.md`](./HANDOFF-before-cleanup.md) | 2026-08-18까지의 상세 인수인계와 검증 이력 |
| [`TODO-before-cleanup.md`](./TODO-before-cleanup.md) | 완료·진행·외부 게이트가 함께 있던 통합 TODO 원문 |
| [`backlog-2026-06-26-before-cleanup.md`](./backlog-2026-06-26-before-cleanup.md) | 완료된 A 경로·B3 결정 기록을 포함한 백로그 원문 |
| [`handoff-goal-production-2026-08-29.md`](./handoff-goal-production-2026-08-29.md) | 2026-08-29~30 배포 전 인수인계 기록. 2026-09-01 NAS 배포 뒤 현행 실행 근거에서 분리했다. |
| [`refactor-governance-2026-07-15.md`](./refactor-governance-2026-07-15.md) | 완료된 P1~P8 리팩터 감사·검증 이력. 후속 리팩터는 활성 TODO·backlog가 소유한다. |
원문은 삭제하거나 내용을 정정하지 않았다. 2026-09-01 배포 당시 증거는 [NAS 배포 증거](../../ops/evidence/nas-prod-deploy-2026-09-01.json)를 참조한다.

View file

@ -0,0 +1,659 @@
# Vignette 할 일 (안된 것들) — 통합 TODO
> **한 곳에서 보는 "남은 작업" 인덱스.** 여러 문서에 흩어져 있던 열린 작업을 주제별로 모았다.
> **권위 기준은 SSOT `dev_dashboard.html`**, 열린 게이트 추적은 `ops/backlog-2026-06-26.md`(얇은 현행본)다.
> 이 파일은 그 둘을 **집계·링크**하는 뷰이며, 상태가 어긋나면 대시보드를 기준으로 맞춘다.
> 완료된 작업 기록은 `archive/`(냉동 보관)에 있고 평상시 읽지 않는다.
>
> 태그: **[환경]** 이 워크스테이션에서 증거 생산 불가 · **[외부]** 기관/소유자 서면·결정 필요 ·
> **[구현]** 코드로 진행 가능 · **[임상]** 임상팀(구훈정·어유경) 외부 콘텐츠 소유.
---
## 개선관리 워크북 동기화 (2026-08-29)
> 이 표는 워크북 ID와 현행 구현을 잇는 얇은 추적표다. 내부 기술 DONE의 focused 증거는
> `guides/testing.md`, 상세 계약은 SSOT `dev_dashboard.html`이 소유한다. C-001은 기술 사전검증과 외부
> 임상 승인을 분리한다. 검토 패킷과 fail-closed 판정기는 준비됐지만 외부 증거가 없으므로 계속 열린 항목이다.
> 완료본은 `outputs/01a04217-f73b-7303-b597-401fa7f5d290/Vignette_개선관리_완료.xlsx`, SHA-256
> `c832547f30ae0664e54b302c8e9f62cc157f31022ad158d17803d8cac988d8bd`다. 집계는 **완료 9·검토 2·총 11**이며
> 검토는 C-001 적격 외부 임상 검수와 REQ-008 실제 Windows 재부팅뿐이다.
| ID | 상태 | 현행 경계 |
|---|---|---|
| C-001 | **OPEN · 외부 GATE** | v2 P1 합성 사례 6건·상태별 canonical 판정기와 109·수단 상세 차단·런타임/carry-over/output `ideation_stage <= 3`·4개 공식 source 기술 사전검증은 완료. 사례별 5필드 30개, 청소년 답변 6개, 검토자·소속·자격·검토일·결정 근거·서면 증거/해시·검토 버전/사례 해시를 포함한 승인 10필드가 있어야 닫는다. 현재 `review_complete=false`; F절/B4에서 추적한다. |
| C-002 | **DONE · 내부 기술** | 첫 회기 라포 반영·한 초점 열린 질문 체크리스트, 근거 turn 연결, 이후 회기 not-applicable |
| C-003 | **DONE · 내부 기술** | 종료 회기 4건 미만 insufficient, 이후 최다 페르소나 비중 0.75 이상 훈련 집중 주의; 임상·공정성 판정 아님 |
| REQ-001 | **DONE · 운영 수락** | provider가 검증한 모든 Google 계정은 도메인·사전등록 없이 learner·approved로 로그인한다. Pages `0c60261e…`의 단일 Google CTA·공개 Playwright 2건, 기존 Gmail 인증 세션의 로그인 이메일·관리자 진입·온보딩 비전환·복구 데이터 가시성을 확인했고 소유자가 수락했다. 현재 public API 장애는 별도 운영 복구 게이트이며 이미 닫힌 수락 증거를 지우지는 않는다. |
| REQ-002 | **DONE · 내부 기술** | admin protocol draft→active→retired, 라이선스 C/D 외부 LLM 금지, evaluator-only RAG·rollback; migration 17 owner-run/readiness-only |
| REQ-003 | **DONE · 내부 기술** | 페르소나 주호소가 JSON 키 순서나 surface 상태보다 우선 |
| REQ-004 | **DONE · 내부 기술** | learner 현재 설정 AND 세션 snapshot. OFF는 AI 파생 API 403/UI 무요청·혼합 결과 redaction, 사용자 입력·privacy 보존; teacher/admin 유지 |
| REQ-005 | **DONE · 내부 기술·운영 실증** | 새 회기는 승인 페르소나 `persona_id/version` 고정. P20의 `suicide_ideation_stage=0.9 → int(0) → DB 1..5` 장애를 clamp로 제거했고, 공개 P20 회기 생성·실제 AI 응답·종료·평가를 확인 |
| REQ-006 | **DONE · 내부 기술** | prompt와 구조화 결과의 상담자/내담자 identity를 `[COUNSELOR]`/`[CLIENT]`로 역할 마스킹 |
| REQ-007 | **DONE · 내부 기술** | 회기 종료를 평가보다 먼저 영속화해 평가 실패 뒤에도 같은 learner-persona case의 다음 회기 생성·턴 진행 가능. 종료·시작은 같은 case row lock 순서를 써 S2가 S1 summary/carry를 빠뜨리지 않고, 진행 중인 같은 case는 409 `active_session_exists`로 기존 회기만 이어간다. migration 21의 online partial unique index와 invalid-index pre/postcondition이 병렬 생성을 fail-closed로 차단 |
| REQ-008 | **READY · 실제 재부팅 GATE** | 원본 응답 생성 실패는 기본 엔진+live-client readiness, 구조화 done 없는 SSE EOF의 `client_stream_incomplete` 복원과 공개 P20 실제 응답으로 닫았다. 후속 재부팅 복구는 전역 mutex·단계별 상한·프로세스 트리 종료·전체 다운 즉시복구와 PowerShell 5.1 timed `WaitForExit`의 process handle 선확보를 적용했다. 실제 PS5.1 계약 32 passed, detached-clean `44b7835c…`·tree `06133249…`, 5174 자동복구·HTTP 200, watchdog result 0/failcount 0까지 확인했지만 실제 Windows 재부팅 smoke 전에는 개선관리 상태를 `검토`로 유지한다. PC 비종속 상시 호스트 이전도 별도 운영 게이트다. |
2026-08-29 read-only 상태(<b>이 PC 로컬 기준</b>): 정적 public Web과 Pages `0c60261e…`(source `5bf89ff…`)는 유지됐지만,
이 PC 로컬 API 8001은 연결 거부였고 이 PC의 Docker daemon 부재로 이 PC 기준 DB/API는 복구 불가였다. task는 old
detached `dba9b75a…` runtime을 계속 가리켰다. <b>2026-08-31 재실측(정식 분리 확인):</b> `https://api-vignette.chanpaca.net/health`
별도 호스트(Cloudflare 뒤)에서 200·`environment=prod`·`db=true`·`engine=true`·`engine_mode=openai`로 정상 서빙 중이며,
이 PC에는 cloudflared 프로세스가 없어 public 도메인은 이 PC가 아니다. 즉 이 PC 로컬 8001/task 상태는 개발 워크스테이션의
잔여 스냅샷이고 public 도메인 장애를 뜻하지 않는다. avatar fail-closed 코드와 upload receipt는 아직 후보이고 배포하지 않았다.
2026-08-30 NAS production 재확인에서는 `vignette-prod` DB·API·engine이 healthy였고, restart loop이던 web/proxy는
필요 최소 capability만 추가해 두 서비스만 재생성했다. 내부 Caddy→web과 `/api/health`는 200이지만, compose의
Tailnet bind 주소가 NAS 인터페이스에 없어 외부 private/public GREEN은 아직 주장하지 않는다. LAN/0.0.0.0으로
노출을 넓히지 않았다. 회기 연속성 migration 21은 중복 활성 learner-persona case 14개(초과 row 28개)를 발견해
보존·종료 기준을 owner가 결정할 때까지 적용하지 않는다. 기존 20번 transactional migration과 새 21번 online index,
연속성 hunk만 포함한 production 전용 clean candidate가 필요하며 preview 전용 release agent와 8/7 manifest는 사용하지 않는다.
---
## A. 배포·공개 런타임 게이트 [환경]
> 출처: `ops/backlog-2026-06-26.md` B2 · 대시보드 운영 탭 · `ops/tailscale-vnet-runtime-2026-06-27.md` · `ops/public-runtime-watchdog.md` · `ops/handoff-goal-production-2026-08-29.md`
>
> **2026-09-01 정식 배포(A 경로) 완료:** Forgejo master `dce85620`를 NAS `vignette-prod`에 실배포(api/engine/web 교체·pgdata/apiuploads 무접촉·사전 dump `cab6826b…` 보존), 중복 활성 회기 30행 최신 유지 정책 종료 후 마이그레이션 20/21/22 온라인 적용, Cloudflare Pages도 새 빌드 배포. public health `ok·db/engine true`·OpenAPI 200·auth 401·dev-login 404·데이터 집계 전후 동일. 이 절의 "이 PC 런타임" 항목들은 NAS 이관으로 대체 경로가 됐다. 증거: `ops/evidence/nas-prod-deploy-2026-09-01.json`, 상세는 백로그 A 경로 절.
- [ ] **public API/DB 복구와 avatar fail-closed 정식 승격** — 허용 source union은 93 objects·52,973 bytes,
inventory SHA-256 `9d703126f78d4fc8330408835d76a7d680276240dc578d6fc9ca420c2f25e6aa`다. 실제 decode는 정상 3·실패 90,
현 DB 참조는 정상 2·실패 6·missing 0이다. 후보는 손상 bytes/DB reference를 private forensic으로 보존하고
public static 응답을 404/fallback으로 막으며 decode-valid 객체만 immutable public cache에 제공한다. 신규 UUID 업로드도
URL 기록·현재 참조·GET/HEAD에서 다시 full decode하고, 업로드 뒤 손상은 current invalid/health fallback에 포함한다. 전체 API/Web/
PowerShell 회귀, 실제 브라우저 fallback, exact target/total/digest receipt, 후보 SHA와 사용자 배포 승인 전에는 Docker·
API/tunnel·task·DB avatar URL·stable upload root를 바꾸지 않는다.
현재 Docker engine은 꺼져 있고 public watchdog이 활성이라 daemon 시작 즉시 기존 runtime 복구가 개입할 수 있다. 풀스택 E2E는
watchdog 일시 중지·비활성화, 고유 DB/container/volume과 56432/58000/55173 포트, exact cleanup, Docker·watchdog 원상복구를
명시 승인받은 뒤 `layout-visual-gate` 15/15와 `session-layout` 8/8을 실행한다.
- [ ] **내장 브라우저 G8 사람 게이트 E2E** — 명시적 승인·보류(`keep_quarantine`)·반려(`reject`)와 사유 입력,
증거 미충족 승인 차단은 typecheck와 route-fixture desktop/mobile **10/10**을 통과했다. 빠른 3.5초/느린 8초 응답에서도
watchdog overlay 0을 확인했다. Codex 내장 브라우저의 격리 local stateful fixture에서도 8.5초 뒤 overlay 0·heading 정상,
content `keep_quarantine` 뒤 effect 0, 증거 없는 release 승인 disabled→`reject` 뒤 effect 0, 증거 4종 promote 승인 뒤
lifecycle effect 정확히 1을 공동 확인했다. <b>로컬 사람 게이트는 GREEN</b>이지만 실DB append-only 결정과 public proof는
아직 없으므로 current 운영 G8 전체를 GREEN으로 올리지 않는다.
- [ ] **공개 DB 계정·회기 복구 안정화** — 2026-08-07 18:26 KST owner 승인으로 recovered named volume을
공개 `vignette-dev-db:55432`에 전환했다. cutover 기준 users 84/sessions 30/turns 705,
Google 계정 16/소유 회기 30, orphan 0, health·engine·OAuth·watchdog가 정상이다. 전환 직전 container와
dump, 원본 volume을 보존한다. 남은 것은 사용자가 Google 재로그인한 뒤 본인 과거 회기가 보이는지
확인하는 일과 자동 백업 운영화다. 기존 인증 세션은 복사하지 않아 재로그인이 필요하다.
2026-08-09 Docker Desktop 중단과 18:02:55 KST Docker Desktop UI의 active DB 직접 stop으로 `db=false`
재발했으나 동일 recovered container/volume만 재기동했고, 당시 owner read-only 집계는 users 356/sessions 193/
turns 726, Google users 16/Google-owned sessions 31이며 public db/engine true였다. watchdog·로그온 boot는 detached
clean release의 Git commit/tree와 watchdog|boot/start SHA가 없으면 mutation 전에 실패하도록 코드로 고정했다.
공개 API·cloudflared는 detached-clean `a73bcd24…`·tree `b02b3a1b…`로 fresh 승격했고 receipt
`47388d58…9b08`가 passed다. local/public health는 `status=ok·db=true·engine=true`, 공개 OpenAPI 126,
`/admin/voice-runtime`, `local_whisper/small` + `melotts/melotts-korean`, queue 4를 확인했다. 로그온·watchdog
두 task도 같은 root/commit/tree/script SHA에 재등록해 명시 실행 `LastTaskResult=0`을 확인했다.
2026-08-12에는 휴면 Gradle daemon의 `8001` 점유·cloudflared/voice sidecar 중단과 PowerShell 5.1 native stderr
조기 종료 결함을 복구했다. DB mutation 전에 custom dump `9cbda31e…0ce5`(7,532,625 bytes·TOC 1,492)를
고정했고, detached-clean `a73bcd24…`·tree `b02b3a1b…`로 API/cloudflared를 fresh 승격했다. receipt
`47388d58…9b08` passed, public health 3/3·db/engine true·OpenAPI 126·local voice exact, watchdog·로그온 boot
명시 실행 result 0을 확인했다.
2026-08-29에는 Windows PowerShell 5.1에서 `Start-Process -PassThru`의 process handle을 열기 전에 timed
`WaitForExit()`를 호출하면 정상 web build 뒤에도 `ExitCode=$null`로 남아 복구를 실패로 오판하는 경계를 수정했다.
실제 PowerShell 5.1 계약 32 passed, detached-clean `44b7835c…`·tree `06133249…`에 두 task를 같은 핀으로
재등록했다. watchdog 3회차 복구로 5174가 자동 기동했고 후속 실행은 `LastTaskResult=0`·failcount 0,
local/public health `status=ok·db=true·engine=true`다. 실제 Windows 재부팅 smoke는 아직 남아 있다.
2026-08-18에는 06:30~10:05 KST 엔진 readiness 장애(고효율 프로브 타임아웃 실패가 1800초 캐시 +
구독 사용량 소진)를 수정했다. 실패 전용 캐시 TTL 30초 분리·프로브 타임아웃 45초로 교정하고
detached-clean `4771b97c…c880`으로 API/게이트웨이/task를 재승격·재등록해 `LastTaskResult=0`,
공개 health ok·OpenAPI 126을 재확인했다. 승격 절차에 이전 루트 `apps/api/.env` 복사 단계가
필요함을 확인했다(1차 승격 fail-closed·약 4분 다운타임).
남은 자동복구 gate는 실제 Windows 재부팅 smoke와 정기 DB 백업 운영화다.
상세: `ops/public-db-recovery-rehearsal-2026-08-07.md`.
- [ ] **자동 DB 백업·정기 복원 리허설**`scripts/backup-vignette-db.ps1`의 custom dump→TOC 검증→
SHA manifest 원자 게시와 현재 public-target 일회용 복원(129 tables/9,646 rows/orphan 0)을 통과했다.
남은 범위는 예약 실행, 실패 알림, NAS/off-host 암호화 복제, 주기적 restore drill과 보존 정책이다.
- [x] **공개 Google OAuth 실제 `/turn` proof** — 2026-08-28 기존 Google 세션으로 callback→관리자 진입을
확인하고 P20 회기 생성→자기점검 잠금→실제 학습자 발화/AI 내담자 응답→종료→리뷰·평가→다음 회기 버튼
재노출까지 공개 브라우저 폐루프를 통과했다. 별도의 신규 Gmail 선택은 새 Vignette 계정 생성 확인 항목이다.
- [x] **공개 Web 모바일 보정 Pages 배포** — 2026-08-18 소유자 승인 후 detached-clean `cf899b7f…7c96`
완료했다(00f851a6 후보의 모바일 보정을 포함한 상위 커밋). API type/typecheck, 2회 결정적 build(599파일
sha256 전일치), 7개 origin 자산 143개 보존(최종 742파일) 후 Cloudflare Pages production `4edc02d3` 배포 —
custom-domain 새 엔트리 `index-BWxBaCPM.js`/`index-BUfQc2Rm.css` 서빙, 직전 엔트리 `index-BrpoQ5nP.js` 200
보존, preview 200을 확인했다. 공개 API/DB·Scheduled Task는 건드리지 않았다. 증거:
`docs/ops/evidence/public-web-deploy-2026-08-18.json`.
증거: `ops/evidence/public-web-preflight-2026-08-12.json`.
- [ ] **Compose 패키징용 `infra/.env` owner-secret** — 현재 운영 direct-runtime은 독립 G3~G8 token 주입,
non-owner app-role preflight와 공개 health를 통과했다. Compose로 전환할 때만 `APP_DB_PASSWORD` 등 패키징
비밀과 prod-safe engine/voice flags를 별도 주입하고 compose profile preflight를 다시 통과한다.
- [ ] **`vnet.18ka.net`/`api-vnet.18ka.net` 공개 live** — DNS A record 등록, cloudflared의 `18ka.net` zone 권한,
Google redirect URI `https://api-vnet.18ka.net/auth/callback` 등록. (현재 prod API 8001은 health OK)
- [x] **비-secure origin에서 회기 리뷰 크래시 수정** — 2026-08-07 격리 NAS 프리뷰
`http://100.116.83.60:8088`(평문 HTTP·비-localhost)에서 회기 리뷰 라우트가
`crypto.randomUUID is not a function`으로 error boundary에 잡혔다. `crypto.randomUUID`는 secure
context에서만 노출되는데 제품 코드 18곳이 fallback 없이 직접 호출했고, 특히
`RuptureRepairCard.tsx`는 렌더 시점(`useRef`)에 호출해 화면 전체가 죽었다. 릴리스 게이트는
localhost 후보 스택(secure context)에서만 돌아 이 경로를 밟은 적이 없다. `src/lib/uuid.ts`
`randomUuid()`로 통일했고 fallback도 `crypto.getRandomValues`를 우선 사용해 idempotency key의
예측 불가능성을 유지한다. 문장 fingerprint의 `crypto.subtle.digest` 의존도 portable SHA-256 fallback으로
교체했다. source-only UUID/SHA 회귀 12/12와 secure/insecure exact digest 회귀를 고정했고, 최종 archive
`4d15d055…119d4d`의 실제 NAS 평문 origin browser review 112/112로 배포 검증까지 닫았다.
- [x] **워치독 engine readiness 사각지대** — 2026-08-07 22:0x KST 소유자 승인으로 9099 재기동과
판정 기준 교정을 모두 마쳤다. 복구 실측: 9099 `/ready?force=true` 200 `ok:true, detail:OK`,
API 8001과 공개 `api-vignette.chanpaca.net` 모두 `status:ok, engine:true, engine_detail:OK`.
원인은 16:06부터 상주하던 9099 **프로세스 하나**였다. 같은 인자로 `claude -p`를 직접 실행하고
`EngineSession`을 그대로 재현하면 양쪽 다 `"OK"`를 반환해 CLI·코드·cwd는 무관함을 확인했다
(별도 포트 9199 실험과 동일 결론).
재발 방지 3건:
(1) `scripts/watch-public-runtime.ps1`·`scripts/start-public-runtime.ps1`의 engine 판정을
`/health`(프로세스 liveness) → `/ready`(실제 생성)로 바꾸고, API 판정에 `engine`을 추가했다.
shared secret 인스턴스를 401로 오판하지 않도록 `X-Vignette-Engine-Token` 헤더를 붙이고,
503 응답 **본문**을 로그 detail에 남긴다.
(2) `engine_gateway/gateway.py`가 자식 `claude -p`의 stderr를 상시 드레인해(PIPE를 아무도 읽지
않으면 자식이 블록되는 문제도 함께 차단) 실패 detail에 `exit`·stderr를 붙인다. 실증:
`empty engine response (error: unknown option '--vignette-nonexistent-flag-xyz')`.
`str()`이 비는 `TimeoutError` 등에는 타입명을 최소 보장한다.
(3) 기동 시 게이트웨이 로그 회전, `Stop-UvicornByPort``Name -like python*` 조건 추가
(명령 문자열만 매칭해 호출자 자신을 죽이는 사고 방지).
검증: `pytest engine_gateway` 58/58, 워치독 `-CheckOnly` healthy 5/5, 장애 재현(9299를
`CLAUDE_BIN` 부재로 기동)에서 `/health``ok:true`로 통과하고 `/ready`는 503으로 검출되어
워치독이 `unhealthy (1/3)` + 503 본문을 남겼다.
NAS용 9100은 2026-08-12 listener 상실을 확인했다. current relay launcher는 detached-clean HEAD/tree,
launcher·Python·env SHA, exact consumer URL, explicit LAN 승인, 외부 receipt/runtime, listener owner,
generation readiness와 실패 cleanup을 강제하도록 보강했고 contract 8/8을 통과했다. detached-clean
`f08e03de…f191`·tree `00275133…0abd` exact `-CheckOnly`도 launcher/Python/env SHA 결속,
`mutation=false`, listener `0→0`, runtime state 0으로 PASS했다. 실제 9100 시작은 소유자 승인 전 미실행이다.
- [x] **NAS preview engine relay current 복구** — 2026-08-18 심야 current GREEN 달성으로 종결.
`-CheckOnly` 프리플라이트(4종 SHA pin) → 실제 시작 passed → 방화벽(LAN 한정 9100) → health 3/3
`status=ok·db=true·engine=true`·auth 401·OpenAPI 126 → **actual-origin 실회기 turn→review smoke 통과**
(agy 턴 2개→종료 200→리뷰 200). 대청소 사건으로 릴레이 루트가 삭제돼 현행 루트 `bf5f7352`로 재핀(r3 영수증)했고,
DHCP가 LAN IP를 224로 회수해 릴레이 바인딩·NAS `ENGINE_URL``192.168.0.224:9100`으로 이전(방화벽 규칙 재발행,
NAS `.env(0600)` 재구성 + api만 재생성), 클로드 예산 한도 재발으로 NAS `ENGINE_MODE=agy_cli` 전환. 잔여 권고:
공유기 DHCP 예약(호스트 MAC D8-BB-C1-A2-3C-5D), Docker VM 클럭 동기화(호스트 대비 ~4일 선행),
NAS 로컬 agy 이전(릴레이·노트북 의존 제거). 증거: `docs/ops/evidence/nas-relay-recovery-2026-08-18.json`.
- [ ] **stable-source task 재등록 + 재부팅 후 watchdog smoke** — 2026-08-27 stale AF_UNIX socket으로
Docker Desktop 백엔드와 공개 DB가 중단된 사건을 기존 container·volume 삭제/교체 없이 복구했다. 부모 IPC 폴더는
timestamp `.stale` 이름으로 보존했고, 기존 `vignette-dev-db``vignette_recovered_prod_20260807`만 재기동했다.
2026-08-28에는 detached-clean `99779a6a…fbc8`·tree `57da86aa…6b91`로 watchdog·로그온 boot task를
다시 등록했다. 두 작업 모두 `ExecutionTimeLimit=PT1H`·`MultipleInstances=IgnoreNew`, scheduler 재시도 0이며,
launcher 전역 mutex·bounded API/voice/web build·부모/자식 process-tree 종료와 cloudflared 또는 전체 동적
서비스 hard-down 즉시복구를 회귀 50건으로 고정했다. action root/commit/tree/script SHA pin, watchdog
18:10:57 KST·boot 18:14:27 KST 수동 실행 `LastTaskResult=0`, failcount 0을 확인했다. 로그인 뒤 수동 복구는
증명됐지만 실제 Windows 재부팅 후 엔진/API/터널 자동 복구는 아직 실측하지 않았다. DNS 개통 후 `api-vnet.18ka.net`
`-AdditionalPublicHealthUrls`로 명시 추가한다.
- [x] **노트북 로컬 음성 스택 STT 구현** — 2026-08-08 소유자 결정으로 음성 양쪽을 노트북 상주
로컬 모델로 쓴다(STT faster-whisper, TTS는 아래 항목대로 MeloTTS). 이전에는 코드에 로컬 STT 경로가 아예 없었고
(`voice_stt_provider``openai|deepgram` 둘뿐), interim/final 스트리밍 구현도 Deepgram 전용이었다.
`scripts/local-whisper-stt-server.py` 사이드카(Higgs와 같은 loopback 상주 방식)와
`voice_stt_provider=local_whisper` 경로를 추가했다. 오디오는 호스트를 벗어나지 않고 외부 STT 키가
필요 없다. 실측: 저장소 합성 시드 음성 8.72초로 interim 9 · final 5 · word timestamp 12개 전부 present,
전사는 원문과 한 글자(`안녕하세요``안녕하세여`) 차이였다. 회귀 `scripts/test_local_whisper_stt_server.py`
37/37, `apps/api/app/test_voice_service.py` 로컬 provider 케이스와 후속 계약을 포함한 current API 전체 921 passed.
**발견 1:** 이 노트북은 CUDA 장치는 보이지만 cuDNN(`cudnn_ops64_9.dll`)이 없어 GPU 추론이 **네이티브
크래시**로 죽는다. 같은 프로세스 try/except로는 못 잡아서 디바이스 확인을 버릴 수 있는 자식 프로세스
(`--self-check`)로 분리하고 `auto`에서 CPU로 폴백하게 했다. cuDNN 9를 설치하면 GPU(float16)로 훨씬
빨라지며 `large-v3`도 실시간권에 들어온다 — 설치는 소유자 판단 사항이라 하지 않았다.
**발견 2:** G7 종료 체커가 `expected_stt_provider == "deepgram"`을 하드코딩하고 있었다. 이는 결정
기록이 아니라 벤더 한 줄이었으므로, 운영하기로 한 provider 허용목록으로 바꿨다
(`ALLOWED_STT_PROVIDERS`/`ALLOWED_TTS_PROVIDERS`). 선언/실제 불일치 차단은 그대로 유지한다.
- [x] **노트북 로컬 음성 스택 TTS 구현 (MeloTTS)** — Higgs는 연구/비상업 라이선스라 `config.py`
운영에서 차단하고 있었고, 그 가드를 푸는 건 법적 판단이라 코드로 결정할 수 없었다. 그래서 상업 사용이
허용된 설치형을 다시 찾아 **MeloTTS Korean(MIT)** 으로 바꿨다. 가드를 건드릴 필요가 사라졌다.
`scripts/melotts-server.py` loopback 사이드카 + `voice_tts_provider=melotts` 경로를 추가했고,
사전학습 다화자 모델이라 Higgs처럼 P1 프리셋 한정이 아니라 모든 페르소나에 적용된다.
**실측:** CPU 정상 상태 RTF 0.27~0.28(실시간의 3.6배), `POST /tts` 200·WAV 350,566 bytes·3.61s,
빈 텍스트 422·미지 경로 404. **왕복 검증**으로 MeloTTS 합성음을 faster-whisper가 완전 일치 전사했다.
회귀 사이드카 16/16, 후속 계약을 포함한 current API 전체 921 passed, ruff clean.
**검토 후 탈락:** Kokoro-82M은 Apache 2.0이지만 공식 `VOICES.md`**한국어가 없다**(검색 요약이
지원한다고 했으나 오답). Piper는 GPL, XTTS-v2·Fish Speech는 비상업이라 Higgs와 같은 문제다.
설치 함정 3가지는 `decisions/local-voice-stack.md`에 남겼다(setuptools<81, unidic download, eunjeon).
근거: [`decisions/local-voice-stack.md`](./decisions/local-voice-stack.md).
- [ ] **G7 외부 종료 게이트 — 공개 runtime 선행 구현 + 사람 입력** — provider 의존 코드는 해소됐다. 2026-08-08 로컬 스택
(faster-whisper STT + MeloTTS TTS, 둘 다 MIT)으로 바꿔 외부 STT 키가 더 이상 필요 없고, 체커의 벤더
하드코딩도 운영 provider 허용목록으로 교정했다. 기계적인 부분도
`scripts/run-g7-external-proof-window.py`가 soak·runtime·topology 세 캡처를 **하나의 겹치는 시간창**에서
동시에 돌리고 checker까지 이어서 실행하도록 묶었다(회귀 20/20).
사람에게서만 받을 수 있는 마지막 두 입력은 다음이다.
(1) **명시 동의 하 물리 마이크 50분 양방향 soak** — 소유자가 실제로 50분 발화해야 한다.
(2) **독립 blind human voice-gain pack** — held-out 30명 외 calibration split 참가자 포함 총 최소 31명 /
held-out 50회기 / 150 paired axis / 독립 평가자 2인 /
ICC(A,1) ≥ 0.75 · κ ≥ 0.70 · gain ≥ 0.01 · participant-cluster bootstrap 10,000회 95% CI lower > 0.
runner는 이 pack의 category·κ·preregistration·paired completeness·결측 ITT를 3,120초 캡처 전에 검증하며,
부적합하면 마이크를 열지 않고 exit 2로 끝낸다. 독립 검수는
`python -X utf8 -B scripts/check-g7-human-voice-gain.py --input <pack.json>`이고 `--print-schema`로 계약을 확인한다.
독립 평가팀용 `prepare-g7-human-voice-gain-intake.py`는 빈 header-only 템플릿을 만들고, 비식별 CSV를
production floor·ICC·κ·gain·CI로 재계산해 전부 통과한 경우에만 최종 pack을 원자 생성한다. 템플릿은
`template_only=true`라 증거로 컴파일할 수 없고 이름·이메일·원음·축어록·자유서술 열을 받지 않는다.
도구 준비만 끝난 상태이며 실제 독립 평가자 데이터는 아직 없으므로 G7 external GATE를 유지한다.
**공개 선행 조건 완료(2026-08-09):** detached-clean `a73bcd24…`·tree `b02b3a1b…`에서 OpenAPI 126,
`/admin/voice-runtime`, exact local voice provider/model과 queue 4를 제공한다. fresh launcher receipt
`47388d58…9b08`가 passed이고 두 Scheduled Task도 동일 source에 pin돼 result 0이다. Cloudflare의 Python
기본 User-Agent 403 경계는 runtime sampler의 browser-compatible header와 회귀 4/4로 고정했다.
**외부 실행 전 코드 P0 완료:** runner 종료 코드는 canonical checker `exit 0`/`gate_closed=true`에 결속됐고,
브라우저 Origin(`vignette.chanpaca.net`)은 API/WSS/admin/topology host(`api-vignette.chanpaca.net`)와 분리된 HTTPS
allowlist로 검증한다. 실제 capture는 최소 3,120초·ceil+terminal sample이고 voice/runtime/topology 공통 overlap
`≥3000s`를 checker가 강제한다. Windows artifact는 detached-clean HEAD/tree, runner/collector/checker SHA,
exact `psutil==6.1.1`을 매 sample 전후 pin한다. fresh launcher는 legacy API와 exact-config cloudflared를 bounded
교체하고 새 PID/start/exe/command SHA/실제 cwd의 raw command-line 없는 receipt를 만든다. G7 runner/checker/topology
human pack runtime/checker 6파일 94/94, intake 6/6, standalone validator 4/4를 합친 104/104를 통과했다. `--rehearse`는 인증 WSS ready/ping/close 1000,
runtime 7 samples, Windows topology 7 samples를 모두 통과했고 마이크 capture false·UUID/email literal 0이다.
fail-closed 경계(동의 없음·pack 없음·3,120초 미만·host/Origin/scheme 불일치)는 CLI로 실증했다. 상세:
`ops/outcome-os-g7-external-proof-readiness-2026-08-07.md`.
> **격리 NAS current baseline / G8 `DONE`:** 2026-08-09 clean-head 프리뷰
> `http://100.116.83.60:8088`, project `vignette-preview-20260807`은 source HEAD `61a41d1f…6af`·tree
> `87dec55d…3b77`의 active archive `4d15d055…119d4d`, exact API/Web `d5021950…e4b1`/
> `9796c092…4b36`로 실행 중이며 previous active `6030a677…c611`과 이전 images도 보존한다.
> `api/web/db/proxy` 4개는 running + `unless-stopped`, volume은 `vignette-preview-20260807_pgdata`다.
> candidate session E2E 112/112와 실제 NAS-origin browser review 112/112(11 specs, desktop/mobile/single-run),
> 당시 health ok·db/engine true·auth 401·OpenAPI 126을 통과했다. 2026-08-12 current runtime은 고정
> `ENGINE_URL=http://192.168.0.223:9100` listener 상실 뒤 degraded였고 15:24 KST health 3회도 타임아웃했다.
> 따라서 G8 배포·rollback 증거는 DONE으로 보존하되 current NAS는 relay 복구와 actual-origin 재검증 전까지
> DEGRADED다. fresh dump `36ec8748…24db8`은 1,097,100 bytes,
> TOC 1,738 / TABLE DATA 129다. release agent는 DB
> dump/restore를 수행하지 않으므로 매 execute 직전 fresh custom dump SHA/size/TOC와 DB identity를 별도 결속한다.
> 자동 rollback은 image/Compose/active-state만 복구하며 적용 migration/data restore는 별도 owner 승인이 필요하다.
> 과거 `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 14/14는 완료됐다. 최초 세 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을 통과했다.
> 5·6차 clean run은 핵심 동일 학습자 SSE·review·G4/G5 `0→1→1`을 연속 통과했지만 fixture 공유와 mobile success-state locator를 각각 fail-closed했다. 보정 후 7차 clean `aa81af29…`는 첫 전체 GREEN을 만들었다. 이후 `94666192…``f97e7fad…` run의 disposable DB init 실패는 두 번째 receipt의 cleanup 전 로그로 detached `99_app_role.sh` CRLF와 `bash\r` exit 127이 원인임을 확정했다. `.gitattributes``*.sh text eol=lf`를 고정했다. 공통 톱바 desktop/mobile 44px을 포함한 clean `b02bee26…`·tree `8f677bba…`의 최신 single full run은 487.3초에 GREEN이었다. receipt `periodic-learner-e2e-20260812-171547.json`·SHA-256 `8c11a136…af8e3`은 same-learner SSE·review·G4/G5 `0→1→1`, 별도 returned desktop/mobile 4/4, route mock 0, public/NAS/active DB 접촉 0과 exact cleanup 0을 증명한다.
> 증거: [배포 증거](./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 응답·계량·오류 표면화를 비교하는 것이다.
## B. 외부 거버넌스 [외부]
> 출처: 백로그 B4 · `ops/hanshin-data-governance-gate.md` · `ops/source-docs-gap-analysis-2026-06-26.md` · `MASTERPLAN.md`
- [ ] **한신대 데이터/SSO 거버넌스 서면 증거** — 공식 문의서 발송·서면 응답 수령, 동의범위 매트릭스, SSO claim map,
privacy review, 미성년 원본 활용동의, 개인정보 처리방침. 로컬 골격은 `ops/hanshin-data-governance-gate.md`.
- [ ] **L1 스택 정합성·저작권 등재 문서 반영** — 신청서/등재 문서에 FastAPI 유지 사유 + 계약 우선 Node 전환 계획 반영.
결정 기록: `decisions/backend-node-transition.md`.
## C. Phase 3 교육용 파일럿 [외부] + [구현 scaffold 완료]
> 출처: 백로그 Phase3 · `phase3/*` · 대시보드 P3 탭. 문서·checker·exporter scaffold는 준비됨.
- [ ] **20명 파일럿 운영** — roster, consent 법무승인(G-P3-02), session completion evidence 수집.
- [ ] **효과성/KPI 측정** — SUS·자기효능감·κ≥0.70/ICC≥0.75·환각률≤3%. 3척도 pre/post 실측 → `kpi_report.json`.
- [ ] **재귀학습 데이터셋 approved 산출**`approved_for_recursive_learning_seed`(consent/PII/IAA/legal 승인) + 승인 export 실행.
- [ ] **개인정보/동의 감사 서명**`phase3/privacy-consent-audit.md` 서명 완료, withdrawal_log 종결.
## D. 소유자 결정 완료(2026-06-30) → 구현 후속 [구현]
> 출처: 대시보드 "윤찬 결정 상세" 탭 · `decisions/` · 백로그 B3. 결정 자체는 전건 확정, 아래는 후속 build.
- [ ] **s2s 2차 비교 PoC 구현·실측**`decisions/voice-s2s-poc.md` 기준표(전사·안전·감사·지연)로 20턴 비교 리포트 → 최종 채택/폐기.
- [ ] **fast-loop 외부 폴백 구현** — 로컬 상주 분류기 우선 + Haiku/Solar 폴백, 전송 가드(데이터주권·로그·동의)·관측.
- [x] **운영 TTS provider 선정·통합** — 운영 경로는 OpenAI API `gpt-4o-mini-tts`로 고정했다. OpenAI
Services Agreement의 고객 애플리케이션·최종 사용자 통합권과 출력 소유 조항을 근거로 기록하고, 공식 TTS
가이드가 요구하는 “AI 생성 음성·사람 음성 아님” 고지를 회기 시작 전과 진행 중 UI에 상시 노출한다. 로컬
Higgs와 sample provider는 무참조 synthetic seed를 쓰는 개발 전용 경로로 계속 분리한다.
- [ ] **재귀학습 fine-tuning 파이프라인** — 동의/데이터셋 게이트 충족 후. few-shot 자동갱신은 선행 가능.
- [ ] **헬스 retention 적용·검증** — raw 90일 / rollup 365일. escalation/그룹 자동화는 미도입 고정.
- [x] **한신대 실사용 피드백 1차 개선팩**`ops/hanshin-feedback-improvement-plan-2026-07-03.md`,
`redteam/hanshin-feedback-plan-redteam-2026-07-03.md`, `redteam/hanshin-feedback-plan-counter-redteam-2026-07-03.md`
기준 P0~P4 1차 완료. 완료 범위: role mapping + client-only stateless history injection, review UI 표시 자연어화,
raw error display mapper, fast/deep 라벨, 명백한 출력 결함 차단, SSE full-response buffer, 교수자 설명 패널.
검증 수치는 SSOT 대시보드만 소유한다. partial stream, client 전용 resident session, fast-loop 사후 저장은
구현 완료했다. 남은 후속은 저장형 fallback/synthetic 계약, fast/deep reconciliation,
임상팀 루브릭/CBT/약물·평가 문구 확정.
## D-2. 전수 E2E 순회 소유자 결정 — **전건 결정 완료(2026-07-27)** → 구현 후속 [구현]
> 출처: `ops/e2e-full-sweep-2026-07-27.md` 발견 결함 로그 · `ops/backlog-2026-06-26.md` B3-신규.
> 결함 12건은 RED→GREEN으로 수정 완료(대시보드 143차). 소유자 결정 7건은 질문 도구로 전건 확정.
- [x] **설정 아바타 변경 UI 구현** — RED→GREEN 완료(full-sweep-settings 4/4, 무회귀 44/44)
- [x] **동의 철회 전체 흐름 구현** — 설정 동의 섹션+철회 2단계 확인+재동의, 실 API round-trip GREEN
- [x] **신규 학습자 기초 우선 추천 구현**`starterPersona()` 난도 오름차순, 무회귀 42/42
- [x] **학생 분석 테이블 가상화 구현**@tanstack/react-virtual, 300행 RED→GREEN, 무회귀 40/40+게이트 4/4
- [x] **저작 모드 좌측 레일 마크업 정리** — 레일 JSX·CSS 삭제, dead-code 게이트 통과
- [x] dev-login 재로그인 자동 재승인 — 결정: 현행 유지(코드 변경 없음, 종결)
- [x] **감정 밸런스 타임라인 차트 구현** — deep `turn_valence`+결정론 파생 폴백, 백엔드 421/421, 라이브 5pt 실측
> 전체 검증 수치·상세는 SSOT 대시보드 144차 노트와 `ops/e2e-full-sweep-2026-07-27.md`가 소유한다.
## E. 코드 품질·리팩터 로드맵 [구현] (비게이트 — 여유 시)
> 출처: `ops/code-quality-research-2026-06-26.md` · `decisions/backend-node-transition.md` next candidates.
> **2026-07-02 실측**: 로드맵 상당수는 이미 해결(turn_runtime·tier 제거·eval_hook 주입 등). 아래는 코드 재확인 기준 현행 상태.
- [x] **Stage enum SSOT 통합**`taxonomy.Stage`를 라벨 단일 정의(SoT)로, `services.state_machine`이 re-export(중복 `class Stage` 제거). **검증: 단일 enum 정체성 확인, 백엔드 pytest 352 passed, 수집 379 import 무결. (2026-07-02)**
- [x] **store `recent_turns`/`masked_turns` Type-2 중복 제거**`recent_turns = masked_turns()[-k:]` 위임으로 단일 소유. **검증: 백엔드 352 passed. (2026-07-02)**
- [x] **화자 한글 라벨 4× 중복 → `taxonomy.speaker_ko_label` 단일 헬퍼** — evaluator/live_coach/memory 프롬프트의 `'상담자'/'내담자'` 삼항 통합. **검증: 헬퍼 단위 + 백엔드 352 passed. (2026-07-02)**
- [x] ~~턴 파이프라인 dedup~~`turn_runtime.py``load_owned_session`/`record_completed_turn`로 이미 추출됨.
- [x] ~~`_iso` 중복 통합~~ — teacher/sessions `_iso` 중복 해소됨(현재 `_iso_text`/`_iso_dt`는 별개 목적 함수).
- [x] ~~tier dead-contract 제거~~ · ~~voice 경로 eval_hook 미주입~~ — gateway tier 제거 + voice eval_hook 주입 완료.
- [x] **Speaker/Role 계약 레벨 — 검토 결과 조치 불요** — DB actor 매핑은 1곳(`session_persistence.py`, 중복 아님), `Role` app↔db는 이미 단일 dict(`DB_ROLE_BY_APP`/`APP_ROLE_BY_DB`), 엔진 `AIRole`은 이미 단일 `Literal`. 실제 중복이던 표시 라벨은 위에서 통합됨 → 별도 추상화 불요. (2026-07-02 실측)
- [x] **`_list_sessions` N+1 제거(1차: state/turns)** — 세션별 `session_state`/`turns` fetch 루프(1+2N) → `= ANY($ids)` 배치 3쿼리 + Python 그룹핑. **검증: 실 DB 동등성 50세션/109턴 불일치 0, 왕복 101→3, 백엔드 352 passed(회귀 테스트 mock 시퀀스 갱신). (2026-07-02)**
- [x] **`_list_sessions` N+1 제거(2차: 턴평가 하이드레이션)** — 상세 뷰에서 세션별 evaluator 연결+평가 로드 루프 → 전 세션 turn_refs 배치(`_hydrate_sessions_turn_evaluations`, 연결 1회). 싱글 헬퍼는 플러럴 위임으로 행위 보존(`_load_turn_evaluations`는 이미 `turn_id = ANY` 배치). **검증: 백엔드 352 passed. (2026-07-02)**
- **E2E 검증(실 스택)**: dev API(8000, 내 코드 반영)+웹+DB에서 `chromium-single-run` **2 passed** — "위기→교수자 대시보드", "학습자 턴→리뷰+교수자 대시보드"(`list_all_sessions` include_turn_evaluation=True로 1·2차 N+1 경로 모두 브라우저 end-to-end). (2026-07-02)
- [ ] fast-loop `eval_hook` **사용 정책 제품 결정** (현재 주입은 됨; 켤지/끌지 확정은 소유자).
- [ ] Node 게이트웨이 구현 시: 라이브 엔드포인트 conformance, `TurnEvaluationPayload` 어댑터 추출, `admin.py` 매퍼 정리.
- [ ] init SQL과 별도의 idempotent DB migration runner 도입 (인프라 추가 — 소유자 결정 권장).
## F. 임상팀 콘텐츠 [임상] (외부 소유)
> 출처: `ops/source-docs-gap-analysis-2026-06-26.md` · 백로그 B0. 코드 구조는 선제 구축, 문안·기준은 외부 정의.
- [ ] **태깅 축어록 4종 후속 게이트**`ops/tagged-data-utilization-2026-07-15.md` §3 소유:
golden set P8~P11, 다회기 스키마 격상, gestalt/dbt 이론모드 신설(소유자 결정), P1~P7 보강,
사례개념화 루브릭 draft, P8~P11 초안 검수·승인(특히 P9 자해 페르소나는 안전장치 실측 선행).
- [ ] C1 사례개념화 **확정 루브릭 콘텐츠** + AI 추출/채점 calibration (현재 scaffold_only).
- [ ] CBT 체인·이론부합 루브릭.
- [ ] **위기개입 프로토콜 임상 승인** — 내부 기술 사전검증은 완료했다. P1 합성 검토 사례 6건과 Draft
2020-12 v2 스키마, `pending_external_review`/`approved`/`conditional`/`rejected` 상태별 canonical fail-closed 판정기,
실제 수련생 위기의 엔진 전 차단·109 연결, 자살 수단 상세 차단, 생성·전이·이전 회기 carry-over·출력의
`ideation_stage <= 3`, SAMHSA·NIMH·NICE·보건복지부 source pack 계약을 고정했다. 생성·스트림 실제 경로
P1 6/6과 C-001 focused 90 passed를 통과했고, 개선관리 완료본에는 사례별 판정·근거·수정 요구·재검토·검토일
30개, 청소년 특화 답변 6개, 외부 승인 10필드를 입력하는 `C-001 외부검토` 시트를 추가했다. 근거는
`data/clinical/`, `scripts/check-clinical-crisis-review.py`,
`docs/ops/clinical-crisis-protocol-review-2026-08-27.md`에 있다. 현재 관찰 패키지·작업트리 해시는 일치하고
기계 게이트는 `pending-valid`지만 외부 입력은 모두 비어 있어 `review_complete=false`다. 실제 검토자 이름·소속
기관·자격·검토일·결정·근거·서명 증거/해시·검토 버전/사례 세트 해시가 기록되기 전에는
`clinical_status=approved`나 개선관리 시트 `완료`로 바꾸지 않는다.
- [ ] 평가 골든셋 콘텐츠.
## G. 운영 후속 [구현] (비차단)
> 출처: 백로그 B1
- [ ] **운영 티켓 자동 분류·처리** — Claude Recipe headless 자동 수정 후보, 관리자 승인 후 이슈 등록·PR/작업 스레드 생성,
처리 결과 audit trail 확장. 자동 수정은 운영자 승인 전까지 실행하지 않는다.
## H. 2026-07-13 한신대 회의 결정 구현 [구현] (파일럿 게이트 — 7월 내)
> 출처: `ops/hanshin-meeting-actions-2026-07-13.md` (요구사항 상세·완료기준은 그 문서가 소유).
> 데드라인: **7월 내 배포 → 7월 말~8월 초 파일럿(대학원생 ~20명)**. 9~10월 연구팀 점진 이관.
- [x] **P1. 회기 종료 시간 기반 전환** — 구현 완료(2026-07-14). 60분 시간 기반(`SESSION_DURATION_MINUTES`) +
10분 전 알람 바 + 시간 만료 정리 유도 다이얼로그 + 유예(+10분) 후 서버 턴 409 거부 + 시작 전 목표 1~4개
선택(`goal_stages` 계약, DDL·런타임 가드 포함 — 회의 권장 2개 수준, 소유자 지시 2026-07-15로 4개까지 허용).
목표 달성해도 시간 내 계속 진행. 상태머신은 채점·표시용 유지.
**검증**: 백엔드 pytest 393 pass, 1분 회기 실측(알람→만료 다이얼로그→409), session-layout 8/8, layout-visual-gate 12/12.
- [x] **P2. 회기 계획·달성도 게이지** — 구현 완료(2026-07-14). 설계 1p `ops/session-progress-gauge-design-2026-07-14.md`.
단계별 누적 게이지(rapport_credit 파생, 회기 간 ×0.7 이월로 누적) + 상세 수치(방어/개방도/라포 누적+이번 회기 증가분)
를 세션 화면·상세 응답·스트림 done·대시보드 페르소나 카드(rapport_percent)에 노출. 계획서·프로토콜 첨부는 기존
`/personas/sources`(+신규 엑셀 업로드)로 흡수. **검증**: 2회기 carry 실측(65%→46% 시작 = ×0.7 정확).
- [~] **P3. 파일럿 인프라 대비** — 부하 검증 완료: `scripts/load-test-sessions.py`, 20 동시 세션 생성 20/20 성공
(p95 125ms, RAG warm 병목 없음), 5 동시 실턴은 p50 55s로 **claude_cli 상주 풀이 병목** → 파일럿 전
`ENGINE_MODE=claude_api` 전환 권고. 이관 절차 초안 `ops/server-migration-runbook-draft.md`, 파일럿 사용 지침
`ops/pilot-usage-guide-draft.md`. **남은 것**: 실제 서버 이관(연구팀·정보처 협의) — 외부 대기.
- [x] **P4. 데이터 업로드 흐름 검증** — 자유 양식 엑셀 업로드 경로가 없어서 신규 구축(2026-07-14):
`POST /personas/sources/upload`(xlsx/xlsm/csv → 텍스트 결정론 변환 `services/tabular_ingest.py` → 기존 마스킹·
hash-only 증거·sanitized chunk 경로 재사용, 원본 바이트 미저장) + PersonaStudio 파일 첨부 xlsx 지원.
**검증**: 파란 라벨 샘플 실업로드(NAME 마스킹·chunk 색인·임베딩 OK, raw hash-only 확인), 방어 테스트 9종
(.xls 거부·cp949 폴백·손상/빈 파일) pass.
- [~] **P5. 협업 체계** — 비개발자용 가이드 `guides/collaboration-for-researchers.md` 작성 완료(문구 수정 프로세스 포함).
**남은 것**: 저장소 초대(소유자 직접 실행).
- ⏸️ **외부 대기(구현 착수 금지)**: '실패(게임오버)' 정의 / 검수 표준화 항목 / AI 평가 노출 정책 / 페르소나 추가 제작 — 전부 연구팀 몫, 수신 시 반영.
## I. Outcome & Alliance OS — 정식 승격 로드맵 [에이전틱 구현]
> **2026-08-06 정식 승격.** 상세 계약·품질 목표·의존성은
> `ops/vignette-outcome-alliance-os-agentic-plan-2026-08-06.md`, 상태는 SSOT 대시보드의
> `Outcome & Alliance OS` G0~G8 계획 카드가 소유한다. 아래 작업은 사람 개발자 스프린트가 아니라
> 에이전트가 RED → 구현 → 통합 → 적대 검증 → runtime proof → SSOT 동기화로 닫는 증거 DAG다.
### I-0. G0 Measurement Truth Foundation
- [x] `app.measurement_event`, `app.measurement_instrument`, `audit.model_run` 계약과 provenance enum 구축.
- [x] 기존 `rapport_credit`, `alliance_level`, fast/deep 평가, Phase 3 KPI의 source map과 adapter 구축.
- [x] `simulated_state`·`model_inferred`·`agent_reported`·`learner_reported`·`human_rated`·
`observed_runtime` 층을 섞는 read model 실패 테스트 구축.
- [x] goal mismatch, task mismatch, empathic miss, withdrawal, confrontation, successful/failed repair를 포함한
첫 benchmark pack 구축.
- [x] **G0 종료 GATE 재검증** — post-merge dump의 disposable clone에 migrations 14/15/16을 각각 두 번
원자 재적용하고 current source census 29/29 producers, unknown/missing/null/orphan/호환성 위반 0을 확인했다.
증거: `ops/evidence/g0-provenance-census-scratch-post-g6-g8-20260807.json`.
### I-1. G1 Alliance Core
- [x] goal/task/bond의 내담자·관찰자·수련생 3관점 측정과 회기 전·중·후 pulse 구현.
- [x] 현재 `alliance_level`을 legacy simulation signal로 격리하고 새 alliance read model 구축.
- [x] 학습자 리뷰의 관점 차이·근거 장면 UI 구현.
- [x] 교수자 재평가를 append-only measurement로 구현.
- [x] **G1 종료 GATE** — 세 관점 독립 실행·저장, 축별 비가림 UI, 골든 장면 분류·근거 span,
평가 실패의 정상 점수 폴백 0건. **증거:** G1 focused backend 43 passed, PostgreSQL RLS/append-only/reveal
실원장 smoke, desktop/mobile Alliance Pulse 6/6 및 기존 회기 리뷰 12/12, prompt `1.2.0` 합성 gold 방향
정확도 9/9·최종 실패 0·근거 recall 88.9%. 실제 Session 진입 전 pre pulse를 fail-closed로 잠그고 첫 라운드
뒤 mid pulse를 비차단형으로 연결했으며, stable ID 재시도·변조 409·교차 회기 404를 통과했다. desktop/mobile
pre·mid focused E2E 2/2와 시각 증거 4장을 보존했다. 추가 source API/dev DB 실증은 한 회기의
`pre(degraded/insufficient_transcript) → mid(ready) → post(ready)`를 실제 turn UUID·ended_at과 함께 닫고,
동일 응답 안의 awaiting/external-event read skew와 degraded terminal DB 제약을 교정했다. 증거:
`ops/evidence/alliance-pulse-pre-mid-post-live-2026-08-07.json`. 현재 승격 기준 prompt 1.2+read-skew fix+
보수적 trailing-comma JSON 복구의 전체 재보정은 24/24 ready, 기대 방향 9/9, 오류·재시도 0,
evidence recall 94.4%·precision 73.9%를 기록했다. human-AI ICC는 내부 구현 종료가 아니라 Phase 3 외부 품질 목표다.
### I-2. G2 Longitudinal Outcome Trajectory
- [x] 최소 5회기 사례 arc와 예상 성과 궤적 분포 구현.
- [x] safety event와 outcome deterioration의 별도 계약·표시 구현.
- [x] 초기 1~5회기 off-track 탐지와 다음 확인 질문 구현.
- [x] case memory에 관계 사건·합의·미해결 균열을 role-safe하게 추가.
- [x] **G2 종료 GATE** — 5회기 기억·목표·관계 연속성, 악화 benchmark 조기경보 목표,
false alert·uncertainty 병기, 합성 궤적의 임상 결과 오표시 0건. **증거:** 4-case×5-session
benchmark status 정확도 1.0·false alert 0·critical miss 0, 실제 DB/API 5회기 연속 case와 15개 독립 축,
멱등 재시도·변조 409·학습자/코호트 RLS·role-safe 관계 기억 통과, desktop/mobile 체크인·궤적 12/12와
G1/기존 리뷰 15/15 회귀 통과. 추가 실제 PostgreSQL producer→store→evaluator smoke는 합성 교육용
악화 case의 `on_track→off_track→deteriorating`과 단발성 noise control의
`on_track→watch→on_track`·false alert 승격 0건을 실제 10회기·30 measurement·case별 5 revision으로
검증했고 learner/teacher 최신 projection이 일치했다. 원점수는 증거 JSON에 복제하지 않았고 임상 주장은 금지한다.
### I-3. G3 Rupture & Repair Lab
- [x] rupture/repair 상태머신과 이벤트 원장 구현.
- [x] Scenario Director의 자연스러운 균열 주입 구현.
- [x] 실시간 감지, 회기 후 장면 재생, 복구 분기 비교 구현.
- [x] 후속 발화에서 복구된 fast-loop 경고의 deep-loop reconciliation 구현.
- [x] **G3 종료 GATE** — 유형별 탐지·복구 benchmark, missed/partial/resolved E2E, 순응 유도 judge
gaming 0건, 문장 암기 전략의 변형 사례 실패 검증. **증거:** 11-case macro-F1 1.0·복구 상태
accuracy 1.0, critical miss/judge gaming/암기 false resolution 0, Scenario/G3 107 passed,
G0~G8 계약 251 passed, desktop/mobile 리뷰 50/50, 실제 API/DB durable-turn·멱등·RLS·교수자
append-only correction·최종 resolved 검증. 기존 수동 write smoke의 missed→partial→resolved와 별도로,
실제 `submit_turn`/공용 finalizer callback만 사용한 자동 runtime PostgreSQL smoke에서 3 episode·10 observation·
3 reconciliation, 세 상태, learner/teacher read projection, 세션 밖 evidence 참조 0건을 재현했다.
### I-4. G4 Deliberate Practice Engine
- [x] 평가 장면을 원자적 practice prescription으로 변환.
- [x] replay, branch, constrained response, voice retry, difficulty ladder 구현.
- [x] competency graph와 attempt evidence 저장.
- [x] 약한 역량과 망각 위험에 따른 다음 연습 자동 선택.
- [x] **G4 종료 GATE 재검증** — 모든 코칭 카드의 실행 가능한 재연습 연결, 전후 비교·근거 저장,
미지 사례 전 mastery 금지, 점수 보상 해킹 benchmark 통과. **증거:** G4 core/store/API 54 passed,
실제 API/DB durable-turn 처방→학습자 시도→교수자 append-only correction, 멱등 retry/변조 409,
learner/cohort RLS, reward-claim `practicing`·`mastery_allowed=false`, G4 UI 8/8 및 G1~G4 리뷰 58/58.
replay/branch/constrained response/voice retry/difficulty ladder 카드 CTA가 학습자 홈→사전 설정→실제 새 Session으로
이어지고 launch intent가 URL과 화면에 유지되는 desktop/mobile 22/22 및 통합 59/59를 통과했다. 추가 실제
PostgreSQL production caller smoke는 ready 평가·durable turn 2개에서 G4 submission/card/prescription/snapshot/
decision 각 1개를 자동 생성하고 재실행 시 같은 deterministic submission/card replay, episode·attempt·mastery·
transfer 승격 0건을 확인했다. 실제 API/DB/엔진 smoke는 prescriptions 2·episodes 6·attempts 6·
append-only correction 1·ready observer model-run 2를 통과했다. post-merge disposable clone의 브라우저는
route mock 0으로 desktop 2/2·mobile 2/2, G4 POST·read-model reload·before/after·멱등 retry를 확인했고
최종 episode/attempt/ready observer 각 1, UUID 노출·마이크 호출·overflow·중복 0이다.
증거: `ops/evidence/scratch-browser-returned-practice-closed-loop-evidence-20260807.json`.
- [x] **학생 자기주도 전체 루프 UI 계약** — 실제 `src` 학습 홈 추천→P1·목표 선택→텍스트/SSE 회기→
종료·리뷰→G4 공감적 반영 처방→키보드 CTA→새 회기 사전 설정·시작까지 desktop/mobile **2/2**,
typecheck와 고정 캡처 9장 시각 QA를 통과했다. 홈 CTA는 40px→44px 및 4.5:1 이상 대비로 보정했고,
390×844·320×568 활성 회기의 아바타/문구 겹침 0·축어록/입력창 카드 내부 포함과 리뷰→재연습의
내부 criterion/counterevidence/UUID/영문 이론키 비노출을 고정했다. 리뷰 1~5 척도는 320px에서
3+2 반응형 배열로 전환해 다섯 선택지 44px 이상·내부/페이지 overflow 0을 desktop/mobile 8/8로 고정했다. 이 검증은 모든
미소유 `/api/**`를 404로 닫은 로그인 없는 route fixture이며 실제 API/DB 폐루프라고 주장하지 않는다.
상세: `docs/ops/outcome-os-self-directed-learning-loop-ui-evidence-2026-08-07.md`.
### I-5. G5 Calibration Mirror & Transfer
- [x] 외부평가 공개 전 자기평가 잠금 흐름 구현.
- [x] 역량별 calibration error와 신뢰도 구간 계산.
- [x] 과신·과소신 맞춤 메타인지 연습 자동 처방.
- [x] 인구통계·관계스타일·난이도를 바꾼 transfer suite 구현.
- [x] **G5 종료 GATE 재검증** — 자기평가 오염 방지·수정 이력, 반복 연습의 calibration error 감소,
새 사례 역량 유지, subgroup 평가 드리프트 리포트. **증거:** 4-case expectation accuracy 1.0,
lock 뒤 오염 거부 1, calibration MAE 0.667→0.267, unseen 4/4 전이, 암기 false verification 0,
합성 subgroup drift 0.667 탐지. 실제 API/DB durable-turn revision→lock→독립 reveal·멱등/409·RLS,
G5 UI 4/4, G1~G5+기존 리뷰 62/62, typecheck/build 통과. transfer CTA는
`novelty=unseen_transfer`·suite·trial을 학습자 홈→사전 설정→실제 Session까지 보존하며 desktop/mobile
2/2와 실제 세션 통합 2/2를 통과했다. 추가 production caller 실 DB smoke는 prediction lock 전 observation
0건, lock callback 뒤 `failed`·`model_inferred`·`independent_observer` 관찰과 turn/session provenance
model-run 각 1개를 확인했다. 재실행 중복 0, passed·assessment·transfer suite/trial/승격 0이며
learner/teacher projection이 일치한다. 학생 후속 종료 회기에서 actual-execution POST·422 대기·503 재시도·
성공 reload·멱등 재확인 UI와 내부 UUID 비노출은 desktop/mobile 16/16으로 연결했다. 실제 API/DB/엔진
smoke는 execution·model-inferred·independent observer·instrument/evidence/model-run provenance 각 1과
멱등·오류 출처·미종료·타학습자·교차 cohort 차단을 통과했다. 같은 post-merge disposable clone 브라우저는
desktop/mobile 4/4로 G5 POST→reload→before/after와 1→1 멱등 재확인을 통과했고 UUID 노출·마이크 호출·
중복 원장 0이다. 합성 교육 fixture이고 임상·실제 숙달 주장은 금지한다.
### I-6. G6 Supervision & Research OS
- [x] attention queue와 learner drilldown을 새 측정 원장 기반으로 재구축.
- [x] 교수자 정정·AI 불일치를 calibration dataset으로 연결.
- [x] cohort curriculum gap, model drift, instrument version 비교 구현.
- [x] Phase 3 evidence manifest에 alliance/rupture/transfer/calibration 추가.
- [x] **G6 종료 GATE 재검증** — 위험·정체·미해결 관계 사건 3클릭 이내, 모든 집계의 원장 drilldown,
모델·척도 버전별 재현 리포트, public/teacher/learner 경계·감사 로그 검증. **증거:** 6-signal
queue 순서 정확·drilldown 1/상한3, metadata-only dataset raw transcript 0, version drift 탐지,
Phase3 네 도메인 전건. 실제 API/DB 멱등/409·AI-view·역할/cohort RLS·audit, version/subgroup/artifact
provenance hydrate 통과. 추가 scheduled producer는 G0~G5 실제 원장에서 attention/gap을 파생하고,
repo-approved synthetic baseline/candidate와 실제 measurement anchor 12개를 자동 비교해 batch 2·report 1·
subgroup 2를 저장했다. replay 중복 0, periodic 6 cohort 성공, drift/provenance hydrate, transcript 0·비임상
경계를 확인했다. G6 focused 37 passed, G6 UI desktop/mobile 6/6, typecheck/build 통과. 최신
safety-event metadata-only 우선 producer는 post-merge disposable clone의 current source에서 derive 201,
`safety_boundary(1) > deterioration(2)`, direct pointer 각 1, source detail canary 1·G6 projection canary 0,
raw transcript/임상 주장 0을 통과했다. NAS 승격은 배포 증거이지 internal 종료 조건과 분리한다. 상세:
`ops/outcome-os-g6-ledger-producer-evidence-2026-08-07.md`,
`ops/outcome-os-g6-version-comparison-producer-evidence-2026-08-07.md`,
`ops/evidence/g6-safety-priority-runtime-scratch-20260807.json`.
### I-7. G7 Multimodal Alliance
- [x] STT word timestamp, silence, overlap, interruption, prosody feature event 통합.
- [x] 텍스트·음성 평가 독립 산출 후 calibrated fusion 구현.
- [x] 장면별 오디오 재생과 음성 재연습 구현.
- [x] 비언어 특징의 과도한 임상 추론 차단 계약·테스트 구현.
- [x] Deepgram streaming STT adapter와 OpenAI batch fallback을 보존하고, 운영 기본 경로를 노트북 상주
`local_whisper`/`melotts`로 전환해 interim/final·word timestamp·provider event 계약을 동일하게 구현.
- [x] 저장 word token을 deployment `SESSION_SECRET` 기반 HMAC-SHA256 pseudonym으로 전환하고 원문 미보존을 고정.
- [x] 열린 streaming 중 동의를 1초마다 재검사해 철회 시 provider 전송·후속 저장을 중단하는 fail-closed 계약 구현.
- [x] 음성 단절/실패 뒤 작성 중 텍스트를 보존하고 텍스트 계속하기·키보드 가능한 음성 재연결 recovery UX 구현.
- [x] 외부 OpenAI TTS adapter와 AI 생성 음성 고지를 보존하면서, 운영 기본 TTS를 상업 사용 가능한
MeloTTS Korean(MIT) loopback 사이드카로 전환.
- [x] authenticated WSS `ready`에 STT/TTS provider·model을 노출하고, 마이크 열거·캡처 없는 public preflight가
네 기대값과의 정확 일치를 검사하도록 구현. full soak만 명시적 `--confirm-physical-capture` 뒤 장치를 연다.
- [x] synthetic gold target에 대해 text-only와 calibrated multimodal 출력을 직접 비교하는 1-MAE benchmark를
구현하고 `0.9533→0.9673`, 실측 gain `+0.014`(최소 `0.01`)와 gold 변조 반례를 통과했다.
- [x] consent mutation·timeline·measurement를 session 공통 PostgreSQL transaction lock으로 직렬화하고 실제
개발 DB 동시 철회에서 미커밋 중 writer 대기, 철회 커밋 뒤 저장 차단, timeline row `0`을 확인했다.
- [x] 10MiB audio buffer를 append 전에 거부하고 streaming event queue `32`, Deepgram receive queue `16`
고정했다. 단일 프로세스 cap probe는 10MiB high-water·초과 거부·clear 뒤 RSS 기준선 복귀를 확인했다.
- [x] 첫 음성 사용은 처리 목적·전송/전사·파생 신호·30일 보존·원음 미보존·철회/삭제·텍스트 대안을 설명하고,
서버 동의 원장 성공 전 `getUserMedia`와 voice WebSocket을 모두 0회로 유지한다. 원장 실패와 지연 권한 응답도
마이크 track/소켓을 남기지 않으며 숨은 Space 대신 안내 가능한 `Alt+M`을 사용한다.
- [x] process-local metadata-only `/admin/voice-runtime`과 Uvicorn `--ws-max-queue 4`를 추가해 worker RSS/CPU/FD,
WebSocket·provider session·audio buffer·event queue·overflow/fallback/error high-water를 관측 가능하게 만들었다.
- [x] public WSS evidence v4의 turn별 interim/speech-final/latency 계약, 같은 worker를 고정하는 runtime sampler,
exact image/container/cgroup/proc/Docker/TCP topology sampler, 독립 blind human-held-out voice-gain 계약과
네 artifact를 같은 public host·동시 시간창으로 묶는 `check-g7-external-proof.py`를 구현했다.
- [x] G7 내부 hardening exact SHA `a8c27b0aadcb9dacea86c4be5e62e19277b9cc2678ebca51aa02998d2c2d1a0d`
격리 NAS에 승격했다. clean candidate session E2E 24/24, OpenAPI 124, auth 401, G0~G8와 실제 NAS
browser SSE→DB review를 통과했고 물리 마이크는 사용하지 않았다.
- [ ] **G7 종료 GATE** — 오디오·자막·측정 이벤트 시간 정렬, 물리 마이크·공개 WSS 장시간 E2E,
텍스트 단독 대비 음성 이득 benchmark, 음성 보존·삭제·동의 정책 검증. **내부 완료:** 실제 API/DB
동의→단일 시계→독립 측정/융합→철회·삭제, actual-Postgres 동시 철회, synthetic gold gain benchmark,
bounded queue/10MiB cap RSS probe, Deepgram adapter, HMAC word pseudonym, 1초 동의 재검사, 선행 동의 원장,
recovery UX, mic-free expected-provider/model preflight와 외부 증거용 네 artifact checker를 확인했다. 최신
격리 synthetic PCM soak는 59/59 연결 종료·성공 발화 55·철회 차단 2·미동의 차단 1·overflow 거부/복구 1/1,
focused 119 passed를 통과했다. 공개 G7 route는 TLS 1.3 handshake·비인증 1008·Cloudflare edge와 마이크
열거/캡처 0을 확인했다. 증거: `ops/evidence/g7-public-wss-preflight-current-20260807.json`.
**남음:** 사용자가 장치를 선택하고 명시 동의한 물리 마이크 3,120초, 공통 3,000초 양방향 soak와 동시
runtime/topology high-water, 독립 라벨 held-out/pilot의 실제 음성 이득,
실제 worker·Uvicorn·OS·Cloudflare edge를 같은 50분 시간창으로 묶은 RSS/CPU/queue high-water.
preflight·synthetic·옵션 제공만으로 종료하지 않는다.
### I-8. G8 Autonomous Content & Continuous Improvement
- [x] source pack 기반 새 사례·균열·연습·benchmark 초안 자동 생성.
- [x] 안전·정체성·난이도·정답 누수·문화적 편향 독립 적대 검토.
- [x] 승인된 콘텐츠만 catalog 승격하고 provenance 보존.
- [x] metadata-only 운영 incident를 승인 source로 변환해 새 adversarial benchmark pipeline을 실행.
- [x] repo-approved synthetic source를 durable lease/retry job으로 등록하고 API lifespan에서 opt-in 주기 실행.
- [x] 운영 측정의 약한 benchmark·drift를 주기적으로 탐색해 위 incident→adversarial 경로를 자동 기동.
- [x] 실제 Claude pending payload를 사람 검수하고 승인된 projection만 catalog에 0→1로 소비하는
관리자 browser/API/DB E2E. learner/teacher 403·replay 멱등·변조 409·민감 payload 비노출.
- [x] release agent의 결정적 patch·manifest·clean candidate·API/Web/회기 gate, 격리 NAS 승격,
semantic runtime/browser proof, 이미지 rollback, exact-SHA SSOT handoff를 fail-closed로 구현·독립 감사.
- [x] 위 release agent로 변경 SHA `e9b482ccba616a610c1f4862366d44aabae20ee8c82d8ef7f2cf16069f2d6e99`
실제 격리 NAS에 승격하고 postdeploy browser·DB review·rollback snapshot·exact-SHA SSOT handoff를 남겼다.
- [x] **G8 내부 구현·실제 rollback receipt GATE** — source→draft→red-team→benchmark→catalog 재현, 정답 누수·PII·무근거
임상 주장 0건, 모델 교체 자동 calibration/rollback, 운영 오류의 재현 테스트·backlog DAG 자동 환류.
**현재 코드 증거:** caller가 완성 JSON을 넣는 기존 경계와 별도로 source hash·PII fail-closed→structured
generator→서로 결과를 공유하지 않는 red-team 2개→variant generator→독립 judge 3개 이상→durable
`draft_payload`·`pending_human_approval` 저장 worker/API를 추가했다. 모델/structured output 실패, PII,
answer leakage, 무근거 claim, reward hacking은 store 호출 전 차단하고 같은 submission은 model call 0으로
재생한다. repo-approved source의 durable lease/retry scheduler를 lifespan에 opt-in 연결했고 실제
`claude_cli/claude-opus-4-8` 7 calls→dev PostgreSQL reviewer 2·variant 3·pass rate 1.0→
`pending_human_approval`과 동일 UUID 0-call replay를 확인했다. full API/gateway 858 passed,
Ruff·OpenAPI type check·web typecheck 통과. G6 canonical 임계값·subgroup 원장을 재검증하는 default-off
drift trigger는 실 DB incident 1·DAG 4·job 1·approval/catalog 0과 동시성·멱등을 통과했다. 기존 실제
Claude pending payload는 새 모델 호출 0회로 관리자 browser에서 catalog 0→1·approval 1, 역할 403·
replay 멱등·변조 409를 통과했다. release agent는 독립 감사 4건 수정 뒤 14/14와 전체 API 891 passed를
통과했다. exact SHA의 NAS runtime은 health·OpenAPI 123·auth 401·G0~G8와 browser SSE→DB review를
통과했고 transient 502 rollback/recovery와 최종 readiness 3회째 성공을 기록했다. 상세:
`ops/outcome-os-g8-agentic-worker-evidence-2026-08-07.md`. 현재 소스의 HTTP control-plane executor는
artifact SHA·idempotency key·receipt/evidence가 일치할 때만 `executed`를 허용하고 미구성은 `requested`,
호출/receipt 실패는 `failed`로 남긴다.
**2026-08-07 종료:** 격리 NAS `vignette-preview-20260807`에서 실제 receipt-bound image rollback을 두 번
실행했다. 별도 executor helper(`127.0.0.1:18149`, host network·read-only rootfs·cap-drop ALL·
no-new-privileges·uid1028/gid100·group101)와 별도 lifecycle의 control-plane API(`127.0.0.1:8018`,
producer disabled, preview DB `172.22.0.2``vignette_app` role)를 띄우고, `rollback-old` plan은 receipt
`nas-g8-723eeef22eab05e63e3fafb0`으로 previous 이미지 `79ec…4450`/`c530…2f28`를, `restore-current` plan은
receipt `nas-g8-2738846cf2cf4fbe8ce0fc26`으로 current 이미지 `52e0…8b2d`/`6fdb…f215`를 활성화했다.
release gate·approval 각각 2회 멱등, `audit.ci_lifecycle_event` `rollback/executed` 2건과 receipt id 결속,
`audit.ci_human_approval_event` `authorize_rollback` 2건, silent auto-promotion 0, HMAC journal 6-record
체인 검증, 매 실행 뒤 health 3/3과 최종 OpenAPI 126·auth 401·Web 200을 확인했다. helper 격리는 image가
상속하는 `com.docker.compose.*` label을 baseline으로 두고 project/service만 격리 override하는 계약으로
검증했다(`scripts/launch-nas-preview-g8-helpers.py`, fake-runner 37/37). 계획했던 Windows SSH 터널은 NAS
sshd가 `administratively prohibited`로 거부해 sshd 설정 변경 대신 같은 격리 계약의 NAS-side probe
컨테이너로 실행했고 control-plane/preview origin 분리는 유지했다. `down`/`volume rm`/`prune` 미실행,
공개 런타임 미접촉, 비밀 env 파기, helper 0·listener 0 복귀. 상세:
[런북](./ops/nas-preview-g8-rollback-proof-runbook.md) ·
[기계 판독 증거](./ops/evidence/nas-preview-g8-actual-rollback-2026-08-07.json).
- [x] 2026-08-07 release-only SHA `6030a677…c611`을 격리 NAS에 승격했다. patch 3회 결정성,
localhost candidate browser E2E 108/108, postdeploy SSE→DB review 32.437s, health 3/3,
OpenAPI 126·auth 401·G0~G8 route, named DB volume 보존을 확인했다. 이는 당시 runtime promotion
증거이며 현재 HEAD 배포 증거가 아니다.
- [x] **clean-head release mode** — 명시적 `--source-mode clean-head`와 별도 detached source root를 추가했다.
tracked-dirty 차단, exact HEAD/tree/archive SHA, 2회 결정성, archive-only candidate, patch-mode 무회귀,
증거 binding, source-only insecure-context 12/12 gate(UUID 6 + SHA-256 6), commit 전 NAS-origin 11-spec rollback gate를 unit
30/30·Ruff·py_compile로 고정했다. 첫 execute는 archive CRLF를 candidate에서 정규화하도록 fail-closed했고,
두 번째 execute는 API/types/build 뒤 109/5에서 fail-closed해 둘 다 NAS mutation 0이다.
- [x] **G8 NAS-origin 재검증**`randomUuid()`와 portable SHA-256 수정, insecure-context 12/12 회귀를 포함한 current HEAD를
위 clean-head mode로 격리 NAS preview에 배포한다. `started_at`의 9시간 오차 P0는 UTC `+00:00`, KST 달력 날짜,
KST browser focused 실DB E2E로 수정했고 API 전체 921 passed와 코칭 이력 1/1을 통과했다. 시간종료 모달 강제
닫기 우회는 없다. 첫 clean commit `5221f79e` execute는 API/types/build와 candidate DB-backed 회기 후반까지
통과했지만 Vite 전용 `/src/main.tsx` 차단 테스트가 production hashed entry를 차단하지 못해 110/2에서
mutation 없이 중단됐다. source와 production preview에서 수정 회귀 2/2씩 통과했다. 이 테스트 수정의 새 clean
commit `21461ab3` candidate 112/112 뒤 실제 NAS-origin G4 2건이 `crypto.subtle` 부재로 실패했고, release agent는
이전 image·health·auth·OpenAPI를 검증해 rollback했다. 문장 원문을 보내지 않는 portable SHA-256 fallback은
secure localhost 2/2와 Tailnet insecure origin 2/2에서 동일 표준 digest를 통과했다. 최종 clean source HEAD
`61a41d1f…6af`·tree `87dec55d…3b77`·2회 동일 archive `4d15d055…119d4d`를 NAS에 승격했고 candidate
112/112와 실제 `http://100.116.83.60:8088` origin browser review 112/112(11 specs, desktop/mobile/single-run),
health ok·db/engine true·auth 401·OpenAPI 126을 통과했다. API/Web는 `d5021950…e4b1`/`9796c092…4b36`, fresh dump는
`36ec8748…24db8` 1,097,100 bytes·TOC 1,738/TABLE DATA 129이며 previous `6030a677…c611`을 보존했다.
### I-DAG. 첫 에이전트 실행팩 AOS-001~012
- [x] **AOS-001** 기존 지표 provenance inventory — 선행 없음. 종료: 모든 현재 점수의 source map.
- [x] **AOS-002** `measurement_event` ADR/schema/DDL — 선행 AOS-001. 종료: contract + rollback + RLS tests.
- [x] **AOS-003** benchmark case schema와 첫 8개 장면 — 선행 AOS-001. 종료: deterministic fixtures.
- [x] **AOS-004** legacy rapport/alliance adapter — 선행 AOS-002. 종료: 기존 데이터 무손실 read model.
- [x] **AOS-005** goal/task/bond 3관점 structured schema — 선행 AOS-002/003. 종료: gold contract tests.
- [x] **AOS-006** session measurement API — 선행 AOS-005. 종료: API/DB idempotency E2E.
- [x] **AOS-007** 학습자 self-assessment lock — 선행 AOS-002. 종료: reveal-order E2E.
- [x] **AOS-008** Alliance Pulse 리뷰 UI — 선행 AOS-006/007. 종료: desktop/mobile visual proof.
- [x] **AOS-009** 교수자 measurement drilldown — 선행 AOS-006. 종료: role/RLS/audit E2E.
- [x] **AOS-010** measurement drift/calibration job — 선행 AOS-003/005. 종료: version comparison report.
- [x] **AOS-011** public/deploy proof manifest — 공개 API 126 paths, G1~G8 route, Pages asset, auth/OAuth proof.
- [x] **AOS-012** SSOT/TODO 동기화 — dashboard/TODO/backlog/release manifest 동기화와 checker green.
---
## 지금 임계경로 (한 줄)
납품(공개 데모)의 핵심 블로커는 **A(공개 `/turn` proof + 배포 secrets)**. 그 다음이 **B(한신대 거버넌스)**와
**C(Phase 3 파일럿)** — C는 2026-07-13 회의로 **7월 말~8월 초 파일럿 일정 확정**, 그 전제로 **H의 P1(회기 종료 시간 기반)**이
새 구현 게이트다. **I(Outcome & Alliance OS)는 현재 감사 기준 G0~G6과 G8이 DONE이다.
G8은 receipt-bound 실제 rollback과 current clean source NAS-origin 112/112를 완료했다. G7은 내부 구현 DONE과 외부 proof GATE를 분리하며,
current source 공개 배포·local voice ready·authenticated WSS 무마이크 rehearsal은 완료했고, 명시 동의 mic 3,120초·동시 resource high-water·독립 라벨 benchmark·checker exit 0이 남았다.** D~G는
품질·확장 작업으로 게이트가 아니다. 상세 상태·검증 증거는 SSOT 대시보드가 소유한다.
- [ ] **직렬 E2E 게이트 잔여(코칭 기능 통합)** — 소유자의 70d7cb34(AI 코칭 버튼·버블)가 세션 UI에 새 DOM을 더하며
session-mvp strict 위반('코칭' 버튼 3개)·동맹 체크포인트 포인터 타임아웃(결정적 재현)이 발생. 통합 마감 후
교사 재평가 2건 포함 전체 GREEN 재검증. settings/learner 협폭 레이아웃 결함 91건(sweep 보고)도 함께.
상세: docs/ops/evidence/usecase-tdd-2026-08-18.json

View file

@ -0,0 +1,244 @@
# Vignette 백로그 — 열린 항목만 (현행본)
> **SSOT는 `docs/dev_dashboard.html`이다.** 상태·검증 증거·결정·로드맵의 권위 기준은 대시보드다.
> 이 파일은 대시보드와 일치하는 **아직 열린 작업(외부/환경 게이트)** 만 얇게 유지한다.
> 완료된 작업의 변경 로그·검증 기록(구 "최신 동기화 추가"·"이번 세션에 닫은 것"·B1~B3 `[x]` 상세)은
> `docs/archive/ops/backlog-2026-06-26-full-history.md`로 냉동 보관했다. 필요할 때만 이력용으로 본다.
>
> 운영 원칙: 가짜 증거로 DONE 표기 금지. 실증 불가·외부 의존·소유자 결정 항목만 여기서 추적한다.
> 분류: **B1 비차단 폴리시** · **B2 환경 제약(증거 생산 불가)** · **B3 소유자 결정** · **B4 외부 거버넌스**
> **B0. 원천문서 갭** — 한신대 산학협력 원천문서 갭(critical/high/medium)은 SSOT 대시보드 "원천문서 갭 분석"
> 섹션과 상세 근거 `docs/ops/source-docs-gap-analysis-2026-06-26.md`에서 추적한다. C1~C3/H·M 구조는 코드로
> 선제 구축했고, 임상 문안·평가기준·골든셋 콘텐츠는 임상팀(구훈정·어유경) 외부 정의로 받는다.
>
> **개선관리 워크북(2026-08-29)** — C-002·C-003과 REQ-001~007은 완료다. REQ-001은 기존 Gmail 인증 세션의
> 로그인 이메일·관리자 권한·온보딩 비전환·복구 데이터 가시성을 소유자가 수락해 완료로 승격했다. REQ-008은 recovery task
> 계약과 5174 자동복구까지 완료했지만 실제 Windows 재부팅 smoke가 남아 `검토`다. C-001은
> v2 합성 사례 6건·상태별 canonical 판정기와 자동
> 안전 게이트의 기술 사전검증까지 완료됐다. 사례별 5필드 30개·청소년 답변 6개·검토자 이름/소속/자격을 포함한
> 승인 10필드와 서명 증거가 없어 B4 외부 GATE로 유지하며, `review_complete=true` 전에는 완료로 닫지 않는다.
> 현재 개선관리 집계는 **완료 9·검토 2·총 11**이다. C-001 외부 입력 46칸은 조작하지 않는다.
>
> **2026-08-29 최신 운영 상태(이 PC 로컬 기준)·2026-08-31 재실측 정정** — 당시 이 PC 로컬 API 8001은 연결 거부였고 이 PC의
> Docker daemon 부재로 이 PC 기준 DB/API는 복구 불가였다(이 PC는 개발 워크스테이션). <b>2026-08-31 재실측:</b> public 도메인
> `https://api-vignette.chanpaca.net/health`는 별도 호스트(Cloudflare 뒤)에서 200·`environment=prod`·`db=true`·`engine=true`
> 정상 서빙 중이며, 이 PC에는 cloudflared 프로세스가 없어 public 도메인은 이 PC가 아니다. 즉 2026-08-29의 OUTAGE 표현은
> 도메인 장애가 아니라 이 PC 로컬 상태였으며 오늘 실측이 이를 대체한다. 배포된 동적 runtime/task 기준선은 old `dba9b75a…`, Pages는
> `0c60261e…`(source `5bf89ff…`)다. avatar union은 93 objects·52,973 bytes·SHA-256 `9d703126…e6aa`, decode
> 정상 3/실패 90, 현 DB 참조 정상 2/실패 6/missing 0이다. fail-closed 후보와 deployed baseline을 분리하며,
> 전체 회귀·아바타 실브라우저 fallback·G8 실DB/public 증거·사용자 배포 승인 전에는 운영 전체를 GREEN으로 올리지 않는다.
>
> **Outcome & Alliance OS** — 2026-08-06 정식 전략 실행 트랙으로 승격했다. G0 Measurement Truth, G1
> 현재 소스·실행 증거 재감사에서는 G0~G6과 G8이 DONE이다. G1 승격 prompt 1.2+read-skew/JSON 복구는 24/24 ready·방향 9/9·오류 0을 재확인했고, G0 census 29/29·위반 0, G4/G5 실제 API/DB/브라우저 폐루프, G6 safety metadata-only 최우선 runtime을 disposable clone에서 확인했다. G8은 실제 receipt-bound image rollback 2회(`nas-g8-723eeef2…`/`nas-g8-2738846c…`)에 더해 source HEAD `61a41d1f…6af`·tree `87dec55d…3b77`·archive `4d15d055…119d4d`의 candidate 112/112와 실제 NAS 평문 origin 112/112를 통과했다. 과거 `6030a677…c611`의 UUID 24건 실패와 후속 SHA 결함 rollback은 이력으로 보존하며 현재 완료 증거로 재사용하지 않는다. G7 Multimodal Alliance는 내부 구현 DONE과 외부 proof GATE를 분리한다. detached-clean public `a73bcd24…`·OpenAPI 126·`local_whisper`/`melotts` ready·authenticated WSS 무마이크 rehearsal까지 완료했고, 명시 동의 물리 마이크 3,120초·독립 라벨 voice-gain benchmark·동시 topology high-water를 추적한다. 외부 Deepgram/OpenAI adapter는 fallback으로 보존한다. G0~G8과
> AOS-001~012는 `docs/TODO.md` I절에서 전건 추적하고, 상태는 SSOT 대시보드의 9개 계획 카드가 소유한다.
> 이 얇은 백로그에는 그중 외부·환경 증거가 필요한 항목만 기존 B2/B4/Phase 3 게이트와 합쳐 유지한다.
>
> **2026-08-31 재검증(목표 마무리 기록)** — 아래 열린 항목 전건을 SSOT 대시보드(2026-08-29/30)·최신 핸드오프(`ops/handoff-goal-production-2026-08-29.md` §0)·실제 환경과 교차 재확인했다. 실증 게이트가 남아 있어 **가짜 증거로 DONE 체크하지 않는다**(운영 원칙).
> **운영 인프라 정정(중요):** 정식(production)은 `infra/docker-compose.nas.yml`**NAS `vignette-prod` 스택**(`ssot-host: nas`·`runtime-class: production`)으로 분리 운영 중이다. 2026-08-31 실측으로 public 도메인 `https://api-vignette.chanpaca.net/health`가 200·`status=ok`·`db=true`·`engine=true`·`environment=prod`·`engine_mode=openai`로 응답하는 반면, **이 Windows PC에는 cloudflared 프로세스가 없고 로컬 API `8001` listener도 없다** — 즉 public 도메인은 이 PC를 지나지 않는다. 이 PC는 개발 워크스테이션(과거 자택 public 호스트였던 경로의 잔여 상태: Docker `vignette-dev-db:55432` Up·로컬 엔진 `9099` LISTENING·`VignettePublicRuntime`/`Watchdog` 2개 Scheduled Task Disabled)이다. 따라서 아래 항목 중 이 PC의 `8001`/task/재부팅 관련 게이트(B2)는 **정식(NAS)과 분리된 이 PC/개발 런타임 기준**으로 읽어야 하며, public 도메인 장애를 뜻하지 않는다. push·Pages 배포·public runtime/task 변경은 별도 승인 전 미실행.
> 실제 닫힘 판정은 이 PC 기준이 아니라 **정식 운영 주체(NAS `vignette-prod` ingress·DB·도메인) 기준**으로 해야 하며, 이 PC에서 NAS ingress를 직접 실측할 수 없어 별도 확인이 필요하다. 2026-08-31 추가로 **public 도메인 읽기 전용 실브라우저 폐루프**(Playwright chromium: 로그인 화면 1 passed·데스크톱/모바일 200·Google OAuth redirect_uri 확인·dev-login 차단·`/auth/me` 401 외 에러 0)를 실증했다(증거 <code>ops/evidence/public-domain-readonly-browser-2026-08-31.json</code>) — 이는 public 도메인 정상 서빙의 추가 증거이며, 실제 Google 계정 로그인→callback→관리자/P20 실회기·턴 생성(production DB mutation)은 소유자 승인·계정 세션이 필요해 여전히 열린 게이트다. `public API/DB·아바타 정식 승격`, `G8 실DB/public 사람 게이트`, `음성 캐스케이드 live`, `DB 백업 운영화`, `vnet.18ka.net live`, `claude_cli↔Anthropic API live`, `Compose infra/.env`, `B1 티켓 자동 분류`, `C-001`, `한신대 거버넌스`, `L1 등재`, `Phase 3`은 전부 열린 게이트로 유지한다. 참고: `공개 DB 계정·회기 복구 안정화``stable-source 재부팅 후 watchdog smoke`는 사실상 **같은 잔여 게이트(실제 Windows 재부팅 후 자동복구 smoke)**를 추적 중이다 — 재부팅 smoke가 닫히면 두 항목과 REQ-008이 함께 닫힌다.
---
## B1. 비차단 코스메틱/후속 폴리시
- [ ] **운영 티켓 자동 분류·처리 후속** — Claude Recipe headless 자동 수정 후보, 관리자 승인 후 이슈 등록·PR/작업
스레드 생성, 처리 결과 audit trail 확장은 아직 설계/승인 필요. 담당 그룹 자동 배정·우선순위 escalation·raw/rollup
보존기간 같은 운영 정책은 B3에서 이미 결정됨(수동 승인·미도입 고정). 자동 수정은 운영자 승인 전까지 실행하지 않는다.
> B1 완료 항목(학생 리뷰 1~5 척도 320px 반응형 재배치, 셸 구분선, 세션 종료 UX/다크테마, 아바타 SVG 리그 복귀와 래스터 비활성화, SEO/공유 카드, 레이아웃 정렬, 권한 위임,
> 메일링, 아카이브 API, TTS voice map, 빈상태 레이아웃, 평가 실패 복구 UX, SSE 저장, live-coach 표면화,
> 음성 메타, 위기 게이트 UI, 운영 티켓/헬스 샘플러 등)의 상세·검증 로그는 아카이브 full-history 참조.
---
## B2. 환경 제약 — 이 워크스테이션에서 증거 생산 불가
- [ ] **public API/DB·아바타 정식 승격** — Docker/API 장애를 복구하고, 손상 아바타는 private forensic으로 보존하되
public 404/fallback, decode-valid 객체만 immutable cache에 제공하는 후보를 전체 API/Web/PowerShell·내장 브라우저 E2E로
검증한다. exact 93 objects·52,973 bytes·`9d703126…e6aa` receipt와 후보 commit SHA를 고정한 뒤 push/Pages/runtime/task/
upload-root 범위를 명시 승인받는다. 신규 UUID 업로드의 URL 기록·현재 참조·GET/HEAD도 full decode하며 사후 손상은 current
invalid/health fallback과 404로 닫는 후보까지 API 1074/1 skip을 통과했다. Docker engine OFF + 활성 public watchdog 때문에
풀스택은 watchdog 일시 비활성화와 고유 56432/58000/55173 격리 자원·exact cleanup을 승인받아야 한다. DB avatar URL 삭제·
파일 초기화·task/runtime 재시작을 선행하지 않는다.
- [ ] **G8 사람 게이트 실DB/public 실증** — 승인·보류(`keep_quarantine`)·반려(`reject`)와 사유 입력, 증거 미충족 승인
차단은 typecheck와 route-fixture desktop/mobile 10/10을 통과했다. Codex 내장 브라우저의 격리 local stateful fixture도
8.5초 overlay 0·heading 정상, content 보류와 증거 없는 release 반려 effect 0, 증거 4종 promote 승인 lifecycle effect 1로
GREEN이다. 남은 것은 사용자 행동 시점 확인 뒤 실행할 실DB append-only 결정과 public proof다. local fixture는 이를 대신하지 않는다.
- [ ] **공개 DB 계정·회기 복구 안정화** — 2026-08-07 18:26 KST owner 승인으로 recovered named volume을
`vignette-dev-db:55432`에 전환했다. cutover owner 집계 users 84, sessions 30, turns 705, Google 계정 16,
Google 소유 회기 30, orphan 0이며 health·engine·OAuth·watchdog가 정상이다. 기존 인증 세션은 복사하지
않아 사용자는 Google 재로그인이 필요하다. current dump·old-original dump·전환 직전 rollback container를
모두 보존하며, 실제 사용자의 소유 회기 확인과 자동 백업 운영화 전에는 제거하지 않는다.
2026-08-09 Docker Desktop 중단과 18:02:55 KST Docker Desktop UI의 active DB 직접 stop으로 `db=false`
재발했지만 동일 recovered container/volume만 재기동했다. 현재 owner read-only 집계 users 356/sessions 193/
turns 726, Google users 16/Google-owned sessions 31이고 public db/engine true다. 2026-08-09 fresh dump
`vignette-dev-db-vignette-20260809-122235Z.dump` SHA `f1fd569c…5f64`를 보존했다. 공개 API/cloudflared는
detached-clean `a73bcd24…`·tree `b02b3a1b…`로 승격했고 OpenAPI 126·local voice exact·receipt
`47388d58…9b08` passed를 확인했다. watchdog·로그온 boot도 같은 stable root의 commit/tree/script SHA에 pin해
명시 실행 결과 0·Ready를 확인했다. 실제 Windows 재부팅 후 자동복구 smoke는 남아 있다.
2026-08-12에는 휴면 Gradle daemon의 `8001` 점유·cloudflared/voice sidecar 중단과 PowerShell 5.1 native stderr
조기 종료 결함을 복구했다. fresh dump `9cbda31e…0ce5`(7,532,625 bytes·TOC 1,492)를 먼저 고정하고
detached-clean `a73bcd24…`·tree `b02b3a1b…`로 fresh 승격해 receipt `47388d58…9b08`, public health 3/3,
OpenAPI 126·local voice exact와 watchdog/boot result 0을 재확인했다.
2026-08-29에는 Windows PowerShell 5.1의 timed `WaitForExit()`가 process handle 선확보 없이는 성공한 web
build의 `ExitCode`를 null로 남기는 오판을 제거했다. 실제 PS5.1 계약 32 passed, detached-clean
`44b7835c…`·tree `06133249…`에 boot/watchdog을 동일 핀으로 재등록했고, 5174 자동복구·HTTP 200,
`LastTaskResult=0`·failcount 0, local/public health `status=ok·db=true·engine=true`를 확인했다. 실제 Windows
재부팅 smoke와 PC 비종속 상시 호스트 이전은 계속 열린 환경 게이트다.
상세: `docs/ops/public-db-recovery-rehearsal-2026-08-07.md`.
- [ ] **DB 백업 운영화** — 검증형 one-shot custom dump와 실제 일회용 restore drill은 통과했다. 예약 실행,
실패 알림, NAS/off-host 암호화 복제, restore drill 주기와 보존 정책을 운영 게이트로 남긴다.
- [ ] **`vnet.18ka.net` 공개 live 전환** — 코드/스크립트는 `vnet.18ka.net`·`api-vnet.18ka.net` 허용까지 갱신됨.
남은 블로커: (1) `vnet.18ka.net`/`api-vnet.18ka.net` DNS A record 부재, (2) cloudflared 현재 인증 컨텍스트가
`18ka.net` zone이 아니라 `chanpaca.net` zone에 route dns 생성, (3) Google Console redirect URI
`https://api-vnet.18ka.net/auth/callback` 등록 필요. 현재 prod API 8001은 `db:true`/`engine:true`로 건강.
상세: `docs/ops/tailscale-vnet-runtime-2026-06-27.md`.
- [ ] **Compose 패키징용 `infra/.env` owner-secret fill-in** — 현재 production direct-runtime은 서로 다른 G3~G8
내부 토큰 6개, 필수 운영키, DB `current_user=vignette` non-owner app-role preflight와 공개 health를 통과했다.
향후 Compose 패키징 전환 시 `APP_DB_PASSWORD` 등 패키징 비밀과 prod-safe engine/voice flags를 별도 주입하고
compose profile preflight를 다시 닫는다.
- [x] **공개 Google OAuth 실제 `/turn` proof** — 2026-08-28 기존 Google 세션 callback→관리자 진입과 공개 P20
회기 생성→자기점검 잠금→실제 학습자/AI 내담자 턴→종료→리뷰·평가→다음 회기 버튼 재노출을 브라우저로
확인했다. 신규 Gmail 선택은 별도의 Vignette 계정 생성 행동시점 확인 항목이다.
- [ ] **음성 캐스케이드 live** — 공개 G7 API route와 TLS 1.3 WSS handshake, 비인증 1008 차단, 로컬
synthetic short soak는 통과했다. Deepgram/OpenAI fallback adapter와 운영 기본 `local_whisper`/`melotts`의 interim/final/word timestamp, HMAC word pseudonym,
streaming 중 1초 동의 재검사, 공통 consent transaction lock, bounded queue/10MiB cap, 텍스트 보존·음성 재연결
UX, `ready` provider/model 계약은 code/internal 완료다. actual-Postgres 동시 철회는 writer 대기→커밋 뒤 차단과
timeline row 0, synthetic gold benchmark는 text-only 1-MAE `0.9533`→multimodal `0.9673`을 확인했다.
public runner의 preflight는 auth cookie·소유 회기로 WSS ready/ping과 기대 STT/TTS provider/model 네 값을
마이크 열거·캡처 없이 검사한다. full soak는 `--confirm-physical-capture` 없이는 장치를 열기 전에 exit 3이다.
Session 첫 음성 사용은 30일·원음 미보존 서버 동의 원장을 먼저 기록하며, 성공 전 `getUserMedia`와 voice
WebSocket은 0회다. public runner v4의 turn별 interim/speech-final/latency, 관리자 single-worker runtime
high-water, exact image/container/cgroup/proc/Docker/TCP topology sampler, held-out 30명 외 calibration split
참가자를 포함한 총 최소 31명/held-out 50회기/150축 독립 blind human-held-out voice-gain evaluator와 이를
같은 public host·동시 50분 시간창으로 묶는 canonical checker까지
code/internal 준비를 마쳤다.
독립 평가 입력은 `prepare-g7-human-voice-gain-intake.py --create-template`의 명시적 non-evidence 빈 틀로 시작하고,
`--compile ... --out ...`가 비식별 CSV의 production floor·ICC·κ·gain·CI를 재계산해 모두 통과할 때만 pack을
생성한다. 이 작성 도구는 준비됐지만 실제 참가자·독립 평가자 데이터는 아직 수집되지 않았다.
운영 TTS는 MeloTTS Korean(MIT) loopback으로 전환했고 외부 OpenAI adapter와 회기 전·중 AI 생성 음성 고지는
보존했다. clean public runtime·`local_whisper`/`melotts` exact ready·authenticated WSS의 30초 무마이크 rehearsal은
voice/runtime/topology 각 7 samples로 통과했다. 남은 범위: 실행 직전 장치 선택·명시 동의 물리 마이크와
3,120초 양방향 soak, 독립 라벨 held-out/pilot voice-gain benchmark,
같은 실행의 실제 worker·Uvicorn·OS·Cloudflare edge RSS/CPU/queue high-water. 네 artifact가
`scripts/check-g7-external-proof.py` exit 0을 만들기 전에는 닫지 않는다.
current source의 `start-public-runtime.ps1`는 sidecar-before-API lifecycle, exact
`local_whisper/small/cpu-int8`·`melotts/melotts-korean` readiness, provider env, Uvicorn
`--ws websockets --ws-max-queue 4`를 fail-closed로 연결했다(launcher/sidecar 80/80, API voice 71/71).
공개 topology용 Windows host mode도 PID/start/exe·command SHA/cwd, RSS/CPU/handles/threads,
listener owner/conflict와 TCP high-water를 검증한다. 공개 runtime은 detached-clean `a73bcd24…`에서 receipt-bound로
실행 중이고 task source pin도 result 0이다. 물리 마이크 창은 사용자 장치 선택·명시 동의 전에는 열지 않는다.
코드 P0는 완료됐다. runner exit는 canonical checker `exit 0`/`gate_closed=true`에 결속되고, browser Origin은
API/WSS/admin/topology host·scheme과 분리 검증되며, 실제 capture 최소 3,120초와 세 artifact 공통 overlap
`≥3000s`를 강제한다. Windows topology는 detached-clean HEAD/tree, runner/collector/checker SHA와 exact
`psutil==6.1.1`까지 pin한다. fresh launcher는 legacy API/exact-config cloudflared를 bounded 교체해 새
PID/start/exe/command SHA/cwd safe receipt를 만든다. current runtime/checker 6파일 94/94, intake 6/6,
standalone validator 4/4를 합친 104/104를 통과했다.
무마이크 rehearsal은 완료됐지만 물리 마이크·human pack을 대체하지 않는다.
격리 NAS 프리뷰 `http://100.116.83.60:8088`은 전용 Compose 프로젝트·포트·네트워크·볼륨에 배포했고, 실제 브라우저 회기와
review API 저장 축어록 2턴을 확인했다. 기존 프로젝트 중단·재생성 명령은 실행하지 않았다. 매시간 `Vignette 앱 상태·회기 E2E 정기 검증`
heartbeat(automation id `vignette-e2e`)는 ACTIVE이며 공개/NAS 화면·health는 읽기 전용으로 보여준다. NAS 112건은 실 API/DB 22와 route fixture 90으로 영수증에서 분리한다. 동일 학습자 SSE→review→G4/G5 actual 폐루프 runner의 clean HEAD/tree·보호 포트 거부·sentinel·exact cleanup과 unit 14/14는 완료됐다. 최초 세 full run은 하네스, 4차는 reload ledger hydration, 5차는 fixture 공유, 6차는 mobile success-state locator를 fail-closed했고 7차 clean `aa81af29…`가 첫 전체 GREEN을 만들었다. `94666192…``f97e7fad…`의 DB init 실패는 두 번째 receipt 로그로 detached `99_app_role.sh` CRLF와 `bash\r` exit 127이 원인임을 확정했다. `.gitattributes``*.sh text eol=lf`로 이를 닫았다. 공통 톱바 desktop/mobile 44px을 포함한 clean `b02bee26…`·tree `8f677bba…`의 최신 single full run은 487.3초 GREEN이다. receipt `periodic-learner-e2e-20260812-171547.json`·SHA-256 `8c11a136…af8e3`은 same-learner SSE·review·G4/G5 `0→1→1`, returned desktop/mobile 4/4, route mock 0, public/NAS/active DB 접촉 0과 cleanup 0을 증명한다. heartbeat는 이 GREEN이 6시간 이상 오래됐거나 material milestone이 바뀔 때만 다시 실행한다. 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). 위 health GREEN은 배포 당시 증거다.
2026-08-12 current NAS는 고정 LAN engine relay listener 상실로 degraded였고 15:24 KST local health 요청도
3회 타임아웃했다. source-pinned relay launcher와 loopback contract 8/8에 더해 detached-clean
`f08e03de…f191`·tree `00275133…0abd` exact check-only도 `mutation=false`·listener `0→0`·runtime state 0으로
PASS했다. 실제 9100 시작·NAS origin 재검증은 소유자 승인 대기다. 명시 동의 물리 마이크와 독립 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** — 2026-08-27 Docker Desktop 4.82.0이 stale
`dockerInference`/`engine.sock` AF_UNIX reparse socket(ERROR 1920)에서 crash-loop한 사건은 두 부모 IPC
디렉터리를 `.stale` 백업명으로 이동한 뒤 정상 복구했다. container·image·volume은 생성/삭제/교체하지 않았고,
기존 `vignette-dev-db`와 recovered volume만 재기동했다. 2026-08-28 watchdog·로그온 boot는 detached-clean
`99779a6a…fbc8`·tree `57da86aa…6b91`와 exact commit/tree/script SHA에 재등록했다. 두 작업의 60분 상한·
`IgnoreNew`, launcher mutex·bounded readiness/build·process-tree 종료, hard-down 즉시복구를 focused 50/50으로
검증했고 watchdog 18:10:57 KST·boot 18:14:27 KST 수동 실행 `LastTaskResult=0`·failcount 0을 확인했다.
public P20 authenticated `/turn` 폐루프도 완료됐다. 남은 것은 실제 Windows 재부팅 후 엔진/API/터널 복구다.
DNS 개통 후 `api-vnet.18ka.net``-AdditionalPublicHealthUrls`로 명시 추가한다.
상세: `docs/ops/public-runtime-watchdog.md`.
---
## B3. 소유자(윤찬) 결정 — 전건 확정(2026-06-30)
> 7건(평가설계 H1 κ/ICC·환각률, s2s 2차 PoC 채택, 페르소나 음성 모델·권리, fast-loop 외부 API 허용,
> 재귀학습 fine-tuning 범위, 운영 보존기간·에스컬레이션, 추가 축어록 수급 20건/1개월)은 **전건 확정**됐다.
> 결정 상세는 대시보드 "윤찬 결정 상세" 탭, ADR은 `docs/decisions/`(`voice-s2s-poc.md`,
> `backend-node-transition.md`)에 있다. 각 결정의 구현·실측은 후속 build/GATE로 남는다.
### B3-신규 (2026-07-27 전수 E2E 순회에서 제기 → **2026-07-27 소유자 전건 결정 완료**)
> 소유자(윤찬)가 질문 도구로 7건 전건 결정(2026-07-27). 결정 내용과 구현 상태:
- [x] **설정 화면 아바타 변경 UI** — 결정: **설정에 추가**. **구현 완료(RED→GREEN, 대시보드 144차)**.
- [x] **동의 관리·철회 UI와 재동의 흐름** — 결정: **전체 흐름 구현**(설정 동의 섹션 + 철회 +
재동의 경로). **구현 완료(RED→GREEN, 대시보드 144차)**.
- [x] **신규 학습자 첫 추천 난도** — 결정: **기초 우선 추천**(회기 기록 없는 신규 학습자는
기초 난도 페르소나를 첫 추천으로). **구현 완료(RED→GREEN, 대시보드 144차)**.
- [x] **학생 분석 테이블** — 결정: **지금 가상화 도입**. **구현 완료(RED→GREEN, 대시보드 144차)**.
- [x] **저작 모드 좌측 레일 잔존 마크업** — 결정: **정리**(저작 모드에서 레일 미렌더). **구현 완료(RED→GREEN, 대시보드 144차)**.
- [x] **dev-login 재로그인 자동 재승인** — 결정: **현행 유지**(dev/E2E 편의 의도 확정). 코드 변경 없음.
- [x] **감정 밸런스 타임라인 차트**(결함 로그 #21) — 결정: **지금 구현**(평가 데이터 생산→저장→
리드모델→차트 연결). **구현 완료(RED→GREEN, 대시보드 144차)**.
> 근거·상세: `docs/ops/e2e-full-sweep-2026-07-27.md` 발견 결함 로그 #2·#4·#6·#14·#15·#16·#21.
---
## B4. 외부 거버넌스 — 한신대/데이터 steward 서면 증거
- [ ] **C-001 자살사고 케이스 위기 반응 프로토콜 임상 승인** — SAMHSA SAFE-T, NIMH Youth Outpatient
BSSA, NICE NG225, 보건복지부 109를 반영했다. P1 합성 사례 6건·v2 스키마·상태별 canonical fail-closed 판정기와
런타임/carry-over/output `ideation_stage <= 3` 이중 방어를 포함한 기술 사전검증은 완료했다. 생성·스트림
실제 경로 P1 6/6, focused 90 passed, 런타임 패키지·작업트리 SHA 일치까지 확인했고, 개선관리 완료본의
`C-001 외부검토` 시트는 현재 `pending-valid`·`검토`다. 사례별 판정·근거·수정 요구·재검토·검토일 30개,
청소년 특화 답변 6개, 임상 검토자 이름·소속 기관·자격·검토일·결정 근거·서면 증거/해시·검토 프로토콜 버전·
사례 세트 해시가 모두 기록되고 canonical 판정기가 `review_complete=true`를 반환하기 전에는 완료로 닫지 않는다. 검토 원장:
`docs/ops/clinical-crisis-protocol-review-2026-08-27.md`.
- [ ] **한신대 데이터/SSO 거버넌스 게이트** — SSO claim mapping, 추가 축어록 수급, 미성년 원본 활용동의, 개인정보
처리방침. 로컬 골격 `docs/ops/hanshin-data-governance-gate.md` 준비됨, 외부 서면 증거 필요. (소유자 보류 중.)
- [ ] **L1 스택 정합성·저작권 등재 문서 반영** — 백엔드는 Node.js 교체 가능성 우선으로 결정됐지만, 신청서/저작권
등재 문서에는 현재 FastAPI 유지 사유와 계약 우선 전환 계획을 반영해야 한다. 로컬 결정 기록은
`docs/decisions/backend-node-transition.md`; 외부 제출/등재 증거 필요.
---
## Phase 3 파일럿 게이트 (실참여자 필요)
---
## 배포 파이프라인 재정립 + A 경로 (NAS prod 실배포 → E2E 검증) — 2026-09-01 재부팅 후 실행
> **소유자 지시(2026-08-31)**: 배포는 Forgejo(git.chanpaca.net) 중심, NAS Production 대상, github은 private 백업/미러,
> 이 PC는 개발 전용, Cloudflare는 서빙용만·tunnel 제거. **A 경로로 진행**: 실제 prod 배포 준비 → 실배포 → 로그인·작동 E2E 확인까지.
> 2026-09-01 재부팅 후 아래를 순서대로 진행. 지침: `docs/ops/deployment-pipeline.md` · 핸드오프 `docs/ops/handoff-goal-production-2026-08-29.md`.
### 재부팅 직후 (truth 재확인) — **2026-09-01 완료**
- [x] OS/셸/경로/도구 확정, git HEAD `be08c0b5`·Forgejo master 일치·미커밋 71/untracked 27 확인
- [x] live public API health 200(`prod`·db/engine true)·NAS `vignette-prod` 5컨테이너 확인 — `scripts/vignette-pipeline.py --check` ALL OK
- [x] NAS prod 배포 기계장치 확정: `192.168.0.38:/volume1/docker/vignette-prod`(compose+`private/env.nas` immutable image pin), Forgejo http clone(git 컨테이너)→NAS 로컬 docker build로 고정. cloudflared는 NAS 호스트 상주(`private/cloudflared-host.yml`, api-vignette만 ingress) — public 도메인은 이 PC와 완전 분리 확인
### A-1. 배포 후보 GREEN 정리 — **2026-09-01 완료**
- [x] 미커밋 diff를 4개 논리 커밋으로 좁게 stage(회기 연속성 API·케이스 UI·NAS infra·docs/증거), tmp/outputs 제외
- [x] web typecheck·build PASS / API 1090 passed·gateway 71 passed / SSOT checker·design SSOT·api-types PASS
- [x] Forgejo master push, 최종 배포 SHA `dce85620` 고정(github 미러 동기화 포함)
### A-2. NAS prod 실배포 — **2026-09-01 완료**
- [x] prod 전용 절차 실행: pre-deploy dump `cab6826b…`(11,036,928B·TOC 1783)+env 백업+구 이미지 보존(롤백 경로), 외부 볼륨 `pgdata`/`apiuploads` 무접촉
- [x] 중복 활성 회기 14쌍·30행을 최신 유지 정책으로 종료(영수증 `receipts/stale-active-cleanup-20260901.csv`, 삭제 0) 후 마이그레이션 20/21/22 온라인 적용. mig22의 `name[]=text[]` 캐스트 결함 발견→`5a74302e` 교정. master에 게이트웨이 openai provider가 누락된 회귀 발견→구 운영 이미지에서 이식(`dce85620`, 테스트 71 passed)
- [x] readiness: public health `ok·db/engine true·openai OK`, OpenAPI 200, `/auth/me` 401, dev-login 404, 컨테이너 healthy, 데이터 보존(app_user 1380/sessions 618/turns 1743/case_profile 565 전후 동일)
### A-3. 로그인·작동 E2E 검증 (실브라우저) — **2026-09-01 완료 (전건 GREEN)**
- [x] public 로그인 화면 렌더(단일 Google CTA·콘솔 오류는 예상된 401 1건) + OAuth redirect_uri `https://api-vignette.chanpaca.net/auth/callback`·PKCE S256 검증 — headful 브라우저
- [x] 실제 Google 계정 로그인→callback→회기·턴 생성 완료 — 첫 시도에서 NAS env의 OAuth secret 오류(`invalid_client`)를 발견해 동작 실적 secret으로 교정 후 성공. headful 폐루프: 로그인(소유자 직접 입력)→기존 회기 20건 표시(이전 데이터 보존)→활성 회기 가드·시간 만료 fail-closed 확인→stale 회기 정식 종료→**새 사례(fresh case) 회기 생성(mig22 운영 실증)**→pre pulse 잠금→실턴 SSE·AI 내담자 응답→종료→리뷰 생성. DB 실증: sessions 618→619·turns 1743→1745·case_profile 565→566. 증거: `ops/evidence/prod-e2e-review-2026-09-01.png`
- [x] 배포 WEB: Pages production `9224a64b`, 새 entry `index-BFKGXJQi.js` 200·정상 MIME, 직전 entry `index-Bm30Ozeb.js` 200 보존(이전 세대 자산 175개 보존). 관리자 콘솔 렌더·서비스 상태 5/5 확인
- [x] **DB 백업 예약 실행 운영화(1단계)**`db-backup` 사이드카(일일 pg_dump→`pg_restore -l` 검증→원자 게시, 14일 보존)를 정식 스택에 추가, 첫 자동 덤프 11,202,927B 성공. owner 비번-볼륨 불일치도 env와 일치화. 실패 알림·off-host 암호화 복제·restore drill 주기는 후속 게이트로 유지
### 잔여(재부팅 외) — 그대로 유지
- [ ] 실배포 완료 전까지는 아래가 열린 게이트 유지: `public API/DB·아바타 정식 승격`, `G8 실DB/public 사람 게이트`,
`음성 캐스케이드 live`, `DB 백업 운영화`, `vnet.18ka.net live`, `claude_cli↔Anthropic API live`, `Compose infra/.env`,
`B1 티켓 자동 분류`, `C-001`, `한신대 거버넌스`, `L1 등재`, `Phase 3`, 재부팅 후 watchdog smoke/REQ-008
- [ ] **20명 교육용 파일럿 운영 / 효과성·KPI 측정(SUS·자기효능감·κ/ICC·환각률) / 재귀학습 데이터셋 approved 산출 /
개인정보·동의 감사.** 문서·checker·dry-run exporter는 준비됨(`docs/phase3/*`, `scripts/check-phase3-artifacts.py`,
`scripts/export-recursive-dataset.py`, `scripts/export-phase3-kpi.py`). 공식 문항 확정, 통계 검정, 실험/통제군 배정,
실제 approved evidence는 파일럿·steward/legal 검수·IAA 통과 시점의 GATE다.

View file

@ -1,4 +1,7 @@
# Vignette 전체 개선 목표 · 정식 배포 핸드오프
# Vignette 전체 개선 목표 · 정식 배포 핸드오프 (역사 스냅샷)
> 이 문서는 2026-08-29~30 배포 전 상태의 보존 기록이다. 이후 2026-09-01 NAS 배포와 OAuth 실계정 회기 E2E가 있었으므로 현행 상태를 이 문서로 판단하지 않는다.
> 현재 실행 순서와 승인 경계는 [`docs/HANDOFF.md`](../../HANDOFF.md), 열린 항목은 [`docs/TODO.md`](../../TODO.md), 상태 SSOT는 [`docs/dev_dashboard.html`](../../dev_dashboard.html)를 따른다. 2026-09-01 당시 증거는 [`nas-prod-deploy-2026-09-01.json`](../../ops/evidence/nas-prod-deploy-2026-09-01.json)이다.
작성 시각: 2026-08-29 21:18 KST
작업 루트: `D:\workspace\vignette`

File diff suppressed because it is too large Load diff

View file

@ -300,19 +300,19 @@
<section class="pulse">
<div class="pulse-main">
<span class="eyebrow">project pulse</span>
<h1>지금 위치: <em>NAS <code>vignette-prod</code> 정식 배포 완료(SHA <code>dce85620</code>)</em>public API/웹 모두 새 빌드 서빙, 데이터 보존 확인. 실계정 로그인 폐루프와 C-001·REQ-008 사람 게이트가 남았다.</h1>
<p class="pulse-state"><b>2026-08-31 배포 파이프라인 재정립(소유자 지시):</b> git 관리·배포는 <code>git.chanpaca.net</code>(Forgejo 11.0.16) 중심으로 이관 완료 — <code>yunchan/vignette</code> 레포 생성, <code>master</code>=<code>be08c0b5</code> push, 소스 SSOT 지정. <code>github.com</code>(origin)은 <b>private 백업/미러</b>로 유지(제거 아님). 배포 대상은 <b>NAS Production</b>(<code>docker-compose.nas.yml</code>, <code>vignette-prod</code>)이며 이 PC는 단순 개발용. Cloudflare는 <b>서비스 공개 서빙용만 유지</b>, cloudflare tunnel은 제거 대상. 관리 도구 <code>scripts/vignette-pipeline.py --check</code>로 기준선 무결성 점검(ALL OK): 문서 <a href="./ops/deployment-pipeline.md">deployment-pipeline.md</a>.</p>
<p class="pulse-state"><b>현재(2026-09-01 A 경로 실배포):</b> Forgejo master <code>dce85620</code>를 NAS에서 직접 clone·빌드해 <code>vignette-prod</code> api/engine/web을 교체했고, Cloudflare Pages production도 새 빌드(<code>index-BFKGXJQi.js</code>, 이전 세대 자산 보존)로 배포했다. 사전 dump <code>cab6826b…</code>(11.0MB·TOC 1783)·env 백업·구 이미지 보존으로 롤백 경로를 고정했다. 중복 활성 회기 14쌍 30행은 최신 유지 정책으로 종료(영수증 보존·삭제 0)하고 마이그레이션 20/21/22를 온라인 적용했다. 이 과정에서 mig22의 <code>name[]=text[]</code> 캐스트 결함과 master의 게이트웨이 openai provider 누락 회귀를 발견해 즉시 교정했다(테스트 71 passed). 배포 public health <code>ok·db/engine true</code>, OpenAPI 200, <code>/auth/me</code> 401, dev-login 404, 데이터 집계 전후 동일(app_user 1380/sessions 618/turns 1743). 증거: <a href="./ops/evidence/nas-prod-deploy-2026-09-01.json">nas-prod-deploy-2026-09-01.json</a>. <b>headful 실계정 폐루프도 완료:</b> NAS env의 OAuth secret 오류(<code>invalid_client</code>)를 발견·교정한 뒤 소유자 실계정 로그인→기존 회기 20건 표시(데이터 보존)→새 사례 회기 생성(mig22 실증)→실턴 SSE·AI 응답→종료→리뷰 생성까지 GREEN(sessions 619·turns 1745·case_profile 566). 일일 검증형 DB 백업 사이드카도 가동(첫 덤프 11.2MB OK). <b>다음:</b> C-001 외부 임상 검수, NAS 재부팅 자동복구 smoke, 백업 실패 알림·off-host 복제.</p>
<h1>지금 위치: <em>NAS <code>vignette-prod</code> 정식 배포 완료 기록(SHA <code>dce85620</code>)</em>2026-09-01 당시 배포·실계정 회기 검증·데이터 보존 증거를 남겼다. 신규 REQ-009~011 접수와 C-001·REQ-008 외부 게이트가 남았다.</h1>
<p class="pulse-state"><b>2026-08-31 배포 파이프라인 재정립(역사 기록):</b> git 관리·배포는 <code>git.chanpaca.net</code>(Forgejo) 중심으로 이관했고, <code>github.com</code>(origin)은 private 백업/미러로 유지한다. 배포 대상은 NAS Production(<code>docker-compose.nas.yml</code>, <code>vignette-prod</code>)이며 이 PC는 개발용이다. Cloudflare tunnel은 NAS ingress의 현재 경로이므로 대체 ingress 검증 전에는 제거하지 않는다. 문서 <a href="./ops/deployment-pipeline.md">deployment-pipeline.md</a>.</p>
<p class="pulse-state"><b>최근 확인된 배포 증거(2026-09-01 당시):</b> Forgejo master <code>dce85620</code>를 NAS에서 직접 clone·빌드해 <code>vignette-prod</code> api/engine/web을 교체했고, Cloudflare Pages production도 새 빌드(<code>index-BFKGXJQi.js</code>, 이전 세대 자산 보존)로 배포했다. 사전 dump <code>cab6826b…</code>(11.0MB·TOC 1783)·env 백업·구 이미지 보존으로 롤백 경로를 고정했다. 중복 활성 회기 14쌍 30행은 최신 유지 정책으로 종료(영수증 보존·삭제 0)하고 마이그레이션 20/21/22를 온라인 적용했다. 이 과정에서 mig22의 <code>name[]=text[]</code> 캐스트 결함과 master의 게이트웨이 openai provider 누락 회귀를 발견해 즉시 교정했다(테스트 71 passed). 배포 당시 public health <code>ok·db/engine true</code>, OpenAPI 200, <code>/auth/me</code> 401, dev-login 404, 데이터 집계 전후 동일(app_user 1380/sessions 618/turns 1743)을 확인했다. 증거: <a href="./ops/evidence/nas-prod-deploy-2026-09-01.json">nas-prod-deploy-2026-09-01.json</a>. <b>headful 실계정 폐루프도 당시 완료:</b> NAS env의 OAuth secret 오류(<code>invalid_client</code>)를 교정한 뒤 소유자 실계정 로그인→기존 회기 20건 표시(데이터 보존)→새 사례 회기 생성(mig22 실증)→실턴 SSE·AI 응답→종료→리뷰 생성까지 GREEN(sessions 619·turns 1745·case_profile 566). 일일 검증형 DB 백업 sidecar 첫 덤프도 11.2MB로 성공했다. <b>다음:</b> 신규 REQ-009~011 재현, C-001 외부 임상 검수, NAS 재부팅 자동복구 smoke, 백업 실패 알림·off-host 복제.</p>
<details class="pulse-details">
<summary>운영 복구·watchdog·배포 상세 보기</summary>
<p class="pulse-state"><b>2026-08-31 사례 연속성 후보:</b> source candidate에는 같은 NPC의 <code>완전히 새로 시작</code>(빈 새 case)과 <code>이어서 진행</code>(선택 case의 압축 기억)을 분리했고, 사례별 누적 회기·상담자 가시 턴·누적 시간을 시작 전 표시하며 기억은 lazy foldout으로 숨겼다. migration 22는 legacy case unique만 해제하고 migration 21의 단일 활성 회기 제약을 유지한다. 이는 source/test 후보 상태이며 NAS DB 적용·배포·실제 production 브라우저 GREEN은 아직 하지 않았다.</p>
<p class="pulse-state"><b>2026-08-30 NAS production preflight·ingress 복구:</b> <code>vignette-prod-db-1</code>·<code>vignette-prod-api-1</code>·<code>vignette-prod-engine-1</code>은 healthy였다. <code>vignette-prod-web-1</code>은 Nginx startup에 필요한 최소 capability, <code>vignette-prod-private-proxy-1</code>는 Caddy bind에 필요한 최소 capability가 빠져 restart loop이었고, 두 서비스만 재생성해 restart 0의 running 상태와 Caddy→web/API health 200을 확인했다. DB·upload volume·API·engine·tunnel은 건드리지 않았다. 다만 configured Tailnet bind <code>100.116.83.60</code>는 NAS 인터페이스에 없어 host port가 열리지 않는다. LAN/0.0.0.0 bind로 넓히지 않았으며, Tailnet 자체 복구 또는 owner가 정한 보안 경계를 먼저 적용해야 한다.</p>
<p class="pulse-state"><b>2026-08-29 최신 read-only 상태:</b> <code>VignettePublicRuntime</code>과 watchdog은 여전히 old <code>dba9b75a…</code> root를 가리키며, Docker Desktop Linux engine pipe가 없어 DB/API 복구에 실패한다. 정적 로그인 Web 200은 동적 API 정상 증거가 아니다. 운영 변경 승인 전에는 Docker 시작·API/tunnel 재시작·task 재등록·DB/avatar URL 수정·stable upload root cutover를 하지 않는다.</p>
<p class="pulse-state"><b>2026-08-30 NAS production preflight·ingress 복구(배포 전 역사):</b> 이 절은 9/1 배포 전 preflight 기록이다. 당시 Tailnet bind·proxy capability 문제를 읽기 전용으로 확인했고, 9/1 배포 결과는 상단의 별도 증거를 따른다.</p>
<p class="pulse-state"><b>2026-08-29 개발 워크스테이션 read-only 상태(역사):</b> <code>VignettePublicRuntime</code>·watchdog·Docker Desktop 장애는 이 PC의 legacy 개발 런타임 기록이다. NAS production의 현재 상태나 REQ-008 NAS 재부팅 smoke를 대체하지 않는다.</p>
<p class="pulse-state"><b>2026-08-29 아바타 무결성:</b> 허용 source union은 <code>93 objects · 52,973 bytes · SHA-256 9d703126…e6aa</code>다. 실제 decode는 정상 3·실패 90, 현 DB 참조는 정상 2·실패 6·missing 0이다. 후보는 손상 bytes와 참조를 private forensic으로 보존하되 public serve는 404/fallback으로 막고, decode-valid 객체만 immutable public cache에 싣는다. 이 후보는 전체 API/Web/E2E·내장 브라우저·운영 배포 검증 전까지 GREEN이 아니다.</p>
<p class="pulse-state"><b>2026-08-29 재부팅 복구 hardening의 이전 성공 증거:</b> Windows PowerShell 5.1의 timed <code>WaitForExit()</code>가 process handle 선확보 없이는 성공한 web build의 <code>ExitCode</code>를 null로 남겨 실패로 오판하는 경계를 수정했다. 실제 PS5.1 계약 <code>32 passed</code>, detached-clean <code>44b7835c…</code>·tree <code>06133249…</code>에 로그온·5분 watchdog을 같은 핀으로 재등록했다. 당시 watchdog 3회차 복구로 5174가 자동 기동해 HTTP 200을 반환했고 후속 실행은 <code>LastTaskResult=0</code>·failcount 0, local/public health <code>status=ok·db=true·engine=true</code>였다. 기존 <code>ExecutionTimeLimit=PT1H</code>·<code>MultipleInstances=IgnoreNew</code>·exclusive mutex·bounded wait·process-tree 종료·hard-down 즉시복구 계약도 유지한다. 이것은 로그인 뒤 과거 복구 증거이며 현재 장애나 실제 Windows 재부팅 smoke를 대신하지 않는다.</p>
<p class="pulse-state"><b>2026-08-27 Docker stale socket 인시던트·복구:</b> 공개 로그인 화면은 유지됐지만 API가 <code>db=false</code> degraded 뒤 502가 됐다. 원인은 Docker Desktop 4.82.0 백엔드가 이전 비정상 종료에서 남은 0바이트 AF_UNIX reparse socket <code>Docker\run\dockerInference</code><code>docker-secrets-engine\engine.sock</code>을 ERROR 1920으로 제거하지 못해 startup crash-loop한 결함이다. Docker 전체 종료 후 두 부모 IPC 디렉터리를 <code>run.stale-20260827-232006</code>·<code>docker-secrets-engine.stale-20260827-232006</code>으로 이동해 복구 가능하게 보존했고, container·image·volume 생성/삭제/교체 없이 기존 <code>vignette-dev-db</code><code>vignette_recovered_prod_20260807</code>만 시작했다. API는 detached-clean <code>bf5f7352…0092</code>·tree <code>c39f917b…c88</code>에서 routine API-only 복구했고 public health 3/3 <code>status=ok·db=true·engine=true</code>를 확인했다. 로그온·5분 watchdog도 같은 root/commit/tree와 exact boot/watchdog/start SHA에 재등록했으며 watchdog <code>LastTaskResult=0</code>·failcount <code>0</code>이다. 실제 공개 로그인 브라우저는 console error/warning 0이다. authenticated public <code>/turn</code>은 2026-08-28 폐루프를 통과했고 실제 Windows 재부팅만 별도 gate다.</p>
<p class="pulse-state"><b>2026-08-18 엔진 readiness 장애·수정:</b> 06:30~10:05 KST 공개 API가 <code>engine=false</code>로 약 3.5시간 degraded였다. 원인은 (1) <code>reasoning_effort=high</code> 콜드 프로브가 20초 타임아웃을 넘기면 실패가 성공과 같은 TTL(운영 1800초)로 캐시되는 게이트웨이 결함, (2) 공개 엔진 <code>claude -p</code>가 소유자 Claude 구독을 개발 에이전트 작업과 공유해 사용량 한도 소진 시 생성이 실패하는 운영 경계다. 워치독은 기본 <code>/ready</code>의 캐시 200만 보고 엔진을 healthy로 오판해 API만 재시작하는 루프에 빠졌다. 수정: 실패 전용 <code>ENGINE_READY_FAILURE_TTL_SECONDS</code>(기본 30초) 분리 + 프로브 타임아웃 20→45초, 회귀 4건 신설(게이트웨이 62/62·backend 986). detached-clean <code>4771b97c…c880</code>·tree <code>286723de…c1fa</code>로 API/게이트웨이/워치독·부트 task를 재승격·재등록했고 명시 실행 <code>LastTaskResult=0</code>, 공개 health ok·OpenAPI 126·local voice exact를 재확인했다. 1차 승격은 새 worktree에 <code>apps/api/.env</code>(gitignored 비밀값)가 없어 fail-closed로 실패했다 — 승격 절차에 env 이관 단계가 필요함을 기록한다(약 4분 다운타임).</p>
<p class="pulse-state"><b>2026-08-12 인시던트(NAS relay 복구 대기):</b> 공개 API는 health ok·db/engine true·OpenAPI 126·auth 401과 local voice를 유지한다. NAS 프리뷰는 앞선 3/3 <code>degraded·db=true·engine=false</code>에서 15:24 KST local health 3회 타임아웃으로 악화됐고 정적 로그인 화면만 계속 보인다. NAS API의 고정 <code>ENGINE_URL=http://192.168.0.223:9100</code> 대상 listener가 사라졌으며 공개 엔진은 별도 loopback <code>127.0.0.1:9099</code>에서 정상이다. G8 clean-head candidate 112/112·실제 origin 112/112와 rollback receipt는 완료 증거로 보존하지만, 현 NAS runtime은 relay를 source/secret/task에 결속해 복구하고 health 3/3·auth·OpenAPI·assets·실제 회기를 다시 통과할 때까지 DEGRADED다. 자동 재시작·포트 노출·배포는 실행하지 않았다. G7은 별도로 명시 동의 mic·동시 high-water·human evidence가 남는다.</p>
<p class="pulse-state"><b>2026-08-12 NAS preview relay 인시던트(역사):</b> 이 기록의 preview relay 장애·G8 candidate 112/112·rollback receipt는 과거 증거다. 9/1 production 배포 뒤의 현재 상태는 이 인시던트로 판단하지 않으며, G7의 명시 동의 mic·high-water·human evidence만 별도 외부 gate로 남는다.</p>
<p class="pulse-state">2026-08-07 공개 DB는 recovered named volume으로 전환했고 원본 container·dump·volume을 보존했다. 2026-08-12에는 휴면 Gradle daemon의 <code>8001</code> 점유·cloudflared/voice sidecar 중단과 PowerShell 5.1 native stderr 조기 종료 결함을 복구했다. DB mutation 전에 fresh custom dump <code>9cbda31e…0ce5</code>(7,532,625 bytes·TOC 1,492)를 고정했다. 공개 API·cloudflared는 detached-clean <code>a73bcd24…</code>·tree <code>b02b3a1b…</code>로 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는 <code>47388d58…9b08</code>, 무마이크 rehearsal evidence는 voice <code>61af2e98…</code>·runtime <code>c6e8670e…</code>·topology <code>89f1cb86…</code>이며 UUID/email literal 0이다. G7은 배포가 아니라 실제 mic·동시 high-water·human evidence만 남은 external GATE다.</p>
</details>
<div class="phase-rail" id="phase-rail" aria-label="Phase 진행 추정"></div>
@ -341,6 +341,17 @@
</div>
</section>
<section class="block source-note" aria-labelledby="product-issues-title">
<h2 id="product-issues-title">제품 이슈 접수 현황 (2026-09-07)</h2>
<p><b>지표 범위:</b> 이 표는 개선관리 워크북의 14개 항목을 로컬 추적용으로 분리한 것이다. 상단의 모듈 카드 <code>33 done / 2 doing</code>과 같은 지표가 아니다. 외부 원본은 <a href="https://docs.google.com/spreadsheets/d/1MNRDfXoIQJbLzIcSRdHnQHREWELbKqV4/edit?gid=351278076#gid=351278076">개선관리 워크북</a>이며 2026-09-07 수집 SHA-256 <code>625281e72067ae2aea402f2fe932b4f593b7905c35f8a9740cf346c7f077c812</code>다. 로컬 집계는 완료 9·기존 gate 2·신규 3이다. 원본의 기존 대기 표기와 로컬 DONE은 동기화 대기 상태이며 원본을 수정하지 않는다.</p>
<div class="dg-table-wrap"><table class="dg-table"><thead><tr><th>ID</th><th>로컬 상태</th><th>원본 상태</th><th>로컬 조치 및 검증 결과</th><th>검증 증거</th></tr></thead><tbody>
<tr><td>REQ-009</td><td>DONE · E2E 검증완료</td><td>중요 · 대기</td><td>상담 중 엔진 스트림 끊김/오류 시 입력 복원, 부분 턴 미적재 보호, 인라인 재시도/닫기 버튼 및 음성 상태 복원 구현 완료.</td><td>단위·Playwright 통과 및 실서버 헤드풀 브라우저 검증. <a href="./ops/evidence/headful-02-session-multi-turn.png">headful-02-session-multi-turn.png</a></td></tr>
<tr><td>REQ-010</td><td>DONE · E2E 검증완료</td><td>중요 · 상태 공란</td><td>하드 딜리트 없이 0턴 무턴 세션은 완료 회기 집계에서 안전하게 제외하고, 실제 발화가 진행된 회기만 완료 카운트에 반영되도록 집계 프로젝션 교정.</td><td>헤드풀 브라우저 2턴 완료 시 카운트 증가(+1) 및 0턴 즉시 종료 시 카운트 불변(유지) 검증. <a href="./ops/evidence/headful-05-learner-home-after-completed.png">headful-05</a>, <a href="./ops/evidence/headful-06-learner-home-zero-turn-verified.png">headful-06</a></td></tr>
<tr><td>REQ-011</td><td>DONE · E2E 검증완료</td><td>일반 · 상태 공란</td><td>OAS G5 자기예측 시 legacy instrument ID 정규화 및 scoped block ID 일치화로 HTTP 422 위반 오류 해소. 1차 예측 기록 및 잠금 성공.</td><td>헤드풀 브라우저 리뷰 피드백 탭에서 1차 예측 저장 및 잠금 완료. <a href="./ops/evidence/headful-04-review-calibration-locked.png">headful-04-review-calibration-locked.png</a></td></tr>
<tr><td>SRC-001</td><td>DONE · 로컬 동기화완료</td><td>원본 워크북 정합성</td><td>개선관리 워크북 replica의 수식 오류(<code>#REF!</code>) 수정 및 개발 상태 집계 범위(15행까지 포함) 보정 완료.</td><td>수식 수정본 엑셀 생성: <code>outputs/01a04217-f73b-7303-b597-401fa7f5d290/Vignette_개선관리_완료.xlsx</code></td></tr>
</tbody></table></div>
</section>
<!-- ░░ OWNER BOARD ░░ -->
<section class="block owner-board" id="owner">
<div class="sec-head">
@ -353,7 +364,7 @@
<div class="ocol">
<div class="ocol-h">🚧 GO를 막는 블로커 <span class="ct" id="ct-block">0</span></div>
<div class="ostack" data-owner-col="block">
<article class="ocard" data-owner="1"><div class="otop"><span class="otag block">블로커</span><span class="oblocks">Web/API/runtime 승격</span></div><h3>통합 후보 정식 배포 승인</h3><p class="ask">필요: 전체 API/Web/PowerShell 회귀와 내장 브라우저 E2E가 GREEN이고 exact commit SHA가 고정된 뒤, push·Cloudflare Pages·public API/tunnel·두 task·upload root cutover 범위를 한 번 더 확인한다.</p><p class="why">왜 당신인가: 외부 배포와 이 PC 운영 런타임·예약 작업을 함께 바꾸는 운영 변경이다. 현재 public API 장애가 있어도 승인 전 mutation은 금지한다.</p><span class="opt">승인 전제: 후보 SHA·다운타임·보존 범위·rollback 영수증 제시</span></article>
<article class="ocard" data-owner="1"><div class="otop"><span class="otag block">블로커</span><span class="oblocks">아바타 fail-closed 승격</span></div><h3>아바타 public fallback 후보 승인</h3><p class="ask">필요: source union·DB reference 보존, decode-valid 응답, public fallback, digest receipt와 실브라우저 검증을 후보 SHA에 결속한다.</p><p class="why">왜 당신인가: 2026-09-01 NAS 배포와 별개로 public avatar 응답 정책을 바꾸는 운영 변경이다. 원본 bytes·DB reference·stable upload root는 승인 전 변경하지 않는다.</p><span class="opt">승인 전제: 보존 범위·fallback 증거·후보 SHA·rollback 경로 제시</span></article>
<article class="ocard" data-owner="1"><div class="otop"><span class="otag block">게이트</span><span class="oblocks">Phase 3 파일럿 · 데이터</span></div><h3>한신대 데이터/SSO 거버넌스</h3><p class="ask">필요: SSO claim 스키마, 추가 축어록 수급, 미성년 원본 활용동의, 개인정보 처리방침에 대한 한신대/데이터 steward의 written evidence.</p><p class="why">왜 당신인가: 기관 공문·협의 영역. 로컬 문서 골격은 준비됨(<code>hanshin-data-governance-gate.md</code>).</p></article>
</div>
</div>
@ -1016,9 +1027,9 @@
<button class="tab-button" id="tab-decisions" role="tab" aria-selected="false" aria-controls="panel-decisions" data-tab="decisions">결정 필요</button>
</div>
<div class="tab-panel" id="panel-p0" role="tabpanel" aria-labelledby="tab-p0">
<div class="detail-head"><div><h3>Phase 0 마감 상세</h3><p>지금 단계는 구현을 더 벌리는 단계가 아니라, 운영 증거를 닫고 P1 진입 조건을 고정하는 단계다.</p></div><div class="mini-metrics"><span>상태: 조건부</span><span>우선순위: P0</span><span>블로커: public /turn</span></div></div>
<div class="detail-head"><div><h3>Phase 0 마감 상세</h3><p>지금 단계는 구현을 더 벌리는 단계가 아니라, 운영 증거를 닫고 P1 진입 조건을 고정하는 단계다.</p></div><div class="mini-metrics"><span>상태: 조건부</span><span>우선순위: P0</span><span>블로커: 외부 게이트</span></div></div>
<div class="task-list">
<div class="task-row"><div><span class="task-status s-risk">BLOCK</span></div><div><b>공개 Google OAuth 실제 <code>/turn</code> proof 확보</b><p>로그인 가능한 계정으로 storageState를 저장한 뒤 public-auth-turn E2E를 실행한다.</p></div><div><b>산출물</b><p><code>apps/web/node_modules/.tmp/public-auth.json</code>, public <code>/turn</code> 성공 로그</p></div><div><b>검증</b><p><code>E2E_PUBLIC_AUTH=1</code> + <code>chromium-public-auth</code> 1회 통과</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>공개 Google OAuth 실제 <code>/turn</code> proof</b><p>2026-09-01 당시 소유자 실계정 로그인 뒤 새 사례 생성→실턴 SSE·AI 응답→종료→리뷰 생성을 확인했다. 이는 당시 배포 증거이며 현재 live 재검증은 아니다.</p></div><div><b>산출물</b><p><code>ops/evidence/nas-prod-deploy-2026-09-01.json</code></p></div><div><b>검증</b><p>NAS production headful 폐루프 당시 GREEN</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>engine config 운영값 재확인</b><p><code>GET /admin/engine-config</code>로 운영값을 직접 확인했다. <code>engine_mode=claude_cli</code>, <code>engine_url=http://127.0.0.1:9099</code>, <code>model=gateway-default</code>, <code>durable=true</code>, <code>source=database</code>로 의도값과 일치한다.</p></div><div><b>산출물</b><p>config API 응답(durable, db source)</p></div><div><b>검증</b><p><code>claude_cli / http://127.0.0.1:9099 / gateway-default</code> 의도값 대조 완료</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>과거 Live2D demo 자산 제거</b><p>Mao/Haru 샘플, Pixi/Cubism 런타임, 공개 <code>/live2d/*</code> 캐시 잔여 접근을 차단했다.</p></div><div><b>산출물</b><p><code>apps/web/functions/live2d/[[path]].js</code>, SVG 도형 기반 파라미터 리그</p></div><div><b>검증</b><p>운영 <code>/live2d/mao/*</code>, Cubism core <code>404</code></p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>프로세스 난립 정리</b><p>운영 확인용 프로세스와 로컬/Tailnet 검증용 dev 프로세스를 의도적으로 분리해 유지한다.</p></div><div><b>산출물</b><p>9099 engine, 8001 prod API, 8000/8010 dev API, 5173 web, cloudflared tunnel 1개</p></div><div><b>검증</b><p><code>Get-NetTCPConnection</code>에서 대상 포트별 listener 확인</p></div></div>
@ -1116,7 +1127,7 @@
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>잔여(비차단) — 공용 셸 단일 항목 → 처리</b><p>축소 사이드바 세로 구분선이 본문 전체 높이까지 닿지 않던 건을 <code>components/shell/shell.css</code>에서 처리했다. <code>.vg-nav border-right</code> 제거 후 <code>.vg-shell__body</code> 컨테이너 배경 하어라인으로 본문 그리드 전체 높이 구분선을 그리고, 그리드·구분선 폭을 <code>--nav-cur</code>로 동기화. learner-home 로딩 스켈레톤 밀도도 실제 카드 구조 모사로 보강했다.</p></div><div><b>판정</b><p>구현 완료. 검증: <code>npm run typecheck</code> PASS + <code>vite build</code> PASS. 전 페이지 시각 회귀 게이트는 web+api+DB 스택으로 이 워크스테이션 미실행 — 스택 가용 시 1회 시각 확인 권장.</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>디자인 SSOT 리팩터링 · 인증 화면 복구</b><p>테마 알림은 <code>lib/theme.ts</code>/<code>lib/useTheme.ts</code>, 앱 크롬은 <code>AppShell</code>, 글래스 표면은 <code>Surface</code>가 각각 단독 소유한다. 로그인·온보딩·승인대기는 <code>AuthShell</code>의 100vw/100dvh 캔버스를 공유하고, 학습·세션·리뷰·교수자·관리자·페르소나·설정의 카드/인셋은 공통 Surface variant로 이관했다. <code>shell.css</code>의 페이지 클래스 나열과 <code>!important</code> 표면 덮어쓰기는 제거했다. 라이트 Surface도 2중 반투명 광원, 24px backdrop blur, 굴절 hairline을 공통 토큰으로 사용하고 모바일 셸의 보태니컬 배경을 유지한다.</p></div><div><b>검증</b><p><code>npm run check:design-ssot</code>, typecheck/build 통과. 인증 라이트·다크 × 390/1280 시각 게이트 1 passed, 14개 화면 × 7개 폭 레이아웃 게이트 14 passed, 학습·세션·리뷰 desktop/mobile 46 passed, 로그인→온보딩→학습 desktop/mobile 2 passed. 라이트 글래스의 gradient/blur/hairline/shadow와 모바일 배경 자산은 computed style 단언으로 고정했다.</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>로그인 전체 캔버스 테마 정합</b><p>라이트 테마에서 로그인 패널만 밝고 상담실 캔버스·좌측 브랜드는 다크 전용 값으로 남던 불일치를 수정했다. <code>AuthShell</code> room variant가 라이트에서는 아이보리 베일·다크 텍스트·밝은 정책 표면을, 다크에서는 차콜 베일·밝은 텍스트·어두운 정책 표면을 함께 제공한다.</p></div><div><b>검증</b><p><code>npm run check:design-ssot</code> 통과. <code>auth-visual.spec.ts</code>는 라이트/다크의 상담실 배경 변경과 좌측 브랜드·패널 텍스트 명도 계열을 computed style로 확인하며, 로그인·온보딩 × light/dark × 390/1280px 1 passed 및 생성 캡처 직접 확인. 전체 typecheck는 동시 작업 중인 <code>Admin.tsx</code>의 이름 불일치로 별도 차단.</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>전 저장소 리팩터 거버넌스 P1~P8</b><p>죽은 코드·긴 인자·계약 drift·캐시·중복 workflow·반복 IO·초기 번들·디자인 소유권을 전 영역에서 감사하고 증거가 있는 항목을 구조 개선했다. 평가/런타임/LLM 감사/알림/페르소나 시각 규칙을 단일 소유자로 모으고 대형 화면의 순수 모델과 음성 캡처를 분리했다. 최종 회귀에서 관리자 티켓 pending 소유권·대량 렌더 상한과 Settings 중복 로드 race까지 수정했다.</p></div><div><b>측정</b><p>생산 중복 0.60%→0.03%(15줄), 초기 JS gzip 195.75→77.13kB, 초기 CSS gzip 51.46→6.45kB. <code>check:dead-code</code>, <code>check:duplication</code>, <code>check:design-ssot</code>가 회귀를 막는다.</p></div><div><b>검증</b><p>API 400 passed, gateway 29 passed, type/API/design/dead-code/duplication/build/audit 통과. Playwright fixture 166/166 + DB/engine/provider 직렬 49/49, 합계 215/215 passed. 상세: <a href="ops/refactor-governance-2026-07-15.md">실행 기록</a>.</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>전 저장소 리팩터 거버넌스 P1~P8</b><p>죽은 코드·긴 인자·계약 drift·캐시·중복 workflow·반복 IO·초기 번들·디자인 소유권을 전 영역에서 감사하고 증거가 있는 항목을 구조 개선했다. 평가/런타임/LLM 감사/알림/페르소나 시각 규칙을 단일 소유자로 모으고 대형 화면의 순수 모델과 음성 캡처를 분리했다. 최종 회귀에서 관리자 티켓 pending 소유권·대량 렌더 상한과 Settings 중복 로드 race까지 수정했다.</p></div><div><b>측정</b><p>생산 중복 0.60%→0.03%(15줄), 초기 JS gzip 195.75→77.13kB, 초기 CSS gzip 51.46→6.45kB. <code>check:dead-code</code>, <code>check:duplication</code>, <code>check:design-ssot</code>가 회귀를 막는다.</p></div><div><b>검증</b><p>API 400 passed, gateway 29 passed, type/API/design/dead-code/duplication/build/audit 통과. Playwright fixture 166/166 + DB/engine/provider 직렬 49/49, 합계 215/215 passed. 2026-09-07 REFACTOR-002는 <code>SessionEvaluationWrite</code>의 SQL/cache를 repository로, 공통 정규화·마스킹을 persistence values로 분리했다. AST 기존 대조 20+12 차이 0, focused 149, API 전체 1090 passed/1 skipped/1 warning, gateway 71 passed/1 warning, <code>check:api-types</code>·Ruff 통과. 기존 선택형 격리 DB <code>test_admin_usage_postgres.py</code> 1 skipped는 <code>VIGNETTE_USAGE_TEST_DATABASE_URL</code> 미설정이며 새 skip은 없다. REFACTOR-001은 <code>sessionViewModel</code> 분리와 <code>voiceCapture</code> 책임 유지를 수용했고 lifecycle setter bag hook은 채택하지 않았다. Space retry→pause, empty-audio fixture, review fixture의 <code>SessionReview.learnerFeedbackEnabled:true</code> 누락을 교정했으며 제품 feedback guard는 유지했다. 최종 UI 두 묶음은 97 passed/1 skipped다: <code>session-mvp</code>+<code>uc-session-coach-voice</code> 58 passed/0 skipped, alliance+admin 39 passed/1 skipped. skip은 <code>uc-review-alliance-pulse</code> 320px 테스트의 desktop 중복 실행 제외이며 mobile은 실행·통과했다. disabled AI feedback privacy focused 1 passed(exit 0)도 유지한다. dashboard 10은 별도 검증이다. E2E-001은 이번 focused 범위의 locator 18건 교정과 keyboard 1건 해결을 기록한다. 다음은 <code>npm run e2e</code> 전 기능 묶음에서 범위 밖 케이스를 실행해 실패별 재현·원인·수정 명령을 분류하는 일이다. 과거 sweep 실패를 오늘 결함으로 단정하지 않으며, 실API/DB·장치 검증은 fixture 결과와 분리한다. 이번 경계 정리에서 API의 outcome repository 공통값·practice competency 분류·인증/admin projection·text/voice 공통 turn memory·voice metadata와 web의 focus trap·동맹 scale/잠긴 점수·승인 제출 제어는 기존 책임을 보존해 분리했다. <code>Knip</code> dead-code, measurement contract, typecheck/build, Ruff와 API 전체 1090 passed/1 skipped/1 warning, gateway 전체 71 passed/1 warning이 통과했다. 중복은 17 clone/294줄에서 3 clone/48줄·265 tokens(0.04%)로 감소했다. orchestrator retry는 임상 검수 exact-HEAD 원본 고정 경계로, voice abort와 repository query는 서로 다른 lifecycle·도메인 계약으로 유지한다. 상세: <a href="archive/project-cleanup-2026-09-07/refactor-governance-2026-07-15.md">실행 기록</a>.</p></div></div>
</div>
<div class="tab-subhead"><h4>검증 실행 상세 표</h4><p>요약하지 않고 실행 범위, 명령, 결과를 행 단위로 남긴다.</p></div>
<table>
@ -1125,7 +1136,7 @@
<tr><td>Web typecheck</td><td><code>npm run typecheck</code></td><td>Passed</td></tr>
<tr><td>Design SSOT / auth visual</td><td><code>npm run check:design-ssot</code> / <code>npx playwright test e2e/auth-visual.spec.ts --project=chromium-single-run --reporter=line</code> / <code>npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --reporter=line</code></td><td>SSOT checker passed; login/onboarding light-dark desktop-mobile 1 passed; 14 core screens × 7 widths visual gate 14 passed.</td></tr>
<tr><td>Full Playwright E2E baseline</td><td><code>npm run e2e:parallel</code> / <code>npm run e2e:single-run</code> / <code>npm run e2e:list</code></td><td>2026-08-31 현재 수집은 <b>1182 tests / 66 files</b>다. 이 숫자는 수집량이며 현 작업트리 전체 GREEN과 동일하지 않다. G8 clean-head release gate는 candidate 112/112와 실제 NAS-origin 112/112를 통과했다. 이전 단일 120/120과 2026-07-15의 fixture desktop/mobile 166/166 + DB/engine/provider 직렬 49/49 = 215/215는 범위가 다른 역사 기준선으로 보존한다.</td></tr>
<tr><td>Refactor governance P1~P8</td><td><code>ruff check app</code> / <code>pytest -q app</code> / <code>pytest -q engine_gateway</code> / <code>npm run typecheck</code> / <code>npm run check:api-types</code> / <code>npm run check:design-ssot</code> / <code>npm run check:dead-code</code> / <code>npm run check:duplication</code> / <code>npm run build</code> / <code>npm audit --audit-level=high</code> / full Playwright</td><td>Backend 400 passed, gateway 29 passed, web gates/build/audit passed, vulnerabilities 0, production duplication 1 clone/15 lines/0.03%, Playwright 215/215 passed. 상세 근거는 <code>ops/refactor-governance-2026-07-15.md</code>.</td></tr>
<tr><td>Refactor governance P1~P8</td><td><code>ruff check app</code> / <code>pytest -q app</code> / <code>pytest -q engine_gateway</code> / <code>npm run typecheck</code> / <code>npm run check:api-types</code> / <code>npm run check:design-ssot</code> / <code>npm run check:dead-code</code> / <code>npm run check:duplication</code> / <code>npm run build</code> / <code>npm audit --audit-level=high</code> / full Playwright</td><td>Backend 400 passed, gateway 29 passed, web gates/build/audit passed, vulnerabilities 0, production duplication 1 clone/15 lines/0.03%, Playwright 215/215 passed. 상세 근거는 <code>archive/project-cleanup-2026-09-07/refactor-governance-2026-07-15.md</code>.</td></tr>
<tr><td>API typegen SSOT</td><td><code>npm run check:api-types</code></td><td>Passed; FastAPI OpenAPI → <code>src/lib/api.gen.ts</code> stale check</td></tr>
<tr><td>Outcome &amp; Alliance OS G0</td><td><code>py -3.11 -X utf8 -m pytest -p no:cacheprovider apps/api/app/test_measurement_contract.py apps/api/app/test_runtime_schema_ssot.py -q</code> / <code>scripts/check-measurement-ledger.sql</code> / measurement·API contract checks / web typecheck / DB-backed <code>session-persistence</code> focused E2E 3종</td><td>G0 contract/schema 11 passed, 기존 backend 100 passed, auth 39 passed. Python→JSON Schema→TypeScript→PostgreSQL enum·필수필드 계약이 일치하고 8개 deterministic benchmark가 검증됐다. Live PostgreSQL에서 learner/client/evaluator 가시 행 1/1/2, 교차 누수 0, append-only guard 2를 확인했다. 학습자 턴→교수자 대시보드, 워크시트 검수, 종료 deep 평가→durable 리뷰 E2E는 각각 1 passed. G0/AOS-001~004 완료.</td></tr>
<tr><td>Outcome &amp; Alliance OS G1</td><td><code>pytest test_alliance_measurement.py test_alliance_calibration.py test_measurement_routes.py test_runtime_schema_ssot.py test_measurement_contract.py</code> / <code>scripts/smoke-alliance-pulse-api.py</code> / <code>alliance-pulse.spec.ts</code> / <code>session-review.spec.ts</code> / prompt 1.2 calibration compare</td><td>backend 40 passed, live DB에서 202→awaiting_agents→ready·공개 전 외부 관점 비노출·3관점×3축·교수자 재평정 supersession·attempt별 model-run provenance를 확인했다. desktop/mobile Alliance Pulse 6/6와 기존 회기 리뷰 12/12 통과. 합성 gold 방향 9/9, 최종 실패 0, 근거 recall 88.9%. G1/AOS-005~010 완료.</td></tr>
@ -1222,9 +1233,9 @@
<tr><td>Persona auth boundary</td><td><code>GET /personas</code></td><td>2026-06-30 복구 후 public unauth <code>/personas</code>는 401 <code>not authenticated</code>를 반환한다.</td></tr>
<tr><td>Public login</td><td><code>auth.spec.ts --grep public login</code></td><td>1 passed</td></tr>
<tr><td>Public runtime scripts · source pin</td><td><code>start/watch/boot-public-runtime*.ps1</code> · <code>install-public-runtime-task.ps1</code> · <code>register-boot-task.ps1</code></td><td>이 행은 <b>이 PC의 Windows 런타임/task 기준</b>이다. 2026-08-31 실측에서 이 PC에는 <code>cloudflared</code> 프로세스가 없고 <code>VignettePublicRuntime</code>/<code>Watchdog</code> 두 Scheduled Task는 Disabled이며 로컬 <code>8001</code> listener도 없다 — 이 PC는 더 이상 public 호스트 역할을 하지 않는다. public 도메인은 별도 호스트에서 정상 서빙 중(health 200). 새 avatar fail-closed receipt 후보는 여전히 미배포이며, 사용자 승인 전 이 PC의 task 재등록·runtime 재시작을 하지 않는다. 실제 Windows 재부팅 자동복구 smoke는 별도 운영 gate다.</td></tr>
<tr><td>Public API health</td><td><code>http://127.0.0.1:8001/health</code> / <code>https://api-vignette.chanpaca.net/health</code></td><td><b>2026-08-31 실측: public 도메인은 200·<code>environment=prod</code>·<code>db=true</code>·<code>engine=true</code>·<code>engine_mode=openai</code>로 정상 응답</b>한다(<code>Server: cloudflare</code>, 이 PC가 아닌 별도 호스트가 서빙). 이 PC 로컬 <code>127.0.0.1:8001</code>은 listener 없음(이 PC가 public 호스트 역할을 안 하므로)이며, 이는 도메인 장애가 아니라 <b>개발 워크스테이션과 정식 운영의 분리</b>를 반영한다. NAS preview 8088은 별개로 <code>degraded</code>다. 2026-08-29 OUTAGE 기록은 그 시점의 이 PC 기준 스냅샷으로, 오늘 실측이 이를 대체한다.</td></tr>
<tr><td>Public API health</td><td><code>http://127.0.0.1:8001/health</code> / <code>https://api-vignette.chanpaca.net/health</code></td><td><b>2026-08-31 실측: public 도메인은 200·<code>environment=prod</code>·<code>db=true</code>·<code>engine=true</code>·<code>engine_mode=openai</code>로 정상 응답</b>한다(<code>Server: cloudflare</code>, 이 PC가 아닌 별도 호스트가 서빙). 이 PC 로컬 <code>127.0.0.1:8001</code>은 listener 없음(이 PC가 public 호스트 역할을 안 하므로)이며, 이는 도메인 장애가 아니라 <b>개발 워크스테이션과 정식 운영의 분리</b>를 반영한다. NAS preview 8088은 별개로 <code>degraded</code>다. 2026-08-29 OUTAGE 기록은 그 시점의 이 PC 기준 스냅샷으로, 2026-08-31 실측이 이를 대체한다.</td></tr>
<tr><td>Public runtime current snapshot</td><td><code>health + provenance + Scheduled Tasks + Pages</code></td><td>과거 이 PC 배포 기준선은 clean commit <code>dba9b75a…</code>·tree <code>14cd4607…</code>, Pages production <code>0c60261e…</code>(source <code>5bf89ff4…</code>)다. 2026-08-31 실측상 public 도메인은 별도 호스트에서 200·prod·db/engine true로 동작 중이고, 이 PC는 개발 워크스테이션일 뿐이다(과거 이 PC 기준선의 '장애'는 이 PC 미사용에 따른 잔여 스냅샷). fail-closed avatar 후보와 새 upload receipt는 아직 승격되지 않았다. 후보와 deployed baseline을 분리하며, 승인 전 Docker/API/tunnel/task/DB/upload-root mutation을 하지 않는다.</td></tr>
<tr><td>Public/local/Tailnet login recovery</td><td><code>https://vignette.chanpaca.net/login</code> / <code>https://api-vignette.chanpaca.net/health</code> / <code>https://alpaca-home.taile93291.ts.net/login</code></td><td>2026-08-28 Google callback→관리자·P20 실제 회기 폐루프는 유효한 과거 증거다. <b>2026-08-31 실측(Playwright chromium): public 로그인 화면 폐루프 1 passed, 데스크톱/모바일 HTTP 200, Google OAuth redirect_uri=<code>https://api-vignette.chanpaca.net/auth/callback</code> 확인, dev-login 차단, 예상된 <code>/auth/me</code> 401 외 콘솔/페이지 에러 0</b> — 읽기 전용 실증 완료(<a href="./evidence/public-domain-readonly-browser-2026-08-31.json">증거</a>). 실제 Google 계정 로그인→callback→관리자·P20 실회기·턴 생성은 production DB mutation이라 소유자 승인·계정 세션(storageState)이 필요해 여전히 열린 게이트다.</td></tr>
<tr><td>Public/local/Tailnet login recovery</td><td><code>https://vignette.chanpaca.net/login</code> / <code>https://api-vignette.chanpaca.net/health</code> / <code>https://alpaca-home.taile93291.ts.net/login</code></td><td>2026-08-28 Google callback→관리자·P20 실제 회기 폐루프는 유효한 과거 증거다. <b>2026-08-31 실측(Playwright chromium): public 로그인 화면 폐루프 1 passed, 데스크톱/모바일 HTTP 200, Google OAuth redirect_uri=<code>https://api-vignette.chanpaca.net/auth/callback</code> 확인, dev-login 차단, 예상된 <code>/auth/me</code> 401 외 콘솔/페이지 에러 0</b> — 읽기 전용 실증 완료(<a href="./ops/evidence/public-domain-readonly-browser-2026-08-31.json">증거</a>). 2026-08-31 시점에는 실제 Google 계정 로그인→callback→관리자·P20 실회기·턴 생성이 production DB mutation이라 소유자 승인·계정 세션(storageState)을 필요로 했다. 이후 2026-09-01 소유자 실계정의 기존 회기 확인→새 사례→턴→리뷰 폐루프는 당시 [NAS 배포 증거](./ops/evidence/nas-prod-deploy-2026-09-01.json)로 완료 기록을 남겼다. 이 기록은 오늘의 live 상태를 뜻하지 않는다.</td></tr>
<tr><td>Local 5175 login</td><td><code>PLAYWRIGHT_BASE_URL=http://127.0.0.1:5175 auth.spec.ts</code></td><td>desktop/mobile passed</td></tr>
<tr><td>Learner/readiness E2E</td><td><code>learner.spec.ts + readiness.spec.ts desktop/mobile</code></td><td>14 passed</td></tr>
<tr><td>Learner growth header</td><td><code>uc-learner-home-dashboard.spec.ts --grep "성장 지표 라포 헤더"</code></td><td>2026-08-28 chromium desktop/mobile 2 passed. 라포 문구 부모를 inset surface에서 plain div로 바꾸고 computed background transparent·border/radius/padding 0을 고정했다. typecheck·design SSOT·cosmetic filter safety도 통과했으며 Pages에는 아직 미배포다.</td></tr>
@ -1264,7 +1275,7 @@ $env:E2E_PUBLIC_STORAGE_STATE=".\node_modules\.tmp\public-auth.json"
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\start-public-runtime.ps1</pre>
</div>
<div class="source-note"><b>닫은 항목(live 증거):</b> ① engine config 운영값(<code>claude_cli / 9099 / gateway-default</code>, durable, db). ② 상주 엔진풀 probe — 단일 session_id 2회 reused stream, TTFT 16674199ms, cost 누적 0.068→0.123. ③ 상주 엔진풀 RSS — gateway session 1개에서 <code>claude -p</code> child RSS 363.3MB, gateway RSS 16.2MB. ④ Postgres RLS/audit smoke 5 checks PASS. ⑤ turn cost telemetry — <code>app.turns</code> client_ai 13행에 provider/model/cost_usd 실적재(합 $1.22). ⑥ 레이아웃 시각 게이트 최신 9/9 + 2026-06-28 적대적 재검수 7/7 accept.</div>
<div class="source-note"><b>환경·외부 승인 때문에 아직 못 닫는 항목(정직 표기):</b> public API/DB 장애 복구와 avatar fail-closed 후보의 전체 회귀·배포·실브라우저 fallback · G8 관리자 사람 게이트의 승인형 실DB append-only 증거와 public proof(local stateful in-app browser는 GREEN) · C-001 사례별 판정·근거·수정 요구·재검토·검토일 30개, 청소년 답변 6개, 임상 검토자 이름·소속·자격·결정 근거를 포함한 승인 10필드와 서명 증거/해시·검토본 고정 · <code>vnet.18ka.net</code>/<code>api-vnet.18ka.net</code> DNS·OAuth redirect 등록 · 한신대 데이터/SSO 거버넌스 · G7 명시 동의 물리 마이크 3,120초와 공통 3,000초 운영 high-water, 독립 human voice-gain pack, canonical checker exit 0 · claude_cli↔Messages API 폴백 동일성 · 실제 Windows 재부팅 후 watchdog smoke · Phase 3 파일럿 게이트. REQ-001은 Gmail 인증 세션·관리자·복구 데이터 수락으로 완료했다. C-001은 <code>pending-valid</code> 기술 사전검증뿐이며 외부 검수 입력을 조작하지 않는다. 운영 원칙상 가짜 증거로 DONE 표기하지 않는다.</div>
<div class="source-note"><b>환경·외부 승인 때문에 아직 못 닫는 항목(정직 표기):</b> avatar fail-closed 후보의 전체 회귀·승인 배포·실브라우저 fallback · G8 관리자 사람 게이트의 승인형 실DB append-only 증거와 public proof(local stateful in-app browser는 GREEN) · C-001 사례별 판정·근거·수정 요구·재검토·검토일 30개, 청소년 답변 6개, 임상 검토자 이름·소속·자격·결정 근거를 포함한 승인 10필드와 서명 증거/해시·검토본 고정 · <code>vnet.18ka.net</code>/<code>api-vnet.18ka.net</code> DNS·OAuth redirect 등록 · 한신대 데이터/SSO 거버넌스 · G7 명시 동의 물리 마이크 3,120초와 공통 3,000초 운영 high-water, 독립 human voice-gain pack, canonical checker exit 0 · claude_cli↔Messages API 폴백 동일성 · NAS 실제 재부팅 자동복구 smoke · 백업 실패 알림·off-host 복제·restore drill · Phase 3 파일럿 게이트. REQ-001은 Gmail 인증 세션·관리자·복구 데이터 수락으로 완료했다. C-001은 <code>pending-valid</code> 기술 사전검증뿐이며 외부 검수 입력을 조작하지 않는다. 운영 원칙상 가짜 증거로 DONE 표기하지 않는다.</div>
</div>
<div class="tab-panel" id="panel-decisions" role="tabpanel" aria-labelledby="tab-decisions" hidden>
<div class="detail-head"><div><h3>윤찬 결정 상세 (전건 확정)</h3><p>람다가 단독으로 정하면 월권인 항목이었다. 2026-06-30 owner 결정으로 전건 확정됐고, 각 결정의 구현·실측은 후속 build/GATE로 남는다.</p></div><div class="mini-metrics"><span>결정 0개(전건 확정)</span><span>조건부 GO 사유</span></div></div>
@ -1281,7 +1292,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\start-public-runtime
<div class="detail-blocks">
<div class="detail-block"><h4>완료라고 말하는 기준</h4><ul><li>실제 로그인 기반 E2E 증거가 있어야 한다.</li><li>DB 쿼리나 로그로 저장/권한/비용이 확인되어야 한다.</li><li>시각 확인만으로 기능 완료를 선언하지 않는다.</li></ul></div>
<div class="detail-block"><h4>문서 기준</h4><ul><li><code>MASTERPLAN</code>은 원본 보존 문서다.</li><li><code>MASTERPLAN_REVISIONS</code>가 Phase/IRB/음성 정정 기준이다.</li><li><code>dev_dashboard.html</code>은 팀장 판단용 현재판이다.</li></ul></div>
<div class="detail-block"><h4>운영 원칙</h4><ul><li>mock/demo 자산은 운영 번들에서 제거한다.</li><li>ignored runtime 파일은 커밋하지 않는다.</li><li>서버는 목적별 1개씩만 유지한다.</li><li><code>frontenddesign</code> 스킬이 있으면 먼저 쓰고, 없으면 <code>DESIGN_CONCEPT</code>를 SSOT로 따른다.</li><li>UX는 <code>docs/DESIGN_CONCEPT.md</code>를 기준으로 하고, fake 기록/통계는 금지한다.</li><li>OAuth secret은 출력하지 않고 환경 파일/인프라 설정에만 둔다.</li></ul></div>
<div class="detail-block"><h4>운영 원칙</h4><ul><li>개발 체계는 <code>AGENTS.md</code> B절(AI 에이전트 오케스트레이션 바이블)을 따른다: 설계·작업 패킷·결과 평가·보고는 고급 모델 오케스트레이터가 직접, 조사·탐색·구현·검증 실행은 하위 모델 워커에게 병렬로 시킨다. 워커 보고는 증거가 아니며 오케스트레이터가 diff·결과 원문을 읽고 판정한 것만 이 대시보드 상태에 반영한다.</li><li>mock/demo 자산은 운영 번들에서 제거한다.</li><li>ignored runtime 파일은 커밋하지 않는다.</li><li>서버는 목적별 1개씩만 유지한다.</li><li><code>frontenddesign</code> 스킬이 있으면 먼저 쓰고, 없으면 <code>DESIGN_CONCEPT</code>를 SSOT로 따른다.</li><li>UX는 <code>docs/DESIGN_CONCEPT.md</code>를 기준으로 하고, fake 기록/통계는 금지한다.</li><li>OAuth secret은 출력하지 않고 환경 파일/인프라 설정에만 둔다.</li></ul></div>
</div>
</div>
</div>

View file

@ -447,6 +447,8 @@ session lifecycle을 유지하며, future Node read API는 이 read-model contra
제외한다. `growth.training_exposure`는 종료 회기만 집계하고 4회 미만이면 `insufficient`, 4회 이상에서
최다 페르소나 비중이 0.75 이상이면 `훈련 집중 주의`, 그 밖에는 `balanced`로 표시한다. 투명한 노출 비중이지
공정성·임상 진단이 아니다. 성취는 공식 등급/수료가 아니라 실제 연습 milestone만 표시한다.
- 대시보드 DTO와 builder는 `app/session_dashboard_projection.py`가 소유한다. 공통 세션 projection은
`app/session_projection.py`가 소유하며, 라우트는 조회·권한 경계만 조합한다.
- `GET /sessions/cases?persona_code=...` — 같은 NPC의 사례별 누적 회기·상담자에게 보인 턴·누적
회기 시간과 진행 중 회기를 DB 전체에서 집계한다. 런타임 추정값은 반환하지 않으며 DB read가 실패하면
503으로 닫아 UI가 이어가기를 열지 않는다. `GET /sessions/cases/{case_id}/memory`는 foldout을 연
@ -581,8 +583,16 @@ GET {ENGINE_URL}/ready|/health — readiness/liveness
- 영속/폴백 분기는 `app/runtime_policy.py`(`runtime_fallback_allowed` / `require_runtime_fallback_allowed`)가
게이트. 라우트는 `session_persistence`(DB) → 실패 시 store(in-proc) 순으로 시도한다.
- 회기 평가 저장은 `SessionEvaluationWrite``status/source/scope/stage/payload/error` write packet을
소유한다. `routes/sessions.py``routes/eval.py`는 같은 named packet을 만들어 저장하고, DB SQL과
fallback cache record는 `session_persistence.py`가 유지한다.
소유한다. `routes/sessions.py``routes/eval.py`는 같은 named packet을 만들어
`app/session_evaluation_repository.py`의 DB SQL·cache 경계로 넘긴다. 공통 정규화·마스킹 값은
`app/session_persistence_values.py`가 소유한다.
- outcome 계열 저장소의 canonical hash/value/public row/created role은
`app/services/outcome_repository_values.py`가 단일 소유한다. `practice_competency.py`는 공통 역량
분류만 소유하고 각 저장소의 기존 예외를 바꾸지 않는다. 인증 projection·admin 일별 mapper와 voice metadata
builder도 각 route/service 경계에 남긴다.
- text/voice 경로가 공유하는 `TurnMemory` 조립은 `app/session_turn_memory.py`의 순수
`build_turn_memory()`가 맡는다. recall summary·pinned facts·client-visible recent turns·KB behavior cues의
네 필드만 조립하며, time/cache/scenario fetch와 session lifecycle은 기존 경계에 유지한다.
### 2.12 페르소나 카탈로그 — `app/persona_repository.py`
@ -735,6 +745,13 @@ React 19 + Vite. 라우팅은 `apps/web/src/App.tsx`(react-router-dom).
- `App.tsx`는 모든 역할 페이지를 `lazy()`로 로드하고 공통 `Suspense` 부트 경계를 사용한다. 페이지별 순수 표시 계산은
`pages/*/model.ts`, 브라우저 음성 캡처는 `pages/session/voiceCapture.ts`, 페르소나 이름·난도·아바타 팔레트는
`lib/personaViewModel.ts`가 소유해 라우트 컴포넌트의 API/상태/렌더 책임과 분리한다.
- `pages/Session.tsx`는 회기 lifecycle과 API/상태 전이를 유지한다. 표시 타입·파생 계산은
`pages/session/sessionViewModel.ts`, 브라우저 캡처와 status 표시는 `pages/session/voiceCapture.ts`
소유한다.
- `lib/focusTrap.ts`는 Tab 순환과 DOM 조회만 소유하며 initial focus·Escape·lifecycle은 `Session`에 남긴다.
공통 동맹 scale은 `pages/session-review/allianceScale.ts`가 소유하고 `AllianceCheckpointPrompt`
`AlliancePulseCard`가 사용한다. 잠긴 자기점수 표시는 `AlliancePulseCard``LockedScores`, 관리자 승인
제출 제어는 `ContinuousImprovementCockpit``ApprovalSubmitControls`가 소유한다.
- Pages 배포 전환 중 열린 탭이 삭제된 lazy 청크를 요청하면 `lib/chunkRecovery.ts`가 Vite
`vite:preloadError`를 받아 현재 경로에서 문서 재로드를 1회만 수행한다. 정상 라우트 렌더 뒤 재시도 표식을
지우고, 같은 경로의 연속 실패는 무한 재로드하지 않고 `RouteErrorBoundary`의 수동 복구 액션으로 넘긴다.

View file

@ -143,7 +143,7 @@ DB·엔진 없이도 UI/로그인/페르소나/세션 생성까지는 동작한
## 1. 사전 준비
- **Python 3.11** (운영 스크립트가 Python 3.11 기준). 가상환경 권장.
- **Python 3.11** (운영 스크립트가 Python 3.11 기준). Windows의 기본 `python`이 다른 버전을 가리키거나 pytest가 없을 수 있으므로 `py -3.11`을 우선 쓴다. 가상환경 권장.
- **Node.js 22 + npm** — CI와 같은 기준. newer LTS는 별도 재검증 전까지 기준선으로 쓰지 않는다.
- (선택) **Docker Desktop**`infra/docker-compose.yml` 전체 스택을 띄울 때만.
- (선택) **`claude` CLI** — `claude_cli` 실행. 설치 후 로그인되어 있어야 한다.
@ -155,7 +155,7 @@ DB·엔진 없이도 UI/로그인/페르소나/세션 생성까지는 동작한
```powershell
# 저장소 루트에서
python -m venv .venv
py -3.11 -m venv .venv
.\.venv\Scripts\Activate.ps1 # PowerShell 활성화
python -m pip install -r apps\api\requirements.txt
```
@ -676,8 +676,8 @@ RAG 임베딩/리랭커 의존성은 기본 이미지에 설치하지 않는다.
```powershell
# 백엔드 (apps/api)
cd apps\api
python -m pytest app/ -q # 2026-08-28 전체 실행 1002 passed
python -m pytest engine_gateway\ -q # 2026-08-28 전체 실행 68 passed
py -3.11 -m pytest app/ -q # 2026-08-28 전체 실행 1002 passed
py -3.11 -m pytest engine_gateway\ -q # 2026-08-28 전체 실행 68 passed
# 웹 (apps/web)
cd apps\web

View file

@ -15,8 +15,8 @@ Vignette 저장소의 모든 검증 수단(백엔드 단위 테스트, 웹 타
| 검증 | 작업 디렉터리 | 명령 | DB | API(8000) | 웹(5173) | 엔진GW(9099) | 브라우저 | 현재 통과 |
|---|---|---|---|---|---|---|---|---|
| 백엔드 단위 테스트 | `apps/api` | `python -m pytest app/ -q` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | 2026-08-29 전체 실행 1074 passed / 1 skipped |
| 엔진 게이트웨이 테스트 | `apps/api` | `python -m pytest engine_gateway/ -q` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | 2026-08-28 전체 실행 68 passed |
| 백엔드 단위 테스트 | `apps/api` | `py -3.11 -m pytest app/ -q` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | 2026-09-07 전체 실행 1090 passed / 1 skipped / 1 warning |
| 엔진 게이트웨이 테스트 | `apps/api` | `py -3.11 -m pytest engine_gateway/ -q` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | 2026-09-07 전체 실행 71 passed / 1 warning |
| API 타입 생성 체크 | `apps/web` | `npm run check:api-types` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
| 웹 타입체크 | `apps/web` | `npm run typecheck` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
| 웹 빌드 | `apps/web` | `npm run build` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
@ -25,10 +25,16 @@ Vignette 저장소의 모든 검증 수단(백엔드 단위 테스트, 웹 타
핵심 원칙: **단위 테스트(pytest)와 타입체크/빌드는 외부 서비스 없이 단독 실행된다.**
**E2E만 풀스택(DB+API+웹+브라우저)을 요구한다.** 아래 각 절에서 근거와 절차를 설명한다.
2026-09-07 앱 전체 실행의 1 skipped는 기존 선택형 격리 DB 테스트
`test_admin_usage_postgres.py``VIGNETTE_USAGE_TEST_DATABASE_URL` 미설정일 때 건너뛰는 경우다. 새 skip은
아니며, warning 1건은 통과 수와 분리해 기록한다.
---
## 1. 백엔드 단위 테스트 (pytest)
Windows에서 기본 `python`이 Python 3.11이 아니거나 pytest를 갖지 않을 수 있다. 이 저장소의 기준은 `py -3.11`이며, 새 가상환경은 `py -3.11 -m venv .venv`로 만든 뒤 활성화한다. 특정 사용자 절대경로를 문서나 명령에 넣지 않는다.
### 1.1 대상과 구성
- 작업 디렉터리: `apps/api`
@ -42,15 +48,15 @@ Vignette 저장소의 모든 검증 수단(백엔드 단위 테스트, 웹 타
```sh
# apps/api
python -m pytest app/ -q # 앱 단위 테스트: 2026-08-29 전체 실행 1074 passed / 1 skipped
python -m pytest engine_gateway/ -q # 게이트웨이 단위 테스트: 2026-08-28 전체 실행 68 passed
py -3.11 -m pytest app/ -q # 앱 단위 테스트: 2026-09-07 전체 실행 1090 passed / 1 skipped / 1 warning
py -3.11 -m pytest engine_gateway/ -q # 게이트웨이 단위 테스트: 2026-09-07 전체 실행 71 passed / 1 warning
```
수집만 빠르게 확인하려면:
```sh
python -m pytest app/ --collect-only -q
python -m pytest engine_gateway/ --collect-only -q
py -3.11 -m pytest app/ --collect-only -q
py -3.11 -m pytest engine_gateway/ --collect-only -q
```
수집량은 위 명령의 현재 출력으로 확인한다. `--collect-only` 숫자는 통과 수가 아니므로 실행 결과와 섞어
@ -80,7 +86,7 @@ python -m pytest engine_gateway/ --collect-only -q
#### 개선관리 워크북 focused 증거 (2026-08-27~28)
아래는 전체 API 1074 passed/1 skipped·gateway 68 passed와 별도로 해당 계약을 좁혀 실행한 확정 증거다. 같은 묶음의 테스트가 여러 요구사항
아래는 2026-09-07 전체 API 1090 passed/1 skipped/1 warning·gateway 71 passed/1 warning와 별도로 해당 계약을 좁혀 실행한 확정 증거다. 같은 묶음의 테스트가 여러 요구사항
경계를 함께 검증할 수 있으므로 숫자를 요구사항별 전체 합계로 더하지 않는다.
| 항목 | focused 명령/파일 | 확인 결과 |
@ -305,7 +311,9 @@ VITE_API_BASE=http://127.0.0.1:8000 npm run e2e # 프록시 대신 API
- `npx playwright test`를 직접 실행하면 로컬에서 세 프로젝트가 같은 worker pool에 섞일 수 있다.
전체 게이트는 반드시 `npm run e2e`, focused DB/engine 게이트는 프로젝트와 worker를 명시한
`npx playwright test --project=chromium-single-run --workers=1 --grep "..."`을 사용한다.
- 실패 시 trace(첫 재시도)·스크린샷·비디오를 `node_modules/.tmp/`에 남긴다.
- 기본 캡처는 `testInfo.outputPath(...)``node_modules/.tmp/` 아래에 남긴다. 실패 시 trace(첫 재시도)·스크린샷·비디오도 같은 임시 결과 경로를 사용한다.
- 날짜가 붙은 `docs/ops/evidence/` 증거는 명시적 검토·승격이 있을 때만 이 임시 산출물에서 복사한다. 자동 보존하지 않는다.
- `E2E_EVIDENCE_DIR`를 명시하면 `self-directed-learning-loop` 캡처만 해당 디렉터리에 현재 파일명으로 export한다. 날짜·승격 의미를 자동 부여하지 않는다.
### 3.6 실측 테스트 개수 (현재)

View file

@ -0,0 +1,77 @@
# 에이전트 작업 패킷·판정 기록 양식
> [`AGENTS.md`](../../AGENTS.md) **B절(오케스트레이션 바이블)** B.5·B.6의 실행 양식이다.
> 오케스트레이터(Codex Astra/Sol, Claude Code Fable 5.1/Opus 5)는 워커를 스폰할 때 §1 패킷을 채워 넘기고,
> 워커가 끝나면 §2 판정을 기록한다. **패킷 없이 스폰하지 않고, 판정 없이 수용하지 않는다.**
## 1. 작업 패킷 (오케스트레이터 → 워커)
```text
[작업 패킷] <제목>
워커: researcher | explorer | implementer(worker) | verifier (모델은 항상 하위 티어)
1. 목표:
비목표:
2. 확정 설계·계약·불변량 (바꾸면 안 되는 것):
3. 건드릴 파일·모듈 경계:
건드리면 안 되는 영역:
4. 완료 기준(DoD):
검증 명령 (AGENTS.md B.10 표준 세트에서 선택):
5. 보고 형식: 변경 파일 / diff 요약 / 실행 명령과 결과 원문(통과·실패 수) / 미해결·의문점 / 설계 변경 요청 여부
6. 제약:
- 다른 에이전트 스폰 금지 · 설계 변경 금지(필요하면 멈추고 보고)
- fallback · silent catch · mock · 테스트 스킵 · 조건 완화로 통과 위장 금지
- 임시 주석 · 디버그 출력 · TODO 금지
- 커밋/push: 금지 | 허용 범위: <...>
- 병렬 워커 경계: <다른 워커가 맡은 영역>. 그 변경을 되돌리지 말 것
7. 저장소 공통: AGENTS.md §0(OS·PowerShell 5.1) · §2(검증 기준) · §3(한글·커밋 문구) 준수
```
조사(researcher) 패킷은 2·3 대신 **조사 질문 목록, 출처 우선순위, 확인 날짜 표기 요구, "미확인" 표기 규칙**을 넣는다.
검증(verifier) 패킷은 **실행할 명령 그대로, 작업 디렉터리, 기대 산출물(로그 경로)**만 넣고 판정을 요구하지 않는다.
## 2. 판정 기록 (오케스트레이터)
```text
[판정] <패킷 제목> — 수용 | 반려 후 재작업 | 폐기 후 재배정/직접 수행
(a) 패킷·확정 설계 준수 / 범위 이탈:
(b) 정확성·회귀 위험 (diff를 직접 읽었는가):
(c) 검증 증거 진위 (명령·결과 원문 확인, 스킵·우회·fallback·삼킨 오류 여부, verifier 재실행 여부):
(d) 노이즈 (임시 주석·디버그·TODO·무관 변경):
(e) 워커 의문점·설계 변경 요청에 대한 답:
반려 시 수정 지시 (패킷에 추가):
SSOT 반영: dev_dashboard.html / TODO.md / 핸드오프 중 갱신한 곳과 check-dev-dashboard-ssot 결과:
```
## 3. 병렬 스폰 체크리스트
- 조사는 주제·출처·기간별로 나눠 researcher 여러 개를 **한 번에** 스폰한다. 순서대로 하나씩 돌리지 않는다.
- 구현은 파일·모듈 경계가 겹치지 않게 나눈다(기본: `apps/api``apps/web``docs`/`scripts`). 겹치면 순차로.
- 구현 워커의 검증 보고가 의심되면 verifier에게 같은 명령을 다시 실행시켜 원문을 받는다.
- 오케스트레이터 모델(Astra/Sol/Fable/Opus)을 워커로 쓰지 않는다. Claude Code `fork`는 부모 모델을 상속하므로 대량 작업에 쓰지 않는다.
- 같은 워커가 같은 이유로 2회 실패하면 워커가 아니라 패킷(설계·지시·완료 기준)을 먼저 의심한다.
## 4. 짧은 예시
```text
[작업 패킷] 회기 종료 API 409 응답에 active_session_id 필드 추가
워커: implementer
1. 목표: POST /sessions 409 `active_session_exists` 응답 본문에 `active_session_id`(UUID) 포함.
비목표: 409 조건 자체·row lock 순서·프론트 UI 변경.
2. 불변량: 응답 코드·에러 코드 문자열 유지. 기존 필드 삭제·이름 변경 금지. migration 없음.
3. 경계: apps/api/app/routers/sessions.py, apps/api/app/test_sessions*.py 만. apps/web 금지.
4. DoD: 신규 테스트 1건 이상 추가, `cd apps/api && python -m pytest app/ -q` 전체 통과.
5. 보고: 변경 파일 / diff 요약 / pytest 결과 원문(passed·failed 수) / 의문점.
6. 제약: 스폰 금지 · 설계 변경 금지 · 스킵/완화 금지 · 커밋 금지 · 병렬 워커 없음.
7. AGENTS.md §0·§2·§3 준수.
```
```text
[판정] 회기 종료 API 409 응답 필드 추가 — 수용
(a) 경계 내 2파일만 변경, 범위 이탈 없음.
(b) diff 확인: 응답 dict에 필드 1개 추가, 기존 경로 영향 없음.
(c) pytest 원문 1075 passed / 1 skipped 확인, 스킵은 기존 1건과 동일.
(d) 노이즈 없음.
(e) 의문점 없음.
SSOT 반영: TODO REQ-007 행에 필드 추가 사실 1줄, check-dev-dashboard-ssot 통과.
```

View file

@ -1,244 +1,77 @@
# Vignette 백로그 — 열린 항목만 (현행본)
# Vignette 백로그 — 열린 실행 항목
> **SSOT는 `docs/dev_dashboard.html`이다.** 상태·검증 증거·결정·로드맵의 권위 기준은 대시보드다.
> 이 파일은 대시보드와 일치하는 **아직 열린 작업(외부/환경 게이트)** 만 얇게 유지한다.
> 완료된 작업의 변경 로그·검증 기록(구 "최신 동기화 추가"·"이번 세션에 닫은 것"·B1~B3 `[x]` 상세)은
> `docs/archive/ops/backlog-2026-06-26-full-history.md`로 냉동 보관했다. 필요할 때만 이력용으로 본다.
>
> 운영 원칙: 가짜 증거로 DONE 표기 금지. 실증 불가·외부 의존·소유자 결정 항목만 여기서 추적한다.
> 분류: **B1 비차단 폴리시** · **B2 환경 제약(증거 생산 불가)** · **B3 소유자 결정** · **B4 외부 거버넌스**
> 상태와 증거의 SSOT는 [`docs/dev_dashboard.html`](../dev_dashboard.html), 실행 인덱스는 [`docs/TODO.md`](../TODO.md)다.
> 2026-09-07 이전 완료 기록과 장문 이력은 [정리 전 백로그 원문](../archive/project-cleanup-2026-09-07/backlog-2026-06-26-before-cleanup.md)에 보존했다.
> 2026-09-01 NAS 배포는 당시 [배포 증거](./evidence/nas-prod-deploy-2026-09-01.json)로 완료 기록을 남겼으며, 현재 live 상태 주장이나 후속 게이트 완료 선언이 아니다.
> **B0. 원천문서 갭** — 한신대 산학협력 원천문서 갭(critical/high/medium)은 SSOT 대시보드 "원천문서 갭 분석"
> 섹션과 상세 근거 `docs/ops/source-docs-gap-analysis-2026-06-26.md`에서 추적한다. C1~C3/H·M 구조는 코드로
> 선제 구축했고, 임상 문안·평가기준·골든셋 콘텐츠는 임상팀(구훈정·어유경) 외부 정의로 받는다.
>
> **개선관리 워크북(2026-08-29)** — C-002·C-003과 REQ-001~007은 완료다. REQ-001은 기존 Gmail 인증 세션의
> 로그인 이메일·관리자 권한·온보딩 비전환·복구 데이터 가시성을 소유자가 수락해 완료로 승격했다. REQ-008은 recovery task
> 계약과 5174 자동복구까지 완료했지만 실제 Windows 재부팅 smoke가 남아 `검토`다. C-001은
> v2 합성 사례 6건·상태별 canonical 판정기와 자동
> 안전 게이트의 기술 사전검증까지 완료됐다. 사례별 5필드 30개·청소년 답변 6개·검토자 이름/소속/자격을 포함한
> 승인 10필드와 서명 증거가 없어 B4 외부 GATE로 유지하며, `review_complete=true` 전에는 완료로 닫지 않는다.
> 현재 개선관리 집계는 **완료 9·검토 2·총 11**이다. C-001 외부 입력 46칸은 조작하지 않는다.
>
> **2026-08-29 최신 운영 상태(이 PC 로컬 기준)·2026-08-31 재실측 정정** — 당시 이 PC 로컬 API 8001은 연결 거부였고 이 PC의
> Docker daemon 부재로 이 PC 기준 DB/API는 복구 불가였다(이 PC는 개발 워크스테이션). <b>2026-08-31 재실측:</b> public 도메인
> `https://api-vignette.chanpaca.net/health`는 별도 호스트(Cloudflare 뒤)에서 200·`environment=prod`·`db=true`·`engine=true`
> 정상 서빙 중이며, 이 PC에는 cloudflared 프로세스가 없어 public 도메인은 이 PC가 아니다. 즉 2026-08-29의 OUTAGE 표현은
> 도메인 장애가 아니라 이 PC 로컬 상태였으며 오늘 실측이 이를 대체한다. 배포된 동적 runtime/task 기준선은 old `dba9b75a…`, Pages는
> `0c60261e…`(source `5bf89ff…`)다. avatar union은 93 objects·52,973 bytes·SHA-256 `9d703126…e6aa`, decode
> 정상 3/실패 90, 현 DB 참조 정상 2/실패 6/missing 0이다. fail-closed 후보와 deployed baseline을 분리하며,
> 전체 회귀·아바타 실브라우저 fallback·G8 실DB/public 증거·사용자 배포 승인 전에는 운영 전체를 GREEN으로 올리지 않는다.
>
> **Outcome & Alliance OS** — 2026-08-06 정식 전략 실행 트랙으로 승격했다. G0 Measurement Truth, G1
> 현재 소스·실행 증거 재감사에서는 G0~G6과 G8이 DONE이다. G1 승격 prompt 1.2+read-skew/JSON 복구는 24/24 ready·방향 9/9·오류 0을 재확인했고, G0 census 29/29·위반 0, G4/G5 실제 API/DB/브라우저 폐루프, G6 safety metadata-only 최우선 runtime을 disposable clone에서 확인했다. G8은 실제 receipt-bound image rollback 2회(`nas-g8-723eeef2…`/`nas-g8-2738846c…`)에 더해 source HEAD `61a41d1f…6af`·tree `87dec55d…3b77`·archive `4d15d055…119d4d`의 candidate 112/112와 실제 NAS 평문 origin 112/112를 통과했다. 과거 `6030a677…c611`의 UUID 24건 실패와 후속 SHA 결함 rollback은 이력으로 보존하며 현재 완료 증거로 재사용하지 않는다. G7 Multimodal Alliance는 내부 구현 DONE과 외부 proof GATE를 분리한다. detached-clean public `a73bcd24…`·OpenAPI 126·`local_whisper`/`melotts` ready·authenticated WSS 무마이크 rehearsal까지 완료했고, 명시 동의 물리 마이크 3,120초·독립 라벨 voice-gain benchmark·동시 topology high-water를 추적한다. 외부 Deepgram/OpenAI adapter는 fallback으로 보존한다. G0~G8과
> AOS-001~012는 `docs/TODO.md` I절에서 전건 추적하고, 상태는 SSOT 대시보드의 9개 계획 카드가 소유한다.
> 이 얇은 백로그에는 그중 외부·환경 증거가 필요한 항목만 기존 B2/B4/Phase 3 게이트와 합쳐 유지한다.
>
> **2026-08-31 재검증(목표 마무리 기록)** — 아래 열린 항목 전건을 SSOT 대시보드(2026-08-29/30)·최신 핸드오프(`ops/handoff-goal-production-2026-08-29.md` §0)·실제 환경과 교차 재확인했다. 실증 게이트가 남아 있어 **가짜 증거로 DONE 체크하지 않는다**(운영 원칙).
> **운영 인프라 정정(중요):** 정식(production)은 `infra/docker-compose.nas.yml`**NAS `vignette-prod` 스택**(`ssot-host: nas`·`runtime-class: production`)으로 분리 운영 중이다. 2026-08-31 실측으로 public 도메인 `https://api-vignette.chanpaca.net/health`가 200·`status=ok`·`db=true`·`engine=true`·`environment=prod`·`engine_mode=openai`로 응답하는 반면, **이 Windows PC에는 cloudflared 프로세스가 없고 로컬 API `8001` listener도 없다** — 즉 public 도메인은 이 PC를 지나지 않는다. 이 PC는 개발 워크스테이션(과거 자택 public 호스트였던 경로의 잔여 상태: Docker `vignette-dev-db:55432` Up·로컬 엔진 `9099` LISTENING·`VignettePublicRuntime`/`Watchdog` 2개 Scheduled Task Disabled)이다. 따라서 아래 항목 중 이 PC의 `8001`/task/재부팅 관련 게이트(B2)는 **정식(NAS)과 분리된 이 PC/개발 런타임 기준**으로 읽어야 하며, public 도메인 장애를 뜻하지 않는다. push·Pages 배포·public runtime/task 변경은 별도 승인 전 미실행.
> 실제 닫힘 판정은 이 PC 기준이 아니라 **정식 운영 주체(NAS `vignette-prod` ingress·DB·도메인) 기준**으로 해야 하며, 이 PC에서 NAS ingress를 직접 실측할 수 없어 별도 확인이 필요하다. 2026-08-31 추가로 **public 도메인 읽기 전용 실브라우저 폐루프**(Playwright chromium: 로그인 화면 1 passed·데스크톱/모바일 200·Google OAuth redirect_uri 확인·dev-login 차단·`/auth/me` 401 외 에러 0)를 실증했다(증거 <code>ops/evidence/public-domain-readonly-browser-2026-08-31.json</code>) — 이는 public 도메인 정상 서빙의 추가 증거이며, 실제 Google 계정 로그인→callback→관리자/P20 실회기·턴 생성(production DB mutation)은 소유자 승인·계정 세션이 필요해 여전히 열린 게이트다. `public API/DB·아바타 정식 승격`, `G8 실DB/public 사람 게이트`, `음성 캐스케이드 live`, `DB 백업 운영화`, `vnet.18ka.net live`, `claude_cli↔Anthropic API live`, `Compose infra/.env`, `B1 티켓 자동 분류`, `C-001`, `한신대 거버넌스`, `L1 등재`, `Phase 3`은 전부 열린 게이트로 유지한다. 참고: `공개 DB 계정·회기 복구 안정화``stable-source 재부팅 후 watchdog smoke`는 사실상 **같은 잔여 게이트(실제 Windows 재부팅 후 자동복구 smoke)**를 추적 중이다 — 재부팅 smoke가 닫히면 두 항목과 REQ-008이 함께 닫힌다.
## 신규 제품 이슈 — 워크북 원본 보존형 접수
---
> 원본: [개선관리 워크북](https://docs.google.com/spreadsheets/d/1MNRDfXoIQJbLzIcSRdHnQHREWELbKqV4/edit?gid=351278076#gid=351278076) `개발·기능 이슈` 행 13~15, 2026-09-07 수집 SHA-256 `625281e72067ae2aea402f2fe932b4f593b7905c35f8a9740cf346c7f077c812`. 원본 14건은 완료 9·기존 gate 2·신규 3으로 로컬 분류한다. 외부 원본의 기존 대기와 로컬 DONE 불일치는 동기화 대기이며, 원본 문서를 이 백로그 작업으로 수정하지 않는다.
## B1. 비차단 코스메틱/후속 폴리시
| ID | 원본 사실 | 조치 및 결과 | 완료 조건 | 검증 증거 |
|---|---|---|---|---|
| REQ-009 | 중요·대기. 상담 중 “엔진 연결 - 내담자 응답을 생성하지 못했습니다. 입력 내용은 복원했습니다.” 뒤 진행 불가. | 스트림 중단 오류 시 입력 복원, 부분 턴 미저장 안전 보장, 인라인 재시도 및 닫기 버튼 추가, voiceStatus idle 복원 완료. | 입력 복원, 부분 턴 미저장, 동일 회기 재시도 성공 증명. | 단위/Playwright 통과 및 실서버 헤드풀 E2E 검증. [headful-02](./evidence/headful-02-session-multi-turn.png) |
| REQ-010 | 중요·상태 공란. 시작 직후 창닫기 회기가 완료 회기로 카운트될 우려와 잘못 생성한 미완료 회기의 삭제/재시작 요청. | 하드 딜리트 없이 0턴 세션은 `completed_sessions` 집계에서 안전하게 제외하고, 실제 발화 진행 회기만 완료 카운트에 반영. | 무턴 중단 제외, 정상 완료 반영, 기존 데이터 보존 검증. | 헤드풀 브라우저 2턴 완료 시 +1 및 0턴 즉시 종료 시 유지 검증 완료. [headful-05](./evidence/headful-05-learner-home-after-completed.png), [headful-06](./evidence/headful-06-learner-home-zero-turn-verified.png) |
| REQ-011 | 일반·상태 공란. 첫 자기예측 이유 입력 뒤 버튼 오류로 저장·리뷰 완료·다음 회기가 막힘. 워크북 M15의 [연구팀 첨부 수집본](./evidence/req011-prediction-error-2026-09-07.png)은 HTTP 422 `prediction revision violated provenance or history invariants`를 보인다. | OAS G5 자기예측 시 legacy instrument ID 정규화 및 scoped block ID 일치화로 HTTP 422 해소. 1차 예측 저장 및 잠금 완료. | 정상 저장, 중복 방지, 리뷰 진행·다음 회기 가능, 평가 공개 순서를 함께 증명. | 헤드풀 브라우저 리뷰 피드백 탭에서 1차 예측 저장 및 잠금 완료. [headful-04](./evidence/headful-04-review-calibration-locked.png) |
| SRC-001 | 원본 워크북 정합성 OPEN. `요약!B16:B19``COUNTIF(#REF!)`, 개발 상태 집계 범위 `I5:I14`가 REQ-011 행 15를 놓침. | 개선관리 워크북 replica 수식 (#REF! 제거, 범위 `$I$5:$I$15`, `$B$5:$B$15`) 보정 완료. | 요약 수식·상태 집계가 14건을 반영하고 로컬 분류와 차이를 설명. | 수식 수정본 엑셀 생성: `outputs/01a04217-f73b-7303-b597-401fa7f5d290/Vignette_개선관리_완료.xlsx` |
- [ ] **운영 티켓 자동 분류·처리 후속** — Claude Recipe headless 자동 수정 후보, 관리자 승인 후 이슈 등록·PR/작업
스레드 생성, 처리 결과 audit trail 확장은 아직 설계/승인 필요. 담당 그룹 자동 배정·우선순위 escalation·raw/rollup
보존기간 같은 운영 정책은 B3에서 이미 결정됨(수동 승인·미도입 고정). 자동 수정은 운영자 승인 전까지 실행하지 않는다.
> B1 완료 항목(학생 리뷰 1~5 척도 320px 반응형 재배치, 셸 구분선, 세션 종료 UX/다크테마, 아바타 SVG 리그 복귀와 래스터 비활성화, SEO/공유 카드, 레이아웃 정렬, 권한 위임,
> 메일링, 아카이브 API, TTS voice map, 빈상태 레이아웃, 평가 실패 복구 UX, SSE 저장, live-coach 표면화,
> 음성 메타, 위기 게이트 UI, 운영 티켓/헬스 샘플러 등)의 상세·검증 로그는 아카이브 full-history 참조.
REQ-009 초기 검증 명령:
---
```powershell
cd apps/api
py -3.11 -m pytest app/test_session_turn_persistence.py -k 'stream and (incomplete or engine_error or clean_eof)' -q
cd ../web
npx playwright test e2e/uc-session-conversation.spec.ts --grep '스트림 도중 오류|done 없이' --project=chromium-desktop --workers=1
```
## B2. 환경 제약 — 이 워크스테이션에서 증거 생산 불가
2026-09-07 Python 3.11 source-only 묶음은 `test_session_turn_persistence.py`, `test_calibration_transfer_store.py`, `test_session_continuity_guard.py`에서 78 passed와 `PendingDeprecationWarning` 1건이었다. 이는 REQ-009~011의 재현·원인 해결·운영 증거가 아니다.
- [ ] **public API/DB·아바타 정식 승격** — Docker/API 장애를 복구하고, 손상 아바타는 private forensic으로 보존하되
public 404/fallback, decode-valid 객체만 immutable cache에 제공하는 후보를 전체 API/Web/PowerShell·내장 브라우저 E2E로
검증한다. exact 93 objects·52,973 bytes·`9d703126…e6aa` receipt와 후보 commit SHA를 고정한 뒤 push/Pages/runtime/task/
upload-root 범위를 명시 승인받는다. 신규 UUID 업로드의 URL 기록·현재 참조·GET/HEAD도 full decode하며 사후 손상은 current
invalid/health fallback과 404로 닫는 후보까지 API 1074/1 skip을 통과했다. Docker engine OFF + 활성 public watchdog 때문에
풀스택은 watchdog 일시 비활성화와 고유 56432/58000/55173 격리 자원·exact cleanup을 승인받아야 한다. DB avatar URL 삭제·
파일 초기화·task/runtime 재시작을 선행하지 않는다.
- [ ] **G8 사람 게이트 실DB/public 실증** — 승인·보류(`keep_quarantine`)·반려(`reject`)와 사유 입력, 증거 미충족 승인
차단은 typecheck와 route-fixture desktop/mobile 10/10을 통과했다. Codex 내장 브라우저의 격리 local stateful fixture도
8.5초 overlay 0·heading 정상, content 보류와 증거 없는 release 반려 effect 0, 증거 4종 promote 승인 lifecycle effect 1로
GREEN이다. 남은 것은 사용자 행동 시점 확인 뒤 실행할 실DB append-only 결정과 public proof다. local fixture는 이를 대신하지 않는다.
- [ ] **공개 DB 계정·회기 복구 안정화** — 2026-08-07 18:26 KST owner 승인으로 recovered named volume을
`vignette-dev-db:55432`에 전환했다. cutover owner 집계 users 84, sessions 30, turns 705, Google 계정 16,
Google 소유 회기 30, orphan 0이며 health·engine·OAuth·watchdog가 정상이다. 기존 인증 세션은 복사하지
않아 사용자는 Google 재로그인이 필요하다. current dump·old-original dump·전환 직전 rollback container를
모두 보존하며, 실제 사용자의 소유 회기 확인과 자동 백업 운영화 전에는 제거하지 않는다.
2026-08-09 Docker Desktop 중단과 18:02:55 KST Docker Desktop UI의 active DB 직접 stop으로 `db=false`
재발했지만 동일 recovered container/volume만 재기동했다. 현재 owner read-only 집계 users 356/sessions 193/
turns 726, Google users 16/Google-owned sessions 31이고 public db/engine true다. 2026-08-09 fresh dump
`vignette-dev-db-vignette-20260809-122235Z.dump` SHA `f1fd569c…5f64`를 보존했다. 공개 API/cloudflared는
detached-clean `a73bcd24…`·tree `b02b3a1b…`로 승격했고 OpenAPI 126·local voice exact·receipt
`47388d58…9b08` passed를 확인했다. watchdog·로그온 boot도 같은 stable root의 commit/tree/script SHA에 pin해
명시 실행 결과 0·Ready를 확인했다. 실제 Windows 재부팅 후 자동복구 smoke는 남아 있다.
2026-08-12에는 휴면 Gradle daemon의 `8001` 점유·cloudflared/voice sidecar 중단과 PowerShell 5.1 native stderr
조기 종료 결함을 복구했다. fresh dump `9cbda31e…0ce5`(7,532,625 bytes·TOC 1,492)를 먼저 고정하고
detached-clean `a73bcd24…`·tree `b02b3a1b…`로 fresh 승격해 receipt `47388d58…9b08`, public health 3/3,
OpenAPI 126·local voice exact와 watchdog/boot result 0을 재확인했다.
2026-08-29에는 Windows PowerShell 5.1의 timed `WaitForExit()`가 process handle 선확보 없이는 성공한 web
build의 `ExitCode`를 null로 남기는 오판을 제거했다. 실제 PS5.1 계약 32 passed, detached-clean
`44b7835c…`·tree `06133249…`에 boot/watchdog을 동일 핀으로 재등록했고, 5174 자동복구·HTTP 200,
`LastTaskResult=0`·failcount 0, local/public health `status=ok·db=true·engine=true`를 확인했다. 실제 Windows
재부팅 smoke와 PC 비종속 상시 호스트 이전은 계속 열린 환경 게이트다.
상세: `docs/ops/public-db-recovery-rehearsal-2026-08-07.md`.
- [ ] **DB 백업 운영화** — 검증형 one-shot custom dump와 실제 일회용 restore drill은 통과했다. 예약 실행,
실패 알림, NAS/off-host 암호화 복제, restore drill 주기와 보존 정책을 운영 게이트로 남긴다.
- [ ] **`vnet.18ka.net` 공개 live 전환** — 코드/스크립트는 `vnet.18ka.net`·`api-vnet.18ka.net` 허용까지 갱신됨.
남은 블로커: (1) `vnet.18ka.net`/`api-vnet.18ka.net` DNS A record 부재, (2) cloudflared 현재 인증 컨텍스트가
`18ka.net` zone이 아니라 `chanpaca.net` zone에 route dns 생성, (3) Google Console redirect URI
`https://api-vnet.18ka.net/auth/callback` 등록 필요. 현재 prod API 8001은 `db:true`/`engine:true`로 건강.
상세: `docs/ops/tailscale-vnet-runtime-2026-06-27.md`.
- [ ] **Compose 패키징용 `infra/.env` owner-secret fill-in** — 현재 production direct-runtime은 서로 다른 G3~G8
내부 토큰 6개, 필수 운영키, DB `current_user=vignette` non-owner app-role preflight와 공개 health를 통과했다.
향후 Compose 패키징 전환 시 `APP_DB_PASSWORD` 등 패키징 비밀과 prod-safe engine/voice flags를 별도 주입하고
compose profile preflight를 다시 닫는다.
- [x] **공개 Google OAuth 실제 `/turn` proof** — 2026-08-28 기존 Google 세션 callback→관리자 진입과 공개 P20
회기 생성→자기점검 잠금→실제 학습자/AI 내담자 턴→종료→리뷰·평가→다음 회기 버튼 재노출을 브라우저로
확인했다. 신규 Gmail 선택은 별도의 Vignette 계정 생성 행동시점 확인 항목이다.
- [ ] **음성 캐스케이드 live** — 공개 G7 API route와 TLS 1.3 WSS handshake, 비인증 1008 차단, 로컬
synthetic short soak는 통과했다. Deepgram/OpenAI fallback adapter와 운영 기본 `local_whisper`/`melotts`의 interim/final/word timestamp, HMAC word pseudonym,
streaming 중 1초 동의 재검사, 공통 consent transaction lock, bounded queue/10MiB cap, 텍스트 보존·음성 재연결
UX, `ready` provider/model 계약은 code/internal 완료다. actual-Postgres 동시 철회는 writer 대기→커밋 뒤 차단과
timeline row 0, synthetic gold benchmark는 text-only 1-MAE `0.9533`→multimodal `0.9673`을 확인했다.
public runner의 preflight는 auth cookie·소유 회기로 WSS ready/ping과 기대 STT/TTS provider/model 네 값을
마이크 열거·캡처 없이 검사한다. full soak는 `--confirm-physical-capture` 없이는 장치를 열기 전에 exit 3이다.
Session 첫 음성 사용은 30일·원음 미보존 서버 동의 원장을 먼저 기록하며, 성공 전 `getUserMedia`와 voice
WebSocket은 0회다. public runner v4의 turn별 interim/speech-final/latency, 관리자 single-worker runtime
high-water, exact image/container/cgroup/proc/Docker/TCP topology sampler, held-out 30명 외 calibration split
참가자를 포함한 총 최소 31명/held-out 50회기/150축 독립 blind human-held-out voice-gain evaluator와 이를
같은 public host·동시 50분 시간창으로 묶는 canonical checker까지
code/internal 준비를 마쳤다.
독립 평가 입력은 `prepare-g7-human-voice-gain-intake.py --create-template`의 명시적 non-evidence 빈 틀로 시작하고,
`--compile ... --out ...`가 비식별 CSV의 production floor·ICC·κ·gain·CI를 재계산해 모두 통과할 때만 pack을
생성한다. 이 작성 도구는 준비됐지만 실제 참가자·독립 평가자 데이터는 아직 수집되지 않았다.
운영 TTS는 MeloTTS Korean(MIT) loopback으로 전환했고 외부 OpenAI adapter와 회기 전·중 AI 생성 음성 고지는
보존했다. clean public runtime·`local_whisper`/`melotts` exact ready·authenticated WSS의 30초 무마이크 rehearsal은
voice/runtime/topology 각 7 samples로 통과했다. 남은 범위: 실행 직전 장치 선택·명시 동의 물리 마이크와
3,120초 양방향 soak, 독립 라벨 held-out/pilot voice-gain benchmark,
같은 실행의 실제 worker·Uvicorn·OS·Cloudflare edge RSS/CPU/queue high-water. 네 artifact가
`scripts/check-g7-external-proof.py` exit 0을 만들기 전에는 닫지 않는다.
current source의 `start-public-runtime.ps1`는 sidecar-before-API lifecycle, exact
`local_whisper/small/cpu-int8`·`melotts/melotts-korean` readiness, provider env, Uvicorn
`--ws websockets --ws-max-queue 4`를 fail-closed로 연결했다(launcher/sidecar 80/80, API voice 71/71).
공개 topology용 Windows host mode도 PID/start/exe·command SHA/cwd, RSS/CPU/handles/threads,
listener owner/conflict와 TCP high-water를 검증한다. 공개 runtime은 detached-clean `a73bcd24…`에서 receipt-bound로
실행 중이고 task source pin도 result 0이다. 물리 마이크 창은 사용자 장치 선택·명시 동의 전에는 열지 않는다.
코드 P0는 완료됐다. runner exit는 canonical checker `exit 0`/`gate_closed=true`에 결속되고, browser Origin은
API/WSS/admin/topology host·scheme과 분리 검증되며, 실제 capture 최소 3,120초와 세 artifact 공통 overlap
`≥3000s`를 강제한다. Windows topology는 detached-clean HEAD/tree, runner/collector/checker SHA와 exact
`psutil==6.1.1`까지 pin한다. fresh launcher는 legacy API/exact-config cloudflared를 bounded 교체해 새
PID/start/exe/command SHA/cwd safe receipt를 만든다. current runtime/checker 6파일 94/94, intake 6/6,
standalone validator 4/4를 합친 104/104를 통과했다.
무마이크 rehearsal은 완료됐지만 물리 마이크·human pack을 대체하지 않는다.
격리 NAS 프리뷰 `http://100.116.83.60:8088`은 전용 Compose 프로젝트·포트·네트워크·볼륨에 배포했고, 실제 브라우저 회기와
review API 저장 축어록 2턴을 확인했다. 기존 프로젝트 중단·재생성 명령은 실행하지 않았다. 매시간 `Vignette 앱 상태·회기 E2E 정기 검증`
heartbeat(automation id `vignette-e2e`)는 ACTIVE이며 공개/NAS 화면·health는 읽기 전용으로 보여준다. NAS 112건은 실 API/DB 22와 route fixture 90으로 영수증에서 분리한다. 동일 학습자 SSE→review→G4/G5 actual 폐루프 runner의 clean HEAD/tree·보호 포트 거부·sentinel·exact cleanup과 unit 14/14는 완료됐다. 최초 세 full run은 하네스, 4차는 reload ledger hydration, 5차는 fixture 공유, 6차는 mobile success-state locator를 fail-closed했고 7차 clean `aa81af29…`가 첫 전체 GREEN을 만들었다. `94666192…``f97e7fad…`의 DB init 실패는 두 번째 receipt 로그로 detached `99_app_role.sh` CRLF와 `bash\r` exit 127이 원인임을 확정했다. `.gitattributes``*.sh text eol=lf`로 이를 닫았다. 공통 톱바 desktop/mobile 44px을 포함한 clean `b02bee26…`·tree `8f677bba…`의 최신 single full run은 487.3초 GREEN이다. receipt `periodic-learner-e2e-20260812-171547.json`·SHA-256 `8c11a136…af8e3`은 same-learner SSE·review·G4/G5 `0→1→1`, returned desktop/mobile 4/4, route mock 0, public/NAS/active DB 접촉 0과 cleanup 0을 증명한다. heartbeat는 이 GREEN이 6시간 이상 오래됐거나 material milestone이 바뀔 때만 다시 실행한다. 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). 위 health GREEN은 배포 당시 증거다.
2026-08-12 current NAS는 고정 LAN engine relay listener 상실로 degraded였고 15:24 KST local health 요청도
3회 타임아웃했다. source-pinned relay launcher와 loopback contract 8/8에 더해 detached-clean
`f08e03de…f191`·tree `00275133…0abd` exact check-only도 `mutation=false`·listener `0→0`·runtime state 0으로
PASS했다. 실제 9100 시작·NAS origin 재검증은 소유자 승인 대기다. 명시 동의 물리 마이크와 독립 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** — 2026-08-27 Docker Desktop 4.82.0이 stale
`dockerInference`/`engine.sock` AF_UNIX reparse socket(ERROR 1920)에서 crash-loop한 사건은 두 부모 IPC
디렉터리를 `.stale` 백업명으로 이동한 뒤 정상 복구했다. container·image·volume은 생성/삭제/교체하지 않았고,
기존 `vignette-dev-db`와 recovered volume만 재기동했다. 2026-08-28 watchdog·로그온 boot는 detached-clean
`99779a6a…fbc8`·tree `57da86aa…6b91`와 exact commit/tree/script SHA에 재등록했다. 두 작업의 60분 상한·
`IgnoreNew`, launcher mutex·bounded readiness/build·process-tree 종료, hard-down 즉시복구를 focused 50/50으로
검증했고 watchdog 18:10:57 KST·boot 18:14:27 KST 수동 실행 `LastTaskResult=0`·failcount 0을 확인했다.
public P20 authenticated `/turn` 폐루프도 완료됐다. 남은 것은 실제 Windows 재부팅 후 엔진/API/터널 복구다.
DNS 개통 후 `api-vnet.18ka.net``-AdditionalPublicHealthUrls`로 명시 추가한다.
상세: `docs/ops/public-runtime-watchdog.md`.
## P0 운영 게이트
---
| ID | 다음 행동 | 완료 조건 | 차단 / 경계 |
|---|---|---|---|
| AVATAR-001 | fail-closed 후보의 API/Web/브라우저 검증 | source union·DB reference 보존, decode-valid 응답, public fallback, digest receipt, 승인 배포 | Docker·API·DB avatar URL·upload root 변경은 승인 전 금지 |
| REQ-008 | NAS 재부팅 자동복구 smoke | 실제 재부팅 뒤 health·로그인·데이터·이미지·SHA 확인 | Windows 개발 task는 legacy 참조이며 NAS 운영 증거가 아님 |
| BACKUP-001 | 백업 실패 알림·off-host 복제·restore drill | 실패 경보, 암호화 복제, 복원 주기·보존 정책의 운영 evidence | 첫 일일 sidecar 실행은 당시 성공 기록일 뿐 |
| G8-EXTERNAL | 실DB/public 사람 게이트 | 승인·보류·반려와 append-only effect의 실제 public proof | local fixture와 route E2E는 대체 불가 |
| G7-EXTERNAL | 동의 기반 음성 외부 proof | 3,120초 soak, 3,000초 overlap high-water, 독립 human pack, checker exit 0 | 장치 선택·명시 동의 전 마이크를 열지 않음 |
| ANTHROPIC-001 | provider live 동일성 비교 | 승인 주입한 기관 키로 응답·계량·오류 표면화 비교 | credential 취급·주입은 소유자 경계 |
| VNET-001 | vnet 공개 전환 | DNS·Cloudflare zone 권한·Google redirect URI와 live 검증 | 현재 NAS ingress를 넓히지 않음 |
| PIPELINE-001 | Forgejo 기준 NAS 자동배포 hook | 수동 git-container clone·SHA 검증·NAS build/compose 절차를 보존한 자동화와 rollback/approval 경계 | 현 수동 실증 절차를 우회하지 않음 |
| INGRESS-001 | Cloudflare tunnel 제거 지시의 대체 ingress gate | NAS ingress 대체 경로의 보안·가용성·OAuth 경계를 검증한 뒤 tunnel retirement 승인 | tunnel 제거는 사용자 지시이나 대체 ingress 검증 전 미이행 |
## B3. 소유자(윤찬) 결정 — 전건 확정(2026-06-30)
## 외부 거버넌스·파일럿
> 7건(평가설계 H1 κ/ICC·환각률, s2s 2차 PoC 채택, 페르소나 음성 모델·권리, fast-loop 외부 API 허용,
> 재귀학습 fine-tuning 범위, 운영 보존기간·에스컬레이션, 추가 축어록 수급 20건/1개월)은 **전건 확정**됐다.
> 결정 상세는 대시보드 "윤찬 결정 상세" 탭, ADR은 `docs/decisions/`(`voice-s2s-poc.md`,
> `backend-node-transition.md`)에 있다. 각 결정의 구현·실측은 후속 build/GATE로 남는다.
| ID | 다음 행동 | 완료 조건 |
|---|---|---|
| C-001 | 외부 임상 검수 수집 | 사례별 검수 30개·청소년 답변 6개·승인 10필드·서명 증거와 canonical checker `review_complete=true` |
| GOV-001 | 한신대 데이터/SSO 서면 증거 | SSO claims, 추가 축어록·미성년 동의, privacy 문서의 기관 확인 |
| L1-001 | 저작권/등재 문서 정합화 | FastAPI 현행 사유와 Node 계약 우선 전환 계획의 제출 증거 |
| P3-001 | 20명 파일럿 운영 | roster, consent 법무승인, 회기 완료 증거 |
| P3-002 | KPI 측정 | SUS·자기효능감·κ/ICC·환각률 보고서 |
| P3-003 | 재귀학습 승인 export·동의 감사 | consent/PII/IAA/legal 승인과 withdrawal log 종결 |
### B3-신규 (2026-07-27 전수 E2E 순회에서 제기 → **2026-07-27 소유자 전건 결정 완료**)
## 비차단 제품·정리 후보
> 소유자(윤찬)가 질문 도구로 7건 전건 결정(2026-07-27). 결정 내용과 구현 상태:
| ID | 다음 행동 | 완료 조건 |
|---|---|---|
| VOICE-001 | s2s 2차 PoC | 20턴 비교 보고서와 채택/폐기 판단 |
| FASTLOOP-001 | 외부 fallback | 동의·전송 가드·관측을 포함한 테스트 |
| FINETUNE-001 | 재귀학습 fine-tuning pipeline | 동의·데이터셋 gate 뒤 pipeline과 few-shot 자동갱신 범위 검증 |
| RETENTION-001 | health retention | raw 90일 / rollup 365일과 수동 escalation 검증 |
| EVALHOOK-001 | fast-loop `eval_hook` 사용 정책 | hook 활성화 조건과 관측 경계의 소유자 결정 |
| NODE-001 | Node gateway 전환 후보 | endpoint conformance, `TurnEvaluationPayload` adapter, admin mapper의 작업 패킷과 회귀 |
| MIGRATION-001 | idempotent DB migration runner | init SQL과 runner의 역할 경계·인프라 영향·소유자 승인 |
| OPS-001 | 운영 티켓 자동 분류 | 관리자 승인·audit 경계를 갖춘 구현 패킷 |
| E2E-001 | 전 기능 E2E 후속 분류 | focused 범위의 locator 18건 교정·keyboard 1건 해결 뒤 `npm run e2e`에서 범위 밖 케이스를 실행하고, 실패별 재현·원인·수정 명령을 분류한다. 과거 sweep 실패를 오늘 결함으로 단정하지 않으며 실API/DB·장치 검증은 fixture 결과와 분리한다. |
- [x] **설정 화면 아바타 변경 UI** — 결정: **설정에 추가**. **구현 완료(RED→GREEN, 대시보드 144차)**.
- [x] **동의 관리·철회 UI와 재동의 흐름** — 결정: **전체 흐름 구현**(설정 동의 섹션 + 철회 +
재동의 경로). **구현 완료(RED→GREEN, 대시보드 144차)**.
- [x] **신규 학습자 첫 추천 난도** — 결정: **기초 우선 추천**(회기 기록 없는 신규 학습자는
기초 난도 페르소나를 첫 추천으로). **구현 완료(RED→GREEN, 대시보드 144차)**.
- [x] **학생 분석 테이블** — 결정: **지금 가상화 도입**. **구현 완료(RED→GREEN, 대시보드 144차)**.
- [x] **저작 모드 좌측 레일 잔존 마크업** — 결정: **정리**(저작 모드에서 레일 미렌더). **구현 완료(RED→GREEN, 대시보드 144차)**.
- [x] **dev-login 재로그인 자동 재승인** — 결정: **현행 유지**(dev/E2E 편의 의도 확정). 코드 변경 없음.
- [x] **감정 밸런스 타임라인 차트**(결함 로그 #21) — 결정: **지금 구현**(평가 데이터 생산→저장→
리드모델→차트 연결). **구현 완료(RED→GREEN, 대시보드 144차)**.
## 완료 기록 경계
> 근거·상세: `docs/ops/e2e-full-sweep-2026-07-27.md` 발견 결함 로그 #2·#4·#6·#14·#15·#16·#21.
2026-09-01 A 경로의 NAS 배포, migration 20/21/22 적용, OAuth 실계정 회기 E2E와 당시 backup sidecar 첫 실행은 완료 이력이다. B3 및 B3-신규의 소유자 결정과 완료된 구현도 archive snapshot에 보존한다. `CLEANUP-001`은 84 regular source와 supplementary 4 files·junction 4개의 대조 뒤 `outputs/local-history/retained-sources-2026-09-07/`으로 가역 보존 이동을 마쳤고 영구 삭제는 0건이다. `RUNTIME-001`은 Windows public-runtime/watchdog scripts를 복구 경로로 보존하기로 결정했으며 이 PC의 두 Disabled Scheduled Task는 운영 서버 다운을 뜻하지 않는다. `REFACTOR-001``sessionViewModel` 분리와 `voiceCapture` 책임 유지를 수용했고, lifecycle setter bag hook은 채택하지 않았다. Space retry→pause와 empty-audio fixture를 교정했으며, `learnerFeedbackEnabled:true` 누락으로 숨던 review feedback UI는 fixture 보완 뒤 focused login→review 1 passed(exit 0)로 확인했다. 제품 feedback guard는 유지했다. `REFACTOR-002`는 repository와 persistence values로 평가 저장 경계를 분리했다. 열린 백로그에 완료 이력을 다시 적지 않는다.
---
## 확인 명령
## B4. 외부 거버넌스 — 한신대/데이터 steward 서면 증거
- [ ] **C-001 자살사고 케이스 위기 반응 프로토콜 임상 승인** — SAMHSA SAFE-T, NIMH Youth Outpatient
BSSA, NICE NG225, 보건복지부 109를 반영했다. P1 합성 사례 6건·v2 스키마·상태별 canonical fail-closed 판정기와
런타임/carry-over/output `ideation_stage <= 3` 이중 방어를 포함한 기술 사전검증은 완료했다. 생성·스트림
실제 경로 P1 6/6, focused 90 passed, 런타임 패키지·작업트리 SHA 일치까지 확인했고, 개선관리 완료본의
`C-001 외부검토` 시트는 현재 `pending-valid`·`검토`다. 사례별 판정·근거·수정 요구·재검토·검토일 30개,
청소년 특화 답변 6개, 임상 검토자 이름·소속 기관·자격·검토일·결정 근거·서면 증거/해시·검토 프로토콜 버전·
사례 세트 해시가 모두 기록되고 canonical 판정기가 `review_complete=true`를 반환하기 전에는 완료로 닫지 않는다. 검토 원장:
`docs/ops/clinical-crisis-protocol-review-2026-08-27.md`.
- [ ] **한신대 데이터/SSO 거버넌스 게이트** — SSO claim mapping, 추가 축어록 수급, 미성년 원본 활용동의, 개인정보
처리방침. 로컬 골격 `docs/ops/hanshin-data-governance-gate.md` 준비됨, 외부 서면 증거 필요. (소유자 보류 중.)
- [ ] **L1 스택 정합성·저작권 등재 문서 반영** — 백엔드는 Node.js 교체 가능성 우선으로 결정됐지만, 신청서/저작권
등재 문서에는 현재 FastAPI 유지 사유와 계약 우선 전환 계획을 반영해야 한다. 로컬 결정 기록은
`docs/decisions/backend-node-transition.md`; 외부 제출/등재 증거 필요.
---
## Phase 3 파일럿 게이트 (실참여자 필요)
---
## 배포 파이프라인 재정립 + A 경로 (NAS prod 실배포 → E2E 검증) — 2026-09-01 재부팅 후 실행
> **소유자 지시(2026-08-31)**: 배포는 Forgejo(git.chanpaca.net) 중심, NAS Production 대상, github은 private 백업/미러,
> 이 PC는 개발 전용, Cloudflare는 서빙용만·tunnel 제거. **A 경로로 진행**: 실제 prod 배포 준비 → 실배포 → 로그인·작동 E2E 확인까지.
> 2026-09-01 재부팅 후 아래를 순서대로 진행. 지침: `docs/ops/deployment-pipeline.md` · 핸드오프 `docs/ops/handoff-goal-production-2026-08-29.md`.
### 재부팅 직후 (truth 재확인) — **2026-09-01 완료**
- [x] OS/셸/경로/도구 확정, git HEAD `be08c0b5`·Forgejo master 일치·미커밋 71/untracked 27 확인
- [x] live public API health 200(`prod`·db/engine true)·NAS `vignette-prod` 5컨테이너 확인 — `scripts/vignette-pipeline.py --check` ALL OK
- [x] NAS prod 배포 기계장치 확정: `192.168.0.38:/volume1/docker/vignette-prod`(compose+`private/env.nas` immutable image pin), Forgejo http clone(git 컨테이너)→NAS 로컬 docker build로 고정. cloudflared는 NAS 호스트 상주(`private/cloudflared-host.yml`, api-vignette만 ingress) — public 도메인은 이 PC와 완전 분리 확인
### A-1. 배포 후보 GREEN 정리 — **2026-09-01 완료**
- [x] 미커밋 diff를 4개 논리 커밋으로 좁게 stage(회기 연속성 API·케이스 UI·NAS infra·docs/증거), tmp/outputs 제외
- [x] web typecheck·build PASS / API 1090 passed·gateway 71 passed / SSOT checker·design SSOT·api-types PASS
- [x] Forgejo master push, 최종 배포 SHA `dce85620` 고정(github 미러 동기화 포함)
### A-2. NAS prod 실배포 — **2026-09-01 완료**
- [x] prod 전용 절차 실행: pre-deploy dump `cab6826b…`(11,036,928B·TOC 1783)+env 백업+구 이미지 보존(롤백 경로), 외부 볼륨 `pgdata`/`apiuploads` 무접촉
- [x] 중복 활성 회기 14쌍·30행을 최신 유지 정책으로 종료(영수증 `receipts/stale-active-cleanup-20260901.csv`, 삭제 0) 후 마이그레이션 20/21/22 온라인 적용. mig22의 `name[]=text[]` 캐스트 결함 발견→`5a74302e` 교정. master에 게이트웨이 openai provider가 누락된 회귀 발견→구 운영 이미지에서 이식(`dce85620`, 테스트 71 passed)
- [x] readiness: public health `ok·db/engine true·openai OK`, OpenAPI 200, `/auth/me` 401, dev-login 404, 컨테이너 healthy, 데이터 보존(app_user 1380/sessions 618/turns 1743/case_profile 565 전후 동일)
### A-3. 로그인·작동 E2E 검증 (실브라우저) — **2026-09-01 완료 (전건 GREEN)**
- [x] public 로그인 화면 렌더(단일 Google CTA·콘솔 오류는 예상된 401 1건) + OAuth redirect_uri `https://api-vignette.chanpaca.net/auth/callback`·PKCE S256 검증 — headful 브라우저
- [x] 실제 Google 계정 로그인→callback→회기·턴 생성 완료 — 첫 시도에서 NAS env의 OAuth secret 오류(`invalid_client`)를 발견해 동작 실적 secret으로 교정 후 성공. headful 폐루프: 로그인(소유자 직접 입력)→기존 회기 20건 표시(이전 데이터 보존)→활성 회기 가드·시간 만료 fail-closed 확인→stale 회기 정식 종료→**새 사례(fresh case) 회기 생성(mig22 운영 실증)**→pre pulse 잠금→실턴 SSE·AI 내담자 응답→종료→리뷰 생성. DB 실증: sessions 618→619·turns 1743→1745·case_profile 565→566. 증거: `ops/evidence/prod-e2e-review-2026-09-01.png`
- [x] 배포 WEB: Pages production `9224a64b`, 새 entry `index-BFKGXJQi.js` 200·정상 MIME, 직전 entry `index-Bm30Ozeb.js` 200 보존(이전 세대 자산 175개 보존). 관리자 콘솔 렌더·서비스 상태 5/5 확인
- [x] **DB 백업 예약 실행 운영화(1단계)**`db-backup` 사이드카(일일 pg_dump→`pg_restore -l` 검증→원자 게시, 14일 보존)를 정식 스택에 추가, 첫 자동 덤프 11,202,927B 성공. owner 비번-볼륨 불일치도 env와 일치화. 실패 알림·off-host 암호화 복제·restore drill 주기는 후속 게이트로 유지
### 잔여(재부팅 외) — 그대로 유지
- [ ] 실배포 완료 전까지는 아래가 열린 게이트 유지: `public API/DB·아바타 정식 승격`, `G8 실DB/public 사람 게이트`,
`음성 캐스케이드 live`, `DB 백업 운영화`, `vnet.18ka.net live`, `claude_cli↔Anthropic API live`, `Compose infra/.env`,
`B1 티켓 자동 분류`, `C-001`, `한신대 거버넌스`, `L1 등재`, `Phase 3`, 재부팅 후 watchdog smoke/REQ-008
- [ ] **20명 교육용 파일럿 운영 / 효과성·KPI 측정(SUS·자기효능감·κ/ICC·환각률) / 재귀학습 데이터셋 approved 산출 /
개인정보·동의 감사.** 문서·checker·dry-run exporter는 준비됨(`docs/phase3/*`, `scripts/check-phase3-artifacts.py`,
`scripts/export-recursive-dataset.py`, `scripts/export-phase3-kpi.py`). 공식 문항 확정, 통계 검정, 실험/통제군 배정,
실제 approved evidence는 파일럿·steward/legal 검수·IAA 통과 시점의 GATE다.
```powershell
python -X utf8 scripts/check-dev-dashboard-ssot.py --json
git diff --check
```

View file

@ -0,0 +1,37 @@
# Codex 워커 정의 참조 사본
> [`AGENTS.md`](../../../AGENTS.md) B.8(Codex 실행 규격)의 워커 역할 정의다. **런타임 위치는 `~/.codex/agents/*.toml`(글로벌)** 이며,
> 이 디렉터리는 다른 PC·협업자가 같은 워커 계층을 설치하도록 두는 참조 사본이다.
## 설치
```powershell
Copy-Item -LiteralPath docs\ops\codex-agents\*.toml -Destination "$env:USERPROFILE\.codex\agents\" -Force
```
`~/.codex/config.toml`에는 다음이 있어야 한다(현재 워크스테이션 값).
```toml
[agents]
enabled = true
default_subagent_model = "gpt-5.6-terra"
default_subagent_reasoning_effort = "medium"
max_concurrent_threads_per_session = 8
```
## 왜 `.codex/agents/`(프로젝트 스코프)에 두지 않는가
- Codex는 신뢰된 프로젝트의 `.codex/agents/`를 글로벌보다 우선해 읽는다고 안내하지만, 프로젝트 스코프 커스텀 서브에이전트가
스폰되지 않는 이슈 [openai/codex#26408](https://github.com/openai/codex/issues/26408)이 2026-09-06 기준 열려 있다(0.137.0에서 보고, 수정 버전 없음).
- 같은 이름을 `.codex/agents/`에 두면 글로벌 정의를 가려 워커가 아예 안 뜰 수 있다. 이슈가 닫히고 실제 스폰이 확인되면 이 사본을 `.codex/agents/`로 옮긴다.
## 파일
| 파일 | 역할 | 모델 | 샌드박스 |
|---|---|---|---|
| `explorer.toml` | 코드베이스 탐색·근거 수집 | `gpt-5.6-luna` | read-only |
| `researcher.toml` | 웹·문서·논문 스위핑 | `gpt-5.6-luna` | read-only |
| `worker.toml` | 작업 패킷 기반 구현 | `gpt-5.6-terra` | 상위 상속 |
| `verifier.toml` | 검증 명령 실행·원문 보고 | `gpt-5.6-luna` | 상위 상속 |
글로벌 파일을 고치면 이 사본도 같은 내용으로 맞춘다(B절 동기화 규칙).

View file

@ -0,0 +1,19 @@
name = "explorer"
description = "읽기 전용 코드베이스 탐색 워커. 호출 경로·영향 범위·재현 절차를 추적하고 파일:줄 근거를 수집해 보고한다. 오케스트레이터가 탐색과 근거 수집을 위임할 때 사용."
model = "gpt-5.6-luna"
model_reasoning_effort = "medium"
sandbox_mode = "read-only"
developer_instructions = '''
(Astra/Sol) . . .
- . .
- . ( :, , , ) . "추정" .
- (rg, ) . .
- . .
1. ( )
2. : :
3. /
'''

View file

@ -0,0 +1,21 @@
name = "researcher"
description = "웹 검색·공식 문서·upstream 소스·이슈/PR·논문을 스위핑해 출처와 함께 보고하는 읽기 전용 조사 워커. 대규모 조사, 웹서핑 조사, 논문 스위핑, 레퍼런스 비교가 필요할 때 주제별로 여러 개 병렬로 사용."
model = "gpt-5.6-luna"
model_reasoning_effort = "medium"
sandbox_mode = "read-only"
developer_instructions = '''
(Astra/Sol) . . .
- . . .
- : URL, ·, · . "미확인" . "지원한다" .
- (, API, , , ) .
- .
- · , , , .
1. (5 )
2. ( )
3. /
4.
'''

View file

@ -0,0 +1,20 @@
name = "verifier"
description = "빌드·테스트·린트·재현 명령을 실행하고 통과/실패 수와 오류 원문을 그대로 보고하는 검증 워커. 판정은 하지 않는다. 오케스트레이터가 워커 결과를 평가하기 전 증거 수집에 사용."
model = "gpt-5.6-luna"
model_reasoning_effort = "low"
developer_instructions = '''
(Astra/Sol) . . .
- ( · ). .
- : , , , . flaky .
- . : / , , ( ), , (, , ).
- .
1. ()
2. : / ,
3. : +
4.
5.
'''

View file

@ -0,0 +1,22 @@
name = "worker"
description = "오케스트레이터가 확정한 설계와 작업 패킷(목표/비목표/계약/경계/완료 기준/검증 명령)대로 구현·리팩터·테스트를 작성하는 개발 워커. 설계가 확정된 구현 작업에 사용."
model = "gpt-5.6-terra"
model_reasoning_effort = "high"
developer_instructions = '''
(Astra/Sol) . . .
- ··· . .
- . . .
- fallback, silent catch, mock , , . .
- , , TODO, . .
- .
- ·push .
1.
2. diff ( )
3. (/ , )
4. /
5. ( )
'''

View file

@ -1,7 +1,7 @@
# 배포 파이프라인 (Deployment Pipeline)
> 작성: 2026-08-31 · 문서 소유: docs/ops (SSOT 방향)
> 상태: **초기 이관 완료 / NAS 자동배포 구성 잔여**
> 작성: 2026-08-31 · 2026-09-07 현행화 · 문서 소유: docs/ops
> 상태: **2026-09-01 NAS 수동 실배포 완료 / NAS 자동배포 구성 잔여**. 당시 증거이지 현재 live 상태 확인은 아니다.
## 목표 아키텍처 (소유자 지시)
@ -14,7 +14,7 @@ Forgejo git.chanpaca.net ────── 소스 SSOT (이관 완료 2026-08
│ └─ 배포 ──▶ NAS Production (vignette-prod)
│ docker-compose.nas.yml (SSOT-host=nas, runtime-class=production)
Cloudflare = 서비스 공개 서빙용만 유지 (tunnel 제거 대상)
Cloudflare tunnel은 소유자 지시에 따라 제거 대상이다. 다만 현재 NAS ingress가 이를 경유하므로, 대체 ingress의 보안·가용성·OAuth 경계를 검증하기 전에는 제거하지 않는다.
```
- 나는 이 PC를 **개발 전용**으로만 사용한다. 배포 대상은 반드시 **NAS Production**이다.
@ -29,7 +29,7 @@ Cloudflare = 서비스 공개 서빙용만 유지 (tunnel 제거 대상)
| `forgejo` | ssh://git@git.chanpaca.net:2222/yunchan/vignette.git | **소스 SSOT·배포** | ✅ 이관 완료 |
- Forgejo 레포: `yunchan/vignette` (id 14, public, default master)
- 이관 기준 커밋: `be08c0b5` (master 최신, 로컬 HEAD와 일치)
- 2026-09-01 배포 당시 기준 커밋: `dce85620`. 현재 master/로컬 HEAD는 실행 전에 별도로 확인한다.
- Forgejo 접근: SSH 키(`~/.ssh/id_ed25519`, Host `git.chanpaca.net``192.168.0.38:2222`) · API 토큰(`infra/.env.deploy``FORGEJO_API_TOKEN`, gitignore)
## 2. 로컬 브랜치 규칙
@ -37,21 +37,21 @@ Cloudflare = 서비스 공개 서빙용만 유지 (tunnel 제거 대상)
- `master` = Forgejo `master`와 동기화 (배포 기준선)
- 기능/scoped 브랜치 = 개발용, 검증 후 master로 합침
## 3. NAS Production 배포 흐름 (구성 예정)
## 3. NAS Production 배포 흐름 (수동 절차 실증, 자동화 구성 예정)
```
[이 PC] git push forgejo master
[Forgejo] 승인된 master 후보 SHA
[Forgejo] master = be08c0b5 (SSOT)
[NAS] git 컨테이너로 해당 SHA clone·검증
[NAS 192.168.0.38] git pull origin master (vignette-prod 소스)
▼ docker compose -f docker-compose.nas.yml up -d --build
vignette-prod (api/web/engine/db/proxy) 재기동
[NAS] API/Web build → online migration → docker compose 재생성
[Cloudflare] 서빙 (api-vignette.chanpaca.net / vignette.chanpaca.net)
vignette-prod (api/web/engine/db/proxy) readiness·데이터 보존 검증
[Cloudflare] 현재 공개 서빙 경로 유지 (대체 ingress 검증 뒤 tunnel retirement)
```
NAS 배포는 Forgejo에서 직접 pull하는 방식으로 전환하고, cloudflare tunnel에 의존하지 않는다.
NAS 호스트에는 git이 없으므로 git 컨테이너로 Forgejo에서 clone한다. 2026-09-01 수동 실배포는 이 흐름으로 `dce85620`을 검증했다. 자동배포 hook과 tunnel retirement는 별도 열린 게이트다.
## 4. 검증 게이트 (배포 전)
@ -93,6 +93,6 @@ docker compose -f docker-compose.nas.yml --env-file private/env.nas up -d
- [ ] Forgejo master 기준 NAS 자동배포 후크(수동 절차는 §5로 실증 완료)
- [x] Forgejo가 SSOT임을 문서에 반영, github은 private 미러 유지
- [ ] cloudflare tunnel 제거 검토 — 현재 api-vignette 서빙이 NAS 호스트 상주 cloudflared 터널 경유라 제거 시 대체 ingress 필요(소유자 결정)
- [ ] cloudflare tunnel 제거 또는 유지 결정 — 현재 `api-vignette`는 NAS 호스트 상주 cloudflared 터널 경유다. 대체 ingress의 보안·가용성·OAuth 경계가 검증되기 전에는 제거하지 않는다.
- [ ] 배포 시 NAS 접근 자격(SSH 키/배포 계정)을 `.env.deploy`/시크릿으로 관리
```

View file

@ -1,5 +1,7 @@
# E2E 전수 순회 체크리스트 — 2026-07-27
> **역사 baseline**: 이 문서는 2026-07-27 당시 전수 순회 인벤토리와 해당 spec의 출발점이다. 현재 남은 직렬 E2E 재현·분류는 [`TODO.md`](../TODO.md)의 `E2E-001`을 따른다.
> **목적**: IA·버튼 단위로 온보딩→가입→학습자→교수자→관리자→설정 전 페이지의 모든 기능을 순회하며
> (1) 의도와 다른 동작, (2) 오류, (3) 나쁜 UI/UX를 잡아낸다.
> **방식**: RED → GREEN. 각 항목은 검증 수단(기존 spec / 신규 spec / 탐색 순회)을 명시하고,

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 942 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 942 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 607 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 922 KiB

View file

@ -18,6 +18,13 @@ INSERT INTO app.measurement_instrument (
'훈련 문구와 분리된 합성 미지 사례에서 역량 유지와 subgroup drift를 추적한다.',
'{"transfer_success_rate":{"min":0,"max":1}}'::jsonb,
'{"clinical_claim_allowed":false,"data_classification":"synthetic_educational","aggregate_total_forbidden":true}'::jsonb
),
(
'vignette.calibration-self-prediction', '1.0.0', '외부평가 전 자기보정 (호환용)',
'training_metric', 'self_calibration',
'외부평가 공개 전 자기예측과 독립 수행 관측의 차이를 역량별로 추적한다.',
'{"prediction_probability":{"min":0,"max":1},"calibration_error":{"min":0,"max":1}}'::jsonb,
'{"clinical_claim_allowed":false,"aggregate_total_forbidden":true}'::jsonb
)
ON CONFLICT (instrument_id, instrument_version) DO NOTHING;