allo-chat / backend /app /serialize.py
dave
Upload folder using huggingface_hub
e92b660 verified
Raw
History Blame Contribute Delete
7.72 kB
"""Conversion modèles -> schémas de sortie (avec URL de photo + attributs JSON).
On construit les schémas explicitement plutôt que via model_validate() sur
l'ORM : la relation `photos` porte ses `attributes` sous forme de chaîne JSON,
qu'il faut parser nous-mêmes (sinon Pydantic rejette une str là où il attend
un dict).
"""
from __future__ import annotations
import json
import re
from . import models, schemas, storage
# La description est saisie librement par le propriétaire et MONTRÉE au trouveur.
# La promesse de confidentialité = le trouveur ne voit jamais le contact du
# propriétaire. On masque donc tout courriel / numéro de téléphone qui se serait
# glissé dans le texte libre (ex. « contactez-moi au 514-555-1234 »).
_EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b")
_PHONE_RE = re.compile(r"(?<!\d)(?:\+?\d[\s().\-]?){7,}\d(?!\d)")
# Audit #14 : la description est MONTRÉE au trouveur — on masque aussi les
# vecteurs de contact / localisation glissés en texte libre.
_URL_RE = re.compile(r"\b(?:https?://|www\.)\S+", re.I)
_HANDLE_RE = re.compile(r"(?<!\w)@[A-Za-z0-9_.]{2,}")
# Adresse FR (« 123 rue Saint-Denis ») ET EN (« 12 Oak Street ») — on englobe
# le NOM de voie qui suit, pas seulement le type, jusqu'à la ponctuation.
_ADDR_FR_RE = re.compile(
r"\b\d{1,5}\s+(?:rue|av|avenue|boul|boulevard|ch|chemin|rang|place|impasse)\b[^,.;\n]*",
re.I,
)
_ADDR_EN_RE = re.compile(
r"\b\d{1,5}\s+[A-Za-zÀ-ÿ'’\-]+(?:\s+[A-Za-zÀ-ÿ'’\-]+)*\s+"
r"(?:street|st|avenue|ave|boulevard|blvd|road|rd|drive|dr|lane|ln|way|court|ct|place|pl)\b\.?",
re.I,
)
# Coordonnées GPS décimales (« 45.531, -73.581 ») et code postal canadien (« H2X 1Y4 »).
_GPS_RE = re.compile(r"[-+]?\d{1,3}\.\d{3,}\s*[,;]\s*[-+]?\d{1,3}\.\d{3,}")
_POSTAL_CA_RE = re.compile(r"\b[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d\b")
def redact_contact(text: str | None) -> str | None:
"""Masque courriels, téléphones, URLs, @identifiants, adresses civiques,
coordonnées GPS et codes postaux d'un texte libre rendu PUBLIC (description
montrée au trouveur). Le masquage par regex reste best-effort : l'avis invite
aussi explicitement à ne pas écrire de coordonnées dans la description."""
if not text:
return text
t = _EMAIL_RE.sub("[masqué]", text)
t = _URL_RE.sub("[masqué]", t)
t = _GPS_RE.sub("[masqué]", t)
t = _POSTAL_CA_RE.sub("[masqué]", t)
t = _ADDR_FR_RE.sub("[masqué]", t)
t = _ADDR_EN_RE.sub("[masqué]", t)
t = _PHONE_RE.sub("[masqué]", t)
t = _HANDLE_RE.sub("[masqué]", t)
return t
def clip(s: str | None, n: int) -> str | None:
"""Tronque une saisie libre à la longueur de sa colonne. Postgres applique
les limites VARCHAR(n) (contrairement à SQLite) : une saisie trop longue
lèverait sinon une 500 StringDataRightTruncation en prod. Helper partagé par
tous les routers qui écrivent du texte libre (cats, sightings, training)."""
if s is None:
return None
s = s.strip()
return s[:n] if s else None
def photo_summary(p: "models.Photo | None") -> str | None:
"""Courte description du pelage stockée dans les attributs de la photo
(ex. « chat écaille de tortue ») — affichée quand l'IA a détecté le chat."""
if not p or not p.attributes:
return None
try:
return json.loads(p.attributes).get("summary")
except (json.JSONDecodeError, AttributeError):
return None
def photo_out(p: models.Photo) -> schemas.PhotoOut:
attrs = None
if p.attributes:
try:
attrs = json.loads(p.attributes)
except json.JSONDecodeError:
attrs = None
return schemas.PhotoOut(
id=p.id, filename=p.filename, url=storage.photo_url(p), attributes=attrs
)
def photo_out_public(p: models.Photo) -> schemas.PhotoOut:
"""Vue photo destinée au TROUVEUR. Audit MIT #10 : on ne renvoie PAS le dict
`attributes` complet (il contient la boîte de détection, la méthode et les
couleurs internes des photos du propriétaire). On ne garde qu'un résumé
lisible du pelage (« chat écaille de tortue »), utile pour reconnaître."""
summary = photo_summary(p)
return schemas.PhotoOut(
id=p.id, filename=p.filename, url=storage.photo_url(p),
attributes={"summary": summary} if summary else None,
)
def cat_out(cat: models.Cat) -> schemas.CatOut:
return schemas.CatOut(
id=cat.id,
name=cat.name,
description=cat.description,
color=cat.color,
pattern=cat.pattern,
breed=cat.breed,
contact_name=cat.contact_name,
contact_info=cat.contact_info,
last_seen_lat=cat.last_seen_lat,
last_seen_lng=cat.last_seen_lng,
last_seen_at=cat.last_seen_at,
status=cat.status,
created_at=cat.created_at,
photos=[photo_out(p) for p in cat.photos],
)
def sighting_out(s: models.Sighting) -> schemas.SightingOut:
return schemas.SightingOut(
id=s.id,
description=s.description,
lat=s.lat,
lng=s.lng,
spotted_at=s.spotted_at,
reporter_contact=s.reporter_contact,
status=s.status,
created_at=s.created_at,
photos=[photo_out(p) for p in s.photos],
)
def _candidate_token(sighting_id: int, cat_id: int) -> str:
"""Token HMAC signé liant un candidat à un signalement précis.
C4 — Remplace le cat_id brut dans la réponse publique pour empêcher
l'énumération des chats perdus par GPS bombing."""
import hashlib
import hmac
from . import config
key = (config.PHOTO_SIGN_KEY or "dev").encode()
return hmac.new(key, f"candidate:{sighting_id}:{cat_id}".encode(),
hashlib.sha256).hexdigest()[:24]
def candidate_out(cat: models.Cat, confidence: float,
sighting_id: int | None = None) -> schemas.CandidateOut:
"""Chat candidat montré au trouveur : photos + nom/desc + confiance, SANS
contact ni position du propriétaire.
`sighting_id` est requis pour générer le token signé ; si absent (tests
legacy), on utilise l'id du chat (moins sécurisé mais fonctionnel)."""
token = _candidate_token(sighting_id or 0, cat.id)
return schemas.CandidateOut(
candidate_token=token,
name=redact_contact(cat.name),
description=redact_contact(cat.description),
# Audit MIT #11 — color/pattern sont aussi du texte libre montré au
# trouveur : on y applique le même caviardage (un propriétaire peut
# glisser « noir, me joindre au 514… » dans le champ couleur).
color=redact_contact(cat.color),
pattern=redact_contact(cat.pattern),
confidence=confidence,
# On ne montre que quelques photos (assez pour reconnaître) — pas tout
# l'enrôlement : moins d'exposition, payload plus léger. Vue PUBLIQUE :
# sans les attributs de détection internes (audit MIT #10).
photos=[photo_out_public(p) for p in cat.photos[:3]],
is_poster=getattr(cat, "source", "owner") == "poster",
)
def lead_out(le: models.ContactLead) -> schemas.LeadOut:
"""Signalement tel que vu par le PROPRIÉTAIRE (photo + lieu du trouveur)."""
s = le.sighting
photo_url = None
if s and s.photos:
photo_url = storage.photo_url(s.photos[0])
return schemas.LeadOut(
id=le.id,
finder_contact=le.finder_contact,
message=le.message,
confidence=le.confidence,
created_at=le.created_at,
sighting_lat=s.lat if s else None,
sighting_lng=s.lng if s else None,
sighting_photo_url=photo_url,
)