"""Tools the model can call, and the code that runs them against the store.""" from __future__ import annotations import json import logging from collections.abc import Callable from dataclasses import dataclass, field from typing import Any from .store import Store logger = logging.getLogger(__name__) CATEGORIES = ["t-shirts", "hoodies", "jeans", "sneakers", "jackets", "accessories"] TOOL_SCHEMAS: list[dict[str, Any]] = [ { "type": "function", "function": { "name": "get_order_status", "description": ( "Look up an existing order. Requires the order number (format NS-12345) and the last 4 digits " "of the phone number used for the order. Ask the customer for both before calling." ), "parameters": { "type": "object", "properties": { "order_id": {"type": "string", "description": "Order number, e.g. NS-10241"}, "phone_last4": {"type": "string", "description": "Last 4 digits of the customer's phone number"}, }, "required": ["order_id", "phone_last4"], "additionalProperties": False, }, }, }, { "type": "function", "function": { "name": "search_products", "description": ( "Search the catalog for products, prices, colors and sizes in stock. " f"Write the query as short English keywords (product type, category or color). Categories: {', '.join(CATEGORIES)}." ), "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "English keywords, e.g. 'sneakers', 'black hoodie', 'jeans'"}, "size": {"type": "string", "description": "Optional size to check, e.g. 'L', '42', '32'"}, }, "required": ["query"], "additionalProperties": False, }, }, }, { "type": "function", "function": { "name": "get_delivery_fee", "description": "Get the delivery fee and estimated delivery time to an Algerian wilaya, by name or number.", "parameters": { "type": "object", "properties": { "wilaya": {"type": "string", "description": "Wilaya name in any language, or its number, e.g. 'Oran', 'وهران', '31'"}, }, "required": ["wilaya"], "additionalProperties": False, }, }, }, { "type": "function", "function": { "name": "get_store_policies", "description": "Get the store's return and exchange policy, support hours and contact rules.", "parameters": {"type": "object", "properties": {}, "additionalProperties": False}, }, }, { "type": "function", "function": { "name": "request_human_agent", "description": ( "Hand the conversation to a human agent. Use it when the customer asks for a person, has a complaint, " "wants to change or cancel an order, or when you cannot solve the request with the other tools." ), "parameters": { "type": "object", "properties": { "reason": {"type": "string", "description": "One-sentence summary of what the customer needs, in English"}, }, "required": ["reason"], "additionalProperties": False, }, }, }, ] @dataclass class ToolContext: """Who is talking to the bot, so tools can act on behalf of the right customer.""" chat_id: int username: str | None = None tickets: list[int] = field(default_factory=list) class ToolExecutor: def __init__(self, store: Store) -> None: self._store = store self._handlers: dict[str, Callable[[dict[str, Any], ToolContext], dict[str, Any]]] = { "get_order_status": self._get_order_status, "search_products": self._search_products, "get_delivery_fee": self._get_delivery_fee, "get_store_policies": self._get_store_policies, "request_human_agent": self._request_human_agent, } def run(self, name: str, raw_arguments: str | None, ctx: ToolContext) -> str: """Run one tool call and return a JSON string for the model. Never raises.""" handler = self._handlers.get(name) if handler is None: return json.dumps({"error": f"unknown tool '{name}'"}) try: args = json.loads(raw_arguments) if raw_arguments else {} if not isinstance(args, dict): raise ValueError("arguments must be a JSON object") except (json.JSONDecodeError, ValueError) as exc: return json.dumps({"error": f"invalid arguments: {exc}"}) try: result = handler(args, ctx) except Exception: logger.exception("tool %s failed", name) return json.dumps({"error": "internal error while running the tool"}) return json.dumps(result, ensure_ascii=False) def _get_order_status(self, args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]: order = self._store.order_status(str(args.get("order_id", "")), str(args.get("phone_last4", ""))) if order is None: return { "found": False, "message": "No order matches this number and these phone digits. Ask the customer to check both.", } return {"found": True, "order": order} def _search_products(self, args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]: size = args.get("size") products = self._store.search_products(str(args.get("query", "")), str(size) if size else None) return {"count": len(products), "products": products, "currency": "DZD"} def _get_delivery_fee(self, args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]: fee = self._store.delivery_fee(str(args.get("wilaya", ""))) if fee is None: return {"covered": False, "covered_wilayas": self._store.covered_wilayas()} return {"covered": True, **fee, "currency": "DZD"} def _get_store_policies(self, args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]: return self._store.store_info() def _request_human_agent(self, args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]: reason = str(args.get("reason", "")).strip() or "Customer asked for a human agent" ticket_id = self._store.create_ticket(ctx.chat_id, ctx.username, reason) ctx.tickets.append(ticket_id) return {"ticket_id": ticket_id, "message": "A human agent has been notified and will reply in this chat."}