# app/intent_schema.py from __future__ import annotations from typing import List, Optional, Literal, Union, Tuple from pydantic import BaseModel, Field, ValidationError # ---- Canonical intent names ---- IntentName = Literal[ "reservation.create", "order.create", "hours.get", "menu.search", "smalltalk", "other", ] # ---- Slot models ---- class ReservationSlots(BaseModel): name: Optional[str] = None party_size: Optional[int] = Field(default=None, ge=1, le=20) date: Optional[str] = None # ISO preferred (YYYY-MM-DD) or “today” time: Optional[str] = None # “19:00” or “7 pm” phone: Optional[str] = None model_config = {"extra": "ignore"} # tolerate extra keys from LLM class OrderItem(BaseModel): name: str qty: int = Field(default=1, ge=1) model_config = {"extra": "ignore"} class OrderSlots(BaseModel): items: List[OrderItem] = Field(default_factory=list) notes: Optional[str] = None model_config = {"extra": "ignore"} class MenuSlots(BaseModel): query: Optional[str] = None dietary: List[str] = Field(default_factory=list) # e.g., ["vegan","gluten-free"] model_config = {"extra": "ignore"} # ---- Envelope returned by the router/LLM ---- class IntentEnvelope(BaseModel): intent: IntentName need_more_info: bool = False ask_user: Optional[str] = None # a single, polite follow-up question if info missing # Keep it loose at the API boundary; we’ll coerce it with helper below. slots: dict = Field(default_factory=dict) model_config = {"extra": "ignore"} # ---- Helpers to validate slots into the right model ---- SlotsUnion = Union[ReservationSlots, OrderSlots, MenuSlots, dict] def coerce_slots(intent: IntentName, slots: dict | None) -> Tuple[SlotsUnion, Optional[str]]: """ Try to convert a loose slots dict into the correct typed model based on 'intent'. Returns (slots_obj, error_message). If cannot validate, returns (original_dict, message). This keeps the pipeline resilient while giving you typed access when possible. """ raw = slots or {} try: if intent == "reservation.create": return ReservationSlots(**raw), None if intent == "order.create": return OrderSlots(**raw), None if intent == "menu.search": return MenuSlots(**raw), None # 'hours.get', 'smalltalk', 'other' often don’t need slots return raw, None except ValidationError as ve: return raw, f"slot_validation_failed: {ve.errors()}"