#!/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_)