상담자 발화 판정(A)·감정(B)·표현(C) 20문항 질문 세트, 감쇠 없는 이번 턴 반응과 비대칭 기분 전이, 개방도 게이트로 생성 지시를 만들고 ccd.coping_strategy 전달 누락을 고친다. trace v2와 고정 문구 속마음 요약(migration 24, AI 경로 차단 RLS)을 같은 트랜잭션에 저장하고 피드백 정책이 켜진 경우에만 done·TurnResponse·음성 reply·리뷰로 노출한다. 회기 화면 속마음 보기 토글, 리뷰 접힘 블록, 관리자 감정 관측 v2 표시를 추가한다.
448 lines
18 KiB
Python
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}
|
|
),
|
|
}
|