"""Modèles de données (SQLAlchemy). Quatre entités : - Cat : une alerte de chat perdu (ou retrouvé). - Sighting : un chat repéré par un utilisateur dans la rue. - Photo : une image rattachée à un Cat OU à un Sighting. Porte l'embedding IA et les attributs visuels extraits. - FeedbackPair : le SIGNAL D'ENTRAÎNEMENT communautaire. Chaque verdict « même chat / chats différents » alimente le futur ré-entraînement du modèle de reconnaissance (le « data flywheel »). """ from __future__ import annotations import datetime as dt from sqlalchemy import DateTime, Float, ForeignKey, LargeBinary, String, Text, func from sqlalchemy.orm import Mapped, mapped_column, relationship from .database import Base def _now() -> dt.datetime: return dt.datetime.now(dt.timezone.utc) class Cat(Base): __tablename__ = "cats" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str | None] = mapped_column(String(120), default=None) description: Mapped[str | None] = mapped_column(Text, default=None) color: Mapped[str | None] = mapped_column(String(120), default=None) pattern: Mapped[str | None] = mapped_column(String(120), default=None) breed: Mapped[str | None] = mapped_column(String(120), default=None) contact_name: Mapped[str | None] = mapped_column(String(120), default=None) contact_info: Mapped[str | None] = mapped_column(String(255), default=None) # EMAIL NORMALISÉ = identifiant du propriétaire pour le suivi par code (OTP # envoyé par courriel — gratuit). L'accès exige le code, pas le seul email. email: Mapped[str | None] = mapped_column(String(255), index=True, default=None) # Téléphone optionnel (contact privé du propriétaire, jamais montré aux autres). # ⚠️ CHIFFRÉ au repos (Fernet → préfixe « enc1: », ~150 caractères) : la # colonne DOIT être Text/illimitée. Une VARCHAR(32) faisait planter l'INSERT # sur Postgres (StringDataRightTruncation → 500) dès qu'un téléphone était # saisi ; SQLite (tests) n'applique pas la limite et masquait le bug. phone: Mapped[str | None] = mapped_column(Text, default=None) last_seen_lat: Mapped[float | None] = mapped_column(Float, default=None) last_seen_lng: Mapped[float | None] = mapped_column(Float, default=None) last_seen_at: Mapped[dt.datetime | None] = mapped_column(DateTime, default=None) # lost | found | reunited status: Mapped[str] = mapped_column(String(20), default="lost") # owner | poster (affiches partagées par des passants, sans compte propriétaire) source: Mapped[str] = mapped_column(String(20), default="owner") # CONFIDENTIALITÉ : code secret remis au propriétaire à la déclaration. Seul # moyen d'accéder à SA fiche et à SES signalements reçus. Jamais exposé ailleurs. claim_token: Mapped[str | None] = mapped_column( String(64), unique=True, index=True, default=None ) created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_now) # Dernière activité du propriétaire (déclaration, consultation, MAJ statut, # signalement reçu). Sert à la PURGE DE RÉTENTION (Loi 25) : on ne supprime # qu'une fiche RÉELLEMENT inactive, jamais une recherche encore suivie. last_activity_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_now) photos: Mapped[list[Photo]] = relationship( back_populates="cat", cascade="all, delete-orphan" ) leads: Mapped[list[ContactLead]] = relationship( back_populates="cat", cascade="all, delete-orphan" ) class Sighting(Base): __tablename__ = "sightings" id: Mapped[int] = mapped_column(primary_key=True) description: Mapped[str | None] = mapped_column(Text, default=None) lat: Mapped[float | None] = mapped_column(Float, default=None) lng: Mapped[float | None] = mapped_column(Float, default=None) spotted_at: Mapped[dt.datetime | None] = mapped_column(DateTime, default=None) reporter_contact: Mapped[str | None] = mapped_column(String(255), default=None) # open | linked | dismissed status: Mapped[str] = mapped_column(String(20), default="open") # Meilleure correspondance calculée à la création (interne, jamais renvoyée # au trouveur). Sert à rattacher un lead au bon chat sans rien lui révéler. matched_cat_id: Mapped[int | None] = mapped_column( ForeignKey("cats.id"), default=None ) matched_confidence: Mapped[float | None] = mapped_column(Float, default=None) # IDs des chats candidats proposés pour CE signalement (JSON). Un lead ne peut # cibler qu'un de ces chats — empêche le spam de leads sur des cat_id arbitraires. candidate_ids: Mapped[str | None] = mapped_column(Text, default=None) # Résultat d'analyse rendu (JSON [{id, c}]) calculé EN ARRIÈRE-PLAN. NULL tant # que l'analyse IA n'est pas terminée (= état « analyse en cours » côté sondage). # Non NULL (même "[]") = analyse finie → le client peut afficher les candidats. result_json: Mapped[str | None] = mapped_column(Text, default=None) created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_now) photos: Mapped[list[Photo]] = relationship( back_populates="sighting", cascade="all, delete-orphan" ) class Photo(Base): __tablename__ = "photos" id: Mapped[int] = mapped_column(primary_key=True) filename: Mapped[str] = mapped_column(String(255)) # Image stockée EN BASE (persistance gratuite : survit aux redéploiements, # contrairement au disque éphémère). Redimensionnée/compressée à l'upload. data: Mapped[bytes | None] = mapped_column(LargeBinary, default=None) content_type: Mapped[str | None] = mapped_column(String(50), default=None) # Rattachement : exactement un des deux est renseigné. cat_id: Mapped[int | None] = mapped_column(ForeignKey("cats.id"), default=None) sighting_id: Mapped[int | None] = mapped_column( ForeignKey("sightings.id"), default=None ) # Embedding IA (vecteur normalisé) sérialisé en JSON, + version du modèle # qui l'a produit, pour pouvoir recalculer après un ré-entraînement. embedding: Mapped[str | None] = mapped_column(Text, default=None) embedding_model: Mapped[str | None] = mapped_column(String(60), default=None) # Attributs visuels extraits (couleurs dominantes, luminosité…) en JSON. attributes: Mapped[str | None] = mapped_column(Text, default=None) created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_now) cat: Mapped[Cat | None] = relationship(back_populates="photos") sighting: Mapped[Sighting | None] = relationship(back_populates="photos") class FeedbackPair(Base): """Donnée d'entraînement générée par la communauté. Chaque ligne dit : « ces deux photos sont (le même chat | des chats différents) ». C'est exactement le signal nécessaire au metric learning (triplet / contrastive loss) lors du ré-entraînement périodique. """ __tablename__ = "feedback_pairs" id: Mapped[int] = mapped_column(primary_key=True) photo_a_id: Mapped[int] = mapped_column(ForeignKey("photos.id")) photo_b_id: Mapped[int] = mapped_column(ForeignKey("photos.id")) # same | different verdict: Mapped[str] = mapped_column(String(20)) # match_confirmation | active_learning | owner | import source: Mapped[str] = mapped_column(String(40), default="match_confirmation") created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_now) class ContactLead(Base): """Signalement laissé par un TROUVEUR pour un chat perdu correspondant. Sens unique vers le propriétaire : le trouveur dépose ici SES coordonnées, sa photo (via le sighting) et le lieu. Le propriétaire les consulte avec son code secret. Le trouveur ne voit jamais le chat perdu ni le propriétaire. """ __tablename__ = "contact_leads" id: Mapped[int] = mapped_column(primary_key=True) cat_id: Mapped[int] = mapped_column(ForeignKey("cats.id")) sighting_id: Mapped[int | None] = mapped_column( ForeignKey("sightings.id"), default=None ) finder_contact: Mapped[str | None] = mapped_column(String(255), default=None) message: Mapped[str | None] = mapped_column(Text, default=None) confidence: Mapped[float | None] = mapped_column(Float, default=None) created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_now) cat: Mapped[Cat] = relationship(back_populates="leads") sighting: Mapped[Sighting | None] = relationship() class TrainingPhoto(Base): """Photo DONNÉE VOLONTAIREMENT pour aider à entraîner l'IA. Pool consenti et séparé : ces photos ne sont JAMAIS des fiches privées de chats perdus. C'est le seul matériel montré dans le jeu d'étiquetage. """ __tablename__ = "training_photos" id: Mapped[int] = mapped_column(primary_key=True) filename: Mapped[str] = mapped_column(String(255)) data: Mapped[bytes | None] = mapped_column(LargeBinary, default=None) content_type: Mapped[str | None] = mapped_column(String(50), default=None) embedding: Mapped[str | None] = mapped_column(Text, default=None) # Identité optionnelle déclarée par le contributeur (ex. « mon chat Mia »), # pour regrouper plusieurs photos d'un même individu donné. label_group: Mapped[str | None] = mapped_column(String(120), default=None) created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_now) class EmailOTP(Base): """Code à usage unique pour prouver la possession d'une adresse courriel. Le code est stocké HACHÉ (jamais en clair). Expire vite, tentatives limitées. """ __tablename__ = "email_otps" id: Mapped[int] = mapped_column(primary_key=True) email: Mapped[str] = mapped_column(String(255), index=True) code_hash: Mapped[str] = mapped_column(String(64)) expires_at: Mapped[dt.datetime] = mapped_column(DateTime) attempts: Mapped[int] = mapped_column(default=0) created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_now) class TrainingLabel(Base): """Verdict humain « même / différent » sur une paire de TrainingPhoto.""" __tablename__ = "training_labels" id: Mapped[int] = mapped_column(primary_key=True) photo_a_id: Mapped[int] = mapped_column(ForeignKey("training_photos.id")) photo_b_id: Mapped[int] = mapped_column(ForeignKey("training_photos.id")) verdict: Mapped[str] = mapped_column(String(20)) # same | different created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_now)