allo-chat / backend /app /models.py
dave
Upload folder using huggingface_hub
8d32cb2 verified
Raw
History Blame Contribute Delete
10.7 kB
"""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)