"""Schemi Pydantic del Memory Gateway.""" from __future__ import annotations from datetime import datetime from typing import Literal, Optional from pydantic import BaseModel, Field, field_validator from config import MAX_TEXT_LEN class MemoryLink(BaseModel): target_id: str = Field(..., description="UUID del record target collegato") predicate: str = Field(default="part_of", max_length=64, description="Tipo di relazione: parent_of, part_of, relates_to, supersedes...") weight: float = Field(default=1.0, ge=0.0, le=1.0) class MemoryIn(BaseModel): text: str = Field(min_length=1, max_length=MAX_TEXT_LEN) kind: Literal["decision", "fact", "episode", "preference"] = "fact" agent_id: Optional[str] = Field(default=None, max_length=64, description="Solo provenienza, nessun isolamento") project_id: str = Field(min_length=1, max_length=64, description="OBBLIGATORIO: progetto/dominio di appartenenza (kebab-case)") scope: Literal["agent", "project", "org"] = "agent" source: Optional[str] = Field(default=None, max_length=256) confidence: Literal["high", "medium", "low"] = Field(default="medium", description="Affidabilità del record") expires_at: Optional[str] = None supersedes_id: Optional[str] = None supersede_reason: Optional[str] = Field(default=None, max_length=512) parent_id: Optional[str] = Field(default=None, description="UUID del record genitore per gerarchia/subtopic") level: Optional[Literal["L1_ROOT", "L2_SUBTOPIC", "L3_DETAIL"]] = Field(default=None, description="Livello gerarchico") topic: Optional[str] = Field(default=None, max_length=128, description="Topic gerarchico") links: Optional[list[MemoryLink]] = Field(default=None, description="Collegamenti semantici e relazionali") importance: float = Field(default=0.5, ge=0.0, le=1.0, description="Importanza stabile del record (usata nello score composito)") private: bool = Field(default=False, description="Riservato: escluso dalle ricerche standard, visibile solo con include_private o topic esplicito") @field_validator("expires_at") @classmethod def _validate_expires_at(cls, v: Optional[str]) -> Optional[str]: if v is None: return v try: datetime.fromisoformat(v.replace("Z", "+00:00")) except ValueError: raise ValueError("expires_at deve essere una data ISO 8601 valida (es. 2026-09-01T00:00:00Z)") return v class ScoreIn(BaseModel): """Primitiva di scoring cross-encoder (usata da estensione e job di consolidamento).""" query: str = Field(min_length=1, max_length=512) documents: list[str] = Field(min_length=1, max_length=32) class SearchIn(BaseModel): query: str = Field(min_length=1, max_length=512) kind: Optional[Literal["decision", "fact", "episode", "preference"]] = None project_id: Optional[str] = None scope: Optional[Literal["agent", "project", "org"]] = None include_superseded: bool = False min_score: Optional[float] = Field(default=None, ge=0.0, le=1.0) top_k: int = Field(default=5, ge=1, le=20) hybrid: bool = False parent_id: Optional[str] = None level: Optional[Literal["L1_ROOT", "L2_SUBTOPIC", "L3_DETAIL"]] = None topic: Optional[str] = None include_private: bool = Field(default=False, description="Includi i record privati (solo ricerche esplicite)") rerank: Optional[bool] = Field(default=None, description="Override per-query dello stadio rerank (None = default server)") queries: Optional[list[str]] = Field(default=None, max_length=3, description="Varianti di query (max 3): pool unito, dedup e rerank unico") @field_validator("queries") @classmethod def _validate_queries(cls, v: Optional[list[str]]) -> Optional[list[str]]: if v is None: return v cleaned = [q.strip() for q in v if q and q.strip()] if len(cleaned) != len(v): raise ValueError("le query non devono essere vuote") return cleaned queries: Optional[list[str]] = Field(default=None, max_length=3, description="Varianti di query (max 3): pool unito, dedup e rerank unico")