Complete DDS training workflow and delivery package

This commit is contained in:
andreysk0304 2026-09-24 01:10:49 +03:00
commit 4c4b91064f
229 changed files with 11969 additions and 1024 deletions

View file

@ -11,10 +11,13 @@
import logging
import re
from datetime import datetime
from datetime import datetime, timedelta, timezone
from uuid import UUID
from xml.etree import ElementTree as ET
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
@ -27,13 +30,68 @@ from app.config import get_settings
from app.db.base import get_session
from app.db.models import AuditLog, Session as SessionRow, Trainee, User
from app.domain import ekp
from app.domain.roles import ROLE_LABELS, Role
from app.domain.roles import ROLE_LABELS, SCREENS, Role
from app.domain.timers import NORMATIVES
from app.dialog.llm import is_loopback_url
from app.monitoring import recent_events, sample_metrics
from app.session.hub import hub
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin", tags=["admin"])
def _configuration_xml() -> bytes:
"""Безопасный переносимый снимок конфигурации без паролей и ключей."""
settings = get_settings()
root = ET.Element("lctConfiguration", {"version": "1"})
ET.SubElement(root, "platform", {
"offline": str(settings.offline).lower(),
"voiceEnabled": str(settings.voice_enabled).lower(),
"secureCookies": str(settings.secure_cookies).lower(),
})
models = ET.SubElement(root, "localModels")
ET.SubElement(models, "dialogue", {"name": settings.llm_model_caller})
ET.SubElement(models, "russianControl", {
"name": settings.llm_model_control,
"grammarEnabled": str(settings.grammar_llm_enabled).lower(),
})
ET.SubElement(models, "speechToText", {
"name": settings.stt_model,
"enabled": str(settings.voice_enabled).lower(),
})
workstations = ET.SubElement(root, "workstations")
for role, screens in SCREENS.items():
workstation = ET.SubElement(workstations, "workstation", {
"role": role.value, "label": ROLE_LABELS[role],
})
for path in screens:
ET.SubElement(workstation, "screen", {"path": path})
timers = ET.SubElement(root, "timerLimits")
for code, normative in NORMATIVES.items():
ET.SubElement(timers, "timer", {
"code": code.value,
"milliseconds": str(settings.limit_ms(code)),
"defaultMilliseconds": str(normative.limit_ms),
})
reference = ekp.reference()
ET.SubElement(root, "ekp", {
"version": reference.version,
"incidents": str(len(reference.incidents)),
})
ET.indent(root, space=" ")
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
@router.get("/config.xml")
async def configuration_xml(request: Request) -> Response:
require(request, Role.ADMIN)
return Response(
content=_configuration_xml(),
media_type="application/xml",
headers={"Content-Disposition": 'attachment; filename="lct-workstations.xml"'},
)
class UserOut(BaseModel):
id: UUID
login: str
@ -47,7 +105,7 @@ class UserOut(BaseModel):
class UserCreate(BaseModel):
login: str = Field(min_length=3, max_length=80)
full_name: str = Field(min_length=1, max_length=120)
password: str = Field(min_length=8, description="Короткий пароль не заводится")
password: str = Field(min_length=8, max_length=1024, description="Пароль должен быть от 8 до 1024 символов")
role: Role
service: str | None = None
@ -59,7 +117,7 @@ class UserPatch(BaseModel):
role: Role | None = None
service: str | None = None
blocked: bool | None = None
password: str | None = Field(default=None, min_length=8)
password: str | None = Field(default=None, min_length=8, max_length=1024)
def _out(user: User) -> UserOut:
@ -148,8 +206,9 @@ async def patch_user(
if not changed:
return _out(user)
user.auth_version += 1
await db.commit()
invalidate_login(user.login)
invalidate_login(user.login, user.auth_version)
await audit(who.login, who.role.value, "user.update", user.login, ", ".join(changed))
return _out(user)
@ -169,12 +228,18 @@ async def audit_log(
action: str | None = None,
actor: str | None = None,
limit: int = 200,
offset: int = 0,
db: AsyncSession = Depends(get_session),
) -> list[AuditOut]:
"""Журнал действий. Администратор его читает, но не правит: точки удаления
или изменения записи здесь нет — ТЗ требует хранения, а не управления."""
require(request, Role.ADMIN)
query = select(AuditLog).order_by(AuditLog.at.desc()).limit(max(1, min(limit, 1000)))
query = (
select(AuditLog)
.order_by(AuditLog.at.desc(), AuditLog.id.desc())
.limit(max(1, min(limit, 1001)))
.offset(max(0, min(offset, 10_000_000)))
)
if action:
query = query.where(AuditLog.action == action)
if actor:
@ -195,6 +260,104 @@ class ServiceState(BaseModel):
detail: str
class RuntimeMetrics(BaseModel):
at: datetime
uptime_seconds: float
cpu_percent: float
load_1m_percent: float | None
cpu_cores: int
rss_bytes: int | None
memory_total_bytes: int | None
memory_available_bytes: int | None
disk_total_bytes: int
disk_free_bytes: int
threads: int
active_sessions: int
restored_sessions: int
completed_sessions_24h: int
class DiagnosticEvent(BaseModel):
at: datetime
level: str
source: str
message: str
class FailureEvent(BaseModel):
at: datetime
actor: str
action: str
object_id: str | None
detail: str
class DiagnosticReport(BaseModel):
generated_at: datetime
metrics: RuntimeMetrics
recent_system_events: list[DiagnosticEvent]
failed_actions_24h: list[FailureEvent]
async def _runtime_metrics(db: AsyncSession) -> RuntimeMetrics:
from app.main import app
raw = sample_metrics()
since = datetime.now(timezone.utc) - timedelta(hours=24)
completed = await db.scalar(
select(func.count()).select_from(SessionRow).where(SessionRow.ended_at >= since)
)
return RuntimeMetrics(
**raw,
active_sessions=sum(not item.ended for item in hub._sessions.values()), # noqa: SLF001
restored_sessions=getattr(app.state, "sessions_restored", 0),
completed_sessions_24h=completed or 0,
)
async def _diagnostic_report(db: AsyncSession) -> DiagnosticReport:
since = datetime.now(timezone.utc) - timedelta(hours=24)
rows = await db.scalars(
select(AuditLog)
.where(AuditLog.at >= since, AuditLog.action.like("%.failed"))
.order_by(AuditLog.at.desc())
.limit(200)
)
return DiagnosticReport(
generated_at=datetime.now(timezone.utc),
metrics=await _runtime_metrics(db),
recent_system_events=[DiagnosticEvent(**item) for item in recent_events(limit=100)],
failed_actions_24h=[
FailureEvent(
at=row.at, actor=row.actor, action=row.action,
object_id=row.object_id, detail=row.detail,
)
for row in rows
],
)
@router.get("/diagnostics", response_model=DiagnosticReport)
async def diagnostics(
request: Request, db: AsyncSession = Depends(get_session)
) -> DiagnosticReport:
"""Live load plus a bounded, redacted incident report for the admin."""
require(request, Role.ADMIN)
return await _diagnostic_report(db)
@router.get("/diagnostics.json")
async def download_diagnostics(
request: Request, db: AsyncSession = Depends(get_session)
) -> JSONResponse:
require(request, Role.ADMIN)
report = await _diagnostic_report(db)
return JSONResponse(
jsonable_encoder(report),
headers={"Content-Disposition": 'attachment; filename="lct-diagnostics.json"'},
)
@router.get("/status", response_model=list[ServiceState])
async def status(request: Request, db: AsyncSession = Depends(get_session)) -> list[ServiceState]:
"""Состояние компонентов стенда — то, что администратор смотрит до занятия,
@ -220,6 +383,21 @@ async def status(request: Request, db: AsyncSession = Depends(get_session)) -> l
detail="распознавание и синтез готовы" if models_ready else "не загружены: занятие пойдёт без голоса",
)
)
metrics = sample_metrics()
disk_free = metrics["disk_free_bytes"]
disk_total = max(metrics["disk_total_bytes"], 1)
disk_ok = disk_free >= 1024 ** 3 and disk_free / disk_total >= 0.05
load = metrics["load_1m_percent"]
states.append(ServiceState(
name="Нагрузка backend",
ok=disk_ok and (load is None or load < 100),
detail=(
f"CPU процесса {metrics['cpu_percent']:.1f}%; "
+ (f"нагрузка хоста {load:.1f}%; " if load is not None else "")
+ f"RAM процесса {(metrics['rss_bytes'] or 0) / 1024 ** 2:.0f} МБ; "
+ f"свободно на диске {disk_free / 1024 ** 3:.1f} ГБ"
),
))
states.append(
ServiceState(
name="Эмбеддинги",
@ -228,11 +406,20 @@ async def status(request: Request, db: AsyncSession = Depends(get_session)) -> l
else "нет модели: подсказки идут по порядку чек-листа",
)
)
llm_configured = (
is_loopback_url(
settings.llm_base_url,
allow_docker_host=settings.allow_docker_host_models,
)
if settings.offline or settings.llm_provider == "local"
else bool(settings.llm_api_key and settings.llm_base_url)
)
states.append(
ServiceState(
name="Провайдер LLM",
ok=bool(settings.llm_api_key and settings.llm_base_url),
detail=settings.llm_base_url or "не настроен: звонящий читает офлайн-таблицу",
ok=llm_configured,
detail=(f"локальный адрес разрешён: {settings.llm_base_url}"
if llm_configured else "не настроен: звонящий читает офлайн-таблицу"),
)
)
@ -248,9 +435,39 @@ async def status(request: Request, db: AsyncSession = Depends(get_session)) -> l
ServiceState(
name="Живых занятий",
ok=True,
detail=str(len(hub._sessions)), # noqa: SLF001 — реестр в памяти процесса
detail=(
f"активно {sum(not item.ended for item in hub._sessions.values())}; "
f"восстановлено после запуска {getattr(app.state, 'sessions_restored', 0)}"
), # noqa: SLF001 — реестр в памяти процесса
)
)
try:
copies = backup_service.listing()
except backup_service.BackupError as exc:
states.append(ServiceState(
name="Резервное копирование",
ok=False,
detail=f"ошибка чтения копий: {exc}",
))
copies = []
if copies:
latest = copies[0]
age_seconds = max(0.0, (datetime.now(timezone.utc) - latest["at"]).total_seconds())
allowed_age = max(60, settings.backup_interval_seconds) + max(
60, settings.backup_retry_seconds
)
states.append(ServiceState(
name="Резервное копирование",
ok=age_seconds <= allowed_age,
detail=(f"последняя копия {latest['name']}, "
f"{age_seconds / 3600:.1f} ч назад; хранится {len(copies)}"),
))
else:
states.append(ServiceState(
name="Резервное копирование",
ok=False,
detail="успешных копий ещё нет",
))
# Секрет сессии по умолчанию — не ошибка запуска, но на стенде это дыра,
# и увидеть её должен администратор, а не проверяющий.
default_secret = settings.session_secret.startswith("dev-secret")
@ -273,7 +490,12 @@ class BackupOut(BaseModel):
@router.get("/backups", response_model=list[BackupOut])
async def backups(request: Request) -> list[BackupOut]:
require(request, Role.ADMIN)
return [BackupOut(**item) for item in await run_in_threadpool(backup_service.listing)]
try:
copies = await run_in_threadpool(backup_service.listing)
except backup_service.BackupError as exc:
detail = _safe_backup_error(exc)
raise HTTPException(status_code=503, detail=detail) from exc
return [BackupOut(**item) for item in copies]
def _safe_backup_error(exc: backup_service.BackupError) -> str: