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
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY main.py .
|
||||
EXPOSE 8080
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
@@ -0,0 +1,231 @@
|
||||
#!/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)
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi>=0.110
|
||||
uvicorn[standard]>=0.29
|
||||
python-multipart>=0.0.9
|
||||
pydantic>=2.6
|
||||
Reference in New Issue
Block a user