CarPurchaseAdvisor / src /recommendation_engine.py
ochsncon's picture
Update src/recommendation_engine.py
afbc0a3 verified
Raw
History Blame Contribute Delete
7.58 kB
"""Recommendation engine combining rules and optional LLM explanation."""
from __future__ import annotations
import json
from typing import Any
from openai import OpenAI
from src.config import LLM_API_KEY, LLM_MODEL, LLM_PROMPT_VERSION
from src.utils import format_currency_chf
def _assess_price_vs_budget(estimated_price: float, budget: float) -> tuple[str, str]:
if budget <= 0:
return "Budget not provided", "Enter a budget to compare it with the estimated price."
ratio = estimated_price / budget if budget else 0.0
if ratio <= 0.9:
return "Within budget", "The estimated price is clearly below the budget."
if ratio <= 1.1:
return "Close to budget", "The estimated price is near the entered budget."
return "Above budget", "The estimated price is above the entered budget."
def _financing_orientation(
budget: float,
estimated_price: float,
max_monthly_rate: float,
) -> dict[str, Any]:
if budget >= estimated_price:
return {
"financing_orientation": "Realistic",
"financing_gap": 0.0,
"rough_months_needed": 0.0,
"financing_reason": "Buying with own funds looks realistic. Keep a reserve for registration and maintenance.",
}
financing_gap = max(0.0, estimated_price - budget)
if max_monthly_rate <= 0:
return {
"financing_orientation": "Unrealistic",
"financing_gap": financing_gap,
"rough_months_needed": None,
"financing_reason": "No monthly rate was provided, so the financing gap cannot be translated into months.",
}
rough_months_needed = financing_gap / max_monthly_rate
if rough_months_needed <= 12:
orientation = "Realistic"
elif rough_months_needed <= 24:
orientation = "Tight"
else:
orientation = "Unrealistic"
return {
"financing_orientation": orientation,
"financing_gap": financing_gap,
"rough_months_needed": rough_months_needed,
"financing_reason": (
f"The financing gap is {format_currency_chf(financing_gap)}. "
f"At {format_currency_chf(max_monthly_rate)} per month, this is about {rough_months_needed:.1f} months."
),
}
def _build_llm_prompt_structured(
user_inputs: dict[str, Any],
vision_results: dict[str, Any],
price_prediction: dict[str, Any],
financing_text: str,
budget_assessment: str,
budget_reason: str,
) -> str:
return f"""
You are an assistant for a used-car orientation app.
Write in concise, plain English for non-experts.
Rules:
- This is only a first orientation and not binding advice.
- Do not claim technical diagnosis from the image.
- Mention limitations clearly.
Structured inputs:
- Vision predicted class/model group: {vision_results.get('predicted_class')}
- Vision confidence: {vision_results.get('confidence')}
- Estimated price: {price_prediction.get('estimated_price')} CHF
- Estimated range: {price_prediction.get('lower_bound')} - {price_prediction.get('upper_bound')} CHF
- Budget: {user_inputs.get('budget_chf')} CHF
- Max monthly rate: {user_inputs.get('max_monthly_rate_chf')} CHF
Derived recommendations:
- Price vs budget: {budget_assessment}
- Budget reason: {budget_reason}
- Financing orientation: {financing_text}
Write one short paragraph only. Mention the predicted class, the price range, the budget assessment, the simple financing orientation, and the main limitations.
""".strip()
def _build_llm_prompt_concise(
user_inputs: dict[str, Any],
vision_results: dict[str, Any],
price_prediction: dict[str, Any],
financing_text: str,
budget_assessment: str,
) -> str:
return f"""
Short and clear in English. Orientation only, not binding advice.
Image: {vision_results.get('predicted_class')} ({vision_results.get('confidence')})
Price: {price_prediction.get('estimated_price')} CHF, range {price_prediction.get('lower_bound')} - {price_prediction.get('upper_bound')} CHF
Budget: {user_inputs.get('budget_chf')} CHF
Monthly rate: {user_inputs.get('max_monthly_rate_chf')} CHF
Assessment: {budget_assessment}
Financing orientation: {financing_text}
Reply with 1 compact paragraph. No premiums, no interest rates, no technical diagnosis.
""".strip()
def _build_llm_prompt(
user_inputs: dict[str, Any],
vision_results: dict[str, Any],
price_prediction: dict[str, Any],
financing_text: str,
budget_assessment: str,
budget_reason: str,
) -> str:
if LLM_PROMPT_VERSION == "concise":
return _build_llm_prompt_concise(
user_inputs,
vision_results,
price_prediction,
financing_text,
budget_assessment,
)
return _build_llm_prompt_structured(
user_inputs,
vision_results,
price_prediction,
financing_text,
budget_assessment,
budget_reason,
)
def _call_llm(prompt: str) -> str | None:
if not LLM_API_KEY:
return None
try:
client = OpenAI(api_key=LLM_API_KEY)
response = client.chat.completions.create(
model=LLM_MODEL,
messages=[
{"role": "system", "content": "You are a cautious automotive purchase advisor."},
{"role": "user", "content": prompt},
],
temperature=0.3,
max_tokens=450,
)
return (response.choices[0].message.content or "").strip()
except Exception:
return None
def generate_recommendation(
user_inputs: dict[str, Any],
vision_results: dict[str, Any],
price_prediction: dict[str, Any],
) -> dict[str, str]:
"""Generate budget assessment, financing orientation and explanation text."""
estimated_price = float(price_prediction.get("estimated_price", 0) or 0)
budget = float(user_inputs.get("budget_chf", 0) or 0)
max_monthly_rate = float(user_inputs.get("max_monthly_rate_chf", 0) or 0)
budget_assessment, budget_reason = _assess_price_vs_budget(estimated_price, budget)
financing_payload = _financing_orientation(budget, estimated_price, max_monthly_rate)
prompt = _build_llm_prompt(
user_inputs=user_inputs,
vision_results=vision_results,
price_prediction=price_prediction,
financing_text=financing_payload["financing_orientation"],
budget_assessment=budget_assessment,
budget_reason=budget_reason,
)
llm_text = _call_llm(prompt)
if llm_text:
explanation = llm_text.strip()
else:
explanation = (
f"The image suggests '{vision_results.get('predicted_class', 'Unknown')}'. "
f"The estimated price is about {price_prediction.get('estimated_price')} CHF "
f"with a range of {price_prediction.get('lower_bound')} to {price_prediction.get('upper_bound')} CHF. "
f"{budget_assessment}: {budget_reason} "
f"{financing_payload['financing_reason']} "
"This is a short orientation only and does not replace professional advice."
)
return {
"price_budget_assessment": budget_assessment,
"price_budget_reason": budget_reason,
"financing_orientation": financing_payload["financing_orientation"],
"financing_gap": financing_payload["financing_gap"],
"rough_months_needed": financing_payload["rough_months_needed"],
"financing_reason": financing_payload["financing_reason"],
"full_explanation": explanation,
"prompt_version": LLM_PROMPT_VERSION,
}