""" app.py — PawMap Build Small Hackathon · Backyard AI Track · Junho 2026 Custom frontend via gradio.Server """ import json import logging import os import tempfile import time import uuid from pathlib import Path from gradio import Server from gradio.data_classes import FileData from fastapi.responses import HTMLResponse, JSONResponse from fastapi import Query from fastapi.staticfiles import StaticFiles from core.ai import AnimalAI from core.database import Database, DATA_DIR, PHOTOS_DIR from core.matcher import AnimalMatcher from core.seed import seed_if_empty from core.tracer import log_trace logging.basicConfig(level=logging.INFO) db = Database() ai = AnimalAI() matcher = AnimalMatcher() seed_if_empty(db) # popula o mapa com dados de demo se o banco estiver vazio def _photo_url(photo_path: str) -> str: """Convert DB-relative photo path to a URL served by the /photos/ static mount. photo_path is relative to DATA_DIR (e.g. 'photos/animal_42/abc.jpg'). The static mount serves PHOTOS_DIR at /photos/, so we strip the 'photos/' prefix. """ if not photo_path: return "" # Normalise separators p = photo_path.replace("\\", "/") if p.startswith("photos/"): p = p[len("photos/"):] return f"/photos/{p}" # In-memory session store for analyze → confirm two-step flow _pending: dict[str, dict] = {} app = Server() # Serve photos as static files at /photos/... PHOTOS_DIR.mkdir(parents=True, exist_ok=True) app.mount("/photos", StaticFiles(directory=str(PHOTOS_DIR)), name="photos") # Serve frontend assets (CSS, JS, images) at /static/... STATIC_DIR = Path(__file__).parent / "static" STATIC_DIR.mkdir(exist_ok=True) app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") # ─── Frontend ───────────────────────────────────────────────────────────────── @app.get("/", response_class=HTMLResponse) async def homepage(): html_path = Path(__file__).parent / "index.html" return html_path.read_text(encoding="utf-8") # ─── Data APIs (FastAPI routes, no queuing needed) ──────────────────────────── @app.get("/api/map-data") async def get_map_data( species: str = Query("all"), timeframe: str = Query("all"), ): data = db.get_map_data(species, timeframe) for item in data: item["photo_url"] = _photo_url(item.pop("last_photo", "") or "") return JSONResponse(content=data) @app.get("/api/animals") async def get_animals(): animals = db.get_recent_animals(limit=30) for a in animals: a["photo_url"] = _photo_url(a.pop("last_photo_path", "") or "") a.pop("embedding", None) return JSONResponse(content=animals) @app.get("/api/animal/{animal_id}") async def get_animal(animal_id: int): detail = db.get_animal_detail(animal_id) if not detail: return JSONResponse(content={"error": "not found"}, status_code=404) for s in detail.get("sightings", []): s["photo_url"] = _photo_url(s.get("photo_path") or "") for h in detail.get("help_events", []): h["photo_url"] = _photo_url(h.get("photo_path") or "") detail.get("animal", {}).pop("embedding", None) return JSONResponse(content=detail) # ─── ML APIs (queued via Gradio) ────────────────────────────────────────────── @app.api(name="analyze_image") def analyze_image(image_path: FileData) -> dict: """ Step 1: Analyze photo with AI, find similar animals. Returns session_id + AI description + top matches (no DB write yet). """ from PIL import Image as PILImage img = PILImage.open(image_path["path"]).convert("RGB") description = ai.analyze_image(img) # Rejeição: a IA não detectou nenhum animal na foto if description.get("is_animal") is False: return { "error": "Nenhum cão ou gato identificado na foto. Por favor, fotografe um animal de rua.", "session_id": "", "description": {}, "similar": [], } embedding = ai.get_embedding(description) candidates = db.get_all_animals_with_embeddings() top_matches = matcher.find_top_matches(embedding, candidates, top_n=3) # Enrich matches with photo URLs and sighting info similar = [] for m in top_matches: sightings = db.get_animal_sightings(m["id"]) photo_path = next( (s["photo_path"] for s in sightings if s.get("photo_path")), None ) latest = sightings[0] if sightings else {} similar.append({ "id": m["id"], "score_pct": round(m["score"] * 100), "photo_url": _photo_url(photo_path) if photo_path else "", "days_ago": latest.get("days_ago", ""), }) # Save image to temp file for the confirm step tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False, dir=DATA_DIR) img.save(tmp.name, format="JPEG", quality=85) tmp.close() session_id = uuid.uuid4().hex _pending[session_id] = { "temp_path": tmp.name, "description": description, "embedding": embedding, "timestamp": time.time(), } _cleanup_sessions() log_trace({ "event": "analyze", "session_id": session_id, "description": {k: v for k, v in description.items() if k not in ("_ai_success",)}, "top_matches": [{"id": m["id"], "score_pct": m["score_pct"]} for m in similar], }) return { "session_id": session_id, "description": description, "similar": similar, } @app.api(name="confirm_sighting") def confirm_sighting( session_id: str, gps_json: str = "", notes: str = "", condition: str = "", animal_name: str = "", ) -> dict: """ Step 2: User reviewed/edited the AI results → save sighting to DB. """ import datetime from PIL import Image as PILImage session = _pending.pop(session_id, None) if not session: return {"error": "Sessão expirada. Tire a foto novamente."} img = PILImage.open(session["temp_path"]).convert("RGB") description = session["description"] embedding = session["embedding"] # Clean up temp file try: os.unlink(session["temp_path"]) except Exception: pass # Parse GPS try: coords = json.loads(gps_json) if gps_json and gps_json.strip() else {} except Exception: coords = {} lat = round(float(coords["lat"]), 5) if coords.get("lat") else None lng = round(float(coords["lng"]), 5) if coords.get("lng") else None # Append condition to notes full_notes = notes if condition: full_notes = (notes + f" [Condição: {condition}]").strip() candidates = db.get_all_animals_with_embeddings() match = matcher.find_match(embedding, candidates) clean_name = animal_name.strip() or None if match: animal_id, _ = match photo_path = db.save_photo(img, animal_id=animal_id) db.add_sighting(animal_id, photo_path, lat, lng, full_notes) db.update_animal(animal_id) if clean_name: db.update_animal_name(animal_id, clean_name) animal = db.get_animal(animal_id) count = animal["sighting_count"] species = animal["species"] desc_obj = json.loads(animal.get("description") or "{}") is_new = False else: animal_id = db.create_animal(description, embedding, name=clean_name) photo_path = db.save_photo(img, animal_id=animal_id) db.add_sighting(animal_id, photo_path, lat, lng, full_notes) count = 1 species = description.get("species", "dog") desc_obj = description is_new = True # Display name: user-given > AI-generated fallback animal_row = db.get_animal(animal_id) saved_name = animal_row.get("name") if animal_row else None breed = desc_obj.get("breed_estimate", "") color = desc_obj.get("primary_color", "") name = saved_name or " ".join(filter(None, [ "Dog" if species == "dog" else "Cat", color.capitalize() if color else "", breed if breed and breed.lower() not in ("srd", "unknown", "") else "", ])).strip() or ("Dog" if species == "dog" else "Cat") result = { "animal_id": animal_id, "is_new": is_new, "count": count, "species": species, "name": name, "photo_url": _photo_url(photo_path) if photo_path else "", "location": f"Lat {lat:.4f}, Lng {lng:.4f}" if lat and lng else "Localização não registrada", "time": datetime.datetime.now().strftime("%H:%M"), } log_trace({ "event": "confirm", "session_id": session_id, "animal_id": animal_id, "is_new": is_new, "species": species, "sighting_count": count, "gps": {"lat": lat, "lng": lng}, "description": desc_obj, }) return result def _cleanup_sessions(): cutoff = time.time() - 1800 # 30 min for k in list(_pending.keys()): if _pending[k]["timestamp"] < cutoff: try: os.unlink(_pending[k]["temp_path"]) except Exception: pass _pending.pop(k, None) # ─── Help ───────────────────────────────────────────── @app.post("/api/animal/{animal_id}/helped") async def mark_helped(animal_id: int): """Legacy — mantido por compatibilidade. Prefira submit_help_proof.""" animal = db.get_animal(animal_id) if not animal: return JSONResponse(content={"error": "not found"}, status_code=404) db.add_sighting(animal_id, None, None, None, "", is_help_event=True, help_type="other") db.update_animal(animal_id) return JSONResponse(content={"ok": True}) @app.api(name="submit_help_proof") def submit_help_proof( animal_id: int, help_type: str = "other", notes: str = "", image_path: FileData = None, ) -> dict: """ Registra que alguém ajudou o animal, com foto de prova opcional. A IA verifica se a foto é do mesmo animal e detecta melhora de condição. """ from PIL import Image as PILImage import json as _json photo_path = None ai_verified = False condition_update = None match_score = None if image_path and image_path.get("path"): img = PILImage.open(image_path["path"]).convert("RGB") # Analisa a foto com IA description = ai.analyze_image(img) if description.get("is_animal") is not False and description.get("_ai_success"): embedding = ai.get_embedding(description) candidates = db.get_all_animals_with_embeddings() # Verifica se é o mesmo animal match = matcher.find_match(embedding, candidates) if match: matched_id, score = match ai_verified = (matched_id == animal_id) match_score = round(score * 100) # Detecta melhora de condição animal_data = db.get_animal(animal_id) if animal_data: prev_desc = _json.loads(animal_data.get("description") or "{}") prev_condition = prev_desc.get("condition", "") new_condition = description.get("condition", "") condition_rank = {"injured": 0, "thin": 1, "healthy": 2} if (condition_rank.get(new_condition, -1) > condition_rank.get(prev_condition, -1)): condition_update = new_condition photo_path = db.save_photo(img, animal_id=animal_id) db.add_sighting( animal_id, photo_path, None, None, notes, is_help_event=True, help_type=help_type, ) db.update_animal(animal_id) log_trace({ "event": "help_proof", "animal_id": animal_id, "help_type": help_type, "has_photo": photo_path is not None, "ai_verified": ai_verified, "match_score": match_score, "condition_update": condition_update, }) return { "ok": True, "animal_id": animal_id, "help_type": help_type, "ai_verified": ai_verified, "match_score": match_score, "condition_update": condition_update, "photo_url": _photo_url(photo_path) if photo_path else "", } # ─── Admin ──────────────────────────────────────────── @app.get("/admin/push-traces") async def push_traces(): """Publica data/traces.jsonl como dataset no HF Hub. Acesse esta URL no browser para disparar o upload. Requer HF_TOKEN e HF_DATASET_ID nos Secrets do Space. """ from core.tracer import push_to_hub, TRACES_PATH if not TRACES_PATH.exists(): return JSONResponse(content={"ok": False, "error": "Nenhum trace encontrado ainda."}) lines = TRACES_PATH.read_text().strip().splitlines() push_to_hub() return JSONResponse(content={"ok": True, "traces_published": len(lines)}) # ─── Launch ──────────────────────────────────────────── if __name__ == "__main__": DATA_DIR.mkdir(parents=True, exist_ok=True) PHOTOS_DIR.mkdir(parents=True, exist_ok=True) app.launch( server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)), show_error=True, )