Complete DDS training workflow and delivery package
This commit is contained in:
parent
68dd83c7c2
commit
4c4b91064f
229 changed files with 11969 additions and 1024 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue