395 lines
16 KiB
Python
395 lines
16 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 .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=["*"],
|
|
)
|
|
|
|
# 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}
|
|
),
|
|
}
|