Spaces:
Sleeping
Sleeping
| """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) | |