289 lines
11 KiB
Python
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() |