Ctrl K
ComunidadGuías…

rex/shopping_list_handler.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Shopping list voice intent handler (US-SL-002).

Detects shopping list utterances before they reach the LLM so Rex can respond
immediately without spending tokens on a trivial task.

Supported patterns
------------------
Add intent:
  "add milk to the shopping list"
  "add milk, eggs, and butter to my list"
  "put oat milk on the list"

Read intent:
  "what's on my shopping list?"
  "what is on the list?"
  "read my shopping list"
  "show me my list"
"""

from __future__ import annotations

import logging
import re

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Compiled patterns
# ---------------------------------------------------------------------------

_ADD_PATTERNS: list[re.Pattern[str]] = [
    re.compile(
        r"^(?:please\s+)?add\s+(.+?)\s+to\s+(?:the\s+|my\s+)?(?:shopping\s+)?list$",
        re.IGNORECASE,
    ),
    re.compile(
        r"^(?:please\s+)?put\s+(.+?)\s+on\s+(?:the\s+|my\s+)?(?:shopping\s+)?list$",
        re.IGNORECASE,
    ),
]

_READ_PATTERNS: list[re.Pattern[str]] = [
    re.compile(r"what(?:'s| is)\s+on\s+(?:my\s+|the\s+)?(?:shopping\s+)?list", re.IGNORECASE),
    re.compile(
        r"(?:read|show|list)\s+(?:me\s+)?(?:my\s+|the\s+)?(?:shopping\s+)?list", re.IGNORECASE
    ),
    re.compile(r"(?:what\s+)?do\s+i\s+need\s+(?:to\s+buy|from\s+the\s+store)", re.IGNORECASE),
]

# Separates items in a list utterance: commas, "and", semicolons
_ITEM_SPLIT_RE = re.compile(r",\s*(?:and\s+)?|\s+and\s+|;\s*", re.IGNORECASE)


def _extract_items(raw: str) -> list[str]:
    """Split a raw item string into individual normalised item names."""
    parts = _ITEM_SPLIT_RE.split(raw)
    items = [p.strip().rstrip(".!?").strip() for p in parts]
    return [i for i in items if i]


def _match_add(transcript: str) -> str | None:
    """Return the raw items string if transcript matches an add pattern, else None."""
    for pattern in _ADD_PATTERNS:
        m = pattern.match(transcript.strip())
        if m:
            return m.group(1)
    return None


def _match_read(transcript: str) -> bool:
    """Return True if transcript matches a read/list pattern."""
    for pattern in _READ_PATTERNS:
        if pattern.search(transcript.strip()):
            return True
    return False


# ---------------------------------------------------------------------------
# Handler class
# ---------------------------------------------------------------------------


class ShoppingListHandler:
    """Handles shopping list voice intents before they reach the LLM.

    Parameters
    ----------
    shopping_list:
        A ``ShoppingList`` instance shared with the rest of the application.
    """

    def __init__(self, shopping_list) -> None:  # avoid circular import
        self._list = shopping_list

    def handle(self, transcript: str, *, user_id: str = "default") -> str | None:
        """Return a response string if *transcript* is a shopping list command, else None.

        Parameters
        ----------
        transcript:
            The user's spoken (transcribed) text.
        user_id:
            The active user identifier used as ``added_by`` for new items.
        """
        # --- add intent ---
        raw_items = _match_add(transcript)
        if raw_items is not None:
            items = _extract_items(raw_items)
            if not items:
                return None
            added: list[str] = []
            for item_name in items:
                self._list.add_item(item_name, added_by=user_id)
                added.append(item_name)
            logger.debug("[shopping] added %d item(s) for user %r: %s", len(added), user_id, added)
            if len(added) == 1:
                return f"Added {added[0]} to your shopping list."
            items_str = ", ".join(added[:-1]) + f", and {added[-1]}"
            return f"Added {items_str} to your shopping list."

        # --- read intent ---
        if _match_read(transcript):
            unchecked = self._list.list_items(include_checked=False)
            if not unchecked:
                return "Your shopping list is empty."
            if len(unchecked) == 1:
                item = unchecked[0]
                qty = (
                    f"{item.quantity:g} {item.unit}".strip() if item.unit else f"{item.quantity:g}"
                )
                return f"You have {qty} {item.name} on your list."
            parts = []
            for item in unchecked:
                qty = (
                    f"{item.quantity:g} {item.unit}".strip() if item.unit else f"{item.quantity:g}"
                )
                parts.append(f"{qty} {item.name}".strip())
            items_str = ", ".join(parts[:-1]) + f", and {parts[-1]}"
            return f"You need: {items_str}."

        return None


__all__ = ["ShoppingListHandler"]