7d40cf95bf
- FastAPI Identify API (port 8081) - Neo4j propertygraf (64 noder, 80 kanter) - Ingest-pipeline (Python) - Byggscript + integrationstester - Docker Compose setup
232 lines
8.1 KiB
Python
232 lines
8.1 KiB
Python
#!/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)
|