#!/usr/bin/env python3 """ Landvex Identify API v0 FastAPI-implementation av pilot-specen. """ import json import hashlib from pathlib import Path from typing import List, Optional from datetime import datetime from fastapi import FastAPI, HTTPException, File, UploadFile, Form from pydantic import BaseModel, Field # ─── ladda data vid startup ───────────────────────────────────────── DATA_DIR = Path("/app/data") poster = {} # lvx_id -> post kanter = [] # grafkanter index_slug = {} # slug -> lvx_id index_namn = {} # sv+en -> lvx_id def ladda(): global poster, kanter, index_slug, index_namn with open(DATA_DIR / "lvx-klassposter-master-v3.json", encoding="utf-8") as f: for p in json.load(f)["poster"]: poster[p["lvx_id"]] = p if p.get("slug"): index_slug[p["slug"]] = p["lvx_id"] for lang in ("sv", "en"): if p.get("namn", {}).get(lang): index_namn[p["namn"][lang].lower()] = p["lvx_id"] # modeller for fn in ("lvx-produktmodeller-vag2.json", "lvx-produktmodeller-vag3.json", "lvx-produktmodeller-auto.json"): fp = DATA_DIR / fn if fp.exists(): with open(fp, encoding="utf-8") as f: for p in json.load(f).get("poster", []): poster[p["lvx_id"]] = p # graf with open(DATA_DIR / "lvx-graf-kanter-v3.json", encoding="utf-8") as f: kanter = json.load(f)["kanter"] ladda() # ─── Pydantic-modeller ────────────────────────────────────────────── class Kalla(BaseModel): titel: str url: Optional[str] = None class IdentifyKandidat(BaseModel): lvx_id: str posttyp: str namn: dict konfidens: float = Field(..., ge=0, le=1) verifieringsniva: str kannetecken_match: List[str] = [] ocr_falt: Optional[dict] = None class IdentifyResponse(BaseModel): bild_hash: str kandidater: List[IdentifyKandidat] bounty_skapad: Optional[str] = None besked: str class ObjectResponse(BaseModel): lvx_id: str posttyp: str namn: dict doman: str verifieringsniva: str konfidens: float kallor: List[Kalla] teknik: Optional[dict] = None ai: Optional[dict] = None problem: List[dict] = [] relationer: List[dict] = [] class FeedbackRequest(BaseModel): lvx_id: Optional[str] = None bild_hash: Optional[str] = None kommentar: str position: Optional[str] = None class FeedbackResponse(BaseModel): bounty_id: str status: str # ─── helpers ──────────────────────────────────────────────────────── def relaterade(obj_id: str): """Hämta alla kanter där objektet är subjekt.""" return [k for k in kanter if k["subjekt"] == obj_id] def succession(obj_id: str): """Ersättningskedja framåt och bakåt.""" ut = {"ersatter": [], "ersatts_av": []} for k in kanter: if k["subjekt"] == obj_id: if k["predikat"] == "ersatter": ut["ersatter"].append(k["objekt"]) if k["predikat"] == "ersatts_av": ut["ersatts_av"].append(k["objekt"]) if k["objekt"] == obj_id: if k["predikat"] == "ersatter": ut["ersatts_av"].append(k["subjekt"]) if k["predikat"] == "ersatts_av": ut["ersatter"].append(k["subjekt"]) return ut def hash_bild(data: bytes) -> str: return hashlib.sha256(data).hexdigest()[:16] # ─── FastAPI app ──────────────────────────────────────────────────── app = FastAPI( title="Landvex Object API", description="Identify, query and feedback API for infrastructure objects.", version="0.1.0" ) @app.get("/health") def health(): return {"status": "ok", "poster": len(poster), "kanter": len(kanter)} @app.post("/v0/identify", response_model=IdentifyResponse) async def identify( bild: UploadFile = File(...), position: Optional[str] = Form(None) ): """ Bild in → rankade kandidater med konfidens. I piloten: mockad vision — svarar med de mest kompletta posterna. """ data = await bild.read() bh = hash_bild(data) # TODO: vision-modell → feature extraction → vektorsök # Pilot: returnera alla Tier 1-klasser sorterade efter konfidens kandidater = [] for p in poster.values(): if p.get("tier") != 1 and p.get("posttyp") != "objektklass": continue prov = p.get("proveniens", {}) k = IdentifyKandidat( lvx_id=p["lvx_id"], posttyp=p["posttyp"], namn=p.get("namn", {}), konfidens=prov.get("konfidens", 0.5), verifieringsniva=prov.get("verifieringsniva", "obekraftad"), kannetecken_match=p.get("ai", {}).get("kannetecken", [])[:3], ) kandidater.append(k) kandidater.sort(key=lambda x: x.konfidens, reverse=True) top = kandidater[:5] # Om ingen når modellnivå → bounty bounty = None besked = "Kandidater hittade" if not any(k.konfidens > 0.75 for k in top): bounty = f"BNTY-{bh[:8]}" besked = "Svag konfidens — bounty skapad för fältverifiering" return IdentifyResponse( bild_hash=bh, kandidater=top, bounty_skapad=bounty, besked=besked ) @app.get("/v0/objects/{lvx_id}", response_model=ObjectResponse) def get_object(lvx_id: str): p = poster.get(lvx_id) if not p: raise HTTPException(status_code=404, detail="Objekt ej funnet") prov = p.get("proveniens", {}) return ObjectResponse( lvx_id=p["lvx_id"], posttyp=p["posttyp"], namn=p.get("namn", {}), doman=p.get("doman", ""), verifieringsniva=prov.get("verifieringsniva", "obekraftad"), konfidens=prov.get("konfidens", 0), kallor=[Kalla(**k) for k in prov.get("kallor", [])], teknik=p.get("teknik"), ai=p.get("ai"), problem=p.get("problem", []), relationer=relaterade(lvx_id), ) @app.get("/v0/objects/{lvx_id}/succession") def get_succession(lvx_id: str): if lvx_id not in poster: raise HTTPException(status_code=404, detail="Objekt ej funnet") return {"lvx_id": lvx_id, **succession(lvx_id)} @app.get("/v0/search") def search(q: str, doman: Optional[str] = None, verifieringsniva: Optional[str] = None): """Fritextsök över namn och kännetecken.""" q = q.lower() träffar = [] for p in poster.values(): namn = " ".join(p.get("namn", {}).values()).lower() kann = " ".join(p.get("ai", {}).get("kannetecken", [])).lower() if q not in namn and q not in kann: continue if doman and p.get("doman") != doman: continue if verifieringsniva and p.get("proveniens", {}).get("verifieringsniva") != verifieringsniva: continue träffar.append({ "lvx_id": p["lvx_id"], "namn": p.get("namn", {}), "verifieringsniva": p.get("proveniens", {}).get("verifieringsniva", "obekraftad"), "konfidens": p.get("proveniens", {}).get("konfidens", 0), }) träffar.sort(key=lambda x: x["konfidens"], reverse=True) return {"antal": len(träffar), "traffar": träffar[:20]} @app.get("/v0/standards/{beteckning}/objects") def objects_by_standard(beteckning: str): bet = beteckning.upper() träffar = [] for k in kanter: if k["predikat"] == "foljer_standard" and bet in k["objekt"].upper(): träffar.append(k["subjekt"]) return {"standard": beteckning, "antal": len(träffar), "lvx_ids": sorted(set(träffar))} @app.post("/v0/feedback", response_model=FeedbackResponse) def feedback(req: FeedbackRequest): """Fel eller okänt objekt → bounty.""" bid = f"BNTY-FB-{hashlib.sha256(req.kommentar.encode()).hexdigest()[:8]}" return FeedbackResponse(bounty_id=bid, status="skapad") if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8080)