Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/core/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
34 changes: 34 additions & 0 deletions app/schemas/order.py
Original file line number Diff line number Diff line change
@@ -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] = []
87 changes: 87 additions & 0 deletions app/services/intent_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
23 changes: 22 additions & 1 deletion app/services/rag_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
INTENT_FAQ,
INTENT_GENERAL,
INTENT_HYBRID,
INTENT_ORDER_STATUS,
INTENT_PRODUCT,
INTENT_SEARCH,
LINK_TELEGRAM,
Expand All @@ -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
Expand Down Expand Up @@ -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]:
"""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions app/services/woo_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
75 changes: 75 additions & 0 deletions app/services/woo_smart_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
4 changes: 3 additions & 1 deletion app/utils/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Loading