bae705aa97
- 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
258 lines
10 KiB
Python
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()
|