Spaces:
Sleeping
Sleeping
| """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, | |
| } | |