Files
service-finder/backend/app/services/asset_matcher_service.py
2026-06-04 07:26:22 +00:00

289 lines
11 KiB
Python

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