diff --git a/app/core/constants.py b/app/core/constants.py index 8986dbf..6b438c5 100644 --- a/app/core/constants.py +++ b/app/core/constants.py @@ -61,6 +61,7 @@ INTENT_FAQ = "faq" INTENT_GENERAL = "general" INTENT_CONTACT = "contact" +INTENT_ORDER_STATUS = "order_status" # --- Regex Patterns --- REGEX_PHONE = r"(?:\+380|380|0)\d{9}" diff --git a/app/schemas/order.py b/app/schemas/order.py new file mode 100644 index 0000000..ba0cc5a --- /dev/null +++ b/app/schemas/order.py @@ -0,0 +1,34 @@ +from typing import Any + +from pydantic import BaseModel, Field + + +class WooOrderLineItem(BaseModel): + name: str = "" + quantity: int = 0 + price: float = 0.0 + total: float = 0.0 + sku: str = "" + + +class WooOrderShipping(BaseModel): + method_title: str = "" + meta_data: list[dict[str, Any]] = [] + + +class WooOrderBilling(BaseModel): + first_name: str = "" + last_name: str = "" + phone: str = "" + + +class WooOrder(BaseModel): + id: int + status: str = "" + total: float = 0.0 + currency: str = "UAH" + date_created: str = "" + billing: WooOrderBilling = Field(default_factory=WooOrderBilling) + payment_method_title: str = "" + shipping_lines: list[WooOrderShipping] = [] + line_items: list[WooOrderLineItem] = [] diff --git a/app/services/intent_handlers.py b/app/services/intent_handlers.py index 0fbe7e5..ad89ad1 100644 --- a/app/services/intent_handlers.py +++ b/app/services/intent_handlers.py @@ -381,3 +381,90 @@ async def handle( requires_lead=requires_lead, lead_form_type=lead_form_type, ) + + +class OrderStatusIntentHandler: + """Handler for order status intent.""" + + def __init__(self, woo_service: Any): + self.woo_service = woo_service + + async def handle( + self, + intent_data: dict[str, Any], + system_instructions: list[str], + product_facts: list[str], + ) -> IntentContextResult: + order_id_str = str(intent_data.get("strict_query") or "").strip() + + # Витягуємо лише цифри, якщо LLM додала текст + import re + + match = re.search(r"\d+", order_id_str) + if match: + order_id_str = match.group(0) + + if not order_id_str or not order_id_str.isdigit(): + system_instructions.append( + "Не вдалося визначити номер замовлення. Попроси клієнта вказати точний номер замовлення (тільки цифри)." + ) + return IntentContextResult( + product_facts=product_facts, + system_instructions=system_instructions, + extracted_links=[], + requires_lead=False, + lead_form_type=None, + new_intent_type=None, + ) + + order_id = int(order_id_str) + try: + order_data = await self.woo_service.get_order_async(order_id) + except Exception as e: + logger.exception("WooCommerce order fetch failed", extra={"error": str(e)}) + order_data = None + + if not order_data: + system_instructions.append( + f"Замовлення з номером {order_id} не знайдено. Перепроси та спитай, чи можливо клієнт помилився цифрою або номером." + ) + else: + status_map = { + "pending": "Очікує оплати", + "processing": "В обробці", + "on-hold": "На утриманні", + "completed": "Виконано", + "cancelled": "Скасовано", + "refunded": "Повернено", + "failed": "Не вдалося", + } + raw_status = str(order_data.get("status", "")) + status_ua = status_map.get(raw_status, raw_status) + + fact = f"Замовлення #{order_data.get('id')}. Статус: {status_ua}. Дата створення: {order_data.get('date_created')}. Сума: {order_data.get('total')} {order_data.get('currency')}." + fact += f" Спосіб оплати: {order_data.get('payment_method_title')}." + + shipping = order_data.get("shipping_lines", []) + if shipping: + fact += f" Спосіб доставки: {shipping[0].get('method_title')}." + + items = order_data.get("line_items", []) + if items: + items_str = ", ".join( + f"{item.get('name')} (x{item.get('quantity')})" for item in items + ) + fact += f" Товари: {items_str}." + + product_facts.append(fact) + system_instructions.append( + "Клієнт запитує про своє замовлення. Використовуй надані дані (статус, суму, товари, доставку), щоб ввічливо відповісти йому. Не вигадуй інформацію, якої немає." + ) + + return IntentContextResult( + product_facts=product_facts, + system_instructions=system_instructions, + extracted_links=[], + requires_lead=False, + lead_form_type=None, + new_intent_type=None, + ) diff --git a/app/services/rag_engine.py b/app/services/rag_engine.py index 6085ee7..685d95b 100644 --- a/app/services/rag_engine.py +++ b/app/services/rag_engine.py @@ -20,6 +20,7 @@ INTENT_FAQ, INTENT_GENERAL, INTENT_HYBRID, + INTENT_ORDER_STATUS, INTENT_PRODUCT, INTENT_SEARCH, LINK_TELEGRAM, @@ -45,7 +46,11 @@ from app.services.category_manager import CategoryManager from app.services.chat_memory_service import ChatMemoryService from app.services.guardrails_service import GuardrailsService -from app.services.intent_handlers import ProductCheckoutIntentHandler, SearchIntentHandler +from app.services.intent_handlers import ( + OrderStatusIntentHandler, + ProductCheckoutIntentHandler, + SearchIntentHandler, +) from app.services.openai_service import OpenAIService from app.services.price_comparator import PriceComparator from app.services.telegram_service import TelegramService @@ -92,6 +97,9 @@ def __init__( price_comparator=price_comparator, telegram_service=telegram_service, settings=settings ) self.search_intent_handler = SearchIntentHandler(price_comparator=price_comparator) + self.order_status_intent_handler = OrderStatusIntentHandler( + woo_service=price_comparator.woo_service + ) async def detect_intent(self, question: str, history_context: str) -> dict[str, Any]: """ @@ -155,6 +163,7 @@ async def detect_intent(self, question: str, history_context: str) -> dict[str, intent_hybrid=INTENT_HYBRID, intent_general=INTENT_GENERAL, intent_contact=INTENT_CONTACT, + intent_order_status=INTENT_ORDER_STATUS, ) try: @@ -301,6 +310,18 @@ async def _get_intent_context( ) extracted_links.append({"text": LINK_VIBER, "url": self.settings.viber_contact_url}) + elif intent_type == INTENT_ORDER_STATUS: + res = await self.order_status_intent_handler.handle( + intent_data=intent_data, + system_instructions=system_instructions, + product_facts=product_facts, + ) + product_facts = res.product_facts + system_instructions = res.system_instructions + extracted_links = res.extracted_links + requires_lead = res.requires_lead + lead_form_type = res.lead_form_type + return IntentContextResult( product_facts=product_facts, system_instructions=system_instructions, diff --git a/app/services/woo_service.py b/app/services/woo_service.py index 67df1f7..e69dccb 100644 --- a/app/services/woo_service.py +++ b/app/services/woo_service.py @@ -216,3 +216,38 @@ async def get_daily_orders_stats(self) -> dict[str, Any]: "paid": paid, "tags": tags, } + + async def get_order_async(self, order_id: int) -> dict[str, Any] | None: + """Asynchronous fetch of a specific order in WooCommerce by ID.""" + from app.services.woo_smart_parser import parse_order + + order_url = f"{self.base_url.replace('/products', '/orders')}/{order_id}" + client = self._get_client() + + try: + resp = await client.get( + order_url, auth=(self.woo_ck, self.woo_cs), timeout=self.timeout + ) + resp.raise_for_status() + data = resp.json() + if data: + return await asyncio.to_thread(parse_order, data) + except httpx.TimeoutException: + logger.error("WooCommerce API Order Fetch Timeout", order_id=order_id) + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + logger.info("WooCommerce API Order Not Found", order_id=order_id) + else: + logger.error( + "WooCommerce API Order Fetch HTTP Status Error", error=str(e), order_id=order_id + ) + except httpx.RequestError as e: + logger.error( + "WooCommerce API Order Fetch Request Error", error=str(e), order_id=order_id + ) + except Exception as e: + logger.error( + "WooCommerce API Order Fetch Unexpected Error", error=str(e), order_id=order_id + ) + + return None diff --git a/app/services/woo_smart_parser.py b/app/services/woo_smart_parser.py index 18ec833..761516f 100644 --- a/app/services/woo_smart_parser.py +++ b/app/services/woo_smart_parser.py @@ -128,3 +128,78 @@ def parse_product(raw_product: dict[str, Any] | None, max_desc_length: int = 400 ) return clean_data + + +def parse_order(raw_order: dict[str, Any] | None) -> dict[str, Any]: + """ + Cleans and structures raw WooCommerce order data for further use (e.g., in LLM). + """ + if not isinstance(raw_order, dict): + logger.warning("Smart Parser received invalid order input: expected dict") + return {} + + try: + from app.schemas.order import WooOrder, WooOrderBilling, WooOrderLineItem, WooOrderShipping + + billing_raw = dict(raw_order.get("billing") or {}) + billing = WooOrderBilling( + first_name=str(billing_raw.get("first_name", "")), + last_name=str(billing_raw.get("last_name", "")), + phone=str(billing_raw.get("phone", "")), + ) + from typing import cast + + shipping_lines: list[WooOrderShipping] = [] + raw_shipping = raw_order.get("shipping_lines") + if isinstance(raw_shipping, list): + shipping_list = cast(list[Any], raw_shipping) + for sl_item in shipping_list: + if isinstance(sl_item, dict): + sl_dict = cast(dict[str, Any], sl_item) + meta_data = sl_dict.get("meta_data") + meta_data_list = ( + cast(list[Any], meta_data) if isinstance(meta_data, list) else [] + ) + shipping_lines.append( + WooOrderShipping( + method_title=str(sl_dict.get("method_title", "")), + meta_data=meta_data_list, + ) + ) + + line_items: list[WooOrderLineItem] = [] + raw_items = raw_order.get("line_items") + if isinstance(raw_items, list): + items_list = cast(list[Any], raw_items) + for li_item in items_list: + if isinstance(li_item, dict): + li_dict = cast(dict[str, Any], li_item) + line_items.append( + WooOrderLineItem( + name=str(li_dict.get("name", "")), + quantity=int(li_dict.get("quantity", 0)), + price=float(li_dict.get("price", 0.0)), + total=float(li_dict.get("total", 0.0)), + sku=str(li_dict.get("sku", "")), + ) + ) + + order = WooOrder( + id=int(raw_order.get("id", 0)), + status=str(raw_order.get("status", "")), + total=float(raw_order.get("total", 0.0)), + currency=str(raw_order.get("currency", "UAH")), + date_created=str(raw_order.get("date_created", "")), + billing=billing, + payment_method_title=str(raw_order.get("payment_method_title", "")), + shipping_lines=shipping_lines, + line_items=line_items, + ) + return order.model_dump() + except Exception as e: + logger.error( + "Smart Parser crashed on order", + order_id=raw_order.get("id") if raw_order else None, + error=str(e), + ) + return {} diff --git a/app/utils/prompts.py b/app/utils/prompts.py index cf4b72d..53afc98 100644 --- a/app/utils/prompts.py +++ b/app/utils/prompts.py @@ -41,7 +41,9 @@ -> {{"intent": "{intent_general}", "product_name": null, "strict_query": null, "broad_query": null, "category_query": null, "normalized_faq_queries": []}} 8. If the client expresses a desire to contact a manager, talk to a human, or leave contacts: -> {{"intent": "{intent_contact}", "product_name": null, "strict_query": null, "broad_query": null, "category_query": null, "normalized_faq_queries": []}} -9. CRITICAL: The "intent" MUST be based MAINLY on the "Current Query". If the user ignores a previous bot request to provide contact info and instead asks a completely new question, classify the intent based ONLY on the new question. Do NOT carry over "contact" intent from the History unless the current query explicitly asks for a manager. +9. If the client asks about the status, details, or shipping of a specific existing order (often providing an order number like 276677): + -> {{"intent": "{intent_order_status}", "product_name": null, "strict_query": "extract the order number (digits) here if present", "broad_query": null, "category_query": null, "normalized_faq_queries": []}} +10. CRITICAL: The "intent" MUST be based MAINLY on the "Current Query". If the user ignores a previous bot request to provide contact info and instead asks a completely new question, classify the intent based ONLY on the new question. Do NOT carry over "contact" intent from the History unless the current query explicitly asks for a manager. Respond ONLY with valid JSON."""