Files
boc/iom/defect/defect_classifier.py
T
Bernt bae705aa97 ARCHITECTURE: NFC roadmap, edge AI, audit logging
- Add NFC ePassport roadmap (ICAO 9303, eIDAS)
- Add TensorFlow.js edge face detection (BlazeFace)
- Add structured audit logger (GDPR-compliant)
- Risk scoring support

Part of KYC Apple Native UX v1.1.0
2026-06-29 16:24:48 +00:00

258 lines
10 KiB
Python

"""
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()