Files
boc/projects/landvex/20-automation/ingest.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

234 lines
12 KiB
Python

#!/usr/bin/env python3
# Landvex Ingest v0 — deterministisk pipeline
# kandidatposter (extraktorns output) -> resolve -> merge -> validera -> master/graf/bounty-förslag
import json, re, difflib
from pathlib import Path
OUT = Path("/mnt/user-data/outputs")
IDAG = "2026-07-04"
PREDIKAT = {"monteras_pa","star_pa","ansluts_med","matas_fran","ingar_i","del_av",
"ersatter","ersatts_av","kompatibel_med","forvaxlas_med","tillverkas_av","foljer_standard","variant_av"}
VERIF_RANK = {"obekraftad":0,"kallbelagd":1,"faltverifierad":2,"tillverkarbekraftad":3}
DOMANER = {"TRP","ELN","VAT","TEL","BYG","PRK","JVG","HMN","FLG"}
ID_RE = re.compile(r"^LVX-(TRP|ELN|VAT|TEL|BYG|PRK|JVG|HMN|FLG)-[0-9]{4}$|^LVX-P-[0-9]{7}$")
def load(p):
with open(OUT/p, encoding="utf-8") as f: return json.load(f)
def save(p,obj):
with open(OUT/p,"w",encoding="utf-8") as f: json.dump(obj,f,ensure_ascii=False,indent=2)
def norm_bet(b):
b = b.upper().split(":")[0]
b = re.sub(r"\((KARTLÄGGS|KARTLAGGS)\)","",b)
return re.sub(r"\s+"," ",b).strip()
TOM = ("", None)
def ar_tom(v): return v in TOM or v == [] or v == {}
class Ingest:
def __init__(self):
self.poster = {p["lvx_id"]: p for p in load("lvx-klassposter-master-v2.json")["poster"]}
self.modeller = {}
for fn in ["lvx-produktmodeller-vag2.json","lvx-produktmodeller-vag3.json"]:
for m in load(fn)["poster"]: self.modeller[m["lvx_id"]] = m
self.reg = load("lvx-id-register.json")
self.nya_modeller, self.logg, self.konflikter = [], [], []
self.stat = {"uppdaterade":0,"nya":0,"nivalyft":0,"kallor":0,"standarder":0}
# ---- resolve ----
def hitta(self, kand):
rid = kand.get("lvx_id")
if rid:
if rid in self.poster: return self.poster[rid]
if rid in self.modeller: return self.modeller[rid]
raise KeyError(f"{kand['kandidat_id']}: okänt lvx_id {rid}")
# entitetsupplösning utan ID: slug, annars fuzzy på namn
post = kand.get("post",{})
alla = {**self.poster, **self.modeller}
for r in alla.values():
if r.get("slug") and r["slug"] == post.get("slug"): return r
namn = post.get("namn",{}).get("sv","")
kandnamn = {r["namn"]["sv"]: r for r in alla.values()}
tr = difflib.get_close_matches(namn, kandnamn.keys(), n=1, cutoff=0.88)
return kandnamn[tr[0]] if tr else None
# ---- merge-primitiver ----
def merge_kallor(self, rec, nya):
kl = rec.setdefault("proveniens",{}).setdefault("kallor",[])
urls = {k.get("url") for k in kl}
for k in nya:
if k.get("url") not in urls:
kl.append(k); urls.add(k.get("url"))
self.stat["kallor"] += 1
self.logg.append(f"{rec['lvx_id']}: +källa {k.get('url','?')[:60]}")
def merge_standarder(self, rec, nya):
st = rec.setdefault("standarder",[])
idx = {norm_bet(s["beteckning"]): i for i,s in enumerate(st) if s.get("beteckning")}
for s in nya:
n = norm_bet(s["beteckning"])
if n in idx:
st[idx[n]] = s
self.logg.append(f"{rec['lvx_id']}: standard uppdaterad {s['beteckning']}")
else:
st.append(s)
self.logg.append(f"{rec['lvx_id']}: +standard {s['beteckning']}")
self.stat["standarder"] += 1
def fyll(self, base, patch, rid, stig=""):
for k,v in patch.items():
here = f"{stig}.{k}" if stig else k
if k not in base or ar_tom(base.get(k)):
base[k] = v
self.logg.append(f"{rid}: fyllde {here}")
elif isinstance(base[k], dict) and isinstance(v, dict):
self.fyll(base[k], v, rid, here)
elif base[k] != v:
self.konflikter.append(f"{rid}: {here} — befintligt värde behålls (granskningskö i produktion)")
# ---- åtgärder ----
def uppdatera(self, kand):
rec = self.hitta(kand)
patch = dict(kand.get("patch",{}))
if "standarder" in patch: self.merge_standarder(rec, patch.pop("standarder"))
prov = patch.pop("proveniens", {})
if prov.get("kallor"): self.merge_kallor(rec, prov["kallor"])
if patch: self.fyll(rec, patch, rec["lvx_id"])
fs = kand.get("verifieringsniva_forslag")
if fs:
nu = rec["proveniens"].get("verifieringsniva","obekraftad")
if VERIF_RANK[fs] > VERIF_RANK[nu]:
if VERIF_RANK[fs] >= 1 and not rec["proveniens"].get("kallor"):
self.konflikter.append(f"{rec['lvx_id']}: nivålyft nekad — inga källor")
else:
rec["proveniens"]["verifieringsniva"] = fs
rec["proveniens"]["konfidens"] = max(rec["proveniens"].get("konfidens",0), 0.72)
self.stat["nivalyft"] += 1
self.logg.append(f"{rec['lvx_id']}: {nu}{fs}")
rec["proveniens"]["uppdaterad"] = IDAG
self.stat["uppdaterade"] += 1
def ny(self, kand):
if self.hitta(kand):
self.konflikter.append(f"{kand['kandidat_id']}: dublett — matchar befintlig post, konverteras ej")
return
post = kand["post"]
typ = kand.get("serie",{}).get("typ","modell" if post.get("posttyp")=="produktmodell" else "klass")
if typ == "modell":
post["lvx_id"] = f"LVX-P-{self.reg['P_nasta']:07d}"
self.reg["P_nasta"] += 1
if not post.get("parent"): raise ValueError(f"{kand['kandidat_id']}: modell utan parent")
self.modeller[post["lvx_id"]] = post
self.nya_modeller.append(post)
else:
dom, tier = post["doman"], str(kand["serie"]["tier"])
nn = self.reg["klass_nasta"].setdefault(dom,{}).setdefault(tier,1)
post["lvx_id"] = f"LVX-{dom}-0{tier}{nn:02d}"
self.reg["klass_nasta"][dom][tier] = nn + 1
self.poster[post["lvx_id"]] = post
post.setdefault("proveniens",{}).setdefault("skapad",IDAG)
post["proveniens"]["uppdaterad"] = IDAG
self.stat["nya"] += 1
self.logg.append(f"NY: {post['lvx_id']}{post['namn']['sv']}")
def kor(self, fil):
for kand in load(fil)["kandidater"]:
(self.uppdatera if kand["atgard"]=="uppdatera" else self.ny)(kand)
# ---- validering (samma regler som konsolidering v2) ----
def validera(poster, modeller):
fel = []
def kolla(rec, typ):
rid = rec.get("lvx_id","?")
if not ID_RE.match(rid): fel.append(f"{rid}: ID-format")
if rec.get("doman") not in DOMANER: fel.append(f"{rid}: domän")
if not (rec.get("namn",{}).get("sv") and rec["namn"].get("en")): fel.append(f"{rid}: namn")
prov = rec.get("proveniens",{})
if prov.get("verifieringsniva") not in VERIF_RANK: fel.append(f"{rid}: verifieringsnivå")
for pr in rec.get("problem",[]):
if not pr.get("kalla"): fel.append(f"{rid}: problem utan källa")
for rel in rec.get("relationer",[]):
if rel.get("predikat") not in PREDIKAT: fel.append(f"{rid}: predikat {rel.get('predikat')}")
if typ=="modell" and not rec.get("parent"): fel.append(f"{rid}: parent")
if prov.get("verifieringsniva")=="kallbelagd" and not prov.get("kallor"): fel.append(f"{rid}: källbelagd utan källor")
for p in poster.values(): kolla(p,"klass")
for m in modeller.values(): kolla(m,"modell")
return fel
# ---- graf ----
def bygg_graf(poster, modeller):
kanter, olosta = [], {}
def multi(m): return " / " in m and all(ID_RE.match(x.strip()) for x in m.split(" / "))
def kant(s,pred,obj,niva,beskr=""):
typ = "lvx" if ID_RE.match(obj or "") else ("standard" if pred=="foljer_standard" else "text")
if typ=="text": olosta[obj] = olosta.get(obj,0)+1
kanter.append({"subjekt":s,"predikat":pred,"objekt":obj,"objekt_typ":typ,"niva":niva,
**({"beskrivning":beskr} if beskr else {})})
def emit(s,pred,m,niva,beskr=""):
if multi(m):
for x in m.split(" / "): kant(s,pred,x.strip(),niva,beskr)
else: kant(s,pred,m,niva,beskr)
for p in poster.values():
for rel in p.get("relationer",[]): emit(p["lvx_id"],rel["predikat"],rel["mal"],"klass",rel.get("beskrivning",""))
for st in p.get("standarder",[]):
if st.get("beteckning"): kant(p["lvx_id"],"foljer_standard",st["beteckning"],"klass",st.get("roll",""))
for m in modeller.values():
kant(m["lvx_id"],"tillhor_klass",m["parent"],"modell")
for e in m.get("historik",{}).get("ersatter",[]): kant(m["lvx_id"],"ersatter",e,"modell")
for e in m.get("historik",{}).get("ersatts_av",[]): kant(m["lvx_id"],"ersatts_av",e,"modell")
for rel in m.get("relationer",[]): emit(m["lvx_id"],rel["predikat"],rel["mal"],"modell",rel.get("beskrivning",""))
return kanter, olosta
# ---- flyhjulet: bounty-förslag ur nya/uppgraderade poster ----
def bounty_forslag(poster):
try: tackta = {b["mal_lvx"] for b in load("lvx-zoomer-bounties-vag1.json")["bounties"]}
except FileNotFoundError: tackta = set()
ut = []
for p in poster.values():
if VERIF_RANK[p["proveniens"]["verifieringsniva"]] < 1: continue
falt = p.get("ai",{}).get("ocr",{}).get("falt",[])
if falt and p["lvx_id"] not in tackta:
ut.append({"forslag_id": f"BNTY-K-{len(ut)+1:03d}", "mal_lvx": p["lvx_id"],
"titel": f"OCR-fält: {p['namn']['sv']}", "ocr_falt": falt,
"grund": "auto-genererad ur postens OCR-fält (ingest-pipelinen)"})
return ut
if __name__ == "__main__":
ing = Ingest()
ing.kor("intag/kandidatposter-vag7.json")
fel = validera(ing.poster, ing.modeller)
kanter, olosta = bygg_graf(ing.poster, ing.modeller)
vf = {}
for p in ing.poster.values():
v = p["proveniens"]["verifieringsniva"]; vf[v] = vf.get(v,0)+1
save("lvx-klassposter-master-v3.json", {
"beskrivning":"Landvex IOL — master v3 (ingest-pipelinen, våg 7). Ersätter v2.",
"genererad": IDAG, "antal_poster": len(ing.poster), "verifieringsfordelning": vf,
"poster":[ing.poster[k] for k in sorted(ing.poster)]})
if ing.nya_modeller:
save("lvx-produktmodeller-auto.json", {
"beskrivning":"Modellposter mintade av ingest-pipelinen.", "genererad": IDAG, "poster": ing.nya_modeller})
lvx_k = sum(1 for k in kanter if k["objekt_typ"]=="lvx"); std_k = sum(1 for k in kanter if k["objekt_typ"]=="standard")
save("lvx-graf-kanter-v3.json", {
"beskrivning":"Landvex IOL — grafexport v3 (efter ingest våg 7).", "genererad": IDAG,
"noder":{"objektklasser":len(ing.poster),"produktmodeller":len(ing.modeller)},
"statistik":{"kanter_totalt":len(kanter),"upplosta_lvx_kanter":lvx_k,"standardkanter":std_k,"textnoder_olosta":len(olosta)},
"olosta_platshallare":[{"text":k,"forekomster":v} for k,v in sorted(olosta.items())],
"kanter":kanter})
bf = bounty_forslag(ing.poster)
save("lvx-bounty-kandidater-vag7.json", {"beskrivning":"Auto-genererade bounty-förslag (granskas innan publicering).","genererad":IDAG,"forslag":bf})
ing.reg["uppdaterad"] = IDAG
save("lvx-id-register.json", ing.reg)
print(f"Uppdaterade poster: {ing.stat['uppdaterade']} | Nya: {ing.stat['nya']} (register P_nasta={ing.reg['P_nasta']})")
print(f"Nivålyft: {ing.stat['nivalyft']} | +källor: {ing.stat['kallor']} | standarder: {ing.stat['standarder']}")
print(f"Verifieringsfördelning: {vf}")
print(f"Modeller totalt: {len(ing.modeller)} | Kanter: {len(kanter)} ({lvx_k} lvx / {std_k} standard / {len(olosta)} textnoder)")
print(f"Bounty-förslag: {len(bf)} | Konflikter: {len(ing.konflikter)} | Valideringsfel: {len(fel)}")
for r in ing.logg: print(" ·", r)
for k in ing.konflikter: print(" !", k)
for f_ in fel: print(" !!", f_)