Complete training workflow and acceptance hardening
This commit is contained in:
parent
4c4b91064f
commit
7237265833
243 changed files with 17014 additions and 1500 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue