feat: LLM-анализ — индикатор прогресса, кнопка рескана, статистика на дашборде

- Добавлен статус {"status": "analyzing"} в finding.report на время LLM-анализа
- Кнопка рескана (Retry) под LLM-отчётом в ручном режиме
- LLM-статистика на дашборде: analysed / pending
- Защита от двойного анализа через per-finding asyncio.Lock
- _llm_spinner.html — фрагмент спиннера для состояния analysing
- Удалён мёртвый код: constants, i18n, CSS, queries
- Фиксы: _env_int, индексы БД, UnicodeDecodeError, time.mktime и др.
- Шаблоны: shared includes (_status_badge, _pagination)
- AGENTS.md: workflow (lint, test, commit, rebuild)
This commit is contained in:
Marker689
2026-05-10 09:54:04 +03:00
parent c99a7bf67c
commit 6984844161
26 changed files with 261 additions and 266 deletions

View File

@@ -1,5 +1,6 @@
"""Web UI routes — Jinja2 + htmx pages."""
import asyncio
from urllib.parse import unquote
from fastapi import APIRouter, Depends, Request
@@ -8,6 +9,7 @@ from jinja2 import Environment, PackageLoader, select_autoescape
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import config
from ..constants import (
APP_PACKAGE,
DEFAULT_SORT_BY_PACKAGES,
@@ -26,11 +28,15 @@ from ..i18n import t as _t
router = APIRouter(tags=["web"])
_llm_locks: dict[int, asyncio.Lock] = {}
_llm_lock = asyncio.Lock()
_jinja_env = Environment(
loader=PackageLoader(APP_PACKAGE, "web/templates"),
autoescape=select_autoescape(),
)
_jinja_env.globals["t"] = _t
_jinja_env.globals["config"] = config
def _render(name: str, **context) -> HTMLResponse:
@@ -109,7 +115,7 @@ async def scan_detail(
.options(selectinload(Scan.findings))
)
if not scan:
return HTMLResponse("<h1>Not found</h1>", status_code=404)
return HTMLResponse(f"<h1>{_t('not_found', request.state.lang)}</h1>", status_code=404)
return _render("scan_detail.html", scan=scan, request=request)
@@ -186,7 +192,7 @@ async def package_detail(
)
if not scans:
return HTMLResponse("<h1>Not found</h1>", status_code=404)
return HTMLResponse(f"<h1>{_t('not_found', request.state.lang)}</h1>", status_code=404)
all_findings = []
for s in scans:
@@ -205,31 +211,72 @@ async def package_detail(
@router.post("/api/v1/findings/{finding_id}/analyze", response_class=HTMLResponse)
async def analyze_finding_htmx(
finding_id: int,
request: Request,
retry: bool = False,
session: AsyncSession = Depends(get_session),
):
"""HTMX fragment: trigger LLM analysis and return styled result HTML."""
from ..config import config
from ..core.llm import analyze_finding
lang = request.state.lang
if not config.llm_enabled:
msg = _t("llm_disabled", lang)
return HTMLResponse(
'<div class="llm-actions"><small class="flagged">LLM analysis is disabled</small></div>'
f'<div class="llm-actions"><small class="flagged">{msg}</small></div>'
)
finding = await session.scalar(select(Finding).where(Finding.id == finding_id))
if not finding:
msg = _t("llm_not_found", lang)
return HTMLResponse(
'<div class="llm-actions"><small class="flagged">Finding not found</small></div>',
f'<div class="llm-actions"><small class="flagged">{msg}</small></div>',
status_code=404,
)
report = await analyze_finding(finding.data)
if not retry and finding.report and finding.report.get("verdict"):
return _render(
"_llm_report_fragment.html",
report=finding.report,
finding_id=finding_id,
request=request,
)
if not retry and finding.report and finding.report.get("status") == "analyzing":
return _render("_llm_spinner.html", request=request)
async with _llm_lock:
if finding_id not in _llm_locks:
_llm_locks[finding_id] = asyncio.Lock()
lock = _llm_locks[finding_id]
if lock.locked():
return _render("_llm_spinner.html", request=request)
async with lock:
try:
finding.report = {"status": "analyzing"}
await session.commit()
report = await analyze_finding(finding.data)
finally:
async with _llm_lock:
_llm_locks.pop(finding_id, None)
if report is None:
finding.report = None
await session.commit()
msg = _t("llm_failed", lang)
return HTMLResponse(
'<div class="llm-actions"><small class="flagged">LLM analysis failed</small></div>'
f'<div class="llm-actions"><small class="flagged">{msg}</small></div>'
)
finding.report = report
await session.commit()
return _render("_llm_report_fragment.html", report=report)
return _render(
"_llm_report_fragment.html",
report=report,
finding_id=finding_id,
request=request,
)