"""HTTP request/response models. Everything the web client sees is defined here, so the OpenAPI document at `/docs` is a complete, accurate description of the API surface. Money crosses the wire as `float` for the chart libraries; it is `Decimal` everywhere it matters (database columns and the validation arithmetic). """ from __future__ import annotations import uuid from datetime import date, datetime from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field from app.models.enums import ( AnomalySeverity, AnomalyStatus, AnomalyType, DocumentKind, DocumentStatus, Lane, PipelineStage, StageState, ) from app.models.schemas import ValidationCheck class ErrorDetail(BaseModel): code: str message: str details: dict[str, Any] = Field(default_factory=dict) class ErrorResponse(BaseModel): """The only error shape the API ever returns (spec §7: no stack traces).""" error: ErrorDetail class UploadResponse(BaseModel): """Result of `POST /v1/documents`.""" document_id: uuid.UUID file_hash: str = Field(description="SHA-256 of the file bytes — the idempotency key.") filename: str status: DocumentStatus duplicate: bool = Field( description="True when these exact bytes were already ingested; no new record was created." ) class StageProgress(BaseModel): """One node in the six-stage pipeline visual.""" stage: PipelineStage state: StageState detail: str | None = None at: datetime | None = None class ExtractionOut(BaseModel): """Schema-validated fields, as rendered in the result card.""" vendor: str | None = None invoice_number: str | None = None issue_date: date | None = None due_date: date | None = None line_items: list[dict[str, Any]] = Field(default_factory=list) subtotal: float | None = None tax: float | None = None total: float | None = None currency: str | None = None payment_terms: str | None = None is_valid: bool = False repair_attempts: int = 0 model: str | None = None lane: Lane | None = None checks: list[ValidationCheck] = Field(default_factory=list) class AnomalyOut(BaseModel): """A review-queue card: severity, plain-English reason, evidence.""" id: uuid.UUID document_id: uuid.UUID anomaly_type: AnomalyType severity: AnomalySeverity reason: str score: float | None = None evidence: dict[str, Any] = Field(default_factory=dict) status: AnomalyStatus created_at: datetime resolved_at: datetime | None = None resolved_note: str | None = None vendor: str | None = None total: float | None = None currency: str | None = None filename: str | None = None class DocumentStatusResponse(BaseModel): """`GET /v1/documents/{id}/status` — what drives the animated pipeline. The stage list is derived from the append-only audit log, so the UI reflects what actually happened rather than a client-side timer. """ document_id: uuid.UUID status: DocumentStatus status_reason: str | None = None doc_kind: DocumentKind | None = None lane: Lane | None = None stages: list[StageProgress] progress: float = Field(ge=0.0, le=1.0) is_terminal: bool latency_ms: int | None = None cost_usd: float = 0.0 anomaly_count: int = 0 highest_severity: AnomalySeverity | None = None extraction: ExtractionOut | None = None updated_at: datetime class DocumentSummary(BaseModel): id: uuid.UUID filename: str status: DocumentStatus doc_kind: DocumentKind | None = None lane: Lane | None = None media_type: str size_bytes: int vendor: str | None = None total: float | None = None currency: str | None = None issue_date: date | None = None anomaly_count: int = 0 highest_severity: AnomalySeverity | None = None latency_ms: int | None = None cost_usd: float = 0.0 created_at: datetime class DocumentListResponse(BaseModel): items: list[DocumentSummary] total: int limit: int offset: int class AuditEntryOut(BaseModel): """One row of the append-only trail rendered in the Audit Trail drawer.""" id: int event: str stage: PipelineStage | None = None actor: str payload: dict[str, Any] = Field(default_factory=dict) created_at: datetime class TraceOut(BaseModel): """One LLM call: tokens, cost, latency, retries.""" id: uuid.UUID stage: PipelineStage purpose: str model: str mode: str input_tokens: int output_tokens: int latency_ms: int attempts: int cost_usd: float created_at: datetime class DocumentDetail(BaseModel): """Everything about one document, for the detail view.""" id: uuid.UUID file_hash: str filename: str media_type: str size_bytes: int status: DocumentStatus status_reason: str | None = None doc_kind: DocumentKind | None = None lane: Lane | None = None page_count: int | None = None latency_ms: int | None = None cost_usd: float = 0.0 llm_mode: str | None = None created_at: datetime updated_at: datetime extraction: ExtractionOut | None = None anomalies: list[AnomalyOut] = Field(default_factory=list) audit: list[AuditEntryOut] = Field(default_factory=list) traces: list[TraceOut] = Field(default_factory=list) class ResolveAnomalyRequest(BaseModel): """Approve/Reject from the review queue — writes back to Postgres.""" model_config = ConfigDict(extra="forbid") action: Literal["approve", "reject"] note: str | None = Field(default=None, max_length=1000) class VendorSpend(BaseModel): vendor: str total: float invoice_count: int currency: str | None = None class StatsResponse(BaseModel): """`GET /v1/stats` — the KPI cards and the vendor-spend chart.""" documents_total: int documents_processed: int documents_needs_review: int documents_failed: int anomalies_open: int anomalies_total: int avg_latency_ms: float p95_latency_ms: float est_cost_usd: float avg_cost_per_document_usd: float llm_mode: str router_model: str extractor_model: str vendor_spend: list[VendorSpend] = Field(default_factory=list) status_breakdown: dict[str, int] = Field(default_factory=dict) severity_breakdown: dict[str, int] = Field(default_factory=dict) anomaly_type_breakdown: dict[str, int] = Field(default_factory=dict) class HealthResponse(BaseModel): status: Literal["ok", "degraded"] version: str environment: str database: Literal["up", "down"] llm_mode: str #: `enabled` — the Langfuse exporter is built and receiving calls. #: `disabled` — no keys configured; the local log sink is the whole story. #: `unavailable` — keys are configured but the client could not be built, so #: nothing is being exported. Previously indistinguishable from `enabled`. langfuse: Literal["enabled", "disabled", "unavailable"]