"""OpenAI-backed order parser. Wraps the OpenAI Chat Completions API. Returns a validated :class:`OrderTicket`. If the API key is missing OR the API call fails, ``parse_order`` falls back to a deterministic heuristic parser so that the Gradio demo never crashes (this is documented in the README). """ from __future__ import annotations import json import os import re import sys from pathlib import Path from typing import Optional sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from src.config import DISH_TO_STATION, OPENAI_MODEL # noqa: E402 from src.nlp.prompts import PROMPTS # noqa: E402 from src.nlp.schema import ORDER_JSON_SCHEMA, OrderItem, OrderTicket, route_to_station # noqa: E402 def _maybe_load_dotenv() -> None: try: from dotenv import load_dotenv # type: ignore except ImportError: return env_path = Path(__file__).resolve().parents[2] / ".env" if env_path.exists(): load_dotenv(env_path) _maybe_load_dotenv() def _openai_client(): api_key = os.getenv("OPENAI_API_KEY") if not api_key: return None try: from openai import OpenAI # type: ignore except ImportError: return None return OpenAI(api_key=api_key) def call_llm(text: str, prompt_version: str = "v3_constrained") -> Optional[dict]: """Call the LLM. Returns the parsed JSON dict, or None on failure.""" client = _openai_client() if client is None: return None system = PROMPTS.get(prompt_version, PROMPTS["v3_constrained"]) try: response = client.chat.completions.create( model=OPENAI_MODEL, messages=[ {"role": "system", "content": system}, {"role": "user", "content": text}, ], response_format={"type": "json_object"}, temperature=0.0, ) content = response.choices[0].message.content or "{}" return json.loads(content) except Exception as exc: # noqa: BLE001 - we degrade to fallback print(f"[nlp.parser] OpenAI call failed: {exc}") return None _NUMBER_WORDS = { "one": 1, "ein": 1, "eine": 1, "einen": 1, "a": 1, "two": 2, "zwei": 2, "three": 3, "drei": 3, "four": 4, "vier": 4, "five": 5, "fuenf": 5, "fünf": 5, "six": 6, "sechs": 6, "seven": 7, "sieben": 7, "eight": 8, "acht": 8, } def heuristic_parse(text: str) -> OrderTicket: """Very small fallback parser used when OpenAI is unavailable.""" text_low = text.lower() items: list[OrderItem] = [] for keyword, station in DISH_TO_STATION.items(): if keyword in text_low: qty = 1 # try to find a count immediately before the keyword match = re.search(rf"(\d+)\s+\w*\s*{re.escape(keyword)}", text_low) if match: qty = int(match.group(1)) else: for word, value in _NUMBER_WORDS.items(): if re.search(rf"\b{word}\b\s+\w*\s*{re.escape(keyword)}", text_low): qty = value break items.append( OrderItem( dish=keyword, quantity=qty, modifiers=[], station=station, ) ) if not items: items.append( OrderItem(dish="unknown_dish", quantity=1, modifiers=[], station=None) ) return OrderTicket(items=items, raw_text=text).finalize() def parse_order(text: str, prompt_version: str = "v3_constrained") -> OrderTicket: """Public entry point. Returns a finalized :class:`OrderTicket`.""" data = call_llm(text, prompt_version=prompt_version) if data is None: return heuristic_parse(text) try: ticket = OrderTicket.model_validate({**data, "raw_text": text}) return ticket.finalize() except Exception as exc: # noqa: BLE001 print(f"[nlp.parser] schema validation failed: {exc}. Using heuristic.") return heuristic_parse(text)