vignette/apps/api/app/main.py
Yun Chan 29c406d89f Jev 내담자 평가·표현 v2와 속마음 공개
상담자 발화 판정(A)·감정(B)·표현(C) 20문항 질문 세트, 감쇠 없는 이번 턴 반응과 비대칭 기분 전이, 개방도 게이트로 생성 지시를 만들고 ccd.coping_strategy 전달 누락을 고친다.

trace v2와 고정 문구 속마음 요약(migration 24, AI 경로 차단 RLS)을 같은 트랜잭션에 저장하고 피드백 정책이 켜진 경우에만 done·TurnResponse·음성 reply·리뷰로 노출한다. 회기 화면 속마음 보기 토글, 리뷰 접힘 블록, 관리자 감정 관측 v2 표시를 추가한다.
2026-09-30 13:23:09 +09:00

448 lines
18 KiB
Python

"""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 .services.jev_client import jev_client
from .runtime_schema import (
CALIBRATION_TRANSFER_SCHEMA_CONTRACT,
CLIENT_AFFECT_TRACE_SCHEMA_CONTRACT,
CLIENT_INNER_REACTION_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
client_affect_trace_schema_ready = False
client_inner_reaction_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:
client_affect_trace_schema_ready = await schema_contract_ready(
conn,
CLIENT_AFFECT_TRACE_SCHEMA_CONTRACT,
)
client_inner_reaction_schema_ready = await schema_contract_ready(
conn,
CLIENT_INNER_REACTION_SCHEMA_CONTRACT,
)
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(
CLIENT_AFFECT_TRACE_SCHEMA_CONTRACT,
ready=client_affect_trace_schema_ready,
):
logger.warning(
"감정 관측 trace 스키마가 불완전해 Jev 전이 trace를 저장할 수 없음; "
"infra/db/init/23_client_affect_trace.sql 적용 필요"
)
if runtime_schema_bootstrap_required(
CLIENT_INNER_REACTION_SCHEMA_CONTRACT,
ready=client_inner_reaction_schema_ready,
):
logger.warning(
"속마음 요약 스키마가 불완전해 Jev 활성 턴이 저장 단계에서 실패할 수 있음; "
"infra/db/init/24_client_inner_reaction.sql 적용 필요"
)
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()
await jev_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 jev_client.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"),
"client_affect_provider": settings.client_affect_provider,
"jev": {
"configured": jev_client.configured,
"live_verified": False,
"provider": settings.jev_provider,
"model": settings.jev_model,
},
"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}
),
}