160 lines
5.7 KiB
Python
160 lines
5.7 KiB
Python
"""Frontend analysis-result contract (camelCase) for the SSE events endpoint.
|
|
|
|
Mirrors the web client's Zod schema (`analysisResultSchema` / `analysisIssueSchema`):
|
|
|
|
analysisResultSchema = z.object({
|
|
id, fileName, fileType, fileSize,
|
|
status: 'pending' | 'processing' | 'completed' | 'failed',
|
|
issues: analysisIssue[],
|
|
summary, riskScore (0-10), createdAt, completedAt: string | null,
|
|
})
|
|
|
|
Backend document statuses are normalized to that enum; report findings
|
|
(`core.analysis.report_schema.Finding`) are mapped to `analysisIssueSchema`.
|
|
`riskScore` and `summary` are not stored — they are derived from the issue
|
|
list on completion (severity-weighted score, counts-based summary line).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
from typing import Any, Literal
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
from src.contract_check.core.analysis.analyzer import checklist_title
|
|
from src.contract_check.core.logging import get_logger
|
|
|
|
log = get_logger(__name__)
|
|
|
|
AnalysisStatus = Literal["pending", "processing", "completed", "failed"]
|
|
IssueSeverity = Literal["critical", "warning", "info"]
|
|
|
|
# Backend documents.status -> analysisResultSchema.status.
|
|
_STATUS_MAP: dict[str, AnalysisStatus] = {
|
|
"queued": "pending",
|
|
"extracting": "processing",
|
|
"prescreening": "processing",
|
|
"ocr": "processing",
|
|
"analyzing": "processing",
|
|
"done": "completed",
|
|
# Terminal without a report; surfaced to the client as a completed,
|
|
# empty-issues result (prescreen routed it away from deep analysis).
|
|
"manual_review": "completed",
|
|
"failed": "failed",
|
|
}
|
|
|
|
# Finding.severity -> analysisIssueSchema.severity.
|
|
_SEVERITY_MAP: dict[str, IssueSeverity] = {
|
|
"high": "critical",
|
|
"medium": "warning",
|
|
"low": "info",
|
|
}
|
|
|
|
# riskScore weights (capped at 10) per issue severity.
|
|
_RISK_WEIGHTS: dict[IssueSeverity, int] = {"critical": 4, "warning": 2, "info": 1}
|
|
|
|
|
|
class AnalysisIssue(BaseModel):
|
|
"""One contract risk finding in the frontend contract shape."""
|
|
|
|
model_config = ConfigDict(populate_by_name=True)
|
|
|
|
id: str
|
|
severity: IssueSeverity
|
|
category: str
|
|
title: str
|
|
description: str
|
|
fragment: str | None = None
|
|
line_number: int | None = Field(default=None, serialization_alias="lineNumber")
|
|
|
|
|
|
class AnalysisResult(BaseModel):
|
|
"""analysisResultSchema mirror — the SSE event payload for /reports/{id}/events."""
|
|
|
|
model_config = ConfigDict(populate_by_name=True)
|
|
|
|
id: str
|
|
file_name: str = Field(serialization_alias="fileName")
|
|
file_type: str = Field(default="", serialization_alias="fileType")
|
|
file_size: int = Field(default=0, serialization_alias="fileSize")
|
|
status: AnalysisStatus
|
|
issues: list[AnalysisIssue] = Field(default_factory=list)
|
|
summary: str = ""
|
|
risk_score: int = Field(default=0, ge=0, le=10, serialization_alias="riskScore")
|
|
created_at: str = Field(serialization_alias="createdAt")
|
|
completed_at: str | None = Field(default=None, serialization_alias="completedAt")
|
|
|
|
def dump_json(self) -> str:
|
|
return self.model_dump_json(by_alias=True)
|
|
|
|
|
|
def _iso(value: Any) -> str:
|
|
if isinstance(value, dt.datetime):
|
|
return value.isoformat()
|
|
return str(value)
|
|
|
|
|
|
def _map_finding(finding: dict[str, Any], index: int) -> AnalysisIssue:
|
|
checklist_id = str(finding.get("checklist_id") or "unknown")
|
|
severity = _SEVERITY_MAP.get(str(finding.get("severity") or ""), "info")
|
|
risk = str(finding.get("risk") or "").strip()
|
|
recommendation = str(finding.get("recommendation") or "").strip()
|
|
description = risk or checklist_title(checklist_id)
|
|
if recommendation:
|
|
description = f"{description}\n\nРекомендация: {recommendation}"
|
|
quote = str(finding.get("quote") or "").strip()
|
|
section_ref = str(finding.get("section_ref") or "").strip()
|
|
fragment = f"{quote} (п. {section_ref})" if quote and section_ref else (quote or None)
|
|
return AnalysisIssue(
|
|
id=f"{checklist_id}-{index}",
|
|
severity=severity,
|
|
category=checklist_id,
|
|
title=checklist_title(checklist_id),
|
|
description=description,
|
|
fragment=fragment,
|
|
)
|
|
|
|
|
|
def to_analysis_result(row: dict[str, Any]) -> AnalysisResult:
|
|
"""Build an AnalysisResult from a `fetch_document_status_for_user` row."""
|
|
doc_status = str(row["status"])
|
|
status = _STATUS_MAP.get(doc_status)
|
|
if status is None:
|
|
log.warning("unknown_document_status_mapped", status=doc_status)
|
|
status = "processing" if doc_status not in ("done",) else "completed"
|
|
|
|
issues: list[AnalysisIssue] = []
|
|
risk_score = 0
|
|
summary = ""
|
|
|
|
if status == "completed" and doc_status == "done":
|
|
content = row.get("content_json") or {}
|
|
findings = content.get("findings", []) if isinstance(content, dict) else []
|
|
issues = [_map_finding(f, i) for i, f in enumerate(findings)]
|
|
risk_score = min(10, sum(_RISK_WEIGHTS[i.severity] for i in issues))
|
|
counts = {"critical": 0, "warning": 0, "info": 0}
|
|
for issue in issues:
|
|
counts[issue.severity] += 1
|
|
summary = (
|
|
"Риски не найдены"
|
|
if not issues
|
|
else (
|
|
f"Критичных: {counts['critical']}, "
|
|
f"предупреждений: {counts['warning']}, "
|
|
f"замечаний: {counts['info']}"
|
|
)
|
|
)
|
|
|
|
return AnalysisResult(
|
|
id=str(row["id"]),
|
|
file_name=str(row.get("filename") or ""),
|
|
file_type=str(row.get("mime") or ""),
|
|
file_size=int(row.get("bytes") or 0),
|
|
status=status,
|
|
issues=issues,
|
|
summary=summary,
|
|
risk_score=risk_score,
|
|
created_at=_iso(row["created_at"]),
|
|
completed_at=_iso(row["report_created_at"]) if row.get("report_created_at") else None,
|
|
)
|