"""Vignette FastAPI 앱 엔트리포인트. Dockerfile CMD: uvicorn app.main:app --host 0.0.0.0 --port 8000 소유: 상태머신·가드레일·엔진 어댑터 호출부·SSE 스트리머·RBAC (마스터플랜 §5). 엔진 게이트웨이(engine_gateway/)는 람다 직접 소유 — 여기선 engine_client 로 호출만. """ from __future__ import annotations from contextlib import asynccontextmanager import logging from dataclasses import asdict from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from . import __version__ from .access_logging import install_uvicorn_access_log_redaction from .auth_sessions import ensure_runtime_tables from .config import settings from .db import acquire, close_pool, get_pool, healthcheck, init_pool from .engine_client import engine_client from .persona_repository import materialize_seed_personas from .session_persistence import ensure_review_tables from .runtime_schema import ( CALIBRATION_TRANSFER_SCHEMA_CONTRACT, CONTINUOUS_IMPROVEMENT_SCHEMA_CONTRACT, DELIBERATE_PRACTICE_SCHEMA_CONTRACT, MEASUREMENT_SCHEMA_CONTRACT, MULTIMODAL_ALLIANCE_SCHEMA_CONTRACT, OUTCOME_TRAJECTORY_SCHEMA_CONTRACT, RUPTURE_REPAIR_SCHEMA_CONTRACT, SUPERVISION_RESEARCH_SCHEMA_CONTRACT, runtime_schema_bootstrap_required, schema_contract_ready, ) from .routes import auth as auth_routes from .routes import calibration_transfer as calibration_transfer_routes from .routes import continuous_improvement as continuous_improvement_routes from .routes import admin as admin_routes from .routes import client_diagnostics as client_diagnostics_routes from .routes import deliberate_practices as deliberate_practice_routes from .routes import eval as eval_routes from .routes import kb as kb_routes from .routes import multimodal_alliance as multimodal_alliance_routes from .routes import measurements as measurement_routes from .routes import outcome_trajectories as outcome_trajectory_routes from .routes import personas as persona_routes from .routes import protocols as protocol_routes from .routes import rupture_repairs as rupture_repair_routes from .routes import supervision_research as supervision_research_routes from .routes import sessions as session_routes from .routes import share as share_routes from .routes import teacher as teacher_routes from .routes import users as user_routes from .routes import voice as voice_routes from .services.notifications import ensure_notification_tables from .services.protocol_registry import ensure_protocol_tables from .services import ( alliance_measurement, continuous_improvement_producer, supervision_research_producer, ) from .services.voice import voice_service from .upload_runtime import ( FlatPublicAvatarStaticFiles, public_avatar_root, upload_manifest_proof, upload_write_freeze_gate, validate_runtime_upload_database_state, ) logger = logging.getLogger(__name__) install_uvicorn_access_log_redaction() @asynccontextmanager async def lifespan(app: FastAPI): """startup: DB 풀 + 엔진 클라이언트 / shutdown: 정리. DB 미가용(Docker off / NAS 연결불가)이면 store 인메모리 폴백으로 degraded 기동한다. """ measurement_schema_ready = False outcome_trajectory_schema_ready = False rupture_repair_schema_ready = False deliberate_practice_schema_ready = False calibration_transfer_schema_ready = False supervision_research_schema_ready = False continuous_improvement_schema_ready = False multimodal_alliance_schema_ready = False app.state.upload_database_proof = None try: await init_pool() if upload_manifest_proof is not None: app.state.upload_database_proof = ( await validate_runtime_upload_database_state(get_pool()) ) await ensure_runtime_tables() await ensure_review_tables() await ensure_notification_tables() await ensure_protocol_tables() async with acquire(role="admin") as conn: measurement_schema_ready = await schema_contract_ready( conn, MEASUREMENT_SCHEMA_CONTRACT, ) outcome_trajectory_schema_ready = await schema_contract_ready( conn, OUTCOME_TRAJECTORY_SCHEMA_CONTRACT, ) rupture_repair_schema_ready = await schema_contract_ready( conn, RUPTURE_REPAIR_SCHEMA_CONTRACT, ) deliberate_practice_schema_ready = await schema_contract_ready( conn, DELIBERATE_PRACTICE_SCHEMA_CONTRACT, ) calibration_transfer_schema_ready = await schema_contract_ready( conn, CALIBRATION_TRANSFER_SCHEMA_CONTRACT, ) supervision_research_schema_ready = await schema_contract_ready( conn, SUPERVISION_RESEARCH_SCHEMA_CONTRACT, ) continuous_improvement_schema_ready = await schema_contract_ready( conn, CONTINUOUS_IMPROVEMENT_SCHEMA_CONTRACT, ) multimodal_alliance_schema_ready = await schema_contract_ready( conn, MULTIMODAL_ALLIANCE_SCHEMA_CONTRACT, ) if runtime_schema_bootstrap_required( MEASUREMENT_SCHEMA_CONTRACT, ready=measurement_schema_ready, ): logger.warning( "Outcome/Alliance 측정 스키마가 불완전해 측정 경로를 시작하지 않음; " "infra/db/init/07_measurement_foundation.sql 적용 필요" ) if runtime_schema_bootstrap_required( OUTCOME_TRAJECTORY_SCHEMA_CONTRACT, ready=outcome_trajectory_schema_ready, ): logger.warning( "종단 성과 궤적 스키마가 불완전해 G2 경로가 실패할 수 있음; " "infra/db/init/08_outcome_trajectory.sql 적용 필요" ) if runtime_schema_bootstrap_required( RUPTURE_REPAIR_SCHEMA_CONTRACT, ready=rupture_repair_schema_ready, ): logger.warning( "파열·수선 원장 스키마가 불완전해 G3 경로가 실패할 수 있음; " "infra/db/init/09_rupture_repair.sql 적용 필요" ) if runtime_schema_bootstrap_required( DELIBERATE_PRACTICE_SCHEMA_CONTRACT, ready=deliberate_practice_schema_ready, ): logger.warning( "숙의 연습 원장 스키마가 불완전해 G4 경로가 실패할 수 있음; " "infra/db/init/10_deliberate_practice.sql 적용 필요" ) if runtime_schema_bootstrap_required( CALIBRATION_TRANSFER_SCHEMA_CONTRACT, ready=calibration_transfer_schema_ready, ): logger.warning( "자기보정·전이 원장 스키마가 불완전해 G5 경로가 실패할 수 있음; " "infra/db/init/11_calibration_transfer.sql 적용 필요" ) if runtime_schema_bootstrap_required( SUPERVISION_RESEARCH_SCHEMA_CONTRACT, ready=supervision_research_schema_ready, ): logger.warning( "감독·연구 원장 스키마가 불완전해 G6 경로가 실패할 수 있음; " "infra/db/init/12_supervision_research.sql 적용 필요" ) if runtime_schema_bootstrap_required( CONTINUOUS_IMPROVEMENT_SCHEMA_CONTRACT, ready=continuous_improvement_schema_ready, ): logger.warning( "지속 개선 승인 원장 스키마가 불완전해 G8 경로가 실패할 수 있음; " "infra/db/init/14_continuous_improvement.sql 적용 필요" ) if runtime_schema_bootstrap_required( MULTIMODAL_ALLIANCE_SCHEMA_CONTRACT, ready=multimodal_alliance_schema_ready, ): logger.warning( "멀티모달 동맹 원장 스키마가 불완전해 G7 경로가 실패할 수 있음; " "infra/db/init/13_multimodal_alliance.sql 적용 필요" ) if settings.auto_seed_personas: await materialize_seed_personas() await admin_routes.apply_engine_config_from_store() except Exception as exc: # DB 없어도 store 폴백으로 1턴 동작 (dev/로컬) if settings.environment != "dev" or upload_manifest_proof is not None: raise import logging logging.getLogger("uvicorn.error").warning( "DB 풀 초기화 실패 — store 인메모리 폴백으로 degraded 기동: %s", exc ) await engine_client.startup() if measurement_schema_ready: try: recovered = await alliance_measurement.recover_pending_alliance_pulses() if recovered: logger.info("미완료 Alliance Pulse %d건 재예약", recovered) except Exception: if settings.environment != "dev": raise logger.exception("미완료 Alliance Pulse 복구 예약 실패") await voice_service.startup() session_routes.schedule_missing_session_evaluation_recovery() if supervision_research_schema_ready: supervision_research_producer.schedule_supervision_research_producer() if continuous_improvement_schema_ready: continuous_improvement_producer.schedule_continuous_improvement_producer() try: yield finally: await continuous_improvement_producer.stop_continuous_improvement_producer() await supervision_research_producer.stop_supervision_research_producer() session_routes.cancel_missing_session_evaluation_recovery() await voice_service.shutdown() await engine_client.shutdown() try: await close_pool() except Exception: pass app = FastAPI( title="Vignette API", version=__version__, description="상담 시뮬레이션 훈련 플랫폼 백엔드 (FastAPI · 상태머신 · 가드레일 · RBAC)", lifespan=lifespan, ) # CORS — 정적 프론트(chanpaca.net) + SSE 분리경로(stream.chanpaca.net) app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins, allow_credentials=True, # __Host- HttpOnly 쿠키 전송 allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], allow_headers=["*"], ) @app.middleware("http") async def no_store_sensitive_responses(request, call_next): """민감 데이터 응답을 HTTP 캐시에서 제외한다. /personas·/admin 응답은 세션 쿠키가 붙은 요청에만 개인·임상 데이터를 담는다. 어떤 중간 캐시가 이 응답을 재사용하면 미인증 요청에 데이터가 새어나가므로 (2026-09-11 E2E에서 일시 재현) no-store로 강제한다. """ response = await call_next(request) path = request.url.path if path.startswith("/personas") or path.startswith("/admin"): response.headers["Cache-Control"] = "no-store" return response # TODO: Presidio PII 마스킹 미들웨어 (외부 LLM 경로 진입 전 하드 게이트, R7/F-03) app.mount( "/uploads/profile-avatars", FlatPublicAvatarStaticFiles(directory=str(public_avatar_root)), name="profile-avatars", ) app.include_router(auth_routes.router) app.include_router(calibration_transfer_routes.router) app.include_router(continuous_improvement_routes.router) app.include_router(client_diagnostics_routes.router) app.include_router(admin_routes.router) app.include_router(protocol_routes.router) app.include_router(persona_routes.router) app.include_router(session_routes.router) app.include_router(measurement_routes.router) app.include_router(multimodal_alliance_routes.router) app.include_router(outcome_trajectory_routes.router) app.include_router(rupture_repair_routes.router) app.include_router(deliberate_practice_routes.router) app.include_router(supervision_research_routes.router) app.include_router(share_routes.router) app.include_router(teacher_routes.router) app.include_router(user_routes.router) # Features 트랙 스텁 라우터(evaluator/voice/rag 가 채움). 등록만 — import 가능 보장. app.include_router(eval_routes.router) app.include_router(voice_routes.router) app.include_router(kb_routes.router) @app.get("/health", tags=["meta"]) async def health() -> dict[str, object]: """liveness + DB + 엔진 게이트웨이 readiness.""" db_ok = await healthcheck() engine = await engine_client.health_detail() engine_ok = bool(engine.get("ok")) upload_freeze = asdict(upload_write_freeze_gate.status()) upload_database_proof = getattr(app.state, "upload_database_proof", None) upload_write_gate_ok = ( not bool(upload_freeze["capable"]) or bool(upload_freeze["valid"]) ) upload_database_ok = ( upload_manifest_proof is None or ( upload_database_proof is not None and upload_database_proof.database_target_sha256 == upload_manifest_proof.database_target_sha256 ) ) return { "status": ( "ok" if db_ok and engine_ok and upload_write_gate_ok and upload_database_ok else "degraded" ), "version": __version__, "environment": settings.environment, "db": db_ok, "engine": engine_ok, "engine_detail": engine.get("detail"), "engine_mode": engine_client.engine_mode, "live_client_provider": ( engine_client.live_client_provider or engine_client.engine_mode ), "default_engine": engine.get("default_engine"), "live_client_engine": engine.get("live_client_engine"), "upload_write_freeze": upload_freeze, "upload_manifest": ( { "required": True, "validated": True, "manifest_sha256": upload_manifest_proof.manifest_sha256, "database_target_sha256": ( upload_manifest_proof.database_target_sha256 ), "preserved_object_count": ( upload_manifest_proof.preserved_object_count ), "preserved_total_size_bytes": ( upload_manifest_proof.preserved_total_size_bytes ), "preserved_decode_valid_count": ( upload_manifest_proof.preserved_decode_valid_count ), "preserved_decode_invalid_count": ( upload_manifest_proof.preserved_decode_invalid_count ), "required_decode_invalid_object_count": ( upload_manifest_proof.required_decode_invalid_object_count ), "required_decode_invalid_reference_count": ( upload_manifest_proof.required_decode_invalid_reference_count ), "forensic_fallback_active": ( upload_manifest_proof.required_decode_invalid_reference_count > 0 or ( upload_database_proof is not None and upload_database_proof.current_references.decode_invalid_reference_count > 0 ) ), "preserved_object_set_sha256": ( upload_manifest_proof.preserved_object_set_sha256 ), "required_object_count": upload_manifest_proof.required_object_count, "reference_set_sha256": upload_manifest_proof.reference_set_sha256, "runtime_database_validated": upload_database_ok, "current_object_count": ( upload_database_proof.current_references.object_count if upload_database_proof is not None else None ), "current_reference_count": ( upload_database_proof.current_references.reference_count if upload_database_proof is not None else None ), "current_reference_set_sha256": ( upload_database_proof.current_references.reference_set_sha256 if upload_database_proof is not None else None ), "current_decode_invalid_object_count": ( upload_database_proof.current_references.decode_invalid_object_count if upload_database_proof is not None else None ), "current_decode_invalid_reference_count": ( upload_database_proof.current_references.decode_invalid_reference_count if upload_database_proof is not None else None ), } if upload_manifest_proof is not None else {"required": False, "validated": False} ), }