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))) && ( +
Lead Capture
++ Via Email · No CRM +
++ Configure where captured inquiries are sent via email. +
+{fieldInfo.description}
Type: {fieldInfo.type}
@@ -355,7 +506,7 @@ export default function JsonFieldMapper({ > Use Fixed Value - {!isRequired && ( + {showSkipButton && (For: {productName}
+ )} + + {/* ── Success state ──────────────────────────── */} + {succeeded ? ( +{successMessage}
+ ++ Enter your birth details once — we'll build your Lal Kitab chart and you can ask + anything about it afterward. +
+ + {error &&{message.content}
@@ -323,7 +360,12 @@ export const MessageBubble: React.FC