2026.06.04 frontend építés közben
This commit is contained in:
289
backend/app/services/asset_matcher_service.py
Normal file
289
backend/app/services/asset_matcher_service.py
Normal file
@@ -0,0 +1,289 @@
|
||||
# /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()
|
||||
Reference in New Issue
Block a user