diff --git a/INQUIRY_MODE_PLAN.md b/INQUIRY_MODE_PLAN.md new file mode 100644 index 0000000..6e565f3 Binary files /dev/null and b/INQUIRY_MODE_PLAN.md differ diff --git a/LEAD_CAPTURE.md b/LEAD_CAPTURE.md new file mode 100644 index 0000000..0664f5e --- /dev/null +++ b/LEAD_CAPTURE.md @@ -0,0 +1,472 @@ +# Lead Capture — Full Architecture + +How the conversational inquiry/lead-capture feature works end-to-end, from +admin configuration through widget interaction to email delivery. + +--- + +## 1. Overview + +The lead-capture feature lets any AI agent collect contact details from a +visitor in a natural multi-turn conversation and email them to the brand owner, +with no CRM required. It is controlled by a single per-agent toggle in the +Admin → Agent Studio → Skills panel ("Lead Capture · Via Email · No CRM"). + +When enabled, every product card in the widget shows a **"Send Inquiry for +Price →"** button. Clicking it — or typing a purchase-intent phrase like +"send inquiry" / "request a quote" — starts an automatic, deterministic +multi-turn flow that collects 7 required fields in two conversational batches, +shows a confirmation summary, and on approval sends an email to the configured +recipient. + +--- + +## 2. Files Involved + +| Layer | File | Responsibility | +|---|---|---| +| Admin UI | `apps/admin/src/components/AgentStudio/AgentCapabilityRail.tsx` | "Lead Capture · Via Email · No CRM" skill toggle | +| Admin UI | `apps/admin/src/components/AgentStudio/AgentConfigForm.tsx` | Recipient email, confirm-before-send, success message fields | +| Admin save | `apps/admin/src/utils/agentWizardPayload.ts` | Serialises `inquiry.*` fields into agent configuration payload | +| Public API | `apps/api/app/api/v1/endpoints/public.py` | Exposes `inquiry.enabled` in `/api/v1/public/agents/{id}` | +| Widget bootstrap | `apps/widget/src/App.tsx` | Reads `inquiry.enabled`, sets `showInquiry` state | +| Widget UI | `apps/widget/src/components/ProductCard.tsx` | Renders "Send Inquiry for Price →" button, calls `onSendInquiry` on click | +| Widget UI | `apps/widget/src/components/MessageBubble.tsx` | Passes `onSendInquiry` prop down to ProductCard | +| Widget UI | `apps/widget/src/components/ChatWindow.tsx` | Provides `onSendMessage` as the `onSendInquiry` implementation | +| Orchestration | `apps/api/app/services/message_service.py` | All inquiry orchestration: trigger detection, pending state, field parsing, tool wiring | +| Tool | `packages/tools/src/tools/builtin/inquiry_tool.py` | Field batching, validation, confirmation logic, callback invocation | +| Persistence | `apps/api/app/services/inquiry_service.py` | Saves inquiry doc to brand-isolated MongoDB collection | +| Delivery | `apps/api/app/services/inquiry_delivery_service.py` | Sends email via SMTP in a worker thread | +| Config | `apps/api/app/config.py` | SMTP_HOST/PORT/USERNAME/PASSWORD/FROM_EMAIL/USE_TLS, INQUIRY_FALLBACK_RECIPIENT | + +--- + +## 3. Configuration (Admin Panel) + +### Enable the skill + +1. Open **Agent Studio** for any agent. +2. In the **Skills** rail, click **"Lead Capture · Via Email · No CRM"** to toggle it on. +3. This sets `configuration.inquiry.enabled = true` in the agent's MongoDB document (system DB → `agents` collection). + +### Inquiry config fields stored + +```json +{ + "inquiry": { + "enabled": true, + "recipient_email": "owner@brand.com", + "confirm_before_send": true, + "success_message": "Your inquiry has been sent. Our team will reach out shortly!" + } +} +``` + +- **`recipient_email`** — where the email is sent. Falls back to `INQUIRY_FALLBACK_RECIPIENT` env var if empty. +- **`confirm_before_send`** — default `true`. When true the agent shows a summary and waits for the user to confirm before sending. +- **`success_message`** — shown to the user after successful delivery. + +--- + +## 4. Widget Bootstrap — How the Flag Reaches the Frontend + +``` +App.tsx (useEffect on agentId) + │ + ├─ GET /api/v1/public/agents/{agent_id} + │ └─ public.py: _public_agent_config() + │ reads configuration.inquiry → exposes {enabled, recipient_email, ...} + │ + ├─ setShowInquiry(agent.configuration.inquiry.enabled === true) + │ + └─ passes showInquiry={showInquiry} → ChatWindow → MessageBubble +``` + +The `inquiry` block is deliberately kept in the public config response so the +widget can show/hide the button without an authenticated request. + +--- + +## 5. Product Card Button + +**`apps/widget/src/components/ProductCard.tsx`** + +Two independent buttons are rendered: + +``` +┌────────────────────────┐ +│ Learn More → │ shown when product has a URL (ctaUrl) +│ Send Inquiry for Price│ shown when showInquiry=true AND onSendInquiry is provided +└────────────────────────┘ +``` + +When the user clicks **"Send Inquiry for Price →"**: + +```ts +onSendInquiry(`Send inquiry for price of ${displayProduct.name}`) +``` + +This calls `onSendMessage` in `ChatWindow`, which: +1. Appends the text as a user chat bubble (visible in the UI) +2. Sends it to the backend WebSocket / HTTP endpoint as a normal user message + +--- + +## 6. Backend — Turn 1: Trigger Detection + +Every incoming message passes through `MessageService.stream_message` (WebSocket, +used by the widget) or `MessageService.process_message` (HTTP). + +After the LLM planner runs, **`_adapt_turn_plan_for_inquiry`** is called: + +``` +_load_inquiry_pending_state(conversation_id) + ↓ reads most recent assistant message metadata from short-term memory + ↓ returns {} if no active inquiry (most-recent-first check) + ↓ +_adapt_turn_plan_for_inquiry(turn_plan, message, pending_state) + ↓ + If pending_state is empty AND message matches INQUIRY_TRIGGER_PATTERN: + → fresh trigger path (Turn 1) + → wipe bogus contact fields LLM may have extracted from trigger text + → extract product_name from trigger message ("Send inquiry for price of X" → "X") + → inject {"tool_id": "submit_inquiry"} into turn_plan.tool_plan + → returns modified turn_plan +``` + +**`INQUIRY_TRIGGER_PATTERN`** matches phrases like: +`send inquiry`, `send an inquiry`, `request a quote`, `get a quote`, +`want a quotation`, `contact sales`, `bulk order`, `want to buy`, +`have your team contact me`, `reach out to me` + +--- + +## 7. Backend — Turns 2+: In-Flight Continuation + +Once an inquiry is started, **every subsequent user message** is intercepted: + +``` +_load_inquiry_pending_state(conversation_id) + ↓ finds inquiry_pending in most-recent assistant message metadata + ↓ {resolved_inputs: {product_name, ...}, missing_input: [...], awaiting_confirmation: false} + ↓ +_adapt_turn_plan_for_inquiry(turn_plan, message, pending_state) + ↓ + pending_state is non-empty (active inquiry): + │ + ├─ INQUIRY_CANCEL_PATTERN matched ("cancel", "never mind", etc.) + │ → clear tool_plan, set action=clarify, cancel message, mark _inquiry_cancelled + │ + ├─ Parse user's message for the fields the tool last asked for: + │ _parse_inquiry_fields_from_message(message, pending_state["missing_input"]) + │ → email extracted by regex (unambiguous) + │ → phone extracted by regex (7+ digits required) + │ → remaining comma-separated parts assigned positionally to remaining fields + │ → merged into turn_plan.resolved_inputs alongside already-collected fields + │ + ├─ awaiting_confirmation=true AND INQUIRY_AFFIRM_PATTERN matched? + │ → set confirm=True in the submit_inquiry step input + │ + └─ inject/keep submit_inquiry in tool_plan, reset action to "ready" +``` + +**`INQUIRY_CANCEL_PATTERN`**: `cancel`, `never mind`, `forget it`, `stop this`, +`not now`, `don't send`, `do not send`, `skip this`, `no longer` + +**`INQUIRY_AFFIRM_PATTERN`**: `yes`, `yeah`, `yep`, `correct`, `confirmed`, +`go ahead`, `please send`, `send it`, `that's right`, `looks good`, `sounds good`, `okay` + +--- + +## 8. Tool Registration + +At agent config load time (`_register_configured_capabilities`): + +```python +if inquiry_config.get("enabled"): + inquiry_tool = InquiryTool( + brand_slug=self.brand_id, + brand_id=..., + agent_id=..., + conversation_id=None, # set per-turn by _configure_inquiry_tool_for_turn + agent_config=config, + submit_callback=self._submit_inquiry, # bound to MessageService instance + ) + self.tool_registry.register(inquiry_tool) +``` + +Per-turn, `_configure_inquiry_tool_for_turn` sets: +- `tool.conversation_id` = current conversation ID +- `tool.active_product_focus` = product currently being discussed (from session_state) + +--- + +## 9. Tool Execution — `inquiry_tool.py` + +`InquiryTool.run(payload, confirm)` is called by `_execute_planner_tool_plan` +with the merged payload (remembered_inputs + parsed fields from this turn). + +### Field collection state machine + +``` +run() called with merged payload +│ +├─ ALL_MISSING = [f for f in REQUIRED_FIELDS if value is empty] +│ +├─ all_missing non-empty? +│ │ +│ ├─ only product_name missing → ask for product_name (auto-fill failed) +│ │ +│ ├─ BATCH_1 (name, phone) not yet done? +│ │ → return ToolResult(success=False, missing_input=["name","phone"]) +│ │ +│ └─ BATCH_1 done, BATCH_2 (email, city, country, query) incomplete? +│ → return ToolResult(success=False, missing_input=[remaining BATCH_2 fields]) +│ +├─ all fields present → validate email format + phone format +│ → if invalid: return ToolResult(success=False, invalid_fields=[...]) +│ +├─ confirm_before_send=true AND confirm=false? +│ → return ToolResult(success=True, data="AWAITING_CONFIRMATION", +│ metadata={awaiting_confirmation:true, summary:{...}}) +│ +└─ all fields valid + confirmed (or confirm_before_send=false) + → call submit_callback(merged_fields, agent_config) + → returns ToolResult(success=True, data=success_message, + metadata={inquiry_submitted:true, ...}) +``` + +### Required fields (7 total) + +| Field | Batch | Never asked from user? | +|---|---|---| +| `product_name` | Auto | Yes — auto-filled from trigger message / active_product_focus | +| `name` | Batch 1 | No | +| `phone` | Batch 1 | No | +| `email` | Batch 2 | No | +| `city` | Batch 2 | No | +| `country` | Batch 2 | No | +| `query` | Batch 2 | No | + +--- + +## 10. LLM Response Generation + +After the tool runs, `_generate_planner_agent_result` synthesises the user-facing +response. The system prompt includes hard rules: + +- If `missing_input` list in metadata → **ask for exactly those fields, nothing else** +- If `awaiting_confirmation: true` → **show summary, ask "Shall I send this?"** +- If `inquiry_submitted: true` → **confirm sent, team will follow up** + +The claim-evidence guard (`response_validator.py`) is tuned to pass +inquiry-related responses (tool-reported structured state is counted as evidence, +bare list markers / introductory lines / quoted example text are excluded from +factual-claim detection). + +--- + +## 11. Pending State Persistence + +After each turn, the inquiry state is saved into the assistant message's +short-term memory metadata so the next turn can resume: + +```python +# metadata written to short_term.add_message(..., metadata={...}) +"inquiry_pending": { + "resolved_inputs": {"product_name": "X", "name": "Y", "phone": "Z", ...}, + "missing_input": ["email", "city", "country", "query"], + "awaiting_confirmation": False +} +``` + +On the next turn, `_load_inquiry_pending_state` reads the **most recent +assistant message** only. If that message has no `inquiry_pending` (i.e. the +agent replied to something else in between), the inquiry is considered +interrupted and returns `{}` — preventing an old abandoned inquiry from +hijacking unrelated future messages. + +Terminal markers: +- `{"submitted": True}` — inquiry was sent; never resume +- `{"cancelled": True}` — user cancelled; never resume + +--- + +## 12. Submission — `_submit_inquiry` in MessageService + +```python +async def _submit_inquiry(self, payload: dict, agent_config: dict) -> dict: + # 1. Persist to MongoDB (brand-isolated DB) + inquiry_doc = await self.inquiry_service.save_inquiry( + brand_slug=..., contact={name, phone, email, city, country}, + requirements=payload["query"], + product_context={name: payload["product_name"], ...} + ) + + # 2. Deliver (SMTP email, non-blocking thread) + delivery = await self.inquiry_delivery_service.send(inquiry_doc, agent_config) + + # 3. Mark delivered or failed in MongoDB + if delivery.success: + await self.inquiry_service.mark_delivered(...) + else: + await self.inquiry_service.mark_failed(...) + + return {"success": delivery.success, "inquiry_id": ..., "delivered": delivery.success} +``` + +--- + +## 13. Email Delivery — `InquiryDeliveryService` + +``` +_send_email() runs in asyncio.to_thread (non-blocking, 15s timeout) +│ +├─ recipient = agent_config.inquiry.recipient_email +│ OR env var INQUIRY_FALLBACK_RECIPIENT +│ +├─ subject = "New Inquiry: {product_name} — {brand_slug}" +│ +├─ body (plain text): +│ Product: {name} (SKU: {sku}) +│ Name / Phone / Email / City / Country +│ Query: {requirements} +│ Inquiry ID / Conversation ID / Brand +│ +└─ sent via smtplib.SMTP → STARTTLS → login → sendmail +``` + +SMTP settings come from env vars: +``` +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USERNAME=sender@gmail.com +SMTP_PASSWORD=app-password +SMTP_FROM_EMAIL=sender@gmail.com +SMTP_USE_TLS=true +INQUIRY_FALLBACK_RECIPIENT=fallback@brand.com +``` + +--- + +## 14. MongoDB Persistence — `InquiryService` + +Inquiries are stored in the **brand's own database** (brand-isolated): + +``` +MongoDB Atlas +└── {brand-slug}/ ← brand-isolated database + └── inquiries ← collection + └── { + inquiry_id: uuid, + brand_id, brand_slug, agent_id, conversation_id, + status: "submitted" | "delivered" | "delivery_failed", + product_context: { name, sku }, + contact: { name, phone, email, city, country }, + requirements: "user's query text", + delivery: { method, delivered_at, error }, + created_at, updated_at + } +``` + +Indexes: `inquiry_id` (unique), `(agent_id, created_at)`, `conversation_id` + +--- + +## 15. Complete Turn-by-Turn Flow (Example) + +``` +User clicks "Send Inquiry for Price →" on "Denon Home 150 Wireless Speaker" card + │ + ▼ +Widget sends: "Send inquiry for price of Denon Home 150 Wireless Speaker" + │ + ▼ Turn 1 — Trigger +INQUIRY_TRIGGER_PATTERN matches → fresh trigger +product_name extracted from message text → "Denon Home 150 Wireless Speaker" +submit_inquiry called: BATCH_1 fields missing + ← Agent: "Please share your Name and Phone number." + + ▼ Turn 2 — Batch 1 collection +User: "Rishabh, 9140000987" +_parse_inquiry_fields_from_message → {name: "Rishabh", phone: "9140000987"} +merged into resolved_inputs with product_name +submit_inquiry called: BATCH_1 done, BATCH_2 fields missing + ← Agent: "Please share your Email, City, Country, and Query." + + ▼ Turn 3 — Batch 2 collection +User: "rishabh@gmail.com, Delhi, India, want pricing for 10 units" +_parse_inquiry_fields_from_message → {email, city, country, query} +all 7 fields collected → confirm_before_send=true, confirm=false +submit_inquiry returns AWAITING_CONFIRMATION with summary + ← Agent: "Here are your details — [summary]. Shall I send this?" + + ▼ Turn 4 — Confirmation +User: "yes send it" +INQUIRY_AFFIRM_PATTERN matches → confirm=True injected +submit_inquiry called with confirm=True + → _submit_inquiry: + inquiry_service.save_inquiry() → MongoDB + inquiry_delivery_service.send() → SMTP email (asyncio.to_thread) + inquiry_service.mark_delivered() + ← Agent: "Your inquiry has been sent. Our team will reach out shortly!" +``` + +--- + +## 16. Failure Handling + +| Failure | Behaviour | +|---|---| +| SMTP not configured | Returns DeliveryResult(success=False, error="SMTP not configured") | +| SMTP timeout (>15s) | Caught by asyncio.wait_for, returns timeout error | +| SMTP error (auth/network) | Caught, inquiry still persisted in MongoDB with status=delivery_failed | +| No recipient email | Returns error "No recipient_email configured" | +| User cancels mid-flow | INQUIRY_CANCEL_PATTERN detected → `{cancelled:true}` persisted, never resumes | +| User abandons (changes topic) | Next product-search response has no `inquiry_pending` → `_load_inquiry_pending_state` returns `{}` → flow not resumed | + +--- + +## 17. Cancellation + +If the user types any cancel phrase at any point in the flow: + +``` +User: "cancel" / "never mind" / "forget it" / "don't send" / "stop this" + │ + ▼ +INQUIRY_CANCEL_PATTERN matched in _adapt_turn_plan_for_inquiry + → turn_plan.tool_plan = [] + → action = "clarify" + → response_text = "No problem, I've cancelled that inquiry." + → raw_plan["_inquiry_cancelled"] = True + → short_term memory stores: inquiry_pending = {cancelled: True} + → _load_inquiry_pending_state returns {} on every subsequent turn +``` + +--- + +## 18. Key Design Decisions + +**Why model-agnostic field parsing?** +The LLM planner inconsistently extracts contact fields from the user's reply +depending on the model. `_parse_inquiry_fields_from_message` uses regex + positional +comma-splitting so field collection works reliably regardless of which LLM model +the agent uses. + +**Why deterministic tool injection?** +The LLM planner independently decides each turn whether to call `submit_inquiry`. +`_adapt_turn_plan_for_inquiry` overrides that decision deterministically whenever +an inquiry is in flight — the same pattern the codebase uses for Lal Kitab +chart-first flows. This prevents the "silent drop" where the LLM acknowledges +user input without calling the tool. + +**Why most-recent-assistant-message check?** +If a user abandoned an inquiry and started a new product search, scanning all +8 recent messages would find the old `inquiry_pending` and hijack the new query. +Only checking the most recent assistant message means the flow is considered +interrupted the moment any non-inquiry response is stored. + +**Why brand-isolated MongoDB?** +Inquiries are PII. Storing them in the brand's own DB (not a shared collection) +ensures complete data isolation — no cross-brand leakage, easier GDPR deletion. diff --git a/LEAD_CAPTURE_V2_PLAN.md b/LEAD_CAPTURE_V2_PLAN.md new file mode 100644 index 0000000..9c892ab --- /dev/null +++ b/LEAD_CAPTURE_V2_PLAN.md @@ -0,0 +1,408 @@ +# Lead Capture V2 — Architecture Plan + +Upgrade the current conversational batch-collection flow to: +1. An in-widget structured form (no PII ever touches the LLM) +2. LLM-level intent intelligence (knows when to open the form vs show products) +3. Post-inquiry context reset (knows when the inquiry is done and to resume normal mode) + +--- + +## Problems with V1 (Current) + +| Problem | Root cause | +|---|---| +| After inquiry sent, next product query returns "not enough verified info" | `inquiry_pending = {submitted: true}` in last assistant message — `_load_inquiry_pending_state` returns `{}` correctly, but the agent still has no "normal mode" signal — it can end up in a partially-cleared state where the response_validator or planner still sees inquiry context from the prompt history | +| LLM doesn't decide intelligently to open inquiry vs show products | Harness uses `INQUIRY_TRIGGER_PATTERN` (regex) only — no LLM judgment about user intent | +| PII (name, phone, email) flows through the LLM writer | `_parse_inquiry_fields_from_message` → `resolved_inputs` → `evidence` JSON in `_generate_planner_agent_result` prompt | +| Conversational batch collection is fragile across models | Regex heuristic parser works but has edge cases; multiple turns to collect 6 fields is friction | +| No field-level input validation on frontend | Email regex, phone digit check is backend-only — user gets error only after submitting | + +--- + +## What We Already Have (Keep As-Is) + +- `InquiryService` — MongoDB persistence (brand-isolated) ✅ +- `InquiryDeliveryService` — SMTP email in worker thread ✅ +- `_submit_inquiry` in `message_service.py` — save + deliver callback ✅ +- `InquiryTool` registered per-agent when `inquiry.enabled=true` ✅ +- Admin panel toggle (Lead Capture · Via Email · No CRM) ✅ +- `inquiry.enabled` exposed in public agent config ✅ +- Product card "Request a Quote →" button → `onSendInquiry` callback ✅ +- `INQUIRY_TRIGGER_PATTERN` for regex-based trigger detection ✅ + +--- + +## V2 Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ WIDGET (Frontend) │ +│ │ +│ ┌──────────────────┐ ┌──────────────────────────────────────┐ │ +│ │ Chat messages │ │ InquiryFormModal │ │ +│ │ │ │ ┌──────────┐ ┌───────────────────┐│ │ +│ │ [product cards] │ │ │ Name * │ │ Phone * (10 dig) ││ │ +│ │ [agent replies] │ │ ├──────────┤ ├───────────────────┤│ │ +│ │ │ │ │ Email * │ │ Country (select) ││ │ +│ └──────────────────┘ │ ├──────────┤ ├───────────────────┤│ │ +│ │ │ City * │ │ Query ││ │ +│ [Request a Quote →]─────►│ └──────────┘ └───────────────────┘│ │ +│ │ │ │ +│ Agent intent signal ────►│ [Submit] [Skip] │ │ +│ (openInquiryForm event) └──────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ + │ Submit (fields only, never via LLM) + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ POST /api/v1/inquiry/submit │ +│ (new dedicated endpoint — no LLM involved) │ +│ │ +│ inquiry_service.save_inquiry() │ +│ inquiry_delivery_service.send() │ +│ return {success, inquiry_id} │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ + Email delivered to brand owner + Inquiry persisted in brand MongoDB +``` + +--- + +## Part 1 — LLM Intent Intelligence + +### Problem +After inquiry is submitted, "show me amplifiers" gets the low-confidence error +because the response_validator still sees `inquiry_submitted:true` context from +the previous turn in the evidence chain. + +### Fix A — Post-Inquiry Context Reset (Immediate, Small) + +In `_load_inquiry_pending_state`, when it finds `{submitted: true}`, also set +a `post_inquiry_reset` flag on the `MessageService` instance so `_adapt_turn_plan_for_inquiry` +knows: **do not inject submit_inquiry, do not filter tool plan, treat next turn as fully normal**. + +Additionally, after a submitted inquiry, write a lightweight marker into +short-term memory that the session is back in "product assist" mode. + +**File:** `apps/api/app/services/message_service.py` +**Change:** 3 lines in `_load_inquiry_pending_state` + 1 line in `_adapt_turn_plan_for_inquiry` + +### Fix B — LLM Intent Signal (New, Medium) + +Instead of only using `INQUIRY_TRIGGER_PATTERN` (regex), add a second path where +the LLM planner itself can signal intent `"open_inquiry_form"` as an action type. + +The agent's system prompt gains one new rule: + +``` +When the user expresses any of these intents — wants pricing details, requests a +quotation, wants to talk to a human / customer support, wants to know availability, +says "contact me", "call me", "follow up" — set action="open_inquiry_form" in +your plan. Do NOT try to collect fields yourself. The widget will show the form. + +When the user is searching for products, comparing options, or asking general +questions — set action="ready" and answer normally. +``` + +The planner output `action="open_inquiry_form"` is caught in +`_adapt_turn_plan_for_inquiry` (no LLM text generation needed — just a signal). +The API response includes `metadata.open_inquiry_form = true` and the widget +opens the form directly. + +**Files:** +- `apps/api/app/services/agent_turn_planner.py` — add `open_inquiry_form` to valid actions +- `apps/api/app/services/message_service.py` — handle the new action in `_adapt_turn_plan_for_inquiry`, include `open_inquiry_form` flag in streaming metadata +- `apps/widget/src/components/ChatWindow.tsx` — listen for `open_inquiry_form` in message metadata and open the modal + +### Intent decision table (what LLM learns) + +| User says | LLM action | +|---|---| +| "show me speakers" | `ready` — product search | +| "what is the price of X" | `ready` — RAG retrieval | +| "request a quote for X" | `open_inquiry_form` | +| "I want to buy this" | `open_inquiry_form` | +| "can someone call me" | `open_inquiry_form` | +| "contact sales" | `open_inquiry_form` | +| "I need bulk pricing" | `open_inquiry_form` | +| "after inquiry: show me amplifiers" | `ready` — post-inquiry reset active | + +--- + +## Part 2 — In-Widget Structured Form (No PII to LLM) + +### Why a Form Instead of Conversational Collection + +| | Current (Conversational) | V2 (Form) | +|---|---|---| +| PII exposure to LLM | Name/phone/email in LLM prompt evidence | Never — form submits directly to API | +| Validation | Backend only, error after submit | Frontend: phone digits-only, email regex, required fields | +| Friction | 4 chat turns (trigger + 2 batches + confirm) | 1 interaction: fill + submit | +| Country input | Free text, parsing errors | Dropdown (ISO 3166-1 country list) | +| User abandonment | User must type cancel | Skip button — closes form, resumes chat | + +### New Component: `InquiryFormModal` + +**File:** `apps/widget/src/components/InquiryFormModal.tsx` (new) + +```tsx +interface InquiryFormModalProps { + isOpen: boolean + productName: string // pre-filled from click context, not editable + agentId: string + conversationId: string + apiUrl: string + onSuccess: (message: string) => void // shows success in chat + onSkip: () => void // closes form, no action +} +``` + +### Form Fields with Validation + +| Field | Type | Frontend Validation | Required | +|---|---|---|---| +| Product Name | Text (read-only, pre-filled) | — | Auto | +| Name | Text | Non-empty | Yes | +| Phone | Text | Digits + `+`, `-`, `()`, space only; 7–15 digits; no letters | Yes | +| Email | Email input | Native `type="email"` + regex `/.+@.+\..+/` | Yes | +| City | Text | Non-empty | Yes | +| Country | Select dropdown (ISO country list) | Must select | Yes | +| Query | Textarea | Optional — shows "Submit" if empty, no block | No | + +### Buttons + +- **Submit** — enabled only when Name, Phone, Email, City, Country are all valid. Disabled + grey otherwise. +- **Skip** — always visible. Closes the modal, appends a soft message to chat: "No problem! Let me know if you need anything else." + +### Form Submission (Direct to API — No LLM) + +On Submit: + +```ts +POST /api/v1/inquiry/submit +Content-Type: application/json + +{ + "agent_id": "...", + "conversation_id": "...", + "product_name": "Denon Home 150", + "name": "Rishabh", // PII never touches LLM + "phone": "9140000987", + "email": "r@example.com", + "city": "Delhi", + "country": "India", + "query": "want pricing for 10 units" +} +``` + +Response: `{ success: true, message: "Your inquiry has been sent..." }` + +The widget then: +1. Closes the modal +2. Appends the success message as an assistant chat bubble +3. Continues in normal product-search mode + +--- + +## Part 3 — New API Endpoint + +**File:** `apps/api/app/api/v1/endpoints/inquiry.py` (new) + +``` +POST /api/v1/inquiry/submit + ├─ Validate required fields (Pydantic schema) + ├─ Resolve brand_slug from agent_id + ├─ inquiry_service.save_inquiry(...) + ├─ inquiry_delivery_service.send(...) + └─ return {success, inquiry_id, message} +``` + +No LLM involved. No authentication required (public endpoint, widget session +token validated same as `/api/v1/messages/ws`). + +**No change to `InquiryTool`, `InquiryService`, or `InquiryDeliveryService`** — +the endpoint just calls them directly, bypassing the tool layer. + +--- + +## Part 4 — What Changes in the Backend (message_service.py) + +### Remove from `_adapt_turn_plan_for_inquiry` +- `_parse_inquiry_fields_from_message` — no longer needed (form collects fields) +- `_BATCH_1` / `_BATCH_2` logic in `inquiry_tool.py` — no longer needed +- `INQUIRY_AFFIRM_PATTERN` — no longer needed (no confirmation turn) +- `_collect_inquiry_pending_state` / `_load_inquiry_pending_state` for field state — replaced by form submit + +### Keep +- `INQUIRY_TRIGGER_PATTERN` — regex fallback for typed triggers (not all users click the button) +- `_adapt_turn_plan_for_inquiry` — still handles typed triggers, now sets `open_inquiry_form=True` in metadata instead of calling the tool +- Post-inquiry reset logic — clears state after form submission so next turn is normal + +### New: Post-Submit Reset Signal + +When `POST /api/v1/inquiry/submit` succeeds, the widget sets a local flag +`inquiryJustCompleted = true`. On the very next WebSocket message, the API +includes `"inquiry_just_completed": true` in the request metadata. The +message_service reads this and: +1. Does NOT call `_adapt_turn_plan_for_inquiry` +2. Clears any `inquiry_pending` from session state +3. Processes the message as a completely normal turn + +--- + +## Part 5 — Files to Create / Modify + +### New files +| File | What | +|---|---| +| `apps/widget/src/components/InquiryFormModal.tsx` | The form component | +| `apps/widget/src/components/CountrySelect.tsx` | Reusable ISO country dropdown | +| `apps/widget/src/styles/inquiry-form.css` | Form styles matching widget theme | +| `apps/api/app/api/v1/endpoints/inquiry.py` | `POST /api/v1/inquiry/submit` endpoint | + +### Modified files +| File | Change | +|---|---| +| `apps/widget/src/components/ChatWindow.tsx` | Import + control `InquiryFormModal`, pass `productName` on trigger, handle `open_inquiry_form` metadata signal | +| `apps/widget/src/components/MessageBubble.tsx` | Pass `open_inquiry_form` metadata signal up to ChatWindow | +| `apps/widget/src/components/ProductCard.tsx` | "Request a Quote →" now calls `onOpenInquiryForm(productName)` instead of `onSendInquiry(message)` | +| `apps/widget/src/App.tsx` | Wire `showInquiry` to `InquiryFormModal` open/close state | +| `apps/api/app/services/message_service.py` | Add post-inquiry reset, handle `open_inquiry_form` action, remove batch-collection harness | +| `apps/api/app/services/agent_turn_planner.py` | Add `open_inquiry_form` to valid actions list | +| `apps/api/app/api/v1/router.py` | Register new inquiry router | +| `apps/api/app/api/v1/endpoints/__init__.py` | Export inquiry router | + +### Unchanged files (keep exactly as-is) +- `packages/tools/src/tools/builtin/inquiry_tool.py` +- `apps/api/app/services/inquiry_service.py` +- `apps/api/app/services/inquiry_delivery_service.py` +- `apps/api/app/config.py` (SMTP settings unchanged) +- Admin panel components (toggle, config form) + +--- + +## Part 6 — Complete V2 Flow (Example) + +``` +── Scenario A: Button click ────────────────────────────────────────── + +User clicks "Request a Quote →" on "Denon Home 150 Wireless Speaker" + │ + ▼ +ProductCard calls onOpenInquiryForm("Denon Home 150 Wireless Speaker") + │ + ▼ +ChatWindow opens InquiryFormModal(productName="Denon Home 150 Wireless Speaker") + │ + ▼ +User fills: Name, Phone, Email, City, Country, Query (optional) + │ + ├─ Phone validated frontend: digits only, 7-15 digits + ├─ Email validated frontend: must contain @ and . + ├─ Country: dropdown selection + └─ Submit button: enabled only when required fields valid + │ + ▼ +POST /api/v1/inquiry/submit {name, phone, email, city, country, query, product_name, agent_id, conversation_id} + │ ← PII never sent to LLM + ▼ +inquiry_service.save_inquiry() → MongoDB (brand-isolated) +inquiry_delivery_service.send() → SMTP email (asyncio.to_thread) + │ + ▼ +Widget: closes modal, appends assistant bubble: + "Your inquiry has been sent. Our team will reach out to you shortly!" + │ + ▼ +Next user message: "show me amplifiers" + → inquiryJustCompleted flag → post-inquiry reset → normal product search + → agent shows product cards ✅ + +── Scenario B: Typed trigger ───────────────────────────────────────── + +User types: "I need bulk pricing for 50 units" + │ + ▼ +INQUIRY_TRIGGER_PATTERN matches OR LLM planner sets action="open_inquiry_form" + │ + ▼ +API response includes metadata: {open_inquiry_form: true, product_name: "last_discussed_product"} + │ + ▼ +Widget receives → opens InquiryFormModal + [same flow as Scenario A from here] + +── Scenario C: Skip ────────────────────────────────────────────────── + +User opens form → clicks [Skip] + │ + ▼ +Modal closes, no submission +Widget appends: "No problem! Let me know if you need anything else." +Conversation continues normally + +── Scenario D: Post-inquiry product search ─────────────────────────── + +[inquiry just sent successfully] +User: "show me amplifiers" + │ + ▼ +inquiryJustCompleted=true in request metadata +message_service: skip _adapt_turn_plan_for_inquiry entirely +Run normal retrieval pipeline → return product cards ✅ +``` + +--- + +## Part 7 — PII Architecture (Key Change from V1) + +``` +V1 (Current): + User types "Rishabh, 9140000987" + → _parse_inquiry_fields_from_message extracts name/phone + → goes into turn_plan.resolved_inputs + → goes into evidence JSON in _generate_planner_agent_result prompt + → LLM sees "name: Rishabh, phone: 9140000987" in context ← PII in LLM + +V2 (New): + User fills form in widget + → POST /api/v1/inquiry/submit (fields only) + → inquiry_service.save_inquiry() (MongoDB) + → inquiry_delivery_service.send() (SMTP) + → widget shows success message (hardcoded, not LLM-generated) + → LLM never sees name/phone/email/city/country ← No PII in LLM +``` + +The LLM only ever knows: "an inquiry was submitted for Product X". It never +sees the contact details. + +--- + +## Part 8 — Implementation Order + +1. **Fix post-inquiry reset** (`message_service.py`, ~10 lines) — fixes the immediate screenshot bug (product search fails after inquiry) +2. **New API endpoint** (`inquiry.py`, ~50 lines) — the direct submit path +3. **`InquiryFormModal` component** (`InquiryFormModal.tsx`, ~200 lines) — the form UI with validation +4. **`CountrySelect` component** (`CountrySelect.tsx`, ~50 lines) — ISO dropdown +5. **Wire form into ChatWindow/ProductCard** — replace `onSendInquiry` with `onOpenInquiryForm` +6. **LLM intent signal** (`agent_turn_planner.py` + `message_service.py`) — `open_inquiry_form` action +7. **Remove V1 batch harness** (`inquiry_tool.py`, `message_service.py`) — clean up batch/regex collection code + +Steps 1-2 can be done independently. Steps 3-5 are the main UI work. Steps 6-7 are polish. + +--- + +## Part 9 — What This Solves vs V1 + +| Issue | V1 | V2 | +|---|---|---| +| Product search fails after inquiry | ❌ Low-confidence fallback | ✅ Post-inquiry reset clears state | +| PII in LLM prompt | ❌ name/phone/email in evidence | ✅ Never — form submits direct to API | +| Field validation | ❌ Backend only | ✅ Frontend: phone regex, email type, country dropdown | +| Collection friction | ❌ 4 conversational turns | ✅ 1 form interaction | +| Country input errors | ❌ Free text | ✅ ISO dropdown | +| Intent intelligence | ❌ Regex only | ✅ Regex + LLM planner `open_inquiry_form` action | +| Skip / abandon | ❌ Must type "cancel" | ✅ Skip button, always visible | +| Multi-model reliability | ❌ Depends on LLM extracting fields | ✅ Form — model-agnostic | diff --git a/apps/admin/src/api/errorHandler.ts b/apps/admin/src/api/errorHandler.ts index ca63776..24e2fa8 100644 --- a/apps/admin/src/api/errorHandler.ts +++ b/apps/admin/src/api/errorHandler.ts @@ -36,7 +36,20 @@ export function handleApiError(error: unknown): ApiError { // Server returned an error response const status = axiosError.response?.status || 500; - const detail = axiosError.response?.data?.detail || axiosError.response?.data?.message; + const rawDetail = axiosError.response?.data?.detail || axiosError.response?.data?.message; + // FastAPI/Pydantic validation errors (422) return detail as an array of + // {loc, msg, type} objects, not a plain string — flatten those into a + // readable message instead of losing the real error. + const detail = Array.isArray(rawDetail) + ? rawDetail + .map((item: any) => { + if (typeof item === 'string') return item; + const field = Array.isArray(item?.loc) ? item.loc.join('.') : item?.loc; + return field ? `${field}: ${item?.msg}` : item?.msg; + }) + .filter(Boolean) + .join('; ') + : rawDetail; switch (status) { case 400: diff --git a/apps/admin/src/components/AgentStudio/AgentCapabilityRail.tsx b/apps/admin/src/components/AgentStudio/AgentCapabilityRail.tsx index c63c11a..6b12a97 100644 --- a/apps/admin/src/components/AgentStudio/AgentCapabilityRail.tsx +++ b/apps/admin/src/components/AgentStudio/AgentCapabilityRail.tsx @@ -562,7 +562,38 @@ export default function AgentCapabilityRail({ data, onChange, agentId }: AgentSt
} title="Skills"> - {filteredSkills.length === 0 ? ( + {/* Inquiry-based lead capture — built-in, no CRM required */} + {(!query || ['lead', 'capture', 'email', 'inquiry', 'conversational'].some(kw => kw.includes(query))) && ( +
onChange('inquiry_enabled', !data.inquiry_enabled)} + onKeyDown={(e) => e.key === 'Enter' && onChange('inquiry_enabled', !data.inquiry_enabled)} + className={`block w-full cursor-pointer rounded-md border px-3 py-3 text-left transition ${ + data.inquiry_enabled + ? 'border-primary-600 bg-primary-50 text-gray-900' + : 'border-gray-200 bg-white text-gray-900 hover:border-gray-300 hover:bg-gray-50' + }`} + > +
+
+

Lead Capture

+

+ Via Email · No CRM +

+
+ + {data.inquiry_enabled ? 'Selected' : 'Available'} + +
+
+ )} + {/* Registry skills (including the CRM-based Lead Capture skill) */} + {filteredSkills.length === 0 && query ? (
No skills found.
) : filteredSkills.map((skill) => ( + + {data.inquiry_enabled && ( +
+
+
+

Lead Capture Settings

+

+ Configure where captured inquiries are sent via email. +

+
+ + Active + +
+
+ + onChange('inquiry_recipient_email', event.target.value)} + className={inputClass} + placeholder="owner@yourbrand.com" + /> + + + onChange('inquiry_confirm_before_send', data.inquiry_confirm_before_send === false)} + label="Confirm before sending" + description="Agent shows a summary of the captured details and waits for explicit confirmation before submitting." + /> + + + onChange('inquiry_success_message', event.target.value)} + className={inputClass} + placeholder="Your inquiry has been sent to our team. They'll reach out shortly." + /> + +
+
+ )} ); } diff --git a/apps/admin/src/components/AgentStudio/types.ts b/apps/admin/src/components/AgentStudio/types.ts index 1c71cc8..c605ee6 100644 --- a/apps/admin/src/components/AgentStudio/types.ts +++ b/apps/admin/src/components/AgentStudio/types.ts @@ -159,6 +159,11 @@ export interface AgentStudioData { api_data_source_usage: string; context_connectors?: ContextConnector[]; url_context_boost_enabled: boolean; + /** Conversational inquiry capture (send-inquiry workflow for no-fixed-price products). */ + inquiry_enabled?: boolean; + inquiry_recipient_email?: string; + inquiry_confirm_before_send?: boolean; + inquiry_success_message?: string; /** Per-agent chat artifact settings keyed by artifact type id * (e.g. { kundali_chart: { enabled: true } }). */ artifacts_config?: Record }>; diff --git a/apps/admin/src/components/KnowledgeBase/DocumentUploadWizard.tsx b/apps/admin/src/components/KnowledgeBase/DocumentUploadWizard.tsx index 56b6566..7497434 100644 --- a/apps/admin/src/components/KnowledgeBase/DocumentUploadWizard.tsx +++ b/apps/admin/src/components/KnowledgeBase/DocumentUploadWizard.tsx @@ -90,9 +90,21 @@ export default function DocumentUploadWizard({ let missingFields: string[] = []; if (contentType === 'product') { - const requiredFields = ['sku', 'name', 'price', 'currency', 'category']; - missingFields = requiredFields.filter(field => !firstItem[field]); - + // price/currency are only required if the item actually has a price. + // Inquiry-based catalogs (no fixed pricing) intentionally omit both — + // those products render "Send Inquiry" in the widget instead of a price. + const alwaysRequiredFields = ['sku', 'name', 'category']; + missingFields = alwaysRequiredFields.filter(field => !firstItem[field]); + + const hasPrice = firstItem.price !== undefined && firstItem.price !== null; + const hasCurrency = firstItem.currency !== undefined && firstItem.currency !== null && firstItem.currency !== ''; + if (hasPrice && !hasCurrency) { + missingFields.push('currency'); + } + if (hasCurrency && !hasPrice) { + missingFields.push('price'); + } + // Check if optional fields need defaults if (firstItem.in_stock === undefined) { console.warn('[Upload] in_stock missing, will default to true'); @@ -158,10 +170,16 @@ export default function DocumentUploadWizard({ } catch (error: any) { console.error('Upload failed:', error); - // Extract detailed error message from backend + // Extract detailed error message from backend. + // Note: apiClient's response interceptor wraps raw Axios errors into an + // ApiError (see api/errorHandler.ts) *before* this catch block sees them, + // so error.response is usually undefined here — the real backend detail + // lives on error.details instead. let errorMessage = 'Upload failed'; - - if (error.response?.data?.detail) { + + if (error.details) { + errorMessage = error.details; + } else if (error.response?.data?.detail) { // Backend returned detailed error (e.g., "Item 1: Missing required product fields: currency") errorMessage = error.response.data.detail; } else if (error.message) { diff --git a/apps/admin/src/components/KnowledgeBase/JsonFieldMapper.tsx b/apps/admin/src/components/KnowledgeBase/JsonFieldMapper.tsx index a79e0d8..2871c2f 100644 --- a/apps/admin/src/components/KnowledgeBase/JsonFieldMapper.tsx +++ b/apps/admin/src/components/KnowledgeBase/JsonFieldMapper.tsx @@ -36,6 +36,10 @@ export default function JsonFieldMapper({ const [mapping, setMapping] = useState({}); const [previewData, setPreviewData] = useState([]); const [errors, setErrors] = useState([]); + // Inquiry-based brands have no fixed catalog prices — customers must send an inquiry. + // When enabled, price/currency become skippable instead of hard-required. + const [inquiryBased, setInquiryBased] = useState(false); + const skippableWhenInquiryBased = ['price', 'currency']; // Required fields for each content type const requiredFields = contentType === 'product' @@ -56,7 +60,8 @@ export default function JsonFieldMapper({ const optionalFields = contentType === 'product' ? { image_url: { type: 'string', description: 'Product image URL' }, - product_url: { type: 'string', description: 'Product page URL' }, + product_url: { type: 'string', description: 'Official product page URL (title links here)' }, + inquiry_url: { type: 'string', description: 'Inquiry/quote-request form URL — used by "Send Inquiry" button. Use Fixed Value for one shared form.' }, in_stock: { type: 'boolean', description: 'Stock availability (true/false)' }, features: { type: 'array', description: 'Array of feature strings' }, } @@ -109,7 +114,7 @@ export default function JsonFieldMapper({ price: ['amount', 'cost', 'product_price', 'productPrice', 'retail_price', 'retailPrice', 'retail_price_cents'], currency: ['curr', 'currency_code', 'currencyCode', 'price_currency', 'priceCurrency'], category: ['cat', 'product_category', 'productCategory', 'product_type', 'productType', 'item_category', 'type'], - image_url: ['image', 'img', 'imageUrl', 'product_image', 'productImage', 'thumbnail'], + image_url: ['image', 'img', 'imageUrl', 'product_image', 'productImage', 'thumbnail', 'featured_media', 'featured_image', 'jetpack_featured_media_url'], product_url: ['url', 'link', 'productUrl', 'product_link'], in_stock: ['stock', 'inStock', 'stock_available', 'available', 'availability'], features: ['tags', 'attributes', 'specs', 'specifications'], @@ -134,6 +139,82 @@ export default function JsonFieldMapper({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [jsonData]); + // Some JSON exports (notably WordPress/WooCommerce REST) store text fields as + // nested objects like { rendered: "...", raw: "..." } instead of plain strings, + // and numeric IDs (e.g. WooCommerce SKUs like 3269) instead of strings. Unwrap + // those automatically so mapped string fields don't end up as objects/numbers, + // which the backend schema rejects with a 422. + // + // WordPress REST API stores product images as a numeric `featured_media` ID + // rather than a URL. The actual URL lives in `_embedded['wp:featuredmedia'][0].source_url` + // (when the export uses ?_embed) or in `yoast_head_json.og_image[0].url`. + // `extractScalarValue` resolves the numeric ID to a URL automatically when + // either of those companion fields is present in the same row. + const resolveWordPressImageUrl = (rawValue: any, rowItem: any): string | null => { + // Try _embedded['wp:featuredmedia'][0].source_url + const embedded = rowItem?._embedded; + if (embedded && typeof embedded === 'object') { + const featuredMedia = embedded['wp:featuredmedia']; + if (Array.isArray(featuredMedia) && featuredMedia.length > 0) { + const url = featuredMedia[0]?.source_url || featuredMedia[0]?.guid?.rendered; + if (typeof url === 'string' && url.startsWith('http')) return url; + } + } + // Try yoast_head_json.og_image[0].url + const yoast = rowItem?.yoast_head_json; + if (yoast && typeof yoast === 'object') { + const ogImage = yoast.og_image; + if (Array.isArray(ogImage) && ogImage.length > 0) { + const url = ogImage[0]?.url; + if (typeof url === 'string' && url.startsWith('http')) return url; + } + } + // Try faux_featured_media_url, jetpack_featured_media_url (some WP setups) + const jpUrl = rowItem?.jetpack_featured_media_url; + if (typeof jpUrl === 'string' && jpUrl.startsWith('http')) return jpUrl; + return null; + }; + + const extractScalarValue = (field: string, rawValue: any, rowItem?: any): any => { + const allFields = { ...requiredFields, ...optionalFields }; + const expectedType = (allFields as any)[field]?.type; + + if (expectedType !== 'string' || rawValue === null || rawValue === undefined) { + return rawValue; + } + + // WordPress `featured_media` is a numeric attachment ID — resolve to a URL. + if (field === 'image_url' && typeof rawValue === 'number') { + const resolved = rowItem ? resolveWordPressImageUrl(rawValue, rowItem) : null; + return resolved ?? String(rawValue); // fallback: keep ID as string (better than null) + } + + if (typeof rawValue === 'number') { + return String(rawValue); + } + + if (typeof rawValue === 'object' && !Array.isArray(rawValue)) { + // Common WordPress/WooCommerce shape: { rendered, raw } + if ('rendered' in rawValue) return rawValue.rendered; + if ('raw' in rawValue) return rawValue.raw; + if ('name' in rawValue) return rawValue.name; + if ('value' in rawValue) return rawValue.value; + // Fallback: stringify so it's at least a valid string, not an object + return JSON.stringify(rawValue); + } + + if (Array.isArray(rawValue)) { + // e.g. category sometimes comes as [{name: "Faucets"}] or ["Faucets"] + const first = rawValue[0]; + if (first && typeof first === 'object') { + return first.name || first.rendered || first.raw || JSON.stringify(first); + } + return first !== undefined ? String(first) : rawValue; + } + + return rawValue; + }; + // Helper function to parse fixed values to correct types const parseFixedValue = (field: string, value: string): any => { const allFields = { ...requiredFields, ...optionalFields }; @@ -186,7 +267,7 @@ export default function JsonFieldMapper({ if (mappingConfig.mode === 'json') { // Map from JSON field if (mappingConfig.value in item) { - newItem[reqField] = item[mappingConfig.value]; + newItem[reqField] = extractScalarValue(reqField, item[mappingConfig.value], item); } } else if (mappingConfig.mode === 'fixed') { // Use fixed value for all items (with type parsing) @@ -202,7 +283,7 @@ export default function JsonFieldMapper({ if (mappingConfig.mode === 'json') { if (mappingConfig.value in item) { - newItem[optField] = item[mappingConfig.value]; + newItem[optField] = extractScalarValue(optField, item[mappingConfig.value], item); } } else if (mappingConfig.mode === 'fixed') { newItem[optField] = parseFixedValue(optField, mappingConfig.value); @@ -218,24 +299,31 @@ export default function JsonFieldMapper({ setErrors([`Mapping error: ${error instanceof Error ? error.message : 'Unknown error'}`]); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mapping, jsonData]); + }, [mapping, jsonData, inquiryBased]); const validateMapping = (mappedData: any[]) => { const newErrors: string[] = []; // Check all required fields are mapped Object.keys(requiredFields).forEach(field => { + const isSkippable = inquiryBased && skippableWhenInquiryBased.includes(field); const config = mapping[field]; if (!config || config.mode === 'empty') { - newErrors.push(`Required field "${field}" is not mapped`); + if (!isSkippable) { + newErrors.push(`Required field "${field}" is not mapped`); + } } else if (config.mode === 'fixed' && (!config.value || config.value.trim() === '')) { - newErrors.push(`Required field "${field}" has empty fixed value`); + if (!isSkippable) { + newErrors.push(`Required field "${field}" has empty fixed value`); + } } }); // Validate first few items mappedData.slice(0, 10).forEach((item, idx) => { Object.keys(requiredFields).forEach(field => { + const isSkippable = inquiryBased && skippableWhenInquiryBased.includes(field); + if (isSkippable) return; if (!item[field] || item[field] === null || item[field] === '') { newErrors.push(`Item ${idx + 1}: Missing value for required field "${field}"`); } @@ -259,7 +347,66 @@ export default function JsonFieldMapper({ })); }; - const handleConfirm = () => { + // Some WordPress REST exports only carry `_links['wp:featuredmedia'][0].href` + // (the media API endpoint URL) with no embedded data. Fetch each URL to get + // `source_url` and patch `image_url` in the already-mapped items before they + // are submitted. Requests are de-duplicated and run in parallel. Items without + // a numeric image_url (already resolved or not a WP export) are skipped. + const resolveMediaLinks = async (items: any[], rawItems: any[]): Promise => { + // Build a map: numeric ID → media endpoint URL, from _links in the raw row + const idToHref = new Map(); + rawItems.forEach(raw => { + const mediaId = typeof raw?.featured_media === 'number' ? raw.featured_media : null; + if (!mediaId) return; + const href = raw?._links?.['wp:featuredmedia']?.[0]?.href; + if (href) idToHref.set(mediaId, href); + }); + + if (idToHref.size === 0) return items; + + // Only process items whose image_url is still a bare numeric string (unresolved) + const unresolvedIds = new Set(); + items.forEach(item => { + const val = item?.image_url; + if (typeof val === 'string' && /^\d+$/.test(val)) { + const id = parseInt(val, 10); + if (idToHref.has(id)) unresolvedIds.add(id); + } + }); + + if (unresolvedIds.size === 0) return items; + + // Fetch all media endpoints in parallel; silently ignore failures + const idToUrl = new Map(); + await Promise.allSettled( + Array.from(unresolvedIds).map(async (id) => { + const href = idToHref.get(id)!; + try { + const res = await fetch(href); + if (res.ok) { + const json = await res.json(); + const url: string | undefined = json?.source_url || json?.guid?.rendered; + if (url && url.startsWith('http')) idToUrl.set(id, url); + } + } catch { + // Network error or CORS — leave field as-is; product uploads still succeed + } + }) + ); + + if (idToUrl.size === 0) return items; + + return items.map(item => { + const val = item?.image_url; + if (typeof val === 'string' && /^\d+$/.test(val)) { + const resolved = idToUrl.get(parseInt(val, 10)); + if (resolved) return { ...item, image_url: resolved }; + } + return item; + }); + }; + + const handleConfirm = async () => { if (errors.length > 0) return; isDev && console.log('[JsonFieldMapper] handleConfirm - Starting mapping with config:', mapping); @@ -274,7 +421,7 @@ export default function JsonFieldMapper({ if (config.mode === 'json') { if (config.value in item) { - newItem[field] = item[config.value]; + newItem[field] = extractScalarValue(field, item[config.value], item); } } else if (config.mode === 'fixed') { // Parse fixed values to correct types using helper @@ -300,7 +447,8 @@ export default function JsonFieldMapper({ isDev && console.log('[JsonFieldMapper] Total mapped items:', mappedData.length); isDev && console.log('[JsonFieldMapper] Sample mapped data:', mappedData.slice(0, 2)); - onMappingComplete(mappedData); + const resolvedData = await resolveMediaLinks(mappedData, jsonData); + onMappingComplete(resolvedData); }; const getFieldColor = (field: string) => { @@ -312,6 +460,8 @@ export default function JsonFieldMapper({ }; const renderFieldMapping = (fieldName: string, fieldInfo: any, isRequired: boolean) => { + const skippable = inquiryBased && skippableWhenInquiryBased.includes(fieldName); + const showSkipButton = !isRequired || skippable; const config = mapping[fieldName] || { mode: 'empty', value: '' }; return ( @@ -320,7 +470,8 @@ export default function JsonFieldMapper({ {/* Required Field */}

{fieldInfo.description}

Type: {fieldInfo.type}

@@ -355,7 +506,7 @@ export default function JsonFieldMapper({ > Use Fixed Value - {!isRequired && ( + {showSkipButton && (
@@ -430,6 +581,36 @@ export default function JsonFieldMapper({

+ {contentType === 'product' && ( + + )} + {/* Detected Fields Summary */}

diff --git a/apps/admin/src/hooks/useAgentWizardController.ts b/apps/admin/src/hooks/useAgentWizardController.ts index 300a743..1d8686c 100644 --- a/apps/admin/src/hooks/useAgentWizardController.ts +++ b/apps/admin/src/hooks/useAgentWizardController.ts @@ -134,6 +134,10 @@ const initialData: AgentWizardData = { long_term_memory: false, auto_compaction: true, context_window_messages: 12, + inquiry_enabled: false, + inquiry_recipient_email: '', + inquiry_confirm_before_send: true, + inquiry_success_message: '', typing_indicators: true, response_streaming: true, widget_enabled: true, @@ -279,6 +283,10 @@ function mapAgentToWizardData(existingAgent: Agent): Partial { long_term_memory: memory.long_term?.enabled ?? false, auto_compaction: memory.short_term?.auto_compaction ?? true, context_window_messages: memory.short_term?.window_messages ?? 12, + inquiry_enabled: (config.inquiry || {}).enabled ?? false, + inquiry_recipient_email: (config.inquiry || {}).recipient_email || '', + inquiry_confirm_before_send: (config.inquiry || {}).confirm_before_send ?? true, + inquiry_success_message: (config.inquiry || {}).success_message || '', typing_indicators: features.typing_indicators ?? true, response_streaming: features.response_streaming ?? true, widget_enabled: widgetChannel.enabled ?? true, diff --git a/apps/admin/src/pages/AgentWizard.tsx b/apps/admin/src/pages/AgentWizard.tsx index 0c656ff..2f15b4d 100644 --- a/apps/admin/src/pages/AgentWizard.tsx +++ b/apps/admin/src/pages/AgentWizard.tsx @@ -230,6 +230,13 @@ export default function AgentWizard() { status: agentData.long_term_memory ? 'enabled' : 'needs_privacy_setup', }, }, + inquiry: { + enabled: agentData.inquiry_enabled, + recipient_email: agentData.inquiry_recipient_email, + delivery_method: 'email', + confirm_before_send: agentData.inquiry_confirm_before_send, + success_message: agentData.inquiry_success_message || undefined, + }, }, }; diff --git a/apps/admin/src/utils/agentWizardPayload.ts b/apps/admin/src/utils/agentWizardPayload.ts index d89442d..2abccd4 100644 --- a/apps/admin/src/utils/agentWizardPayload.ts +++ b/apps/admin/src/utils/agentWizardPayload.ts @@ -74,6 +74,10 @@ export interface AgentWizardData { context_connectors: ContextConnector[]; url_context_boost_enabled: boolean; artifacts_config: Record }>; + inquiry_enabled?: boolean; + inquiry_recipient_email?: string; + inquiry_confirm_before_send?: boolean; + inquiry_success_message?: string; selected_skill_ids: string[]; selected_tool_ids: string[]; agent_api_enabled: boolean; @@ -283,6 +287,13 @@ export function buildAgentWizardPayload( status: agentData.long_term_memory ? 'enabled' : 'needs_privacy_setup', }, }, + inquiry: { + enabled: agentData.inquiry_enabled, + recipient_email: agentData.inquiry_recipient_email, + delivery_method: 'email', + confirm_before_send: agentData.inquiry_confirm_before_send, + success_message: agentData.inquiry_success_message || undefined, + }, skills: buildCapabilityConfig(existingConfiguration.skills, agentData.selected_skill_ids, 'skill_id'), tools: buildCapabilityConfig(existingConfiguration.tools, agentData.selected_tool_ids, 'tool_id'), agent_api: { diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 147c77c..7ea75ac 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -43,7 +43,8 @@ WORKDIR /app/api EXPOSE 8000 -RUN chmod +x /app/api/docker-entrypoint.sh \ +RUN sed -i 's/\r$//' /app/api/docker-entrypoint.sh \ + && chmod +x /app/api/docker-entrypoint.sh \ && groupadd --system app \ && useradd --system --gid app --home-dir /app --shell /usr/sbin/nologin app \ && chown -R app:app /app diff --git a/apps/api/app/api/v1/__init__.py b/apps/api/app/api/v1/__init__.py index 97c520d..5eeb770 100644 --- a/apps/api/app/api/v1/__init__.py +++ b/apps/api/app/api/v1/__init__.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends from . import agent_api -from .endpoints import messages, ingestion, status, knowledge, activity, catalog, public +from .endpoints import messages, ingestion, status, knowledge, activity, catalog, public, inquiry, lalkitab_profile from .admin import router as admin_router from .auth import auth_router from ...security.rate_limiter import rate_limit_dependency @@ -22,6 +22,8 @@ api_router.include_router(activity.router, prefix="/activity", tags=["activity"]) api_router.include_router(catalog.router, prefix="/catalog", tags=["catalog"]) api_router.include_router(public.router, prefix="/public", tags=["public"]) +api_router.include_router(inquiry.router, prefix="/inquiry", tags=["inquiry"]) +api_router.include_router(lalkitab_profile.router, prefix="/lalkitab", tags=["lalkitab"]) api_router.include_router(agent_api.router, prefix="/agent-api", tags=["agent-api"]) # Include admin routers diff --git a/apps/api/app/api/v1/endpoints/inquiry.py b/apps/api/app/api/v1/endpoints/inquiry.py new file mode 100644 index 0000000..1fa9e55 --- /dev/null +++ b/apps/api/app/api/v1/endpoints/inquiry.py @@ -0,0 +1,139 @@ +""" +Public inquiry submission endpoint. + +Accepts form fields directly from the widget InquiryFormModal. +PII (name, phone, email, city, country) never touches the LLM — it goes +straight to InquiryService (MongoDB) and InquiryDeliveryService (SMTP). +""" + +from __future__ import annotations + +from typing import Optional +from fastapi import APIRouter, Header +from pydantic import BaseModel, Field +import structlog + +from ....services.inquiry_service import InquiryService +from ....services.inquiry_delivery_service import InquiryDeliveryService +from ....config import Settings +from ....connections import connection_manager + +router = APIRouter() +logger = structlog.get_logger(__name__) + + +class InquirySubmitRequest(BaseModel): + agent_id: str = Field(..., min_length=1) + conversation_id: Optional[str] = None + product_name: Optional[str] = None + product_sku: Optional[str] = None + name: str = Field(..., min_length=1) + phone: str = Field(..., min_length=1) + email: str = Field(..., min_length=3) + city: str = Field(..., min_length=1) + country: str = Field(..., min_length=1) + query: Optional[str] = None + + +class InquirySubmitResponse(BaseModel): + success: bool + inquiry_id: Optional[str] = None + message: str + delivered: bool = False + + +@router.post("/submit", response_model=InquirySubmitResponse) +async def submit_inquiry( + request: InquirySubmitRequest, + x_widget_session: Optional[str] = Header(default=None, alias="X-Widget-Session"), +): + """ + Submit a lead-capture inquiry from the widget form. + + The widget posts form fields directly here — no LLM processes this data. + The endpoint persists to MongoDB and sends an email to the configured + recipient, then returns a success flag + message for the widget to display. + """ + settings = Settings() + + # Resolve brand context from agent_id + try: + system_db = connection_manager.get_system_db() + agent = await system_db.agents.find_one({"id": request.agent_id}) + if not agent: + return InquirySubmitResponse( + success=False, + message="Agent not found.", + delivered=False, + ) + brand_slug: str = agent.get("brand_slug") or agent.get("brand_id") or request.agent_id + brand_id: str = agent.get("brand_id") or request.agent_id + agent_config: dict = agent.get("configuration") or {} + except Exception as exc: + logger.error("inquiry_submit_agent_lookup_failed", error=str(exc)) + return InquirySubmitResponse( + success=False, + message="Could not resolve agent configuration.", + delivered=False, + ) + + inquiry_svc = InquiryService() + delivery_svc = InquiryDeliveryService(settings) + + # Persist inquiry (PII stays in brand-isolated MongoDB, never logged) + try: + inquiry_doc = await inquiry_svc.save_inquiry( + brand_slug=brand_slug, + brand_id=brand_id, + agent_id=request.agent_id, + conversation_id=request.conversation_id, + contact={ + "name": request.name, + "phone": request.phone, + "email": request.email, + "city": request.city, + "country": request.country, + }, + requirements=request.query or "", + product_context={ + "name": request.product_name or "", + "sku": request.product_sku or "", + }, + ) + except Exception as exc: + logger.error("inquiry_submit_save_failed", error=str(exc)) + return InquirySubmitResponse( + success=False, + message="Failed to save inquiry. Please try again.", + delivered=False, + ) + + # Deliver via SMTP (non-blocking worker thread) + delivery = await delivery_svc.send(inquiry_doc, agent_config) + + if delivery.success: + await inquiry_svc.mark_delivered( + brand_slug=brand_slug, + inquiry_id=inquiry_doc["inquiry_id"], + method=delivery.method, + ) + else: + await inquiry_svc.mark_failed( + brand_slug=brand_slug, + inquiry_id=inquiry_doc["inquiry_id"], + method=delivery.method, + error=delivery.error or "unknown", + ) + + inquiry_config = agent_config.get("inquiry") or {} + success_message = ( + str(inquiry_config.get("success_message") or "").strip() + or "Your inquiry has been sent successfully. Our team will reach out to you shortly." + ) + + return InquirySubmitResponse( + success=True, + inquiry_id=inquiry_doc["inquiry_id"], + message=success_message, + delivered=delivery.success, + ) diff --git a/apps/api/app/api/v1/endpoints/knowledge.py b/apps/api/app/api/v1/endpoints/knowledge.py index f483d29..9444c1a 100644 --- a/apps/api/app/api/v1/endpoints/knowledge.py +++ b/apps/api/app/api/v1/endpoints/knowledge.py @@ -114,7 +114,7 @@ class ProductData(BaseModel): """Structured product metadata.""" sku: str = Field(..., description="Product SKU/ID (unique identifier)") name: str = Field(..., description="Product name/title") - price: int = Field(..., description="Price in smallest currency unit (e.g., paise, cents)") + price: Optional[int] = Field(None, description="Price in smallest currency unit (e.g., paise, cents). Omit for inquiry-based catalogs with no fixed pricing.") currency: Optional[str] = Field(None, description="Currency code (e.g., INR, USD)") currency_source: Optional[Literal["shopify_store", "presentment", "catalog", "configured_default", "missing"]] = None category: str = Field(..., description="Product category") @@ -463,8 +463,8 @@ async def bulk_upload_json( missing.append("sku") if not item_dict.get("name"): missing.append("name") - if item_dict.get("price") is None: - missing.append("price") + # price is intentionally optional: inquiry-based catalogs have no + # fixed pricing and instead direct customers to send an inquiry. if not item_dict.get("category"): missing.append("category") @@ -1107,8 +1107,8 @@ def _product_card_from_data(product_data: Dict[str, Any]) -> Dict[str, Any]: return { "sku": product_data.get("sku"), "name": product_data.get("parent_name") or product_data.get("name", "Unknown Product"), - "price": product_data.get("price", 0), - "price_minor": product_data.get("price_minor", product_data.get("price", 0)), + "price": product_data.get("price"), + "price_minor": product_data.get("price_minor", product_data.get("price")), "price_unit": "minor", "currency": product_data.get("currency"), "currency_source": product_data.get("currency_source", "missing"), diff --git a/apps/api/app/api/v1/endpoints/lalkitab_profile.py b/apps/api/app/api/v1/endpoints/lalkitab_profile.py new file mode 100644 index 0000000..86d1489 --- /dev/null +++ b/apps/api/app/api/v1/endpoints/lalkitab_profile.py @@ -0,0 +1,337 @@ +""" +Lal Kitab birth-profile endpoint — form-first flow. + +A mandatory pre-chat form (name, DOB, TOB, birthplace, language) submits here. +This runs the existing chart-first runtime once (geocoding + kundali chart + +all secondary Lal Kitab endpoints), persists the result as a confirmed birth +profile scoped to this widget session's conversation, and returns the chart + +report so the widget can render a horoscope dashboard immediately. + +Once confirmed, chat turns for this conversation never need to re-extract +birth details from free text — message_service.py reads the persisted +profile and feeds it straight into build_lalkitab_runtime_context() as +`birth_profile`, and (for astrology_lalkitab agents with this flow enabled) +chat is gated until a confirmed profile exists. +""" + +from __future__ import annotations + +from typing import Any, Optional + +from fastapi import APIRouter, Header, HTTPException, Request +from pydantic import BaseModel, Field +import structlog + +from ....auth.widget_session import decode_widget_session +from ....config import Settings +from ....connections import connection_manager +from ....services.conversation_scope_store import ( + ConversationScopeAuthorizationError, + ConversationScopeStoreError, + conversation_scope_store, +) +from ....services.lalkitab_runtime import ( + build_lalkitab_runtime_context, + is_lalkitab_agent, +) +from ....services.kundali_chart import extract_kundali_chart_summary +from ....services.message_service import _sanitize_for_json +from ....services.runtime_settings_service import RuntimeSettingsService +from ....services.tool_config_secrets import decrypt_full_agent_configuration_for_runtime + +router = APIRouter() +logger = structlog.get_logger(__name__) + + +class BirthProfileRequest(BaseModel): + agent_id: str = Field(..., min_length=1) + name: str = Field(..., min_length=1) + birth_date: str = Field(..., min_length=1, description="YYYY-MM-DD") + birth_time: str = Field(..., min_length=1, description="HH:MM (24h) or HH:MM AM/PM") + birth_place: str = Field(..., min_length=1) + language: Optional[str] = "en" + + +class BirthProfileResponse(BaseModel): + success: bool + message: str + kundali_chart: Optional[dict[str, Any]] = None + api_context: Optional[dict[str, Any]] = None + awaiting_place_choice: bool = False + place_candidates: list[dict[str, Any]] = Field(default_factory=list) + # A short, stable, non-sensitive machine-readable code so the failure is + # visible and distinguishable in the browser's Network tab response body + # (e.g. "chart_unavailable" vs "missing_input") without ever including a + # provider's raw error text, an API key, or any other connector secret. + error_code: Optional[str] = None + # The real upstream HTTP status Vedika returned (e.g. 429, 401, 503) — + # a single safe integer, never the provider's raw error text/body/ + # headers — so the actual cause is visible in the Network tab without + # reading server logs. + upstream_status_code: Optional[int] = None + + +def _require_session(x_widget_session: Optional[str], agent_id: str): + session = decode_widget_session(x_widget_session, expected_agent_id=agent_id) + if session is None: + raise HTTPException( + status_code=401, + detail="A valid widget session token is required. Start a session at POST /messages/session.", + ) + return session + + +@router.post("/profile", response_model=BirthProfileResponse) +async def submit_birth_profile( + request: BirthProfileRequest, + http_request: Request, + x_widget_session: Optional[str] = Header(default=None, alias="X-Widget-Session"), +): + """Build the full Lal Kitab chart + report once and persist it as this + conversation's confirmed birth profile. + + Status codes are genuine here (not always 200): a real chart/connector + failure returns 502 so it is visibly red in the Network tab, not just a + "success": false line inside an otherwise-fine-looking 200 response. + Only a safe, generic `error_code` + `request_id` ever reach the browser — + never a provider's raw error text, stack trace, or any connector secret; + the full diagnostic detail stays in the server's own structured logs, + correlated by `request_id`. + """ + session = _require_session(x_widget_session, request.agent_id) + request_id = getattr(http_request.state, "request_id", None) + + try: + await conversation_scope_store.require_active_widget_scope( + conversation_id=session.conversation_id, + user_id=session.user_id, + agent_id=session.agent_id, + ) + except ConversationScopeAuthorizationError as exc: + raise HTTPException( + status_code=401, + detail={ + "success": False, + "message": "Widget session is no longer valid", + "error_code": "session_invalid", + "request_id": request_id, + }, + ) from exc + except ConversationScopeStoreError as exc: + raise HTTPException( + status_code=503, + detail={ + "success": False, + "message": "Widget sessions are temporarily unavailable", + "error_code": "session_store_unavailable", + "request_id": request_id, + }, + ) from exc + + system_db = connection_manager.get_system_db() + agent = await system_db.agents.find_one({"id": request.agent_id, "status": "active"}) + if not agent: + raise HTTPException( + status_code=404, + detail={ + "success": False, + "message": "Agent not found or not active", + "error_code": "agent_not_found", + "request_id": request_id, + }, + ) + + # Connector auth (e.g. the Vedika API key) is stored encrypted at rest + # (auth_header_encrypted) — this must be decrypted into the plain + # auth_header field before ContextConnectorTool can build a real + # Authorization header, exactly like message_service.py's normal chat + # path does when loading agent config. Skipping this was the actual bug: + # every geocode/chart call went out with no Authorization header at all, + # regardless of whether a real key had been saved. + raw_config = agent.get("configuration") or {} + config = decrypt_full_agent_configuration_for_runtime(raw_config, RuntimeSettingsService(Settings())) + if not is_lalkitab_agent(config): + raise HTTPException(status_code=400, detail="This agent is not configured as a Lal Kitab / astrology agent") + + birth_profile = { + "name": request.name.strip(), + "birth_date": request.birth_date.strip(), + "birth_time": request.birth_time.strip(), + "birth_place": request.birth_place.strip(), + } + + try: + plan = await build_lalkitab_runtime_context( + config, + "full reading", + birth_profile=birth_profile, + ) + except Exception as exc: + # The real exception (which may reference internal state, stack + # frames, or connector internals) stays server-side only; the + # browser gets a generic 502 plus a stable error_code and the + # request_id already stamped on every response by RequestIDMiddleware + # so this exact failure can be found in server logs. + logger.error("lalkitab_profile_build_failed", error=str(exc), request_id=request_id) + raise HTTPException( + status_code=502, + detail={ + "success": False, + "message": "Could not build the horoscope right now. Please try again.", + "error_code": "chart_build_exception", + "request_id": request_id, + }, + ) from exc + + if plan.awaiting_place_choice: + # Genuinely part of the normal flow (the user must pick a place) — + # not a failure, so this correctly stays 200. + return BirthProfileResponse( + success=False, + message=plan.clarification or "Please confirm which place you were born in.", + awaiting_place_choice=True, + place_candidates=plan.place_candidates or [], + error_code="awaiting_place_choice", + ) + + if plan.missing_input: + # Also a normal, expected flow state (the user just needs to correct + # input), not a backend/connector failure — stays 200. + return BirthProfileResponse( + success=False, + message=plan.clarification or "Some birth details could not be understood. Please check and try again.", + error_code="missing_input", + ) + + if plan.requires_safe_abstention or not plan.chart_validated: + # This is a genuine failure (the Vedika connector call did not + # return a usable, validated chart — e.g. auth failure, rate limit, + # timeout, or a rejected date). It must be visibly red/failed in the + # Network tab, not indistinguishable from a successful 200 response. + # The real cause is already logged server-side inside + # lalkitab_runtime.py's "lalkitab_connector_result_unavailable" + # warning; a safe, specific error_code (the real upstream HTTP + # status only — never the provider's raw error text, headers, or + # body) now also reaches the browser so a 429 rate limit is visibly + # different from an auth failure or a timeout, without needing to + # read server logs for every occurrence. + chart_result = plan.tool_results.get("tool_context_vedika_lal_kitab_lalkitab_chart") if isinstance(plan.tool_results, dict) else None + if not chart_result: + # Endpoint tool names vary by connector id; fall back to the + # first failed connector call this turn made if the exact chart + # tool name doesn't match. + for candidate in (plan.tool_results or {}).values(): + if getattr(candidate, "success", True) is False: + chart_result = candidate + break + upstream_status = None + if chart_result is not None: + candidate_metadata = getattr(chart_result, "metadata", None) + if isinstance(candidate_metadata, dict): + upstream_status = candidate_metadata.get("upstream_status_code") + error_code = f"chart_unavailable_upstream_{upstream_status}" if upstream_status else "chart_unavailable" + logger.warning( + "lalkitab_profile_chart_unavailable", + request_id=request_id, + requires_safe_abstention=bool(plan.requires_safe_abstention), + chart_validated=bool(plan.chart_validated), + upstream_status_code=upstream_status, + ) + raise HTTPException( + status_code=502, + detail={ + "success": False, + "message": "We could not calculate a verified chart from these details right now. Please try again shortly.", + "error_code": error_code, + "upstream_status_code": upstream_status, + "request_id": request_id, + }, + ) + + kundali_chart = extract_kundali_chart_summary(plan.api_context) + if kundali_chart and isinstance(kundali_chart, dict): + kundali_chart.setdefault("birth", {}) + if isinstance(kundali_chart.get("birth"), dict): + kundali_chart["birth"].update( + { + "name": birth_profile["name"], + "date": birth_profile["birth_date"], + "time": birth_profile["birth_time"], + "place": birth_profile["birth_place"], + } + ) + + # Persist as this conversation's confirmed profile. Chat turns read this + # back so birth details never need to be typed into chat. + try: + await conversation_scope_store.set_birth_profile( + conversation_id=session.conversation_id, + user_id=session.user_id, + agent_id=session.agent_id, + birth_profile={ + **birth_profile, + "language": request.language or "en", + "api_context": _sanitize_for_json(plan.api_context), + "kundali_chart": _sanitize_for_json(kundali_chart) if kundali_chart else None, + }, + ) + except ConversationScopeAuthorizationError as exc: + raise HTTPException( + status_code=401, + detail={ + "success": False, + "message": "Widget session is no longer valid", + "error_code": "session_invalid", + "request_id": request_id, + }, + ) from exc + except ConversationScopeStoreError as exc: + logger.error("lalkitab_profile_persist_failed", error=str(exc), request_id=request_id) + raise HTTPException( + status_code=503, + detail={ + "success": False, + "message": "Could not save your birth profile right now. Please try again.", + "error_code": "profile_store_unavailable", + "request_id": request_id, + }, + ) from exc + + return BirthProfileResponse( + success=True, + message="Your horoscope has been generated.", + kundali_chart=_sanitize_for_json(kundali_chart) if kundali_chart else None, + api_context=_sanitize_for_json(plan.api_context), + ) + + +@router.get("/profile", response_model=Optional[BirthProfileResponse]) +async def get_birth_profile( + agent_id: str, + x_widget_session: Optional[str] = Header(default=None, alias="X-Widget-Session"), +): + """Return the confirmed birth profile for this conversation, if one exists. + + The widget calls this on session start to decide whether to show the + birth-details form or go straight to the dashboard + chat. + """ + session = _require_session(x_widget_session, agent_id) + + try: + profile = await conversation_scope_store.get_birth_profile( + conversation_id=session.conversation_id, + user_id=session.user_id, + agent_id=session.agent_id, + ) + except ConversationScopeStoreError as exc: + raise HTTPException(status_code=503, detail="Widget sessions are temporarily unavailable") from exc + + if not profile: + return BirthProfileResponse(success=False, message="No confirmed birth profile yet.") + + return BirthProfileResponse( + success=True, + message="Confirmed birth profile found.", + kundali_chart=profile.get("kundali_chart"), + api_context=profile.get("api_context"), + ) diff --git a/apps/api/app/api/v1/endpoints/public.py b/apps/api/app/api/v1/endpoints/public.py index ecc0c4d..95bb817 100644 --- a/apps/api/app/api/v1/endpoints/public.py +++ b/apps/api/app/api/v1/endpoints/public.py @@ -113,6 +113,15 @@ def _public_agent_config(configuration: dict[str, Any]) -> dict[str, Any]: } if is_commerce_agent: public_config["commerce"] = configuration["commerce"] + # Expose inquiry/lead-capture config so the widget can show the + # "Send Inquiry" button on product cards when the skill is enabled. + inquiry = configuration.get("inquiry") or {} + public_config["inquiry"] = { + "enabled": bool(inquiry.get("enabled", False)), + "recipient_email": inquiry.get("recipient_email", ""), + "confirm_before_send": bool(inquiry.get("confirm_before_send", True)), + "success_message": inquiry.get("success_message", ""), + } return public_config diff --git a/apps/api/app/config.py b/apps/api/app/config.py index c5f2062..5ecb50c 100644 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -339,6 +339,18 @@ class Settings(BaseSettings): # Firecrawl (product catalog scraping) FIRECRAWL_API_KEY: str = "" + # Inquiry capture (conversational "send inquiry" workflow). + # SMTP delivery is for testing; production should swap to a transactional + # provider or route to a CRM/webhook via the same delivery service. + SMTP_HOST: str = "" + SMTP_PORT: int = 587 + SMTP_USERNAME: str = "" + SMTP_PASSWORD: str = "" + SMTP_FROM_EMAIL: str = "" + SMTP_USE_TLS: bool = True + # Default recipient when an agent has no inquiry.recipient_email configured. + INQUIRY_FALLBACK_RECIPIENT: str = "" + # Azure Key Vault Configuration AZURE_KEYVAULT_NAME: Optional[str] = None USE_AZURE_KEYVAULT: bool = False @@ -359,7 +371,7 @@ def parse_cors_origins(cls, v): return [origin.strip() for origin in v.split(",") if origin.strip()] return v - @field_validator("REDIS_SSL", "API_RELOAD", "ENABLE_WEBSOCKETS", "ENABLE_SSE", "ENABLE_METRICS", "ENABLE_TRACING", "ENABLE_HUMAN_TAKEOVER", "ENABLE_AUTO_SUMMARY", "ENABLE_PII_VAULTING", "ENABLE_FACT_EXTRACTION", "ENABLE_GRAPH_RULES", "REDIS_FALLBACK_TO_MONGO", "USE_AZURE_KEYVAULT", "ALLOW_PUBLIC_SIGNUP", "RATE_LIMIT_FAIL_CLOSED", "ATLAS_AUTO_CREATE_VECTOR_INDEXES", "SHOPIFY_WEBHOOKS_ENABLED", "STRAPI_PRIVACY_WORKER", "EVAL_STAGING_ENABLED", mode="before") + @field_validator("REDIS_SSL", "API_RELOAD", "ENABLE_WEBSOCKETS", "ENABLE_SSE", "ENABLE_METRICS", "ENABLE_TRACING", "ENABLE_HUMAN_TAKEOVER", "ENABLE_AUTO_SUMMARY", "ENABLE_PII_VAULTING", "ENABLE_FACT_EXTRACTION", "ENABLE_GRAPH_RULES", "REDIS_FALLBACK_TO_MONGO", "USE_AZURE_KEYVAULT", "ALLOW_PUBLIC_SIGNUP", "RATE_LIMIT_FAIL_CLOSED", "ATLAS_AUTO_CREATE_VECTOR_INDEXES", "SHOPIFY_WEBHOOKS_ENABLED", "STRAPI_PRIVACY_WORKER", "EVAL_STAGING_ENABLED", "SMTP_USE_TLS", mode="before") @classmethod def parse_bool_fields(cls, v): """Parse boolean fields from string.""" @@ -367,7 +379,7 @@ def parse_bool_fields(cls, v): return v.lower() in ("true", "1", "yes", "on") return v - @field_validator("API_WORKERS", "RATE_LIMIT_REQUESTS_PER_MINUTE", "RATE_LIMIT_BURST", "RATE_LIMIT_POLICY_WIDGET_CHAT", "RATE_LIMIT_POLICY_WIDGET_STREAM", "RATE_LIMIT_POLICY_WIDGET_WS_CONNECT", "RATE_LIMIT_POLICY_WIDGET_WS_MESSAGE", "RATE_LIMIT_POLICY_ADMIN_API", "RATE_LIMIT_POLICY_UPLOAD", "RATE_LIMIT_POLICY_STRAPI_SYNC", "MAX_FILE_SIZE_MB", "MAX_UPLOAD_FILES", "MAX_UPLOAD_TOTAL_SIZE_MB", "MAX_ARCHIVE_FILES", "MAX_ARCHIVE_UNCOMPRESSED_SIZE_MB", "MAX_ARCHIVE_COMPRESSION_RATIO", "INGESTION_JOB_TTL_SECONDS", "INGESTION_PAYLOAD_TTL_SECONDS", "INGESTION_LEASE_SECONDS", "INGESTION_MAX_ATTEMPTS", "INGESTION_RETRY_DELAY_SECONDS", "SHOPIFY_WEBHOOK_MAX_BODY_BYTES", "CATALOG_SYNC_JOB_TTL_SECONDS", "CATALOG_SYNC_LEASE_SECONDS", "CATALOG_SYNC_MAX_ATTEMPTS", "CATALOG_SYNC_RETRY_DELAY_SECONDS", "ACCESS_TOKEN_EXPIRE_MINUTES", "PASSWORD_RESET_TOKEN_EXPIRE_MINUTES", "SHORT_TERM_TTL", "EPISODIC_TTL", "SUMMARY_CACHE_TTL", "AUTO_SUMMARY_TURNS", "MAX_MESSAGES_PER_CONVERSATION", "MAX_FACTS_PER_USER", "MAX_SUMMARIES_PER_CONVERSATION", "REDIS_CONNECTION_TIMEOUT", "SUMMARY_MAX_TOKENS", "VECTOR_DIMENSIONS", "PRIVACY_DEFAULT_RETENTION_DAYS", "PRIVACY_EXPORT_MAX_RECORDS", "STRAPI_PRIVACY_LEASE_SECONDS", "STRAPI_PRIVACY_MAX_ATTEMPTS", "STRAPI_PRIVACY_RETRY_DELAY_SECONDS", "EVAL_STAGING_MAX_CASES", "EVAL_RESULT_TTL_SECONDS", mode="before") + @field_validator("API_WORKERS", "RATE_LIMIT_REQUESTS_PER_MINUTE", "RATE_LIMIT_BURST", "RATE_LIMIT_POLICY_WIDGET_CHAT", "RATE_LIMIT_POLICY_WIDGET_STREAM", "RATE_LIMIT_POLICY_WIDGET_WS_CONNECT", "RATE_LIMIT_POLICY_WIDGET_WS_MESSAGE", "RATE_LIMIT_POLICY_ADMIN_API", "RATE_LIMIT_POLICY_UPLOAD", "RATE_LIMIT_POLICY_STRAPI_SYNC", "MAX_FILE_SIZE_MB", "MAX_UPLOAD_FILES", "MAX_UPLOAD_TOTAL_SIZE_MB", "MAX_ARCHIVE_FILES", "MAX_ARCHIVE_UNCOMPRESSED_SIZE_MB", "MAX_ARCHIVE_COMPRESSION_RATIO", "INGESTION_JOB_TTL_SECONDS", "INGESTION_PAYLOAD_TTL_SECONDS", "INGESTION_LEASE_SECONDS", "INGESTION_MAX_ATTEMPTS", "INGESTION_RETRY_DELAY_SECONDS", "SHOPIFY_WEBHOOK_MAX_BODY_BYTES", "CATALOG_SYNC_JOB_TTL_SECONDS", "CATALOG_SYNC_LEASE_SECONDS", "CATALOG_SYNC_MAX_ATTEMPTS", "CATALOG_SYNC_RETRY_DELAY_SECONDS", "ACCESS_TOKEN_EXPIRE_MINUTES", "PASSWORD_RESET_TOKEN_EXPIRE_MINUTES", "SHORT_TERM_TTL", "EPISODIC_TTL", "SUMMARY_CACHE_TTL", "AUTO_SUMMARY_TURNS", "MAX_MESSAGES_PER_CONVERSATION", "MAX_FACTS_PER_USER", "MAX_SUMMARIES_PER_CONVERSATION", "REDIS_CONNECTION_TIMEOUT", "SUMMARY_MAX_TOKENS", "VECTOR_DIMENSIONS", "PRIVACY_DEFAULT_RETENTION_DAYS", "PRIVACY_EXPORT_MAX_RECORDS", "STRAPI_PRIVACY_LEASE_SECONDS", "STRAPI_PRIVACY_MAX_ATTEMPTS", "STRAPI_PRIVACY_RETRY_DELAY_SECONDS", "EVAL_STAGING_MAX_CASES", "EVAL_RESULT_TTL_SECONDS", "SMTP_PORT", mode="before") @classmethod def parse_int_fields(cls, v): """Parse integer fields from string.""" diff --git a/apps/api/app/services/agent_turn_planner.py b/apps/api/app/services/agent_turn_planner.py index 559e895..6b8f277 100644 --- a/apps/api/app/services/agent_turn_planner.py +++ b/apps/api/app/services/agent_turn_planner.py @@ -1,4 +1,4 @@ -from __future__ import annotations +from __future__ import annotations from dataclasses import dataclass, field import json @@ -43,7 +43,7 @@ class AgentTurnPlan: @property def should_short_circuit(self) -> bool: - return self.action in {"greeting", "ask_question", "ask_missing_input", "clarify", "handoff"} + return self.action in {"greeting", "ask_question", "ask_missing_input", "clarify", "handoff", "open_inquiry_form"} class AgentTurnPlanner: @@ -183,19 +183,27 @@ def _build_prompt( - If required inputs are missing, set intent=collect_input and missing_inputs. - If the user gave required details but no question and question_required=true, ask what they want to ask. - If the agent has enough information and tools are needed, set intent=tool_use with a tool_plan. +- If a tool in the available tools list (e.g. open_inquiry_form, submit_inquiry) is clearly + relevant to what the user is asking for — even if some fields it needs are still missing — + prefer intent=tool_use with that tool in tool_plan over guessing intent=collect_input + yourself. That tool reports exactly which of its own fields are missing; do not invent + missing_inputs for it. For open_inquiry_form specifically: read its own tool description + carefully to decide when it applies (quote/pricing/human-contact/bulk-pricing intent) versus + when normal product search/knowledge tools should run instead (product questions, browsing, + comparisons, specs, availability) — never call open_inquiry_form for those. - For ordered recipes, include the prerequisite step first. - If a place/date/time is phrased naturally or unlabeled, still extract it. - Unlabeled comma/period-separated birth details (e.g. "16 July 1987, 15:26, Delhi India") - mean birth_date, birth_time, birth_place — extract all three (date as YYYY-MM-DD, + mean birth_date, birth_time, birth_place — extract all three (date as YYYY-MM-DD, time as HH:MM:SS, place as the text the user gave). - Messages often lead with the person's NAME ("Sandeep Amar, DOB 25/01/1975, 11 AM, New Delhi. How is my health in 2026"): extract it as resolved_inputs.name. A person's - name is NEVER the birth place, and the trailing question is NEVER the birth place — + name is NEVER the birth place, and the trailing question is NEVER the birth place — put the question in "question". - Never ask the user for latitude, longitude, or timezone. A birthplace name is enough; the runtime geocodes it automatically. Ask a clarifying question only when a detail is genuinely ambiguous (e.g. "03/04/1990" could be 3 April or 4 March, or a city name that - exists in several countries with no country given) — otherwise proceed. + exists in several countries with no country given) — otherwise proceed. - Never put astrology connector endpoints (lalkitab_* or geocode_*) in tool_plan; the chart-first astrology runtime resolves the birthplace, builds the chart first, and then calls the relevant secondary endpoints automatically. Signal the need through @@ -251,8 +259,12 @@ def _coerce_plan(self, data: dict[str, Any], *, fallback_plan: ConversationTurnP action = "ready" if intent == "greeting": action = "greeting" - public_response = public_response or "Hi, I’m here. Tell me what you’d like help with." - elif intent == "collect_input" and missing: + public_response = public_response or "Hi, I’m here. Tell me what you’d like help with." + elif intent == "collect_input" and missing and not tool_plan: + # Only short-circuit here when no tool is queued. If a tool_plan is + # present (e.g. submit_inquiry), let it run — the tool has its own, + # more accurate missing-field detection and must not be pre-empted + # by this generic guess. action = "ask_missing_input" public_response = public_response or fallback_plan.response_text elif intent in {"clarify", "handoff"}: @@ -266,6 +278,17 @@ def _coerce_plan(self, data: dict[str, Any], *, fallback_plan: ConversationTurnP if not activities: activities = self._default_activities(intent, public_response, missing) + # Never let a short-circuited turn (greeting/ask_question/ask_missing_input/ + # clarify/handoff) send a blank message to the user. If the LLM planner and + # the deterministic fallback both produced nothing, ask a safe generic + # follow-up instead of silence. + if action in {"greeting", "ask_question", "ask_missing_input", "clarify", "handoff"} and not public_response: + if missing: + missing_labels = ", ".join(item.get("label") or item.get("id") for item in missing if item.get("id")) + public_response = f"Could you share your {missing_labels}?" if missing_labels else "Could you share a bit more detail so I can help?" + else: + public_response = "Could you share a bit more detail so I can help with that?" + response_text = public_response or "" return AgentTurnPlan( intent=intent, diff --git a/apps/api/app/services/conversation_scope_store.py b/apps/api/app/services/conversation_scope_store.py index e207ef6..751fcb6 100644 --- a/apps/api/app/services/conversation_scope_store.py +++ b/apps/api/app/services/conversation_scope_store.py @@ -224,6 +224,78 @@ async def has_long_term_memory_consent( raise ConversationScopeStoreError("Conversation scope storage is unavailable") from exc return bool(document) + async def set_birth_profile( + self, + *, + conversation_id: str, + user_id: str, + agent_id: str, + birth_profile: dict[str, Any], + ) -> None: + """Persist a confirmed Lal Kitab birth profile for this conversation. + + This is the durable record a form-first astrology flow gates chat on: + once confirmed, later turns never need to re-extract birth details + from free text. Scoped to the same (conversation_id, user_id, + agent_id) identity as the rest of this store, so it inherits the same + TTL/erasure model as everything else here — no separate retention + policy to maintain. + """ + collection = self._collection() + now = self._utc_now() + try: + result = await collection.update_one( + { + "_id": conversation_id, + "user_id": user_id, + "agent_id": agent_id, + "privacy.status": {"$ne": "erased"}, + }, + { + "$set": { + "lalkitab_birth_profile": { + **birth_profile, + "confirmed": True, + "confirmed_at": now, + }, + }, + }, + ) + except Exception as exc: + logger.warning("conversation_scope_birth_profile_write_failed", error_type=type(exc).__name__) + raise ConversationScopeStoreError("Conversation scope storage is unavailable") from exc + if not getattr(result, "matched_count", 0): + raise ConversationScopeAuthorizationError("Widget session is not authorized for this conversation") + + async def get_birth_profile( + self, + *, + conversation_id: str, + user_id: str, + agent_id: str, + ) -> dict[str, Any] | None: + """Return the confirmed birth profile for this conversation, if any.""" + collection = self._collection() + try: + document = await collection.find_one( + { + "_id": conversation_id, + "user_id": user_id, + "agent_id": agent_id, + "privacy.status": {"$ne": "erased"}, + }, + {"lalkitab_birth_profile": 1}, + ) + except Exception as exc: + logger.warning("conversation_scope_birth_profile_read_failed", error_type=type(exc).__name__) + raise ConversationScopeStoreError("Conversation scope storage is unavailable") from exc + if not isinstance(document, dict): + return None + profile = document.get("lalkitab_birth_profile") + if not isinstance(profile, dict) or not profile.get("confirmed"): + return None + return profile + async def require_active_widget_scope( self, *, diff --git a/apps/api/app/services/inquiry_delivery_service.py b/apps/api/app/services/inquiry_delivery_service.py new file mode 100644 index 0000000..2a1b76d --- /dev/null +++ b/apps/api/app/services/inquiry_delivery_service.py @@ -0,0 +1,135 @@ +""" +Inquiry Delivery Service. + +Sends a captured inquiry to the brand owner. Testing uses plain SMTP email; +production can swap in a transactional provider or route to a webhook/CRM +without changing the calling code (InquiryTool / inquiry_service). + +PII discipline: never log raw contact fields (name/email/phone). Log only +inquiry_id, brand, and delivery status. +""" + +from __future__ import annotations + +import asyncio +import smtplib +from dataclasses import dataclass +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from typing import Any + +import structlog + +from ..config import Settings + +# smtplib is synchronous/blocking. Running it directly inside an `async def` +# stalls the ENTIRE asyncio event loop (every other request on this process) +# until the socket call resolves or times out. It must always run in a worker +# thread with a hard timeout, never awaited directly on the event loop. +_SMTP_TIMEOUT_SECONDS = 15 + +logger = structlog.get_logger(__name__) + + +@dataclass +class DeliveryResult: + success: bool + method: str + error: str | None = None + + +def _format_inquiry_email_body(inquiry: dict[str, Any]) -> str: + contact = inquiry.get("contact") or {} + product = inquiry.get("product_context") or {} + lines = [ + "New inquiry received via the AI assistant.", + "", + f"Product: {product.get('name') or 'Not specified'}" + + (f" (SKU: {product.get('sku')})" if product.get("sku") else ""), + "", + "Contact details:", + f" Name: {contact.get('name') or '-'}", + f" Phone: {contact.get('phone') or '-'}", + f" Email: {contact.get('email') or '-'}", + f" City: {contact.get('city') or '-'}", + f" Country: {contact.get('country') or '-'}", + "", + "Query:", + f" {inquiry.get('requirements') or '-'}", + "", + f"Inquiry ID: {inquiry.get('inquiry_id')}", + f"Conversation ID: {inquiry.get('conversation_id')}", + f"Brand: {inquiry.get('brand_slug')}", + ] + return "\n".join(lines) + + +class InquiryDeliveryService: + """Pluggable inquiry delivery. Test path: SMTP email. Extend with webhook/CRM later.""" + + def __init__(self, settings: Settings): + self.settings = settings + + async def send(self, inquiry: dict[str, Any], agent_config: dict[str, Any] | None = None) -> DeliveryResult: + inquiry_config = (agent_config or {}).get("inquiry") or {} + method = str(inquiry_config.get("delivery_method") or "email").lower() + + if method == "email": + try: + # Run the blocking smtplib call in a worker thread so a slow/ + # unreachable SMTP server cannot freeze the whole event loop. + return await asyncio.wait_for( + asyncio.to_thread(self._send_email, inquiry, inquiry_config), + timeout=_SMTP_TIMEOUT_SECONDS + 5, + ) + except asyncio.TimeoutError: + logger.error( + "inquiry_delivery_timed_out", + inquiry_id=inquiry.get("inquiry_id"), + method="email", + ) + return DeliveryResult(success=False, method="email", error="Email delivery timed out.") + + # Only email is implemented for the test phase; other methods are + # rejected explicitly rather than silently dropped. + logger.warning("inquiry_delivery_unsupported_method", method=method, inquiry_id=inquiry.get("inquiry_id")) + return DeliveryResult(success=False, method=method, error=f"Delivery method '{method}' is not implemented yet.") + + def _resolve_recipient(self, inquiry_config: dict[str, Any]) -> str: + return ( + str(inquiry_config.get("recipient_email") or "").strip() + or str(self.settings.INQUIRY_FALLBACK_RECIPIENT or "").strip() + ) + + def _send_email(self, inquiry: dict[str, Any], inquiry_config: dict[str, Any]) -> DeliveryResult: + recipient = self._resolve_recipient(inquiry_config) + inquiry_id = inquiry.get("inquiry_id") + + if not recipient: + logger.warning("inquiry_delivery_no_recipient", inquiry_id=inquiry_id) + return DeliveryResult(success=False, method="email", error="No recipient_email configured for this agent.") + + if not self.settings.SMTP_HOST or not self.settings.SMTP_USERNAME or not self.settings.SMTP_PASSWORD: + logger.warning("inquiry_delivery_smtp_not_configured", inquiry_id=inquiry_id) + return DeliveryResult(success=False, method="email", error="SMTP is not configured on the server.") + + product = inquiry.get("product_context") or {} + subject = f"New Inquiry: {product.get('name') or 'Product'} — {inquiry.get('brand_slug') or ''}".strip() + + message = MIMEMultipart() + message["From"] = self.settings.SMTP_FROM_EMAIL or self.settings.SMTP_USERNAME + message["To"] = recipient + message["Subject"] = subject + message.attach(MIMEText(_format_inquiry_email_body(inquiry), "plain")) + + try: + with smtplib.SMTP(self.settings.SMTP_HOST, self.settings.SMTP_PORT, timeout=_SMTP_TIMEOUT_SECONDS) as server: + if self.settings.SMTP_USE_TLS: + server.starttls() + server.login(self.settings.SMTP_USERNAME, self.settings.SMTP_PASSWORD) + server.sendmail(message["From"], [recipient], message.as_string()) + logger.info("inquiry_delivered", inquiry_id=inquiry_id, method="email") + return DeliveryResult(success=True, method="email") + except Exception as exc: # pragma: no cover - network/SMTP errors + logger.error("inquiry_delivery_failed", inquiry_id=inquiry_id, method="email", error=str(exc)) + return DeliveryResult(success=False, method="email", error=str(exc)) diff --git a/apps/api/app/services/inquiry_service.py b/apps/api/app/services/inquiry_service.py new file mode 100644 index 0000000..b6c1a41 --- /dev/null +++ b/apps/api/app/services/inquiry_service.py @@ -0,0 +1,104 @@ +""" +Inquiry persistence (brand-isolated). + +Mirrors observability_service.py's pattern: write into the brand's own +MongoDB database (auto-created on first insert, no manual setup needed). +""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from typing import Any + +import structlog + +from ..connections import connection_manager + +logger = structlog.get_logger(__name__) + +COLLECTION_NAME = "inquiries" +_indexed_brand_dbs: set[str] = set() + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +class InquiryService: + async def _ensure_indexes(self, brand_slug: str) -> None: + db = connection_manager.get_brand_db(brand_slug) + db_name = db.name + if db_name in _indexed_brand_dbs: + return + collection = db[COLLECTION_NAME] + await collection.create_index([("inquiry_id", 1)], unique=True) + await collection.create_index([("agent_id", 1), ("created_at", -1)]) + await collection.create_index([("conversation_id", 1)]) + _indexed_brand_dbs.add(db_name) + logger.info("inquiry_indexes_created", brand_slug=brand_slug) + + async def save_inquiry( + self, + *, + brand_slug: str, + brand_id: str | None, + agent_id: str | None, + conversation_id: str | None, + contact: dict[str, Any], + requirements: str, + product_context: dict[str, Any] | None = None, + ) -> dict[str, Any]: + await self._ensure_indexes(brand_slug) + db = connection_manager.get_brand_db(brand_slug) + + inquiry_id = str(uuid.uuid4()) + doc = { + "inquiry_id": inquiry_id, + "brand_id": brand_id, + "brand_slug": brand_slug, + "agent_id": agent_id, + "conversation_id": conversation_id, + "status": "submitted", + "product_context": product_context or {}, + "contact": contact, + "requirements": requirements, + "delivery": {"method": None, "delivered_at": None, "error": None}, + "created_at": _utc_now(), + "updated_at": _utc_now(), + } + await db[COLLECTION_NAME].insert_one(doc) + # Never log raw contact fields (PII discipline). + logger.info("inquiry_saved", inquiry_id=inquiry_id, brand_slug=brand_slug, agent_id=agent_id) + return doc + + async def mark_delivered(self, *, brand_slug: str, inquiry_id: str, method: str) -> None: + db = connection_manager.get_brand_db(brand_slug) + await db[COLLECTION_NAME].update_one( + {"inquiry_id": inquiry_id}, + { + "$set": { + "status": "delivered", + "delivery.method": method, + "delivery.delivered_at": _utc_now(), + "delivery.error": None, + "updated_at": _utc_now(), + } + }, + ) + logger.info("inquiry_marked_delivered", inquiry_id=inquiry_id, brand_slug=brand_slug) + + async def mark_failed(self, *, brand_slug: str, inquiry_id: str, method: str, error: str) -> None: + db = connection_manager.get_brand_db(brand_slug) + await db[COLLECTION_NAME].update_one( + {"inquiry_id": inquiry_id}, + { + "$set": { + "status": "delivery_failed", + "delivery.method": method, + "delivery.error": error, + "updated_at": _utc_now(), + } + }, + ) + logger.warning("inquiry_marked_failed", inquiry_id=inquiry_id, brand_slug=brand_slug, error=error) diff --git a/apps/api/app/services/knowledge_service.py b/apps/api/app/services/knowledge_service.py index 896a7bd..1c67dfa 100644 --- a/apps/api/app/services/knowledge_service.py +++ b/apps/api/app/services/knowledge_service.py @@ -797,6 +797,9 @@ def _display_product_price( *, price_unit: Optional[str] = None, ) -> str: + if price is None or price == "": + # Inquiry-based catalog: no fixed price, direct customers to inquire. + return "Contact for pricing (send inquiry)" try: numeric_price = Decimal(str(price)) if price_unit == "minor": diff --git a/apps/api/app/services/lalkitab_interpretation.py b/apps/api/app/services/lalkitab_interpretation.py new file mode 100644 index 0000000..f0b54ee --- /dev/null +++ b/apps/api/app/services/lalkitab_interpretation.py @@ -0,0 +1,258 @@ +"""Read-only structured interpretation context for the Lal Kitab LLM prompt. + +This module never calls the Vedika API, never geocodes, and never invents or +recalculates chart data. It only reshapes the *already-calculated and +already-validated* ``api_context["chart_context"]`` (plus any secondary +endpoint results ``lalkitab_runtime.py`` already fetched and cached) into an +explicit, LLM-friendly structure: planets, houses, ascendant, dasha, and +divisional charts. + +``api_context["chart_context"]`` remains the single source of truth for both +the visual kundali chart (``kundali_chart.py`` / ``KundaliChart.tsx``) and +this interpretation context — this module is a projection over that same +data, not a second calculation path. +""" + +from __future__ import annotations + +import re +from typing import Any + +_DASHA_KEY_PATTERN = re.compile(r"dasha", re.IGNORECASE) +_DIVISIONAL_KEY_PATTERN = re.compile( + r"^(?:d\d{1,2}|navamsa|dashamsa|hora|drekkana|saptamsa|dasamsa|" + r"divisional|varga|shodasavarga)\b", + re.IGNORECASE, +) + +# Generic greetings / small talk / "what can you do" style capability +# questions must never be answered from chart data, even once a confirmed +# chart already exists for the conversation — a Lal Kitab agent should still +# be able to say hello or describe itself without dragging in astrology. +_META_OR_GREETING_PATTERN = re.compile( + r"^\s*(?:hi|hello|hey|namaste|namaskar|thanks|thank you|ok|okay|yes|no|bye|goodbye)\s*[.!?]*\s*$" + r"|\bwhat (?:can|do|you can) you do\b" + r"|\bwhat you can do\b" + r"|\bwho are you\b" + r"|\bwhat are you\b" + r"|\bhow (?:do|does) (?:this|it|you) work\b" + r"|\bwhat is this\b", + re.IGNORECASE, +) + + +# lalkitab_runtime.py's own astrology-intent keyword list (kept unchanged +# there deliberately) does not cover every term a Lal Kitab agent should +# still recognize — "dasha" in particular is entirely absent, so a message +# like "What is my current dasha?" fails its `message_requires_lalkitab_api` +# check, is treated as not handled, and falls through to the generic +# Orchestrator, which then calls raw Vedika connector tools directly and +# skips the safe chart-first ordering/caching that runtime provides. This +# is a supplementary check, additive only: it is used to force such a +# message onto the dedicated Lal Kitab runtime rather than replacing any +# existing detection. +_SUPPLEMENTARY_ASTROLOGY_INTENT_PATTERN = re.compile( + r"\bdasha\b|\bmahadasha\b|\bantardasha\b|\bbhukti\b|" + r"\bnavamsa\b|\bdashamsa\b|\bdivisional\s+chart\b|" + r"\brich\b|\bwealthy\b|\bfortune\b|\bdestiny\b|" + # Any keyword-list approach is inherently incomplete against natural + # phrasing ("married" vs "marriage", "job" vs "career", "money" vs + # "wealth"...). Once a validated chart already exists for this + # conversation, virtually any question the user asks is meant for that + # chart — so life-topic words are covered broadly here rather than one + # exact keyword at a time. + r"\bmarr(?:y|ied|iage)\b|\bspouse\b|\bhusband\b|\bwife\b|" + r"\bjob\b|\bwork\b|\bbusiness\b|\bpromotion\b|\bincome\b|\bmoney\b|" + r"\bfinance\b|\beducation\b|\bstudy\b|\bstudies\b|\bexam\b|" + r"\bhealth\b|\billness\b|\bdisease\b|\btravel\b|" + r"\bchildren\b|\bkids\b|\bfamily\b|\blove\b|\brelationship\b", + re.IGNORECASE, +) + + +def is_lalkitab_astrology_intent_message(message: str) -> bool: + """True for messages carrying astrology intent that + lalkitab_runtime.py's own keyword detection does not cover (see the + module note above) — additive only, never used to exclude a message + that runtime would already recognize on its own.""" + return bool(_SUPPLEMENTARY_ASTROLOGY_INTENT_PATTERN.search(message or "")) + + +def is_lalkitab_probe_forced_by_existing_chart( + message: str, + cached_chart_context: Any, +) -> bool: + """True when a validated chart already exists for this conversation and + the message is not a meta/greeting question — meaning it should be + treated as astrology intent regardless of any keyword list. + + This is the durable fix for the underlying problem keyword lists keep + running into: once the user already has a confirmed, validated chart on + screen, almost anything they type next ("where will I get married?", + "how's my job?", "what about kids?"...) is a real question about that + chart, not something a fixed vocabulary can ever fully enumerate. + lalkitab_runtime.py's own `is_valid_lalkitab_context_payload` already + defines what counts as a validated chart; this function only reads that + same signal, it does not duplicate or alter it. + """ + if is_lalkitab_meta_or_greeting_message(message): + return False + from .lalkitab_runtime import is_valid_lalkitab_context_payload + + return is_valid_lalkitab_context_payload(cached_chart_context) + + +def is_lalkitab_meta_or_greeting_message(message: str) -> bool: + """True for generic greetings/small talk/capability questions that should + never be routed into chart-based answering, even with a cached chart.""" + text = (message or "").strip() + if not text: + return True + return bool(_META_OR_GREETING_PATTERN.search(text)) + + +def _collect_matching_subtrees(node: Any, pattern: re.Pattern, *, _depth: int = 0) -> dict[str, Any]: + """Collect ``{key: value}`` for any dict key at any depth matching `pattern`. + + Provider response shapes vary, so dasha/divisional-chart data may live + under any nesting level and under several plausible field names. This + only reads keys that are already present in the calculated payload — + it never fabricates a key or value. + """ + found: dict[str, Any] = {} + if _depth > 6: + return found + if isinstance(node, dict): + for key, value in node.items(): + if isinstance(key, str) and pattern.search(key): + found.setdefault(str(key), value) + found.update(_collect_matching_subtrees(value, pattern, _depth=_depth + 1)) + elif isinstance(node, list): + for item in node: + found.update(_collect_matching_subtrees(item, pattern, _depth=_depth + 1)) + return found + + +def strip_unchanged_birth_fields( + birth_profile: dict[str, Any] | None, + cached_normalized_birth_input: dict[str, Any] | None, +) -> dict[str, Any] | None: + """Drop birth_date/time/place from a birth profile when they merely + restate the already-cached, already-charted values rather than + genuinely new information for this turn. + + The birth-profile extractor deliberately carries forward the + confirmed date/time/place on every later turn (so a follow-up like + "what about my career?" never needs to repeat them) — but + ``build_lalkitab_runtime_context`` treats the mere *presence* of those + fields on ``birth_profile`` as "the user just gave new birth details, + recalculate the chart." Passed through unfiltered, that combination + means a cached, still-valid chart gets silently ignored and Vedika is + re-queried on literally every turn, which is both unnecessary and the + direct cause of hitting the provider's rate limit far more than a real + conversation should. Once a value here matches the cached one, it is + not new — dropping it here does not affect anything if the values + genuinely differ (a real correction/new detail is still passed through). + """ + if not isinstance(birth_profile, dict): + return birth_profile + cached = cached_normalized_birth_input if isinstance(cached_normalized_birth_input, dict) else {} + if not cached: + return birth_profile + + def _same(profile_key: str, *cache_keys: str) -> bool: + value = birth_profile.get(profile_key) + if not value: + return False + value_text = str(value).strip().lower() + return any(str(cached.get(key) or "").strip().lower() == value_text for key in cache_keys) + + filtered = dict(birth_profile) + if _same("birth_date", "date", "birth_date"): + filtered.pop("birth_date", None) + if _same("birth_time", "time", "birth_time"): + filtered.pop("birth_time", None) + if _same("birth_place", "birth_place", "birth_place_resolved"): + filtered.pop("birth_place", None) + return filtered + + +def build_lalkitab_interpretation_context(api_context: dict[str, Any] | None) -> dict[str, Any]: + """Build a clean, explicit interpretation context from the validated + chart context (plus already-fetched secondary endpoint results). + + ``api_context["chart_context"]`` remains the single source of truth; + this function only reads and reorganizes already-calculated data — it + never calls Vedika, never geocodes, and never fills in a value that is + not present in the data. Callers should treat every field here as + directly answerable to the user without hedging, and treat every + missing field as genuinely unavailable (never guessed). + """ + context = api_context if isinstance(api_context, dict) else {} + chart = context.get("chart_context") + secondaries = ( + context.get("secondary_endpoint_results") + if isinstance(context.get("secondary_endpoint_results"), dict) + else {} + ) + + interpretation: dict[str, Any] = { + "ascendant": None, + "planets": chart if isinstance(chart, (dict, list)) else None, + "houses": None, + "dasha": None, + "divisional_charts": None, + "debts": None, + "predictions": None, + "remedies": None, + "totke": None, + "lucky_factors": None, + "varshphal": None, + } + + if isinstance(chart, dict): + for key in ("ascendant", "lagna", "asc"): + if chart.get(key) is not None: + interpretation["ascendant"] = chart.get(key) + break + for key in ("houses", "house_list", "bhava_chart"): + if chart.get(key) is not None: + interpretation["houses"] = chart.get(key) + break + for key in ("debts", "rin", "karmic_debts"): + if chart.get(key) is not None: + interpretation["debts"] = chart.get(key) + break + + dasha_hits: dict[str, Any] = {} + dasha_hits.update(_collect_matching_subtrees(chart, _DASHA_KEY_PATTERN)) + dasha_hits.update(_collect_matching_subtrees(secondaries, _DASHA_KEY_PATTERN)) + if dasha_hits: + interpretation["dasha"] = dasha_hits + + divisional_hits: dict[str, Any] = {} + divisional_hits.update(_collect_matching_subtrees(chart, _DIVISIONAL_KEY_PATTERN)) + divisional_hits.update(_collect_matching_subtrees(secondaries, _DIVISIONAL_KEY_PATTERN)) + if divisional_hits: + interpretation["divisional_charts"] = divisional_hits + + if isinstance(secondaries, dict): + if secondaries.get("lalkitab_predictions") is not None: + interpretation["predictions"] = secondaries.get("lalkitab_predictions") + if secondaries.get("lalkitab_remedies") is not None: + interpretation["remedies"] = secondaries.get("lalkitab_remedies") + if secondaries.get("lalkitab_totke") is not None: + interpretation["totke"] = secondaries.get("lalkitab_totke") + if secondaries.get("lalkitab_lucky") is not None: + interpretation["lucky_factors"] = secondaries.get("lalkitab_lucky") + if secondaries.get("lalkitab_varshphal") is not None: + interpretation["varshphal"] = secondaries.get("lalkitab_varshphal") + if secondaries.get("lalkitab_houses") is not None and interpretation["houses"] is None: + interpretation["houses"] = secondaries.get("lalkitab_houses") + if secondaries.get("lalkitab_debts") is not None and interpretation["debts"] is None: + interpretation["debts"] = secondaries.get("lalkitab_debts") + + # Drop empty/None keys so the LLM sees a compact, honest context — no + # placeholder nulls that could be misread as "this field exists but is + # empty" when the data was simply never fetched or never present. + return {key: value for key, value in interpretation.items() if value not in (None, {}, [])} diff --git a/apps/api/app/services/lalkitab_runtime.py b/apps/api/app/services/lalkitab_runtime.py index b766ba2..ac7722f 100644 --- a/apps/api/app/services/lalkitab_runtime.py +++ b/apps/api/app/services/lalkitab_runtime.py @@ -984,6 +984,22 @@ def _refresh_datetime() -> None: result.tool_results[tool.name] = tool_result metadata = tool_result.metadata or {} payload_valid = tool_result.success and is_valid_lalkitab_context_payload(tool_result.data) + if not payload_valid: + # Vedika is a third-party API that has already failed for several + # distinct reasons this integration (missing/malformed auth + # header, rate limiting) with no other visible signal — the + # widget/endpoint only ever sees a generic "could not calculate a + # verified chart" message. Log the real reason (safe: connector + # error text/HTTP status only, never birth details or the API + # key) so a recurrence can be diagnosed without another live + # paid API call. + logger.warning( + "lalkitab_connector_result_unavailable", + endpoint_id=endpoint_id, + tool_success=tool_result.success, + tool_error=tool_result.error, + latency_ms=metadata.get("latency_ms"), + ) event_type = "connector_result" if payload_valid else "connector_error" result.events.append( { diff --git a/apps/api/app/services/message_service.py b/apps/api/app/services/message_service.py index 66623e0..5f22d2a 100644 --- a/apps/api/app/services/message_service.py +++ b/apps/api/app/services/message_service.py @@ -31,6 +31,8 @@ # Phase 6: SOTA Agentic Orchestrator (package imports) from tools.registry import ToolRegistry from tools.builtin.retrieval_tool import CatalogSearchTool, RetrievalTool +from tools.builtin.inquiry_tool import InquiryTool +from tools.builtin.open_inquiry_form_tool import OpenInquiryFormTool from tools.types import ToolResult from agent_runtime.orchestrator import Orchestrator, AgentResult from agent_runtime.orchestrator_shopify import ShopifyOrchestrator @@ -46,6 +48,8 @@ from .commerce_config import is_commerce_agent_config, normalize_commerce_configuration from . import commerce_response as _commerce_response from .observability_service import ObservabilityService +from .inquiry_service import InquiryService +from .inquiry_delivery_service import InquiryDeliveryService from .prompt_assembler import PromptAssembler from .skill_registry import BuiltInSkillRegistry from .artifact_registry import is_artifact_enabled @@ -56,6 +60,15 @@ extract_kundali_chart_summary, is_lalkitab_agent, is_valid_lalkitab_context_payload, + message_contains_birth_details, + message_requires_lalkitab_api, +) +from .lalkitab_interpretation import ( + build_lalkitab_interpretation_context, + is_lalkitab_astrology_intent_message, + is_lalkitab_meta_or_greeting_message, + is_lalkitab_probe_forced_by_existing_chart, + strip_unchanged_birth_fields, ) from .conversation_policy import ( activity_stream_response_kwargs, @@ -114,6 +127,27 @@ "code": "generation_failed", "retryable": True, } +INQUIRY_CANCEL_PATTERN = re.compile( + r"\b(cancel|never\s*mind|nevermind|forget it|stop this|not now|don'?t send|do not send|skip this|no longer)\b", + re.IGNORECASE, +) +INQUIRY_AFFIRM_PATTERN = re.compile( + r"^\s*(yes|yeah|yep|yup|correct|confirm(ed)?|go ahead|please send|send it|that'?s right|looks good|sounds good|ok(ay)?)\b", + re.IGNORECASE, +) +# Explicit purchase-intent / inquiry triggers (product-card "Send Inquiry for +# Price" click, or the user typing it). Matched on a FRESH turn (no pending +# inquiry state yet) to deterministically start the tool - the LLM planner is +# not reliable about including submit_inquiry in tool_plan on its own, +# especially once a long/SKU-heavy product name is in the message. +INQUIRY_TRIGGER_PATTERN = re.compile( + r"\b(send\s+(?:an?\s+)?inquir(?:y|ies)|inquir(?:e|y)\s+(?:for|about)|" + r"request\s+a?\s*quote|get\s+a?\s*quote|want\s+a?\s*quot(?:e|ation)|" + r"quotation\s+for|contact\s+sales|bulk\s+order|want\s+to\s+buy|" + r"have\s+(?:your\s+)?team\s+contact\s+me|reach\s+out\s+to\s+me|" + r"contact\s+me\s+(?:about|regarding))\b", + re.IGNORECASE, +) INTERNAL_SOURCE_TERMS = re.compile( r"\b(api|rag|chunk|chunks|connector|connectors|endpoint|endpoints|tool call|tool calls|tool-backed|" r"execution history|observation|observations|geocode lookup|runtime|mcp)\b", @@ -365,6 +399,9 @@ def __init__(self, settings: Settings, brand_id: Optional[str] = None, agent_id: api_token=settings.STRAPI_API_TOKEN, ) self.observability = ObservabilityService() + self.inquiry_service = InquiryService() + self.inquiry_delivery_service = InquiryDeliveryService(settings) + self._current_conversation_id: Optional[str] = None logger.info("message_service_initialized", brand_id=self.brand_id) @@ -695,12 +732,44 @@ def _lalkitab_pending_from_policy(self, policy_state: dict, turn_plan) -> dict: ("birth_place", "birth_place"), ) + def _lalkitab_requires_birth_profile_form(self, config: dict[str, Any] | None) -> bool: + """Whether this Lal Kitab agent uses the mandatory pre-chat form flow. + + Default ON for every astrology_lalkitab agent — the form-first flow + (birth-details form -> full chart/report -> chat about your own + chart) is the standard Lal Kitab experience. Set + configuration.lalkitab.require_birth_profile_form = false explicitly + to opt a specific agent OUT and keep the legacy chat-based birth + detail collection instead. + """ + config = config if isinstance(config, dict) else {} + if not is_lalkitab_agent(config): + return False + lalkitab_cfg = config.get("lalkitab") if isinstance(config.get("lalkitab"), dict) else {} + flag = lalkitab_cfg.get("require_birth_profile_form") + return True if flag is None else bool(flag) + + async def _load_confirmed_lalkitab_profile( + self, *, conversation_id: str, user_id: str, agent_id: str + ) -> dict[str, Any] | None: + """Read the confirmed birth profile persisted by POST /lalkitab/profile.""" + try: + return await conversation_scope_store.get_birth_profile( + conversation_id=conversation_id, + user_id=user_id, + agent_id=agent_id, + ) + except ConversationScopeStoreError: + logger.warning("lalkitab_confirmed_profile_read_failed", conversation_id=conversation_id) + return None + async def _prepare_lalkitab_turn( self, turn_plan: AgentTurnPlan, message: str, policy_state: dict[str, Any], llm_provider: Any | None = None, + confirmed_profile: dict[str, Any] | None = None, ) -> tuple[AgentTurnPlan, dict[str, Any] | None]: """LLM-first understanding for Lal Kitab turns. @@ -709,6 +778,11 @@ async def _prepare_lalkitab_turn( to correct the planner's view of the turn, so a person's name or the question can never be treated as the birth place. Returns the adapted plan plus the profile for the chart-first runtime. + + confirmed_profile, when present (form-first flow, see + _lalkitab_requires_birth_profile_form), seeds the birth details so the + extractor only needs to separate the user's actual question from the + message — it never needs to re-derive date/time/place from chat text. """ if not is_lalkitab_agent(self.agent_config or {}): return turn_plan, None @@ -717,6 +791,10 @@ async def _prepare_lalkitab_turn( **(policy_state.get("resolved_inputs") if isinstance(policy_state.get("resolved_inputs"), dict) else {}), **(turn_plan.resolved_inputs or {}), } + if confirmed_profile: + for key in ("name", "birth_date", "birth_time", "birth_place"): + if confirmed_profile.get(key): + prior_profile[key] = confirmed_profile[key] try: profile = await extract_birth_profile( message, @@ -763,6 +841,34 @@ async def _prepare_lalkitab_turn( return self._adapt_turn_plan_for_lalkitab(turn_plan), profile + def _lalkitab_probe_message(self, message: str, cached_chart_context: Any = None) -> str: + """Text handed to build_lalkitab_runtime_context for its own + keyword-based intent detection only (never used for the actual + answer, endpoint payloads, or anything shown to the user). + + lalkitab_runtime.py's astrology-intent keyword list is kept + unchanged there deliberately, but any fixed keyword list is + inherently incomplete against natural phrasing ("dasha", "married" + vs "marriage", "job" vs "career"...). A message that keyword list + doesn't recognize is treated as not handled and falls through to + the generic Orchestrator, which calls raw Vedika tools directly and + skips the safe chart-first ordering/caching/RAG steps that runtime + provides — this is what caused fewer/incorrect steps to run for + some questions. Once a validated chart already exists for this + conversation and the message isn't a meta/greeting question, it + should be routed to the dedicated runtime regardless of vocabulary; + appending a recognized hint only in that gap fixes the routing + without touching lalkitab_runtime.py or changing what the user + actually asked. + """ + if message_requires_lalkitab_api(message) or message_contains_birth_details(message): + return message + if is_lalkitab_astrology_intent_message(message) or is_lalkitab_probe_forced_by_existing_chart( + message, cached_chart_context, + ): + return f"{message} (lal kitab chart prediction)" + return message + def _adapt_turn_plan_for_lalkitab(self, turn_plan: AgentTurnPlan) -> AgentTurnPlan: """Keep Lal Kitab turns on the chart-first runtime. @@ -811,6 +917,198 @@ def _adapt_turn_plan_for_lalkitab(self, turn_plan: AgentTurnPlan) -> AgentTurnPl } return turn_plan + async def _submit_inquiry(self, payload: dict, agent_config: dict) -> dict: + """Persist + deliver a captured inquiry. Called by InquiryTool.run(). + + Delivery failure does not lose the inquiry: it is always persisted + first, then delivery is attempted and the outcome recorded. + """ + product_context = { + "name": payload.get("product_name"), + } + contact = { + "name": payload.get("name"), + "phone": payload.get("phone"), + "email": payload.get("email"), + "city": payload.get("city"), + "country": payload.get("country"), + } + inquiry_doc = await self.inquiry_service.save_inquiry( + brand_slug=self.brand_id, + brand_id=(self.agent_record or {}).get("brand_id"), + agent_id=self.agent_id, + conversation_id=self._current_conversation_id, + contact=contact, + requirements=payload.get("query") or "", + product_context=product_context, + ) + delivery = await self.inquiry_delivery_service.send(inquiry_doc, agent_config) + if delivery.success: + await self.inquiry_service.mark_delivered( + brand_slug=self.brand_id, + inquiry_id=inquiry_doc["inquiry_id"], + method=delivery.method, + ) + await self.observability.track_event( + event_type="inquiry_submitted", + brand_slug=self.brand_id, + agent_id=self.agent_id, + conversation_id=self._current_conversation_id, + payload={"inquiry_id": inquiry_doc["inquiry_id"], "delivery_method": delivery.method}, + ) + return {"success": True, "inquiry_id": inquiry_doc["inquiry_id"], "delivered": True} + + await self.inquiry_service.mark_failed( + brand_slug=self.brand_id, + inquiry_id=inquiry_doc["inquiry_id"], + method=delivery.method, + error=delivery.error or "unknown_error", + ) + await self.observability.track_event( + event_type="inquiry_delivery_failed", + brand_slug=self.brand_id, + agent_id=self.agent_id, + conversation_id=self._current_conversation_id, + payload={"inquiry_id": inquiry_doc["inquiry_id"], "delivery_method": delivery.method, "error": delivery.error}, + ) + return {"success": False, "inquiry_id": inquiry_doc["inquiry_id"], "delivered": False, "error": delivery.error} + + def _configure_inquiry_tool_for_turn(self, conversation_id: str, session_state: dict) -> None: + """Attach the current turn's conversation_id and discussed-product + context to the registered inquiry tool, if one is active.""" + self._current_conversation_id = conversation_id + tool = self.tool_registry.get("submit_inquiry") if self.tool_registry else None + if not tool: + return + focus = session_state.get("active_product_focus") + product_focus = focus[0] if isinstance(focus, list) and focus else (focus if isinstance(focus, dict) else {}) + tool.active_product_focus = product_focus or {} + + async def _load_inquiry_pending_state(self, conversation_id: str) -> dict: + """Resume an in-flight inquiry across turns. + + Returns the fields collected so far and whether the flow is waiting on + the user's explicit confirmation. Stops at the first terminal marker + (submitted/cancelled) so an old, already-finished inquiry never gets + silently resumed by a later, unrelated conversation turn. + """ + if not self.short_term: + return {} + try: + recent = await self.short_term.get_recent_messages(conversation_id=conversation_id, limit=8) + except Exception as exc: # pragma: no cover - defensive + logger.warning("inquiry_pending_state_load_failed", error=str(exc)) + return {} + for msg in reversed(recent or []): + role = msg.role.value if hasattr(msg.role, "value") else str(msg.role) + if role != "assistant": + continue + meta = msg.metadata or {} + pending = meta.get("inquiry_pending") + if isinstance(pending, dict) and pending: + if pending.get("cancelled") or pending.get("submitted"): + return {} + if pending.get("resolved_inputs") or pending.get("awaiting_confirmation"): + return dict(pending) + return {} + + def _collect_inquiry_pending_state(self, tool_results: dict[str, Any]) -> dict: + """Derive the inquiry state to persist for the next turn from this + turn's submit_inquiry tool result, if one ran.""" + result = (tool_results or {}).get("submit_inquiry") if isinstance(tool_results, dict) else None + if result is None: + return {} + metadata = getattr(result, "metadata", None) or {} + if metadata.get("inquiry_submitted"): + return {"submitted": True} + resolved_inputs = metadata.get("resolved_inputs") if isinstance(metadata.get("resolved_inputs"), dict) else {} + if not resolved_inputs: + return {} + return { + "resolved_inputs": resolved_inputs, + "missing_input": metadata.get("missing_input") or [], + "awaiting_confirmation": bool(metadata.get("awaiting_confirmation")), + } + + def _adapt_turn_plan_for_inquiry( + self, turn_plan: AgentTurnPlan, message: str, pending_state: dict[str, Any] + ) -> AgentTurnPlan: + """V2: opens the inline inquiry form based on intent. + + The V1 conversational batch collection (InquiryTool, submit_inquiry, + pending state, field batching) is fully disabled. The inquiry form + (POST /api/v1/inquiry/submit) handles all contact-field collection. + + This method only: + 1. Lets through LLM-decided open_inquiry_form actions. + 2. Regex fallback for typed inquiry triggers the LLM may miss. + 3. Handles cancel phrases. + 4. Returns turn_plan unchanged for everything else (normal turns). + """ + if self.tool_registry is None: + return turn_plan + # The open_inquiry_form tool only exists in this agent's registry when + # configuration.inquiry.enabled == true (see + # _register_configured_capabilities). If it isn't registered, the + # whole inquiry feature must be inert for this agent — no LLM tool + # call path, and no regex fallback either. + if self.tool_registry.get("open_inquiry_form") is None: + return turn_plan + # LLM planner already decided to open the form — let it through + if turn_plan.action == "open_inquiry_form": + return turn_plan + # Regex fallback: typed triggers the LLM may have missed (e.g. it + # chose intent=answer instead of calling the open_inquiry_form tool) + if INQUIRY_TRIGGER_PATTERN.search(message or "") and turn_plan.action != "open_inquiry_form": + if not isinstance(turn_plan.resolved_inputs, dict): + turn_plan.resolved_inputs = {} + # Only extract product name for explicit product-mention verbs, + # not for generic support/contact/pricing phrases + _product_match = re.match( + r"(?i)^.*?(?:send\s+(?:an?\s+)?inquir(?:y|ies)|request\s+a?\s*quote" + r"|inquir(?:e|y)\s+for|want\s+a?\s*quot(?:e|ation)\s+for|quotation\s+for)" + r"(?:\s+for\s+(?:price\s+of|the\s+price\s+of|the|a))?\s*(.+)$", + (message or "").strip(), + ) + _pname = _product_match.group(1).strip() if _product_match else "" + # The optional "for the/a/price of" group above only matches as a + # whole — if the text after the trigger verb is bare "for " + # (no "the"/"a"/"price of"), that group fails entirely and the + # leading "for " leaks into the capture (e.g. "for Marshall Acton + # III"). Strip any leftover leading preposition unconditionally. + _pname = re.sub(r"^(?:for|about|regarding)\s+", "", _pname, flags=re.IGNORECASE).strip() + if _pname and re.match( + r"^(price|pricing|this|a quote|quotation|inquiry|customer support" + r"|support|an agent|a human|someone|the team|sales)$", + _pname, + re.IGNORECASE, + ): + _pname = "" + if _pname: + turn_plan.resolved_inputs["product_name"] = _pname + _open_msg = ( + f"Opening the quote form for **{_pname}** — please fill in your details." + if _pname + else "Opening the quote form — please fill in your details." + ) + turn_plan.tool_plan = [] + turn_plan.action = "open_inquiry_form" + turn_plan.intent = "open_inquiry_form" + turn_plan.public_response = _open_msg + turn_plan.response_text = _open_msg + return turn_plan + # Cancel phrase — short-circuit + if INQUIRY_CANCEL_PATTERN.search(message or ""): + turn_plan.tool_plan = [] + turn_plan.action = "clarify" + turn_plan.intent = "clarify" + cancel_msg = "No problem! Let me know if you need anything else." + turn_plan.public_response = cancel_msg + turn_plan.response_text = cancel_msg + turn_plan.context_decision = {**(turn_plan.context_decision or {}), "reason": "inquiry_cancelled"} + turn_plan.raw_plan = {**(turn_plan.raw_plan or {}), "_inquiry_cancelled": True} + return turn_plan + return turn_plan def _apply_remembered_connector_inputs(self, remembered_inputs: dict) -> None: """Push conversation-remembered inputs onto registered connector tools so follow-up tool calls auto-fill required fields (universal: any connector).""" @@ -1084,6 +1382,14 @@ async def _generate_lalkitab_agent_result( " House | Rashi | Planets, listing all 12 houses in order, built ONLY from the\n" " calculated chart context. Write \"—\" for empty houses. Never guess a placement." ) + # A clean, explicit projection of the SAME validated chart_context + # (planets, houses, ascendant, dasha, divisional charts) the visual + # kundali chart is built from — see lalkitab_interpretation.py. This + # is not a second calculation path; it only reshapes data already + # calculated once by the Vedika connector in lalkitab_runtime.py so + # the LLM can look up an answer directly instead of re-deriving it + # from the raw nested payload. + interpretation_context = build_lalkitab_interpretation_context(api_context) prompt = f""" {self.system_prompt} @@ -1092,11 +1398,20 @@ async def _generate_lalkitab_agent_result( affectionate ("beta", "child"), never clinical or system-like. Rules: -- Use the calculated context internally for chart/calculation facts. +- The "Chart Interpretation Context" below is the SAME calculated chart already + validated for this user — planets, houses, ascendant, dasha, and divisional charts + are all read directly from it. If the value the user asked about (Moon's house, + current dasha, a planet's sign, career/health/marriage-relevant houses, etc.) is + present there, answer it directly and confidently. Do not say you lack verified + information when the requested value is present in that context. +- Never invent a placement, house, dasha period, remedy, prediction, totke, lucky + factor, or divisional-chart value that is not present in the calculated context. + If a specific requested value is genuinely absent from it (e.g. no dasha data was + returned), say plainly that it was not part of the calculated data, rather than + guessing — but never claim "no verified information" about the chart as a whole + when other parts of it are available. - Use the knowledge context internally for interpretation policy, tone, explanations, FAQs, and Lal Kitab reference context. {confirmation_rule} -- Never invent chart placements, debts, remedies, predictions, totke, lucky factors, houses, or varshphal data. -- If calculated context is incomplete, ask for the missing detail or say that you cannot verify that part. - Speak like a human advisor helping the user, not like a system explaining its architecture. {source_rule} - Do not claim certainty beyond the provided sources. @@ -1114,7 +1429,7 @@ async def _generate_lalkitab_agent_result( house governs and what the placement means), like an astrologer walking through the chart aloud. 4. **The real matter** — directly address the user's question using the chart and any - prediction/houses/debts evidence. Name the strengths first, then the weak spot, + prediction/houses/debts/dasha evidence. Name the strengths first, then the weak spot, plainly and kindly. 5. **Lal Kitab remedies** — a short numbered list, only from remedy/totke evidence; practical conduct corrections, not just rituals. @@ -1133,7 +1448,11 @@ async def _generate_lalkitab_agent_result( Conversation (rolling memory + recent turns): {json.dumps(chat_history, default=str, indent=2)} -Calculated API Context: +Chart Interpretation Context (planets, houses, ascendant, dasha, divisional charts — +derived from the same validated calculated chart used for the visual kundali diagram): +{json.dumps(_sanitize_for_json(interpretation_context), default=str, indent=2)} + +Calculated API Context (raw): {json.dumps(_sanitize_for_json(api_context), default=str, indent=2)} Knowledge Context: @@ -1159,6 +1478,13 @@ async def _generate_lalkitab_agent_result( "used_cached_context": bool(getattr(lalkitab_plan, "used_cached_context", False)), "lalkitab_api_context_full": _sanitize_for_json(api_context), "lalkitab_rag_context_full": _sanitize_for_json(rag_context), + # Same projection injected into the prompt above, kept here so + # the claim-evidence guard's evidence pool always matches + # exactly what the LLM was actually shown (dasha/divisional + # data lives in secondary_endpoint_results, already part of + # lalkitab_api_context_full, but this keeps the mapping + # explicit and stable if that raw shape ever changes). + "lalkitab_interpretation_context_full": _sanitize_for_json(interpretation_context), "rag_context": { "chunks_count": len(rag_context.get("chunks") or []), "sources": rag_context.get("sources") or [], @@ -1534,7 +1860,17 @@ async def _execute_planner_tool_plan( tool_name = getattr(tool, "name", tool_id) tool_input = step.get("input") if isinstance(step.get("input"), dict) else {} payload = tool_input.get("payload") if isinstance(tool_input.get("payload"), dict) else {} - payload = {**resolved_inputs, **payload} + # open_inquiry_form is a stateless, single-turn signal (unlike + # submit_inquiry/connector tools, which intentionally remember + # fields across turns via resolved_inputs). Carrying forward a + # product_name resolved 2+ turns ago (e.g. from an earlier, + # different product's inquiry) would silently mislabel a fresh + # "bulk order" / "talk to a human" request with the wrong + # product. Only this turn's own tool-call arguments apply. + if tool_id == "open_inquiry_form": + payload = dict(payload) + else: + payload = {**resolved_inputs, **payload} run_kwargs = dict(tool_input) if "query" not in run_kwargs: run_kwargs["query"] = turn_plan.question or message @@ -1561,7 +1897,17 @@ async def _execute_planner_tool_plan( ) ) try: - result = await tool.run(**run_kwargs) + # Hard timeout on every tool call: a tool that blocks forever + # (e.g. a synchronous network call not offloaded to a thread) + # must never be able to hang the whole request/event loop. + result = await asyncio.wait_for(tool.run(**run_kwargs), timeout=30) + except asyncio.TimeoutError: + result = ToolResult( + success=False, + data=None, + error=f"{tool_name} timed out after 30s.", + metadata={"tool_id": tool_id, "tool_name": tool_name, "timed_out": True}, + ) except Exception as exc: # pragma: no cover - defensive result = ToolResult(success=False, data=None, error=str(exc), metadata={"tool_id": tool_id, "tool_name": tool_name}) tool_results[tool_name] = result @@ -1590,6 +1936,39 @@ async def _execute_planner_tool_plan( resolved_inputs.update({k: v for k, v in metadata["resolved_inputs"].items() if v not in (None, "")}) return tool_results, events + def _open_inquiry_form_short_circuit(self, tool_results: dict[str, ToolResult]) -> AgentResult | None: + """If the planner called open_inquiry_form this turn, short-circuit + straight to the widget-facing answer instead of paying for an LLM + writer call — the tool result already tells us exactly what to say + and what metadata the widget needs to render the inline form.""" + for result in (tool_results or {}).values(): + meta = getattr(result, "metadata", None) or {} + if result.success and meta.get("open_inquiry_form"): + product_name = str(meta.get("product_name") or "").strip() + answer = ( + f"Opening the quote form for **{product_name}** — please fill in your details." + if product_name + else "Opening the quote form — please fill in your details." + ) + return AgentResult( + answer=answer, + metadata={ + "tool_results": dict(tool_results or {}), + "steps_executed": len(tool_results or {}), + "validation_passed": True, + "validation_confidence": 1.0, + "open_inquiry_form": True, + "product_name": product_name, + # Fixed acknowledgement template, not a generated + # factual claim — a product name containing a digit + # (e.g. "Dali Sonik 3") would otherwise be treated as + # an unsupported numeric anchor by the claim-evidence + # guard downstream (_apply_post_response_guardrails). + "_trusted_safety_template": True, + }, + ) + return None + async def _generate_planner_agent_result( self, *, @@ -1600,6 +1979,9 @@ async def _generate_planner_agent_result( rag_tool_result: ToolResult | None = None, ) -> AgentResult: """Synthesize a public answer from the LLM-first plan and executed evidence.""" + short_circuit = self._open_inquiry_form_short_circuit(tool_results) + if short_circuit is not None: + return short_circuit all_tool_results = dict(tool_results or {}) if rag_tool_result: all_tool_results["knowledge_search"] = rag_tool_result @@ -1642,6 +2024,9 @@ async def _generate_planner_agent_result( - Answer the user directly in the configured agent style. - Use the evidence internally; do not fabricate missing facts. - If evidence is insufficient, ask for the next useful detail or say what you can answer without overclaiming. +- If any tool result above has a "missing_input" list in its metadata (for example the submit_inquiry tool), you MUST explicitly ask the user for exactly those fields by name in your reply, one short natural sentence — do not just acknowledge the request without asking. Ask for at most 2-3 fields at a time. +- If a tool result has "awaiting_confirmation": true in its metadata, show a short summary of the fields in its "summary" and explicitly ask the user to confirm before you send it (e.g. "Shall I send this?"). +- If a tool result has "inquiry_submitted": true in its metadata, confirm to the user that it was sent successfully and that the team will follow up. - {source_rule} """ response = await self.llm_provider.generate(prompt) @@ -1752,6 +2137,21 @@ def _register_configured_capabilities(self, config: dict) -> dict: self.tool_registry.register(connector_tool) registered_tools.append(connector_tool.name) + # V2: InquiryTool (V1 conversational batch collection) is disabled. + # The inquiry form (POST /api/v1/inquiry/submit) handles all field collection. + # inquiry_config = config.get("inquiry") or {} # kept for reference only + + # Skill-based tool calling: the LLM planner opens the inquiry form by + # explicitly calling this tool (structured tool_plan entry), same as + # any other registered tool/connector. This tool takes no contact + # fields and never touches PII — it only signals intent. It is + # registered ONLY when this agent has inquiry.enabled == true; if the + # flag is off, the tool does not exist in this agent's registry and + # the LLM has no way to call it, so the whole feature is inert. + if bool((config.get("inquiry") or {}).get("enabled")): + self.tool_registry.register(OpenInquiryFormTool()) + registered_tools.append("open_inquiry_form") + if registered_skills or registered_tools: logger.info( "agent_capabilities_registered", @@ -1822,6 +2222,11 @@ async def _ensure_memory_initialized(self): async def _load_agent_config(self, agent_id: str): """Load agent configuration from system database.""" + # Bound at function scope (not only inside the shopify-mcp branch below) + # so the `except McpDiscoveryError` clause can never itself raise + # UnboundLocalError if an unrelated exception occurs earlier in this + # function, before the shopify-only branch would otherwise import it. + from tools.mcp_client import McpDiscoveryError try: self.agent_id = agent_id # Initialize brand database first @@ -1877,7 +2282,7 @@ async def _load_agent_config(self, agent_id: str): # Initialize remote MCP tools only when live Shopify actions are enabled. # Catalog-backed ecommerce answers can run without MCP/customer auth. if config.get("data_source") == "shopify": - from tools.mcp_client import McpClient, McpDiscoveryError + from tools.mcp_client import McpClient from agent_runtime.orchestrator_shopify import ShopifyOrchestrator # For local development we assume port 3005 for the Shopify MCP Service @@ -2102,6 +2507,27 @@ async def process_message(self, request: MessageRequest) -> MessageResponse: decision=guardrail_decision, ) + confirmed_lalkitab_profile: dict[str, Any] | None = None + if self._lalkitab_requires_birth_profile_form(self.agent_config): + confirmed_lalkitab_profile = await self._load_confirmed_lalkitab_profile( + conversation_id=conversation_id, user_id=user_id, agent_id=agent_id + ) + if not confirmed_lalkitab_profile: + gate_message = ( + "Please complete the birth details form to generate your horoscope " + "before we continue — I need your confirmed chart to answer safely." + ) + duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000) + return MessageResponse( + message=gate_message, + conversation_id=conversation_id, + citations=[], + metadata={"requires_lalkitab_birth_profile_form": True}, + context_used=0, + confidence_score=1.0, + processing_time_ms=duration_ms, + ) + runtime_message = guardrail_decision.get("safe_query") or request.message capability_scope = guardrail_decision.get("capability_scope") user_metadata = { @@ -2141,8 +2567,16 @@ async def process_message(self, request: MessageRequest) -> MessageResponse: fallback_plan=fallback_turn_plan, ) turn_plan, lalkitab_profile = await self._prepare_lalkitab_turn( - turn_plan, runtime_message, policy_state, llm_provider=planner_provider + turn_plan, + runtime_message, + policy_state, + llm_provider=planner_provider, + confirmed_profile=confirmed_lalkitab_profile, ) + inquiry_pending_state = ( + await self._load_inquiry_pending_state(conversation_id) if short_term_enabled else {} + ) + turn_plan = self._adapt_turn_plan_for_inquiry(turn_plan, runtime_message, inquiry_pending_state) if turn_plan.should_short_circuit: response_text = turn_plan.response_text @@ -2155,6 +2589,21 @@ async def process_message(self, request: MessageRequest) -> MessageResponse: "validation_passed": True, "validation_confidence": 1.0, } + if (turn_plan.raw_plan or {}).get("_inquiry_cancelled"): + metadata["inquiry_pending"] = {"cancelled": True} + if turn_plan.action == "open_inquiry_form": + metadata["open_inquiry_form"] = True + _product_name = turn_plan.resolved_inputs.get("product_name") if isinstance(turn_plan.resolved_inputs, dict) else None + if _product_name: + metadata["product_name"] = _product_name + # This is a fixed, server-owned acknowledgement template + # ("Opening the quote form for ..."), not a + # generated factual claim. Any digit in the product name + # (e.g. "Dali Sonik 3", "Denon Home 150") would otherwise + # be treated as an unsupported numeric anchor by the + # claim-evidence guard below and get replaced with the + # generic low-confidence message. + metadata["_trusted_safety_template"] = True response_text, metadata = self._apply_post_response_guardrails(response_text, metadata) if short_term_enabled: await self.short_term.add_message( @@ -2189,6 +2638,7 @@ async def process_message(self, request: MessageRequest) -> MessageResponse: message=response_text, conversation_id=conversation_id, citations=[], + metadata=metadata, context_used=0, confidence_score=min(1.0, max(0.0, float(metadata.get("validation_confidence", 1.0)))), processing_time_ms=duration_ms, @@ -2225,6 +2675,7 @@ async def process_message(self, request: MessageRequest) -> MessageResponse: remembered_inputs = session_state.get("connector_inputs") or {} if remembered_inputs: self._apply_remembered_connector_inputs(remembered_inputs) + self._configure_inquiry_tool_for_turn(conversation_id, session_state) context_dict = turn_context.context @@ -2243,11 +2694,50 @@ async def process_message(self, request: MessageRequest) -> MessageResponse: **lalkitab_pending, **self._lalkitab_pending_from_policy(policy_state, turn_plan), } + # The chart persisted by POST /lalkitab/profile already + # contains a validated api_context. Short-term memory only + # has it after the first assistant turn, so without this the + # first chat turn(s) after the form has no previous chart to + # reuse and recalculates from Vedika again — seed it here so + # the already-generated chart is reused from turn one. + if not lalkitab_pending.get("api_context") and isinstance( + (confirmed_lalkitab_profile or {}).get("api_context"), dict + ): + lalkitab_pending["api_context"] = confirmed_lalkitab_profile["api_context"] + # A confirmed birth profile is carried into every later turn + # (so follow-up astrology questions never repeat birth + # details), but a bare greeting or "what can you do"-style + # capability question must still get a normal conversational + # answer rather than being routed into chart synthesis just + # because a profile happens to be attached to the session. + lalkitab_birth_profile = ( + None + if is_lalkitab_meta_or_greeting_message(runtime_message) + else (lalkitab_profile or confirmed_lalkitab_profile) + ) + # The confirmed profile's birth_date/time/place are carried + # forward on every turn by design (so follow-ups never need + # to repeat them) — but build_lalkitab_runtime_context reads + # their mere presence as "new birth details, recalculate." + # Strip whatever is unchanged from the already-cached chart + # so an ordinary follow-up question reuses that chart + # instead of re-querying Vedika (and hitting its rate limit) + # on every single turn. + lalkitab_birth_profile = strip_unchanged_birth_fields( + lalkitab_birth_profile, + lalkitab_pending.get("normalized_birth_input"), + ) lalkitab_plan = await build_lalkitab_runtime_context( self.agent_config or {}, - runtime_message, + # Intent-detection only (see _lalkitab_probe_message) — + # the real, unmodified runtime_message is still used + # everywhere else below for the actual answer. + self._lalkitab_probe_message( + runtime_message, + (lalkitab_pending.get("api_context") or {}).get("chart_context"), + ), pending_state=lalkitab_pending, - birth_profile=lalkitab_profile, + birth_profile=lalkitab_birth_profile, ) if lalkitab_plan.handled and ( @@ -2305,7 +2795,7 @@ async def process_message(self, request: MessageRequest) -> MessageResponse: lalkitab_plan=lalkitab_plan, rag_context=rag_context, rag_tool_result=rag_tool_result, - birth_profile=lalkitab_profile, + birth_profile=lalkitab_profile or confirmed_lalkitab_profile, ) # 4. RUN SOTA ORCHESTRATOR LOOP @@ -2424,6 +2914,12 @@ async def process_message(self, request: MessageRequest) -> MessageResponse: **(response_metadata or {}), "kundali_chart": _sanitize_for_json(agent_metadata["kundali_chart"]), } + if agent_metadata.get("open_inquiry_form"): + response_metadata = { + **(response_metadata or {}), + "open_inquiry_form": True, + "product_name": agent_metadata.get("product_name") or "", + } strapi_assistant_metadata = { "products": unique_products, "dealers": unique_dealers, @@ -2476,6 +2972,7 @@ async def process_message(self, request: MessageRequest) -> MessageResponse: "lalkitab_api_context": agent_metadata.get("lalkitab_api_context_full"), "lalkitab_rag_context": agent_metadata.get("lalkitab_rag_context_full"), "connector_inputs": self._collect_connector_inputs(session_state, lalkitab_plan, agent_metadata) or None, + "inquiry_pending": self._collect_inquiry_pending_state(tool_results) or None, } ) @@ -2677,6 +3174,38 @@ async def stream_message(self, request: MessageRequest) -> AsyncGenerator[Stream ) return + confirmed_lalkitab_profile: dict[str, Any] | None = None + if self._lalkitab_requires_birth_profile_form(self.agent_config): + confirmed_lalkitab_profile = await self._load_confirmed_lalkitab_profile( + conversation_id=conversation_id, user_id=user_id, agent_id=agent_id + ) + if not confirmed_lalkitab_profile: + gate_message = ( + "Please complete the birth details form to generate your horoscope " + "before we continue — I need your confirmed chart to answer safely." + ) + yield StreamingMessageResponse( + type="content", + content=gate_message, + conversation_id=conversation_id, + ) + yield StreamingMessageResponse( + type="final_answer", + content=gate_message, + conversation_id=conversation_id, + metadata={"requires_lalkitab_birth_profile_form": True}, + ) + yield StreamingMessageResponse( + type="metadata", + content="", + conversation_id=conversation_id, + citations=[], + context_used=0, + confidence_score=1.0, + metadata={"requires_lalkitab_birth_profile_form": True}, + ) + return + runtime_message = guardrail_decision.get("safe_query") or request.message capability_scope = guardrail_decision.get("capability_scope") user_metadata = { @@ -2715,8 +3244,16 @@ async def stream_message(self, request: MessageRequest) -> AsyncGenerator[Stream fallback_plan=fallback_turn_plan, ) turn_plan, lalkitab_profile = await self._prepare_lalkitab_turn( - turn_plan, runtime_message, policy_state, llm_provider=planner_provider + turn_plan, + runtime_message, + policy_state, + llm_provider=planner_provider, + confirmed_profile=confirmed_lalkitab_profile, + ) + inquiry_pending_state = ( + await self._load_inquiry_pending_state(conversation_id) if short_term_enabled else {} ) + turn_plan = self._adapt_turn_plan_for_inquiry(turn_plan, runtime_message, inquiry_pending_state) for activity in turn_plan.activities: if activity.get("visibility") != "hidden": yield StreamingMessageResponse(**activity_stream_response_kwargs(activity, conversation_id)) @@ -2729,6 +3266,19 @@ async def stream_message(self, request: MessageRequest) -> AsyncGenerator[Stream "validation_passed": True, "validation_confidence": 1.0, } + if (turn_plan.raw_plan or {}).get("_inquiry_cancelled"): + short_circuit_metadata["inquiry_pending"] = {"cancelled": True} + _inquiry_form_metadata: dict[str, Any] = {} + if turn_plan.action == "open_inquiry_form": + _inquiry_form_metadata["open_inquiry_form"] = True + _product_name = turn_plan.resolved_inputs.get("product_name") if isinstance(turn_plan.resolved_inputs, dict) else None + if _product_name: + _inquiry_form_metadata["product_name"] = _product_name + short_circuit_metadata.update(_inquiry_form_metadata) + # Fixed acknowledgement template, not a generated factual + # claim — see the sync-path comment above for why this + # must bypass the claim-evidence guard. + short_circuit_metadata["_trusted_safety_template"] = True response_text, short_circuit_metadata = self._apply_post_response_guardrails( turn_plan.response_text, short_circuit_metadata, @@ -2746,6 +3296,7 @@ async def stream_message(self, request: MessageRequest) -> AsyncGenerator[Stream "resolved_inputs": _sanitize_for_json(turn_plan.resolved_inputs), "pending_inputs": turn_plan.pending_inputs, "context_decision": turn_plan.context_decision, + **_inquiry_form_metadata, }, ) if short_term_enabled: @@ -2782,6 +3333,7 @@ async def stream_message(self, request: MessageRequest) -> AsyncGenerator[Stream "resolved_inputs": _sanitize_for_json(turn_plan.resolved_inputs), "pending_inputs": turn_plan.pending_inputs, "context_decision": turn_plan.context_decision, + **_inquiry_form_metadata, }, ) yield StreamingMessageResponse( @@ -2812,11 +3364,47 @@ async def stream_message(self, request: MessageRequest) -> AsyncGenerator[Stream **lalkitab_pending, **self._lalkitab_pending_from_policy(policy_state, turn_plan), } + # The chart persisted by POST /lalkitab/profile already + # contains a validated api_context. Short-term memory only + # has it after the first assistant turn, so without this the + # first chat turn(s) after the form has no previous chart to + # reuse and recalculates from Vedika again — seed it here so + # the already-generated chart is reused from turn one. + if not lalkitab_pending.get("api_context") and isinstance( + (confirmed_lalkitab_profile or {}).get("api_context"), dict + ): + lalkitab_pending["api_context"] = confirmed_lalkitab_profile["api_context"] + # A confirmed birth profile is carried into every later turn + # (so follow-up astrology questions never repeat birth + # details), but a bare greeting or "what can you do"-style + # capability question must still get a normal conversational + # answer rather than being routed into chart synthesis just + # because a profile happens to be attached to the session. + lalkitab_birth_profile = ( + None + if is_lalkitab_meta_or_greeting_message(runtime_message) + else (lalkitab_profile or confirmed_lalkitab_profile) + ) + # See the matching comment in the sync send_message path: + # strip birth_date/time/place that merely restate the + # already-cached chart's own values, so an ordinary + # follow-up reuses that chart instead of re-querying Vedika + # (and hitting its rate limit) on every single turn. + lalkitab_birth_profile = strip_unchanged_birth_fields( + lalkitab_birth_profile, + lalkitab_pending.get("normalized_birth_input"), + ) lalkitab_plan = await build_lalkitab_runtime_context( self.agent_config or {}, - runtime_message, + # Intent-detection only (see _lalkitab_probe_message) — + # the real, unmodified runtime_message is still used + # everywhere else below for the actual answer. + self._lalkitab_probe_message( + runtime_message, + (lalkitab_pending.get("api_context") or {}).get("chart_context"), + ), pending_state=lalkitab_pending, - birth_profile=lalkitab_profile, + birth_profile=lalkitab_birth_profile, ) # Surface real runtime activity (geocoding, connector calls) as it happens. @@ -2968,6 +3556,7 @@ async def stream_message(self, request: MessageRequest) -> AsyncGenerator[Stream remembered_inputs = session_state.get("connector_inputs") or {} if remembered_inputs: self._apply_remembered_connector_inputs(remembered_inputs) + self._configure_inquiry_tool_for_turn(conversation_id, session_state) planner_tool_results: dict[str, ToolResult] = {} if turn_plan.tool_plan: @@ -3036,7 +3625,7 @@ async def stream_message(self, request: MessageRequest) -> AsyncGenerator[Stream lalkitab_plan=lalkitab_plan, rag_context=rag_context, rag_tool_result=rag_tool_result, - birth_profile=lalkitab_profile, + birth_profile=lalkitab_profile or confirmed_lalkitab_profile, ) elif isinstance(self.orchestrator, ShopifyOrchestrator): # Define a queue to capture status updates from the orchestrator @@ -3265,6 +3854,9 @@ async def on_event(event: dict): # Structured chart payload the widget renders as the visual # kundali artifact above the reading. final_answer_metadata["kundali_chart"] = _sanitize_for_json(agent_metadata["kundali_chart"]) + if agent_metadata.get("open_inquiry_form"): + final_answer_metadata["open_inquiry_form"] = True + final_answer_metadata["product_name"] = agent_metadata.get("product_name") or "" yield StreamingMessageResponse( type="final_answer", content=full_response, @@ -3352,6 +3944,7 @@ async def on_event(event: dict): "lalkitab_rag_context": agent_metadata.get("lalkitab_rag_context_full"), # Remembered connector inputs so follow-ups reuse them. "connector_inputs": self._collect_connector_inputs(session_state, lalkitab_plan, agent_metadata) or None, + "inquiry_pending": self._collect_inquiry_pending_state(tool_results) or None, } ) @@ -3468,6 +4061,9 @@ async def on_event(event: dict): } if retrieval_health: stream_response_metadata["retrieval"] = retrieval_health + if agent_metadata.get("open_inquiry_form"): + stream_response_metadata["open_inquiry_form"] = True + stream_response_metadata["product_name"] = agent_metadata.get("product_name") or "" yield StreamingMessageResponse( type="metadata", diff --git a/apps/api/app/services/response_validator.py b/apps/api/app/services/response_validator.py index 15f8396..fd14876 100644 --- a/apps/api/app/services/response_validator.py +++ b/apps/api/app/services/response_validator.py @@ -10,6 +10,17 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple from dataclasses import dataclass +# Reused read-only: the same planet name -> Vedika short-code mapping that +# drives the visual kundali chart (kundali_chart.py is never modified here, +# only its existing lookup table is imported), so a claim written with full +# planet names ("Mercury", "Rahu") can be matched against evidence stored +# with Vedika's short codes ("Me", "Ra") without maintaining a second copy +# of this mapping. +try: + from .kundali_chart import KUNDALI_PLANET_CODES as _PLANET_NAME_TO_CODE +except ImportError: # pragma: no cover - defensive; keeps this module usable standalone + _PLANET_NAME_TO_CODE = {} + logger = structlog.get_logger(__name__) @@ -94,15 +105,34 @@ class ValidationResult: _SAFE_RESPONSE_PATTERNS = ( re.compile(r"^\s*(?:hi|hello|hey|good (?:morning|afternoon|evening))\b", re.IGNORECASE), re.compile(r"^\s*(?:thanks|thank you|you're welcome|you are welcome)\b", re.IGNORECASE), - re.compile(r"\b(?:i can|i'll|i will|let me|please|could you|can you)\s+(?:help|check|find|share|provide|confirm|tell|choose|try)\b", re.IGNORECASE), + re.compile(r"\b(?:i can|i'll|i will|let me|please|could you|can you)\s+(?:help|check|find|share|provide|confirm|tell|choose|try|ask|collect|gather|prepare|proceed|send|process|reach out|get back|follow up|need|have)\b", re.IGNORECASE), + re.compile(r"\bonce (?:i|we) have\b", re.IGNORECASE), re.compile(r"\b(?:how can i help|what would you like|share (?:the|a)|please (?:share|provide|confirm))\b", re.IGNORECASE), re.compile(r"\b(?:don't|do not|cannot|can[’']t|couldn't|couldn[’']t|unable to)\s+(?:verify|confirm|answer|safely give|access)\b", re.IGNORECASE), re.compile(r"\b(?:don[’']t have|do not have|not enough|insufficient)\s+(?:enough )?(?:verified )?(?:information|evidence)\b", re.IGNORECASE), re.compile(r"\b(?:try again|contact (?:the )?(?:brand|support|team))\b", re.IGNORECASE), re.compile(r"^\s*(?:streaming )?response complete\.?\s*$", re.IGNORECASE), + # Mandatory Lal Kitab closing disclaimer (dictated verbatim by the + # runtime's own prompt template, not a generated factual claim). + re.compile(r"\b(?:guidance|tradition)\b.{0,40}\bnot a guarantee\b", re.IGNORECASE), re.compile(r"^\s*this is a helpful response(?: with citations)?\.?\s*$", re.IGNORECASE), re.compile(r"^\s*here (?:is|are) (?:the )?(?:matching|relevant) (?:product|products|option|options|result|results)\.?\s*$", re.IGNORECASE), ) +_YEAR_LIKE_PATTERN = re.compile(r"\b(19|20)\d{2}\b") +_PERCENT_PATTERN = re.compile(r"\b\d{1,3}(?:\.\d+)?\s*%") +_AGE_CONTEXT_PATTERN = re.compile(r"\bage[d]?\s+\d{1,3}\b", re.IGNORECASE) +_LALKITAB_RISKY_CLAIM = re.compile( + r"\b(?:sun|moon|mars|mercury|jupiter|venus|saturn|rahu|ketu|" + r"surya|chandra|mangal|budh|guru|shukra|shani|" + r"house|bhava|bhav|rashi|lagna|ascendant|" + r"donate|offer|wear|chant|fast|feed|avoid|recite|worship|" + r"remedy|remedies|totke|totka|" + r"dasha|mahadasha|antardasha|bhukti|" + r"navamsa|dashamsa|divisional|varga|" + r"rich|wealth|wealthy|fortune|destiny|" + r"career|marriage|health|finance|education|foreign|business|job)\b", + re.IGNORECASE, +) _ASSERTION_PATTERN = re.compile( r"\b(?:is|are|was|were|has|have|had|cost|costs|priced|price|available|includes?|" r"supports?|ships?|delivers?|located|made|works?|compatible|warranty|guarantee|" @@ -111,8 +141,27 @@ class ValidationResult: ) +_ORDINAL_SUFFIX_PATTERN = re.compile(r"^(\d+)(?:st|nd|rd|th)$") + + def _canonical_token(token: str) -> str: token = token.lower() + # "10th house" (prose) vs "house: 10" (Vedika's raw field) must compare + # equal — an ordinal is the same fact as its bare number, not a + # different word, and Lal Kitab readings are written in ordinals + # ("in the 10th house") while the chart/predictions payload always + # stores plain house numbers. + ordinal_match = _ORDINAL_SUFFIX_PATTERN.match(token) + if ordinal_match: + return ordinal_match.group(1) + # Vedika's chart/predictions/houses payloads store planets as short + # codes ("Me", "Ra", "Su"...), but a Lal Kitab reading is written with + # full planet names ("Mercury", "Rahu") per the prompt's own + # instruction to use readable names — canonicalizing both to the same + # short code lets a correct answer's planet mentions actually match the + # calculated evidence instead of being treated as unsupported. + if token in _PLANET_NAME_TO_CODE: + return _PLANET_NAME_TO_CODE[token].lower() token = _NUMBER_WORDS.get(token, token) token = _TOKEN_ALIASES.get(token, token) if len(token) > 4 and token.endswith("ing"): @@ -148,8 +197,19 @@ def _anchors(value: str) -> set[str]: anchors.add(f"number:{number.replace(',', '')}") for token in _SKU_PATTERN.findall(text): anchors.add(f"sku:{token.lower()}") + # A spelled-out number word ("one", "two"...) is only a real factual + # anchor when it quantifies a countable unit (e.g. "five year warranty", + # "two variants") - idiomatic usage ("send it in one message", "give me + # a moment") is not a claim about the product/brand and must not require + # evidence just because it contains a number word. for word, number in _NUMBER_WORDS.items(): - if re.search(rf"\b{re.escape(word)}\b", text, re.IGNORECASE): + if re.search( + rf"\b{re.escape(word)}\b\s+(?:year|years|month|months|day|days|" + rf"week|weeks|unit|units|item|items|variant|variants|piece|pieces|" + rf"time|times|percent|%)\b", + text, + re.IGNORECASE, + ): anchors.add(f"number:{number}") if re.search(r"\b(?:in[ -]stock|available now)\b", text, re.IGNORECASE): anchors.add("availability:in_stock") @@ -158,6 +218,18 @@ def _anchors(value: str) -> set[str]: return anchors +# Product/commerce price fields are stored in minor currency units (e.g. +# paisa/cents) throughout this codebase (see ProductCard/ProductVariant +# price_minor contract), but the LLM writer always displays prices in major +# units (e.g. "INR 52,900" for price_minor=5290000). Without also emitting the +# major-unit value as evidence text, every priced-product answer would fail +# the claim-evidence numeric-anchor check purely due to unit mismatch, not an +# actual hallucination. +_MINOR_UNIT_PRICE_FIELDS = { + "price_minor", "price_min", "price_max", "price", "cost", "amount", +} + + def _structured_text(value: Any, *, depth: int = 0) -> str: """Flatten structured evidence while retaining field names as factual context.""" if depth > 5 or value is None: @@ -168,6 +240,17 @@ def _structured_text(value: Any, *, depth: int = 0) -> str: safe_key = str(key).replace("_", " ").replace("-", " ") if key in {"in_stock", "inStock", "available"} and isinstance(item, bool): pieces.append("availability in stock" if item else "availability out of stock") + if ( + key in _MINOR_UNIT_PRICE_FIELDS + and isinstance(item, (int, float)) + and not isinstance(item, bool) + ): + # Emit both the raw minor-unit number and its major-unit + # (÷100) equivalent so a claim quoting either form matches. + major_value = item / 100 + major_text = f"{major_value:.2f}".rstrip("0").rstrip(".") + pieces.append(f"{safe_key} {item} {major_text}") + continue nested = _structured_text(item, depth=depth + 1) if nested: pieces.append(f"{safe_key} {nested}") @@ -200,9 +283,14 @@ def _tool_evidence(tool_results: Any, runtime_metadata: Optional[Dict[str, Any]] """Collect textual and structured evidence without returning identifiers or diagnostics.""" records: list[str] = [] for tool_result in (tool_results or {}).values() if isinstance(tool_results, dict) else []: - if getattr(tool_result, "success", True) is False: - continue - data = getattr(tool_result, "data", None) + # A failed tool call (e.g. a multi-turn data-collection tool like + # submit_inquiry reporting "missing required fields") still carries + # trustworthy, tool-reported structured state in `metadata` — that is + # not a hallucination risk and must not be discarded. Only the + # free-form `data`/error text on a failure is untrustworthy as + # evidence, since it's often just an error message. + tool_succeeded = getattr(tool_result, "success", True) is not False + data = getattr(tool_result, "data", None) if tool_succeeded else None if isinstance(data, dict): text = _structured_text(data) if text: @@ -220,7 +308,7 @@ def _tool_evidence(tool_results: Any, runtime_metadata: Optional[Dict[str, Any]] metadata = getattr(tool_result, "metadata", None) if not isinstance(metadata, dict): continue - for key in ("products", "dealers", "validated_products", "active_product_focus"): + for key in ("products", "dealers", "validated_products", "active_product_focus", "missing_input", "invalid_fields"): values = metadata.get(key) if isinstance(values, list): for item in values: @@ -234,8 +322,12 @@ def _tool_evidence(tool_results: Any, runtime_metadata: Optional[Dict[str, Any]] text = _citation_text(item) if text: records.append(text) - for key in ("response_summary", "summary"): - text = _actual_text(metadata.get(key)) + for key in ("response_summary", "summary", "resolved_inputs"): + value = metadata.get(key) + if isinstance(value, dict): + text = _structured_text(value) + else: + text = _actual_text(value) if text: records.append(text) @@ -249,6 +341,16 @@ def _tool_evidence(tool_results: Any, runtime_metadata: Optional[Dict[str, Any]] text = _structured_text(api_context.get(key)) if text: records.append(text) + # Same validated chart data, reshaped for the LLM prompt (planets, + # houses, ascendant, dasha, divisional charts) — must be usable as + # evidence too, or a correctly grounded dasha/divisional-chart answer + # would be rejected purely because that projection uses different + # field names than the raw nested API payload. + interpretation_context = metadata.get("lalkitab_interpretation_context_full") + if isinstance(interpretation_context, dict): + text = _structured_text(interpretation_context) + if text: + records.append(text) rag_context = metadata.get("lalkitab_rag_context_full") if isinstance(rag_context, dict): for chunk in rag_context.get("chunks") or []: @@ -262,21 +364,75 @@ def _tool_evidence(tool_results: Any, runtime_metadata: Optional[Dict[str, Any]] def _split_claims(response: str) -> Iterable[str]: for claim in re.split(r"(?<=[!?])\s+|(?<=\.)\s+(?=[A-Z#*\-])|\n+", response or ""): claim = claim.strip(" \t-*#") + # Strip a leading numbered/lettered list marker ("1.", "2)", "a.") + # left over from splitting "1. **Your name**" — it is not itself a + # claim, the label after it is. + claim = re.sub(r"^\(?\d{1,3}[.)]\s*|^\(?[a-zA-Z][.)]\s*", "", claim) if claim: yield claim +_QUOTED_EXAMPLE_PATTERN = re.compile(r'^[\s\-]*[“"\'](.+)[”"\']\s*$') +# A markdown table row (the mandated Lal Kitab kundali/Confidence tables use +# this exact "| a | b | c |" shape) is a formatting/layout line, not itself a +# standalone prose sentence making a claim. Its actual content (a house +# number + rashi + planets) is still checked by the normal claim/anchor +# logic below via `_anchors`/`_meaningful_tokens` on the same text, but a +# genuinely empty row (e.g. "| 10 | — | — |" for a house with no planets) +# must never be flagged as an "unsupported claim containing the number 10" — +# there is nothing there to verify, it is the table format itself. +_TABLE_ROW_PATTERN = re.compile(r"^\s*\|.*\|\s*$") + + def _is_safe_nonfactual(claim: str) -> bool: if not claim: return True if claim.rstrip().endswith("?"): return True + # A line ending in ":" is introducing a following list/summary (e.g. + # "Got it — here's the inquiry I have:"), not itself a standalone + # factual assertion - the actual data is in the bullet lines after it, + # which are each validated separately. + if claim.rstrip().endswith(":"): + return True + # A claim that is entirely wrapped in quotes is illustrative example + # phrasing the assistant is suggesting the user could type (e.g. bullet + # examples under "for example:"), not a first-person factual assertion — + # even if the example text happens to echo a number the user already + # gave, it is not something that needs independent evidence. + if _QUOTED_EXAMPLE_PATTERN.match(claim.strip()): + return True return any(pattern.search(claim) for pattern in _SAFE_RESPONSE_PATTERNS) +def _is_empty_table_row(claim: str) -> bool: + """True for a markdown table row whose data cells are all placeholders + (blank, "—", "-") — e.g. a chart table's empty-house row "| 10 | — | — |". + Such a row asserts nothing; it is layout, not a claim that a value of 10 + (or any other cell) is a verified fact.""" + if not _TABLE_ROW_PATTERN.match(claim): + return False + cells = [cell.strip() for cell in claim.strip().strip("|").split("|")] + # A row is "empty" only if every cell after the first (the row label + # itself, e.g. a house number or an "Area" name) is blank/placeholder. + # A row with real content in a later cell (a rashi name, a planet, a + # percentage) is a genuine claim and must still be checked normally. + return len(cells) > 1 and all( + cell in ("", "—", "-", "–") or re.fullmatch(r"[-—–\s]*", cell) + for cell in cells[1:] + ) + + def _is_factual_claim(claim: str) -> bool: if _is_safe_nonfactual(claim): return False + if _is_empty_table_row(claim): + return False + # A bare list/number marker (e.g. "1." left over after splitting a + # markdown numbered list like "1. **Your name**") has an anchor but no + # actual words, so it must never be treated as a standalone factual claim. + if not _meaningful_tokens(claim): + return False if _anchors(claim): return True return bool(_ASSERTION_PATTERN.search(claim)) @@ -309,6 +465,19 @@ def validate_claim_evidence( for record in evidence_records: evidence_anchors.update(_anchors(record)) + # Lal Kitab (and any other astrology-style runtime) answers are written + # in a deliberately paraphrased, warm advisor voice on top of already + # deterministic, pre-validated evidence (a chart context validated + # upstream by is_valid_lalkitab_context_payload, plus real retrieved + # reference chunks) — the writer is explicitly instructed to speak "like + # an astrologer walking through the chart aloud" rather than quote + # source text. Requiring 2 exact shared words per sentence against that + # evidence routinely fails ordinary, correctly grounded interpretation + # and remedy sentences purely on vocabulary choice, not hallucination. + # This does not weaken numeric/anchor checks (price, SKU, dates) for any + # runtime, and does not relax anything for non-Lal-Kitab claims. + is_lalkitab = bool((runtime_metadata or {}).get("lalkitab_runtime")) + unsupported = 0 claim_count = 0 if not sanitized: @@ -318,15 +487,43 @@ def validate_claim_evidence( continue claim_count += 1 anchors = _anchors(claim) + if is_lalkitab and anchors: + # A Lal Kitab reading routinely states a calendar year ("your + # career this year, 2026, looks steady…"), an age threshold + # copied straight out of the chart's own `currentAge`/timing + # data ("especially after age 14, as the chart shows"), or a + # percentage in the mandated Confidence table — none of these + # are meant to appear verbatim inside retrieved evidence text, + # they are the model's own timing reference or self-assessed + # confidence grading, not a fact being sourced from a chunk. + # Real hallucination risk (an invented house number, a wrong + # birth date/time, a fabricated SKU) is still caught: those + # anchors remain required to match, and the semantic overlap + # check right below still applies to every claim regardless. + exempt = set() + if _YEAR_LIKE_PATTERN.search(claim) or _PERCENT_PATTERN.search(claim) or _AGE_CONTEXT_PATTERN.search(claim): + exempt = {a for a in anchors if a.startswith("number:")} + anchors = anchors - exempt if anchors and not anchors.issubset(evidence_anchors): unsupported += 1 continue + if is_lalkitab and not anchors and not _LALKITAB_RISKY_CLAIM.search(claim): + # A Lal Kitab sentence with no numeric/date/SKU anchor AND no + # planet/house/remedy/life-area content is generic interpretive + # framing (e.g. "This is a positive placement", closing + # encouragement, the guidance-not-a-guarantee disclaimer) rather + # than an independently checkable fact. Sentences that actually + # carry astrology/remedy substance (a planet, a remedy verb, a + # life-area topic) still go through the normal overlap check + # right below, so an unsupported/hallucinated remedy or + # prediction is still caught and rejected. + continue claim_tokens = _meaningful_tokens(claim) overlaps = [len(claim_tokens & tokens) for tokens in evidence_tokens] best_overlap = max(overlaps, default=0) # Exact anchors still need a semantic tie such as price, warranty, a # SKU, or a product/entity token. Anchor-only matches are not enough. - required_overlap = 1 if anchors else 2 + required_overlap = 1 if (anchors or is_lalkitab) else 2 if best_overlap < required_overlap: unsupported += 1 @@ -516,19 +713,24 @@ async def _validate_products(self, response: str, catalog_products: List[Dict]) if price_num < min_price * 0.5 or price_num > max_price * 1.5: issues.append(f"WARNING: Suspicious price '{price_str}' outside catalog range") - # Check for vague product descriptions without SKU + # Check for vague product descriptions without SKU. This must never + # fire when real catalog products were actually retrieved and are + # already shown to the user as product cards: a natural-language + # answer ("Here are some BP monitors...") is not "vague" just because + # it doesn't spell out SKU codes inline — the cards are the grounding. vague_patterns = [ r'our\s+\w+\s+product', r'this\s+\w+\s+model', r'available\s+\w+\s+options', ] - - for pattern in vague_patterns: - if re.search(pattern, response.lower()): - # Only flag if no SKUs mentioned at all - if not potential_skus: - issues.append("WARNING: Vague product reference without specific SKU") - break + + if not catalog_products: + for pattern in vague_patterns: + if re.search(pattern, response.lower()): + # Only flag if no SKUs mentioned at all + if not potential_skus: + issues.append("WARNING: Vague product reference without specific SKU") + break return issues diff --git a/apps/api/app/services/tool_registry.py b/apps/api/app/services/tool_registry.py index fee4718..2ee0bda 100644 --- a/apps/api/app/services/tool_registry.py +++ b/apps/api/app/services/tool_registry.py @@ -3,6 +3,7 @@ from copy import deepcopy from typing import Any from urllib.parse import urlparse +import asyncio import ipaddress import json import re @@ -467,7 +468,18 @@ def _connector_auth_headers(auth: dict[str, Any] | None) -> dict[str, str]: if parsed: return parsed if isinstance(raw_header, str) and raw_header.strip(): - return {"Authorization": f"Bearer {raw_header.strip()}"} + value = raw_header.strip() + # The admin UI's "Auth header" field stores only the header + # VALUE (e.g. "Bearer vk_live_xxx" or a bare token), never the + # "Header-Name: value" pair — _parse_auth_header() above only + # matches the latter. Do not blindly prefix "Bearer " here: if + # the saved value already carries its own auth scheme (Bearer, + # Basic, Token, ApiKey, ...), doing so double-wraps it into an + # invalid "Bearer Bearer vk_live_xxx" header that every provider + # rejects as unauthorized. + if re.match(r"^[A-Za-z][A-Za-z0-9._-]*\s+\S", value): + return {"Authorization": value} + return {"Authorization": f"Bearer {value}"} return {} @@ -565,6 +577,44 @@ def _redacted_url(url: str) -> str: return parsed._replace(query="", fragment="").geturl() +# A handful of top-level response fields are near-universal provider +# bookkeeping (billing/quota/balance, HTTP-shaped envelopes, internal +# endpoint routing) that a user-facing citation card must never show +# verbatim — they read as confusing raw JSON rather than a real source. +# This is deliberately generic (keyed on common field names, not any single +# connector's schema) since ContextConnectorTool/ApiDataSourceTool serve any +# configured connector, not just Lal Kitab's Vedika integration. +_INTERNAL_RESPONSE_FIELD_NAMES = { + "billing", "balance", "balancebefore", "balanceafter", "charged", + "currency", "endpoint", "status_code", "statuscode", "request_id", + "requestid", "trace_id", "traceid", +} + + +def _public_connector_snippet(connector_name: str, endpoint_name: str | None, data: Any) -> str: + """Build a short, human-readable citation snippet for a connector call — + never the provider's raw JSON response (billing/balance fields, internal + endpoint paths, request envelopes), which is not a readable source and + can look like something is broken even when the call succeeded fine. + The full raw payload remains available internally via `data`/ + `response_summary` for anything that actually needs it (e.g. the + claim-evidence evidence pool); only this display string is redacted. + """ + label = f"{connector_name} · {endpoint_name}" if endpoint_name else connector_name + if isinstance(data, dict): + readable_keys = [ + str(key) for key in data.keys() + if str(key).lower() not in _INTERNAL_RESPONSE_FIELD_NAMES + ] + if readable_keys: + preview = ", ".join(readable_keys[:6]) + return f"{label} returned calculated data ({preview})." + return f"{label} returned calculated data." + if isinstance(data, list): + return f"{label} returned {len(data)} calculated result(s)." + return f"{label} returned calculated data." + + def _host_allowed(url: str, allowlist: list[Any] | None) -> bool: if not allowlist: return False @@ -691,11 +741,15 @@ async def run(self, query: str, payload: dict[str, Any] | None = None, **kwargs) "provider": "http", "source_name": source_name, "url": url, + # No `url` on the citation itself: like ContextConnectorTool, + # this is a raw API endpoint (often auth-protected) rather + # than a page a user could actually open and read, so linking + # it only leads to a confusing auth-error page. The internal + # `url` field above is kept for diagnostics/logging only. "sources": [ { "title": source_name, - "url": url, - "snippet": serialized[:500], + "snippet": _public_connector_snippet(source_name, None, data), } ], "confidence": 0.9, @@ -855,6 +909,17 @@ async def run(self, query: str, payload: dict[str, Any] | None = None, **kwargs) response = await client.get(url, params=request_payload if self.endpoint.get("payload_mode") == "flat_body" else payload, headers=headers) else: response = await client.request(method, url, json=request_payload, headers=headers) + if response.status_code == 429 and attempt < retry_count: + # Provider rate limit — retrying immediately would + # just hit 429 again. Honor Retry-After when the + # provider sends one, otherwise back off briefly. + retry_after = response.headers.get("retry-after") + try: + delay = min(max(float(retry_after), 0.5), 10.0) if retry_after else 1.5 * (attempt + 1) + except ValueError: + delay = 1.5 * (attempt + 1) + await asyncio.sleep(delay) + continue response.raise_for_status() content_type = response.headers.get("content-type", "") raw_text = response.text @@ -882,6 +947,18 @@ async def run(self, query: str, payload: dict[str, Any] | None = None, **kwargs) raise except httpx.HTTPError as exc: error_message = str(exc) or type(exc).__name__ + # The provider's raw HTTP status (e.g. 429 rate-limited, 401 + # unauthorized) is a single, safe integer — no secrets, no + # request/response body — so it is threaded all the way up to + # the caller's error_code as e.g. "upstream_429" instead of a + # generic "chart_unavailable". This lets a real cause (rate + # limit vs auth failure vs timeout) be told apart in the + # browser's Network tab without reading server logs, while the + # provider's actual error text/headers/body are still never + # exposed to the browser. + upstream_status_code = ( + exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None + ) return ToolResult( success=False, data=None, @@ -895,6 +972,7 @@ async def run(self, query: str, payload: dict[str, Any] | None = None, **kwargs) "url": _redacted_url(url), "latency_ms": round((time.perf_counter() - started) * 1000, 2), "error_type": type(exc).__name__, + "upstream_status_code": upstream_status_code, }, ) @@ -922,11 +1000,25 @@ async def run(self, query: str, payload: dict[str, Any] | None = None, **kwargs) "headers": _redacted_headers(headers), **request_shape_extra, }, + # A user-facing citation card must never show the provider's + # raw JSON (billing/balance fields, internal endpoint paths, + # request shape) — that is internal diagnostic detail, not a + # readable source. `_public_connector_snippet` builds a short, + # generic human-readable line instead; the full untouched + # `data`/`response_summary` below still carries the raw + # payload for internal use (claim-evidence matching, etc.). + # + # No `url` here: this is a machine API endpoint that requires + # a secret Authorization header the browser will never send, + # so clicking it only ever lands a user on a bare "auth + # required" error page — never an actual readable source page + # they can visit. A citation card with no url safely renders + # as plain, non-clickable text (see MessageBubble.tsx), so + # omitting it is strictly safer than pointing at a dead end. "sources": [ { "title": f"{connector_name} · {endpoint_name}", - "url": _redacted_url(url), - "snippet": serialized[:500], + "snippet": _public_connector_snippet(connector_name, endpoint_name, data), } ], "response_summary": serialized[:1000], diff --git a/apps/api/tests/test_lalkitab_chart_answering.py b/apps/api/tests/test_lalkitab_chart_answering.py new file mode 100644 index 0000000..615b03e --- /dev/null +++ b/apps/api/tests/test_lalkitab_chart_answering.py @@ -0,0 +1,528 @@ +"""Offline verification for the Lal Kitab chart-answering fix. + +Exercises _generate_lalkitab_agent_result directly with a mocked LLM +provider (captures the prompt, returns a scripted answer) and a realistic +validated chart_context, then runs the real claim-evidence guard on the +result. No network/LLM calls. +""" + +import pytest +from types import SimpleNamespace + +from app.services.lalkitab_interpretation import ( + build_lalkitab_interpretation_context, + is_lalkitab_meta_or_greeting_message, +) +from app.services.response_validator import validate_claim_evidence + + +def _api_context(): + return { + "normalized_birth_input": { + "date": "1990-05-16", "time": "10:30:00", "birth_place": "Delhi, India", + }, + "chart_context": { + "ascendant": "Cancer", + "houses": [ + {"house": 1, "planets": ["Sun", "Mercury"], "effects": ["Sun exalted - excellent results"]}, + {"house": 5, "planets": ["Mars"], "effects": []}, + {"house": 8, "planets": ["Moon", "Saturn"], "effects": []}, + {"house": 9, "planets": ["Rahu"], "effects": []}, + ], + "mahadasha": "Saturn", + "antardasha": "Moon", + "debts": [], + }, + "secondary_endpoint_results": { + "lalkitab_predictions": {"currentAge": 34, "placements": ["career steady"]}, + "lalkitab_remedies": {"overallStrength": "Mixed chart", "placements": ["offer wheat on Sundays"]}, + }, + "source_provenance": [{"endpoint_id": "lalkitab_chart", "endpoint_name": "Lal Kitab Chart"}], + } + + +class _CapturingLLM: + def __init__(self, answer: str): + self.answer = answer + self.last_prompt = None + + async def generate(self, prompt: str, **kwargs): + self.last_prompt = prompt + return SimpleNamespace(content=self.answer) + + +@pytest.mark.parametrize("question,answer", [ + ( + "Where is my Moon?", + "Beta, your Moon sits in the 8th house, together with Saturn. This can bring " + "emotional depth but also some ups and downs around health and transformation.", + ), + ( + "How is my career?", + "Beta, looking at your chart, your career is steady this year, especially with " + "the exalted Sun in the 1st house giving you leadership drive and confidence.", + ), + ( + "Tell me about my life", + "Beta, your ascendant is Cancer, with Sun and Mercury exalted in the 1st house " + "giving you strong self-confidence, while Moon and Saturn in the 8th house bring " + "emotional depth. Mars in the 5th house adds courage in matters of children.", + ), + ( + "When will I get rich?", + "Beta, your chart shows career steadiness (as noted in the calculated predictions), " + "and the exalted Sun in the 1st house supports steady financial growth over time.", + ), + ( + "What is my current dasha?", + "Beta, your current dasha is Saturn mahadasha with Moon antardasha, which brings " + "a period of introspection and steady, disciplined effort in your life.", + ), +]) +@pytest.mark.asyncio +async def test_chart_backed_questions_are_answered_not_refused(question, answer): + api_context = _api_context() + interpretation_context = build_lalkitab_interpretation_context(api_context) + # Sanity: the interpretation context must actually carry dasha data, + # otherwise the "what is my current dasha" case is not a real test. + assert interpretation_context.get("dasha") == {"mahadasha": "Saturn", "antardasha": "Moon"} + + metadata = { + "lalkitab_runtime": True, + "lalkitab_api_context_full": api_context, + "lalkitab_rag_context_full": {"chunks": [ + {"doc_id": "lalkitab_document_1", "content": "Sun exalted in the first house grants excellent results and confidence."} + ]}, + "lalkitab_interpretation_context_full": interpretation_context, + } + result = validate_claim_evidence(answer, tool_results={}, runtime_metadata=metadata) + assert result.is_valid, f"Q: {question!r} -> rejected: {result.metadata}" + assert not is_lalkitab_meta_or_greeting_message(question), f"{question!r} was wrongly classified as meta/greeting" + + +@pytest.mark.parametrize("message", [ + "what you can do", + "what can you do", + "hi", + "hello", + "who are you", +]) +def test_meta_and_greetings_never_treated_as_chart_questions(message): + assert is_lalkitab_meta_or_greeting_message(message) is True + + +@pytest.mark.parametrize("message", [ + "Where is my Moon?", + "How is my career?", + "Tell me about my life", + "When will I get rich?", + "What is my current dasha?", +]) +def test_real_astrology_questions_never_treated_as_meta(message): + assert is_lalkitab_meta_or_greeting_message(message) is False + + +@pytest.mark.asyncio +async def test_end_to_end_generate_lalkitab_agent_result_answers_moon_question(): + """Exercises the real _generate_lalkitab_agent_result (prompt build + + LLM call + post-response guard) with a mocked LLM, mirroring + test_lalkitab_safety.py's existing pattern. No network/LLM calls.""" + from app.services.message_service import MessageService + + service = MessageService.__new__(MessageService) + answer = ( + "Beta, your Moon is placed in the 8th house, sitting with Saturn. " + "This brings emotional depth but also some ups and downs in health matters." + ) + service.llm_provider = _CapturingLLM(answer) + service.system_prompt = "You are a Lal Kitab guide." + service.agent_config = {"domain": {"template": "astrology_lalkitab"}} + from app.config import Settings + service.settings = Settings() + + api_context = _api_context() + plan = SimpleNamespace( + api_context=api_context, + tool_results={}, + selected_endpoint_ids=["lalkitab_chart"], + chart_validated=True, + requires_safe_abstention=False, + used_cached_context=True, + ) + + result = await service._generate_lalkitab_agent_result( + message="Where is my Moon?", + chat_history=[], + lalkitab_plan=plan, + rag_context={}, + rag_tool_result=None, + ) + + assert result.answer == answer + assert result.metadata["validation_passed"] is True + # The prompt actually sent to the LLM must contain the structured + # interpretation context (dasha in this case) so the model can look the + # value up directly instead of parsing the raw nested payload. + assert "Chart Interpretation Context" in service.llm_provider.last_prompt + assert "mahadasha" in service.llm_provider.last_prompt + + guard_result = service._apply_post_response_guardrails(result.answer, result.metadata) + final_text, final_metadata = guard_result + assert final_text == answer + assert final_metadata.get("fallback") is not True + + +def test_full_reading_with_mandatory_empty_house_table_rows_is_not_rejected(): + """Regression test: when the kundali_chart visual artifact is disabled, + the prompt mandates a full 12-row markdown table including empty houses + (e.g. "| 10 | — | — |"). Those placeholder rows must never be treated as + unsupported factual claims about the number 10/11/12 — that was the + exact bug causing every full-format reading to be discarded.""" + api_context = { + "normalized_birth_input": {"date": "1987-07-16", "time": "15:26:00", "birth_place": "Lucknow, India"}, + "chart_context": { + "ascendant": "Cancer", + "houses": [ + {"house": 1, "planets": ["Sun", "Mercury"], "effects": []}, + {"house": 8, "planets": ["Moon", "Saturn"], "effects": []}, + ], + }, + "secondary_endpoint_results": { + "lalkitab_predictions": {"currentAge": 34, "placements": []}, + "lalkitab_remedies": {"overallStrength": "Mixed chart", "placements": []}, + }, + } + metadata = { + "lalkitab_runtime": True, + "lalkitab_api_context_full": api_context, + "lalkitab_rag_context_full": {}, + } + answer = ( + "Beta, your birth details are confirmed: 16 July 1987, 15:26, Lucknow, India.\n\n" + "| House | Rashi | Planets |\n" + "|-------|-------|---------|\n" + "| 1 | Cancer | Sun, Mercury |\n" + "| 2 | — | — |\n" + "| 3 | — | — |\n" + "| 8 | Aquarius | Moon, Saturn |\n" + "| 10 | — | — |\n" + "| 11 | — | — |\n" + "| 12 | — | — |\n\n" + "Where the planets sit: In the 1st house, Sun sits with Mercury, giving you natural " + "leadership. In the 8th house, Moon sits with Saturn, bringing depth but some emotional " + "ups and downs.\n\n" + "Career: with Sun and Mercury in the 1st house, you carry sharp thinking and confidence.\n\n" + "Lal Kitab remedies: Offer jaggery to birds every Wednesday.\n\n" + "The final word: with steady effort, beta, this chart has real strength to build on.\n\n" + "Lal Kitab reading is a guidance tradition, not a guarantee - your own conduct and effort " + "remain the biggest remedy." + ) + result = validate_claim_evidence(answer, tool_results={}, runtime_metadata=metadata) + assert result.is_valid, result.metadata + + +def test_fabricated_house_row_in_table_is_still_rejected(): + """A table row with a real (non-empty) fabricated placement must still + fail — the empty-row exemption must not become a hallucination hole.""" + api_context = { + "chart_context": { + "ascendant": "Cancer", + "houses": [{"house": 1, "planets": ["Sun"], "effects": []}], + }, + } + metadata = { + "lalkitab_runtime": True, + "lalkitab_api_context_full": api_context, + "lalkitab_rag_context_full": {}, + } + bad_answer = "| House | Rashi | Planets |\n|-------|-------|---------|\n| 3 | Leo | Venus, Rahu |" + result = validate_claim_evidence(bad_answer, tool_results={}, runtime_metadata=metadata) + assert result.is_valid is False + + +def test_planet_abbreviation_and_ordinal_house_answers_are_not_falsely_rejected(): + """Regression test: Vedika's chart/predictions payloads store planets as + short codes ("Me", "Ra", "Su"...) and house numbers as plain integers, + while a correct Lal Kitab reading writes full planet names ("Mercury", + "Rahu") and ordinals ("10th house") per the prompt's own formatting + rules. Before the token-canonicalization fix, this vocabulary mismatch + caused entirely correct, well-grounded readings to be randomly + discarded as "unsupported.\"""" + api_context = { + "normalized_birth_input": {"date": "2005-03-10", "time": "13:05:00", "birth_place": "Lucknow, India"}, + "chart_context": { + "success": True, + "data": { + "ascendant": "Leo", + "houses": [ + {"house": 4, "planets": ["Ju", "Ke"], "effects": []}, + {"house": 10, "planets": ["Me", "Ra"], "effects": []}, + {"house": 11, "planets": ["Ma"], "effects": []}, + ], + }, + }, + "secondary_endpoint_results": { + "lalkitab_predictions": {"success": True, "data": {"currentAge": 21, "placements": []}}, + }, + } + metadata = { + "lalkitab_runtime": True, + "lalkitab_api_context_full": api_context, + "lalkitab_rag_context_full": {}, + } + answer = ( + "Beta, your birth details are confirmed: 10 March 2005, 13:05, Lucknow, India. " + "Your ascendant is Leo.\n\n" + "Where the planets sit: In the 4th house, Jupiter and Ketu sit together. " + "In the 10th house, Mercury and Rahu sit. In the 11th house, Mars sits alone.\n\n" + "Career: with Mercury and Rahu in the 10th house of career, you have sharp " + "communication skills and unconventional thinking.\n\n" + "The final word: with steady effort, beta, this combination brings real growth.\n\n" + "Lal Kitab reading is a guidance tradition, not a guarantee - your own conduct " + "and effort remain the biggest remedy." + ) + result = validate_claim_evidence(answer, tool_results={}, runtime_metadata=metadata) + assert result.is_valid, result.metadata + + +def test_planet_in_wrong_house_still_fails_after_canonicalization_fix(): + """The abbreviation/ordinal canonicalization must not create a new + hallucination hole: a real, non-empty, but wrong house/planet claim + (Mars claimed in house 10, but the chart has it in house 11) must still + fail the anchor check.""" + api_context = { + "chart_context": {"data": {"houses": [{"house": 11, "planets": ["Ma"], "effects": []}]}}, + } + metadata = { + "lalkitab_runtime": True, + "lalkitab_api_context_full": api_context, + "lalkitab_rag_context_full": {}, + } + bad_answer = "House 10: Mars. This placement in the 10th house of career brings great success." + result = validate_claim_evidence(bad_answer, tool_results={}, runtime_metadata=metadata) + assert result.is_valid is False + + +@pytest.mark.asyncio +async def test_unchanged_birth_profile_reuses_cached_chart_without_recalling_vedika(monkeypatch): + """Regression test: the confirmed birth profile is carried forward on + every later turn (so follow-ups never repeat birth details) — but + passing it through unfiltered made build_lalkitab_runtime_context treat + its mere presence as "new birth details," recalculating the chart from + Vedika on literally every single question and needlessly burning the + provider's rate limit. strip_unchanged_birth_fields must remove + birth_date/time/place once they only restate the cached chart's own + values, so an ordinary follow-up reuses the cached chart instead.""" + from app.services import lalkitab_runtime as lk + from app.services.context_connector_packs import get_connector_pack + from app.services.lalkitab_interpretation import strip_unchanged_birth_fields + + pack = get_connector_pack("vedika_lal_kitab") + pack["auth"] = {"type": "bearer", "token": "test"} + config = {"domain": {"template": "astrology_lalkitab"}, "context_connectors": [pack]} + + connector_calls = [] + + async def fake_run(self, query, payload=None, **kwargs): + connector_calls.append(self.endpoint["id"]) + return ToolResult(success=True, data={"endpoint": self.endpoint["id"], "ascendant": "Leo"}) + + monkeypatch.setattr(lk.ContextConnectorTool, "run", fake_run) + + birth_profile = { + "source": "llm", + "name": "Arjuna Singh", + "birth_date": "1985-07-04", + "birth_time": "06:10:00", + "birth_place": "Lucknow, India", + "question": "Tell me about my life", + } + cached_normalized = { + "date": "1985-07-04", "time": "06:10:00", "birth_place": "Lucknow, India", + "latitude": 26.8467, "longitude": 80.9462, "timezone": "+05:30", + } + pending_state = { + "api_context": { + "chart_context": {"ascendant": "Leo", "houses": [{"house": 1, "planets": ["Su", "Ma"], "effects": []}]}, + "secondary_endpoint_results": { + "lalkitab_predictions": {"currentAge": 40, "placements": []}, + "lalkitab_remedies": {"overallStrength": "Mixed chart", "placements": []}, + }, + "normalized_birth_input": cached_normalized, + }, + } + + filtered_profile = strip_unchanged_birth_fields(birth_profile, cached_normalized) + assert "birth_date" not in filtered_profile + assert "birth_time" not in filtered_profile + assert "birth_place" not in filtered_profile + assert filtered_profile["question"] == "Tell me about my life" + + out = await lk.build_lalkitab_runtime_context( + config, + "Tell me about my life", + pending_state=pending_state, + birth_profile=filtered_profile, + ) + assert connector_calls == [], "cached chart should be reused, not re-fetched from Vedika" + assert out.used_cached_context is True + + +def test_strip_unchanged_birth_fields_still_passes_through_a_real_correction(): + """A genuine correction (a new place that differs from the cache) must + not be stripped — only fields that merely restate the cache are dropped.""" + from app.services.lalkitab_interpretation import strip_unchanged_birth_fields + + birth_profile = {"birth_date": "1985-07-04", "birth_time": "06:10:00", "birth_place": "Mumbai, India"} + cached_normalized = {"date": "1985-07-04", "time": "06:10:00", "birth_place": "Lucknow, India"} + filtered = strip_unchanged_birth_fields(birth_profile, cached_normalized) + assert filtered["birth_place"] == "Mumbai, India" + assert "birth_date" not in filtered + assert "birth_time" not in filtered + + +def test_lalkitab_probe_message_appends_hint_only_when_runtime_would_miss_intent(): + """Regression test: lalkitab_runtime.py's own keyword list has no + "dasha" term, so "What is my current dasha?" alone was falling through + to the generic Orchestrator (which calls raw Vedika tools directly and + skips chart-first ordering/caching) instead of the dedicated Lal Kitab + runtime. _lalkitab_probe_message must append a recognized hint only in + that gap — never for messages the runtime already understands, and + never affecting the real message used for the actual answer.""" + from app.services.message_service import MessageService + + service = MessageService.__new__(MessageService) + + probed = service._lalkitab_probe_message("What is my current dasha?") + assert probed != "What is my current dasha?" + assert "lal kitab" in probed.lower() + + # Runtime already recognizes these — must pass through unchanged. + for message in ("How is my career?", "Where is my Moon?", "DOB 1990-01-01, 10:00, Delhi"): + assert service._lalkitab_probe_message(message) == message + + +@pytest.mark.asyncio +async def test_dasha_only_question_is_now_handled_by_lalkitab_runtime(monkeypatch): + """End-to-end confirmation: with the probe applied, a bare dasha + question is now routed to (and handled by) the dedicated Lal Kitab + runtime instead of falling through to the generic Orchestrator.""" + from app.services import lalkitab_runtime as lk + from app.services.context_connector_packs import get_connector_pack + from app.services.message_service import MessageService + + pack = get_connector_pack("vedika_lal_kitab") + pack["auth"] = {"type": "bearer", "token": "test"} + config = {"domain": {"template": "astrology_lalkitab"}, "context_connectors": [pack]} + + async def fake_run(self, query, payload=None, **kwargs): + return ToolResult(success=True, data={"endpoint": self.endpoint["id"], "ascendant": "Leo"}) + + monkeypatch.setattr(lk.ContextConnectorTool, "run", fake_run) + + service = MessageService.__new__(MessageService) + probed_message = service._lalkitab_probe_message("What is my current dasha?") + + # Without the probe, this question is NOT handled (reproduces the bug). + unprobed = await lk.build_lalkitab_runtime_context( + config, "What is my current dasha?", pending_state={}, birth_profile=None, + ) + assert unprobed.handled is False + + # With the probe applied (as message_service.py now does), it IS handled. + probed_result = await lk.build_lalkitab_runtime_context( + config, probed_message, pending_state={}, birth_profile=None, + ) + assert probed_result.handled is True + + +def test_probe_forced_by_existing_chart_for_married_question(): + """Regression test matching the exact reported bug: with a validated + chart already on screen, "where I will get married" (no exact keyword + match in lalkitab_runtime.py's own list — "marry"/"married" is not + "marriage") was falling through to the generic Orchestrator instead of + the dedicated Lal Kitab runtime (fewer steps, RAG/chart-context skipped, + wrong "no verified chart data" answer). Once a validated chart exists, + any non-meta question must be forced onto the dedicated runtime.""" + from app.services.lalkitab_interpretation import is_lalkitab_probe_forced_by_existing_chart + + chart = {"ascendant": "Scorpio", "houses": [{"house": 1, "planets": ["Ke"], "effects": []}]} + assert is_lalkitab_probe_forced_by_existing_chart("where I will get married", chart) is True + assert is_lalkitab_probe_forced_by_existing_chart("How is my career?", chart) is True + # Greetings/meta questions must still be excluded even with a chart. + assert is_lalkitab_probe_forced_by_existing_chart("hi", chart) is False + assert is_lalkitab_probe_forced_by_existing_chart("what can you do", chart) is False + # No chart yet -> must not force routing (there's nothing to answer from). + assert is_lalkitab_probe_forced_by_existing_chart("where I will get married", None) is False + assert is_lalkitab_probe_forced_by_existing_chart("where I will get married", {}) is False + + +def test_probe_message_uses_broadened_check_with_cached_chart(): + from app.services.message_service import MessageService + + service = MessageService.__new__(MessageService) + chart = {"ascendant": "Scorpio", "houses": [{"house": 1, "planets": ["Ke"], "effects": []}]} + + probed = service._lalkitab_probe_message("where I will get married", chart) + assert probed != "where I will get married" + assert "lal kitab" in probed.lower() + + # "married" is itself a recognized life-topic keyword (see + # _SUPPLEMENTARY_ASTROLOGY_INTENT_PATTERN), so it is probed even + # without a cached chart — the chart-presence check exists as a + # broader catch-all for words that pattern doesn't happen to cover. + assert service._lalkitab_probe_message("where I will get married", None) != "where I will get married" + + # A truly unrecognized message with no chart is left unchanged (there + # is nothing yet to route it against). + assert service._lalkitab_probe_message("what a lovely day", None) == "what a lovely day" + + # A message the runtime already understands passes through untouched + # regardless of chart presence. + assert service._lalkitab_probe_message("How is my career?", chart) == "How is my career?" + + +@pytest.mark.asyncio +async def test_married_question_with_existing_chart_is_now_handled_end_to_end(monkeypatch): + from app.services import lalkitab_runtime as lk + from app.services.context_connector_packs import get_connector_pack + from app.services.message_service import MessageService + + pack = get_connector_pack("vedika_lal_kitab") + pack["auth"] = {"type": "bearer", "token": "test"} + config = {"domain": {"template": "astrology_lalkitab"}, "context_connectors": [pack]} + + async def fake_run(self, query, payload=None, **kwargs): + return ToolResult(success=True, data={"endpoint": self.endpoint["id"], "ascendant": "Scorpio"}) + + monkeypatch.setattr(lk.ContextConnectorTool, "run", fake_run) + + service = MessageService.__new__(MessageService) + chart = {"ascendant": "Scorpio", "houses": [{"house": 1, "planets": ["Ke"], "effects": []}]} + probed_message = service._lalkitab_probe_message("where I will get married", chart) + + pending_state = {"api_context": {"chart_context": chart, "normalized_birth_input": { + "date": "2003-03-10", "time": "01:10:00", "birth_place": "Lucknow, India", + }}} + + unprobed = await lk.build_lalkitab_runtime_context( + config, "where I will get married", pending_state=pending_state, birth_profile=None, + ) + assert unprobed.handled is False # reproduces the bug + + probed_result = await lk.build_lalkitab_runtime_context( + config, probed_message, pending_state=pending_state, birth_profile=None, + ) + assert probed_result.handled is True # fixed + + +def test_hallucinated_dasha_without_evidence_is_still_rejected(): + metadata = { + "lalkitab_runtime": True, + "lalkitab_api_context_full": {"chart_context": {"ascendant": "Aries"}}, + "lalkitab_rag_context_full": {}, + "lalkitab_interpretation_context_full": {"ascendant": "Aries"}, + } + bad_answer = "Your current dasha is Venus mahadasha with Rahu antardasha." + result = validate_claim_evidence(bad_answer, tool_results={}, runtime_metadata=metadata) + assert result.is_valid is False diff --git a/apps/widget/index.html b/apps/widget/index.html index ed607ad..d4c325c 100644 --- a/apps/widget/index.html +++ b/apps/widget/index.html @@ -189,6 +189,13 @@

NOVA Chat Widget

const previewInput = document.getElementById('agent-preview-input'); const currentAgentId = new URLSearchParams(window.location.search).get('agent_id'); if (currentAgentId && previewInput) previewInput.value = currentAgentId; + // Hide the demo container entirely when an agent_id is in the URL so the + // page's form cannot intercept keyboard events (Enter) typed into the + // widget chat input rendered on top of it. + if (currentAgentId) { + const demoContainer = document.querySelector('.demo-container'); + if (demoContainer) demoContainer.style.display = 'none'; + } previewForm?.addEventListener('submit', function (event) { event.preventDefault(); const agentId = previewInput?.value?.trim(); diff --git a/apps/widget/package-lock.json b/apps/widget/package-lock.json index 6eb7880..3b5587e 100644 --- a/apps/widget/package-lock.json +++ b/apps/widget/package-lock.json @@ -156,7 +156,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -519,7 +518,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -560,7 +558,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -2052,7 +2049,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2158,7 +2156,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.21.tgz", "integrity": "sha512-CsGG2P3I5y48RPMfprQGfy4JPRZ6csfC3ltBZSRItG3ngggmNY/qs2uZKp4p9VbrpqNNSMzUZNFZKzgOGnd/VA==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2169,7 +2166,6 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -2180,7 +2176,6 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2238,7 +2233,6 @@ "integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/types": "8.46.1", @@ -2577,7 +2571,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2628,6 +2621,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -2780,7 +2774,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -3043,7 +3036,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dompurify": { "version": "3.4.12", @@ -3213,7 +3207,6 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3797,7 +3790,6 @@ "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.8.1", @@ -4211,6 +4203,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -4544,7 +4537,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -4576,6 +4568,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -4591,6 +4584,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -4643,7 +4637,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -4656,7 +4649,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -4670,7 +4662,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.17.0", @@ -5029,7 +5022,6 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5138,7 +5130,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5239,7 +5230,6 @@ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -5333,7 +5323,6 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/apps/widget/src/App.tsx b/apps/widget/src/App.tsx index 3eeaf64..80bcf9d 100644 --- a/apps/widget/src/App.tsx +++ b/apps/widget/src/App.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { WidgetButton } from './components/WidgetButton'; import { ChatWindow } from './components/ChatWindow'; +import { BirthDetailsForm } from './components/LalKitab/BirthDetailsForm'; +import { LalKitabSideChart } from './components/LalKitab/LalKitabSideChart'; import { useWidgetStore } from './stores/widgetStore'; import { useFullscreen } from './hooks/useFullscreen'; import { APIClient } from './utils/apiClient'; @@ -89,6 +91,7 @@ function App({ config }: AppProps) { setHumanInControl, setMessageFeedback, removeMessage, + brandTheme, } = useWidgetStore(); const [activity, setActivity] = React.useState(EMPTY_ACTIVITY); @@ -121,6 +124,7 @@ function App({ config }: AppProps) { const [humanTakeoverEnabled, setHumanTakeoverEnabled] = React.useState(false); const [showSources, setShowSources] = React.useState(config?.showSources ?? false); const [showProductCards, setShowProductCards] = React.useState(config?.showProductCards ?? true); + const [showInquiry, setShowInquiry] = React.useState(false); // 'basic' = the lightweight cycling indicator; 'advanced' = the live step // timeline. Admin-configurable per agent; defaults to 'basic'. const [activityMode, setActivityMode] = React.useState<'basic' | 'advanced'>('basic'); @@ -131,6 +135,41 @@ function App({ config }: AppProps) { // Holds the pending conversation lifecycle event type until agentId is ready. const [convStartEvent, setConvStartEvent] = React.useState<'conversation_started' | 'conversation_resumed' | null>(null); + // ── Lal Kitab form-first flow ────────────────────────────────── + // Only active for astrology_lalkitab agents with lalkitab.require_birth_profile_form + // enabled in their configuration (opt-in, see message_service.py + // _lalkitab_requires_birth_profile_form). All other agents are unaffected. + const [lalkitabFormRequired, setLalkitabFormRequired] = React.useState(false); + const [lalkitabProfileStatus, setLalkitabProfileStatus] = React.useState<'unknown' | 'missing' | 'confirmed'>('unknown'); + const [lalkitabChart, setLalkitabChart] = React.useState(null); + const [lalkitabReportText, setLalkitabReportText] = React.useState(undefined); + // Maps this brand's real theme tokens onto the birth-details form's CSS + // variables, so the form matches the same light/dark palette the rest of + // the widget uses instead of a hardcoded color scheme of its own. + const lalkitabCssVars = React.useMemo(() => { + const tk = brandTheme?.tokens; + const mode = brandTheme?.mode ?? 'light'; + const accent = tk?.accentColor ?? '#c2410c'; + const lightArrow = "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='rgba(60,40,20,0.45)' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E\")"; + const darkArrow = "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='rgba(255,255,255,0.4)' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E\")"; + return { + '--lk-bg': tk?.panelBg ?? (mode === 'dark' + ? 'linear-gradient(160deg,#080d14 0%,#0d1520 30%,#061008 100%)' + : 'linear-gradient(160deg,#fdfaf5 0%,#ede8df 100%)'), + '--lk-fg': tk?.titleColor ?? (mode === 'dark' ? '#ffffff' : '#1a1208'), + '--lk-muted': tk?.subtitleColor ?? (mode === 'dark' ? 'rgba(255,255,255,0.45)' : 'rgba(60,40,20,0.5)'), + '--lk-card-bg': mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(255,255,255,0.7)', + '--lk-border': tk?.dividerColor ?? (mode === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)'), + '--lk-card-shadow': mode === 'dark' ? '0 10px 30px rgba(0,0,0,0.35)' : '0 10px 30px rgba(0,0,0,0.08)', + '--lk-input-bg': tk?.inputBg ?? (mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(255,255,255,0.85)'), + '--lk-input-border': tk?.inputBorder ?? (mode === 'dark' ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)'), + '--lk-accent': accent, + '--lk-accent-grad': tk?.sendBg ?? `linear-gradient(135deg,${accent},${accent})`, + '--lk-accent-shadow': tk?.sendShad ?? `0 4px 14px ${accent}55`, + '--lk-select-arrow': mode === 'dark' ? darkArrow : lightArrow, + } as React.CSSProperties; + }, [brandTheme]); + // ── Resolve agent ID ────────────────────────────────────────── React.useEffect(() => { const urlParams = new URLSearchParams(window.location.search); @@ -168,6 +207,17 @@ function App({ config }: AppProps) { const takeoverEnabled = config?.enableHumanTakeover ?? widgetChannel.human_takeover ?? features.human_takeover === true; const shouldShowSources = config?.showSources ?? widgetChannel.show_sources ?? features.show_sources === true; const shouldShowProductCards = config?.showProductCards ?? widgetChannel.show_product_cards ?? features.show_product_cards !== false; + const inquiryConfig = agent.configuration?.inquiry || {}; + const shouldShowInquiry = Boolean(inquiryConfig.enabled); + const domainTemplate = agent.configuration?.domain?.template || agent.configuration?.agent_template || agent.configuration?.template; + const lalkitabCfg = agent.configuration?.lalkitab || {}; + const isLalkitabAgent = String(domainTemplate || '').toLowerCase().includes('lalkitab') || String(domainTemplate || '').toLowerCase().includes('lal_kitab') || String(domainTemplate || '').toLowerCase().includes('lal-kitab') || String(domainTemplate || '').toLowerCase().includes('lal kitab'); + // Default ON for every Lal Kitab agent — set + // configuration.lalkitab.require_birth_profile_form = false explicitly + // to opt a specific agent out of the form-first flow. + const formFlagRaw = lalkitabCfg.require_birth_profile_form; + const formRequired = formFlagRaw === undefined || formFlagRaw === null ? true : Boolean(formFlagRaw); + setLalkitabFormRequired(isLalkitabAgent && formRequired); const resolvedActivityMode = (widgetChannel.activity_mode ?? features.activity_mode) === 'advanced' ? 'advanced' : 'basic'; const resolvedActivityPersistence = @@ -176,6 +226,7 @@ function App({ config }: AppProps) { setHumanTakeoverEnabled(takeoverEnabled); setShowSources(shouldShowSources); setShowProductCards(shouldShowProductCards); + setShowInquiry(shouldShowInquiry); setActivityMode(resolvedActivityMode); setActivityPersistence(resolvedActivityPersistence); if (!brandId) return; @@ -352,6 +403,16 @@ function App({ config }: AppProps) { }; }, [isOpen, conversationId, agentId, setConversationId, setMessages]); + // ── Lal Kitab: always require the birth-details form fresh on every page + // load/refresh — even if this browser session already has a confirmed + // profile stored server-side for this conversation. We deliberately do + // NOT call GET /lalkitab/profile to auto-resume past the form here: a + // refresh must show the form again, not silently jump to chat. + React.useEffect(() => { + if (!lalkitabFormRequired) return; + setLalkitabProfileStatus('missing'); + }, [lalkitabFormRequired]); + // ── Fire conversation lifecycle event once both IDs are ready ─ React.useEffect(() => { if (!convStartEvent || !conversationId || !agentId) return; @@ -369,7 +430,7 @@ function App({ config }: AppProps) { const handleToggleWidget = () => setIsOpen(!isOpen); - const handleSendMessage = async (text: string) => { + const handleSendMessage = async (text: string, opts?: { inquiryJustCompleted?: boolean }) => { if (!agentId) { addMessage({ id: Date.now().toString(), @@ -439,7 +500,7 @@ function App({ config }: AppProps) { const client = useWebSocket ? wsClient : apiClient; const response = await client.sendMessage( - { content: text, context, userId: currentUserId }, + { content: text, context, userId: currentUserId, inquiryJustCompleted: opts?.inquiryJustCompleted }, currentConvId, agentId, (chunk) => { @@ -500,8 +561,24 @@ function App({ config }: AppProps) { } }; - const handleRegenerate = (messageId: string) => { - const msgIndex = messages.findIndex(m => m.id === messageId); + // V2 inquiry: after form submission the success message is shown as an + // assistant bubble directly by ChatWindow. The next real user message + // (anything the user types after that) must carry inquiry_just_completed=true + // so the backend skips the inquiry adapter for that one turn. + const handleSendMessageWithReset = (text: string) => { + // Show the success message as an assistant bubble directly — no LLM call. + // Include inquiry_pending.submitted so the next user message is treated + // as a clean turn (not part of an ongoing inquiry flow). + addMessage({ + id: Date.now().toString(), + content: text, + role: 'assistant', + timestamp: new Date(), + metadata: { inquiry_pending: { submitted: true } }, + }); + }; + + const handleRegenerate = (messageId: string) => { const msgIndex = messages.findIndex(m => m.id === messageId); if (msgIndex < 0) return; // Find the preceding user message let userMsg: string | null = null; @@ -520,27 +597,63 @@ function App({ config }: AppProps) {
- {isOpen && ( -
+ {isOpen && lalkitabFormRequired && lalkitabProfileStatus === 'missing' && ( +
+
+ { + setLalkitabChart(kundaliChart); + setLalkitabProfileStatus('confirmed'); + }} + /> +
+
+ )} + + {isOpen && (!lalkitabFormRequired || lalkitabProfileStatus === 'confirmed') && ( +
setIsOpen(false)} - onToggleExpand={toggleExpanded} + onToggleExpand={lalkitabFormRequired ? undefined : toggleExpanded} onRegenerate={handleRegenerate} onFeedback={setMessageFeedback} showSources={showSources} showProductCards={showProductCards} + showInquiry={showInquiry} isAgentConfigured={Boolean(agentId)} unavailableMessage={unavailableMessage || undefined} apiUrl={configuredApiBase} agentId={agentId} + conversationId={conversationId} + onSendMessageWithReset={handleSendMessageWithReset} + sideChart={ + lalkitabFormRequired && lalkitabProfileStatus === 'confirmed' + ? + : undefined + } + // Lal Kitab-only greeting defaults, used only when this brand + // hasn't configured its own hero/chips text (see ChatWindow's + // fallback order: brandTheme value first, then these defaults). + // Every other agent/brand is completely unaffected. + defaultHeroTitle={lalkitabFormRequired ? "Hi, I'm your Lal Kitab guide" : undefined} + defaultHeroSubtitle={lalkitabFormRequired ? 'How can I help you today?' : undefined} + defaultSuggestionChips={lalkitabFormRequired ? [ + 'Where is my Moon?', + 'How is my career?', + 'Tell me about my life', + 'What is my current dasha?', + ] : undefined} />
)} diff --git a/apps/widget/src/components/ChatWindow.tsx b/apps/widget/src/components/ChatWindow.tsx index 492474b..e2b7ff8 100644 --- a/apps/widget/src/components/ChatWindow.tsx +++ b/apps/widget/src/components/ChatWindow.tsx @@ -3,6 +3,7 @@ import type { Message, BrandThemeTokens, ActivityState } from '../types'; import { MessageBubble } from './MessageBubble'; import { ThinkingIndicator } from './ThinkingIndicator'; import { ActivityTimeline } from './ActivityTimeline'; +import { InquiryFormModal } from './InquiryFormModal'; import { EMPTY_ACTIVITY } from '../utils/activityTimeline'; import { useWidgetStore } from '../stores/widgetStore'; import { NOVA_LOGO } from '../utils/brandTheme'; @@ -26,10 +27,27 @@ interface ChatWindowProps { onFeedback?: (id: string, feedback: 'up' | 'down' | null) => void; showSources?: boolean; showProductCards?: boolean; + showInquiry?: boolean; isAgentConfigured?: boolean; unavailableMessage?: string; apiUrl?: string; agentId?: string | null; + conversationId?: string | null; + onSendMessageWithReset?: (text: string) => void; + /** Optional content rendered as a left-hand column alongside the chat + * (e.g. the Lal Kitab kundali chart) so chart and chat share one panel + * instead of being separate screens. */ + sideChart?: React.ReactNode; + /** Fallback greeting title used only when the brand has no hero_title + * configured of its own — lets a specific flow (e.g. Lal Kitab) show a + * good default greeting without changing behavior for any other brand, + * which keeps using its own configured/default hero text untouched. */ + defaultHeroTitle?: string; + /** Fallback greeting subtitle, same rules as defaultHeroTitle. */ + defaultHeroSubtitle?: string; + /** Fallback clickable suggestion chips shown under the greeting, used + * only when the brand has no suggestion_chips of its own configured. */ + defaultSuggestionChips?: string[]; } // ── Icon components ──────────────────────────────────────────── @@ -150,21 +168,110 @@ export const ChatWindow: React.FC = ({ onFeedback, showSources = false, showProductCards = true, + showInquiry = false, isAgentConfigured = true, unavailableMessage, apiUrl, agentId, + conversationId, + onSendMessageWithReset, + sideChart, + defaultHeroTitle, + defaultHeroSubtitle, + defaultSuggestionChips, }) => { - const { brandTheme } = useWidgetStore(); + const { brandTheme, addMessage, removeMessage } = useWidgetStore(); const [inputValue, setInputValue] = React.useState(''); const messagesEndRef = React.useRef(null); + // ── Inline inquiry form ─────────────────────────────────────────── + const inlineFormMsgId = React.useRef(null); + + const showInquiryFormCard = React.useCallback((productName: string, productSku?: string) => { + if (inlineFormMsgId.current) removeMessage(inlineFormMsgId.current); + const id = `inq_form_${Date.now()}`; + inlineFormMsgId.current = id; + addMessage({ + id, + role: 'assistant', + content: '', + timestamp: new Date(), + metadata: { inline_inquiry_form: true, product_name: productName, product_sku: productSku || '' }, + }); + }, [addMessage, removeMessage]); + + // Product-card "Request a Quote" button: there's no existing chat message + // representing the click, so insert a synthetic user bubble first, then + // show the form after a short delay (bubble appears, then form). + const handleOpenInquiryForm = React.useCallback((productName: string, productSku?: string) => { + addMessage({ + id: `inq_user_${Date.now()}`, + role: 'user', + content: `Request a quote for ${productName}`, + timestamp: new Date(), + }); + setTimeout(() => showInquiryFormCard(productName, productSku), 600); + }, [addMessage, showInquiryFormCard]); + + // API-triggered (typed message / LLM tool call) path: the user's own + // typed message is already visible in the chat, so do NOT insert another + // synthetic "Request a quote for..." bubble — just show the form directly + // under the assistant's "Opening the quote form..." acknowledgement. + const handleOpenInquiryFormFromApi = React.useCallback((productName: string, productSku?: string) => { + showInquiryFormCard(productName, productSku); + }, [showInquiryFormCard]); + + const handleInquirySuccess = React.useCallback((message: string) => { + if (inlineFormMsgId.current) { removeMessage(inlineFormMsgId.current); inlineFormMsgId.current = null; } + if (onSendMessageWithReset) { + onSendMessageWithReset(message); + } else { + addMessage({ + id: `inq_ok_${Date.now()}`, + role: 'assistant', + content: message, + timestamp: new Date(), + // Mark inquiry as submitted so _load_inquiry_pending_state clears the + // flow on the next user message, allowing normal product searches again. + metadata: { inquiry_pending: { submitted: true } }, + }); + } + }, [addMessage, removeMessage, onSendMessageWithReset]); + + const handleInquirySkip = React.useCallback(() => { + if (inlineFormMsgId.current) { removeMessage(inlineFormMsgId.current); inlineFormMsgId.current = null; } + addMessage({ id: `inq_skip_${Date.now()}`, role: 'assistant', content: "No problem! Let me know if you need anything else.", timestamp: new Date() }); + }, [addMessage, removeMessage]); + + // Open form when API signals open_inquiry_form in metadata. + // The assistant message is added as an empty placeholder first (same id) + // and only gets its metadata (incl. open_inquiry_form) filled in later via + // updateMessage once the API responds — so this must re-check whenever the + // metadata flag itself changes, not just when a new message id appears. + const lastMsg = React.useMemo(() => messages[messages.length - 1], [messages]); + const lastMsgOpenInquiryFlag = lastMsg?.role === 'assistant' + ? Boolean((lastMsg.metadata as Record | undefined)?.open_inquiry_form) + : false; + const handledInquiryFormMsgId = React.useRef(null); + React.useEffect(() => { + if (!lastMsg || lastMsg.role !== 'assistant') return; + const meta = lastMsg.metadata as Record | undefined; + if (meta?.open_inquiry_form && showInquiry && handledInquiryFormMsgId.current !== lastMsg.id) { + handledInquiryFormMsgId.current = lastMsg.id; + const pName = typeof meta.product_name === 'string' ? meta.product_name : ''; + handleOpenInquiryFormFromApi(pName); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastMsg?.id, lastMsgOpenInquiryFlag]); + const tk = brandTheme?.tokens; - const mode = brandTheme?.mode ?? 'dark'; + // Default to the light cream palette (not dark) while brand theme is + // still loading, so there's no dark/green flash before tokens arrive — + // every brand should land on the same neutral light look by default. + const mode = brandTheme?.mode ?? 'light'; const isLanding = messages.length === 0 && !isTyping; // Only show the thinking indicator while the assistant hasn't started streaming content yet - const lastMsg = messages[messages.length - 1]; const showThinkingIndicator = isTyping && (!lastMsg || lastMsg.role !== 'assistant' || !lastMsg.content); const categories = brandTheme?.cyclingCategories ?? []; @@ -232,9 +339,42 @@ export const ChatWindow: React.FC = ({ return (
+ {sideChart && ( +
+ {sideChart} +
+ )} +
{/* Background orb */}
@@ -294,12 +434,12 @@ export const ChatWindow: React.FC = ({ )}
- {brandTheme?.heroTitle ?? 'NOVA Preview'} + {brandTheme?.heroTitle ?? defaultHeroTitle ?? 'NOVA Preview'}
{hasCycling ? ( <>
- {brandTheme?.heroSubtitle ?? 'Ask me anything about'} + {brandTheme?.heroSubtitle ?? defaultHeroSubtitle ?? 'Ask me anything about'}
= ({ ) : (
{unavailableMessage || (isAgentConfigured - ? (brandTheme?.heroSubtitle ?? 'Ask me anything') + ? (brandTheme?.heroSubtitle ?? defaultHeroSubtitle ?? 'Ask me anything') : 'Add ?agent_id=your-agent-id&open=1 to activate a specific agent.')}
)}
- {brandTheme && brandTheme.suggestionChips.length > 0 && ( + {(brandTheme?.suggestionChips?.length ? brandTheme.suggestionChips : defaultSuggestionChips ?? []).length > 0 && (
- {brandTheme.suggestionChips.map(chip => ( + {(brandTheme?.suggestionChips?.length ? brandTheme.suggestionChips : defaultSuggestionChips ?? []).map(chip => (
); }; diff --git a/apps/widget/src/components/CountrySelect.tsx b/apps/widget/src/components/CountrySelect.tsx new file mode 100644 index 0000000..14d4449 --- /dev/null +++ b/apps/widget/src/components/CountrySelect.tsx @@ -0,0 +1,234 @@ +import React from 'react'; + +export interface CountryOption { + code: string; + name: string; +} + +// ISO 3166-1 country list (common + full) +export const COUNTRIES: CountryOption[] = [ + { code: 'AF', name: 'Afghanistan' }, + { code: 'AL', name: 'Albania' }, + { code: 'DZ', name: 'Algeria' }, + { code: 'AD', name: 'Andorra' }, + { code: 'AO', name: 'Angola' }, + { code: 'AG', name: 'Antigua and Barbuda' }, + { code: 'AR', name: 'Argentina' }, + { code: 'AM', name: 'Armenia' }, + { code: 'AU', name: 'Australia' }, + { code: 'AT', name: 'Austria' }, + { code: 'AZ', name: 'Azerbaijan' }, + { code: 'BS', name: 'Bahamas' }, + { code: 'BH', name: 'Bahrain' }, + { code: 'BD', name: 'Bangladesh' }, + { code: 'BB', name: 'Barbados' }, + { code: 'BY', name: 'Belarus' }, + { code: 'BE', name: 'Belgium' }, + { code: 'BZ', name: 'Belize' }, + { code: 'BJ', name: 'Benin' }, + { code: 'BT', name: 'Bhutan' }, + { code: 'BO', name: 'Bolivia' }, + { code: 'BA', name: 'Bosnia and Herzegovina' }, + { code: 'BW', name: 'Botswana' }, + { code: 'BR', name: 'Brazil' }, + { code: 'BN', name: 'Brunei' }, + { code: 'BG', name: 'Bulgaria' }, + { code: 'BF', name: 'Burkina Faso' }, + { code: 'BI', name: 'Burundi' }, + { code: 'CV', name: 'Cabo Verde' }, + { code: 'KH', name: 'Cambodia' }, + { code: 'CM', name: 'Cameroon' }, + { code: 'CA', name: 'Canada' }, + { code: 'CF', name: 'Central African Republic' }, + { code: 'TD', name: 'Chad' }, + { code: 'CL', name: 'Chile' }, + { code: 'CN', name: 'China' }, + { code: 'CO', name: 'Colombia' }, + { code: 'KM', name: 'Comoros' }, + { code: 'CG', name: 'Congo' }, + { code: 'CR', name: 'Costa Rica' }, + { code: 'HR', name: 'Croatia' }, + { code: 'CU', name: 'Cuba' }, + { code: 'CY', name: 'Cyprus' }, + { code: 'CZ', name: 'Czech Republic' }, + { code: 'DK', name: 'Denmark' }, + { code: 'DJ', name: 'Djibouti' }, + { code: 'DM', name: 'Dominica' }, + { code: 'DO', name: 'Dominican Republic' }, + { code: 'EC', name: 'Ecuador' }, + { code: 'EG', name: 'Egypt' }, + { code: 'SV', name: 'El Salvador' }, + { code: 'GQ', name: 'Equatorial Guinea' }, + { code: 'ER', name: 'Eritrea' }, + { code: 'EE', name: 'Estonia' }, + { code: 'SZ', name: 'Eswatini' }, + { code: 'ET', name: 'Ethiopia' }, + { code: 'FJ', name: 'Fiji' }, + { code: 'FI', name: 'Finland' }, + { code: 'FR', name: 'France' }, + { code: 'GA', name: 'Gabon' }, + { code: 'GM', name: 'Gambia' }, + { code: 'GE', name: 'Georgia' }, + { code: 'DE', name: 'Germany' }, + { code: 'GH', name: 'Ghana' }, + { code: 'GR', name: 'Greece' }, + { code: 'GD', name: 'Grenada' }, + { code: 'GT', name: 'Guatemala' }, + { code: 'GN', name: 'Guinea' }, + { code: 'GW', name: 'Guinea-Bissau' }, + { code: 'GY', name: 'Guyana' }, + { code: 'HT', name: 'Haiti' }, + { code: 'HN', name: 'Honduras' }, + { code: 'HU', name: 'Hungary' }, + { code: 'IS', name: 'Iceland' }, + { code: 'IN', name: 'India' }, + { code: 'ID', name: 'Indonesia' }, + { code: 'IR', name: 'Iran' }, + { code: 'IQ', name: 'Iraq' }, + { code: 'IE', name: 'Ireland' }, + { code: 'IL', name: 'Israel' }, + { code: 'IT', name: 'Italy' }, + { code: 'JM', name: 'Jamaica' }, + { code: 'JP', name: 'Japan' }, + { code: 'JO', name: 'Jordan' }, + { code: 'KZ', name: 'Kazakhstan' }, + { code: 'KE', name: 'Kenya' }, + { code: 'KI', name: 'Kiribati' }, + { code: 'KW', name: 'Kuwait' }, + { code: 'KG', name: 'Kyrgyzstan' }, + { code: 'LA', name: 'Laos' }, + { code: 'LV', name: 'Latvia' }, + { code: 'LB', name: 'Lebanon' }, + { code: 'LS', name: 'Lesotho' }, + { code: 'LR', name: 'Liberia' }, + { code: 'LY', name: 'Libya' }, + { code: 'LI', name: 'Liechtenstein' }, + { code: 'LT', name: 'Lithuania' }, + { code: 'LU', name: 'Luxembourg' }, + { code: 'MG', name: 'Madagascar' }, + { code: 'MW', name: 'Malawi' }, + { code: 'MY', name: 'Malaysia' }, + { code: 'MV', name: 'Maldives' }, + { code: 'ML', name: 'Mali' }, + { code: 'MT', name: 'Malta' }, + { code: 'MH', name: 'Marshall Islands' }, + { code: 'MR', name: 'Mauritania' }, + { code: 'MU', name: 'Mauritius' }, + { code: 'MX', name: 'Mexico' }, + { code: 'FM', name: 'Micronesia' }, + { code: 'MD', name: 'Moldova' }, + { code: 'MC', name: 'Monaco' }, + { code: 'MN', name: 'Mongolia' }, + { code: 'ME', name: 'Montenegro' }, + { code: 'MA', name: 'Morocco' }, + { code: 'MZ', name: 'Mozambique' }, + { code: 'MM', name: 'Myanmar' }, + { code: 'NA', name: 'Namibia' }, + { code: 'NR', name: 'Nauru' }, + { code: 'NP', name: 'Nepal' }, + { code: 'NL', name: 'Netherlands' }, + { code: 'NZ', name: 'New Zealand' }, + { code: 'NI', name: 'Nicaragua' }, + { code: 'NE', name: 'Niger' }, + { code: 'NG', name: 'Nigeria' }, + { code: 'MK', name: 'North Macedonia' }, + { code: 'NO', name: 'Norway' }, + { code: 'OM', name: 'Oman' }, + { code: 'PK', name: 'Pakistan' }, + { code: 'PW', name: 'Palau' }, + { code: 'PA', name: 'Panama' }, + { code: 'PG', name: 'Papua New Guinea' }, + { code: 'PY', name: 'Paraguay' }, + { code: 'PE', name: 'Peru' }, + { code: 'PH', name: 'Philippines' }, + { code: 'PL', name: 'Poland' }, + { code: 'PT', name: 'Portugal' }, + { code: 'QA', name: 'Qatar' }, + { code: 'RO', name: 'Romania' }, + { code: 'RU', name: 'Russia' }, + { code: 'RW', name: 'Rwanda' }, + { code: 'KN', name: 'Saint Kitts and Nevis' }, + { code: 'LC', name: 'Saint Lucia' }, + { code: 'VC', name: 'Saint Vincent and the Grenadines' }, + { code: 'WS', name: 'Samoa' }, + { code: 'SM', name: 'San Marino' }, + { code: 'ST', name: 'Sao Tome and Principe' }, + { code: 'SA', name: 'Saudi Arabia' }, + { code: 'SN', name: 'Senegal' }, + { code: 'RS', name: 'Serbia' }, + { code: 'SC', name: 'Seychelles' }, + { code: 'SL', name: 'Sierra Leone' }, + { code: 'SG', name: 'Singapore' }, + { code: 'SK', name: 'Slovakia' }, + { code: 'SI', name: 'Slovenia' }, + { code: 'SB', name: 'Solomon Islands' }, + { code: 'SO', name: 'Somalia' }, + { code: 'ZA', name: 'South Africa' }, + { code: 'SS', name: 'South Sudan' }, + { code: 'ES', name: 'Spain' }, + { code: 'LK', name: 'Sri Lanka' }, + { code: 'SD', name: 'Sudan' }, + { code: 'SR', name: 'Suriname' }, + { code: 'SE', name: 'Sweden' }, + { code: 'CH', name: 'Switzerland' }, + { code: 'SY', name: 'Syria' }, + { code: 'TW', name: 'Taiwan' }, + { code: 'TJ', name: 'Tajikistan' }, + { code: 'TZ', name: 'Tanzania' }, + { code: 'TH', name: 'Thailand' }, + { code: 'TL', name: 'Timor-Leste' }, + { code: 'TG', name: 'Togo' }, + { code: 'TO', name: 'Tonga' }, + { code: 'TT', name: 'Trinidad and Tobago' }, + { code: 'TN', name: 'Tunisia' }, + { code: 'TR', name: 'Turkey' }, + { code: 'TM', name: 'Turkmenistan' }, + { code: 'TV', name: 'Tuvalu' }, + { code: 'UG', name: 'Uganda' }, + { code: 'UA', name: 'Ukraine' }, + { code: 'AE', name: 'United Arab Emirates' }, + { code: 'GB', name: 'United Kingdom' }, + { code: 'US', name: 'United States' }, + { code: 'UY', name: 'Uruguay' }, + { code: 'UZ', name: 'Uzbekistan' }, + { code: 'VU', name: 'Vanuatu' }, + { code: 'VE', name: 'Venezuela' }, + { code: 'VN', name: 'Vietnam' }, + { code: 'YE', name: 'Yemen' }, + { code: 'ZM', name: 'Zambia' }, + { code: 'ZW', name: 'Zimbabwe' }, +]; + +interface CountrySelectProps { + value: string; + onChange: (value: string) => void; + className?: string; + id?: string; + required?: boolean; +} + +export const CountrySelect: React.FC = ({ + value, + onChange, + className = '', + id, + required, +}) => { + return ( + + ); +}; diff --git a/apps/widget/src/components/InquiryFormModal.tsx b/apps/widget/src/components/InquiryFormModal.tsx new file mode 100644 index 0000000..840076a --- /dev/null +++ b/apps/widget/src/components/InquiryFormModal.tsx @@ -0,0 +1,445 @@ +import React, { useState, useCallback, useEffect, useRef } from 'react'; +import { CountrySelect } from './CountrySelect'; +import '../styles/inquiry-form.css'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface InquiryFormProps { + isOpen: boolean; + productName?: string; + productSku?: string; + agentId: string; + conversationId?: string; + apiUrl: string; + /** Called with the success message after the inquiry is delivered */ + onSuccess: (message: string) => void; + /** Called when the user clicks Skip */ + onSkip: () => void; + /** When true, renders as an inline chat card (no overlay backdrop) */ + inline?: boolean; +} + +interface FormValues { + name: string; + phone: string; + email: string; + city: string; + country: string; + query: string; +} + +interface FormErrors { + name?: string; + phone?: string; + email?: string; + city?: string; + country?: string; +} + +// ── Validation helpers ──────────────────────────────────────────────────────── + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +// Allows +, -, spaces, parens; requires 7-15 digits +const PHONE_RE = /^[+\-()\s]*(\d[+\-()\s]*){7,15}$/; +const DIGITS_ONLY_RE = /^[0-9+\-()\s]+$/; + +function validatePhone(value: string): string | undefined { + if (!value.trim()) return 'Phone is required.'; + if (!DIGITS_ONLY_RE.test(value)) return 'Only digits, +, -, spaces and ( ) allowed.'; + const digits = value.replace(/\D/g, ''); + if (digits.length !== 10) return 'Phone must be exactly 10 digits.'; + return undefined; +} + +function validateEmail(value: string): string | undefined { + if (!value.trim()) return 'Email is required.'; + if (!EMAIL_RE.test(value)) return 'Enter a valid email address.'; + return undefined; +} + +function validateRequired(value: string, label: string): string | undefined { + if (!value.trim()) return `${label} is required.`; + return undefined; +} + +function allRequiredValid(values: FormValues, errors: FormErrors): boolean { + return ( + !errors.name && + !errors.phone && + !errors.email && + !errors.city && + !errors.country && + values.name.trim() !== '' && + values.phone.trim() !== '' && + values.email.trim() !== '' && + values.city.trim() !== '' && + values.country.trim() !== '' + ); +} + +// ── Component ───────────────────────────────────────────────────────────────── + +export const InquiryFormModal: React.FC = ({ + isOpen, + productName, + productSku, + agentId, + conversationId, + apiUrl, + onSuccess, + onSkip, + inline = false, +}) => { + const [values, setValues] = useState({ + name: '', phone: '', email: '', city: '', country: '', query: '', + }); + const [errors, setErrors] = useState({}); + const [touched, setTouched] = useState>({}); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [succeeded, setSucceeded] = useState(false); + const [successMessage, setSuccessMessage] = useState(''); + + const firstInputRef = useRef(null); + + // Focus first input when modal opens + useEffect(() => { + if (isOpen && !succeeded) { + setTimeout(() => firstInputRef.current?.focus(), 80); + } + }, [isOpen, succeeded]); + + // Reset form state when modal opens for a new product + useEffect(() => { + if (isOpen) { + setValues({ name: '', phone: '', email: '', city: '', country: '', query: '' }); + setErrors({}); + setTouched({}); + setSubmitting(false); + setSubmitError(null); + setSucceeded(false); + } + }, [isOpen, productName]); + + // Trap Escape key + useEffect(() => { + if (!isOpen) return; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onSkip(); }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [isOpen, onSkip]); + + const validate = useCallback((field: keyof FormValues, value: string): FormErrors => { + switch (field) { + case 'name': return { name: validateRequired(value, 'Name') }; + case 'phone': return { phone: validatePhone(value) }; + case 'email': return { email: validateEmail(value) }; + case 'city': return { city: validateRequired(value, 'City') }; + case 'country': return { country: validateRequired(value, 'Country') }; + default: return {}; + } + }, []); + + const handleChange = (field: keyof FormValues) => (value: string) => { + // Phone: reject non-digit / non-allowed characters live, limit to 10 digits + if (field === 'phone') { + const cleaned = value.replace(/[^\d+\-()\s]/g, ''); + // Hard-stop at 10 digits + const digitsOnly = cleaned.replace(/\D/g, ''); + if (digitsOnly.length > 10) return; + setValues(prev => ({ ...prev, phone: cleaned })); + if (touched.phone) { + setErrors(prev => ({ ...prev, ...validate('phone', cleaned) })); + } + return; + } + setValues(prev => ({ ...prev, [field]: value })); + if (touched[field]) { + setErrors(prev => ({ ...prev, ...validate(field as keyof FormValues, value) })); + } + }; + + const handleBlur = (field: keyof FormValues) => () => { + setTouched(prev => ({ ...prev, [field]: true })); + setErrors(prev => ({ ...prev, ...validate(field, values[field]) })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // Validate all required fields + const allTouched: Record = { + name: true, phone: true, email: true, city: true, country: true, + }; + setTouched(allTouched); + const newErrors: FormErrors = { + ...validate('name', values.name), + ...validate('phone', values.phone), + ...validate('email', values.email), + ...validate('city', values.city), + ...validate('country', values.country), + }; + setErrors(newErrors); + + if (!allRequiredValid(values, newErrors)) return; + + setSubmitting(true); + setSubmitError(null); + + try { + const base = apiUrl.replace(/\/$/, ''); + const res = await fetch(`${base}/api/v1/inquiry/submit`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agent_id: agentId, + conversation_id: conversationId || null, + product_name: productName || null, + product_sku: productSku || null, + name: values.name.trim(), + phone: values.phone.trim(), + email: values.email.trim(), + city: values.city.trim(), + country: values.country.trim(), + query: values.query.trim() || null, + }), + }); + + const data = await res.json(); + + if (!res.ok || !data.success) { + setSubmitError(data.message || 'Submission failed. Please try again.'); + setSubmitting(false); + return; + } + + const msg = data.message || 'Your inquiry has been sent. Our team will reach out soon.'; + setSuccessMessage(msg); + setSucceeded(true); + // Notify parent to display success in chat after a short delay + setTimeout(() => onSuccess(msg), 1400); + } catch { + setSubmitError('Network error. Please check your connection and try again.'); + setSubmitting(false); + } + }; + + if (!isOpen) return null; + + const canSubmit = allRequiredValid(values, errors) && !submitting; + + const fieldClass = (field: keyof FormErrors) => { + if (!touched[field]) return 'inq-input'; + if (errors[field]) return 'inq-input error'; + return 'inq-input valid'; + }; + + const selectClass = () => { + if (!touched.country) return 'inq-select'; + if (errors.country) return 'inq-select error'; + return 'inq-select valid'; + }; + + const card = ( +
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label="Request a Quote" + > +
+

Request a Quote

+ +
+ + {productName && ( +

For: {productName}

+ )} + + {/* ── Success state ──────────────────────────── */} + {succeeded ? ( +
+
+

Inquiry Sent!

+

{successMessage}

+ +
+ ) : ( + /* ── Form ──────────────────────────────────── */ +
+ + {/* Row: Name + Phone */} +
+
+ + handleChange('name')(e.target.value)} + onBlur={handleBlur('name')} + autoComplete="name" + /> + {touched.name && errors.name && ( + {errors.name} + )} +
+ +
+ + handleChange('phone')(e.target.value)} + onBlur={handleBlur('phone')} + autoComplete="tel" + maxLength={10} + /> + {touched.phone && errors.phone && ( + {errors.phone} + )} +
+
+ + {/* Email */} +
+ + handleChange('email')(e.target.value)} + onBlur={handleBlur('email')} + autoComplete="email" + /> + {touched.email && errors.email && ( + {errors.email} + )} +
+ + {/* Row: City + Country */} +
+
+ + handleChange('city')(e.target.value)} + onBlur={handleBlur('city')} + autoComplete="address-level2" + /> + {touched.city && errors.city && ( + {errors.city} + )} +
+ +
+ + { + handleChange('country')(val); + setTouched(prev => ({ ...prev, country: true })); + setErrors(prev => ({ ...prev, ...validate('country', val) })); + }} + className={selectClass().replace('inq-select', '').trim()} + required + /> + {touched.country && errors.country && ( + {errors.country} + )} +
+
+ + {/* Query (optional) */} +
+ +