f4f853d94b
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
192 lines
6.7 KiB
Python
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()
|