lost-found-desk / app.py
JacobLinCool's picture
Deploy Lost & Found Desk
f1164d7 verified
Raw
History Blame Contribute Delete
38.3 kB
from __future__ import annotations
import os
import shutil
import sys
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).parent / "src"))
from fastapi import File, Form, HTTPException, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from gradio import Server
from gradio.data_classes import FileData
from starlette.concurrency import run_in_threadpool
from lost_found_desk.config import get_settings
from lost_found_desk.conversation import SEED_MESSAGE, detect_language
from lost_found_desk.matching import rank_candidates
from lost_found_desk.runtime.gpu import maybe_zerogpu
from lost_found_desk.retrieval import cosine, rank_items_by_embedding
from lost_found_desk.schemas import (
ChatMessage,
Claim,
Event,
Item,
ReturnLog,
new_claim_id,
new_event_id,
new_id,
now_iso,
slugify_event_id,
)
from lost_found_desk.security import generate_staff_password, hash_password, verify_password
from lost_found_desk.seed import seed_if_empty
from lost_found_desk.services import ModelHub
from lost_found_desk.storage import JsonStore
from lost_found_desk.telemetry import get_logger, increment, setup_telemetry, shutdown_telemetry
BASE_DIR = Path(__file__).parent.resolve()
settings = get_settings()
# Initialize logging/traces/metrics before anything else logs or runs a model.
setup_telemetry()
logger = get_logger("lost_found_desk.app")
store = JsonStore(settings.db_path)
if settings.seed_sample:
seed_if_empty(store, BASE_DIR, settings)
hub = ModelHub()
# Gradio reserves these top-level paths (plus everything under /gradio_api and
# /api); the SPA catch-all must not shadow them. See spa_fallback below.
_RESERVED_PREFIXES = (
"api/", "gradio_api/", "assets/", "static/", "svelte/",
"queue/", "call/", "run/", "file/", "file=",
)
_RESERVED_EXACT = {
"config", "info", "heartbeat", "upload", "login", "logout", "theme.css",
"robots.txt", "favicon.ico", "manifest.json", "pwa_icon", "monitoring",
}
app = Server(
title="Lost & Found Desk",
description="Multi-event, caption-first lost-and-found workflow with a custom frontend.",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
setup_telemetry(app=app)
_assets_dir = BASE_DIR / "static" / "assets"
if _assets_dir.is_dir():
app.mount("/assets", StaticFiles(directory=str(_assets_dir)), name="assets")
else:
logger.warning(
"static assets dir missing: %s — build the frontend (make init) before serving the SPA",
_assets_dir,
)
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _staff_item_photo_url(event_id: str, item_id: str) -> str:
return f"/api/events/{event_id}/staff/items/{item_id}/photo"
def _filedata_path(filedata: Any) -> str:
if isinstance(filedata, dict):
return filedata.get("path") or filedata.get("name") or ""
path = getattr(filedata, "path", None) or getattr(filedata, "name", None)
return str(path)
_STAFF_ONLY_CLAIM_FIELDS = ("candidates", "match_state")
def _public_claim(claim: dict[str, Any]) -> dict[str, Any]:
"""Strip staff-only fields (candidate matches) from a claimant-facing claim."""
return {k: v for k, v in claim.items() if k not in _STAFF_ONLY_CLAIM_FIELDS}
def _filter_candidates(event_id: str, state: str, raw_candidates: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
"""Keep only candidates that resolve to a real inventory item; never leak the
item object, just the staff-facing fields. State is recomputed from the
survivors so it can't claim a match with zero real candidates."""
out: list[dict[str, Any]] = []
for c in raw_candidates:
item_id = c.get("item_id")
if not item_id or not store.get_item(event_id, item_id):
continue
out.append(
{k: c.get(k) for k in ("item_id", "state", "score", "reason", "staff_next_step", "safe_claimant_message")}
)
if not out and state in {"strong_candidate", "weak_candidate"}:
state = "no_match"
return state, out
def _get_event_or_404(event_id: str) -> dict[str, Any]:
event = store.get_event(event_id)
if not event:
raise HTTPException(status_code=404, detail=f"Event not found: {event_id}")
return event
def _staff_password_from_request(request: Request) -> str:
return request.headers.get("x-staff-password") or request.query_params.get("staff_password") or ""
def _require_staff(request: Request, event_id: str) -> dict[str, Any]:
"""Authorize a staff request against the per-event password (constant-time)."""
event = _get_event_or_404(event_id)
provided = _staff_password_from_request(request)
if not verify_password(provided, event.get("staff_password_hash", ""), event.get("staff_password_salt", "")):
raise HTTPException(
status_code=401,
detail="Staff password required for this event. Set the x-staff-password header.",
)
return event
def _assert_staff_password(event_id: str, password: str) -> None:
"""Password check for Gradio API endpoints (no Request object available)."""
event = store.get_event(event_id)
if not event or not verify_password(
password or "", event.get("staff_password_hash", ""), event.get("staff_password_salt", "")
):
raise ValueError("Invalid event or staff password")
def _save_upload_from_path(source_path: str, original_filename: str | None = None) -> tuple[str, Path]:
src = Path(source_path)
suffix = Path(original_filename or src.name or "item.jpg").suffix.lower() or ".jpg"
if suffix not in {".jpg", ".jpeg", ".png", ".webp"}:
suffix = ".jpg"
item_id = new_id("item")
dest = settings.upload_dir / f"{item_id}{suffix}"
shutil.copyfile(src, dest)
return item_id, dest
def _unique_event_id(requested: str) -> str:
"""Return a free event id: honour a valid requested slug, else generate one."""
slug = slugify_event_id(requested)
if slug:
if store.event_exists(slug):
raise HTTPException(status_code=409, detail="That desk code is already taken — try another one.")
return slug
for _ in range(20):
candidate = new_event_id()
if not store.event_exists(candidate):
return candidate
raise HTTPException(status_code=500, detail="We couldn't generate a desk code — please try again.")
def _create_item_record(
event_id: str, photo_path: Path, item_id: str, found_location: str = "", staff_note: str = ""
) -> dict[str, Any]:
caption_result = _generate_caption_gpu(str(photo_path), found_location, staff_note)
caption = caption_result.get("caption", "")
item = Item(
item_id=item_id,
event_id=event_id,
caption=caption,
photo_path=str(photo_path),
photo_url=_staff_item_photo_url(event_id, item_id),
found_location=found_location,
staff_note=staff_note,
privacy_note=caption_result.get("privacy_note", "not checked"),
)
stored = store.add_item(event_id, item)
# Precompute the retrieval embedding now (at caption time) so large-inventory
# matching can shortlist top-N without re-embedding everything later.
if settings.retrieval_enabled:
try:
doc_text = " ".join(t for t in [caption, found_location, staff_note] if t)
vec = _embed_item_gpu(str(photo_path), doc_text)
store.set_item_embedding(event_id, item_id, vec)
except Exception: # pragma: no cover - embedding is best-effort
logger.warning("item embedding failed for %s (event=%s)", item_id, event_id, exc_info=True)
return stored
def _ensure_item_embeddings(event_id: str, items: list[dict[str, Any]]) -> dict[str, list[float]]:
embeddings = store.get_item_embeddings(event_id)
for item in items:
iid = item.get("item_id", "")
if iid and iid not in embeddings:
try:
doc = " ".join(t for t in [item.get("caption", ""), item.get("found_location", ""), item.get("staff_note", "")] if t)
vec = _embed_item_gpu(item.get("photo_path", ""), doc)
store.set_item_embedding(event_id, iid, vec)
embeddings[iid] = vec
except Exception: # pragma: no cover - best effort
logger.warning("embedding failed for item %s", iid, exc_info=True)
return embeddings
def _embedding_match(event_id: str, summary: str, items: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
"""Multilingual semantic match via the embedding space — lets a claimant's
description in any language retrieve an English-captioned item."""
embeddings = _ensure_item_embeddings(event_id, items)
query = _embed_query_gpu(summary)
scored = sorted(((cosine(query, embeddings.get(i.get("item_id", ""), [])), i) for i in items), key=lambda t: t[0], reverse=True)
candidates: list[dict[str, Any]] = []
for sim, item in scored[:3]:
# Thresholds tuned to nvidia/llama-nemotron-embed-vl-1b-v2's scale: a
# text-query vs image+caption doc tops out ~0.7 (self), a good
# same-language match ~0.4, a cross-language match ~0.25, and unrelated
# ~0.06. So ~0.18 surfaces plausible (incl. cross-language) matches for
# staff to judge, ~0.32 marks a confident one.
state = "strong_candidate" if sim >= settings.embed_strong_threshold else "weak_candidate" if sim >= settings.embed_weak_threshold else None
if state is None:
continue
candidates.append(
{
"item_id": item["item_id"],
"state": state,
"score": round(sim, 3),
"reason": "The owner's description is close to this item record.",
"staff_next_step": "Review the photo and description privately, then confirm in person before arranging pickup.",
"safe_claimant_message": "Staff may have a possible item to review. Please be ready to confirm extra details at pickup.",
}
)
return (candidates[0]["state"] if candidates else "no_match"), candidates
def _live_match(event_id: str, summary: str) -> tuple[str, list[dict[str, Any]]]:
"""Per-turn match against unclaimed inventory, so staff see candidates update
live as the claimant describes their item.
Real mode uses the multilingual embedding space (bridges Chinese↔English);
mock/CPU uses the cheap deterministic token matcher. Either way it never
calls the heavy chat model and falls back gracefully on error."""
items = store.list_unclaimed_items(event_id)
if not items:
return "no_match", []
if settings.retrieval_enabled and settings.model_mode == "real":
try:
return _embedding_match(event_id, summary, items)
except Exception: # pragma: no cover - fall back to token matching
logger.warning("embedding match failed; using token matcher", exc_info=True)
state, candidates = rank_candidates(summary, items)
return _filter_candidates(event_id, state, [c.to_dict() for c in candidates])
def _update_claim_with_chat_result(event_id: str, claim_id: str, user_message: str) -> dict[str, Any]:
claim = store.get_claim(event_id, claim_id)
if not claim:
raise ValueError(f"Claim not found: {claim_id}")
# Real mode: the reply is generated by MiniCPM5 (GPU-wrapped for ZeroGPU),
# guided by the inventory-aware planner; explicit mock mode answers from
# the planner directly (see services.claim_chat_step).
items = store.list_unclaimed_items(event_id)
result = _claim_chat_gpu(claim.get("conversation", []), user_message, items)
new_summary = result.get("summary", claim.get("summary", ""))
match_state, candidates = _live_match(event_id, new_summary)
# The chat tracks description-readiness + the live match only; it must NOT
# touch the lifecycle status (submitting does that) so a later chat turn can
# never demote a submitted/closed claim.
updated = store.append_to_claim(
event_id,
claim_id,
messages=[
ChatMessage(role="user", content=user_message).to_dict(),
ChatMessage(role="assistant", content=result.get("assistant_message", "")).to_dict(),
],
summary=new_summary,
missing_info=result.get("missing_info", []),
readiness_state=result.get("readiness_state", "collecting"),
candidates=candidates,
match_state=match_state,
)
return {"claim": _public_claim(updated), "assistant": result}
def _claimant_photo_url(event_id: str, claim_id: str, photo_id: str) -> str:
return f"/api/events/{event_id}/claims/{claim_id}/photos/{photo_id}"
def _find_upload_file(file_id: str) -> Path | None:
"""Locate an uploaded file by its id prefix (no stored absolute path)."""
if not file_id or "/" in file_id or "." in file_id:
return None
for candidate in settings.upload_dir.glob(f"{file_id}.*"):
if candidate.is_file():
return candidate
return None
def _add_claimant_photo(
event_id: str, claim_id: str, source_path: str, original_filename: str | None = None
) -> dict[str, Any]:
"""Caption a claimant-supplied photo and fold it into their claim.
The claimant's own MiniCPM-V caption is English-normalized like the staff
inventory captions, so it gives staff something concrete to match against —
which also bridges the language gap when the claimant types in another
language.
"""
claim = store.get_claim(event_id, claim_id)
if not claim:
raise ValueError(f"Claim not found: {claim_id}")
src = Path(source_path)
suffix = Path(original_filename or src.name or "photo.jpg").suffix.lower() or ".jpg"
if suffix not in {".jpg", ".jpeg", ".png", ".webp"}:
suffix = ".jpg"
photo_id = new_id("cphoto")
dest = settings.upload_dir / f"{photo_id}{suffix}"
shutil.copyfile(src, dest)
caption_result = _generate_caption_gpu(str(dest))
caption = caption_result.get("caption", "")
entry = {
"photo_id": photo_id,
"photo_url": _claimant_photo_url(event_id, claim_id, photo_id),
"caption": caption,
"privacy_note": caption_result.get("privacy_note", "not checked"),
"created_at": now_iso(),
}
# Detect from the claimant's own words only — the bilingual seed message
# would otherwise force "zh" for every conversation.
language = detect_language(
" ".join(m.get("content", "") for m in claim.get("conversation", []) if m.get("role") == "user")
)
ack = (
f"我從你上傳的照片辨識到:「{caption}」。如果有想補充的細節,也可以直接告訴我。"
if language == "zh"
else f"From your photo I can see: “{caption}”. Tell me anything you'd like to add."
)
summary = (claim.get("summary", "") + f" Photo: {caption}").strip()
match_state, candidates = _live_match(event_id, summary)
updated = store.append_to_claim(
event_id,
claim_id,
messages=[ChatMessage(role="assistant", content=ack).to_dict()],
photos=[entry],
summary=summary,
# A captioned photo is concrete, matchable evidence — let the claimant
# submit right away (readiness only; submitting sets the lifecycle status).
readiness_state="ready_for_staff_review",
candidates=candidates,
match_state=match_state,
)
increment("lfd.claims.photo_added", attributes={"event_id": event_id})
logger.info("claimant added photo %s to claim %s (event=%s)", photo_id, claim_id, event_id)
return {"claim": _public_claim(updated), "photo": entry}
def _shortlist_items(event_id: str, claim: dict[str, Any], items: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Narrow a large inventory to the top-N by embedding similarity to the claim.
No-op for small inventories (<= LFD_MATCH_TOPK) or when retrieval is disabled,
so small events behave exactly as before and pay no embedding cost at match.
"""
topk = settings.match_retrieve_topk
if not settings.retrieval_enabled or len(items) <= topk:
return items
embeddings = store.get_item_embeddings(event_id)
# Self-heal: embed any existing/seed item that lacks a stored vector.
for item in items:
iid = item.get("item_id", "")
if iid and iid not in embeddings:
try:
doc = " ".join(t for t in [item.get("caption", ""), item.get("found_location", ""), item.get("staff_note", "")] if t)
vec = _embed_item_gpu(item.get("photo_path", ""), doc)
store.set_item_embedding(event_id, iid, vec)
embeddings[iid] = vec
except Exception: # pragma: no cover - best effort
logger.warning("on-demand item embedding failed for %s", iid, exc_info=True)
query_vec = _embed_query_gpu(claim.get("summary", ""))
shortlisted = rank_items_by_embedding(query_vec, items, embeddings, topk)
logger.info("shortlisted %d/%d items for claim %s via embeddings", len(shortlisted), len(items), claim.get("claim_id"))
return shortlisted
def _match_claim_for_staff(event_id: str, claim_id: str) -> dict[str, Any]:
"""Deeper, on-demand re-match (uses the model in real mode + embedding
shortlist). Stores the result on the claim; does NOT change lifecycle status
(the live per-turn match already keeps ``match_state`` current)."""
claim = store.get_claim(event_id, claim_id)
if not claim:
raise ValueError(f"Claim not found: {claim_id}")
items = _shortlist_items(event_id, claim, store.list_unclaimed_items(event_id))
result = _match_claim_gpu(claim, items)
state, candidates = _filter_candidates(event_id, result.get("state", "no_match"), result.get("candidates", []))
store.update_claim(event_id, claim_id, candidates=candidates, match_state=state)
increment("lfd.claims.matched", attributes={"event_id": event_id, "state": state})
logger.info("re-matched claim %s (event=%s): state=%s candidates=%d", claim_id, event_id, state, len(candidates))
return {"state": state, "candidates": candidates}
# --------------------------------------------------------------------------- #
# GPU-wrapped model calls (pure inference; no store access)
# --------------------------------------------------------------------------- #
@maybe_zerogpu(duration=settings.zerogpu_caption_duration)
def _generate_caption_gpu(image_path: str, found_location: str = "", staff_note: str = "") -> dict[str, str]:
return hub.generate_caption(image_path, found_location, staff_note)
@maybe_zerogpu(duration=settings.zerogpu_text_duration)
def _claim_chat_gpu(conversation: list[dict[str, Any]], user_message: str, items: list[dict[str, Any]]) -> dict[str, Any]:
return hub.claim_chat_step(conversation, user_message, items)
@maybe_zerogpu(duration=settings.zerogpu_text_duration)
def _match_claim_gpu(claim: dict[str, Any], items: list[dict[str, Any]]) -> dict[str, Any]:
return hub.match_claim(claim, items)
@maybe_zerogpu(duration=settings.zerogpu_text_duration)
def _draft_message_gpu(claim: dict[str, Any], item: dict[str, Any] | None = None) -> str:
return hub.draft_message(claim, item)
@maybe_zerogpu(duration=settings.zerogpu_caption_duration)
def _embed_item_gpu(image_path: str, text: str) -> list[float]:
return hub.embed_item(image_path, text)
@maybe_zerogpu(duration=settings.zerogpu_text_duration)
def _embed_query_gpu(text: str) -> list[float]:
return hub.embed_query(text)
# --------------------------------------------------------------------------- #
# Gradio API endpoints (ZeroGPU-friendly; event-aware)
# --------------------------------------------------------------------------- #
@app.api(name="create_item", concurrency_limit=1)
def create_item_api(
event_id: str, image: FileData, found_location: str = "", staff_note: str = "", staff_password: str = ""
) -> dict[str, Any]:
"""ZeroGPU-friendly item intake from the browser."""
_assert_staff_password(event_id, staff_password)
image_path = _filedata_path(image)
item_id, dest = _save_upload_from_path(image_path)
item = _create_item_record(event_id, dest, item_id, found_location, staff_note)
increment("lfd.items.created", attributes={"event_id": event_id, "source": "gradio"})
logger.info("created item %s (event=%s) via gradio api", item_id, event_id)
return {"item": item}
@app.api(name="claim_chat", concurrency_limit=1)
def claim_chat_api(event_id: str, claim_id: str, user_message: str) -> dict[str, Any]:
"""Claimant-facing follow-up step with store update."""
return _update_claim_with_chat_result(event_id, claim_id, user_message)
@app.api(name="claim_add_photo", concurrency_limit=1)
def claim_add_photo_api(event_id: str, claim_id: str, image: FileData) -> dict[str, Any]:
"""ZeroGPU-friendly claimant photo intake + captioning."""
image_path = _filedata_path(image)
return _add_claimant_photo(event_id, claim_id, image_path)
@app.api(name="match_claim", concurrency_limit=1)
def match_claim_api(event_id: str, claim_id: str, staff_password: str = "") -> dict[str, Any]:
"""Staff-side candidate ranking."""
_assert_staff_password(event_id, staff_password)
return _match_claim_for_staff(event_id, claim_id)
@app.api(name="draft_message", concurrency_limit=1)
def draft_message_api(event_id: str, claim_id: str, item_id: str = "", staff_password: str = "") -> dict[str, str]:
"""Safe handoff message drafting."""
_assert_staff_password(event_id, staff_password)
claim = store.get_claim(event_id, claim_id)
if not claim:
raise ValueError(f"Claim not found: {claim_id}")
item = store.get_item(event_id, item_id) if item_id else None
return {"message": _draft_message_gpu(claim, item)}
# --------------------------------------------------------------------------- #
# SPA shell
# --------------------------------------------------------------------------- #
def _read_index_html() -> str | None:
index = BASE_DIR / "static" / "index.html"
try:
return index.read_text(encoding="utf-8")
except FileNotFoundError:
return None
_INDEX_MISSING_HTML = (
"<!doctype html><html><body style='font-family:sans-serif;padding:2rem'>"
"<h1>Frontend not built</h1><p>Run <code>make init</code> (or "
"<code>cd frontend &amp;&amp; pnpm install &amp;&amp; pnpm run build</code>) to build the SPA "
"into <code>static/</code>, then restart.</p></body></html>"
)
@app.get("/", response_class=HTMLResponse)
async def root() -> HTMLResponse:
html = _read_index_html()
if html is None:
return HTMLResponse(_INDEX_MISSING_HTML, status_code=503)
return HTMLResponse(html)
@app.get("/health")
async def health() -> dict[str, Any]:
return {
"ok": True,
"model_mode": settings.model_mode,
"zerogpu": settings.zerogpu,
"storage": str(settings.db_path),
"events": len(store.list_events()),
}
@app.get("/api/config")
async def public_config() -> dict[str, Any]:
return {
"app_name": settings.app_name,
"model_mode": settings.model_mode,
"device": settings.device,
"zerogpu": settings.zerogpu,
"prefer_gradio_client_for_models": settings.zerogpu,
}
# --------------------------------------------------------------------------- #
# Event lifecycle (public — creating a help desk is self-serve)
# --------------------------------------------------------------------------- #
@app.post("/api/events")
async def create_event(request: Request) -> dict[str, Any]:
payload = await request.json()
name = (payload.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Give your event a name first.")
event_id = _unique_event_id(payload.get("event_id") or "")
password = generate_staff_password()
password_hash, salt = hash_password(password)
record = store.create_event(
Event(event_id=event_id, name=name, staff_password_hash=password_hash, staff_password_salt=salt)
)
increment("lfd.events.created")
logger.info("created event %s (%r)", event_id, name)
# The plaintext staff password is returned exactly once — it is never stored.
return {
"event_id": event_id,
"name": record["name"],
"staff_password": password,
"staff_url": f"/e/{event_id}/staff",
"claim_url": f"/e/{event_id}",
"created_at": record["created_at"],
}
@app.get("/api/events/{event_id}")
async def get_event_public(event_id: str) -> dict[str, Any]:
event = _get_event_or_404(event_id)
return {"event_id": event_id, "name": event.get("name", ""), "exists": True}
@app.post("/api/events/{event_id}/staff/verify")
async def verify_staff(event_id: str, request: Request) -> dict[str, Any]:
_require_staff(request, event_id)
event = store.get_event(event_id)
return {"ok": True, "event_id": event_id, "name": event.get("name", "")}
# --------------------------------------------------------------------------- #
# Public claim flow (claimant-facing; no inventory, photos, or candidates)
# --------------------------------------------------------------------------- #
@app.post("/api/events/{event_id}/claims")
async def start_claim(event_id: str) -> dict[str, Any]:
_get_event_or_404(event_id)
claim = Claim(
claim_id=new_claim_id(),
event_id=event_id,
conversation=[ChatMessage(role="assistant", content=SEED_MESSAGE).to_dict()],
status="draft",
readiness_state="collecting",
)
stored = store.add_claim(event_id, claim)
increment("lfd.claims.started", attributes={"event_id": event_id})
logger.info("started claim %s (event=%s)", stored["claim_id"], event_id)
return {"claim": _public_claim(stored)}
@app.get("/api/events/{event_id}/claims/{claim_id}")
async def get_claim_public(event_id: str, claim_id: str) -> dict[str, Any]:
_get_event_or_404(event_id)
claim = store.get_claim(event_id, claim_id)
if not claim:
raise HTTPException(status_code=404, detail="Claim not found")
# Read-only: the claimant frontend computes "new staff message" by diffing
# the conversation it has already seen, so a poll never mutates the claim
# (which would also race a concurrently-set unread flag).
return {"claim": _public_claim(claim)}
@app.post("/api/events/{event_id}/claims/{claim_id}/chat")
async def public_claim_chat(event_id: str, claim_id: str, request: Request) -> dict[str, Any]:
_get_event_or_404(event_id)
if not store.get_claim(event_id, claim_id):
raise HTTPException(status_code=404, detail="Claim not found")
payload = await request.json()
user_message = (payload.get("message") or "").strip()
if not user_message:
raise HTTPException(status_code=400, detail="Please type a message first.")
try:
return await run_in_threadpool(_update_claim_with_chat_result, event_id, claim_id, user_message)
except ValueError as exc:
raise HTTPException(status_code=404, detail="We couldn't find that report. Check the link and try again.") from exc
@app.post("/api/events/{event_id}/claims/{claim_id}/photo")
async def public_claim_photo(
event_id: str, claim_id: str, photo: UploadFile = File(...)
) -> dict[str, Any]:
"""Claimant uploads a photo of their own lost item; MiniCPM-V captions it."""
_get_event_or_404(event_id)
if not store.get_claim(event_id, claim_id):
raise HTTPException(status_code=404, detail="Claim not found")
suffix = Path(photo.filename or "photo.jpg").suffix.lower() or ".jpg"
if suffix not in {".jpg", ".jpeg", ".png", ".webp"}:
suffix = ".jpg"
tmp_id = new_id("tmp")
tmp_path = settings.upload_dir / f"{tmp_id}{suffix}"
with tmp_path.open("wb") as f:
shutil.copyfileobj(photo.file, f)
try:
result = await run_in_threadpool(
_add_claimant_photo, event_id, claim_id, str(tmp_path), photo.filename
)
except ValueError as exc:
raise HTTPException(status_code=404, detail="We couldn't find that report. Check the link and try again.") from exc
finally:
tmp_path.unlink(missing_ok=True)
return result
@app.get("/api/events/{event_id}/claims/{claim_id}/photos/{photo_id}")
async def claimant_photo(event_id: str, claim_id: str, photo_id: str):
"""Serve a claimant's own uploaded photo (knowing the claim id authorizes it)."""
_get_event_or_404(event_id)
claim = store.get_claim(event_id, claim_id)
if not claim:
raise HTTPException(status_code=404, detail="Claim not found")
if not any(p.get("photo_id") == photo_id for p in claim.get("claimant_photos", [])):
raise HTTPException(status_code=404, detail="Photo not found")
path = _find_upload_file(photo_id)
if path is None:
raise HTTPException(status_code=404, detail="Photo file not found")
return FileResponse(str(path))
@app.post("/api/events/{event_id}/claims/{claim_id}/submit")
async def submit_claim(event_id: str, claim_id: str, request: Request) -> dict[str, Any]:
_get_event_or_404(event_id)
claim = store.get_claim(event_id, claim_id)
if not claim:
raise HTTPException(status_code=404, detail="Claim not found")
payload = await request.json()
contact_name = (payload.get("contact_name") or "").strip()
contact_info = (payload.get("contact_info") or "").strip()
if not contact_info:
raise HTTPException(status_code=400, detail="Please add a way to reach you — an email or phone number.")
status = "ready_for_staff_review" if claim.get("readiness_state") == "ready_for_staff_review" else "needs_more_info"
updated = store.update_claim(
event_id, claim_id, contact_name=contact_name, contact_info=contact_info, status=status
)
increment("lfd.claims.submitted", attributes={"event_id": event_id, "status": status})
logger.info("submitted claim %s (event=%s): status=%s", claim_id, event_id, status)
return {
"claim": _public_claim(updated),
"message": "Thanks. Staff will review possible matches and confirm before any handoff.",
}
# --------------------------------------------------------------------------- #
# Staff console (per-event password required)
# --------------------------------------------------------------------------- #
@app.get("/api/events/{event_id}/staff/items")
async def list_items(event_id: str, request: Request) -> dict[str, Any]:
_require_staff(request, event_id)
return {"items": store.list_items(event_id)}
@app.post("/api/events/{event_id}/staff/items")
async def create_item(
event_id: str,
request: Request,
photo: UploadFile = File(...),
found_location: str = Form(""),
staff_note: str = Form(""),
) -> dict[str, Any]:
_require_staff(request, event_id)
suffix = Path(photo.filename or "item.jpg").suffix.lower() or ".jpg"
if suffix not in {".jpg", ".jpeg", ".png", ".webp"}:
suffix = ".jpg"
item_id = new_id("item")
dest = settings.upload_dir / f"{item_id}{suffix}"
with dest.open("wb") as f:
shutil.copyfileobj(photo.file, f)
# Captioning is CPU/GPU-bound and synchronous; run it off the event loop so a
# slow (MPS) caption does not freeze the single-worker server.
item = await run_in_threadpool(_create_item_record, event_id, dest, item_id, found_location, staff_note)
increment("lfd.items.created", attributes={"event_id": event_id, "source": "rest"})
logger.info("created item %s (event=%s, found_location=%r)", item_id, event_id, found_location)
return {"item": item}
@app.get("/api/events/{event_id}/staff/items/{item_id}/photo")
async def staff_item_photo(event_id: str, item_id: str, request: Request):
"""Serve item photos only to authorized staff.
Claimants never receive inventory photo URLs. The Svelte frontend fetches
these images as authenticated blobs using the staff password header.
"""
_require_staff(request, event_id)
item = store.get_item(event_id, item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
path = Path(item.get("photo_path", ""))
if not path.exists() or not path.is_file():
raise HTTPException(status_code=404, detail="Photo not found")
return FileResponse(str(path))
@app.patch("/api/events/{event_id}/staff/items/{item_id}")
async def update_item(event_id: str, item_id: str, request: Request) -> dict[str, Any]:
_require_staff(request, event_id)
payload = await request.json()
allowed = {"caption", "found_location", "staff_note", "privacy_note", "status"}
updates = {k: v for k, v in payload.items() if k in allowed}
if not store.get_item(event_id, item_id):
raise HTTPException(status_code=404, detail="Item not found")
return {"item": store.update_item(event_id, item_id, **updates)}
@app.get("/api/events/{event_id}/staff/claims")
async def list_claims(event_id: str, request: Request) -> dict[str, Any]:
_require_staff(request, event_id)
return {"claims": store.list_claims(event_id)}
@app.post("/api/events/{event_id}/staff/claims/{claim_id}/match")
async def staff_match_claim(event_id: str, claim_id: str, request: Request) -> dict[str, Any]:
_require_staff(request, event_id)
if not store.get_claim(event_id, claim_id):
raise HTTPException(status_code=404, detail="Claim not found")
return await run_in_threadpool(_match_claim_for_staff, event_id, claim_id)
@app.post("/api/events/{event_id}/staff/claims/{claim_id}/message")
async def staff_message_claimant(event_id: str, claim_id: str, request: Request) -> dict[str, Any]:
"""Post a staff message into the claimant's conversation (visible on resume)."""
_require_staff(request, event_id)
claim = store.get_claim(event_id, claim_id)
if not claim:
raise HTTPException(status_code=404, detail="Claim not found")
payload = await request.json()
message = (payload.get("message") or "").strip()
if not message:
raise HTTPException(status_code=400, detail="Please type a message first.")
updated = store.append_to_claim(
event_id,
claim_id,
messages=[ChatMessage(role="staff", content=message).to_dict()],
has_unread_staff_message=True,
)
increment("lfd.claims.staff_message", attributes={"event_id": event_id})
logger.info("staff messaged claim %s (event=%s)", claim_id, event_id)
return {"claim": updated}
@app.post("/api/events/{event_id}/staff/draft_message")
async def staff_draft_message(event_id: str, request: Request) -> dict[str, Any]:
_require_staff(request, event_id)
payload = await request.json()
claim = store.get_claim(event_id, payload.get("claim_id", ""))
item = store.get_item(event_id, payload.get("item_id", "")) if payload.get("item_id") else None
if not claim:
raise HTTPException(status_code=404, detail="Claim not found")
message = await run_in_threadpool(_draft_message_gpu, claim, item)
return {"message": message}
@app.post("/api/events/{event_id}/staff/returns")
async def mark_returned(event_id: str, request: Request) -> dict[str, Any]:
_require_staff(request, event_id)
payload = await request.json()
item_id = payload.get("item_id")
claim_id = payload.get("claim_id")
if not item_id or not claim_id:
raise HTTPException(status_code=400, detail="Choose an item and a report before recording a return.")
if not store.get_item(event_id, item_id):
raise HTTPException(status_code=404, detail="Item not found")
if not store.get_claim(event_id, claim_id):
raise HTTPException(status_code=404, detail="Claim not found")
log = ReturnLog(
log_id=new_id("return"),
event_id=event_id,
item_id=item_id,
claim_id=claim_id,
staff_note=payload.get("staff_note", ""),
)
stored = store.add_return(event_id, log)
increment("lfd.returns.recorded", attributes={"event_id": event_id})
logger.info("recorded return %s (event=%s): item=%s claim=%s", stored["log_id"], event_id, item_id, claim_id)
return {"return": stored}
@app.get("/api/events/{event_id}/staff/report")
async def report(event_id: str, request: Request) -> dict[str, Any]:
_require_staff(request, event_id)
return store.report(event_id)
@app.middleware("http")
async def spa_fallback(request: Request, call_next):
"""Serve the SPA shell for client-side routes (/e/:id, /e/:id/staff, ...).
Implemented as middleware rather than a ``/{path:path}`` catch-all route so
it never shadows Gradio's own GET endpoints (config/info/queue/upload/file),
which Gradio appends to this same app at launch time. Only unmatched (404)
GET requests to non-reserved paths fall back to index.html.
"""
response = await call_next(request)
if response.status_code == 404 and request.method in {"GET", "HEAD"}:
path = request.url.path.lstrip("/")
if not (path.startswith(_RESERVED_PREFIXES) or path in _RESERVED_EXACT):
html = _read_index_html()
if html is not None:
return HTMLResponse(html)
return response
if __name__ == "__main__":
# HF Spaces sets PORT; Gradio defaults to 7860 locally.
port = int(os.getenv("PORT", "7860"))
logger.info(
"starting Lost & Found Desk: model_mode=%s device=%s zerogpu=%s events=%d port=%d",
settings.model_mode,
settings.device,
settings.zerogpu,
len(store.list_events()),
port,
)
try:
app.launch(server_name="0.0.0.0", server_port=port, show_error=True)
finally:
shutdown_telemetry()