Cleanup: MB 2.0 Gap Analysis előtti állapot (adatok kizárva)

This commit is contained in:
2026-02-23 09:44:02 +01:00
parent 5757754aae
commit 893f39fa15
74 changed files with 34239 additions and 2834 deletions

View File

@@ -0,0 +1,64 @@
# app/services/ai_ocr_service.py
import json
import httpx
import base64
from app.schemas.evidence import RegistrationDocumentExtracted
class AiOcrService:
OLLAMA_URL = "http://service_finder_ollama:11434/api/generate"
MODEL_NAME = "llama3.2-vision"
@classmethod
async def extract_registration_data(cls, clean_image_bytes: bytes) -> RegistrationDocumentExtracted:
base64_image = base64.b64encode(clean_image_bytes).decode('utf-8')
prompt = """
Te egy magyar hatósági okmány-szakértő AI vagy. A feladatod a mellékelt magyar forgalmi engedély (kép) összes adatának kinyerése.
Keresd meg és olvasd le az adatokat az alábbi hatósági kódok alapján:
- A: Rendszám (kötőjellel, pl: ABC-123 vagy AA-BB-123)
- B: Első nyilvántartásba vétel dátuma (YYYY.MM.DD)
- C.1.1: Családi név vagy cégnév
- C.1.2: Utónév
- C.1.3: Teljes lakcím (Irsz, Város, Utca, Házszám)
- C.4: Jogosultság (a = tulajdonos, b = üzembentartó)
- D.1: Gyártmány (pl. TOYOTA, VOLKSWAGEN)
- D.2: Jármű típusa
- D.3: Kereskedelmi leírás (pl. COROLLA, GOLF)
- E: Alvázszám (pontosan 17 karakter)
- G: Saját tömeg (kg)
- F.1: Együttes tömeg (kg)
- P.1: Hengerűrtartalom (cm3)
- P.2: Teljesítmény (kW)
- P.3: Hajtóanyag (pl. Benzin, Gázolaj, Elektromos)
- P.5: Motorkód
- V.9: Környezetvédelmi osztály kódja
- R: Szín
- S.1: Ülések száma
- H: Műszaki érvényesség vége (YYYY.MM.DD)
- Sebességváltó: Keresd a 0, 1, 2, 3 kódokat (0=mechanikus, 2=automata).
VÁLASZ FORMÁTUMA: Kizárólag érvényes JSON. Ha egy adat nem olvasható, az értéke null legyen.
"""
payload = {
"model": cls.MODEL_NAME,
"prompt": prompt,
"images": [base64_image],
"stream": False,
"format": "json"
}
async with httpx.AsyncClient(timeout=90.0) as client:
try:
response = await client.post(cls.OLLAMA_URL, json=payload)
response.raise_for_status()
ai_response_text = response.json().get("response", "{}")
data_dict = json.loads(ai_response_text)
return RegistrationDocumentExtracted(**data_dict)
except Exception as e:
print(f"Robot 3 AI Hiba: {e}")
raise ValueError(f"AI hiba az adatkivonás során: {str(e)}")

View File

@@ -3,24 +3,20 @@ import json
import logging
import asyncio
import re
from typing import Dict, Any, Optional
from google import genai
from google.genai import types
import base64
import httpx
from typing import Dict, Any, Optional, List
from sqlalchemy import select
from app.db.session import SessionLocal
from app.models import SystemParameter
from app.models.system import SystemParameter
logger = logging.getLogger("AI-Service")
class AIService:
"""
AI Service v1.2.5 - Final Integrated Edition
- Robot 2: Technikai dúsítás (Search + Regex JSON parsing)
- Robot 3: OCR (Controlled JSON generation)
"""
api_key = os.getenv("GEMINI_API_KEY")
client = genai.Client(api_key=api_key) if api_key else None
PRIMARY_MODEL = "gemini-2.0-flash"
OLLAMA_BASE_URL = "http://ollama:11434/api/generate"
TEXT_MODEL = "qwen2.5-coder:32b"
VISION_MODEL = "llava:7b"
DVLA_API_KEY = os.getenv("DVLA_API_KEY")
@classmethod
async def get_config_delay(cls) -> float:
@@ -29,83 +25,71 @@ class AIService:
stmt = select(SystemParameter).where(SystemParameter.key == "AI_REQUEST_DELAY")
res = await db.execute(stmt)
param = res.scalar_one_or_none()
return float(param.value) if param else 1.0
except Exception: return 1.0
return float(param.value) if param else 0.1
except Exception:
return 0.1
@classmethod
async def get_clean_vehicle_data(cls, make: str, raw_model: str, v_type: str) -> Optional[Dict[str, Any]]:
"""Robot 2: Adatbányászat Google Search segítségével."""
if not cls.client: return None
async def get_gold_data_from_research(cls, make: str, model: str, raw_context: str) -> Optional[Dict[str, Any]]:
await asyncio.sleep(await cls.get_config_delay())
search_tool = types.Tool(google_search=types.GoogleSearch())
prompt = f"""
KERESS RÁ az interneten: {make} {raw_model} ({v_type}) pontos gyári modellkódja és technikai adatai.
Adj választ szigorúan csak egy JSON blokkban:
FELADAT: A mellékelt kutatási adatokból állíts össze egy hiteles technikai adatlapot.
JÁRMŰ: {make} {model}
KUTATÁSI ADATOK (Szemetesláda tartalom):
{raw_context}
SZIGORÚ SZABÁLYOK:
1. Csak a megerősített adatokat töltsd ki.
2. Ha lóerőt (hp/bhp) találsz, váltsd át kW-ra (hp * 0.745).
3. A 'marketing_name' maradjon 50 karakter alatt.
VÁLASZ FORMÁTUM (Tiszta JSON):
{{
"marketing_name": "tiszta név",
"synonyms": ["név1", "név2"],
"technical_code": "gyári kód",
"year_from": int,
"year_to": int_vagy_null,
"marketing_name": "string",
"technical_code": "string",
"ccm": int,
"kw": int,
"maintenance": {{ "oil_type": "string", "oil_qty": float, "spark_plug": "string", "coolant": "string" }}
"maintenance": {{
"oil_type": "string",
"oil_qty_liters": float,
"spark_plug": "string",
"final_drive": "string"
}},
"tires": {{
"front": "string",
"rear": "string"
}},
"is_duplicate_potential": bool
}}
FONTOS: A 'technical_code' NEM lehet üres. Ha nem találod, adj 'N/A' értéket!
"""
# Search tool használata esetén a response_mime_type tilos!
config = types.GenerateContentConfig(
system_instruction="Profi járműtechnikai adatbányász vagy. Csak tiszta JSON-t válaszolsz markdown kódblokk nélkül.",
tools=[search_tool],
temperature=0.1
)
return await cls._execute_ai_call(prompt, make, model)
@classmethod
async def _execute_ai_call(cls, prompt: str, make: str, model: str) -> Optional[Dict[str, Any]]:
payload = {
"model": cls.TEXT_MODEL,
"prompt": prompt,
"stream": False,
"format": "json",
"options": {"temperature": 0.1}
}
try:
response = cls.client.models.generate_content(model=cls.PRIMARY_MODEL, contents=prompt, config=config)
text = response.text
# Tisztítás: ha az AI mégis tenne bele markdown jeleket
clean_json = re.sub(r'```json\s*|```', '', text).strip()
res_json = json.loads(clean_json)
if isinstance(res_json, list) and len(res_json) > 0: res_json = res_json[0]
return res_json if isinstance(res_json, dict) else None
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(cls.OLLAMA_BASE_URL, json=payload)
response.raise_for_status()
res_json = response.json()
return json.loads(res_json.get("response", "{}"))
except Exception as e:
logger.error(f"❌ AI hiba ({make} {raw_model}): {e}")
logger.error(f"❌ AI hiba ({make} {model}): {e}")
return None
@classmethod
async def analyze_document_image(cls, image_data: bytes, doc_type: str) -> Optional[Dict[str, Any]]:
"""Robot 3: OCR funkció - Forgalmi, Személyi, Számla, Odometer."""
if not cls.client: return None
async def get_clean_vehicle_data(cls, make: str, raw_model: str, v_type: str, sources: Dict[str, Any]) -> Optional[Dict[str, Any]]:
await asyncio.sleep(await cls.get_config_delay())
prompts = {
"identity": "Személyes okmány adatok (név, szám, lejárat).",
"vehicle_reg": "Forgalmi adatok (rendszám, alvázszám, kW, ccm).",
"invoice": "Számla adatok (partner, végösszeg, dátum).",
"odometer": "Csak a kilométeróra állása számként."
}
# Itt maradhat a response_mime_type, mert nem használunk Search-öt
config = types.GenerateContentConfig(
system_instruction="Profi OCR dokumentum-elemző vagy. Csak tiszta JSON-t válaszolsz.",
response_mime_type="application/json"
)
try:
response = cls.client.models.generate_content(
model=cls.PRIMARY_MODEL,
contents=[
f"Elemezd ezt a képet ({doc_type}): {prompts.get(doc_type, 'OCR')}",
types.Part.from_bytes(data=image_data, mime_type="image/jpeg")
],
config=config
)
res_json = json.loads(response.text)
if isinstance(res_json, list) and len(res_json) > 0: res_json = res_json[0]
return res_json if isinstance(res_json, dict) else None
except Exception as e:
logger.error(f"❌ OCR hiba: {e}")
return None
prompt = f"""
FELADAT: Normalizáld a jármű adatait.
GYÁRTÓ: {make} | MODELL: {raw_model}
ADATOK: {json.dumps(sources)}
(JSON válasz marketing_name, synonyms, technical_code, ccm, kw, year_from, year_to)
"""
return await cls._execute_ai_call(prompt, make, raw_model)

View File

@@ -0,0 +1,141 @@
import os
import json
import logging
import asyncio
import re
import base64
import httpx
from typing import Dict, Any, Optional, List
from sqlalchemy import select
from app.db.session import SessionLocal
from app.models import SystemParameter
logger = logging.getLogger("AI-Service")
class AIService:
"""
AI Service v1.3.5 - Private High-Performance Edition
- Engine: Local Ollama (GPU Accelerated)
- Features: DVLA Integration, 50-char Normalization, Private OCR
"""
# A Docker belső hálózatán a szerviznév 'ollama'
OLLAMA_BASE_URL = "http://ollama:11434/api/generate"
TEXT_MODEL = "vehicle-pro"
VISION_MODEL = "llava:7b"
DVLA_API_KEY = os.getenv("DVLA_API_KEY")
@classmethod
async def get_config_delay(cls) -> float:
"""Késleltetés lekérése az adatbázisból."""
try:
async with SessionLocal() as db:
stmt = select(SystemParameter).where(SystemParameter.key == "AI_REQUEST_DELAY")
res = await db.execute(stmt)
param = res.scalar_one_or_none()
return float(param.value) if param else 0.1
except Exception:
return 0.1
@classmethod
async def get_clean_vehicle_data(cls, make: str, raw_model: str, v_type: str, sources: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Robot 2: Adat-összefésülés és normalizálás."""
# Várjunk egy kicsit a GPU kímélése érdekében
await asyncio.sleep(await cls.get_config_delay())
prompt = f"""
FELADAT: Normalizáld a jármű adatait több forrás alapján.
GYÁRTÓ: {make}
NYERS MODELLNÉV: {raw_model}
FORRÁSOK NYERS ADATAI: {json.dumps(sources, ensure_ascii=False)}
SZIGORÚ SZABÁLYOK:
1. 'marketing_name': MAXIMUM 50 KARAKTER!
2. 'synonyms': Gyűjtsd ide az összes többi névváltozatot.
3. 'technical_code': Keresd meg a gyári kódokat.
VÁLASZ FORMÁTUM (Csak tiszta JSON):
{{
"marketing_name": "string (max 50)",
"synonyms": ["string"],
"technical_code": "string",
"ccm": int,
"kw": int,
"euro_class": int,
"year_from": int,
"year_to": int vagy null,
"maintenance": {{
"oil_type": "string",
"oil_qty": float,
"spark_plug": "string"
}},
"is_duplicate_potential": bool
}}
"""
payload = {
"model": cls.TEXT_MODEL,
"prompt": prompt,
"stream": False,
"format": "json",
"options": {"temperature": 0.1}
}
try:
async with httpx.AsyncClient(timeout=90.0) as client:
logger.info(f"📡 AI kérés küldése: {make} {raw_model}...")
response = await client.post(cls.OLLAMA_BASE_URL, json=payload)
response.raise_for_status()
res_json = response.json()
clean_data = json.loads(res_json.get("response", "{}"))
if clean_data.get("marketing_name"):
clean_data["marketing_name"] = clean_data["marketing_name"][:50].strip()
return clean_data
except Exception as e:
logger.error(f"❌ AI hiba ({make} {raw_model}): {e}")
return None
@classmethod
async def get_dvla_data(cls, vrm: str) -> Optional[Dict[str, Any]]:
"""Brit rendszám alapú adatok lekérése."""
if not cls.DVLA_API_KEY: return None
url = "https://driver-vehicle-licensing.api.gov.uk/vehicle-enquiry/v1/vehicles"
headers = {"x-api-key": cls.DVLA_API_KEY, "Content-Type": "application/json"}
try:
async with httpx.AsyncClient() as client:
resp = await client.post(url, json={"registrationNumber": vrm}, headers=headers)
return resp.json() if resp.status_code == 200 else None
except Exception as e:
logger.error(f"❌ DVLA API hiba: {e}")
return None
@classmethod
async def analyze_document_image(cls, image_data: bytes, doc_type: str) -> Optional[Dict[str, Any]]:
"""Robot 3: Helyi OCR és dokumentum elemzés (Llava:7b)."""
await asyncio.sleep(await cls.get_config_delay())
prompts = {
"identity": "Extract ID card data (name, id_number, expiry) as JSON.",
"vehicle_reg": "Extract vehicle registration (plate, VIN, power_kw, engine_ccm) as JSON.",
"invoice": "Extract invoice details (vendor, total_amount, date) as JSON.",
"odometer": "Identify the number on the odometer and return as JSON: {'value': int}."
}
img_b64 = base64.b64encode(image_data).decode('utf-8')
payload = {
"model": cls.VISION_MODEL,
"prompt": prompts.get(doc_type, "Perform OCR and return JSON"),
"images": [img_b64],
"stream": False,
"format": "json"
}
try:
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(cls.OLLAMA_BASE_URL, json=payload)
res_data = response.json()
clean_json = res_data.get("response", "{}")
match = re.search(r'\{.*\}', clean_json, re.DOTALL)
return json.loads(match.group()) if match else json.loads(clean_json)
except Exception as e:
logger.error(f"❌ Helyi OCR hiba: {e}")
return None

View File

@@ -0,0 +1,111 @@
import os
import json
import logging
import asyncio
import re
from typing import Dict, Any, Optional
from google import genai
from google.genai import types
from sqlalchemy import select
from app.db.session import SessionLocal
from app.models import SystemParameter
logger = logging.getLogger("AI-Service")
class AIService:
"""
AI Service v1.2.5 - Final Integrated Edition
- Robot 2: Technikai dúsítás (Search + Regex JSON parsing)
- Robot 3: OCR (Controlled JSON generation)
"""
api_key = os.getenv("GEMINI_API_KEY")
client = genai.Client(api_key=api_key) if api_key else None
PRIMARY_MODEL = "gemini-2.0-flash"
@classmethod
async def get_config_delay(cls) -> float:
try:
async with SessionLocal() as db:
stmt = select(SystemParameter).where(SystemParameter.key == "AI_REQUEST_DELAY")
res = await db.execute(stmt)
param = res.scalar_one_or_none()
return float(param.value) if param else 1.0
except Exception: return 1.0
@classmethod
async def get_clean_vehicle_data(cls, make: str, raw_model: str, v_type: str) -> Optional[Dict[str, Any]]:
"""Robot 2: Adatbányászat Google Search segítségével."""
if not cls.client: return None
await asyncio.sleep(await cls.get_config_delay())
search_tool = types.Tool(google_search=types.GoogleSearch())
prompt = f"""
KERESS RÁ az interneten: {make} {raw_model} ({v_type}) pontos gyári modellkódja és technikai adatai.
Adj választ szigorúan csak egy JSON blokkban:
{{
"marketing_name": "tiszta név",
"synonyms": ["név1", "név2"],
"technical_code": "gyári kód",
"year_from": int,
"year_to": int_vagy_null,
"ccm": int,
"kw": int,
"maintenance": {{ "oil_type": "string", "oil_qty": float, "spark_plug": "string", "coolant": "string" }}
}}
FONTOS: A 'technical_code' NEM lehet üres. Ha nem találod, adj 'N/A' értéket!
"""
# Search tool használata esetén a response_mime_type tilos!
config = types.GenerateContentConfig(
system_instruction="Profi járműtechnikai adatbányász vagy. Csak tiszta JSON-t válaszolsz markdown kódblokk nélkül.",
tools=[search_tool],
temperature=0.1
)
try:
response = cls.client.models.generate_content(model=cls.PRIMARY_MODEL, contents=prompt, config=config)
text = response.text
# Tisztítás: ha az AI mégis tenne bele markdown jeleket
clean_json = re.sub(r'```json\s*|```', '', text).strip()
res_json = json.loads(clean_json)
if isinstance(res_json, list) and len(res_json) > 0: res_json = res_json[0]
return res_json if isinstance(res_json, dict) else None
except Exception as e:
logger.error(f"❌ AI hiba ({make} {raw_model}): {e}")
return None
@classmethod
async def analyze_document_image(cls, image_data: bytes, doc_type: str) -> Optional[Dict[str, Any]]:
"""Robot 3: OCR funkció - Forgalmi, Személyi, Számla, Odometer."""
if not cls.client: return None
await asyncio.sleep(await cls.get_config_delay())
prompts = {
"identity": "Személyes okmány adatok (név, szám, lejárat).",
"vehicle_reg": "Forgalmi adatok (rendszám, alvázszám, kW, ccm).",
"invoice": "Számla adatok (partner, végösszeg, dátum).",
"odometer": "Csak a kilométeróra állása számként."
}
# Itt maradhat a response_mime_type, mert nem használunk Search-öt
config = types.GenerateContentConfig(
system_instruction="Profi OCR dokumentum-elemző vagy. Csak tiszta JSON-t válaszolsz.",
response_mime_type="application/json"
)
try:
response = cls.client.models.generate_content(
model=cls.PRIMARY_MODEL,
contents=[
f"Elemezd ezt a képet ({doc_type}): {prompts.get(doc_type, 'OCR')}",
types.Part.from_bytes(data=image_data, mime_type="image/jpeg")
],
config=config
)
res_json = json.loads(response.text)
if isinstance(res_json, list) and len(res_json) > 0: res_json = res_json[0]
return res_json if isinstance(res_json, dict) else None
except Exception as e:
logger.error(f"❌ OCR hiba: {e}")
return None

View File

@@ -0,0 +1,27 @@
import httpx
import logging
logger = logging.getLogger("DVLA-Service")
class DVLAService:
API_URL = "https://driver-vehicle-licensing.api.gov.uk/vehicle-enquiry/v1/vehicles"
API_KEY = "IDE_MÁSOLD_BE_AZ_API_KULCSOT"
@classmethod
async def get_vehicle_details(cls, vrm: str):
"""VRM az angol rendszám (pl. AB12 CDE)"""
headers = {
"x-api-key": cls.API_KEY,
"Content-Type": "application/json"
}
payload = {"registrationNumber": vrm}
async with httpx.AsyncClient() as client:
try:
response = await client.post(cls.API_URL, json=payload, headers=headers)
if response.status_code == 200:
return response.json()
return None
except Exception as e:
logger.error(f"❌ DVLA hiba: {e}")
return None

View File

@@ -0,0 +1,62 @@
import cv2
import numpy as np
from typing import Optional
class DocumentImageProcessor:
"""
Saját fejlesztésű képtisztító pipeline OCR-hez.
A nyers (mobillal fotózott) képekből kontrasztos, fekete-fehér, zajmentes változatot készít,
amelyet az AI már közel 100%-os pontossággal tud olvasni.
"""
@staticmethod
def process_for_ocr(image_bytes: bytes) -> Optional[bytes]:
try:
# 1. Kép betöltése a memóriából (FastAPI UploadFile bytes-ból)
# A képet nem mentjük a lemezre, villámgyorsan a RAM-ban dolgozzuk fel.
nparr = np.frombuffer(image_bytes, np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if img is None:
raise ValueError("A képet nem sikerült dekódolni.")
# 2. Szürkeárnyalatossá alakítás (A színek csak zavarják a szövegfelismerést)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 3. Kép átméretezése (Felskálázás)
# Az AI és az OCR motorok a minimum 300 DPI körüli képeket szeretik.
height, width = gray.shape
if width < 1000 or height < 1000:
gray = cv2.resize(gray, None, fx=2.0, fy=2.0, interpolation=cv2.INTER_CUBIC)
# 4. Kontraszt növelése (CLAHE - Contrast Limited Adaptive Histogram Equalization)
# Ez eltünteti a vaku okozta becsillanásokat és kiemeli a halvány betűket.
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
contrast = clahe.apply(gray)
# 5. Enyhe homályosítás (Denoising / Noise Reduction)
# Eltünteti a papír textúráját (pl. a forgalmi engedély vízjelét vagy a blokk gyűrődéseit).
blur = cv2.GaussianBlur(contrast, (5, 5), 0)
# 6. Adaptív Küszöbérték (Binarization)
# Minden pixel környezetét külön vizsgálja. Ez küszöböli ki azt, amikor a fotó egyik
# sarka sötét (pl. árnyékot vet a telefon), a másik meg világos.
thresh = cv2.adaptiveThreshold(
blur,
255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY,
11, # Blokk méret (páratlan szám)
2 # Konstans levonás
)
# 7. Visszakódolás bájt formátumba (PNG), hogy átadhassuk az AI-nak
success, encoded_image = cv2.imencode('.png', thresh)
if not success:
raise ValueError("Nem sikerült a feldolgozott képet PNG-be kódolni.")
return encoded_image.tobytes()
except Exception as e:
print(f"Hiba a képfeldolgozás során: {str(e)}")
return None

View File

@@ -0,0 +1,29 @@
import uuid
from minio import Minio
from app.core.config import settings
class StorageService:
client = Minio(
settings.MINIO_ENDPOINT,
access_key=settings.MINIO_ROOT_USER,
secret_key=settings.MINIO_ROOT_PASSWORD,
secure=settings.MINIO_SECURE
)
BUCKET_NAME = "vehicle-documents"
@classmethod
async def upload_document(cls, file_bytes: bytes, file_name: str, folder: str) -> str:
if not cls.client.bucket_exists(cls.BUCKET_NAME):
cls.client.make_bucket(cls.BUCKET_NAME)
# Egyedi fájlnév generálása az ütközések elkerülésére
unique_name = f"{folder}/{uuid.uuid4()}_{file_name}"
from io import BytesIO
cls.client.put_object(
cls.BUCKET_NAME,
unique_name,
BytesIO(file_bytes),
len(file_bytes)
)
return f"{cls.BUCKET_NAME}/{unique_name}"

View File

@@ -0,0 +1,16 @@
from sqlalchemy import Column, Integer, String, Text, Boolean, UniqueConstraint
# JAVÍTÁS: Közvetlenül a base_class-ból importálunk, hogy elkerüljük a körkörös importot
from app.db.base_class import Base
class Translation(Base):
__tablename__ = "translations"
__table_args__ = (
UniqueConstraint("key", "lang_code", name="uq_translation_key_lang"),
{"schema": "data"}
)
id = Column(Integer, primary_key=True, index=True)
key = Column(String(100), nullable=False, index=True)
lang_code = Column(String(5), nullable=False, index=True)
value = Column(Text, nullable=False)
is_published = Column(Boolean, default=False)