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