| 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() |
|
|
| |
| 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() |
|
|
| |
| |
| _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, |
| ) |
|
|
|
|
| |
| |
| |
| 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) |
| |
| |
| 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: |
| 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: |
| 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]: |
| |
| |
| |
| |
| |
| 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: |
| 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}") |
| |
| |
| |
| 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) |
| |
| |
| |
| 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(), |
| } |
| |
| |
| 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, |
| |
| |
| 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) |
| |
| 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: |
| 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} |
|
|
|
|
| |
| |
| |
| @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) |
|
|
|
|
| |
| |
| |
| @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)} |
|
|
|
|
| |
| |
| |
| 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 && pnpm install && 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, |
| } |
|
|
|
|
| |
| |
| |
| @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) |
| |
| 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", "")} |
|
|
|
|
| |
| |
| |
| @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") |
| |
| |
| |
| 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.", |
| } |
|
|
|
|
| |
| |
| |
| @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) |
| |
| |
| 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__": |
| |
| 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() |
|
|