"""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, )