# /opt/docker/dev/service_finder/backend/app/services/asset_matcher_service.py """ Internal Asset Matcher Service Cél: Belső katalógus (vehicle_model_definitions) alapján automatikus eszköz-azonosítás és adatgazdagítás. Matching stratégia: 1. Exact match: make + marketing_name + year_of_manufacture 2. Fuzzy match: make + normalizált név (Levenshtein távolság) 3. Confidence > 90% esetén automatikus adatgazdagítás """ from __future__ import annotations import logging import difflib import uuid from datetime import datetime from typing import Optional, Tuple, List, Dict, Any from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, and_, or_, func from sqlalchemy.orm import selectinload from app.models import Asset, VehicleModelDefinition, AssetCatalog, AssetEvent, AssetTelemetry from app.models.vehicle.vehicle_definitions import VehicleModelDefinition as VMD logger = logging.getLogger(__name__) class AssetMatcherService: """ Belső eszköz matcher szolgáltatás. """ @staticmethod async def find_best_match( db: AsyncSession, asset: Asset, threshold: float = 0.8 ) -> Tuple[Optional[VehicleModelDefinition], float]: """ Megkeresi a legjobb egyezést az asset adatai alapján a vehicle_model_definitions táblában. Args: db: AsyncSession asset: Asset objektum (már tartalmazza a make/model/year stb.) threshold: Minimális confidence threshold (0-1) Returns: Tuple (matched_definition, confidence) """ # Gyűjtsük össze a keresési kritériumokat make = asset.brand or (asset.catalog.make if asset.catalog else None) model = asset.model or (asset.catalog.model if asset.catalog else None) year = asset.year_of_manufacture # Trim és ellenőrzés if make: make = make.strip() if model: model = model.strip() if not make or not model: logger.warning(f"Asset {asset.id} missing make or model, cannot match (make='{make}', model='{model}')") return None, 0.0 # 1. EXACT MATCH: make + marketing_name + year_from exact_match = await AssetMatcherService._exact_match(db, make, model, year) if exact_match: logger.info(f"Exact match found for asset {asset.id}: {make} {model} {year}") return exact_match, 1.0 # 2. FUZZY MATCH: make + normalizált név (year within range) fuzzy_matches = await AssetMatcherService._fuzzy_match(db, make, model, year, threshold) if fuzzy_matches: best_match, confidence = fuzzy_matches[0] logger.info(f"Fuzzy match found for asset {asset.id}: {best_match.make} {best_match.marketing_name} (confidence: {confidence:.2f})") return best_match, confidence # 3. FALLBACK: csak make + model (year ignore) fallback_match = await AssetMatcherService._fallback_match(db, make, model) if fallback_match: logger.info(f"Fallback match found for asset {asset.id}: {make} {model}") return fallback_match, 0.7 # Alacsonyabb confidence logger.warning(f"No match found for asset {asset.id}: {make} {model} {year}") return None, 0.0 @staticmethod async def _exact_match( db: AsyncSession, make: str, model: str, year: Optional[int] ) -> Optional[VehicleModelDefinition]: """ Pontos egyezés: make, marketing_name és year_from/year_to tartomány. Több egyezés esetén a legújabb évjáratút választja. """ stmt = select(VMD).where( VMD.make.ilike(make), VMD.marketing_name.ilike(model) ) if year: # Évjárat tartományban legyen stmt = stmt.where( and_( VMD.year_from <= year, or_(VMD.year_to.is_(None), VMD.year_to >= year) ) ) # Rendezés év szerint csökkenő, limit 1 stmt = stmt.order_by(VMD.year_from.desc()).limit(1) result = await db.execute(stmt) return result.scalar_one_or_none() @staticmethod async def _fuzzy_match( db: AsyncSession, make: str, model: str, year: Optional[int], threshold: float ) -> List[Tuple[VehicleModelDefinition, float]]: """ Fuzzy egyezés: hasonlóság a normalizált név alapján. """ # Először szűrjünk make és év alapján stmt = select(VMD).where(VMD.make.ilike(make)) if year: stmt = stmt.where( and_( VMD.year_from <= year, or_(VMD.year_to.is_(None), VMD.year_to >= year) ) ) result = await db.execute(stmt) candidates = result.scalars().all() if not candidates: return [] # Számítsuk ki a hasonlóságot a model név és a marketing_name között matches = [] for candidate in candidates: similarity = AssetMatcherService._calculate_similarity(model, candidate.marketing_name) if similarity >= threshold: matches.append((candidate, similarity)) # Rendezzük confidence szerint csökkenő sorrendben matches.sort(key=lambda x: x[1], reverse=True) return matches @staticmethod async def _fallback_match( db: AsyncSession, make: str, model: str ) -> Optional[VehicleModelDefinition]: """ Csak make + model alapján, évjárat figyelmen kívül hagyva. """ stmt = select(VMD).where( VMD.make.ilike(make), VMD.marketing_name.ilike(model) ).order_by(VMD.year_from.desc()).limit(1) result = await db.execute(stmt) return result.scalar_one_or_none() @staticmethod def _calculate_similarity(str1: str, str2: str) -> float: """ Szöveg hasonlóság számítása SequenceMatcher segítségével. """ if not str1 or not str2: return 0.0 return difflib.SequenceMatcher(None, str1.lower(), str2.lower()).ratio() @staticmethod async def enrich_asset_from_definition( db: AsyncSession, asset: Asset, definition: VehicleModelDefinition, confidence: float ) -> Asset: """ Gazdagítsa az asset adatait a definition technikai specifikációival. Csak akkor, ha az asset megfelelő mezői üresek. """ # Technikai specifikációk másolása if not asset.power_kw and definition.power_kw: asset.power_kw = definition.power_kw if not asset.torque_nm and definition.torque_nm: asset.torque_nm = definition.torque_nm if not asset.engine_capacity and definition.engine_capacity: asset.engine_capacity = definition.engine_capacity if not asset.transmission_type and definition.transmission_type: asset.transmission_type = definition.transmission_type if not asset.drive_type and definition.drive_type: asset.drive_type = definition.drive_type if not asset.fuel_type and definition.fuel_type: asset.fuel_type = definition.fuel_type if not asset.euro_classification and definition.euro_classification: asset.euro_classification = definition.euro_classification if not asset.vehicle_class and definition.vehicle_class: asset.vehicle_class = definition.vehicle_class if not asset.trim_level and definition.body_type: asset.trim_level = definition.body_type # body_type -> trim_level mapping # Évjárat ellenőrzés if not asset.year_of_manufacture and definition.year_from: asset.year_of_manufacture = definition.year_from # Státusz frissítése if confidence >= 0.9: asset.data_status = 'verified' logger.info(f"Asset {asset.id} enriched and marked as verified (confidence: {confidence:.2f})") else: asset.data_status = 'enriched' logger.info(f"Asset {asset.id} enriched but not verified (confidence: {confidence:.2f})") return asset @staticmethod async def match_and_enrich_asset( db: AsyncSession, asset_id: uuid.UUID, threshold: float = 0.9 ) -> Dict[str, Any]: """ Fő függvény: Asset ID alapján keres match-et és gazdagítja az adatokat. Args: db: AsyncSession asset_id: Asset UUID threshold: Confidence threshold a verification-hoz (alapértelmezett 90%) Returns: Dict with match results """ # Asset betöltése stmt = select(Asset).where(Asset.id == asset_id).options(selectinload(Asset.catalog)) result = await db.execute(stmt) asset = result.scalar_one_or_none() if not asset: raise ValueError(f"Asset {asset_id} not found") logger.info(f"Matching asset {asset_id} ({asset.brand} {asset.model})") # Match keresés definition, confidence = await AssetMatcherService.find_best_match(db, asset, threshold=0.8) if not definition: return { "asset_id": str(asset_id), "matched": False, "confidence": 0.0, "message": "No matching definition found in internal catalog" } # Adatgazdagítás enriched_asset = await AssetMatcherService.enrich_asset_from_definition( db, asset, definition, confidence ) # Mentés await db.commit() return { "asset_id": str(asset_id), "matched": True, "confidence": confidence, "definition_id": definition.id, "definition": f"{definition.make} {definition.marketing_name}", "data_status": enriched_asset.data_status, "enriched_fields": [ field for field in [ "power_kw" if asset.power_kw != enriched_asset.power_kw else None, "torque_nm" if asset.torque_nm != enriched_asset.torque_nm else None, "engine_capacity" if asset.engine_capacity != enriched_asset.engine_capacity else None, "transmission_type" if asset.transmission_type != enriched_asset.transmission_type else None, "drive_type" if asset.drive_type != enriched_asset.drive_type else None, "fuel_type" if asset.fuel_type != enriched_asset.fuel_type else None, ] if field is not None ] } # Singleton instance asset_matcher_service = AssetMatcherService()