Files
boc/projects/landvex/api/main.py
T
Bernt 7d40cf95bf landvex: IOL v3 + Identify API + Neo4j-graf + byggpipeline
- FastAPI Identify API (port 8081)
- Neo4j propertygraf (64 noder, 80 kanter)
- Ingest-pipeline (Python)
- Byggscript + integrationstester
- Docker Compose setup
2026-07-05 05:55:48 +00:00

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)