개선관리 요구사항과 Google 로그인을 완료

This commit is contained in:
Yun Chan 2026-08-28 16:07:09 +09:00
parent cc0a15b7c6
commit 2a39636163
112 changed files with 10166 additions and 527 deletions

View file

@ -82,7 +82,9 @@ vignette/
`lifespan`(startup/shutdown)에서:
1. `init_pool()` → DB 풀 생성, `ensure_runtime_tables()` / `ensure_review_tables()` 보장.
1. `init_pool()` → DB 풀 생성, 런타임 계약 readiness 확인. 개선관리 계약은
`infra/db/init/17_improvement_workbook_contracts.sql`을 owner가 선적용해야 하며,
`protocol_registry.ensure_protocol_tables()``SELECT`로 준비 상태만 확인하고 애플리케이션 역할로 DDL을 실행하지 않는다.
2. `settings.auto_seed_personas``materialize_seed_personas()`로 시스템 페르소나 P1~P3과 저장소 `data/personas/P4~P7.json``app.persona_card`에 누락분만 물리화한다. 기존 DB 저작본/보관본은 덮어쓰거나 되살리지 않는다. 운영자는 `scripts/materialize-persona-seeds.py` dry-run으로 같은 seed/version manifest를 확인하고, 명시적 `--apply`에서만 DB pool을 초기화해 이 materializer를 호출할 수 있다.
3. `engine_client.startup()` / `voice_service.startup()`로 httpx 클라이언트 준비.
4. **DB 초기화 실패 시** `environment == "dev"`이면 예외를 삼키고 경고만 남긴 채 degraded 기동한다
@ -90,8 +92,9 @@ vignette/
등록 라우터(`app/routes/`): `auth, admin, personas, sessions, teacher, users, eval, voice, kb`.
`GET /health`는 liveness + DB readiness(`db.healthcheck()`) + 엔진 게이트웨이 readiness
(`engine_client.health_detail()`)를 합쳐 `{"status": "ok|degraded", db, engine, engine_mode, ...}`를 반환한다.
`GET /health`는 liveness + DB readiness(`db.healthcheck()`) + 기본 엔진 조합과 전용 live-client 공급자의
readiness(`engine_client.health_detail()`)를 합쳐 `{"status": "ok|degraded", db, engine, engine_mode, ...}`를 반환한다.
기본 evaluator 엔진이 준비됐어도 live-client 공급자가 준비되지 않으면 `status=degraded`, `engine=false`다.
DB readiness는 auth/admin 테이블뿐 아니라 세션 read-model 핵심 테이블·컬럼
(`app.sessions`, `app.turns` 음성 메타 컬럼, `app.session_review_status` worksheet 컬럼)을 함께 확인한다.
@ -200,6 +203,9 @@ LLM 아님. 순수함수 + 작은 dataclass `SessionState`.
1. **입력 PII 마스킹** `mask_pii(text) -> MaskResult`: Presidio가 설치돼 있으면 우선 사용,
미설치면 정규식 폴백(`_PII_PATTERNS`: 주민번호/휴대폰/전화/이메일/장문 숫자열). 마스킹본만
저장·외부 LLM 전송에 쓴다(하드 게이트, F-03). Presidio는 지연 로드 캐시(`_try_load_presidio`).
`mask_role_identities()`는 현재 회기의 상담자/학습자 이름을 `[COUNSELOR]`, 페르소나/내담자 이름을
`[CLIENT]`, 그 밖의 이름을 `[NAME]`으로 정규화한다. evaluator·live-coach 입력 전과 구조화 결과 반환 전
모두 적용하며, 선택적 identity 필드는 `getattr(..., None)`으로 안전하게 처리한다.
2. **위기 분류** `classify_crisis(text, speaker_is_persona_context=True) -> CrisisResult`:
가상내담자의 자살사고 *연기*는 시뮬레이션 정상(`PERSONA_PLAY`, escalate=False). 그러나
1인칭 실제 단서(`_FIRST_PERSON_NOW`)가 강하면 **수련생 본인의 실제 위기**(`LEARNER_REAL`,
@ -373,7 +379,10 @@ session lifecycle을 유지하며, future Node read API는 이 read-model contra
- `POST /sessions``get_catalog_persona(code)`로 승인 카드 조회 → `case_profile` upsert →
case digest/직전 summary/pinned fact seed recall → `state_machine.init_state(...)`
`session_persistence.create_session(...)`. 프론트 세션 시작 전 화면은 `persona.theory_target`
`session_persistence.create_session(...)`. 생성 시 승인 페르소나의 `persona_id``persona_version`
세션에 고정하고 시작/상세 응답에도 두 값을 반환한다. 이후 더 높은 버전이 승인돼도 기존 회기는 고정된
역사 버전을 해석한다. DB 영속 생성 실패는 안전하지 않은 성공으로 흡수하지 않고 안정적인
`503 session_persistence_unavailable`로 반환한다. 프론트 세션 시작 전 화면은 `persona.theory_target`
기본값을 쓰되 학습자가 `humanistic`/`cbt`/`integrative` 중 하나를 명시 선택해 기존
`theory_mode` 계약으로 보낸다. DB가 없으면
`runtime_fallback_allowed()` 확인 후 `store.create(...)`로 in-proc 생성.
@ -390,7 +399,9 @@ session lifecycle을 유지하며, future Node read API는 이 read-model contra
순차 append → 상태 갱신. `EngineError`는 503으로 변환.
- `POST /sessions/{id}/stream` — SSE 경로(`run_turn_stream`). token/done/ping/error를 흘리고,
done 시점에 학습자 발화 + 누적 내담자 응답 + 상태를 먼저 영속화하고 즉시 완료한다. fast-loop 평가는
응답 경로 밖에서 같은 learner turn에 사후 저장한다. `sse_heartbeat_seconds`마다 ping(Cloudflare 타임아웃 회피).
응답 경로 밖에서 같은 learner turn에 사후 저장한다. provider 연결이 깨끗한 EOF로 닫혀도 canonical
gateway `done`이 없으면 `client_stream_incomplete` 오류로 끝내며 가짜 성공 턴을 저장하지 않는다.
`sse_heartbeat_seconds`마다 ping(Cloudflare 타임아웃 회피).
- `POST /sessions/{id}/live-coach` — 방금 완료된 상담자 발화를 워크북 요약/RAG/evaluator fast-loop 신호와 대조해
라이브 코칭 카드 1개를 반환하고 `app.live_coach_events``use/-1`로 저장한다. 저장 payload는 마스킹
excerpt와 코칭 구조화 JSON이며, 잔여 기회가 없으면 409로 차단한다.
@ -401,7 +412,9 @@ session lifecycle을 유지하며, future Node read API는 이 read-model contra
`kb.document/kb.chunk`로 색인한다. active hash가 같으면 skip하고, 다르면 최신 document version+1로 색인한다.
임베딩 모델이 없으면 BM25-only degraded 색인으로 진행한다.
- `POST /sessions/{id}/end``memory.make_carry_over(...)` → 세션 종료 + carry 준비 →
`_schedule_session_evaluation(sess)`로 deep-loop 평가를 비동기 태스크로 발사.
`_schedule_session_evaluation(sess)`로 deep-loop 평가를 비동기 태스크로 발사. 세션 종료 영속화가 평가보다
먼저 확정되므로 기존 회기의 deep 평가가 실패해 `error`로 남아도 그 리뷰는 degraded 상태로 읽히고,
같은 학습자는 새 회기를 생성해 턴을 이어갈 수 있다.
- 진행도 파생(P2, 2026-07-14): `session_read_model.build_session_progress(state, prev_rapport_credit,
goal_stages)`가 상태머신 수치를 학습자-안전 %로 파생한다 — 단계별 누적 게이지(현재 단계는
`rapport_credit / STAGE_ADVANCE_RAPPORT` 기준, 지나온 단계 100, 전이 대기 99 캡), 라포 누적
@ -413,7 +426,9 @@ session lifecycle을 유지하며, future Node read API는 이 read-model contra
- `GET /sessions/dashboard` — 학습자 본인 세션만 `include_turn_evaluation=true`로 집계해
`overview/growth/persona_progress/achievements/recent_feedback`를 반환한다. `overview.archived_sessions`
학습자별 보관 상태(`app.session_archive_state`) 기준 카운트이며, 보관된 종료 회기는 리뷰 대기 행동 큐에서
제외한다. 성취는 공식 등급/수료가 아니라 실제 연습 milestone만 표시한다.
제외한다. `growth.training_exposure`는 종료 회기만 집계하고 4회 미만이면 `insufficient`, 4회 이상에서
최다 페르소나 비중이 0.75 이상이면 `훈련 집중 주의`, 그 밖에는 `balanced`로 표시한다. 투명한 노출 비중이지
공정성·임상 진단이 아니다. 성취는 공식 등급/수료가 아니라 실제 연습 milestone만 표시한다.
- `GET /sessions/{id}/review` — 저장된 축어록 + 평가 AI 산출물을
`session_read_model.build_session_review(...)`가 학습자-안전 리뷰로 구성한다. 리뷰 조회 시 발화별 fast-loop 평가는
`app.feedback_scores`/라벨 조인 테이블에서 `TurnRecord.evaluation` 형태로 hydrate한다.
@ -423,10 +438,24 @@ session lifecycle을 유지하며, future Node read API는 이 read-model contra
`ReviewNote.body`는 제한 markdown(`code`, `strong`, blockquote, list)으로 렌더링하고,
`effective_openness` 같은 내부 수치는 `effective openness(유효 개방도)`로 학술 용어화한다.
상담자 질문/발화 근거는 `ReviewNote.quote`로 분리해 리뷰 카드 안에서 인용 블록으로 표시한다.
첫 회기에는 `first-session-rapport-open-question.v1` 체크리스트가 라포 반영과 한 초점 열린 질문을
결정론적으로 관찰하고 근거 turn을 연결한다. 관찰되지 않으면 `not_observed`, 2회기 이후면
`not_applicable`이며 임상 점수가 아닌 교육 체크리스트다.
학습자의 AI 피드백 노출은 현재 계정 설정과 세션 시작 시점 snapshot이 모두 켜진 경우에만 허용한다.
여러 회기를 합치는 학습자 집계도 같은 AND를 각 원천에 적용한다. `calibration/learners/me`는 과거 OFF
원천 또는 실행 회기가 하나라도 섞이면 learner-input-only로 fail-closed해 AI·교수자 파생값을 제거하고,
`practice/learners/me`는 처방·에피소드·최신 역량 그래프의 원천 회기 중 하나라도 OFF이면 403을 반환한다.
일반 practice attempt 제출도 현재 계정과 처방 원천 회기 snapshot이 모두 ON이어야 한다. 그 밖에 OFF인
순수 AI 파생 엔드포인트는 403이고 혼합 응답은 사용자가 입력한 calibration/alliance/multimodal 필드만
보존한 채 AI 결과·근거·측정을 제거한다. 축어록, 저장된 학습자 워크시트 원문, alliance 자기보고,
outcome 관찰 입력, calibration 예측·수정·잠금·실행 입력, multimodal 동의·철회·삭제·raw audio·privacy
ledger, privacy export/delete는 유지한다. teacher/admin 감독 뷰에는 이 learner gate를 적용하지 않는다.
- `POST /sessions/{id}/share` — 학습자가 종료된 본인 회기 리뷰를 URL로 공유하기 위해 공개 토큰을 생성한다.
서버는 `session_read_model.session_share_payload(...)`로 preview를 정규화한 뒤 `app.session_share_link`
토큰 해시와 sanitized preview payload만 저장하고, 원문 축어록·학습자
식별자는 payload에 넣지 않는다. 토큰은 생성 응답에서만 반환되며 새 생성은 기존 토큰을 교체한다.
학습자 AI 피드백이 현재 계정 또는 세션 snapshot에서 OFF이면 새 공유를 거부하고, ON일 때 이미 만든
토큰도 public load 시 같은 두 정책을 다시 확인해 404로 닫는다.
- `DELETE /sessions/{id}/share` — 해당 회기의 공개 공유 토큰을 폐기한다.
- `POST /sessions/{id}/archive` / `POST /sessions/{id}/restore` — 학습자 본인의 종료 회기를 보관/복원한다.
진행 중 회기는 409로 거부한다. 보관은 `app.session_archive_state`만 upsert/delete하는 보기 상태이며,
@ -513,7 +542,8 @@ GET {ENGINE_URL}/ready|/health — readiness/liveness
`scripts/check-engine-gateway-contract.mjs`는 같은 artifact를 Node.js에서 Python import 없이 검증하므로
미래 Node.js gateway의 최소 conformance gate로 쓴다.
`/v1/stream` 종료는 provider pass-through `data: [DONE]`가 아니라 `event: done` + JSON telemetry
payload로 변환해야 한다.
payload로 변환해야 한다. provider의 `[DONE]`은 호환 입력일 뿐 canonical 완료가 아니며, 구조화 `done`
없이 EOF가 오면 앱은 `client_stream_incomplete`로 실패 처리한다.
브라우저로 재방출되는 `/sessions/{id}/stream` SSE는 별도 앱 계약이며, engine gateway SSE의
JSON token payload와 섞지 않는다.
@ -549,6 +579,8 @@ HTTP error mapping을 유지한다.
- `get_approved_persona(code)` / `list_approved_personas()` — 승인된 최신 버전 조회(AI 컨텍스트).
- `get_catalog_persona(code)` — 승인 카드 조회, 실패 시 `settings.allow_seed_persona_fallback`이면
`seed_fallback_persona`(degraded=True)로 폴백.
- `persona_read_model._presenting_summary()` — 카드 JSON의 키 순서와 무관하게 `complaint`/`주호소` 계열을
우선해 제시문제를 구성한다. `surface` 같은 표면 상태를 주호소로 오인하지 않는다.
- 페르소나 워크스페이스: `/teach/personas`는 teacher/admin 전용 운영·저작 작업면이다. 상단 horizontal tab이
`대시보드`(공개/활성 페르소나·학습 인원·세션·평가/라포·검수 요약), `카탈로그`(공개 규칙·구성·버전),
`페르소나`(검색/필터 가능한 전체 table + 내부 `검수 현황` 탭)를 나눈다. 행을 누르면 소개·학습 현황·설정
@ -574,6 +606,21 @@ HTTP error mapping을 유지한다.
- 교수 검수: `list_persona_review_queue(role)` / `update_persona_review_status(action=approve|reject)`
— 승인/반려 시 `audit.audit_log`에 감사 기록.
### 2.13 개선관리 프로토콜 레지스트리 — `app/services/protocol_registry.py`
관리자 전용 `/admin/protocols`가 프로토콜 목록, draft 생성, 활성화, 폐기를 제공한다. 상태는
`draft → active → retired` 단방향이며 retired 항목은 다시 활성화하지 않는다.
- 생성 내용은 서버에서 정규화하고 SHA-256을 계산한다. 라이선스 분류 A/B/C/D와
`external_llm_ok`를 함께 저장하며 C/D는 요청·서비스·DB 제약 모두에서 외부 LLM 사용을 거부한다.
- 활성화는 한 트랜잭션에서 `kb.source`를 등록하고 RAG `kb.document/kb.chunk` 색인을 완료한 뒤에만
`active`로 바꾼다. 색인 실패는 전체 rollback하며 동시 활성화는 직렬화돼 하나만 성공한다.
- chunk는 `visible_to=['evaluator']`, `sensitivity=2`이고 라이선스·외부 LLM 허용 메타데이터를 보존한다.
외부 LLM에 보낼 수 없는 근거는 retrieval 후에도 prompt assembly에서 제외한다.
- 폐기는 연결 문서를 비활성화한 뒤 terminal `retired`로 전이하고, 중간 실패는 rollback한다.
- `ensure_protocol_tables()`는 migration 17 계약을 조회할 뿐 DDL을 만들지 않는다. 누락 시 적용해야 할
migration 파일을 명시해 fail-closed한다.
---
## 3. 엔진 게이트웨이 — `apps/api/engine_gateway/gateway.py`
@ -713,8 +760,8 @@ React 19 + Vite. 라우팅은 `apps/web/src/App.tsx`(react-router-dom).
## 5. 데이터베이스 스키마 개요
DB는 PostgreSQL 16 + pgvector(단일 SoR). 초기화 SQL은 `infra/db/init/`순서대로 적용된다
(`01_extensions → 02_schema → 03_kb → 04_audit_eval_rls → 05_runtime_auth → 06_session_evaluation → 07_measurement_foundation`).
DB는 PostgreSQL 16 + pgvector(단일 SoR). 초기화 SQL은 `infra/db/init/`번호순으로 적용되며,
개선관리 계약까지 필요한 현행 끝점은 `17_improvement_workbook_contracts.sql`이다.
스키마는 **4분할**: `app` / `kb` / `audit` / `ds`.
### 5.1 app 스키마 (`infra/db/init/02_schema.sql`)
@ -865,6 +912,14 @@ DB는 PostgreSQL 16 + pgvector(단일 SoR). 초기화 SQL은 `infra/db/init/`에
fact만 허용하고, 삽입/값 변경 history와 명시적 상담 약속 철회 contradiction까지 남긴다.
관계·임상 fact 승격과 광범위 자동 모순 판정은 후속이다.
### 5.1.1 개선관리 migration 17
`infra/db/init/17_improvement_workbook_contracts.sql``app.app_user``app.sessions`
`learner_feedback_enabled`, 프로토콜 레지스트리와 라이선스/외부 LLM 제약, lifecycle timestamp/index,
관리자 전용 `kb.chunk` write RLS를 멱등하게 추가한다. 새 DB volume은 번호순 init으로 적용되고,
기존 DB는 owner가 단일 트랜잭션으로 실행한다. 애플리케이션 역할 startup은 readiness만 확인하며 runtime
DDL로 빠진 계약을 보충하지 않는다. 누락되면 migration 17 적용을 요구하며 fail-closed한다.
### 5.2 audit 스키마 + app 평가/종단 (`infra/db/init/04_audit_eval_rls.sql`)
- 평가: `app.feedback_scores`(발화별 점수·rationale, `visible_to='{evaluator}'`, loop fast/deep),
@ -916,7 +971,8 @@ DB 레벨 이중강제(`04_audit_eval_rls.sql` §5, `app/db.py` `acquire()`):
그래서 public API가 로그인 시작과 콜백 사이에 재시작돼도 callback은 `invalid_state`로 실패하지 않고
Google token exchange 단계까지 가며, 쿠키 없는 외부 callback 주입은 차단된다.
- OAuth callback 실패는 authorization code, token, raw email을 남기지 않고 reason/status/도메인 수준 정보만
서버 로그에 남긴다. 프론트는 `token_exchange_failed`, `invalid_state`, provider error(`access_denied`/`provider_error`),
서버 로그에 남긴다. Uvicorn access logger에는 별도 필터를 설치해 `/auth/callback`과 후행 슬래시 변형의
query 전체를 제거하되 다른 요청의 query는 보존한다. 프론트는 `token_exchange_failed`, `invalid_state`, provider error(`access_denied`/`provider_error`),
identity claim 실패를 구분하고 실패 reason code를 화면에 함께 표시한다.
- 역할은 `AUTH_TEACHER_EMAILS`/`AUTH_ADMIN_EMAILS` email allowlist로 1차 판정한다.
실제 `admin` 역할 사용자는 관리자 콘솔, 교수자 공간, 학습자 공간에 모두 접근할 수 있다.
@ -927,6 +983,10 @@ DB 레벨 이중강제(`04_audit_eval_rls.sql` §5, `app/db.py` `acquire()`):
`AUTH_EMAIL_COHORT_MAP``AUTH_DOMAIN_COHORT_MAP` 설정, SAML fixture의 `cohort` claim을 합쳐
`cohort_ids`로 세션에 저장한다. 관리 사용자 `app_user.external_id`는 provider subject 기반
(`google:`/`saml:`/`dev:`)으로 저장해 email 변경 리스크를 줄인다.
- 관리자 외부 연구참여자 사전등록 `POST /admin/users`는 요청값과 무관하게 항상
`account_status=pending`으로 정확한 이메일 계정을 만든다. 승인·정지는 별도
`PATCH /admin/users/{user_id}`에서만 수행해 create와 승인 권한 효과를 분리한다. 이는 공개 무제한 가입
엔드포인트가 아니며 관리자 콘솔의 사전등록 탭과 승인 큐가 같은 경계를 따른다.
- 프론트의 최초 진입 경로(`initialPathForUser`)는 pending이면 `/pending`, 온보딩 미완료 일반 사용자는
`/onboarding`, 관리자 콘솔 접근권이 있는 사용자는 기본 역할이 learner/teacher여도 `/admin`을 우선한다.
역할 전환용 `roleHomePath`는 그대로 역할별 홈(`/learn`, `/teach`, `/admin`)만 소유한다.
@ -969,11 +1029,13 @@ DB 레벨 이중강제(`04_audit_eval_rls.sql` §5, `app/db.py` `acquire()`):
- OAuth/SAML callback은 저장된 `next`가 일반 진입 경로(`/`, `/learn`, `/teach`, `/login`, `/onboarding`)이고
로그인 사용자가 관리자 콘솔 접근권을 가지면 `/admin`으로 정규화한다. 단, `/learn/session/...` 같은 깊은
링크는 사용자가 의도적으로 연 URL일 수 있으므로 보존한다.
- `AUTH_ALLOWED_EMAIL_DOMAINS`는 기본 도메인 게이트다. 단, 슈퍼 관리자/관리자가 `/admin/users`
미리 만든 정확한 이메일은 도메인 밖이어도 Google/SAML/dev-login의 이메일 검증을 통과한다.
이 예외는 도메인 전체를 열지 않고, provider 로그인 시 기존 `email:<주소>` 관리 row를
`google:`/`saml:`/`dev:` external_id로 이어받아 역할·코호트·승인 상태를 보존한다.
- 신규 Google/SAML 사용자는 기본적으로 `account_status=pending`으로 생성된다
- Google OIDC는 ID token의 issuer·audience·서명 검증 경로와 `email_verified`를 통과한 모든 이메일을
도메인·사전등록 없이 허용한다. 신규 사용자는 learner·`approved`로 만들고, 기존 pending row를 Google
external id로 연결할 때도 approved로 승격한다. 기존 inactive/suspended 계정은 이 경로로 우회하지 못한다.
- `AUTH_ALLOWED_EMAIL_DOMAINS`는 dev-login·SAML 조직 정책용 게이트다. 슈퍼 관리자/관리자가
`/admin/users`에 미리 만든 정확한 이메일은 이 비-Google 경계에서 도메인 밖 예외로 허용하고,
`email:<주소>` 관리 row를 provider external id로 이어받아 역할·코호트·승인 상태를 보존한다.
- SAML 등 비-Google 신규 사용자는 기본적으로 `account_status=pending`으로 생성된다
(`AUTH_NEW_USER_DEFAULT_STATUS`). `/auth/me`만 pending 상태 확인용으로 열어두고, 그 외 REST/WS 기능
경로는 `account_pending` 또는 인증 실패로 막는다. `AUTH_APPROVED_EMAILS`, 관리자 생성 사용자,
`AUTH_SUPER_ADMIN_EMAILS`, 로컬 dev-login(`external_id=dev:*`)은 자동 approved다.
@ -1086,4 +1148,9 @@ RAG 임베딩/리랭커 의존성은 기본 슬림 이미지에 넣지 않고 `I
- 평가/정답/평가 전용 데이터는 RBAC×AIView(레이어1 `visible_to`) + RLS(레이어2)로 학습자 직접 조회에서 차단된다.
단, 서버 리뷰 응답은 소유 세션 확인 후 evaluator 컨텍스트로 필요한 평가 rows만 hydrate해 학습자-안전 형태로 가공한다.
- 평가/로깅 훅 실패는 비치명적으로 흡수되어 상담 루프를 멈추지 않는다.
- 회기 종료 영속화는 deep 평가보다 먼저 확정한다. 오래된 회기의 평가 실패는 error/degraded 리뷰로 남지만
새 회기 생성과 턴 저장을 막지 않는다.
- 학습자 AI 파생 피드백은 현재 계정 설정과 세션 snapshot의 논리 AND다. 현재 OFF가 과거 ON보다 우선하고,
순수 파생 API는 403, 기존 공개 share token은 404다. 사용자 원문/입력·privacy 조작은 보존하며
teacher/admin 감독 뷰는 유지한다.
```