diff --git a/backend/agent/tools.py b/backend/agent/tools.py index 8f97d7b..f16110b 100644 --- a/backend/agent/tools.py +++ b/backend/agent/tools.py @@ -284,6 +284,74 @@ def parse_search_products_markdown(text: str, limit: int = 6) -> list[dict]: return products +def parse_tracking_result(text: str) -> dict: + raw = text or "" + result = { + "order_number": "", + "status": "", + "recipient": "", + "estimated_delivery": "", + "location": "", + "items": [], + "events": [], + "raw": raw, + } + + if not raw: + return result + + try: + data = json.loads(raw) if isinstance(raw, str) else raw + except json.JSONDecodeError: + return result + + if not isinstance(data, dict): + return result + + result["order_number"] = str( + data.get("order_number") + or data.get("orderNumber") + or data.get("id") + or "" + ).strip() + result["status"] = str(data.get("status") or data.get("order_status") or "").strip() + result["recipient"] = str(data.get("recipient_name") or data.get("recipient") or "").strip() + result["estimated_delivery"] = str( + data.get("estimated_delivery") or data.get("delivery_date") or data.get("estimatedDelivery") or "" + ).strip() + result["location"] = str(data.get("current_location") or data.get("location") or "").strip() + + items = data.get("items") or data.get("products") or data.get("cart") or [] + if isinstance(items, list): + for item in items[:8]: + if isinstance(item, dict): + name = str(item.get("name") or item.get("title") or item.get("product_name") or "").strip() + quantity_raw = item.get("quantity") + quantity = None + if quantity_raw not in (None, ""): + try: + quantity = _coerce_int(quantity_raw) + except (TypeError, ValueError): + quantity = None + if name: + result["items"].append({"name": name, "quantity": quantity}) + elif isinstance(item, str) and item.strip(): + result["items"].append({"name": item.strip(), "quantity": None}) + + events = data.get("timeline") or data.get("history") or data.get("events") or [] + if isinstance(events, list): + for event in events[:8]: + if not isinstance(event, dict): + continue + label = str(event.get("status") or event.get("event") or event.get("description") or "").strip() + time = str(event.get("timestamp") or event.get("date") or event.get("time") or "").strip() + location = str(event.get("location") or "").strip() + if label: + result["events"].append({"label": label, "time": time, "location": location}) + + return result + + def build_tool_result_event(tool_name: str, result: Any) -> dict[str, Any]: event: dict[str, Any] = {"result": result} if tool_name == "search_products" and isinstance(result, str): @@ -296,6 +364,11 @@ def build_tool_result_event(tool_name: str, result: Any) -> dict[str, Any]: if product.get("name") or product.get("product_id") or product.get("product_url"): event["product"] = product event["raw"] = result + elif tool_name == "track_order" and isinstance(result, str): + tracking = parse_tracking_result(result) + if tracking.get("order_number") or tracking.get("status") or tracking.get("events"): + event["tracking"] = tracking + event["raw"] = result return event @@ -364,6 +437,10 @@ async def _check_delivery(city: str, delivery_date: str, product_id: str, **_) - }) +async def _track_order(order_number: str, **_) -> Any: + return await mcp_client.call_tool("kapruka_track_order", {"order_number": order_number}) + + async def _add_to_cart(session_id: str, product_id: str, quantity: int = 1, **_) -> str: raw = await mcp_client.call_tool("kapruka_get_product", {"product_id": product_id}) @@ -568,6 +645,17 @@ async def _checkout(session_id: str, **_) -> str: "required": ["city", "delivery_date", "product_id"], }, }, + { + "name": "track_order", + "description": "Track a Kapruka order by order number and return the latest status and timeline.", + "parameters": { + "type": "object", + "properties": { + "order_number": {"type": "string", "description": "Kapruka order number"}, + }, + "required": ["order_number"], + }, + }, { "name": "add_to_cart", "description": "Add a product to the user's shopping cart. Use this when user wants to add an item.", @@ -666,6 +754,7 @@ async def _checkout(session_id: str, **_) -> str: "list_categories": _list_categories, "list_delivery_cities": _list_delivery_cities, "check_delivery": _check_delivery, + "track_order": _track_order, "add_to_cart": _add_to_cart, "remove_from_cart": _remove_from_cart, "update_cart_quantity": _update_cart_quantity, diff --git a/backend/routes/chat.py b/backend/routes/chat.py index b0ced20..76bf27a 100644 --- a/backend/routes/chat.py +++ b/backend/routes/chat.py @@ -2,6 +2,7 @@ import json import logging +import re import uuid from fastapi import APIRouter @@ -21,6 +22,23 @@ class ChatRequest(BaseModel): history: list[dict] | None = None +def _needs_order_number_prompt(message_text: str) -> bool: + normalized = message_text.lower() + asks_to_track = any( + phrase in normalized + for phrase in ( + "track order", + "track my order", + "track package", + "order status", + "where is my order", + "delivery status", + ) + ) + has_order_number = bool(re.search(r"\b\d{5,}\b", message_text)) + return asks_to_track and not has_order_number + + def _provider_fallback_order(message_text: str) -> list[str]: preferred = resolve_provider_config(message_text).provider ordered: list[str] = [] @@ -58,6 +76,12 @@ async def chat_endpoint(req: ChatRequest): provider_config = resolve_provider_config(req.message) async def event_stream(): + if _needs_order_number_prompt(req.message): + yield f"data: {json.dumps({'type': 'session_id', 'session_id': session_id, 'provider': 'local', 'model': 'order-tracking-guard'})}\n\n" + yield f"data: {json.dumps({'type': 'text', 'text': 'Please share your Kapruka order number and I will track the latest status for you.'})}\n\n" + yield 'data: [DONE]\n\n' + return + candidates = _provider_fallback_order(req.message) if not candidates: yield ( diff --git a/backend/tests/test_chat_route.py b/backend/tests/test_chat_route.py new file mode 100644 index 0000000..e573ea9 --- /dev/null +++ b/backend/tests/test_chat_route.py @@ -0,0 +1,16 @@ +import unittest + +from routes.chat import _needs_order_number_prompt + + +class ChatRouteTest(unittest.TestCase): + def test_prompts_for_order_number_when_tracking_without_one(self): + self.assertTrue(_needs_order_number_prompt("I want to track my order")) + self.assertTrue(_needs_order_number_prompt("Can you check my order status?")) + + def test_skips_prompt_when_order_number_is_present(self): + self.assertFalse(_needs_order_number_prompt("Track order 12345678")) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_tool_arg_sanitizer.py b/backend/tests/test_tool_arg_sanitizer.py index 223d9ce..03bd56b 100644 --- a/backend/tests/test_tool_arg_sanitizer.py +++ b/backend/tests/test_tool_arg_sanitizer.py @@ -1,7 +1,7 @@ import unittest from unittest.mock import AsyncMock, patch -from agent.tools import _search_products, build_tool_result_event, coerce_tool_args, format_tool_result_for_model, parse_failed_tool_generation, parse_product_markdown, parse_search_products_markdown +from agent.tools import _search_products, build_tool_result_event, coerce_tool_args, format_tool_result_for_model, parse_failed_tool_generation, parse_product_markdown, parse_search_products_markdown, parse_tracking_result class ToolArgSanitizerTest(unittest.TestCase): @@ -115,6 +115,31 @@ def test_parses_bracketed_failed_generation(self): "sort": "relevance", })) + def test_parses_tracking_result_and_emits_tracking_event(self): + tracking_json = """{ + "order_number": "12345678", + "status": "Out for delivery", + "recipient_name": "Alex", + "estimated_delivery": "2026-06-30", + "current_location": "Colombo", + "items": [{"name": "Chocolate Box", "quantity": 1}], + "timeline": [ + {"status": "Out for delivery", "timestamp": "2026-06-29 10:30", "location": "Colombo"}, + {"status": "Processed", "timestamp": "2026-06-28 18:00"} + ] + }""" + + parsed = parse_tracking_result(tracking_json) + self.assertEqual(parsed["order_number"], "12345678") + self.assertEqual(parsed["status"], "Out for delivery") + self.assertEqual(parsed["recipient"], "Alex") + self.assertEqual(parsed["items"][0]["name"], "Chocolate Box") + self.assertEqual(parsed["events"][0]["label"], "Out for delivery") + + event = build_tool_result_event("track_order", tracking_json) + self.assertIn("tracking", event) + self.assertEqual(event["tracking"]["location"], "Colombo") + if __name__ == "__main__": unittest.main() diff --git a/docs/feature-checklists/02-order-tracking.md b/docs/feature-checklists/02-order-tracking.md new file mode 100644 index 0000000..402c350 --- /dev/null +++ b/docs/feature-checklists/02-order-tracking.md @@ -0,0 +1,33 @@ +# Feature 02: Order Tracking + +## Goal + +Add order-tracking capability inspired by the candidate repos, including a friendly prompt flow, MCP-backed tracking tool support, and a dedicated tracking status card in chat. + +## Checklist + +- [x] Review candidate tracking implementations and card layouts +- [x] Pick the smallest tracking flow that fits the current architecture +- [x] Add backend `track_order` tool wrapper around `kapruka_track_order` +- [x] Add structured tracking payload support to tool-result events +- [x] Add tracking card component in the frontend +- [x] Add clear entry points from quick actions or common prompts +- [x] Handle the no-order-number case with a helpful assistant response +- [x] Verify tracking render logic with a representative response shape +- [x] Run backend tests relevant to provider/tool behavior +- [x] Run `npm run lint` +- [x] Run `npm run build` +- [x] Verify the flow in a live browser session +- [x] Commit, push branch, and open draft PR + +## Notes + +- Candidate reference: `tmp_compare/kavi-kapruka/components/checkout/TrackOrderCard.tsx` +- Candidate reference: `tmp_compare/Kapruka-Ai-Shopping-Agent/components/TrackingCard.js` +- Candidate reference: `tmp_compare/Kapruka-Ai-Shopping-Agent/lib/agents/tracking-agent.js` +- Browser verification used `http://localhost:3000` through a Playwright fallback because the in-app Browser tool was not callable in this thread. +- Live browser checks covered: + - Missing-order-number flow: "I want to track my order" returns a direct request for the Kapruka order number. + - Tool path flow: "Track order 12345678" executes the tracking path and returns a graceful not-found response. +- Follow-up UI polish added empty-state hero buttons for tracking and category browsing. +- Structured tracking payload normalization is covered by backend unit tests with a representative JSON response shape. diff --git a/docs/feature-checklists/03-pay-link-card.md b/docs/feature-checklists/03-pay-link-card.md new file mode 100644 index 0000000..552be11 --- /dev/null +++ b/docs/feature-checklists/03-pay-link-card.md @@ -0,0 +1,25 @@ +# Feature 03: Pay Link Card + +## Goal + +Upgrade the checkout completion experience so successful order placement produces a visible payment-link or order-summary card instead of only plain chat text. + +## Checklist + +- [ ] Review candidate pay-link and order-summary implementations +- [ ] Confirm current `checkout` tool result shape from the MCP +- [ ] Add structured order payload support to backend tool-result events +- [ ] Add a dedicated pay-link or order-summary card component +- [ ] Surface price lock / payment guidance clearly in the UI +- [ ] Preserve the existing cart and checkout drawer flow +- [ ] Verify behavior for success and incomplete-checkout cases +- [ ] Run backend tests relevant to order payload parsing +- [ ] Run `npm run lint` +- [ ] Run `npm run build` +- [ ] Verify the flow in a live browser session +- [ ] Commit, push branch, and open draft PR + +## Notes + +- Candidate reference: `tmp_compare/kavi-kapruka/components/checkout/PayLinkCard.tsx` +- Candidate reference: `tmp_compare/Kapruka-Ai-Shopping-Agent/components/OrderSummary.js` diff --git a/docs/feature-checklists/04-voice-mode.md b/docs/feature-checklists/04-voice-mode.md new file mode 100644 index 0000000..9a323eb --- /dev/null +++ b/docs/feature-checklists/04-voice-mode.md @@ -0,0 +1,24 @@ +# Feature 04: Voice Mode + +## Goal + +Add browser-native voice input and optional spoken assistant replies using built-in Web Speech APIs, following the candidate approach without new paid services. + +## Checklist + +- [ ] Review candidate voice input/output implementation +- [ ] Decide the smallest supported browser behavior and fallback text +- [ ] Add voice input button and listening state +- [ ] Add optional voice output toggle for assistant replies +- [ ] Keep the chat input usable when voice APIs are unavailable +- [ ] Ensure the UI remains clean on desktop and mobile +- [ ] Add at least one lightweight check for prompt/input behavior +- [ ] Run `npm run lint` +- [ ] Run `npm run build` +- [ ] Verify the flow in a live browser session +- [ ] Commit, push branch, and open draft PR + +## Notes + +- Candidate reference: `tmp_compare/kavi-kapruka/lib/speech.ts` +- Candidate reference: `tmp_compare/kavi-kapruka/components/chat/VoiceButton.tsx` diff --git a/docs/feature-checklists/05-quick-actions-and-welcome.md b/docs/feature-checklists/05-quick-actions-and-welcome.md new file mode 100644 index 0000000..c0680af --- /dev/null +++ b/docs/feature-checklists/05-quick-actions-and-welcome.md @@ -0,0 +1,23 @@ +# Feature 05: Quick Actions And Welcome + +## Goal + +Improve discovery and usability with better quick actions, a stronger welcome state, and candidate-inspired shortcuts into gifts, categories, checkout, and tracking. + +## Checklist + +- [ ] Review candidate welcome screens and quick-action patterns +- [ ] Decide which shortcuts belong on the empty state versus active chat state +- [ ] Add better quick-action chips for common shopping intents +- [ ] Improve the welcome state so it advertises core capabilities clearly +- [ ] Keep the new layout consistent with the current visual direction +- [ ] Verify prompts generated by quick actions still lead to cards or useful assistant text +- [ ] Run `npm run lint` +- [ ] Run `npm run build` +- [ ] Verify the flow in a live browser session +- [ ] Commit, push branch, and open draft PR + +## Notes + +- Candidate reference: `tmp_compare/kavi-kapruka/components/chat/QuickActions.tsx` +- Candidate reference: `tmp_compare/kavi-kapruka/components/chat/WelcomeScreen.tsx` diff --git a/docs/feature-checklists/06-multilingual-ui.md b/docs/feature-checklists/06-multilingual-ui.md new file mode 100644 index 0000000..4d079b2 --- /dev/null +++ b/docs/feature-checklists/06-multilingual-ui.md @@ -0,0 +1,24 @@ +# Feature 06: Multilingual UI + +## Goal + +Make the product feel more Sri Lanka-aware by adding localized UI copy and lightweight language-aware helper responses on top of the current backend language handling. + +## Checklist + +- [ ] Review candidate multilingual dictionaries and language-detection behavior +- [ ] Decide the minimum viable language surface for this app +- [ ] Add localized labels and helper copy for the main UI flows +- [ ] Keep language changes lightweight and avoid duplicating business logic +- [ ] Verify English remains the default fallback +- [ ] Add at least one focused test for language selection or copy mapping +- [ ] Run `npm run lint` +- [ ] Run `npm run build` +- [ ] Verify the flow in a live browser session +- [ ] Commit, push branch, and open draft PR + +## Notes + +- Candidate reference: `tmp_compare/Kapruka-Ai-Shopping-Agent/lib/i18n/index.js` +- Candidate reference: `tmp_compare/Kapruka-Ai-Shopping-Agent/lib/i18n/en.js` +- Candidate reference: `tmp_compare/Kapruka-Ai-Shopping-Agent/lib/i18n/si.js` diff --git a/docs/feature-checklists/07-reliability-and-debug-surface.md b/docs/feature-checklists/07-reliability-and-debug-surface.md new file mode 100644 index 0000000..ae105c2 --- /dev/null +++ b/docs/feature-checklists/07-reliability-and-debug-surface.md @@ -0,0 +1,25 @@ +# Feature 07: Reliability And Debug Surface + +## Goal + +Finish the candidate-inspired hardening work: predictable small-model defaults, provider fallback, and a minimal MCP debug surface for easier iteration and demos. + +## Checklist + +- [ ] Review candidate MCP route and rate-limiting/debug patterns +- [ ] Confirm which reliability work is already shipped versus still missing +- [ ] Add or refine a minimal MCP debug route if still valuable +- [ ] Add lightweight request hardening only where it helps the demo app +- [ ] Keep the solution small and avoid unnecessary platform complexity +- [ ] Verify provider fallback behavior against live or simulated failures +- [ ] Run backend tests relevant to provider selection and fallback +- [ ] Run `npm run lint` +- [ ] Run `npm run build` +- [ ] Verify the flow in a live browser session +- [ ] Commit, push branch, and open draft PR + +## Notes + +- Candidate reference: `tmp_compare/Kapruka-Ai-Shopping-Agent/app/api/mcp/route.js` +- Candidate reference: `tmp_compare/Kapruka-Ai-Shopping-Agent/lib/rate-limiter.js` +- Candidate reference: `tmp_compare/Kapruka-Ai-Shopping-Agent/lib/mcp/client.js` diff --git a/docs/feature-checklists/README.md b/docs/feature-checklists/README.md new file mode 100644 index 0000000..17ba3d4 --- /dev/null +++ b/docs/feature-checklists/README.md @@ -0,0 +1,17 @@ +# Feature Checklists + +This folder tracks the staged feature work for the Kapruka challenge app. + +## Planned iterations + +- `01-gift-advisor.md` - guided gift selection flow +- `02-order-tracking.md` - order tracking tool, prompt flow, and tracking card UI +- `03-pay-link-card.md` - post-checkout order summary and payment-link card +- `04-voice-mode.md` - browser-native voice input and spoken assistant replies +- `05-quick-actions-and-welcome.md` - richer quick actions, onboarding, and category-first prompts +- `06-multilingual-ui.md` - localized UI copy and language-aware helper flows +- `07-reliability-and-debug-surface.md` - provider fallback, MCP debug route, and light request hardening + +## Working rule + +Each file is a per-feature checklist. We update the relevant file as a feature moves from planned to shipped. diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 513e9f7..19682af 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -52,11 +52,15 @@ const suggestions = [ "Party supplies for 10", ]; +const TRACK_ORDER_PROMPT = "I want to track my order. Please ask me for the Kapruka order number if needed."; +const CATEGORY_PROMPT = "Show me Kapruka product categories"; + const chatActions = [ { label: "Gift advisor", kind: "advisor" as const }, { label: "Birthday gifts", prompt: "Find 5 birthday gift ideas on Kapruka under Rs. 5,000" }, { label: "Anniversary gifts", prompt: "Show anniversary gift ideas on Kapruka for my partner" }, - { label: "Show categories", prompt: "Show me Kapruka product categories" }, + { label: "Track order", prompt: TRACK_ORDER_PROMPT }, + { label: "Show categories", prompt: CATEGORY_PROMPT }, ]; function CartIcon({ className = "" }: { className?: string }) { @@ -411,7 +415,7 @@ export default function Home() {