Files
boc/projects/landvex/datafabrik/upplosare/entitetsupplosare.py
T
Bernt f4f853d94b landvex: Datafabrik + Vision + Infrastruktur för 50GB skalning
Datafabrik:
- Skördare: crawler, källvitlista, upphandlingsskördare
- Extraktor: LLM-baserad schemastyrd extraktion
- Upplösare: Entitetsupplösning och deduplicering
- Köer: Schemalagd / kunddriven / fält
- Agentorkestrering: 20+ parallella agenter

Vision:
- Identify-modell: ResNet50 + kontrastivt lärande
- Träningspipeline: NT-Xent loss
- Vektordatabas: FAISS för snabb sökning
- OCR-pipeline: Typskyltsläsning

Infrastruktur:
- Docker Compose production
- Terraform för AWS ECS
- Prometheus + Grafana monitorering
- Neo4j + FAISS + MinIO + Redis
2026-07-05 06:25:51 +00:00

192 lines
6.7 KiB
Python

#!/usr/bin/env python3
"""
Landvex Upplösare — Entitetsupplösning
Slår ihop dubbletter till kanoniska Landvex-ID:n.
"""
import json
import difflib
from pathlib import Path
from typing import List, Dict, Tuple, Optional
from dataclasses import dataclass
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
@dataclass
class EntitetsMatch:
kandidat_id: str
befintlig_id: str
likhet: float
match_typ: str # "namn", "spec", "bild", "kombinerad"
rekommendation: str # "sammanfoga", "granska", "separat"
class EntitetsUpplosare:
"""Upplöser entiteter och föreslår sammanslagningar."""
def __init__(
self,
namn_tröskel: float = 0.85,
spec_tröskel: float = 0.90,
bild_tröskel: float = 0.95,
):
self.namn_tröskel = namn_tröskel
self.spec_tröskel = spec_tröskel
self.bild_tröskel = bild_tröskel
self.vectorizer = TfidfVectorizer(
analyzer='word',
ngram_range=(1, 3),
min_df=1,
)
def namn_likhet(self, namn1: str, namn2: str) -> float:
"""Beräkna likhet mellan två namn."""
# Exakt match
if namn1.lower() == namn2.lower():
return 1.0
# Fuzzy match
return difflib.SequenceMatcher(None, namn1.lower(), namn2.lower()).ratio()
def spec_likhet(self, spec1: Dict, spec2: Dict) -> float:
"""Beräkna likhet mellan tekniska specifikationer."""
# Jämför nyckelfält
nyckel_falt = ["material", "ip_klass", "belastningsklass", "dimensioner"]
matchande = 0
totala = 0
for falt in nyckel_falt:
v1 = spec1.get(falt, "")
v2 = spec2.get(falt, "")
if v1 and v2:
totala += 1
if isinstance(v1, str) and isinstance(v2, str):
if v1.lower() == v2.lower():
matchande += 1
elif v1 == v2:
matchande += 1
return matchande / totala if totala > 0 else 0
def bild_likhet(self, emb1: np.ndarray, emb2: np.ndarray) -> float:
"""Beräkna likhet mellan bildembeddings."""
# Cosine similarity
return float(np.dot(emb1, emb2) / (np.linalg.norm(emb1) * np.linalg.norm(emb2)))
def hitta_dubbletter(
self,
kandidat: Dict,
befintliga: List[Dict],
) -> List[EntitetsMatch]:
"""Hitta potentiella dubbletter för en kandidat."""
matcher = []
for bef in befintliga:
likheter = []
# Namnlikhet
namn1 = kandidat.get("namn", {}).get("sv", "")
namn2 = bef.get("namn", {}).get("sv", "")
if namn1 and namn2:
nl = self.namn_likhet(namn1, namn2)
if nl >= self.namn_tröskel:
likheter.append(("namn", nl))
# Speclikhet
spec1 = kandidat.get("teknik", {})
spec2 = bef.get("teknik", {})
if spec1 and spec2:
sl = self.spec_likhet(spec1, spec2)
if sl >= self.spec_tröskel:
likheter.append(("spec", sl))
# Kombinerad likhet
if likheter:
medel = np.mean([l[1] for l in likheter])
# Rekommendation
if medel > 0.95:
rekommendation = "sammanfoga"
elif medel > 0.85:
rekommendation = "granska"
else:
rekommendation = "separat"
matcher.append(EntitetsMatch(
kandidat_id=kandidat.get("lvx_id", "?"),
befintlig_id=bef.get("lvx_id", "?"),
likhet=medel,
match_typ=" + ".join([l[0] for l in likheter]),
rekommendation=rekommendation,
))
# Sortera efter likhet
matcher.sort(key=lambda x: x.likhet, reverse=True)
return matcher[:5] # Top 5
def sammanfoga(self, kandidat: Dict, befintlig: Dict) -> Dict:
"""Sammanfoga två poster till en kanonisk post."""
resultat = befintlig.copy()
# Merge källor
kallor1 = set(json.dumps(k, sort_keys=True) for k in kandidat.get("proveniens", {}).get("kallor", []))
kallor2 = set(json.dumps(k, sort_keys=True) for k in befintlig.get("proveniens", {}).get("kallor", []))
alla_kallor = [json.loads(k) for k in kallor1 | kallor2]
resultat.setdefault("proveniens", {})["kallor"] = alla_kallor
# Höj verifieringsnivå om möjligt
verif_rank = {"obekraftad": 0, "kallbelagd": 1, "faltverifierad": 2, "tillverkarbekraftad": 3}
v1 = verif_rank.get(kandidat.get("proveniens", {}).get("verifieringsniva", "obekraftad"), 0)
v2 = verif_rank.get(befintlig.get("proveniens", {}).get("verifieringsniva", "obekraftad"), 0)
resultat["proveniens"]["verifieringsniva"] = max([v1, v2], key=lambda x: verif_rank.get(x, 0))
return resultat
def main():
"""Demo: entitetsupplösning."""
print("🔗 Landvex Entitetsupplösare")
upplosare = EntitetsUpplosare()
# Testdata
kandidat = {
"lvx_id": "KAND-001",
"namn": {"sv": "ABB Kabeldon CDC-LT", "en": "ABB Kabeldon CDC-LT"},
"teknik": {"material": "stål", "ip_klass": "IP34D"},
"proveniens": {"verifieringsniva": "kallbelagd", "kallor": [{"titel": "Test"}]},
}
befintliga = [
{
"lvx_id": "LVX-ELN-0101",
"namn": {"sv": "Kabelskåp, lågspänning", "en": "LV distribution cabinet"},
"teknik": {"material": "stål", "ip_klass": "IP34D"},
"proveniens": {"verifieringsniva": "kallbelagd", "kallor": []},
},
{
"lvx_id": "LVX-ELN-0102",
"namn": {"sv": "Elstolpe", "en": "Utility pole"},
"teknik": {"material": "trä", "ip_klass": ""},
"proveniens": {"verifieringsniva": "kallbelagd", "kallor": []},
},
]
matcher = upplosare.hitta_dubbletter(kandidat, befintliga)
print("\n🎯 Matchningar:")
for m in matcher:
print(f" {m.kandidat_id}{m.befintlig_id}")
print(f" Likhet: {m.likhet:.3f} ({m.match_typ})")
print(f" Rekommendation: {m.rekommendation}")
if matcher and matcher[0].rekommendation == "sammanfoga":
sammanfogad = upplosare.sammanfoga(kandidat, befintliga[0])
print(f"\n✅ Sammanfogad: {sammanfogad['lvx_id']}")
if __name__ == "__main__":
main()