From 17b114f64794bfc9228e8e6a32d26ac5e4b67c8d Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sat, 27 Jun 2026 18:47:55 +0900 Subject: [PATCH] =?UTF-8?q?=EC=84=B8=EC=85=98=20=EA=B3=84=EC=95=BD=20SSOT?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/app/routes/sessions.py | 11 ++++++----- apps/web/src/lib/api.gen.ts | 35 +++++++++++++++++++++++---------- apps/web/src/lib/api.ts | 27 +++---------------------- apps/web/src/pages/Session.tsx | 2 +- docs/HANDOFF.md | 4 ++-- docs/dev_dashboard.html | 6 +++--- docs/guides/architecture.md | 3 ++- docs/ops/backlog-2026-06-26.md | 2 +- 8 files changed, 43 insertions(+), 47 deletions(-) diff --git a/apps/api/app/routes/sessions.py b/apps/api/app/routes/sessions.py index 7ee451d..126aed9 100644 --- a/apps/api/app/routes/sessions.py +++ b/apps/api/app/routes/sessions.py @@ -31,6 +31,7 @@ from ..store import InProcSession, TurnRecord, store router = APIRouter(prefix="/sessions", tags=["sessions"]) TheoryMode = Literal["humanistic", "cbt", "integrative"] +StageLabel = Literal["라포", "탐색", "개입", "정리"] EndStateValue = str | int | float | bool | None | dict[str, float] @@ -43,7 +44,7 @@ class SessionStartResponse(BaseModel): session_id: str case_id: str session_no: int - stage: str + stage: StageLabel effective_openness: float recall_summary: Optional[str] = None degraded: bool = False @@ -61,7 +62,7 @@ class CrisisResourceResponse(BaseModel): class TurnResponse(BaseModel): turn_seq: int - stage: str + stage: StageLabel effective_openness: float client_reply: Optional[str] = None safety_flagged: bool = False @@ -83,7 +84,7 @@ class LearnerSessionSummary(BaseModel): persona_name: str session_no: int status: Literal["active", "ended"] - stage: str + stage: StageLabel turn_count: int learner_turn_count: int client_turn_count: int @@ -100,7 +101,7 @@ class LearnerSessionsResponse(BaseModel): class SessionDetailTurn(BaseModel): turn_seq: int speaker: Literal["learner", "client"] - stage: str + stage: StageLabel text: str created_at: str @@ -112,7 +113,7 @@ class SessionDetailResponse(BaseModel): persona_name: str theory_mode: str status: Literal["active", "ended"] - stage: str + stage: StageLabel effective_openness: float started_at: str ended_at: str | None = None diff --git a/apps/web/src/lib/api.gen.ts b/apps/web/src/lib/api.gen.ts index 08cfc94..b1e1b1d 100644 --- a/apps/web/src/lib/api.gen.ts +++ b/apps/web/src/lib/api.gen.ts @@ -1305,8 +1305,11 @@ export interface components { session_id: string; /** Session No */ session_no: number; - /** Stage */ - stage: string; + /** + * Stage + * @enum {string} + */ + stage: "라포" | "탐색" | "개입" | "정리"; /** Started At */ started_at: string; /** @@ -1762,8 +1765,11 @@ export interface components { review_ready: boolean; /** Session Id */ session_id: string; - /** Stage */ - stage: string; + /** + * Stage + * @enum {string} + */ + stage: "라포" | "탐색" | "개입" | "정리"; /** Started At */ started_at: string; /** @@ -1785,8 +1791,11 @@ export interface components { * @enum {string} */ speaker: "learner" | "client"; - /** Stage */ - stage: string; + /** + * Stage + * @enum {string} + */ + stage: "라포" | "탐색" | "개입" | "정리"; /** Text */ text: string; /** Turn Seq */ @@ -1941,8 +1950,11 @@ export interface components { session_id: string; /** Session No */ session_no: number; - /** Stage */ - stage: string; + /** + * Stage + * @enum {string} + */ + stage: "라포" | "탐색" | "개입" | "정리"; }; /** TeacherDashboardResponse */ TeacherDashboardResponse: { @@ -2207,8 +2219,11 @@ export interface components { * @default false */ safety_flagged: boolean; - /** Stage */ - stage: string; + /** + * Stage + * @enum {string} + */ + stage: "라포" | "탐색" | "개입" | "정리"; /** Turn Seq */ turn_seq: number; }; diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 137fec3..ede3a0b 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -165,7 +165,7 @@ export const authApi = { withdrawConsent: () => api.del("/auth/consent"), }; -export type SessionStage = "라포" | "탐색" | "개입" | "정리"; +export type SessionStage = ApiSchema<"SessionStartResponse">["stage"]; /** GET /personas — personas.py PersonaSummary */ export type PersonaSummary = ApiSchema<"PersonaSummary">; @@ -178,15 +178,7 @@ export type PersonaDraftPayload = ApiSchema<"PersonaDraftPayload">; export type PersonaDraftDetail = ApiSchema<"PersonaDraftDetail">; /** POST /sessions — sessions.py SessionStartResponse */ -export interface SessionStartResponse { - session_id: string; - case_id: string; - session_no: number; - stage: SessionStage; - effective_openness: number; - recall_summary: string | null; - degraded: boolean; -} +export type SessionStartResponse = ApiSchema<"SessionStartResponse">; /** POST /sessions/{id}/turn — sessions.py TurnResponse */ export type TurnResponse = ApiSchema<"TurnResponse">; @@ -201,20 +193,7 @@ export type LearnerSessionsResponse = ApiSchema<"LearnerSessionsResponse">; export type SessionDetailTurn = ApiSchema<"SessionDetailTurn">; -export interface SessionDetailResponse { - session_id: string; - case_id: string; - persona_code: string; - persona_name: string; - theory_mode: string; - status: "active" | "ended"; - stage: SessionStage; - effective_openness: number; - started_at: string; - ended_at: string | null; - turns: SessionDetailTurn[]; - review_ready: boolean; -} +export type SessionDetailResponse = ApiSchema<"SessionDetailResponse">; export type ReviewClient = ApiSchema<"ReviewClient">; export type ReviewTechnique = ApiSchema<"ReviewTechnique">; diff --git a/apps/web/src/pages/Session.tsx b/apps/web/src/pages/Session.tsx index f5a1427..999d1c4 100644 --- a/apps/web/src/pages/Session.tsx +++ b/apps/web/src/pages/Session.tsx @@ -587,7 +587,7 @@ export default function Session() { setStage(detail.stage); setOpenness(detail.effective_openness); setUtterances( - detail.turns.map((turn) => ({ + (detail.turns ?? []).map((turn) => ({ id: nextId(), speaker: turn.speaker === "client" ? "client" : "learner", text: turn.text, diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index fa2d437..d7268bb 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,6 +1,6 @@ # Vignette Handoff -> Updated: 2026-06-27 18:39 KST. 새 세션은 이 문서와 `docs/DESIGN_CONCEPT.md`를 먼저 읽고 이어가면 된다. +> Updated: 2026-06-27 18:48 KST. 새 세션은 이 문서와 `docs/DESIGN_CONCEPT.md`를 먼저 읽고 이어가면 된다. ## 현재 상태 @@ -21,7 +21,7 @@ - X2 예산 경고/저비용 평가 라우팅: `ADMIN_USAGE_BUDGET_USD`가 0보다 크면 `/admin/usage`가 budget 상태(ok/warn/exceeded)를 반환하고 `/admin`이 예산 배너를 표시한다. `EVALUATOR_FAST_MODEL`/`EVALUATOR_DEEP_MODEL`을 설정하면 fast/deep 평가 호출만 해당 모델 override로 gateway에 전달한다. 비우면 기존 gateway default 라우팅을 유지한다. - H4 LLM call audit + 동의 게이트: 상담 생성(generate/stream)과 fast/deep 평가의 외부 LLM 호출 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency metadata만 적재한다. prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log`가 8→11로 3행 증가했다. 이번 패스에서 `app_user.consent_at` 기반 learner 동의 수락/철회 API와 세션 시작/voice dev persona 시작 하드게이트를 추가했다. 한국어 이름/기관 NER와 guardian/legal 서명 evidence는 후속. - RAG warm 동시성: E2E가 여러 세션을 빠르게 만들 때 BGE-M3 embedder가 동시에 지연 로드되어 `tqdm` lock 예외와 API health/dev-login timeout이 반복됐다. `rag.py` embedder load/encode를 process-wide `RLock`으로 직렬화하고, `sessions.py` warm task를 semaphore 1개로 제한했다. 최신 검증: `layout-visual-gate + session-layout` 11 passed. -- 계약 SSOT 3차: `apps/web/src/lib/api.ts`의 수기 DTO 중 auth, persona, admin/user/engine, review leaf, teacher safety/growth leaf, learner sessions, session review/worksheet aggregate, teacher dashboard aggregate 타입을 `apps/web/src/lib/api.gen.ts`의 `ApiSchema` alias로 전환했다. generated optional/default 차이는 화면 form state, notification default helper, 배열 렌더링 fallback으로 흡수한다. 이번 패스에서 `.github/workflows/api-contract.yml`을 추가해 PR/master push에서 `npm run check:api-types`가 FastAPI OpenAPI와 generated type drift를 막는다. SessionDetail/SessionStart 계열 stage enum 정리는 후속. 검증: `npm run check:api-types`, `npm run typecheck`, `npm run build`, learner/session-review/teacher/layout focused E2E 32 passed. +- 계약 SSOT 4차: `apps/web/src/lib/api.ts`의 수기 DTO 중 auth, persona, admin/user/engine, review leaf, teacher safety/growth leaf, learner sessions, session review/worksheet aggregate, teacher dashboard aggregate에 이어 `SessionStartResponse`, `SessionDetailResponse`, `SessionDetailTurn`까지 `apps/web/src/lib/api.gen.ts`의 `ApiSchema` alias로 전환했다. 백엔드 세션 응답 stage는 `StageLabel` enum(`라포|탐색|개입|정리`)으로 고정했고, generated optional/default 차이는 화면 form state, notification default helper, 배열 렌더링 fallback으로 흡수한다. `.github/workflows/api-contract.yml`은 PR/master push에서 `npm run check:api-types`로 FastAPI OpenAPI와 generated type drift를 막는다. 검증: `npm run check:api-types`, `npm run typecheck`, `npm run build`, `python -m pytest app/test_session_turn_persistence.py -q` 16 passed, learner/session-review/teacher/layout focused E2E 32 passed. - 턴 런타임 리팩터 2차: `turn_runtime.finalize_completed_turn`으로 REST submit/stream/voice WS의 `record_completed_turn` + `record_safety_event` 호출쌍을 공통화했다. gateway `/v1/stream` route 레벨에서 `token/done/error` SSE 프레임 contract tests를 추가했다. 검증: focused backend 40 passed. - Phase3 artifact checker 강화: `scripts/check-phase3-artifacts.py`가 CSV enum, KPI report 필수 field, approved export의 PII pass·κ/ICC·withdrawn exclusion·consent scope·file sha256을 검증한다. `app/test_phase3_artifact_checker.py` 5 tests 추가. 실제 파일럿 evidence와 steward/legal/IAA gate는 그대로 외부 의존이다. - 세션 종료 UX/dark theme SSOT 정리: 드래그형 `SlideToEnd`를 명시 확인 다이얼로그로 교체하고, Topbar/Settings theme 토글을 `lib/theme.ts` 단일 경로로 통합했다. 저장값이 없으면 dark를 기본으로 두고 앱 부팅 시 theme를 먼저 적용해 초기 flash를 줄인다. API 기본 preference `system`은 Settings에서 light로 오해하지 않고 현재 초기 테마를 따른다. 회기 리뷰·설정 포함 주요 페이지의 흰 섹션 잔재도 dark 작업면으로 맞췄다. 검증: `npm run typecheck`, `npm run build`, `npx playwright test e2e/layout-visual-gate.spec.ts e2e/settings.spec.ts e2e/session-review.spec.ts --project=chromium-desktop --project=chromium-mobile --project=chromium-single-run --workers=1` 22 passed. diff --git a/docs/dev_dashboard.html b/docs/dev_dashboard.html index ed9c6e2..0158dd7 100644 --- a/docs/dev_dashboard.html +++ b/docs/dev_dashboard.html @@ -636,8 +636,8 @@
현재

app/turn_runtime.py를 추가해 load_owned_session, record_completed_turn, append_completed_turn, update_session_state, record_safety_event를 공통화했다. 이번 패스에서 finalize_completed_turn을 추가해 record_completed_turn + record_safety_event 호출쌍을 REST submit/stream과 voice WS 라우트에서 제거했다. stream fast-loop 평가 누락, voice 세션 recall cache, visible_to/evaluator turn 분리는 보존.

검증

pytest app/test_session_turn_persistence.py app/test_voice_ws.py engine_gateway/test_gateway_model.py app/test_phase3_artifact_checker.py app/test_dataset_export.py -q 40 passed.

잔여

더 큰 finalize_turn/SessionRepository 경계, 장기적으로는 세션 저장소 인터페이스 SSOT화가 남아 있다.

- -
현재

apps/api/scripts/export-openapi.pyapps/web/scripts/generate-api-types.mjs로 FastAPI OpenAPI를 apps/web/src/lib/api.gen.ts에 생성한다. npm run check:api-types가 재생성 누락을 잡는다. MeResponse, AuthConfigResponse, ConsentResponse, PersonaSummary, PersonaReviewSummary, admin/user/engine DTO, review leaf DTO, CrisisResourceResponse, teacher safety/growth leaf DTO에 이어 이번 패스에서 LearnerSessionsResponse, SessionReviewResponse, worksheet aggregate, TeacherDashboardResponse, TeacherSessionSummary, TeacherLearnerGrowthApiSchema<...> alias로 전환했다. generated optional/default 차이는 화면 form state, notification default helper, 배열 렌더링 fallback으로 흡수한다. .github/workflows/api-contract.yml은 PR/master push에서 같은 stale check를 실행해 OpenAPI type drift를 CI에서 막는다.

검증

npm run check:api-types, npm run typecheck, npm run build passed. 영향 범위 E2E: learner.spec.ts + session-review.spec.ts + teacher.spec.ts + layout-visual-gate.spec.ts 32 passed.

잔여

SessionDetail/SessionStart 계열은 backend stage enum/guard를 먼저 정리한 뒤 생성 타입 alias로 전환한다.

+ +
현재

apps/api/scripts/export-openapi.pyapps/web/scripts/generate-api-types.mjs로 FastAPI OpenAPI를 apps/web/src/lib/api.gen.ts에 생성한다. npm run check:api-types가 재생성 누락을 잡는다. MeResponse, AuthConfigResponse, ConsentResponse, PersonaSummary, PersonaReviewSummary, admin/user/engine DTO, review leaf DTO, CrisisResourceResponse, teacher safety/growth leaf DTO, LearnerSessionsResponse, SessionReviewResponse, worksheet aggregate, TeacherDashboardResponse, TeacherSessionSummary, TeacherLearnerGrowth에 이어 SessionStartResponse, SessionDetailResponse, SessionDetailTurnApiSchema<...> alias로 전환했다. 세션 응답 stage는 backend StageLabel enum으로 고정해 OpenAPI가 라포|탐색|개입|정리 union을 생성한다. generated optional/default 차이는 화면 form state, notification default helper, 배열 렌더링 fallback으로 흡수한다. .github/workflows/api-contract.yml은 PR/master push에서 같은 stale check를 실행해 OpenAPI type drift를 CI에서 막는다.

검증

npm run check:api-types, npm run typecheck, npm run build passed. python -m pytest app/test_session_turn_persistence.py -q 16 passed. 영향 범위 E2E: learner.spec.ts + session-review.spec.ts + teacher.spec.ts + layout-visual-gate.spec.ts 32 passed.

잔여

평가 조회·리뷰 도식·교수 콘솔 등 아직 자유 문자열로 남긴 stage-like 필드는 각 도메인 의미를 확인한 뒤 별도 축소한다.

@@ -783,7 +783,7 @@ Web typechecknpm run typecheckPassed API typegen SSOTnpm run check:api-typesPassed; FastAPI OpenAPI → src/lib/api.gen.ts stale check - Contract SSOT aggregate DTOnpm run typecheck / npm run build / learner + session-review + teacher + layout gatePassed; learner sessions, session review/worksheet, teacher dashboard aggregates now use generated ApiSchema aliases with UI array fallback. Focused E2E 32 passed. .github/workflows/api-contract.yml now runs npm run check:api-types on API/Web contract changes. + Contract SSOT aggregate DTOnpm run typecheck / npm run build / session persistence / learner + session-review + teacher + layout gatePassed; learner sessions, session review/worksheet, teacher dashboard, session start/detail DTOs now use generated ApiSchema aliases with UI fallback. Stage responses are OpenAPI enum unions. Focused E2E 32 passed. .github/workflows/api-contract.yml now runs npm run check:api-types on API/Web contract changes. Backend pytestpython -m pytest app/ -q130 passed X2 evaluator model routingpython -m pytest app/test_evaluator_model_routing.py app/test_runtime_policy.py -q23 passed; fast/deep evaluator model override and blank-default routing covered C1 case worksheetpython -m pytest app/test_session_turn_persistence.py -q / session-review.spec.ts14 passed; session review exposes transcript-grounded caseWorksheet and UI evidence jump diff --git a/docs/guides/architecture.md b/docs/guides/architecture.md index e0b5c58..13e0ad3 100644 --- a/docs/guides/architecture.md +++ b/docs/guides/architecture.md @@ -375,7 +375,8 @@ React 19 + Vite. 라우팅은 `apps/web/src/App.tsx`(react-router-dom). - 도메인 헬퍼: `sessionApi`(list/get/start/turn/end/review/stream), `personaApi`, `personaReviewApi`, `adminApi`, `adminEngineApi`, `teacherApi`, `userApi`. 주요 응답 타입은 FastAPI OpenAPI에서 생성한 `src/lib/api.gen.ts`의 `ApiSchema<...>` alias를 사용하고, generated optional 배열은 화면 렌더링 계층에서 - 빈 배열 fallback으로 흡수한다. + 빈 배열 fallback으로 흡수한다. 세션 시작/상세/턴 응답의 stage는 backend `StageLabel` + enum(`라포|탐색|개입|정리`)에서 생성한 union을 `SessionStage`로 사용한다. - dev 환경: `vite.config`가 `/api` → `http://127.0.0.1:8000` 프록시(`/api` 프리픽스 제거). 배포 호스트(`vignette.chanpaca.net`, `*.pages.dev`)에서는 `api-vignette.chanpaca.net`을 직접 가리킨다. diff --git a/docs/ops/backlog-2026-06-26.md b/docs/ops/backlog-2026-06-26.md index 57b7853..2fca9bd 100644 --- a/docs/ops/backlog-2026-06-26.md +++ b/docs/ops/backlog-2026-06-26.md @@ -65,7 +65,7 @@ - P1 서연 음성 아트 PoC: Higgs v3 무참조 synthetic seed + 5개 정서/속도 변주(mp3/wav) 생성. 실존 reference voice 미사용. `/voice/ws`에는 dev-only sample TTS provider로 연결했고 live text_turn smoke에서 `p1-sample-poc` binary 20 chunks/78,573 bytes 수신 확인. 이 provider는 TTS override만 담당한다(마이크/STT는 OpenAI 키 필요). 프로덕션 탑재는 라이선스/권리 결정 전 금지. - 부수: RLS smoke 스크립트 cleanup RLS 컨텍스트 버그 수정, `vignette_app`(NOBYPASSRLS) dev 역할 프로비저닝. - 평가 SSOP 2차: `TurnRecord.evaluation`을 `app.feedback_scores`/`turn_technique`/`turn_client_state`/`supervisor_comment`/`alternative_utterance`로 정규화 적재·복원하고, submit/stream/voice 저장 경로를 `app.turn_runtime`으로 공통화했다. 원시 평가 테이블은 evaluator/admin RLS로 제한하고 서버가 evaluator 컨텍스트로 적재한다. `app.turns` insert는 `RETURNING id` 확인으로 silent success 제거. **검증: `pytest app/test_evaluation_persistence.py -q` 6 passed, 관련 회귀 45 passed, `pytest app/` 119 passed, `pytest engine_gateway/` 7 passed.** -- 계약 SSOT 3차: FastAPI OpenAPI를 `apps/web/src/lib/api.gen.ts`로 생성하는 `npm run generate:api-types`와 stale 체크용 `npm run check:api-types`를 유지한다. `TurnResponse`/`SessionEndResponse`에 이어 `MeResponse`, `AuthConfigResponse`, `ConsentResponse`, `PersonaSummary`, `PersonaReviewSummary`, admin/user/engine DTO, review leaf DTO, `CrisisResourceResponse`, teacher safety/growth leaf DTO, `LearnerSessionsResponse`, `SessionReviewResponse`, worksheet aggregate, `TeacherDashboardResponse`, `TeacherSessionSummary`, `TeacherLearnerGrowth`를 생성 타입 alias로 전환했다. generated optional/default 차이는 UI form state, notification default helper, 배열 렌더링 fallback으로 흡수한다. 이번 패스에서 `.github/workflows/api-contract.yml`을 추가해 PR/master push에서 `npm run check:api-types`가 drift를 막는다. SessionDetail/SessionStart 계열의 stage enum 정리는 후속. **검증: `npm run check:api-types`, `npm run typecheck`, `npm run build`, `learner.spec.ts + session-review.spec.ts + teacher.spec.ts + layout-visual-gate.spec.ts` 32 passed.** +- 계약 SSOT 4차: FastAPI OpenAPI를 `apps/web/src/lib/api.gen.ts`로 생성하는 `npm run generate:api-types`와 stale 체크용 `npm run check:api-types`를 유지한다. `TurnResponse`/`SessionEndResponse`에 이어 `MeResponse`, `AuthConfigResponse`, `ConsentResponse`, `PersonaSummary`, `PersonaReviewSummary`, admin/user/engine DTO, review leaf DTO, `CrisisResourceResponse`, teacher safety/growth leaf DTO, `LearnerSessionsResponse`, `SessionReviewResponse`, worksheet aggregate, `TeacherDashboardResponse`, `TeacherSessionSummary`, `TeacherLearnerGrowth`, `SessionStartResponse`, `SessionDetailResponse`, `SessionDetailTurn`를 생성 타입 alias로 전환했다. 세션 응답 stage는 `StageLabel` enum으로 OpenAPI에 고정했고, generated optional/default 차이는 UI form state, notification default helper, 배열 렌더링 fallback으로 흡수한다. `.github/workflows/api-contract.yml`은 PR/master push에서 `npm run check:api-types` drift를 막는다. **검증: `npm run check:api-types`, `npm run typecheck`, `npm run build`, `python -m pytest app/test_session_turn_persistence.py -q` 16 passed, `learner.spec.ts + session-review.spec.ts + teacher.spec.ts + layout-visual-gate.spec.ts` 32 passed.** - 위기게이트 109 1차: 실제 자해·자살 신호는 LLM/엔진 호출 전 중단하고 `crisis_resource(109)`와 `conversation_stopped`를 REST/SSE/voice 응답에 싣는다. `app.safety_events` detail 적재와 교수자 대시보드 안전 알림 큐까지 연결했다. 안전 이벤트 DB insert payload도 회귀화했다. 임상 문안, 실시간 push/메일, 지역 자원 확장은 후속. **검증: `pytest app/test_session_turn_persistence.py app/test_voice_ws.py -q` 20 passed, `pytest app/test_teacher_dashboard.py app/test_session_turn_persistence.py app/test_voice_ws.py -q` 21 passed.** - 교수자 성장 추적 1차: `teacher_dashboard`가 정규화된 턴 평가를 기반으로 학습자별 적절성·라포·기술 사용 추이를 집계하고, 교수 콘솔에 최근 회기/항목별 성장 카드와 안전 알림 큐를 함께 표시한다. **검증: `pytest app/test_teacher_dashboard.py app/test_session_turn_persistence.py app/test_voice_ws.py -q` 21 passed, `pytest app/` 119 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`.** - 페르소나 저작 CRUD 1차: teacher/admin 전용 `POST/GET/PUT /personas/drafts` 경로를 추가해 draft 생성·조회·편집·검수요청을 열고, 교수 콘솔에 JSON 초안 작성/불러오기/수정 UI를 연결했다. 승인된 카드만 학습자 catalog에 노출하는 기존 경계는 유지한다. P4~P7 실제 임상 콘텐츠 입력은 후속. **검증: `pytest app/test_persona_review.py -q` 19 passed, `pytest app/` 119 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`.**