""" defect_classifier.py — IOM Lager 7: AI-klassificeringsmapping Mappar AI-modellens utdata (labels, embeddings, text-beskrivningar) till hierarkiska felkoder i defect_codes.py. Designprincip: AI väljer kod, inte beskrivning. """ from __future__ import annotations import re from dataclasses import dataclass from typing import Dict, List, Optional, Tuple from defect_codes import REGISTRY, DefectCode @dataclass class ClassificationResult: """Resultat från AI-klassificering.""" code: str confidence: float matched_keyword: str alternatives: List[Tuple[str, float]] # (code, confidence) @property def defect(self) -> Optional[DefectCode]: return REGISTRY.get(self.code) def to_dict(self, lang: str = "sv") -> Dict: d = self.defect return { "code": self.code, "name": d.name(lang) if d else None, "confidence": round(self.confidence, 4), "matched_keyword": self.matched_keyword, "severity_hint": d.severity_hint if d else None, "alternatives": [ { "code": c, "name": REGISTRY.get(c).name(lang) if REGISTRY.get(c) else None, "confidence": round(conf, 4), } for c, conf in self.alternatives[:3] ], } # ────────────────────────────────────────────── # Keyword → kod mapping # ────────────────────────────────────────────── # Svenska och engelska nyckelord per kod KEYWORD_MAP: Dict[str, List[str]] = { # Korrosion "1100": ["ytrost", "surface rust", "lätt rost", "light rust", "rostfläck", "rust spot"], "1200": ["genomrost", "through rust", "hål", "hole", "kraftig rost", "heavy rust", "avancerad korrosion"], "1250": ["galvanisk", "galvanic", "kontaktrost", "contact corrosion", "bimetall"], # Betongspricka "1310": ["plastisk krympning", "plastic shrinkage", "tidig spricka", "early crack", "ytspricka"], "1320": ["temperatur", "thermal", "temperaturspricka", "frost", "frost crack", "värmespricka"], "1330": ["lastspricka", "load crack", "böjspricka", "flexural crack", "skjuvspricka", "shear crack"], # Påkörning "1410": ["ytskada", "surface damage", "repa", "scratch", "skrapmärke", "scuff"], "1420": ["deformation", "deform", "buckling", "buckla", "bulge", "bulning", "intryckt", "dented"], "1430": ["genomskjutning", "penetration", "hål", "perforation", "genomhålning", "spricka efter påkörning"], # Saknad komponent "1510": ["stöld", "theft", "stulen", "stolen", "avlägsnad", "removed by theft"], "1520": ["ej installerad", "not installed", "saknas", "missing", "aldrig monterad"], "1530": ["avlägsnad", "removed", "borttagen", "taken away", "avmonterad"], # Lutning / Deformation "1610": ["sättning", "settlement", "sjunkning", "subsidence", "sänkning", "sunken"], "1620": ["böjning", "bending", "böjd", "bent", "nedböjning", "sagging", "genomböjning"], "1630": ["vridning", "torsion", "vriden", "twisted", "skev", "skewed", "vridningsdeformation"], # Vibration "1710": ["mekanisk vibration", "mechanical vibration", "skakning", "shaking", "darrning", "rattle"], "1720": ["aerodynamisk", "aerodynamic", "vindinducerad", "wind-induced", "fladder", "flutter"], # Vegetation "1810": ["blockerande", "blocking", "övervuxen", "overgrown", "buskar", "bushes", "träd", "tree"], "1820": ["rotinträngning", "root intrusion", "rötter", "roots", "rotskador", "root damage"], "1830": ["fuktbevarande", "moisture-retaining", "mossa", "moss", "alger", "algae", "lav", "lichen"], # Blockering "1910": ["fysisk blockering", "physical blockage", "blockerad", "blocked", "igensatt", "clogged"], "1920": ["digital blockering", "digital blockage", "programvarufel", "software fault", "display fel", "display error"], "1930": ["organisk blockering", "organic blockage", "smuts", "dirt blockage", "fett", "grease"], # Ytbeläggning / Smuts "2100": ["smuts", "dirt", "smutsaccumulering", "dirt accumulation", "grime", "smutsig", "dirty", "nedsmutsad"], "2200": ["graffiti", "graffiti", "klotter", "tag", "spray", "målning", "painting"], "2300": ["avflagning", "flaking", "flagar", "flaking off", "blästring", "blistering", "lossnar", "peeling"], } # Fallback: huvudkategorier om ingen underkod matchar CATEGORY_KEYWORDS: Dict[str, List[str]] = { "1000": ["korrosion", "corrosion", "rost", "rust"], "1300": ["betongspricka", "concrete crack", "spricka", "crack", "sprickbildning"], "1400": ["påkörning", "impact", "kollision", "collision", "påkörd", "hit"], "1500": ["saknad", "missing", "borta", "gone", "försvunnen"], "1600": ["lutning", "tilt", "deformation", "deform", "skev", "skew"], "1700": ["vibration", "vibration", "skakning", "shaking"], "1800": ["vegetation", "vegetation", "växter", "plants", "ogräs", "weeds"], "1900": ["blockering", "blockage", "blockerad", "blocked", "stopp", "stopped"], "2000": ["smuts", "dirt", "ytbeläggning", "coating", "målning", "paint"], } class DefectClassifier: """Klassificerar observationer till felkoder via keyword-matching.""" def __init__( self, keyword_map: Optional[Dict[str, List[str]]] = None, category_keywords: Optional[Dict[str, List[str]]] = None, ): self.keywords = keyword_map or KEYWORD_MAP self.cat_keywords = category_keywords or CATEGORY_KEYWORDS self._build_index() def _build_index(self) -> None: """Bygg inverterat index för snabb lookup.""" self._index: Dict[str, List[Tuple[str, float]]] = {} # Underkoder (högre vikt) for code, words in self.keywords.items(): for w in words: self._index.setdefault(w.lower(), []).append((code, 1.0)) # Huvudkategorier (lägre vikt) for code, words in self.cat_keywords.items(): for w in words: self._index.setdefault(w.lower(), []).append((code, 0.7)) # ── Klassificering ──────────────────────── def classify( self, text: str, top_k: int = 3, min_confidence: float = 0.3, ) -> Optional[ClassificationResult]: """ Klassificera en textbeskrivning till felkod. Args: text: Beskrivning från AI eller mänsklig observatör. top_k: Antal alternativ att returnera. min_confidence: Minsta konfidens för accepterad match. """ text_lower = text.lower() scores: Dict[str, float] = {} best_match: Dict[str, str] = {} # Exakt matchning av nyckelord for keyword, matches in self._index.items(): if keyword in text_lower: for code, weight in matches: # Längre nyckelord = mer specifikt = högre konfidens specificity = min(1.0, len(keyword) / 15) score = weight * (0.5 + 0.5 * specificity) if code not in scores or score > scores[code]: scores[code] = score best_match[code] = keyword # Regex-baserad matchning för mått (t.ex. "5 mm spricka") scores = self._apply_measurement_rules(text_lower, scores, best_match) if not scores: return None # Sortera och välj topp-k sorted_scores = sorted(scores.items(), key=lambda x: x[1], reverse=True) top_code, top_score = sorted_scores[0] if top_score < min_confidence: return None alternatives = sorted_scores[1:top_k] return ClassificationResult( code=top_code, confidence=min(1.0, top_score), matched_keyword=best_match.get(top_code, ""), alternatives=alternatives, ) def classify_batch( self, texts: List[str], **kwargs, ) -> List[Optional[ClassificationResult]]: return [self.classify(t, **kwargs) for t in texts] # ── Regler ──────────────────────────────── def _apply_measurement_rules( self, text: str, scores: Dict[str, float], best_match: Dict[str, str], ) -> Dict[str, float]: """Justera scores baserat på mätt och kontext.""" # Sprickmått → förstärk betongspricka crack_mm = self._extract_mm(text) if crack_mm is not None: if "1300" not in scores: scores["1300"] = 0.5 best_match["1300"] = "sprickmått" if crack_mm > 5: scores["1330"] = scores.get("1330", 0) + 0.3 best_match["1330"] = f"lastspricka ({crack_mm}mm)" # Rosttäckning → förstärk korrosion rust_pct = self._extract_percent(text) if rust_pct is not None: if "1000" not in scores: scores["1000"] = 0.5 best_match["1000"] = "rosttäckning" if rust_pct > 30: scores["1200"] = scores.get("1200", 0) + 0.3 best_match["1200"] = f"genomrost ({rust_pct}%)" return scores @staticmethod def _extract_mm(text: str) -> Optional[float]: m = re.search(r'(\d+(?:[.,]\d+)?)\s*mm', text) if m: return float(m.group(1).replace(',', '.')) return None @staticmethod def _extract_percent(text: str) -> Optional[float]: m = re.search(r'(\d+(?:[.,]\d+)?)\s*%', text) if m: return float(m.group(1).replace(',', '.')) return None # ── Validering ──────────────────────────── def validate_code(self, code: str) -> Tuple[bool, Optional[str]]: """Validera att en kod finns och är lämplig.""" if code not in REGISTRY: return False, f"Felkod {code} finns inte i registret" defect = REGISTRY[code] if defect.level == 1: return True, f"Varning: {code} är en huvudkategori, föredra underkod" return True, None # Global singleton CLASSIFIER = DefectClassifier()