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