DealDocumentScreening/src/contract_check/api/schemas/analysis_result.py

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