Complete training workflow and acceptance hardening

This commit is contained in:
andreysk0304 2026-09-26 18:12:27 +03:00 • committed by gglamer
commit 7237265833
243 changed files with 17014 additions and 1500 deletions

View file

@ -2,22 +2,21 @@
import asyncio
import logging
from contextlib import asynccontextmanager
from contextlib import asynccontextmanager, suppress
from pathlib import Path
from fastapi import FastAPI
from sqlalchemy.exc import SQLAlchemyError
from starlette.middleware.sessions import SessionMiddleware
from pathlib import Path
from app.api import auth
from app.api.http import admin as admin_api
from app.api.http import ekp as ekp_api
from app.api.http import groups as groups_api
from app.api.http import materials as materials_api
from app.api.http import scenario_submissions as scenario_submissions_api
from app.api.http import scenarios as scenarios_api
from app.api.http import sessions
from app.api.http import trainees
from app.api.http import sessions, trainees
from app.api.ws import call as call_ws
from app.api.ws import control as control_ws
from app.api.ws import observe as observe_ws
@ -25,13 +24,12 @@ from app.api.ws import station as station_ws
from app.config import get_settings
from app.db.base import get_sessionmaker
from app.dialog.runtime import get_embedder
from app.voice.models import get_voice_models
from app.scenarios import store
from app.session.hub import hub
from app.session.journal import DbJournal
from app.scenarios.loader import ScenarioError
from app.monitoring import install_diagnostics
from app.scenarios import store
from app.scenarios.loader import ScenarioError
from app.session.hub import hub
from app.session.journal import DbJournal, SessionLeaseLost
from app.voice.models import get_voice_models
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
@ -46,6 +44,10 @@ install_diagnostics()
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
try:
settings.validate_deployment_security()
except ValueError as exc:
raise RuntimeError(str(exc)) from exc
if settings.demo_no_db and not settings.dev_auth_bypass:
raise RuntimeError("DEMO_NO_DB требует DEV_AUTH_BYPASS=true для локального входа")
# Библиотека проверяется на старте целиком: сломанный сценарий, найденный
@ -58,6 +60,7 @@ async def lifespan(app: FastAPI):
if settings.demo_no_db:
store.reset_demo_drafts()
materials_api.reset_demo_materials()
scenario_submissions_api.reset_demo_submissions()
# Утверждённые преподавателем сценарии хранятся в БД и должны переживать
# перезапуск процесса. При недоступной БД остаётся базовая YAML-библиотека.
@ -87,7 +90,10 @@ async def lifespan(app: FastAPI):
)
# Журнал: всё, что не записано, для оценки не существует.
hub.journal = None if settings.demo_no_db else DbJournal(get_sessionmaker())
hub.journal = (
None if settings.demo_no_db
else DbJournal(get_sessionmaker(), node_id=settings.backend_node_id)
)
app.state.sessions_restored = 0
if hub.journal is not None:
try:
@ -106,17 +112,81 @@ async def lifespan(app: FastAPI):
"не удалось восстановить активные занятия: %s", exc
)
lease_task = None
if hub.journal is not None and settings.backend_node_id:
async def supervise_session_ownership() -> None:
while True:
await asyncio.sleep(5)
journal = hub.journal
if journal is None:
return
for state in list(hub._sessions.values()):
if state.ended or state.lease_fenced:
continue
try:
await journal.renew(state.session_id)
except SessionLeaseLost:
await hub.fence(state)
except (SQLAlchemyError, OSError, TimeoutError):
logging.getLogger(__name__).warning(
"backend lease renewal failed for %s", state.session_id,
exc_info=True,
)
await hub.fence(state)
except Exception: # noqa: BLE001 — unknown ownership state fails closed
logging.getLogger(__name__).exception(
"unexpected backend lease failure for %s", state.session_id
)
await hub.fence(state)
try:
restored = await journal.claim_expired()
for state in restored:
current = hub._sessions.get(state.session_id)
if current is not None and not current.lease_fenced:
continue
if current is not None:
hub.stop_ticker(state.session_id)
hub.register(state)
hub.start_ticker(state.session_id)
except (SQLAlchemyError, OSError, TimeoutError):
logging.getLogger(__name__).exception(
"не удалось проверить/восстановить занятия с истёкшей backend lease"
)
lease_task = asyncio.create_task(
supervise_session_ownership(), name="session-owner-lease-supervisor"
)
# Эмбеддинги для слот-автомата — грузятся один раз, до первого занятия.
app.state.embeddings_ready = not settings.demo_no_db and get_embedder() is not None
# Модели речи: ~5 секунд на старте стенда вместо паузы на первом звонке.
app.state.models_ready = get_voice_models() is not None
generation_watcher = (
asyncio.create_task(auth.watch_generations(), name="auth-generation-sync")
if not settings.demo_no_db else None
)
yield
if generation_watcher is not None:
generation_watcher.cancel()
with suppress(asyncio.CancelledError):
await generation_watcher
if lease_task is not None:
lease_task.cancel()
with suppress(asyncio.CancelledError):
await lease_task
if hub.journal is not None:
for state in list(hub._sessions.values()):
if not state.ended:
await hub.journal.checkpoint(state)
try:
await hub.journal.checkpoint(state)
except Exception: # noqa: BLE001 — shutdown must release the process
logging.getLogger(__name__).exception(
"не удалось сохранить checkpoint %s при shutdown", state.session_id
)
await hub.shutdown()
for state in list(hub._sessions.values()):
if state.voice is not None:
@ -126,6 +196,9 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="Учебный симулятор занятия для системы 112", lifespan=lifespan)
# Сессия ставится до роутеров: роль должна быть известна и на HTTP, и в момент
# рукопожатия сокета, иначе проверять её в канале будет нечем (app/api/auth.py).
app.add_middleware(
auth.AuthVersionMiddleware,
)
app.add_middleware(
SessionMiddleware,
secret_key=get_settings().session_secret,
@ -136,6 +209,7 @@ app.add_middleware(
app.include_router(auth.router)
app.include_router(sessions.router)
app.include_router(scenarios_api.router)
app.include_router(scenario_submissions_api.router)
app.include_router(ekp_api.router)
app.include_router(groups_api.router)
app.include_router(materials_api.router)