From 2c8f1697623ff971c3435fd3f3fc87d9eefada89 Mon Sep 17 00:00:00 2001 From: Rishabh Srivastava Date: Sun, 26 Jul 2026 11:59:21 +0530 Subject: [PATCH 1/4] Integrate inquiry feature with latest upstream changes --- INQUIRY_MODE_PLAN.md | Bin 0 -> 35372 bytes apps/admin/src/api/errorHandler.ts | 15 +- .../AgentStudio/AgentConfigForm.tsx | 55 ++++ .../admin/src/components/AgentStudio/types.ts | 5 + .../KnowledgeBase/DocumentUploadWizard.tsx | 30 +- .../KnowledgeBase/JsonFieldMapper.tsx | 104 +++++- .../src/hooks/useAgentWizardController.ts | 8 + apps/admin/src/pages/AgentWizard.tsx | 7 + apps/admin/src/utils/agentWizardPayload.ts | 11 + apps/api/Dockerfile | 3 +- apps/api/app/api/v1/endpoints/knowledge.py | 10 +- apps/api/app/config.py | 16 +- apps/api/app/services/agent_turn_planner.py | 21 +- .../app/services/inquiry_delivery_service.py | 135 ++++++++ apps/api/app/services/inquiry_service.py | 104 ++++++ apps/api/app/services/knowledge_service.py | 3 + apps/api/app/services/message_service.py | 305 +++++++++++++++++- apps/api/app/services/response_validator.py | 83 ++++- apps/widget/src/components/ChatWindow.tsx | 1 + apps/widget/src/components/MessageBubble.tsx | 10 +- apps/widget/src/components/ProductCard.tsx | 76 ++++- apps/widget/src/styles/cards.css | 10 + apps/widget/src/types/index.ts | 16 +- packages/tools/src/tools/builtin/__init__.py | 3 +- .../tools/src/tools/builtin/inquiry_tool.py | 223 +++++++++++++ .../tools/src/tools/builtin/retrieval_tool.py | 3 + 26 files changed, 1192 insertions(+), 65 deletions(-) create mode 100644 INQUIRY_MODE_PLAN.md create mode 100644 apps/api/app/services/inquiry_delivery_service.py create mode 100644 apps/api/app/services/inquiry_service.py create mode 100644 packages/tools/src/tools/builtin/inquiry_tool.py diff --git a/INQUIRY_MODE_PLAN.md b/INQUIRY_MODE_PLAN.md new file mode 100644 index 0000000000000000000000000000000000000000..6e565f38ea13beb440e11a4f06d49f3d958453bf GIT binary patch literal 35372 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 literal 0 HcmV?d00001 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/AgentConfigForm.tsx b/apps/admin/src/components/AgentStudio/AgentConfigForm.tsx index 01334df..89ab415 100644 --- a/apps/admin/src/components/AgentStudio/AgentConfigForm.tsx +++ b/apps/admin/src/components/AgentStudio/AgentConfigForm.tsx @@ -785,6 +785,61 @@ export default function AgentConfigForm({ + +
+
+
+

Inquiry Capture

+

+ When enabled, the agent collects Product Name, Name, Phone, Email, City, Country, and + Query conversationally (instead of a form link), confirms with the user, then emails + the inquiry to the recipient below. Best for catalogs with no fixed prices. +

+
+
+ +
+ onChange('inquiry_enabled', !data.inquiry_enabled)} + label="Enable conversational inquiry capture" + description="Agent detects purchase intent (quote/pricing/buy/contact sales) and calls the submit_inquiry tool instead of pointing to a form." + /> +
+ + {data.inquiry_enabled && ( +
+ + 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..fd4ec88 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' }, } @@ -134,6 +139,45 @@ 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. + const extractScalarValue = (field: string, rawValue: any): any => { + const allFields = { ...requiredFields, ...optionalFields }; + const expectedType = (allFields as any)[field]?.type; + + if (expectedType !== 'string' || rawValue === null || rawValue === undefined) { + return rawValue; + } + + 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 +230,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]); } } else if (mappingConfig.mode === 'fixed') { // Use fixed value for all items (with type parsing) @@ -202,7 +246,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]); } } else if (mappingConfig.mode === 'fixed') { newItem[optField] = parseFixedValue(optField, mappingConfig.value); @@ -218,24 +262,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}"`); } @@ -274,7 +325,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]); } } else if (config.mode === 'fixed') { // Parse fixed values to correct types using helper @@ -312,6 +363,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 +373,8 @@ export default function JsonFieldMapper({ {/* Required Field */}

{fieldInfo.description}

Type: {fieldInfo.type}

@@ -355,7 +409,7 @@ export default function JsonFieldMapper({ > Use Fixed Value - {!isRequired && ( + {showSkipButton && (
@@ -430,6 +484,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/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/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..cce3cd7 100644 --- a/apps/api/app/services/agent_turn_planner.py +++ b/apps/api/app/services/agent_turn_planner.py @@ -183,6 +183,10 @@ 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. 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 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") @@ -252,7 +256,11 @@ def _coerce_plan(self, data: dict[str, Any], *, fallback_plan: ConversationTurnP 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: + 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 +274,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/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/message_service.py b/apps/api/app/services/message_service.py index 66623e0..7ab8ea1 100644 --- a/apps/api/app/services/message_service.py +++ b/apps/api/app/services/message_service.py @@ -31,6 +31,7 @@ # 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.types import ToolResult from agent_runtime.orchestrator import Orchestrator, AgentResult from agent_runtime.orchestrator_shopify import ShopifyOrchestrator @@ -46,6 +47,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 @@ -114,6 +117,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 +389,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) @@ -811,6 +838,233 @@ 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: + """Keep an in-flight inquiry on the submit_inquiry tool every turn. + + The LLM planner independently decides each turn whether to call + submit_inquiry again, and it is not reliable about continuing an + in-flight multi-turn inquiry: it sometimes just acknowledges the + user's free-text reply ("Thanks, I've noted your email...") without + calling the tool, silently dropping the collected fields and never + reaching confirmation. Once an inquiry is in flight, this + deterministically keeps the tool in the plan every turn - mirroring + the existing Lal Kitab chart-first override - until it is submitted + or the user explicitly cancels. + """ + if self.tool_registry is None or not self.tool_registry.get("submit_inquiry"): + return turn_plan + has_in_flight_inquiry = bool( + pending_state + and (pending_state.get("resolved_inputs") or pending_state.get("awaiting_confirmation")) + ) + if not has_in_flight_inquiry: + # No inquiry in progress yet: only step in on an explicit, + # unambiguous purchase-intent trigger (e.g. the product card's + # "Send Inquiry for Price" click, or the user typing "send + # inquiry"/"request a quote"). The LLM planner is not reliable + # about including submit_inquiry in tool_plan on its own for this + # first turn, so this deterministically starts the tool instead + # of leaving it to chance. + if not INQUIRY_TRIGGER_PATTERN.search(message or ""): + return turn_plan + # The planner sometimes extracts the trigger message itself + # (e.g. "Send inquiry for price") into resolved_inputs.query, + # which then flows into every submit_inquiry call as if the + # user's actual requirement/question was already given - the + # tool never asks for it. A "query" that is just the trigger + # phrase restated is not a real requirement, so drop it here. + bogus_query = turn_plan.resolved_inputs.get("query") if turn_plan.resolved_inputs else None + if bogus_query and INQUIRY_TRIGGER_PATTERN.search(str(bogus_query)): + turn_plan.resolved_inputs.pop("query", None) + already_calling_inquiry = any( + str(step.get("tool_id") or "") == "submit_inquiry" for step in (turn_plan.tool_plan or []) + ) + if not already_calling_inquiry: + turn_plan.tool_plan = [ + { + "tool_id": "submit_inquiry", + "reason": "Explicit inquiry/quote request detected", + "input": {"payload": {}}, + "depends_on": None, + }, + *(turn_plan.tool_plan or []), + ] + if turn_plan.action in {"ask_missing_input", "clarify", "greeting", "ask_question"}: + turn_plan.action = "ready" + turn_plan.intent = "tool_use" + turn_plan.public_response = None + turn_plan.response_text = "" + return turn_plan + + if INQUIRY_CANCEL_PATTERN.search(message or ""): + turn_plan.tool_plan = [] + turn_plan.action = "clarify" + turn_plan.intent = "clarify" + cancel_message = "No problem, I've cancelled that inquiry. Let me know if you'd like to start a new one." + turn_plan.public_response = cancel_message + turn_plan.response_text = cancel_message + 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 + + # Once the tool is awaiting confirmation, an affirmative reply must + # set confirm=true so the tool actually sends the inquiry instead of + # re-showing the same summary forever. + should_confirm = bool( + pending_state.get("awaiting_confirmation") and INQUIRY_AFFIRM_PATTERN.search(message or "") + ) + already_calling_inquiry = False + for step in turn_plan.tool_plan or []: + if str(step.get("tool_id") or "") == "submit_inquiry": + already_calling_inquiry = True + if should_confirm: + step_input = step.get("input") if isinstance(step.get("input"), dict) else {} + step["input"] = {**step_input, "confirm": True} + if not already_calling_inquiry: + step_input: dict[str, Any] = {"payload": {}} + if should_confirm: + step_input["confirm"] = True + turn_plan.tool_plan = [ + { + "tool_id": "submit_inquiry", + "reason": "Continuing the in-flight inquiry", + "input": step_input, + "depends_on": None, + }, + *(turn_plan.tool_plan or []), + ] + # The planner's own missing-field guess (or any other short-circuit + # action) must never pre-empt the tool: submit_inquiry has its own, + # more accurate missing-field detection, and skipping the tool call + # here would silently drop the just-collected field and truncate the + # response (this previously caused a bare "Got it — noted." + # reply on the turn that completed the last required field, instead + # of running the tool and showing the confirmation summary). + if turn_plan.action in {"ask_missing_input", "clarify", "ask_question", "greeting", "handoff"}: + turn_plan.action = "ready" + turn_plan.intent = "tool_use" + turn_plan.public_response = None + turn_plan.response_text = "" + 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).""" @@ -1561,7 +1815,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 @@ -1642,6 +1906,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 +2019,19 @@ def _register_configured_capabilities(self, config: dict) -> dict: self.tool_registry.register(connector_tool) registered_tools.append(connector_tool.name) + inquiry_config = config.get("inquiry") or {} + if inquiry_config.get("enabled"): + inquiry_tool = InquiryTool( + brand_slug=self.brand_id, + brand_id=(self.agent_record or {}).get("brand_id"), + agent_id=self.agent_id, + conversation_id=None, # set per-turn via _configure_inquiry_tool_for_turn + agent_config=config, + submit_callback=self._submit_inquiry, + ) + self.tool_registry.register(inquiry_tool) + registered_tools.append(inquiry_tool.name) + if registered_skills or registered_tools: logger.info( "agent_capabilities_registered", @@ -1822,6 +2102,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 +2162,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 @@ -2143,6 +2428,10 @@ async def process_message(self, request: MessageRequest) -> MessageResponse: turn_plan, lalkitab_profile = await self._prepare_lalkitab_turn( turn_plan, runtime_message, policy_state, llm_provider=planner_provider ) + 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 +2444,8 @@ 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} response_text, metadata = self._apply_post_response_guardrails(response_text, metadata) if short_term_enabled: await self.short_term.add_message( @@ -2225,6 +2516,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 @@ -2476,6 +2768,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, } ) @@ -2717,6 +3010,10 @@ async def stream_message(self, request: MessageRequest) -> AsyncGenerator[Stream turn_plan, lalkitab_profile = await self._prepare_lalkitab_turn( turn_plan, runtime_message, policy_state, llm_provider=planner_provider ) + 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 +3026,8 @@ 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} response_text, short_circuit_metadata = self._apply_post_response_guardrails( turn_plan.response_text, short_circuit_metadata, @@ -2968,6 +3267,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: @@ -3352,6 +3652,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, } ) diff --git a/apps/api/app/services/response_validator.py b/apps/api/app/services/response_validator.py index 15f8396..1fff69c 100644 --- a/apps/api/app/services/response_validator.py +++ b/apps/api/app/services/response_validator.py @@ -94,7 +94,8 @@ 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), @@ -148,8 +149,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") @@ -200,9 +212,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 +237,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 +251,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) @@ -262,21 +283,46 @@ 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*$') + + 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_factual_claim(claim: str) -> bool: if _is_safe_nonfactual(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)) @@ -516,19 +562,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/widget/src/components/ChatWindow.tsx b/apps/widget/src/components/ChatWindow.tsx index 492474b..1be44ec 100644 --- a/apps/widget/src/components/ChatWindow.tsx +++ b/apps/widget/src/components/ChatWindow.tsx @@ -426,6 +426,7 @@ export const ChatWindow: React.FC = ({ showProductCards={showProductCards} apiUrl={apiUrl} agentId={agentId} + onSendMessage={onSendMessage} /> {showPlaceChips && (
diff --git a/apps/widget/src/components/MessageBubble.tsx b/apps/widget/src/components/MessageBubble.tsx index bcc6d4e..8acaef3 100644 --- a/apps/widget/src/components/MessageBubble.tsx +++ b/apps/widget/src/components/MessageBubble.tsx @@ -19,6 +19,9 @@ interface MessageBubbleProps { showProductCards?: boolean; apiUrl?: string; agentId?: string | null; + /** Sends a chat message as if the user typed it (used by the product + * card's "Send Inquiry for Price" CTA). */ + onSendMessage?: (text: string) => void; } // Initialize markdown-it instance @@ -168,6 +171,7 @@ export const MessageBubble: React.FC = ({ showProductCards = true, apiUrl, agentId, + onSendMessage, }) => { const isUser = message.role === 'user'; const [extractedProducts, setExtractedProducts] = useState([]); @@ -323,7 +327,11 @@ export const MessageBubble: React.FC = ({
{allProducts.map((product) => ( - + ))}

diff --git a/apps/widget/src/components/ProductCard.tsx b/apps/widget/src/components/ProductCard.tsx index 9d825cc..acbbf08 100644 --- a/apps/widget/src/components/ProductCard.tsx +++ b/apps/widget/src/components/ProductCard.tsx @@ -4,9 +4,12 @@ import type { ProductData, ProductVariantData } from '../types'; interface ProductCardProps { product: ProductData; onViewDetails?: (product: ProductData) => void; + /** Called instead of opening a link when the CTA is an inquiry request + * (no fixed price). Receives the ready-to-send chat message text. */ + onSendInquiry?: (message: string) => void; } -export const ProductCard: React.FC = ({ product, onViewDetails }) => { +export const ProductCard: React.FC = ({ product, onViewDetails, onSendInquiry }) => { const [imageError, setImageError] = useState(false); const [variantsExpanded, setVariantsExpanded] = useState(false); const variants = useMemo(() => product.variants || [], [product.variants]); @@ -37,8 +40,8 @@ export const ProductCard: React.FC = ({ product, onViewDetails setImageError(false); }, [defaultVariant]); - const formatPrice = (price?: number, currency?: string) => { - if (price === undefined) return 'Price on request'; + const formatPrice = (price?: number | null, currency?: string) => { + if (price === undefined || price === null) return 'Send Inquiry for Price'; const displayPrice = price / 100; if (!currency) { @@ -72,7 +75,9 @@ export const ProductCard: React.FC = ({ product, onViewDetails variants.length > 1 && !selectedVariant && product.price_min !== undefined && + product.price_min !== null && product.price_max !== undefined && + product.price_max !== null && product.price_min !== product.price_max ) { return `${formatPrice(product.price_min, product.currency)} - ${formatPrice(product.price_max, product.currency)}`; @@ -80,9 +85,19 @@ export const ProductCard: React.FC = ({ product, onViewDetails return formatPrice(displayProduct.price_minor ?? displayProduct.price, displayProduct.currency); }; + const isInquiryProduct = (displayProduct.price_minor ?? displayProduct.price) === undefined + || (displayProduct.price_minor ?? displayProduct.price) === null; + // The CTA button's destination depends on whether this product has a price: + // - Inquiry-based (no price): always go to the dedicated inquiry/quote form. + // - Priced: go to the product's own page (official site). + const ctaUrl = isInquiryProduct + ? (displayProduct.inquiry_url || displayProduct.variant_url || displayProduct.product_url || displayProduct.url) + : (displayProduct.variant_url || displayProduct.product_url || displayProduct.url); + const titleUrl = displayProduct.variant_url || displayProduct.product_url || displayProduct.url; + const handleViewDetails = (e: React.MouseEvent) => { e.stopPropagation(); - + // Track view details if (typeof window !== 'undefined' && window.agentAnalytics) { window.agentAnalytics.track('product_view_details', { @@ -91,11 +106,34 @@ export const ProductCard: React.FC = ({ product, onViewDetails variant_id: displayProduct.variant_id, }); } - + + // Inquiry-based products (no fixed price): kick off the conversational + // inquiry flow directly in chat instead of opening a separate form/page. + if (isInquiryProduct && onSendInquiry) { + if (typeof window !== 'undefined' && window.agentAnalytics) { + window.agentAnalytics.track('product_send_inquiry_click', { + sku: displayProduct.sku || 'unknown', + name: displayProduct.name, + }); + } + onSendInquiry(`Send inquiry for price of ${displayProduct.name}`); + return; + } + if (onViewDetails) { onViewDetails(displayProduct); - } else if (displayProduct.variant_url || displayProduct.product_url) { - window.open(displayProduct.variant_url || displayProduct.product_url, '_blank', 'noopener,noreferrer'); + } else if (ctaUrl) { + window.open(ctaUrl, '_blank', 'noopener,noreferrer'); + } + }; + + const handleTitleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (typeof window !== 'undefined' && window.agentAnalytics) { + window.agentAnalytics.track('product_title_click', { + sku: displayProduct.sku || 'unknown', + name: displayProduct.name, + }); } }; @@ -139,7 +177,19 @@ export const ProductCard: React.FC = ({ product, onViewDetails )}
-

{displayProduct.name}

+ {titleUrl ? ( + + {displayProduct.name} + + ) : ( +

{displayProduct.name}

+ )} {displayProduct.in_stock !== undefined && ( {displayProduct.in_stock ? '✓' : '✗'} @@ -172,13 +222,13 @@ export const ProductCard: React.FC = ({ product, onViewDetails
{!isVariantProduct && {formatProductPrice()}} - {(displayProduct.variant_url || displayProduct.product_url || displayProduct.url) && ( + {(ctaUrl || (isInquiryProduct && onSendInquiry)) && ( )}
@@ -360,8 +410,8 @@ function getVariantId(variant?: ProductVariantData): string { return variant?.variant_id || variant?.id || variant?.sku || variant?.variant_sku || variant?.title || ''; } -function formatVariantPrice(price?: number, currency?: string): string { - if (price === undefined) return 'Price on request'; +function formatVariantPrice(price?: number | null, currency?: string): string { + if (price === undefined || price === null) return 'Send Inquiry for Price'; const displayPrice = price / 100; if (!currency) { return displayPrice.toLocaleString(undefined, { diff --git a/apps/widget/src/styles/cards.css b/apps/widget/src/styles/cards.css index f0080eb..2bf6c4c 100644 --- a/apps/widget/src/styles/cards.css +++ b/apps/widget/src/styles/cards.css @@ -209,6 +209,16 @@ text-overflow: ellipsis; } +.product-name-link { + text-decoration: none; + cursor: pointer; +} + +.product-name-link:hover { + color: #2563EB; + text-decoration: underline; +} + .stock-badge { font-size: 9px; font-weight: 600; diff --git a/apps/widget/src/types/index.ts b/apps/widget/src/types/index.ts index 10cccaa..3e6c178 100644 --- a/apps/widget/src/types/index.ts +++ b/apps/widget/src/types/index.ts @@ -146,9 +146,9 @@ export interface ProductVariantData { title?: string; variant_title?: string; variant_options?: Record; - price?: number; + price?: number | null; /** Canonical minor-unit price. Legacy price is accepted by adapters only. */ - price_minor?: number; + price_minor?: number | null; price_unit?: 'minor'; currency?: string; currency_source?: CurrencySource; @@ -167,9 +167,9 @@ export interface ProductData { product_group_id?: string; handle?: string; name: string; - price?: number; + price?: number | null; /** Canonical minor-unit price. Legacy price is accepted by adapters only. */ - price_minor?: number; + price_minor?: number | null; price_unit?: 'minor'; currency?: string; currency_source?: CurrencySource; @@ -183,8 +183,8 @@ export interface ProductData { description?: string; has_variants?: boolean; variant_count?: number; - price_min?: number; - price_max?: number; + price_min?: number | null; + price_max?: number | null; default_variant_id?: string; variant_id?: string; variant_sku?: string; @@ -192,6 +192,10 @@ export interface ProductData { variant_options?: Record; variant_url?: string; variants?: ProductVariantData[]; + // Dedicated inquiry/quote-request link, independent of product_url. + // Used by the "Send Inquiry" CTA so it can point to a shared form + // instead of the product's own page. + inquiry_url?: string; } export type CurrencySource = 'shopify_store' | 'catalog' | 'presentment' | 'configured_default' | 'missing'; diff --git a/packages/tools/src/tools/builtin/__init__.py b/packages/tools/src/tools/builtin/__init__.py index 050e52b..578c9fd 100644 --- a/packages/tools/src/tools/builtin/__init__.py +++ b/packages/tools/src/tools/builtin/__init__.py @@ -3,5 +3,6 @@ """ from .retrieval_tool import RetrievalTool +from .inquiry_tool import InquiryTool -__all__ = ["RetrievalTool"] +__all__ = ["RetrievalTool", "InquiryTool"] diff --git a/packages/tools/src/tools/builtin/inquiry_tool.py b/packages/tools/src/tools/builtin/inquiry_tool.py new file mode 100644 index 0000000..aad27db --- /dev/null +++ b/packages/tools/src/tools/builtin/inquiry_tool.py @@ -0,0 +1,223 @@ +""" +Inquiry Tool — conversational lead capture for inquiry-based (no fixed price) brands. + +Declares required fields and reports which are still missing so the calling +LLM/planner can ask for them naturally. Remembers resolved fields across +conversation turns via `remembered_inputs` / `resolved_inputs` — the same +generic mechanism this codebase already uses for context connector tools +(see app/services/tool_registry.py::ContextConnectorTool). + +Fields captured (fixed for all products/brands, per product spec): + Product Name, Name, Phone, Email, City, Country, Query + +Delivery is intentionally delegated to an injected callable so this tool has +no direct dependency on SMTP/webhook specifics — the caller (message_service) +wires in the real InquiryDeliveryService + InquiryService. +""" + +from __future__ import annotations + +import re +from typing import Any, Awaitable, Callable, Optional + +from tools.types import BaseTool, ToolResult + +REQUIRED_FIELDS = ["product_name", "name", "phone", "email", "city", "country", "query"] + +FIELD_LABELS = { + "product_name": "Product Name", + "name": "Name", + "phone": "Phone", + "email": "Email", + "city": "City", + "country": "Country", + "query": "Query", +} + +_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +_PHONE_RE = re.compile(r"^\+?[0-9][0-9\-\s()]{6,18}$") + + +def _validate_fields(payload: dict[str, Any]) -> dict[str, str]: + """Return {field: error_message} for any present-but-invalid fields.""" + errors: dict[str, str] = {} + email = str(payload.get("email") or "").strip() + if email and not _EMAIL_RE.match(email): + errors["email"] = "That email address doesn't look valid. Could you double-check it?" + phone = str(payload.get("phone") or "").strip() + if phone and not _PHONE_RE.match(phone): + errors["phone"] = "That phone number doesn't look valid. Could you share it again (with country code if possible)?" + return errors + + +class InquiryTool(BaseTool): + """Collects inquiry details conversationally and submits them on confirmation.""" + + name = "submit_inquiry" + description = ( + "ONLY use this after the user has ALREADY been shown/told about a specific product and " + "then explicitly asks to buy it, get a quotation, request pricing, place a bulk order, " + "contact sales, or have the team reach out (e.g. 'I want a quote for this', 'send an " + "inquiry', 'have someone call me'). Do NOT use this for browsing, searching, or asking " + "what a product is, what it costs, or its specs — use knowledge_search / search_catalog " + "for all of that. If the user has not yet been told about a product, look it up first. " + "Call this with whatever contact fields the user has already provided; it reports which " + "fields are still missing so you can ask for them naturally, one or two at a time. It " + "never sends anything until the user has explicitly confirmed." + ) + + parameters_schema = { + "type": "object", + "properties": { + "payload": { + "type": "object", + "description": "Fields collected so far from the user.", + "properties": { + "product_name": {"type": "string", "description": "The product the user is asking about."}, + "name": {"type": "string", "description": "The user's full name."}, + "phone": {"type": "string", "description": "The user's phone number."}, + "email": {"type": "string", "description": "The user's email address."}, + "city": {"type": "string", "description": "The user's city."}, + "country": {"type": "string", "description": "The user's country."}, + "query": {"type": "string", "description": "What the user needs (quantity, use case, timeline, etc.)."}, + }, + }, + "confirm": { + "type": "boolean", + "description": "Set to true ONLY after the user has explicitly confirmed the summary and agreed to send the inquiry.", + }, + }, + "required": ["payload"], + } + + def __init__( + self, + *, + brand_slug: str, + brand_id: Optional[str], + agent_id: Optional[str], + conversation_id: Optional[str], + agent_config: dict[str, Any], + submit_callback: Callable[[dict[str, Any], dict[str, Any]], Awaitable[dict[str, Any]]], + remembered_inputs: Optional[dict[str, Any]] = None, + active_product_focus: Optional[dict[str, Any]] = None, + ): + """ + submit_callback(inquiry_payload, agent_config) -> dict with keys + {success: bool, inquiry_id: str, delivered: bool, error: str|None}. + Actual persistence + email sending happens in message_service via + inquiry_service.save_inquiry + inquiry_delivery_service.send. + """ + self.brand_slug = brand_slug + self.brand_id = brand_id + self.agent_id = agent_id + self.conversation_id = conversation_id + self.agent_config = agent_config or {} + self.submit_callback = submit_callback + # Conversation-remembered field values (multi-turn resume), read by + # message_service the same way it does for ContextConnectorTool instances. + self.remembered_inputs = {k: v for k, v in (remembered_inputs or {}).items() if v not in (None, "")} + # Auto-attach the product currently being discussed, if the caller has it, + # so the user doesn't have to repeat the product name. + self.active_product_focus = active_product_focus or {} + + def _merged_payload(self, payload: dict[str, Any]) -> dict[str, Any]: + merged = dict(self.remembered_inputs) + merged.update({k: v for k, v in (payload or {}).items() if v not in (None, "")}) + if not merged.get("product_name"): + focus_name = self.active_product_focus.get("name") or self.active_product_focus.get("title") + if focus_name: + merged["product_name"] = focus_name + return merged + + async def run(self, payload: Optional[dict[str, Any]] = None, confirm: bool = False, **kwargs) -> ToolResult: + merged = self._merged_payload(payload or {}) + + missing = [field for field in REQUIRED_FIELDS if not str(merged.get(field) or "").strip()] + if missing: + missing_labels = [FIELD_LABELS[field] for field in missing] + return ToolResult( + success=False, + data=None, + error=f"Missing required fields: {', '.join(missing_labels)}", + metadata={ + "tool_id": "submit_inquiry", + "missing_input": missing, + # Persist whatever we already have so the next turn doesn't + # need to re-ask for fields the user already provided. + "resolved_inputs": {k: v for k, v in merged.items() if k in REQUIRED_FIELDS and v}, + }, + ) + + field_errors = _validate_fields(merged) + if field_errors: + return ToolResult( + success=False, + data=None, + error="; ".join(field_errors.values()), + metadata={ + "tool_id": "submit_inquiry", + "invalid_fields": list(field_errors.keys()), + "resolved_inputs": {k: v for k, v in merged.items() if k in REQUIRED_FIELDS and v}, + }, + ) + + inquiry_config = self.agent_config.get("inquiry") or {} + confirm_required = inquiry_config.get("confirm_before_send", True) is not False + + if confirm_required and not confirm: + summary = {label: merged.get(field) for field, label in FIELD_LABELS.items()} + return ToolResult( + success=True, + data="AWAITING_CONFIRMATION", + metadata={ + "tool_id": "submit_inquiry", + "awaiting_confirmation": True, + "summary": summary, + "resolved_inputs": {k: v for k, v in merged.items() if k in REQUIRED_FIELDS and v}, + }, + ) + + try: + result = await self.submit_callback(merged, self.agent_config) + except Exception as exc: # pragma: no cover - defensive + return ToolResult( + success=False, + data=None, + error=f"Failed to submit the inquiry: {exc}", + metadata={"tool_id": "submit_inquiry"}, + ) + + if not result.get("success"): + # Inquiry is still persisted even if delivery failed (see + # inquiry_service.save_inquiry / mark_failed in message_service). + return ToolResult( + success=False, + data=( + "I saved your inquiry, but I couldn't notify the team automatically " + "just now. Please also try reaching out directly, and we'll make sure " + "someone follows up." + ), + error=result.get("error") or "delivery_failed", + metadata={ + "tool_id": "submit_inquiry", + "inquiry_id": result.get("inquiry_id"), + "delivered": False, + "resolved_inputs": {k: v for k, v in merged.items() if k in REQUIRED_FIELDS and v}, + }, + ) + + success_message = inquiry_config.get("success_message") or ( + "Your inquiry has been sent to our team. They'll reach out to you shortly." + ) + return ToolResult( + success=True, + data=success_message, + metadata={ + "tool_id": "submit_inquiry", + "inquiry_id": result.get("inquiry_id"), + "delivered": True, + "inquiry_submitted": True, + "resolved_inputs": {k: v for k, v in merged.items() if k in REQUIRED_FIELDS and v}, + }, + ) diff --git a/packages/tools/src/tools/builtin/retrieval_tool.py b/packages/tools/src/tools/builtin/retrieval_tool.py index d8c5660..81b67a6 100644 --- a/packages/tools/src/tools/builtin/retrieval_tool.py +++ b/packages/tools/src/tools/builtin/retrieval_tool.py @@ -365,6 +365,9 @@ def _display_product_price(self, product: Dict[str, Any]) -> str: return self._display_price(product.get("price"), currency) def _display_price(self, price: Any, currency: Any) -> 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 = float(price) display_price = numeric_price / 100 From a7299b674830a99d10a834c01332cc5c8f9b5e16 Mon Sep 17 00:00:00 2001 From: Rishabh Srivastava Date: Mon, 3 Aug 2026 16:15:38 +0530 Subject: [PATCH 2/4] architecture docs and inquiry tools --- LEAD_CAPTURE.md | 472 ++++++++++++++++++ LEAD_CAPTURE_V2_PLAN.md | 408 +++++++++++++++ _check_thresh.py | 23 + _test_search.py | 37 ++ .../AgentStudio/AgentCapabilityRail.tsx | 33 +- .../AgentStudio/AgentConfigForm.tsx | 37 +- .../KnowledgeBase/JsonFieldMapper.tsx | 111 +++- apps/api/app/api/v1/__init__.py | 3 +- apps/api/app/api/v1/endpoints/inquiry.py | 139 ++++++ apps/api/app/api/v1/endpoints/public.py | 9 + apps/api/app/services/agent_turn_planner.py | 32 +- apps/api/app/services/message_service.py | 166 ++---- apps/widget/index.html | 7 + apps/widget/src/App.tsx | 31 +- apps/widget/src/components/ChatWindow.tsx | 73 ++- apps/widget/src/components/CountrySelect.tsx | 234 +++++++++ .../src/components/InquiryFormModal.tsx | 445 +++++++++++++++++ apps/widget/src/components/MessageBubble.tsx | 43 +- apps/widget/src/components/ProductCard.tsx | 62 ++- apps/widget/src/styles/inquiry-form.css | 379 ++++++++++++++ apps/widget/src/utils/apiClient.ts | 11 +- apps/widget/src/utils/wsClient.ts | 3 +- docs/INQUIRY_CAPTURE_ARCHITECTURE.md | 238 +++++++++ docs/LEAD_CAPTURE_CRM_ARCHITECTURE.md | 227 +++++++++ .../commons/src/commons/types/requests.py | 4 + .../tools/src/tools/builtin/inquiry_tool.py | 37 +- 26 files changed, 3079 insertions(+), 185 deletions(-) create mode 100644 LEAD_CAPTURE.md create mode 100644 LEAD_CAPTURE_V2_PLAN.md create mode 100644 _check_thresh.py create mode 100644 _test_search.py create mode 100644 apps/api/app/api/v1/endpoints/inquiry.py create mode 100644 apps/widget/src/components/CountrySelect.tsx create mode 100644 apps/widget/src/components/InquiryFormModal.tsx create mode 100644 apps/widget/src/styles/inquiry-form.css create mode 100644 docs/INQUIRY_CAPTURE_ARCHITECTURE.md create mode 100644 docs/LEAD_CAPTURE_CRM_ARCHITECTURE.md 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/_check_thresh.py b/_check_thresh.py new file mode 100644 index 0000000..680b526 --- /dev/null +++ b/_check_thresh.py @@ -0,0 +1,23 @@ +import asyncio +from app.connections import connection_manager + +async def main(): + await connection_manager.connect_mongodb() + db = connection_manager.get_system_db() + agents = await db.agents.find({'status': 'active'}).to_list(20) + for a in agents: + r = a.get('configuration', {}).get('rag', {}).get('retrieval', {}) + print(f"{a['name']}: threshold={r.get('similarity_threshold')} top_k={r.get('top_k')}") + + # Also check how many amplifier products exist in soundtrails + client = connection_manager.mongodb_client + for brand in ['soundtrails', 'aandindia']: + db2 = client[brand] + import re + names = await db2.knowledge_base.distinct('product_data.name', {'content_type': 'product'}) + amps = [n for n in names if re.search(r'amplif', n or '', re.I)] + print(f"\n{brand}: {len(names)} total products, {len(amps)} amplifiers") + for n in amps[:5]: + print(f" - {n}") + +asyncio.run(main()) diff --git a/_test_search.py b/_test_search.py new file mode 100644 index 0000000..5362779 --- /dev/null +++ b/_test_search.py @@ -0,0 +1,37 @@ +import asyncio +from app.connections import connection_manager +from app.config import Settings + +async def main(): + await connection_manager.connect_mongodb() + settings = Settings() + + # Test direct vector search with a simple text match first + client = connection_manager.mongodb_client + db = client['soundtrails'] + + # 1. Count all product chunks + total = await db.knowledge_base.count_documents({'content_type': 'product'}) + print(f"Total product chunks: {total}") + + # 2. Check if embeddings exist + sample = await db.knowledge_base.find_one({'content_type': 'product', 'embeddings': {'$exists': True}}) + if sample: + emb_len = len(sample.get('embeddings', [])) + print(f"Embeddings present, dimension: {emb_len}") + else: + print("NO EMBEDDINGS FOUND - products have no vector data!") + + # 3. Check for amplifier in content field (text match) + amp_text = await db.knowledge_base.count_documents({ + 'content_type': 'product', + 'content': {'$regex': 'amplif', '$options': 'i'} + }) + print(f"Amplifier chunks (text match): {amp_text}") + + # 4. Check Atlas vector index name + indexes = await db.knowledge_base.list_indexes().to_list(20) + for idx in indexes: + print(f"Index: {idx.get('name')} type={idx.get('type','regular')}") + +asyncio.run(main()) 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) => (
-
-
-
-

Inquiry Capture

-

- When enabled, the agent collects Product Name, Name, Phone, Email, City, Country, and - Query conversationally (instead of a form link), confirms with the user, then emails - the inquiry to the recipient below. Best for catalogs with no fixed prices. -

+ {data.inquiry_enabled && ( +
+
+
+

Lead Capture Settings

+

+ Configure where captured inquiries are sent via email. +

+
+ + Active +
-
- -
- onChange('inquiry_enabled', !data.inquiry_enabled)} - label="Enable conversational inquiry capture" - description="Agent detects purchase intent (quote/pricing/buy/contact sales) and calls the submit_inquiry tool instead of pointing to a form." - /> -
- - {data.inquiry_enabled && (
- )} -
+
+ )}
); } diff --git a/apps/admin/src/components/KnowledgeBase/JsonFieldMapper.tsx b/apps/admin/src/components/KnowledgeBase/JsonFieldMapper.tsx index fd4ec88..2871c2f 100644 --- a/apps/admin/src/components/KnowledgeBase/JsonFieldMapper.tsx +++ b/apps/admin/src/components/KnowledgeBase/JsonFieldMapper.tsx @@ -114,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'], @@ -144,7 +144,38 @@ export default function JsonFieldMapper({ // 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. - const extractScalarValue = (field: string, rawValue: any): any => { + // + // 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; @@ -152,6 +183,12 @@ export default function JsonFieldMapper({ 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); } @@ -230,7 +267,7 @@ export default function JsonFieldMapper({ if (mappingConfig.mode === 'json') { // Map from JSON field if (mappingConfig.value in item) { - newItem[reqField] = extractScalarValue(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) @@ -246,7 +283,7 @@ export default function JsonFieldMapper({ if (mappingConfig.mode === 'json') { if (mappingConfig.value in item) { - newItem[optField] = extractScalarValue(optField, item[mappingConfig.value]); + newItem[optField] = extractScalarValue(optField, item[mappingConfig.value], item); } } else if (mappingConfig.mode === 'fixed') { newItem[optField] = parseFixedValue(optField, mappingConfig.value); @@ -310,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); @@ -325,7 +421,7 @@ export default function JsonFieldMapper({ if (config.mode === 'json') { if (config.value in item) { - newItem[field] = extractScalarValue(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 @@ -351,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) => { diff --git a/apps/api/app/api/v1/__init__.py b/apps/api/app/api/v1/__init__.py index 97c520d..a70ba7a 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 from .admin import router as admin_router from .auth import auth_router from ...security.rate_limiter import rate_limit_dependency @@ -22,6 +22,7 @@ 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(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/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/services/agent_turn_planner.py b/apps/api/app/services/agent_turn_planner.py index cce3cd7..d9a7a39 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 @@ -13,7 +13,7 @@ logger = structlog.get_logger(__name__) -VALID_INTENTS = {"greeting", "collect_input", "answer", "tool_use", "clarify", "handoff"} +VALID_INTENTS = {"greeting", "collect_input", "answer", "tool_use", "clarify", "handoff", "open_inquiry_form"} @dataclass @@ -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: @@ -144,7 +144,7 @@ def _build_prompt( Return ONLY strict JSON with this shape: {{ - "intent": "greeting | collect_input | answer | tool_use | clarify | handoff", + "intent": "greeting | collect_input | answer | tool_use | clarify | handoff | open_inquiry_form", "resolved_inputs": {{}}, "missing_inputs": [], "question": "string or null", @@ -180,26 +180,34 @@ def _build_prompt( Rules: - For greetings, set intent=greeting, no tools, and public_response as a natural greeting. +- If the user explicitly wants a quote, pricing details, wants to talk to a human/support, + wants someone to contact them, or asks for a bulk/custom order, set intent=open_inquiry_form. + The widget will open a lead-capture form — do NOT try to collect fields yourself. + This includes: "request a quote", "I want pricing", "contact me", "bulk order", + "talk to someone", "call me back", "need a quote", "how much for 50 units", + "customer support", "need support", "talk to support", "speak to an agent", + "contact sales", "I want to buy", "price breakdown", "wholesale pricing", + "send inquiry", "I need help from a person", "connect me with your team". - 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. 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 + 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 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 @@ -255,10 +263,14 @@ 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." + public_response = public_response or "Hi, I’m here. Tell me what you’d like help with." + elif intent == "open_inquiry_form": + # Signal the widget to open the form modal - no LLM tool call needed. + action = "open_inquiry_form" + public_response = public_response or "I will open a quick form so our team can get back to you." 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, + # 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" diff --git a/apps/api/app/services/message_service.py b/apps/api/app/services/message_service.py index 7ab8ea1..d7bd482 100644 --- a/apps/api/app/services/message_service.py +++ b/apps/api/app/services/message_service.py @@ -954,117 +954,68 @@ def _collect_inquiry_pending_state(self, tool_results: dict[str, Any]) -> dict: def _adapt_turn_plan_for_inquiry( self, turn_plan: AgentTurnPlan, message: str, pending_state: dict[str, Any] ) -> AgentTurnPlan: - """Keep an in-flight inquiry on the submit_inquiry tool every turn. - - The LLM planner independently decides each turn whether to call - submit_inquiry again, and it is not reliable about continuing an - in-flight multi-turn inquiry: it sometimes just acknowledges the - user's free-text reply ("Thanks, I've noted your email...") without - calling the tool, silently dropping the collected fields and never - reaching confirmation. Once an inquiry is in flight, this - deterministically keeps the tool in the plan every turn - mirroring - the existing Lal Kitab chart-first override - until it is submitted - or the user explicitly cancels. + """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 or not self.tool_registry.get("submit_inquiry"): + if self.tool_registry is None: return turn_plan - has_in_flight_inquiry = bool( - pending_state - and (pending_state.get("resolved_inputs") or pending_state.get("awaiting_confirmation")) - ) - if not has_in_flight_inquiry: - # No inquiry in progress yet: only step in on an explicit, - # unambiguous purchase-intent trigger (e.g. the product card's - # "Send Inquiry for Price" click, or the user typing "send - # inquiry"/"request a quote"). The LLM planner is not reliable - # about including submit_inquiry in tool_plan on its own for this - # first turn, so this deterministically starts the tool instead - # of leaving it to chance. - if not INQUIRY_TRIGGER_PATTERN.search(message or ""): - return turn_plan - # The planner sometimes extracts the trigger message itself - # (e.g. "Send inquiry for price") into resolved_inputs.query, - # which then flows into every submit_inquiry call as if the - # user's actual requirement/question was already given - the - # tool never asks for it. A "query" that is just the trigger - # phrase restated is not a real requirement, so drop it here. - bogus_query = turn_plan.resolved_inputs.get("query") if turn_plan.resolved_inputs else None - if bogus_query and INQUIRY_TRIGGER_PATTERN.search(str(bogus_query)): - turn_plan.resolved_inputs.pop("query", None) - already_calling_inquiry = any( - str(step.get("tool_id") or "") == "submit_inquiry" for step in (turn_plan.tool_plan or []) + # 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 + 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 "" + 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." ) - if not already_calling_inquiry: - turn_plan.tool_plan = [ - { - "tool_id": "submit_inquiry", - "reason": "Explicit inquiry/quote request detected", - "input": {"payload": {}}, - "depends_on": None, - }, - *(turn_plan.tool_plan or []), - ] - if turn_plan.action in {"ask_missing_input", "clarify", "greeting", "ask_question"}: - turn_plan.action = "ready" - turn_plan.intent = "tool_use" - turn_plan.public_response = None - turn_plan.response_text = "" + 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_message = "No problem, I've cancelled that inquiry. Let me know if you'd like to start a new one." - turn_plan.public_response = cancel_message - turn_plan.response_text = cancel_message - turn_plan.context_decision = { - **(turn_plan.context_decision or {}), - "reason": "inquiry_cancelled", - } + 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 - - # Once the tool is awaiting confirmation, an affirmative reply must - # set confirm=true so the tool actually sends the inquiry instead of - # re-showing the same summary forever. - should_confirm = bool( - pending_state.get("awaiting_confirmation") and INQUIRY_AFFIRM_PATTERN.search(message or "") - ) - already_calling_inquiry = False - for step in turn_plan.tool_plan or []: - if str(step.get("tool_id") or "") == "submit_inquiry": - already_calling_inquiry = True - if should_confirm: - step_input = step.get("input") if isinstance(step.get("input"), dict) else {} - step["input"] = {**step_input, "confirm": True} - if not already_calling_inquiry: - step_input: dict[str, Any] = {"payload": {}} - if should_confirm: - step_input["confirm"] = True - turn_plan.tool_plan = [ - { - "tool_id": "submit_inquiry", - "reason": "Continuing the in-flight inquiry", - "input": step_input, - "depends_on": None, - }, - *(turn_plan.tool_plan or []), - ] - # The planner's own missing-field guess (or any other short-circuit - # action) must never pre-empt the tool: submit_inquiry has its own, - # more accurate missing-field detection, and skipping the tool call - # here would silently drop the just-collected field and truncate the - # response (this previously caused a bare "Got it — noted." - # reply on the turn that completed the last required field, instead - # of running the tool and showing the confirmation summary). - if turn_plan.action in {"ask_missing_input", "clarify", "ask_question", "greeting", "handoff"}: - turn_plan.action = "ready" - turn_plan.intent = "tool_use" - turn_plan.public_response = None - turn_plan.response_text = "" 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).""" @@ -2019,18 +1970,9 @@ def _register_configured_capabilities(self, config: dict) -> dict: self.tool_registry.register(connector_tool) registered_tools.append(connector_tool.name) - inquiry_config = config.get("inquiry") or {} - if inquiry_config.get("enabled"): - inquiry_tool = InquiryTool( - brand_slug=self.brand_id, - brand_id=(self.agent_record or {}).get("brand_id"), - agent_id=self.agent_id, - conversation_id=None, # set per-turn via _configure_inquiry_tool_for_turn - agent_config=config, - submit_callback=self._submit_inquiry, - ) - self.tool_registry.register(inquiry_tool) - registered_tools.append(inquiry_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 if registered_skills or registered_tools: logger.info( 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/src/App.tsx b/apps/widget/src/App.tsx index 3eeaf64..47fd398 100644 --- a/apps/widget/src/App.tsx +++ b/apps/widget/src/App.tsx @@ -121,6 +121,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'); @@ -168,6 +169,8 @@ 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 resolvedActivityMode = (widgetChannel.activity_mode ?? features.activity_mode) === 'advanced' ? 'advanced' : 'basic'; const resolvedActivityPersistence = @@ -176,6 +179,7 @@ function App({ config }: AppProps) { setHumanTakeoverEnabled(takeoverEnabled); setShowSources(shouldShowSources); setShowProductCards(shouldShowProductCards); + setShowInquiry(shouldShowInquiry); setActivityMode(resolvedActivityMode); setActivityPersistence(resolvedActivityPersistence); if (!brandId) return; @@ -369,7 +373,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 +443,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 +504,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; @@ -537,10 +557,13 @@ function App({ config }: AppProps) { onFeedback={setMessageFeedback} showSources={showSources} showProductCards={showProductCards} + showInquiry={showInquiry} isAgentConfigured={Boolean(agentId)} unavailableMessage={unavailableMessage || undefined} apiUrl={configuredApiBase} agentId={agentId} + conversationId={conversationId} + onSendMessageWithReset={handleSendMessageWithReset} /> )} diff --git a/apps/widget/src/components/ChatWindow.tsx b/apps/widget/src/components/ChatWindow.tsx index 1be44ec..e91405d 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,13 @@ 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; } // ── Icon components ──────────────────────────────────────────── @@ -150,21 +154,81 @@ export const ChatWindow: React.FC = ({ onFeedback, showSources = false, showProductCards = true, + showInquiry = false, isAgentConfigured = true, unavailableMessage, apiUrl, agentId, + conversationId, + onSendMessageWithReset, }) => { - 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 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(() => { + 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 || '' }, + }); + }, 600); + }, [addMessage, removeMessage]); + + 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 + const lastMsg = React.useMemo(() => messages[messages.length - 1], [messages]); + React.useEffect(() => { + if (!lastMsg || lastMsg.role !== 'assistant') return; + const meta = lastMsg.metadata as Record | undefined; + if (meta?.open_inquiry_form && showInquiry) { + const pName = typeof meta.product_name === 'string' ? meta.product_name : ''; + handleOpenInquiryForm(pName); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastMsg?.id]); + const tk = brandTheme?.tokens; const mode = brandTheme?.mode ?? 'dark'; 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 ?? []; @@ -424,9 +488,14 @@ export const ChatWindow: React.FC = ({ onFeedback={onFeedback} showSources={showSources} showProductCards={showProductCards} + showInquiry={showInquiry} apiUrl={apiUrl} agentId={agentId} + conversationId={conversationId ?? undefined} onSendMessage={onSendMessage} + onOpenInquiryForm={showInquiry ? handleOpenInquiryForm : undefined} + onInquirySuccess={handleInquirySuccess} + onInquirySkip={handleInquirySkip} /> {showPlaceChips && (
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) */} +
+ +