- A: gate store con cross-encoder — guardrail.decide async, conferma/scarta quasi-duplicati (CROSS_DUP_CONFIRMED/WEAK/LOW_COSINE), suggerimento supersedes in WARN, degrada a cosine-only se il reranker è giù - B: verifica supersede — cross-score (nuovo,vecchio) sotto soglia → supersede_warning non bloccante + audit - C: score composito in search — rerank + importance (nuovo campo payload) + recency decay (180gg) + authority, pesi SCORE_W_* da env - E: multi-query — SearchIn.queries (max 3), pool unito con dedup, rerank unico; endpoint POST /v1/score come primitiva cross-encoder (F-lite) - extension search.ts: param queries + rerank_score/composite in output - D: scripts/consolidate.py — dedup periodico a coppie via cross-encoder con report ntfy e --apply via gateway - test: 69 pass (+11 strategie); guardrail_version similarity-v2
81 lines
4.1 KiB
Python
81 lines
4.1 KiB
Python
"""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")
|