feat: AI part

This commit is contained in:
talatops 2026-04-12 00:40:25 +05:00
parent 7958b32159
commit 4e83c12ddd
35 changed files with 3466 additions and 140 deletions

555
docs/req-AI.md Normal file
View File

@ -0,0 +1,555 @@
# Vendor use-case knowledge modules & L1 agentic implementation plan
**Read this first (integration).** This file is an **implementation-oriented plan** meant to be **merged into an existing codebase** (Shopify sync, dashboard, DB already in place). **You are not required to follow** the **module codes**, **`shared_field_id`** strings, **table/column names**, **tool names**, **flags**, **slot keys**, or any other **rigid-looking** labels in this document when they would **create conflict** with your current schema, migrations, types, or APIs. Those conventions exist **only** to keep the spec **internally consistent** and to explain **how** the system should behave. **Map concepts, not strings:** keep the **ideas** (which flows are L1 vs ticket vs KB, verification rules, prerequisites, tool-call *shape*); **rename and wire** to whatever your project already uses. If spec and repo disagree on names, **default to the repo** unless you explicitly refactor.
> **Same caution, shorter:** Identifiers in this doc are **illustrative**, not a mandate. **Codebase wins** over literal tags here. Preserve **behavior and structure**, not every ID.
**Purpose.** This document defines **shortlisted customer-support use-cases** as **optional sub-modules**: vendor **knowledge (FAQ-style)**, **L1 transactional automation** (read/act on data you already sync from Shopify into your database), and **ticketing** (human resolution). It also specifies **two customer interfaces** (email + storefront widget), **verification** (email + OTP only), and **constraints** so implementations stay consistent.
**Scope (agentic layer only).** Shopify store, vendor dashboard, and **database hydration from Shopify** are **assumed to exist**. This plan covers the **L1 automation layer** (intent handling, retrieval, slot filling, verification, safe responses, ticket creation). It does **not** describe full end-to-end replacement of human support.
**Data access (tool calls).** The agent does **not** “know” order lines, inventory, tracking, or **category metadetails** by default. Implementation must expose **tool calls** (or equivalent server-side functions) that **query your database** using your **actual schema** and **field names** for each store. Answers for **transactional** flows and for **catalog metadata** flows are only as good as (a) what is synced into the DB and (b) which tools the orchestrator is allowed to invoke for that module. Document concrete tool names, parameters, and return shapes in engineering specs **for your real schema** — those names **need not** match any examples elsewhere in this document (see **Read this first** above).
**Excluded use-cases** (out of scope for this framework):
- Subscription / repeat orders
- Wholesale / B2B / large orders
- Account & security (e.g. Shopify permission / identity beyond email+OTP)
- VIP / loyalty context
- **Shopify customer login** and **magic-link** verification (explicitly not used)
---
## 0. What we automate vs what humans do
### 0.1 L1 only — not the whole pipeline
- **L1** = deflection and **safe, bounded** automation: answer from **knowledge**, **read** order/product state from your DB, perform **allowed** actions (e.g. cancel when rules say yes), and **hand off** when the job requires judgment, negotiation, or operational access beyond the playbook.
- **Tickets** = default path when the use-case is **human work** (order change, warranty processing, payment disputes, complaint resolution, return/exchange **handling**, etc.). There is **no generic end-to-end workflow** for tickets in the agentic sense: a **real human** owns the ticket. The agent may **draft context** for the human (summary, slots filled, policy snippets), but **resolution is human**.
### 0.2 Three pillars (every module maps to one primary pillar)
| Pillar | Meaning | Typical outputs |
|--------|---------|-----------------|
| **Knowledge (FAQ)** | Answers from vendor-configured text + embeddings / RAG; **no** order mutation; may not need identity. | Policy explanations, links, “how we work.” |
| **Transactional L1** | Uses **verified identity** + **prerequisites** (e.g. order id); **reads** DB (and optionally **acts** where allowed). | Order status, tracking snapshot, cancel eligibility + cancel, refund **status** lookup, inventory lookup. |
| **Ticket-led** | After verification (when needed), **create or continue a ticket**; customer-facing reply is acknowledgement + expectations; **human** executes. | Order change, warranty, payment disputes, wrong-item resolution, return/exchange **initiation**, complaints, many shipment exceptions. |
| **Catalog metadata (DB-backed)** | Answers grounded in **structured fields** synced from the storefront (e.g. **category metadetails**), retrieved via **tool calls** — not free-form vendor FAQ text for those facts. | Category-wide sizing notes, materials, care, compatibility text the merchant attached at **category** level; applies to **all products** in that category. |
**Note:** “Refund **policy**” (e.g. “how long do refunds take?”) is **Knowledge**. “**Refund status** for my order” is **Transactional** (needs identification + order/return context in DB).
**Category metadetails module (vendor toggle).** Merchants can maintain **details at the category level** in Shopify (and equivalents); once synced to your DB, that data can power answers (e.g. fit, fabric, voltage) **across every product in those categories**. In the product, the vendor has a **single switch**: **enable** or **disable** use of category metadetails for automation — **no per-category granularity** in v1. When **disabled**, the agent must **not** use tool calls that return category metadetails (fall back to KB, PDP links, or ticket). When **enabled**, the agent may answer from **metadetails of all categories/products** the store has synced, subject to intent and **tool-call** availability.
### 0.3 Tickets and email
- Tickets are **internal** objects; **conversation with the customer** for ticket-heavy flows is still primarily **email** (and mirrored logic on the widget where applicable).
- If the customer is **already on email**, opening a ticket (e.g. return initiation, complaint) can be **initiated automatically** from that thread once rules are satisfied.
- If the customer is on the **widget**, they complete **email + OTP + session** first; then “open a ticket” creates the ticket and **subsequent human↔customer** dialogue happens over **email** (or the same channels you define in `sf_support_channels`).
---
## 1. Customer interfaces
### 1.1 Email
- **Identity signal:** the **sender address** (`From:`) is the customer email for that thread.
- **Strict rule (transactional):** for any **fiber** / sensitive action (order status, cancel, refund status, shipping details tied to an order, etc.), the **only** email identity you trust for lookup is **`From` matches the email on the order / customer record** in your database. The customer **cannot** ask for someone else’s order from their own inbox; they must **send from the email that placed the order** (e.g. they cannot use their personal Gmail to query a friend’s order placed under the friend’s email).
- **Verification path on email:** **no OTP** in v1 of this plan for email (optional future); trust is **email alignment** with DB. If `From` is not associated with any customer/order, respond with a **generic** safe message (do not confirm whether an order exists for another email) and instruct them to contact support from the **email used at checkout**.
- **Multi-order disambiguation:** if `From` matches a customer with **multiple open or recent orders**, L1 **lists** candidate orders (e.g. id, date, short descriptor) and asks **which order** they mean before fetching full details or acting.
### 1.2 Storefront widget (chat)
- **Unauthenticated path:** if the user only needs **Knowledge (FAQ)** and the vendor has enabled that module, answer using RAG/KB **without** collecting email (no session required for pure FAQ).
- **Transactional path:** to run any **Transactional L1** or **Ticket-led** flow that depends on **who** the customer is:
1. Collect **email address** (the one they assert is their order email).
2. Start a **server-side session** bound to that email attempt; show the user that the session is **valid for a fixed TTL** (e.g. **1 hour**) and that further questions in that window use this context.
3. Send **OTP** to that email; user enters OTP in the widget.
4. On success, mark session **verified** for that email; proceed with slot filling (order id, product name, etc.) and L1 or ticket creation.
- **No Shopify login, no magic links** — only **email + OTP** for widget verification.
### 1.3 OTP (widget) — behavioral requirements
- Rate-limit sends and attempts; invalidate OTP after use or expiry.
- Session store: `session_id` → `asserted_email`, `verified_at`, `expires_at`, `store_id`, optional `bound_order_id` after disambiguation.
- After TTL expiry, **re-OTP** required for any new transactional request.
---
## 2. Verification, confidentiality, and prerequisites (cross-cutting)
### 2.1 Constraint: no cross-email order access
- You **must not** disclose order data unless the **verified channel identity** matches the **customer/order email** in the database (email channel: `From`; widget: OTP-proved email).
- **Friend’s order:** impossible to service from the wrong inbox; customer must use the **purchaser’s email** (and that person must be the one in the thread or OTP flow).
### 2.2 Prerequisites pattern
For each **Transactional** or **Ticket-led** module, define:
- **Required slots** (e.g. `order_id`, `product_query`, `issue_subtype`).
- **Verification gate:** `email_aligned` (email) or `otp_verified_session` (widget).
- **L1 action:** read-only, read+act (limited), or **create ticket only**.
### 2.3 Slot filling and back-and-forth
- Orchestrator state: `collect_slots` → `verify` (widget OTP) → `execute_l1` or `open_ticket` → `respond`.
- Ask **only** for the **next missing** prerequisite.
- Reuse **one verified session** on the widget for multiple questions within TTL (e.g. status then tracking for same order).
---
## 3. Use-case catalog — categorization (vetted)
**Legend**
- **K** = Knowledge (FAQ / KB; module enabled gates automation).
- **T** = Transactional L1 (DB read / limited write after verification + slots).
- **X** = Ticket-led (human resolves; L1 may intake + acknowledge + ticket).
- **Catalog metadata (DB-backed)** = see §0.2; answers use **tool calls** against synced **category metadetails** when **`CATEGORY_METADETAILS`** is enabled (no per-category toggle in v1).
| Module code | Customer-facing use-case | Primary pillar | Prerequisites (typical) | L1 automation (agentic) | Human / ticket |
|-------------|----------------------------|----------------|---------------------------|---------------------------|----------------|
| `ALL` | Global store context | — | — | Informs all replies | — |
| `FAQ` | General policies & “how we work” | **K** | None for pure policy | RAG / structured FAQ; **refund policy** lives here, not “status” | If out of scope → suggest email or ticket |
| `CATEGORY_METADETAILS` | Category-level product details from storefront sync | **Catalog metadata (DB-backed)** | Product or category resolution (name/SKU → category); **module must be enabled by vendor** | **Tool calls** to DB: read **category metadetails** fields for the resolved category(ies); use for factual answers store-wide | If disabled, off, or no row → KB / PDP link / ticket |
| `PRODUCT_FIT` | Product fit, sizing, compatibility | **K** *(optional light T: product resolve)* | Product name/SKU for specificity | Answer from KB + **category metadetails (if `CATEGORY_METADETAILS` enabled)** via tools + product metafields/chunks; link to PDP chart | Edge cases → ticket |
| `STORE_LOCAL` | Store hours, pickup, local | **K** | None | KB only | — |
| `FEEDBACK` | After-resolution feedback | **K** | Optional ticket id | Link to survey / ask once | Negative follow-up → human |
| `HUMAN_ESCALATION` | “I want a person” | **K** / routing | None or context | Ack + routing copy + create ticket if policy says so | Human handles queue |
| `ORDER_STATUS` | Where is my order | **T** | Verified identity + `order_id` (or disambiguation) | Read order state from DB; reply with status summary | If anomaly outside playbook → ticket |
| `ORDER_SUMMARY` | Order summary / receipt recap | **T** | Same | Read line items / totals from DB | Tax invoice edge cases → ticket if vendor policy says |
| `SHIPPING_TRACKING` | Carrier, tracking, ETA | **T** | Same | Read tracking fields from DB for that order | Carrier claim / dispute → **X** |
| `ORDER_CANCEL` | Cancel order | **T** | Same + business rules in DB | Eligibility check + cancel if allowed; else explain from KB | If ambiguous or edge case → **X** |
| `REFUND_STATUS` | Status of **my** refund / return payout | **T** | Verified identity + order (or return) reference | Read refund/return state from DB if modeled; else templated “we’ll update you” + **X** | Human if data missing |
| `INVENTORY_STOCK` | Is this in stock | **T** | `product_name` / SKU (widget may need session only if tied to customer cart—optional) | Read inventory from DB | Disambiguation across many SKUs |
| `NOT_RECEIVED_MARKED_DELIVERED` | Tracking says delivered, I don’t have the parcel | **X** | Verified identity + `order_id` | L1: confirm tracking snapshot + vendor policy snippet + **open ticket** | Human investigates carrier / claim |
| `SHIPMENT_STUCK_OR_DELAYED` | Shipment not moving / very late vs expectation | **X** | Verified identity + `order_id` | L1: last known scan + policy snippet + **open ticket** | Human chases carrier / ops |
| `PAYMENT_PROBLEM` | Double charge, wrong amount, promo failed, etc. | **X** | Verified identity + **order reference when payment is order-scoped**; some sub-intents need **last4 / date** as slots | L1: structured intake + **ticket**; **no** automated money movement | Human + finance tools |
| `ORDER_CHANGE` | Change address / items / qty | **X** | Verified identity + `order_id` + requested change | L1: **open ticket** + acknowledgement (human decides feasibility) | Human confirms “possible / not possible” |
| `RETURN_EXCHANGE` | Start return or exchange | **X** | Verified identity + `order_id` (+ reason) | L1: policy from KB + **open ticket** (or portal link if you add later) | Human / ops fulfills RMA |
| `WARRANTY` | Warranty claim (post–return window) | **X** | Verified identity + proof-of-purchase / order | L1: intake + **ticket** | Human adjudicates |
| `WRONG_ITEM` | Wrong / damaged / missing item | **X** | Verified identity + `order_id` + issue type (+ photos on email) | L1: acknowledge + policy + **ticket** with structured fields | Human resolves (reship/refund) |
| `COMPLAINT` | Strong dissatisfaction | **X** | **Strict verification** same as other fiber flows | L1: empathetic ack + **ticket** + severity | Human owns resolution |
**Removed vague module:** `DELIVERY_PROBLEM` is **removed** as a single umbrella. Use **`NOT_RECEIVED_MARKED_DELIVERED`** and **`SHIPMENT_STUCK_OR_DELAYED`** so intents and playbooks stay **specific**.
---
## 4. Design principles (knowledge modules)
### 4.1 Modular enablement
- Each **use-case** = one **sub-module**. Vendor **enables** modules they want.
- **Knowledge** modules: enabled when **required vendor questions** (`shared_field_id`) are complete.
- **Transactional / ticket** modules: additionally require **technical readiness** (e.g. order data in DB, ticket pipeline) — product-defined.
- **`CATEGORY_METADETAILS`:** **no** vendor questionnaire — only a **single store-level toggle** (**on** / **off**). **On** = agent may use **tool calls** to read **category metadetails** for **all** synced categories/products; **off** = those tools are not invoked for customer answers. **No per-category** enable/disable in v1.
### 4.2 Single source of truth (shared answers)
- Vendor questions map to **`shared_field_id`**. Same id = one value; edit propagates everywhere to avoid contradictions.
### 4.3 Duplication in the UI
- Same question may appear in multiple modules for **context**; storage is still keyed by `shared_field_id`.
### 4.4 `ALL` (global)
- Baseline questions apply to every store regardless of modules.
### 4.5 Document boundaries
- This doc specifies **business rules** and **conceptual field identity** for alignment across modules. **Schema, UI copy, exact OTP TTL**, and chunking strategy are implementation details elsewhere — and **physical schema / API names need not match** the `shared_field_id` or module codes in this file (see **Read this first (integration)** at the top).
---
## 5. Shared field ID reference (cross-module truth)
*The keys in the first column are **spec labels** for “one conceptual answer, many modules.” In your app they might be DB columns, JSON keys, or CMS field handles with **different** names — that is **expected** when integrating (see **Read this first**).*
| `shared_field_id` | Typical use |
|-------------------|-------------|
| `sf_brand_display_name` | Brand as customers know it |
| `sf_store_one_liner` | What you sell / for whom |
| `sf_official_languages` | Languages you support in writing |
| `sf_support_channels` | Email, phone, chat, hours |
| `sf_support_first_response_sla` | Expected time to first human/bot reply |
| `sf_tone_and_words_to_avoid` | Voice + banned terms |
| `sf_order_id_help` | How customers find order # / confirmation |
| `sf_first_message_checklist` | What to include when contacting support |
| `sf_policy_link_terms` | Terms of sale URL |
| `sf_policy_link_privacy` | Privacy policy URL |
| `sf_policy_link_returns` | Return policy URL (if separate) |
| `sf_privacy_support_paragraph` | Short: how data is used in support |
| `sf_shipping_regions_served` | Where you ship |
| `sf_shipping_regions_excluded` | Where you do not ship |
| `sf_processing_time_before_ship` | Dispatch / handling time |
| `sf_carriers_and_tracking_policy` | Carriers + when tracking is provided |
| `sf_shipping_methods_and_transit` | Methods + realistic transit ranges |
| `sf_shipping_cost_model` | Free/paid/flat/dynamic |
| `sf_dispatch_cutoff` | Same-day rules if any |
| `sf_address_correction_policy` | Wrong address, failed delivery fees |
| `sf_customs_duties_policy` | International duties disclaimer |
| `sf_delivered_not_received_policy` | “Delivered but I don’t have it” |
| `sf_major_delay_communication` | Strikes, weather, backlog messaging |
| `sf_stuck_shipment_customer_steps` | What customer should do while shipment appears stalled |
| `sf_cancellation_window` | When cancel is allowed |
| `sf_cancellation_how_to_request` | How customer asks to cancel |
| `sf_cancellation_preorder_digital` | Special rules if applicable |
| `sf_order_change_allowed_until` | Until when edits are possible (for KB / human context) |
| `sf_order_change_how_to_request` | How to request change (human path) |
| `sf_order_change_not_possible_next_step` | After cutoff / shipped |
| `sf_return_window` | Days / trigger event |
| `sf_return_condition_requirements` | Tags, unworn, packaging |
| `sf_return_shipping_payer` | Who pays return shipping |
| `sf_exchange_policy` | Swap vs credit vs refund |
| `sf_how_to_start_return` | Portal/email/steps |
| `sf_non_returnable_categories` | Final sale, hygiene, etc. |
| `sf_refund_methods` | Original payment vs store credit |
| `sf_refund_timeline_after_approval` | Time to money back |
| `sf_partial_refund_rules` | Open box, missing parts |
| `sf_store_credit_rules` | Expiry, combinable |
| `sf_payment_methods_accepted` | Cards, wallets, regional limits |
| `sf_promo_discount_rules` | Stacking, exclusions |
| `sf_tax_display_policy` | What’s included at checkout |
| `sf_price_adjustment_policy` | Post-purchase price match if any |
| `sf_failed_payment_guidance` | What customer should retry |
| `sf_wrong_item_report_deadline` | Time limit to report |
| `sf_wrong_item_evidence` | Photos, packing slip, etc. |
| `sf_wrong_item_remedies` | Reship, refund, return-first (human executes) |
| `sf_damage_carrier_vs_warehouse` | How you explain difference |
| `sf_warranty_duration_scope` | Length + what’s covered |
| `sf_warranty_voiders` | Misuse, unauthorized repair |
| `sf_warranty_claim_process` | Proof, repair, replace |
| `sf_sizing_fit_guidance` | Fit notes + size chart link |
| `sf_compatibility_notes` | Devices, voltage, region |
| `sf_materials_care_limitations` | Care, allergens, limitations |
| `sf_back_in_stock_policy` | Waitlist / notify / none |
| `sf_preorder_policy` | Charge timing, ship-by, cancel |
| `sf_discontinued_product_policy` | Alternatives / final sale |
| `sf_physical_locations_hours` | Addresses + hours + holidays |
| `sf_pickup_rules` | Hold time, ID, process |
| `sf_instore_vs_online_inventory` | Same stock or not |
| `sf_human_escalation_when` | Clear “ask a human” path |
| `sf_complaint_acknowledgement_sla` | How fast you acknowledge |
| `sf_complaint_supervisor_escalation` | When a lead reviews |
| `sf_abusive_contact_policy` | Neutral boundary statement |
| `sf_feedback_csat_process` | Survey link / when sent |
| `sf_order_summary_tax_invoice_note` | Invoices, VAT, limitations |
| `sf_double_charge_intake` | What customer should send for duplicate charge claims |
---
## 6. `ALL` — Global / store-wide questions
| # | Question (vendor-facing) | Why it matters | `shared_field_id` |
|---|-------------------------|----------------|-------------------|
| G1 | What is your **brand or store name** as customers should see it? | Consistent identity. | `sf_brand_display_name` |
| G2 | In **one or two sentences**, what do you sell and who is it for? | Grounds answers. | `sf_store_one_liner` |
| G3 | Which **languages** do you officially support in email/support? | Sets expectations. | `sf_official_languages` |
| G4 | What are your **official support channels** (email, phone, chat) and **hours**? | Human + ticket paths. | `sf_support_channels` |
| G5 | Typical **first response time** for human support? | Trust / SLA. | `sf_support_first_response_sla` |
| G6 | **Tone** and **words or promises** to avoid. | Brand-safe automation. | `sf_tone_and_words_to_avoid` |
| G7 | How customers find **order number** / confirmation. | All order-related flows. | `sf_order_id_help` |
| G8 | What customers should **always include** when contacting support. | Faster resolution. | `sf_first_message_checklist` |
| G9 | Link to **terms of sale**. | Legal grounding. | `sf_policy_link_terms` |
| G10 | Link to **privacy policy**. | Trust. | `sf_policy_link_privacy` |
| G11 | Link to **return / refund policy** if separate. | Canonical policy URL. | `sf_policy_link_returns` |
| G12 | **Short paragraph**: how data is used in support. | Privacy framing. | `sf_privacy_support_paragraph` |
---
## 7. Per-module vendor question sets
*Required completion gates the **Knowledge** aspect of each module. **Transactional** and **ticket** behaviors additionally depend on §3 and product integration.*
### 7.1 `ORDER_STATUS` — Order status
| # | Question | `shared_field_id` |
|---|----------|-------------------|
| OS1 | **Normal processing time** before shipment? | `sf_processing_time_before_ship` |
| OS2 | **Proactive status updates** (if any)? | *(optional `sf_order_status_proactive_updates`)* |
| OS3 | If **delayed before shipment**, what do you tell the customer? | `sf_major_delay_communication` |
| OS4 | **Ship to** / **do not ship to**? | `sf_shipping_regions_served`, `sf_shipping_regions_excluded` |
| OS5 | **Order # help** + checklist | `sf_order_id_help`, `sf_first_message_checklist` |
**Transactional note:** L1 reads **order record** from DB after **email match + order disambiguation**.
---
### 7.2 `INVENTORY_STOCK` — Inventory / stock
| # | Question | `shared_field_id` |
|---|----------|-------------------|
| IV1–IV5 | *(unchanged — back-in-stock, pre-order, discontinued, in-store vs online, disclaimer)* | `sf_back_in_stock_policy`, `sf_preorder_policy`, `sf_discontinued_product_policy`, `sf_instore_vs_online_inventory`, optional disclaimer |
**Transactional note:** L1 needs **product identifier**; may run **without** full OTP if policy allows anonymous stock checks on widget — **product decision**; if tied to “my cart order,” require verified session.
---
### 7.3 `FAQ` — General FAQ (includes **refund policy**, not refund status)
| # | Question | `shared_field_id` |
|---|----------|-------------------|
| FQ1–FQ8 | Shipping, returns, **refunds policy summary**, payment, promo, tax, contact, policy links | *(as in prior doc)* |
---
### 7.4 `ORDER_CANCEL` — Order cancellation
| # | Question | `shared_field_id` |
|---|----------|-------------------|
| CX1–CX5 | Window, how to request, next step if impossible, pre-order, refund timing | `sf_cancellation_*`, `sf_refund_*`, `sf_order_change_not_possible_next_step` |
**Transactional note:** L1 runs **eligibility + cancel** in DB/Shopify ruleset; otherwise KB explanation + optional ticket.
---
### 7.5 `HUMAN_ESCALATION`
| # | Question | `shared_field_id` |
|---|----------|-------------------|
| HE1–HE4 | When to ask human, channels, SLA, checklist | `sf_human_escalation_when`, `sf_support_*`, `sf_first_message_checklist` |
---
### 7.6 `COMPLAINT` — Ticket-led, strict verification
| # | Question | `shared_field_id` |
|---|----------|-------------------|
| CP1–CP6 | Ack, SLA, supervisor, remedies summary, abuse policy, channels | `sf_complaint_*`, `sf_wrong_item_remedies`, `sf_refund_methods`, `sf_support_channels` |
**Implementation note:** Always **verify identity** like other fiber flows; **create ticket**; human resolves.
---
### 7.7 `ORDER_SUMMARY`
| # | Question | `shared_field_id` |
|---|----------|-------------------|
| SM1–SM4 | Receipt naming, tax invoice note, checklist, order # help | optional receipt naming, `sf_order_summary_tax_invoice_note`, `sf_first_message_checklist`, `sf_order_id_help` |
---
### 7.8 `REFUND_STATUS` — Transactional (distinct from FAQ refund policy)
| # | Question | `shared_field_id` |
|---|----------|-------------------|
| RF1–RF6 | Methods, timeline, after return received, partial rules, store credit, delay messaging | `sf_refund_*`, optional `sf_refund_after_return_received`, `sf_refund_delay_exception` |
**Transactional note:** L1 reads **refund/return pipeline state** from DB when available; if not modeled, fall back to **ticket** after verification.
---
### 7.9 `SHIPPING_TRACKING`
| # | Question | `shared_field_id` |
|---|----------|-------------------|
| ST1–ST6 | Carriers, methods, cost, cutoff, regions, customs | *(as before)* |
**Transactional note:** L1 reads **tracking fields** for **verified order**.
---
### 7.10 `WRONG_ITEM` — Ticket-led
| # | Question | `shared_field_id` |
|---|----------|-------------------|
| WI1–WI6 | Deadline, evidence, remedies, damage explanation, return shipping, links | `sf_wrong_item_*`, `sf_return_shipping_payer`, `sf_policy_link_returns`, `sf_how_to_start_return` |
---
### 7.11 `RETURN_EXCHANGE` — Ticket-led initiation
| # | Question | `shared_field_id` |
|---|----------|-------------------|
| RE1–RE6 | Window, condition, non-returnable, shipping, exchange, refund timing | `sf_return_*`, `sf_exchange_policy`, `sf_how_to_start_return`, `sf_refund_*` |
---
### 7.12 `NOT_RECEIVED_MARKED_DELIVERED` — Ticket-led (replaces vague “delivery problem”)
| # | Question | `shared_field_id` |
|---|----------|-------------------|
| NR1 | **“Delivered” but not received** — customer steps, wait time, claim | `sf_delivered_not_received_policy` |
| NR2 | **Carrier** / claim expectations | `sf_carriers_and_tracking_policy` |
**Flow:** verify identity + order → show last tracking → policy snippet → **open ticket**.
---
### 7.13 `SHIPMENT_STUCK_OR_DELAYED` — Ticket-led
| # | Question | `shared_field_id` |
|---|----------|-------------------|
| SD1 | When shipment seems **stuck** or **far past** expected range, what should customer do first? | `sf_stuck_shipment_customer_steps` |
| SD2 | How you communicate **major delays** | `sf_major_delay_communication` |
| SD3 | **Carriers** reference | `sf_carriers_and_tracking_policy` |
**Flow:** verify + order → read last scan dates → **open ticket** for human follow-up.
---
### 7.14 `PAYMENT_PROBLEM` — Ticket-led (prerequisites vary)
| # | Question | `shared_field_id` |
|---|----------|-------------------|
| PP1–PP6 | Payment methods, failed payment, **double charge intake**, promo, tax, price adjust | `sf_payment_methods_accepted`, `sf_failed_payment_guidance`, `sf_double_charge_intake`, `sf_promo_discount_rules`, `sf_tax_display_policy`, `sf_price_adjustment_policy` |
**Prerequisites:** for **order-scoped** issues, require **verified identity + order id**; for **pre-purchase checkout** failures, may only have **email + description** → ticket with what you have.
---
### 7.15 `ORDER_CHANGE` — Ticket-led only
| # | Question | `shared_field_id` |
|---|----------|-------------------|
| OC1–OC4 | Until when changes are *sometimes* possible, how to request, impossible case, cancel alternative | `sf_order_change_*`, `sf_cancellation_*` |
**Flow:** never auto-change order in L1 v1; **open ticket** for human.
---
### 7.16 `WARRANTY` — Ticket-led
| # | Question | `shared_field_id` |
|---|----------|-------------------|
| WA1–WA5 | Scope, voiders, process, timeline optional, escalation | `sf_warranty_*`, `sf_complaint_supervisor_escalation` |
---
### 7.17 `PRODUCT_FIT`
| # | Question | `shared_field_id` |
|---|----------|-------------------|
| PF1–PF4 | Sizing, compatibility, materials, human edge cases | `sf_sizing_fit_guidance`, `sf_compatibility_notes`, `sf_materials_care_limitations`, `sf_human_escalation_when` |
---
### 7.18 `STORE_LOCAL`
| # | Question | `shared_field_id` |
|---|----------|-------------------|
| SL1–SL4 | Locations, pickup, inventory nuance, local returns | `sf_physical_locations_hours`, `sf_pickup_rules`, `sf_instore_vs_online_inventory`, `sf_how_to_start_return` |
---
### 7.19 `FEEDBACK`
| # | Question | `shared_field_id` |
|---|----------|-------------------|
| FB1–FB3 | CSAT process, negative follow-up optional, tone | `sf_feedback_csat_process`, optional negative follow-up, `sf_tone_and_words_to_avoid` |
---
### 7.20 `CATEGORY_METADETAILS` — Category metadetails (store-wide, DB via tools)
**Vendor configuration:** **One toggle only** — **Enabled** or **Disabled** for the store. There is **no** granular control per category or per product in this version.
**When enabled**
- The L1 agent may invoke **tool calls** that **read from the database** the **category metadetails** (and any related fields your sync pipeline persists — exact table/column names belong in the engineering schema doc).
- Coverage is **all products** that belong to synced categories: answers can draw on **metadetails for every category** present in the store’s data, after the orchestrator resolves **which category** applies (e.g. from product name/SKU → product → category).
- Typical use: **sizing/fit notes**, **materials**, **care**, **compatibility** text maintained at **category** level in Shopify (or equivalent) rather than retyped into the FAQ.
**When disabled**
- Do **not** call category-metadetails read tools for customer-facing answers.
- Fall back to **`PRODUCT_FIT` / FAQ** vendor text, PDP links, or escalation as appropriate.
**Prerequisites**
- Sync pipeline must persist category metadetails into the DB.
- Agent must still **resolve product/category** (slot filling) when the user asks about a specific item; tool calls return **fields**, not guesses.
**No `shared_field_id` questionnaire** for this module — policy text for *when* to cite storefront vs human lives in other modules (e.g. `PRODUCT_FIT`, `FAQ`).
---
## 8. L1 agentic implementation checklist (engineering)
### 8.1 Core services
- [ ] **Intent router** → module + pillar (K / T / X / catalog-metadata) — use **your** module/enum names if they differ from this doc.
- [ ] **Tool-call layer** (or RPC): explicit functions that **query the database** for each concern — **orders**, **line items**, **fulfillment/tracking**, **inventory**, **refund/return state**, **products**, **categories**, **category metadetails** — using **real table/field names from your schema** (not necessarily any placeholder names in this plan). The LLM/agent must only assert facts returned by tools (plus vendor KB where allowed).
- [ ] **Slot extractor** (per module manifest): `order_id`, `product_name`, `issue_subtype`, etc.
- [ ] **Email adapter:** `from_email` + thread id; enforce **same-email** rule for T/X.
- [ ] **Widget adapter:** session + **OTP** service + TTL (e.g. 1h).
- [ ] **DB readers** (exposed as tools) for order, fulfillment, tracking, inventory, refund state (as available in your schema).
- [ ] **Category metadetails tools:** e.g. resolve `product_id`/`sku` → `category_id` → **fetch metadetails record**; respect vendor **`CATEGORY_METADETAILS` enabled** flag before calling.
- [ ] **Writers** only where allowed (e.g. cancel); **ticket creator** for all **X** modules.
### 8.2 Manifests (per module)
- [ ] `required_slots`, `verification_gate`, `pillar`, `l1_actions[]`, `ticket_template_id` (for X).
- [ ] **Disambiguation** rule when multiple orders match email.
### 8.3 Knowledge
- [ ] Vendor `shared_field_id` storage + sync.
- [ ] RAG/chunking with `module_code` metadata for **K** paths.
- [ ] Store-level flag: **`category_metadetails_enabled`** (boolean) — gates category-metadetails **tool calls** only; no per-category flags in v1.
### 8.4 Safety
- [ ] **No existence leak:** wrong email must not confirm others’ orders.
- [ ] **Rate limits** on OTP and lookups.
- [ ] **Guardrails** (existing) before LLM.
### 8.5 Explicit non-goals (this phase)
- [ ] No Shopify OAuth login for customers.
- [ ] No magic links.
- [ ] No full automated ticket resolution workflow — humans own tickets.
---
## 9. Optional `shared_field_id` backlog
- `sf_order_status_proactive_updates`
- `sf_inventory_disclaimer`
- `sf_receipt_naming`
- `sf_refund_after_return_received`
- `sf_refund_delay_exception`
- `sf_negative_feedback_escalation`
---
## 10. JUNOHUB implementation notes (engineering)
This section records **repo-specific** follow-ups, **operator** steps, and **environment** expectations for the L1 MVP wired into this codebase. It does not replace §1–9 above.
### 10.1 Database and Prisma
- After pulling changes that touch [`prisma/schema.prisma`](prisma/schema.prisma), run:
- `npx prisma generate` (types for `Store.categoryMetadetailsEnabled`, `Store.aiModules`, `StorefrontChatConversation` OTP/session fields, etc.)
- Apply schema to the DB: `npx prisma migrate deploy` (production) or `npx prisma db push` (dev only).
- Shared `Store` `select` shapes live in [`src/lib/prisma/storeSelects.ts`](src/lib/prisma/storeSelects.ts). If TypeScript still complains about unknown `select` keys, the generated client is stale — regenerate and restart the TS server in your IDE.
### 10.2 Partially implemented or deferred (vs full spec)
- **`l1State` on conversations:** Column exists; orchestrator does not yet persist a full `collect_slots | verified | execute | handoff` state machine JSON for every turn (logic is mostly derived per request).
- **`aiModules` JSON:** Stored on `Store` and read by the orchestrator; **no dashboard UI** yet to toggle individual modules — use `PATCH /api/stores/[storeId]` with `{ "aiModules": { "FAQ": true, "ORDER_STATUS": false, ... } }` or leave unset for defaults in code.
- **Ticket-led (X) “intake” logging:** Handoff copy is generated; optional `EmailLog` / `FlaggedEmail` rows with trigger `AI_TICKET_INTAKE` are not fully wired for every X path (Gmail L1 replies use trigger `AI_L1_REPLY` when the orchestrator composes the body).
- **Refund status tool:** Only if return/refund data exists on `CachedOrder` (or related JSON); otherwise orchestrator falls back to KB / ticket messaging.
- **Gmail auto-reply:** L1 runs when the message is not off-topic and passes the existing guards (not sparse, order id present when required, etc.); see [`src/lib/gmail/autoReplyPreview.ts`](src/lib/gmail/autoReplyPreview.ts).
- **Integration tests:** [`tests/gmail-auto-reply/`](tests/gmail-auto-reply/) was not extended in the MVP pass; unit tests live under [`tests/l1/`](tests/l1/).
### 10.3 Environment variables (checklist)
Set in `.env` (names only — **do not commit secrets**):
| Variable | Role |
|----------|------|
| `DATABASE_URL` | Required — Postgres for Prisma. |
| `GROQ_API_KEY` | Required for L1 orchestrator, intent router, storefront reply, Gmail flows using Groq. |
| `OPENAI_API_KEY` | Required for KB embeddings and optional OpenAI chat in KB RAG. |
| `OPENAI_EMBEDDING_MODEL` / `OPENAI_EMBEDDING_DIMENSIONS` | Optional — defaults in [`src/lib/kb/embeddingMeta.ts`](src/lib/kb/embeddingMeta.ts). |
| `OPENAI_CHAT_MODEL` | Optional — KB “Ask” chat model override. |
| `GROQ_MODEL` | Optional — override default Groq model. |
| `STOREFRONT_OTP_SECRET` | **Recommended in production** — salt for OTP hashing ([`src/lib/storefront-chat/otpCrypto.ts`](src/lib/storefront-chat/otpCrypto.ts)); defaults to a dev string if unset. |
| `SMTP_*` | Required for **real** storefront OTP emails and other mail; without SMTP, OTP send is logged/skipped per [`src/lib/email.ts`](src/lib/email.ts). |
| Gmail / Pub/Sub (`GOOGLE_*`, `GMAIL_*`, etc.) | As already used for inbound mail and webhooks. |
**From your side:** keep SMTP and AI keys valid; set a strong `STOREFRONT_OTP_SECRET` in production; run `prisma generate` + migrations after deploy.
---

View File

@ -0,0 +1,451 @@
# Gmail vs storefront widget — use-case guide
This document maps **every customer-facing module** from [`docs/req-AI.md`](req-AI.md) §3 to **how it behaves on email (Gmail integration)** vs **the storefront chat widget**. It is a companion to the spec, not a replacement: **identifiers** (`module codes`, API paths) may differ in code; **behavior** should match the pillars below.
**Legend (pillars)**
| Code | Pillar |
|------|--------|
| **K** | Knowledge (FAQ / KB / RAG; no order mutation) |
| **T** | Transactional L1 (verified identity + DB read / limited write) |
| **X** | Ticket-led (human resolves; L1 may intake + acknowledge + ticket) |
| **Cat** | Catalog metadata (DB-backed category metadetails via tools when enabled) |
---
## 1. Identity and verification (both channels)
### 1.1 Gmail (email)
| Rule | Behavior |
|------|----------|
| Identity | Customer email = **`From:`** on the message. |
| Transactional / ticket “fiber” | Only trust lookups when **`From`** matches the **customer/order email** in your DB. No servicing “someone else’s” order from the wrong inbox. |
| OTP | **Not** in v1 of the spec for email — trust is **email alignment** only. |
| Unknown email | **Generic** safe reply: do not confirm whether an order exists for another address; ask them to contact support from the **email used at checkout**. |
| Multi-order | If `From` matches **multiple** recent/open orders, **list candidates** and ask **which order** before full detail or action. |
### 1.2 Storefront widget
| Rule | Behavior |
|------|----------|
| Pure **K** (e.g. FAQ) | May answer **without** email if the module allows — no session required. |
| **T** / **X** / identity-dependent flows | **Email asserted → OTP → verified session (TTL, e.g. 1h)**. No Shopify login, no magic links. |
| After TTL | **Re-OTP** for new transactional work. |
| Session | Server-side: asserted email, verified time, expiry, optional bound order after disambiguation. |
---
## 2. Module-by-module: Gmail vs storefront
For each row: **what the customer is trying to do**, then **Gmail** vs **Widget** specifics. Modules follow [`docs/req-AI.md`](req-AI.md) §3.
---
### `ALL` — Global store context
| | Gmail | Storefront |
|---|--------|------------|
| **Role** | Informs tone, links, support channels, SLA copy in all replies. | Same: baseline context for every reply. |
| **Verification** | N/A (not a standalone intent). | N/A |
| **Notes** | Uses shared fields (brand name, support channels, tone, policy links, etc.). | Same. |
---
### `FAQ` — General policies & “how we work”
| | Gmail | Storefront |
|---|--------|------------|
| **Pillar** | **K** | **K** |
| **Typical asks** | Return window explanation, “how long do refunds take?” (policy), shipping regions. | Same; widget can be used without OTP for pure policy if FAQ module enabled. |
| **Verification** | None required for pure policy. | None for pure FAQ path per §1.2. |
| **Human** | If out of scope → suggest email or ticket. | Same. |
**Important:** Refund **policy** = **K**. Refund **status for my order** = **T** (`REFUND_STATUS`), not FAQ.
---
### `CATEGORY_METADETAILS` — Category-level details from DB sync
| | Gmail | Storefront |
|---|--------|------------|
| **Pillar** | **Cat** | **Cat** |
| **Typical asks** | “What’s the care instruction for [category]?” after product/category resolution. | Same; often needs product/SKU → category resolution. |
| **Verification** | Needed when combined with order-specific **T** flows; for pure catalog facts, product resolution may suffice. | Gated by store **`categoryMetadetailsEnabled`** (or equivalent). If **off**, fall back to KB / PDP / ticket — **do not** call category-metadetails tools. |
| **Human** | If disabled, no row, or ambiguous → KB link / ticket. | Same. |
---
### `PRODUCT_FIT` — Fit, sizing, compatibility
| | Gmail | Storefront |
|---|--------|------------|
| **Pillar** | **K** (+ optional light **T** to resolve product) | **K** (+ product resolve) |
| **Typical asks** | Sizing, voltage, device compatibility. | Same. |
| **Sources** | KB + **category metadetails** (if enabled) + product metafields/chunks. | Same. |
| **Verification** | Usually not for generic fit; if tied to “my order,” need email alignment or OTP session. | OTP if transactional coupling. |
| **Human** | Edge cases → ticket. | Same. |
---
### `STORE_LOCAL` — Hours, pickup, local
| | Gmail | Storefront |
|---|--------|------------|
| **Pillar** | **K** | **K** |
| **Typical asks** | Store hours, pickup location, BOPIS rules. | Same. |
| **Verification** | None. | None. |
---
### `FEEDBACK` — After-resolution feedback
| | Gmail | Storefront |
|---|--------|------------|
| **Pillar** | **K** | **K** |
| **Typical asks** | CSAT link, one-off survey. | Same. |
| **Verification** | Optional ticket id in context. | Same. |
| **Human** | Negative follow-up → human. | Same. |
---
### `HUMAN_ESCALATION` — “I want a person”
| | Gmail | Storefront |
|---|--------|------------|
| **Pillar** | **K** / routing | **K** / routing |
| **Typical asks** | Escalation to agent. | Same. |
| **Verification** | Context-dependent; may create ticket per policy. | May create ticket; **post-ticket** human dialogue often continues **email** per spec §0.3. |
| **Human** | Queue / human ownership. | Same. |
---
### `ORDER_STATUS` — Where is my order
| | Gmail | Storefront |
|---|--------|------------|
| **Pillar** | **T** | **T** |
| **Prerequisites** | **`From`** matches DB + **order id** (or disambiguation list). | **OTP-verified session** + order id / disambiguation. |
| **L1** | Read order state from DB; status summary. | Same. |
| **Human** | Anomaly outside playbook → ticket. | Same. |
---
### `ORDER_SUMMARY` — Receipt / recap
| | Gmail | Storefront |
|---|--------|------------|
| **Pillar** | **T** | **T** |
| **Prerequisites** | Same as order status: aligned email + order resolution. | OTP session + order resolution. |
| **L1** | Line items / totals from DB. | Same. |
| **Human** | Tax invoice edge cases → ticket if policy says. | Same. |
---
### `SHIPPING_TRACKING` — Carrier, tracking, ETA
| | Gmail | Storefront |
|---|--------|------------|
| **Pillar** | **T** | **T** |
| **Prerequisites** | Email alignment + order. | OTP + order. |
| **L1** | Tracking fields from DB for that order. | Same. |
| **Human** | Carrier claim / dispute → **X**. | Same. |
---
### `ORDER_CANCEL` — Cancel order
| | Gmail | Storefront |
|---|--------|------------|
| **Pillar** | **T** | **T** |
| **Prerequisites** | Verified identity + order + business rules. | OTP + order + rules. |
| **L1** | Eligibility + cancel **if allowed** by implementation; else KB explanation. | Same. *(JUNOHUB MVP may be read-only — see [`docs/req-AI.md`](req-AI.md) §10.)* |
| **Human** | Ambiguous / edge → **X**. | Same. |
---
### `REFUND_STATUS` — Status of *my* refund / payout
| | Gmail | Storefront |
|---|--------|------------|
| **Pillar** | **T** | **T** |
| **Prerequisites** | Verified identity + order/return reference. | OTP + references. |
| **L1** | Read refund/return state from DB **if modeled**; else templated + ticket. | Same. |
| **Human** | When data missing or dispute. | Same. |
---
### `INVENTORY_STOCK` — Is this in stock?
| | Gmail | Storefront |
|---|--------|------------|
| **Pillar** | **T** | **T** |
| **Prerequisites** | Product name / SKU; email alignment **if** tied to “my” purchase (product decision). | Session optional for anonymous stock checks per product policy; cart-specific may need OTP. |
| **L1** | Read inventory from DB. | Same. |
| **Human** | SKU disambiguation across many variants. | Same. |
---
### `NOT_RECEIVED_MARKED_DELIVERED` — Tracking says delivered, I don’t have it
| | Gmail | Storefront |
|---|--------|------------|
| **Pillar** | **X** | **X** |
| **Prerequisites** | Verified identity + order. | OTP + order. |
| **L1** | Tracking snapshot + policy snippet + **open ticket**. | Same. |
| **Human** | Carrier / claim investigation. | Same. |
---
### `SHIPMENT_STUCK_OR_DELAYED` — Not moving / very late
| | Gmail | Storefront |
|---|--------|------------|
| **Pillar** | **X** | **X** |
| **Prerequisites** | Verified identity + order. | OTP + order. |
| **L1** | Last known scan + policy + **ticket**. | Same. |
| **Human** | Carrier / ops follow-up. | Same. |
---
### `PAYMENT_PROBLEM` — Double charge, wrong amount, promo failed, etc.
| | Gmail | Storefront |
|---|--------|------------|
| **Pillar** | **X** | **X** |
| **Prerequisites** | Order-scoped: verified identity + **order reference**; some sub-intents need extra slots (last4, date). | Same where applicable. |
| **L1** | Structured intake + **ticket**; **no** automated money movement. | Same. |
| **Human** | Finance / support. | Same. |
---
### `ORDER_CHANGE` — Change address / items / qty
| | Gmail | Storefront |
|---|--------|------------|
| **Pillar** | **X** | **X** |
| **Prerequisites** | Verified identity + order + requested change. | OTP + order + change description. |
| **L1** | **Open ticket** + acknowledgement (v1: no auto-change). | Same. |
| **Human** | Feasibility decision. | Same. |
---
### `RETURN_EXCHANGE` — Start return or exchange
| | Gmail | Storefront |
|---|--------|------------|
| **Pillar** | **X** | **X** |
| **Prerequisites** | Verified identity + order (+ reason). | OTP + order (+ reason). |
| **L1** | KB policy + **ticket** (or portal link if product adds it). | Same; spec notes post-ticket dialogue often **email**. |
| **Human** | RMA / ops. | Same. |
---
### `WARRANTY` — Warranty claim
| | Gmail | Storefront |
|---|--------|------------|
| **Pillar** | **X** | **X** |
| **Prerequisites** | Verified identity + proof-of-purchase / order. | OTP + order. |
| **L1** | Intake + **ticket**. | Same. |
| **Human** | Adjudication. | Same. |
---
### `WRONG_ITEM` — Wrong / damaged / missing item
| | Gmail | Storefront |
|---|--------|------------|
| **Pillar** | **X** | **X** |
| **Prerequisites** | Verified identity + order + issue type; **email** may include **photos**. | OTP + order + issue type. |
| **L1** | Ack + policy + **ticket** with structured fields. | Same. |
| **Human** | Reship / refund / return handling. | Same. |
---
### `COMPLAINT` — Strong dissatisfaction
| | Gmail | Storefront |
|---|--------|------------|
| **Pillar** | **X** | **X** |
| **Prerequisites** | **Strict** verification same as other fiber flows. | OTP + full context. |
| **L1** | Empathetic ack + **ticket** + severity. | Same. |
| **Human** | Resolution owner. | Same. |
---
## 3. Excluded use-cases (spec § opening)
Not in scope for this framework: subscriptions/repeat orders, wholesale/B2B, account & security beyond email+OTP, VIP/loyalty, **Shopify customer login**, **magic-link** verification.
---
## 4. Quick reference — channel vs pillar
| Pillar | Gmail summary | Storefront summary |
|--------|----------------|-------------------|
| **K** | Answer from KB/RAG; `From` not required for pure policy. | FAQ may work **without** OTP; enable FAQ module. |
| **T** | **`From` = identity**; multi-order → disambiguate. | **Email + OTP + session TTL**; disambiguate orders when needed. |
| **X** | Intake + ticket; thread stays on **email**. | Intake + ticket; **ongoing human↔customer** often **email** per spec. |
| **Cat** | Category metadetails tools only if **store toggle on** + product/category resolution. | Same + OTP when combined with **T**/**X**. |
---
## 5. Testing and operations (JUNOHUB)
- **Prisma / DB:** After schema changes: `npx prisma generate`, migrate or `db push` — see [`docs/req-AI.md`](req-AI.md) §10.1.
- **Env:** `DATABASE_URL`, `GROQ_API_KEY`, embeddings/OpenAI keys as needed, `STOREFRONT_OTP_SECRET`, SMTP for real OTP mail — §10.3.
- **Store toggles:** `categoryMetadetailsEnabled`, `aiModules` JSON — §10.2.
- **Illustrative tests:** [`tests/l1/`](tests/l1/), [`tests/gmail-auto-reply/`](tests/gmail-auto-reply/).
---
## 6. Copy-paste test examples (Gmail + storefront widget)
Use these **verbatim** (or with your real order numbers / SKUs) to exercise each pillar. Replace placeholders:
| Placeholder | Meaning |
|-------------|---------|
| `YOUR_APP` | Base URL, e.g. `http://localhost:3000` |
| `YOUR_SHOP` | Store `shopifyDomain` in DB, e.g. `your-brand.myshopify.com` |
| `YOUR_ORDER` | A real order id / name that exists in **your** `CachedOrder` rows for the tester email |
| `TEST_EMAIL` | For widget OTP: an inbox you can read (or watch server logs if SMTP is dev-only) |
**Gmail:** In production, inbound mail is processed by your Gmail/Pub/Sub pipeline; the **`From:`** address must match a customer with orders when you expect **T**/**X** behavior. For **offline** checks of reply generation, see [`tests/gmail-auto-reply/scenarios.ts`](../tests/gmail-auto-reply/scenarios.ts) and `generateAutoReplyPreview` in [`src/lib/gmail/autoReplyPreview.ts`](../src/lib/gmail/autoReplyPreview.ts).
**Widget:** Open the embedded page (shop is required):
`YOUR_APP/storefront-chat/widget?shop=YOUR_SHOP`
---
### 6.1 Knowledge (**K**) — no OTP required on widget
| Use case | Gmail (`Subject` / `Body`) | Storefront (type in widget) |
|----------|----------------------------|------------------------------|
| **FAQ** — policy | **Subject:** `Return policy`<br>**Body:** `How many days do I have to return an item if I change my mind?` | Same question in one message. |
| **STORE_LOCAL** | **Subject:** `Pickup`<br>**Body:** `What are your store hours and do you offer local pickup?` | Same. |
| **PRODUCT_FIT** (KB) | **Subject:** `Sizing`<br>**Body:** `Does the medium hoodie run true to size?` | Same. |
| **HUMAN_ESCALATION** | **Subject:** `Human`<br>**Body:** `I want to speak to a real person, please.` | Same. |
| **FEEDBACK** | **Subject:** `Thanks`<br>**Body:** `Thank you for the fast delivery — great experience!` | Same (short thank-you). |
---
### 6.2 Catalog metadata (**Cat**) — `CATEGORY_METADETAILS` (toggle must be **on**)
| Use case | Gmail | Storefront |
|----------|-------|------------|
| Category-level facts | **Subject:** `Materials`<br>**Body:** `What materials are used for products in your [Category Name] line?` | Same; include a **specific product or category name** your sync has metadetails for. |
If the toggle is **off**, expect fallback to KB / generic copy, not DB category tools.
---
### 6.3 Transactional (**T**) — Gmail: `From` = order email; Widget: **OTP first**
**Gmail examples** (send from the **same email** as the order in DB):
| Use case | **Subject** | **Body** |
|----------|-------------|----------|
| **ORDER_STATUS** (needs order #) | `Order status` | `Hi, can you confirm status for order #YOUR_ORDER? Thanks.` |
| **ORDER_STATUS** (no order # → disambiguation) | `Where is my package?` | `Hi, I still haven't received anything. Can you check the status for me? Thanks.` |
| **ORDER_SUMMARY** | `Receipt` | `Please send a line-item recap for order #YOUR_ORDER.` |
| **SHIPPING_TRACKING** | `Tracking` | `What carrier and tracking number do you have for order #YOUR_ORDER?` |
| **ORDER_CANCEL** | `Cancel` | `Please cancel order #YOUR_ORDER if it has not shipped yet.` |
| **REFUND_STATUS** | `Refund status` | `Has my refund for order #YOUR_ORDER been issued yet?` |
| **INVENTORY_STOCK** | `Stock check` | `Do you have SKU YOUR-SKU in stock right now?` |
**Hint mismatch (expect “not on file” behavior):**
| **Subject** | **Body** |
|-------------|----------|
| `Order 9999` | `I need help with order #9999.` |
**Storefront — transactional flow**
1. Open `YOUR_APP/storefront-chat/widget?shop=YOUR_SHOP`.
2. **Verify email:** enter `TEST_EMAIL` → **Send code** → enter the **6-digit OTP** from email/logs → **Verify**.
3. Send a **T** message, e.g.:
`Where is order #YOUR_ORDER?`
or
`Is SKU YOUR-SKU in stock?`
Without a verified session, the app may only treat you as anonymous; OTP unlocks identity-bound tools per spec.
---
### 6.4 Ticket-led (**X**) — intake + human; same identity rules
**Gmail** (from purchaser email):
| Use case | **Subject** | **Body** |
|----------|-------------|----------|
| **NOT_RECEIVED_MARKED_DELIVERED** | `Delivered not received` | `Tracking shows delivered for order #YOUR_ORDER but I never got the package.` |
| **SHIPMENT_STUCK_OR_DELAYED** | `Shipment stuck` | `Order #YOUR_ORDER — the tracking has not updated in 10 days.` |
| **PAYMENT_PROBLEM** | `Double charge` | `I see a duplicate charge on my card for order #YOUR_ORDER. Please investigate.` |
| **ORDER_CHANGE** | `Change address` | `Can I change the shipping address on order #YOUR_ORDER?` |
| **RETURN_EXCHANGE** | `Return` | `I want to start a return for order #YOUR_ORDER — wrong size.` |
| **WARRANTY** | `Warranty` | `I need a warranty claim for the item from order #YOUR_ORDER.` |
| **WRONG_ITEM** | `Wrong item` | `Order #YOUR_ORDER arrived with the wrong color — see photos attached.` |
| **COMPLAINT** | `Complaint` | `I am very unhappy with order #YOUR_ORDER and want this escalated.` |
**Storefront:** complete **OTP** (§6.3), then paste the **same body** lines as in the table.
---
### 6.5 Storefront API (`curl`) — same payloads the widget uses
After `next dev` (or your deployed URL):
**1) Post a chat message (anonymous FAQ path)**
```bash
curl -sS -X POST "YOUR_APP/api/storefront-chat/messages" \
-H "Content-Type: application/json" \
-d '{"shop":"YOUR_SHOP","visitorId":"test-visitor-1","content":"What is your return policy?"}'
```
**2) Request OTP**
```bash
curl -sS -X POST "YOUR_APP/api/storefront-chat/otp/request" \
-H "Content-Type: application/json" \
-d '{"shop":"YOUR_SHOP","visitorId":"test-visitor-1","email":"TEST_EMAIL"}'
```
**3) Verify OTP** (replace `123456`)
```bash
curl -sS -X POST "YOUR_APP/api/storefront-chat/otp/verify" \
-H "Content-Type: application/json" \
-d '{"shop":"YOUR_SHOP","visitorId":"test-visitor-1","email":"TEST_EMAIL","code":"123456"}'
```
**4) Transactional message** (same `visitorId` as steps 2–3)
```bash
curl -sS -X POST "YOUR_APP/api/storefront-chat/messages" \
-H "Content-Type: application/json" \
-d '{"shop":"YOUR_SHOP","visitorId":"test-visitor-1","content":"Where is order #YOUR_ORDER?","customerEmail":"TEST_EMAIL"}'
```
**5) Load history**
```bash
curl -sS "YOUR_APP/api/storefront-chat/messages?shop=YOUR_SHOP&visitorId=test-visitor-1"
```
---
### 6.6 Repo-automated Gmail-style scenarios
The strings in [`tests/gmail-auto-reply/scenarios.ts`](../tests/gmail-auto-reply/scenarios.ts) are curated for **`generateAutoReplyPreview`** (e.g. counter-question when no order #, order #1002 happy path, hint mismatch #9999, off-topic weather). Run:
`npm run test` (or `npx vitest run tests/gmail-auto-reply/run.test.ts` when your DB test stack is up — see `package.json` `test:gmail-auto-reply` for the full Docker pipeline).
---
*Aligned with [`docs/req-AI.md`](req-AI.md) §0–§3, §6–§7 (module questions), and §10 (implementation).*

View File

@ -0,0 +1,12 @@
-- AlterTable
ALTER TABLE "Store" ADD COLUMN IF NOT EXISTS "categoryMetadetailsEnabled" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Store" ADD COLUMN IF NOT EXISTS "aiModules" JSONB;
-- AlterTable
ALTER TABLE "StorefrontChatConversation" ADD COLUMN IF NOT EXISTS "assertedEmail" TEXT;
ALTER TABLE "StorefrontChatConversation" ADD COLUMN IF NOT EXISTS "emailVerifiedAt" TIMESTAMP(3);
ALTER TABLE "StorefrontChatConversation" ADD COLUMN IF NOT EXISTS "sessionExpiresAt" TIMESTAMP(3);
ALTER TABLE "StorefrontChatConversation" ADD COLUMN IF NOT EXISTS "boundShopifyOrderId" TEXT;
ALTER TABLE "StorefrontChatConversation" ADD COLUMN IF NOT EXISTS "otpHash" TEXT;
ALTER TABLE "StorefrontChatConversation" ADD COLUMN IF NOT EXISTS "otpExpiresAt" TIMESTAMP(3);
ALTER TABLE "StorefrontChatConversation" ADD COLUMN IF NOT EXISTS "l1State" JSONB;

View File

@ -120,6 +120,11 @@ model Store {
/** Saved structured KB answers (version, per-field text, misc). Embedded as STRUCTURED chunks. */ /** Saved structured KB answers (version, per-field text, misc). Embedded as STRUCTURED chunks. */
knowledgeTemplate Json? knowledgeTemplate Json?
/** When true, L1 may use DB-backed category/product metafield tools for customer answers. */
categoryMetadetailsEnabled Boolean @default(false)
/** Per-module AI enablement, e.g. { "FAQ": true, "ORDER_STATUS": true }. */
aiModules Json?
// Relations // Relations
connections Connection[] connections Connection[]
cachedProducts CachedProduct[] cachedProducts CachedProduct[]
@ -625,6 +630,19 @@ model StorefrontChatConversation {
visitorId String visitorId String
customerEmail String? customerEmail String?
/** Email the customer entered for OTP (asserted order email). */
assertedEmail String?
emailVerifiedAt DateTime?
/** Verified session TTL (e.g. 1h from verification). */
sessionExpiresAt DateTime?
/** After order disambiguation, pinned Shopify order id for this session. */
boundShopifyOrderId String?
/** SHA-256 hex of OTP + store-visitor salt; cleared after verify. */
otpHash String?
otpExpiresAt DateTime?
/** L1 orchestrator: phase, module, slots (JSON). */
l1State Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@ -1,5 +1,9 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import {
storeSelectStorefrontChatApi,
storeSelectStorefrontConversationSession,
} from "@/lib/prisma/storeSelects"
import { generateStorefrontReply } from "@/lib/ai/storefrontGroq" import { generateStorefrontReply } from "@/lib/ai/storefrontGroq"
import { applyRateLimit } from "@/lib/rate-limit" import { applyRateLimit } from "@/lib/rate-limit"
import { storefrontChatPostSchema } from "@/lib/api-schemas/public" import { storefrontChatPostSchema } from "@/lib/api-schemas/public"
@ -36,7 +40,6 @@ export async function POST(req: Request) {
) )
} }
const p = prisma as any
const raw = await req.json().catch(() => ({})) const raw = await req.json().catch(() => ({}))
const parsed = storefrontChatPostSchema.safeParse(raw) const parsed = storefrontChatPostSchema.safeParse(raw)
if (!parsed.success) { if (!parsed.success) {
@ -47,12 +50,13 @@ export async function POST(req: Request) {
const normalizedShop = normalizeShopDomain(shop) const normalizedShop = normalizeShopDomain(shop)
const variants = shopVariants(normalizedShop) const variants = shopVariants(normalizedShop)
const stores = await p.store.findMany({ const stores = await prisma.store.findMany({
where: { shopifyDomain: { in: variants } }, where: { shopifyDomain: { in: variants } },
select: { id: true, storefrontChatEnabled: true } select: storeSelectStorefrontChatApi,
}) })
const store = stores.find((s: any) => Boolean(s.storefrontChatEnabled)) || stores[0] const store =
stores.find((s) => Boolean(s.storefrontChatEnabled)) || stores[0]
if (!store) { if (!store) {
return NextResponse.json({ message: "Store not found" }, { status: 404 }) return NextResponse.json({ message: "Store not found" }, { status: 404 })
} }
@ -61,36 +65,36 @@ export async function POST(req: Request) {
} }
// Find or create conversation for this anonymous visitor. // Find or create conversation for this anonymous visitor.
let conversation = await p.storefrontChatConversation.findFirst({ let conversation = await prisma.storefrontChatConversation.findFirst({
where: { storeId: store.id, visitorId }, where: { storeId: store.id, visitorId },
select: { id: true } select: storeSelectStorefrontConversationSession,
}) })
if (conversation) { if (conversation) {
// Very lightweight rate limit: max 5 messages per 30 seconds per conversation. // Very lightweight rate limit: max 5 messages per 30 seconds per conversation.
const windowMs = 30_000 const windowMs = 30_000
const recentCount = await p.storefrontChatMessage.count({ const recentCount = await prisma.storefrontChatMessage.count({
where: { where: {
conversationId: conversation.id, conversationId: conversation.id,
createdAt: { gte: new Date(Date.now() - windowMs) } createdAt: { gte: new Date(Date.now() - windowMs) },
} },
}) })
if (recentCount >= 5) { if (recentCount >= 5) {
return NextResponse.json({ message: "Too many messages, slow down" }, { status: 429 }) return NextResponse.json({ message: "Too many messages, slow down" }, { status: 429 })
} }
} else { } else {
conversation = await p.storefrontChatConversation.create({ conversation = await prisma.storefrontChatConversation.create({
data: { data: {
storeId: store.id, storeId: store.id,
visitorId, visitorId,
customerEmail: customerEmail ?? null, customerEmail: customerEmail ?? null,
}, },
select: { id: true } select: storeSelectStorefrontConversationSession,
}) })
} }
const customerMessage = await p.storefrontChatMessage.create({ const customerMessage = await prisma.storefrontChatMessage.create({
data: { data: {
conversationId: conversation.id, conversationId: conversation.id,
senderType: "CUSTOMER", senderType: "CUSTOMER",
@ -99,24 +103,35 @@ export async function POST(req: Request) {
select: { id: true, createdAt: true, senderType: true, content: true } select: { id: true, createdAt: true, senderType: true, content: true }
}) })
const recentHistory = await p.storefrontChatMessage.findMany({ const recentHistory = await prisma.storefrontChatMessage.findMany({
where: { conversationId: conversation.id }, where: { conversationId: conversation.id },
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
take: 10, take: 10,
select: { senderType: true, content: true } select: { senderType: true, content: true },
}) })
const sessionOk =
Boolean(conversation.emailVerifiedAt) &&
conversation.sessionExpiresAt &&
(conversation.sessionExpiresAt as unknown as Date).getTime() > Date.now()
const ai = await generateStorefrontReply({ const ai = await generateStorefrontReply({
storeId: store.id, storeId: store.id,
storeName: store.businessName || "Our store",
query: trimmedContent, query: trimmedContent,
visitorId, visitorId,
history: recentHistory.map((m: any) => ({ history: recentHistory.map((m) => ({
senderType: m.senderType, senderType: m.senderType,
content: m.content content: m.content,
})) })),
categoryMetadetailsEnabled: store.categoryMetadetailsEnabled,
aiModulesJson: store.aiModules,
widgetVerifiedEmail: sessionOk ? conversation.assertedEmail : null,
widgetSessionExpiresAt: sessionOk ? conversation.sessionExpiresAt : null,
boundShopifyOrderId: conversation.boundShopifyOrderId,
}) })
const aiMessage = await p.storefrontChatMessage.create({ const aiMessage = await prisma.storefrontChatMessage.create({
data: { data: {
conversationId: conversation.id, conversationId: conversation.id,
senderType: "AI", senderType: "AI",
@ -138,7 +153,6 @@ export async function POST(req: Request) {
export async function GET(req: Request) { export async function GET(req: Request) {
try { try {
const p = prisma as any
const { searchParams } = new URL(req.url) const { searchParams } = new URL(req.url)
const shop = searchParams.get("shop")?.trim() || "" const shop = searchParams.get("shop")?.trim() || ""
const visitorId = searchParams.get("visitorId")?.trim() || "" const visitorId = searchParams.get("visitorId")?.trim() || ""
@ -152,12 +166,13 @@ export async function GET(req: Request) {
const normalizedShop = normalizeShopDomain(shop) const normalizedShop = normalizeShopDomain(shop)
const variants = shopVariants(normalizedShop) const variants = shopVariants(normalizedShop)
const stores = await p.store.findMany({ const stores = await prisma.store.findMany({
where: { shopifyDomain: { in: variants } }, where: { shopifyDomain: { in: variants } },
select: { id: true, storefrontChatEnabled: true } select: { id: true, storefrontChatEnabled: true },
}) })
const store = stores.find((s: any) => Boolean(s.storefrontChatEnabled)) || stores[0] const store =
stores.find((s) => Boolean(s.storefrontChatEnabled)) || stores[0]
if (!store) { if (!store) {
return NextResponse.json({ message: "Store not found" }, { status: 404 }) return NextResponse.json({ message: "Store not found" }, { status: 404 })
@ -166,16 +181,16 @@ export async function GET(req: Request) {
return NextResponse.json({ message: "Widget disabled" }, { status: 403 }) return NextResponse.json({ message: "Widget disabled" }, { status: 403 })
} }
const conversation = await p.storefrontChatConversation.findFirst({ const conversation = await prisma.storefrontChatConversation.findFirst({
where: { storeId: store.id, visitorId }, where: { storeId: store.id, visitorId },
select: { id: true } select: { id: true },
}) })
if (!conversation) { if (!conversation) {
return NextResponse.json({ success: true, messages: [] }) return NextResponse.json({ success: true, messages: [] })
} }
const messages = await p.storefrontChatMessage.findMany({ const messages = await prisma.storefrontChatMessage.findMany({
where: { conversationId: conversation.id }, where: { conversationId: conversation.id },
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
take: 120, take: 120,

View File

@ -0,0 +1,115 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
import { sendEmail } from "@/lib/email"
import { applyRateLimit } from "@/lib/rate-limit"
import { storefrontOtpRequestSchema } from "@/lib/api-schemas/public"
import { validationErrorResponse } from "@/lib/api-schemas/validate"
import {
generateSixDigitOtp,
hashStorefrontOtp,
STOREFRONT_OTP_TTL_MS,
} from "@/lib/storefront-chat/otpCrypto"
function normalizeShopDomain(input: string) {
return input
.trim()
.toLowerCase()
.replace(/^https?:\/\//i, "")
.replace(/\/$/, "")
}
function shopVariants(cleanDomain: string) {
if (!cleanDomain) return []
const v = new Set<string>()
v.add(cleanDomain)
v.add(`${cleanDomain}/`)
v.add(`https://${cleanDomain}`)
v.add(`http://${cleanDomain}`)
return Array.from(v)
}
export async function POST(req: Request) {
try {
const rate = await applyRateLimit(req, "public:storefront-otp-request", {
windowMs: 60_000,
maxRequests: 5,
})
if (!rate.success) {
return NextResponse.json(
{ message: "Too many code requests. Please try again later." },
{ status: 429 },
)
}
const raw = await req.json().catch(() => ({}))
const parsed = storefrontOtpRequestSchema.safeParse(raw)
if (!parsed.success) {
return validationErrorResponse(parsed.error)
}
const { shop, visitorId, email } = parsed.data
const normalizedShop = normalizeShopDomain(shop)
const variants = shopVariants(normalizedShop)
const stores = await prisma.store.findMany({
where: { shopifyDomain: { in: variants } },
select: { id: true, storefrontChatEnabled: true, businessName: true },
})
const store =
stores.find((s) => Boolean(s.storefrontChatEnabled)) || stores[0]
if (!store) {
return NextResponse.json({ message: "Store not found" }, { status: 404 })
}
if (!store.storefrontChatEnabled) {
return NextResponse.json({ message: "Widget disabled" }, { status: 403 })
}
const emailNorm = email.trim().toLowerCase()
const code = generateSixDigitOtp()
const otpHash = hashStorefrontOtp(code, store.id, visitorId)
const otpExpiresAt = new Date(Date.now() + STOREFRONT_OTP_TTL_MS)
await prisma.storefrontChatConversation.upsert({
where: {
storeId_visitorId: { storeId: store.id, visitorId },
},
create: {
storeId: store.id,
visitorId,
customerEmail: emailNorm,
assertedEmail: emailNorm,
otpHash,
otpExpiresAt,
},
update: {
customerEmail: emailNorm,
assertedEmail: emailNorm,
otpHash,
otpExpiresAt,
emailVerifiedAt: null,
sessionExpiresAt: null,
boundShopifyOrderId: null,
},
})
const storeLabel = store.businessName || "Store"
await sendEmail({
to: emailNorm,
subject: `Your verification code — ${storeLabel}`,
html: `
<!DOCTYPE html>
<html><body style="font-family:system-ui,sans-serif;padding:24px;background:#0b1220;color:#e5e7eb;">
<p>Your verification code is:</p>
<p style="font-size:28px;font-weight:700;letter-spacing:4px;">${code}</p>
<p style="color:#9ca3af;font-size:14px;">This code expires in 10 minutes. If you did not request this, you can ignore this email.</p>
</body></html>`,
})
return NextResponse.json({
success: true,
message: "If that email is valid, we sent a 6-digit code.",
})
} catch (error) {
console.error("[STOREFRONT_OTP_REQUEST]", error)
return NextResponse.json({ message: "Internal Error" }, { status: 500 })
}
}

View File

@ -0,0 +1,121 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
import { applyRateLimit } from "@/lib/rate-limit"
import { storefrontOtpVerifySchema } from "@/lib/api-schemas/public"
import { validationErrorResponse } from "@/lib/api-schemas/validate"
import {
hashStorefrontOtp,
STOREFRONT_SESSION_TTL_MS,
} from "@/lib/storefront-chat/otpCrypto"
function normalizeShopDomain(input: string) {
return input
.trim()
.toLowerCase()
.replace(/^https?:\/\//i, "")
.replace(/\/$/, "")
}
function shopVariants(cleanDomain: string) {
if (!cleanDomain) return []
const v = new Set<string>()
v.add(cleanDomain)
v.add(`${cleanDomain}/`)
v.add(`https://${cleanDomain}`)
v.add(`http://${cleanDomain}`)
return Array.from(v)
}
export async function POST(req: Request) {
try {
const rate = await applyRateLimit(req, "public:storefront-otp-verify", {
windowMs: 60_000,
maxRequests: 20,
})
if (!rate.success) {
return NextResponse.json(
{ message: "Too many attempts. Please try again later." },
{ status: 429 },
)
}
const raw = await req.json().catch(() => ({}))
const parsed = storefrontOtpVerifySchema.safeParse(raw)
if (!parsed.success) {
return validationErrorResponse(parsed.error)
}
const { shop, visitorId, email, code } = parsed.data
const normalizedShop = normalizeShopDomain(shop)
const variants = shopVariants(normalizedShop)
const stores = await prisma.store.findMany({
where: { shopifyDomain: { in: variants } },
select: { id: true, storefrontChatEnabled: true },
})
const store =
stores.find((s) => Boolean(s.storefrontChatEnabled)) || stores[0]
if (!store) {
return NextResponse.json({ message: "Store not found" }, { status: 404 })
}
if (!store.storefrontChatEnabled) {
return NextResponse.json({ message: "Widget disabled" }, { status: 403 })
}
const emailNorm = email.trim().toLowerCase()
const conv = await prisma.storefrontChatConversation.findUnique({
where: {
storeId_visitorId: { storeId: store.id, visitorId },
},
select: {
assertedEmail: true,
otpHash: true,
otpExpiresAt: true,
},
})
if (!conv?.otpHash || !conv.otpExpiresAt) {
return NextResponse.json(
{ message: "No active code. Request a new one." },
{ status: 400 },
)
}
if (conv.assertedEmail?.toLowerCase() !== emailNorm) {
return NextResponse.json({ message: "Email does not match." }, { status: 400 })
}
if (conv.otpExpiresAt.getTime() < Date.now()) {
return NextResponse.json(
{ message: "Code expired. Request a new one." },
{ status: 400 },
)
}
const expected = hashStorefrontOtp(code, store.id, visitorId)
if (expected !== conv.otpHash) {
return NextResponse.json({ message: "Invalid code." }, { status: 400 })
}
const sessionExpiresAt = new Date(Date.now() + STOREFRONT_SESSION_TTL_MS)
await prisma.storefrontChatConversation.update({
where: {
storeId_visitorId: { storeId: store.id, visitorId },
},
data: {
otpHash: null,
otpExpiresAt: null,
emailVerifiedAt: new Date(),
sessionExpiresAt,
assertedEmail: emailNorm,
},
})
return NextResponse.json({
success: true,
verified: true,
sessionExpiresAt: sessionExpiresAt.toISOString(),
})
} catch (error) {
console.error("[STOREFRONT_OTP_VERIFY]", error)
return NextResponse.json({ message: "Internal Error" }, { status: 500 })
}
}

View File

@ -5,6 +5,8 @@ import { logger } from "@/lib/logger"
import { import {
knowledgeTemplatePutSchema, knowledgeTemplatePutSchema,
normalizeTemplateAnswers, normalizeTemplateAnswers,
parseKnowledgeTemplateJson,
buildKnowledgeTemplateStored,
type KnowledgeTemplateStored, type KnowledgeTemplateStored,
} from "@/lib/kb/knowledgeTemplate" } from "@/lib/kb/knowledgeTemplate"
import { replaceStructuredKnowledgeChunks } from "@/lib/kb/structuredTemplateChunks" import { replaceStructuredKnowledgeChunks } from "@/lib/kb/structuredTemplateChunks"
@ -47,9 +49,11 @@ export async function GET(
}), }),
]) ])
const normalizedTemplate = parseKnowledgeTemplateJson(store.knowledgeTemplate)
return NextResponse.json({ return NextResponse.json({
knowledgeBaseMode: store.knowledgeBaseMode, knowledgeBaseMode: store.knowledgeBaseMode,
knowledgeTemplate: store.knowledgeTemplate, knowledgeTemplate: normalizedTemplate ?? store.knowledgeTemplate,
counts: { counts: {
documents: documentCount, documents: documentCount,
faqs: faqCount, faqs: faqCount,
@ -167,15 +171,36 @@ export async function PUT(
) )
} }
const answers = normalizeTemplateAnswers(body.template.answers) const prev = parseKnowledgeTemplateJson(store.knowledgeTemplate)
const misc = body.template.misc?.trim() || undefined const mergedAnswers = {
...(prev?.legacyAnswers ?? {}),
const stored: KnowledgeTemplateStored = { ...normalizeTemplateAnswers(body.template.answers),
version: 1,
answers,
misc,
updatedAt: new Date().toISOString(),
} }
const mergedShared: Record<string, string> = {}
for (const [k, v] of Object.entries(prev?.sharedFields ?? {})) {
if (typeof v === "string" && v.trim()) {
mergedShared[k] = v.trim()
}
}
if (body.template.sharedFields !== undefined) {
for (const [k, v] of Object.entries(body.template.sharedFields)) {
if (typeof v === "string" && v.trim()) {
mergedShared[k] = v.trim()
} else {
delete mergedShared[k]
}
}
}
const misc =
body.template.misc !== undefined
? body.template.misc.trim() || undefined
: prev?.misc
const stored: KnowledgeTemplateStored = buildKnowledgeTemplateStored({
answers: mergedAnswers as Record<string, string>,
sharedFields: mergedShared as Record<string, string>,
misc,
})
const nextMode: KnowledgeBaseMode = const nextMode: KnowledgeBaseMode =
store.knowledgeBaseMode === "UNSET" ? "STRUCTURED" : store.knowledgeBaseMode store.knowledgeBaseMode === "UNSET" ? "STRUCTURED" : store.knowledgeBaseMode
@ -202,7 +227,7 @@ export async function PUT(
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
knowledgeBaseMode: refreshed?.knowledgeBaseMode, knowledgeBaseMode: refreshed?.knowledgeBaseMode,
knowledgeTemplate: refreshed?.knowledgeTemplate, knowledgeTemplate: parseKnowledgeTemplateJson(refreshed?.knowledgeTemplate),
}) })
} catch (error) { } catch (error) {
logger.error("PUT knowledge-template error", error) logger.error("PUT knowledge-template error", error)

View File

@ -1,6 +1,8 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { auth } from "@/auth" import { auth } from "@/auth"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { Prisma } from "@prisma/client"
import { storeSelectPatchStorefrontAi } from "@/lib/prisma/storeSelects"
import { import {
getPublishedThemeId, getPublishedThemeId,
getThemeAsset, getThemeAsset,
@ -559,24 +561,31 @@ export async function PATCH(
const nextFont = sanitizeFont(data.storefrontChatFontFamily ?? store.storefrontChatFontFamily) const nextFont = sanitizeFont(data.storefrontChatFontFamily ?? store.storefrontChatFontFamily)
const nextLogoUrl = sanitizeLogoUrl(data.storefrontChatLogoUrl ?? store.storefrontChatLogoUrl) const nextLogoUrl = sanitizeLogoUrl(data.storefrontChatLogoUrl ?? store.storefrontChatLogoUrl)
const updatePayload: Prisma.StoreUpdateInput = {
storefrontChatEnabled: nextEnabled,
storefrontChatName: nextChatName,
storefrontChatTagline: nextTagline,
storefrontChatBrandColor: nextBrandColor,
storefrontChatFontFamily: nextFont,
storefrontChatLogoUrl: nextLogoUrl,
}
if (typeof data.categoryMetadetailsEnabled === "boolean") {
updatePayload.categoryMetadetailsEnabled = data.categoryMetadetailsEnabled
}
if (
data.aiModules !== undefined &&
typeof data.aiModules === "object" &&
data.aiModules !== null &&
!Array.isArray(data.aiModules)
) {
updatePayload.aiModules = data.aiModules as Prisma.InputJsonValue
}
const updatedStore = await prisma.store.update({ const updatedStore = await prisma.store.update({
where: { id: storeId }, where: { id: storeId },
data: { data: updatePayload,
storefrontChatEnabled: nextEnabled, select: storeSelectPatchStorefrontAi,
storefrontChatName: nextChatName,
storefrontChatTagline: nextTagline,
storefrontChatBrandColor: nextBrandColor,
storefrontChatFontFamily: nextFont,
storefrontChatLogoUrl: nextLogoUrl,
},
select: {
storefrontChatEnabled: true,
storefrontChatName: true,
storefrontChatTagline: true,
storefrontChatBrandColor: true,
storefrontChatFontFamily: true,
storefrontChatLogoUrl: true,
}
}) })
// Explicit behavior change: // Explicit behavior change:
@ -608,6 +617,8 @@ export async function PATCH(
storefrontChatBrandColor: updatedStore.storefrontChatBrandColor, storefrontChatBrandColor: updatedStore.storefrontChatBrandColor,
storefrontChatFontFamily: updatedStore.storefrontChatFontFamily, storefrontChatFontFamily: updatedStore.storefrontChatFontFamily,
storefrontChatLogoUrl: updatedStore.storefrontChatLogoUrl, storefrontChatLogoUrl: updatedStore.storefrontChatLogoUrl,
categoryMetadetailsEnabled: updatedStore.categoryMetadetailsEnabled,
aiModules: updatedStore.aiModules,
}) })
} catch (error) { } catch (error) {
console.error("Error updating store settings:", error) console.error("Error updating store settings:", error)

View File

@ -1,8 +1,10 @@
"use server" "use server"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { storeSelectDashboardStorefrontInbox } from "@/lib/prisma/storeSelects"
import { auth } from "@/auth.vendor" import { auth } from "@/auth.vendor"
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
import { StorefrontAiSettingsCard } from "@/components/storefront/StorefrontAiSettingsCard"
export default async function StorefrontChatInboxPage() { export default async function StorefrontChatInboxPage() {
const session = await auth() const session = await auth()
@ -11,11 +13,9 @@ export default async function StorefrontChatInboxPage() {
redirect("/login") redirect("/login")
} }
const p = prisma as any
const stores = await prisma.store.findMany({ const stores = await prisma.store.findMany({
where: { userId: session.user.id, onboardingComplete: true }, where: { userId: session.user.id, onboardingComplete: true },
select: { id: true, businessName: true, shopifyDomain: true, isActive: true } select: storeSelectDashboardStorefrontInbox,
}) })
const store = stores.find((s) => s.isActive) ?? stores[0] const store = stores.find((s) => s.isActive) ?? stores[0]
@ -24,7 +24,7 @@ export default async function StorefrontChatInboxPage() {
redirect("/onboarding") redirect("/onboarding")
} }
const conversations = await p.storefrontChatConversation.findMany({ const conversations = await prisma.storefrontChatConversation.findMany({
where: { storeId: store.id }, where: { storeId: store.id },
orderBy: { updatedAt: "desc" }, orderBy: { updatedAt: "desc" },
take: 25, take: 25,
@ -33,7 +33,7 @@ export default async function StorefrontChatInboxPage() {
const conversationIds = conversations.map((c: { id: string }) => c.id) const conversationIds = conversations.map((c: { id: string }) => c.id)
const messages = await p.storefrontChatMessage.findMany({ const messages = await prisma.storefrontChatMessage.findMany({
where: conversationIds.length ? { conversationId: { in: conversationIds } } : undefined, where: conversationIds.length ? { conversationId: { in: conversationIds } } : undefined,
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
take: 200, take: 200,
@ -73,6 +73,11 @@ export default async function StorefrontChatInboxPage() {
</div> </div>
</div> </div>
<StorefrontAiSettingsCard
storeId={store.id}
initialCategoryMetadetailsEnabled={store.categoryMetadetailsEnabled}
/>
{sorted.length === 0 ? ( {sorted.length === 0 ? (
<div className="bg-white/5 border border-white/10 rounded-2xl p-6 text-slate-300"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6 text-slate-300">
<p className="font-black">No storefront chat messages yet.</p> <p className="font-black">No storefront chat messages yet.</p>

View File

@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { MessageSquare, Send } from "lucide-react" import { Send } from "lucide-react"
type WidgetMessage = { type WidgetMessage = {
id: string id: string
@ -34,6 +34,13 @@ export default function StorefrontChatWidgetPage() {
const [sendError, setSendError] = useState<string | null>(null) const [sendError, setSendError] = useState<string | null>(null)
const [loadingHistory, setLoadingHistory] = useState(false) const [loadingHistory, setLoadingHistory] = useState(false)
const [verifyEmail, setVerifyEmail] = useState("")
const [otpCode, setOtpCode] = useState("")
const [otpSending, setOtpSending] = useState(false)
const [otpBusy, setOtpBusy] = useState(false)
const [otpMessage, setOtpMessage] = useState<string | null>(null)
const [sessionUntil, setSessionUntil] = useState<string | null>(null)
const composerRef = useRef<HTMLTextAreaElement | null>(null) const composerRef = useRef<HTMLTextAreaElement | null>(null)
const messagesEndRef = useRef<HTMLDivElement | null>(null) const messagesEndRef = useRef<HTMLDivElement | null>(null)
@ -122,6 +129,65 @@ export default function StorefrontChatWidgetPage() {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages]) }, [messages])
const sendOtp = async () => {
if (!shop || !visitorId) return
const email = verifyEmail.trim()
if (!email) {
setOtpMessage("Enter your email first.")
return
}
try {
setOtpMessage(null)
setOtpSending(true)
const res = await fetch("/api/storefront-chat/otp/request", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ shop, visitorId, email }),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(data?.message || "Could not send code")
}
setOtpMessage("Check your inbox for a 6-digit code.")
} catch (e: unknown) {
setOtpMessage(e instanceof Error ? e.message : "Failed to send code")
} finally {
setOtpSending(false)
}
}
const verifyOtp = async () => {
if (!shop || !visitorId) return
const email = verifyEmail.trim()
const code = otpCode.trim()
if (!email || !/^\d{6}$/.test(code)) {
setOtpMessage("Enter email and 6-digit code.")
return
}
try {
setOtpMessage(null)
setOtpBusy(true)
const res = await fetch("/api/storefront-chat/otp/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ shop, visitorId, email, code }),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(data?.message || "Invalid code")
}
setSessionUntil(
typeof data.sessionExpiresAt === "string" ? data.sessionExpiresAt : null,
)
setOtpCode("")
setOtpMessage("Verified. You can ask about your orders for about one hour.")
} catch (e: unknown) {
setOtpMessage(e instanceof Error ? e.message : "Verification failed")
} finally {
setOtpBusy(false)
}
}
const send = async () => { const send = async () => {
if (!shop || !visitorId) return if (!shop || !visitorId) return
const text = composer.trim() const text = composer.trim()
@ -222,7 +288,64 @@ export default function StorefrontChatWidgetPage() {
)} )}
</div> </div>
<div className="p-3 border-t border-slate-200 bg-white flex-shrink-0"> <div className="p-3 border-t border-slate-200 bg-white flex-shrink-0 space-y-3">
<details className="rounded-xl border border-slate-200 bg-slate-50/80 px-3 py-2">
<summary className="cursor-pointer text-xs font-bold text-slate-700">
Order help — verify your email
</summary>
<div className="mt-2 space-y-2">
<p className="text-[11px] text-slate-500">
For order status and account-specific questions, enter the email you used
at checkout. We&apos;ll send a one-time code.
</p>
<input
type="email"
value={verifyEmail}
onChange={(e) => setVerifyEmail(e.target.value)}
placeholder="you@example.com"
className="w-full border border-slate-200 rounded-lg px-2 py-1.5 text-sm"
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => void sendOtp()}
disabled={otpSending}
className="text-xs font-bold px-3 py-1.5 rounded-lg bg-slate-800 text-white disabled:opacity-50"
>
{otpSending ? "Sending…" : "Send code"}
</button>
</div>
<div className="flex gap-2 items-center">
<input
type="text"
inputMode="numeric"
maxLength={6}
value={otpCode}
onChange={(e) =>
setOtpCode(e.target.value.replace(/\D/g, "").slice(0, 6))
}
placeholder="000000"
className="w-28 border border-slate-200 rounded-lg px-2 py-1.5 text-sm tracking-widest"
/>
<button
type="button"
onClick={() => void verifyOtp()}
disabled={otpBusy}
className="text-xs font-bold px-3 py-1.5 rounded-lg bg-indigo-600 text-white disabled:opacity-50"
>
{otpBusy ? "…" : "Verify"}
</button>
</div>
{otpMessage ? (
<p className="text-[11px] text-slate-600">{otpMessage}</p>
) : null}
{sessionUntil ? (
<p className="text-[10px] text-slate-400">
Session active until {new Date(sessionUntil).toLocaleString()}
</p>
) : null}
</div>
</details>
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<textarea <textarea
ref={composerRef} ref={composerRef}

View File

@ -9,6 +9,7 @@ import {
type KnowledgeTemplateFieldKey, type KnowledgeTemplateFieldKey,
type KnowledgeTemplateStored, type KnowledgeTemplateStored,
} from "@/lib/kb/knowledgeTemplate" } from "@/lib/kb/knowledgeTemplate"
import { sharedFieldsByGroup } from "@/lib/kb/sharedFieldCatalog"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Loader2, Save, X } from "lucide-react" import { Loader2, Save, X } from "lucide-react"
@ -51,6 +52,7 @@ export function StructuredKnowledgeModal({
), ),
) )
const [misc, setMisc] = useState("") const [misc, setMisc] = useState("")
const [sharedFields, setSharedFields] = useState<Record<string, string>>({})
const loadForm = useCallback(async () => { const loadForm = useCallback(async () => {
const res = await fetch( const res = await fetch(
@ -64,12 +66,21 @@ export function StructuredKnowledgeModal({
setAnswers((prev) => { setAnswers((prev) => {
const next = { ...prev } const next = { ...prev }
for (const f of KNOWLEDGE_TEMPLATE_FIELDS) { for (const f of KNOWLEDGE_TEMPLATE_FIELDS) {
const v = tmpl?.answers?.[f.key] const v = tmpl?.legacyAnswers?.[f.key]
next[f.key] = typeof v === "string" ? v : "" next[f.key] = typeof v === "string" ? v : ""
} }
return next return next
}) })
setMisc(typeof tmpl?.misc === "string" ? tmpl.misc : "") setMisc(typeof tmpl?.misc === "string" ? tmpl.misc : "")
if (tmpl?.sharedFields && typeof tmpl.sharedFields === "object") {
const sf: Record<string, string> = {}
for (const [k, v] of Object.entries(tmpl.sharedFields)) {
if (typeof v === "string") sf[k] = v
}
setSharedFields(sf)
} else {
setSharedFields({})
}
}, [storeId]) }, [storeId])
useEffect(() => { useEffect(() => {
@ -121,6 +132,7 @@ export function StructuredKnowledgeModal({
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
template: { template: {
answers, answers,
sharedFields,
misc: misc.trim() || undefined, misc: misc.trim() || undefined,
}, },
} }
@ -307,6 +319,97 @@ export function StructuredKnowledgeModal({
</div> </div>
))} ))}
<div
className={cn(
"rounded-xl border p-4 space-y-4",
isLight
? "border-slate-200 bg-slate-50/80"
: "border-white/10 bg-white/[0.03]",
)}
>
<div>
<p
className={cn(
"text-sm font-bold",
isLight ? "text-slate-900" : "text-white",
)}
>
Extended policy fields (AI automation)
</p>
<p
className={cn(
"text-[11px] mt-1",
isLight ? "text-slate-500" : "text-slate-500",
)}
>
Optional detailed answers mapped to support playbooks. Same
fields can power multiple modules.
</p>
</div>
{Object.entries(sharedFieldsByGroup()).map(([group, fields]) => {
if (!fields.length) return null
return (
<details
key={group}
className={cn(
"rounded-lg border overflow-hidden",
isLight
? "border-slate-200 bg-white"
: "border-white/10 bg-slate-950/40",
)}
>
<summary
className={cn(
"cursor-pointer px-3 py-2 text-xs font-black uppercase tracking-widest",
isLight
? "text-slate-700 bg-slate-100"
: "text-slate-300 bg-white/5",
)}
>
{group.replace(/_/g, " ")}
</summary>
<div className="p-3 space-y-4">
{fields.map((field) => (
<div key={field.id}>
<label
className={cn(
"block text-xs font-semibold",
isLight
? "text-slate-800"
: "text-slate-200",
)}
>
{field.label}
</label>
<textarea
value={sharedFields[field.id] ?? ""}
onChange={(e) =>
setSharedFields((prev) => ({
...prev,
[field.id]: e.target.value.slice(
0,
KNOWLEDGE_TEMPLATE_ANSWER_MAX,
),
}))
}
placeholder={field.placeholder}
rows={2}
maxLength={KNOWLEDGE_TEMPLATE_ANSWER_MAX}
className={cn(
"mt-1 w-full px-3 py-2 rounded-lg border text-sm resize-y min-h-[56px] focus:outline-none focus:ring-2 focus:ring-indigo-500/30",
isLight
? "bg-white border-slate-200 text-slate-900"
: "bg-slate-950/50 border-white/10 text-white placeholder:text-slate-500",
)}
/>
</div>
))}
</div>
</details>
)
})}
</div>
<div> <div>
<label <label
className={cn( className={cn(

View File

@ -0,0 +1,73 @@
"use client"
import { useState } from "react"
import { cn } from "@/lib/utils"
import { Loader2 } from "lucide-react"
type Props = {
storeId: string
initialCategoryMetadetailsEnabled: boolean
isLight?: boolean
}
export function StorefrontAiSettingsCard({
storeId,
initialCategoryMetadetailsEnabled,
isLight = false,
}: Props) {
const [enabled, setEnabled] = useState(initialCategoryMetadetailsEnabled)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<string | null>(null)
async function save(next: boolean) {
setSaving(true)
setMessage(null)
try {
const res = await fetch(`/api/stores/${encodeURIComponent(storeId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ categoryMetadetailsEnabled: next }),
})
const j = (await res.json().catch(() => ({}))) as { message?: string }
if (!res.ok) throw new Error(j.message || "Failed to save")
setEnabled(next)
setMessage("Saved.")
} catch (e) {
setMessage(e instanceof Error ? e.message : "Failed to save")
} finally {
setSaving(false)
}
}
return (
<div
className={cn(
"rounded-2xl border p-4",
isLight
? "border-slate-200 bg-white"
: "border-white/10 bg-white/5",
)}
>
<p className="text-sm font-black text-white">L1 AI — catalog metafields</p>
<p className="text-xs text-slate-400 mt-1">
When enabled, the storefront assistant may use synced product/category metafields
from Shopify for factual answers. When off, it falls back to your knowledge base
only.
</p>
<label className="mt-3 flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={enabled}
disabled={saving}
onChange={(e) => void save(e.target.checked)}
className="rounded border-slate-500"
/>
<span className="text-sm text-slate-200">Use category / product metafields in AI</span>
{saving ? <Loader2 className="w-4 h-4 animate-spin text-slate-400" /> : null}
</label>
{message ? (
<p className="text-xs mt-2 text-slate-500">{message}</p>
) : null}
</div>
)
}

View File

@ -0,0 +1,40 @@
import type { L1Module } from "@/lib/ai/orchestrator/types"
const DEFAULT_ENABLED: Record<L1Module, boolean> = {
FAQ: true,
ORDER_STATUS: true,
ORDER_SUMMARY: true,
SHIPPING_TRACKING: true,
INVENTORY_STOCK: true,
CATEGORY_METADETAILS: true,
PRODUCT_FIT: true,
RETURN_EXCHANGE: true,
ORDER_CHANGE: true,
COMPLAINT: true,
NOT_RECEIVED_MARKED_DELIVERED: true,
SHIPMENT_STUCK_OR_DELAYED: true,
PAYMENT_PROBLEM: true,
UNKNOWN: true,
}
export function parseAiModulesJson(raw: unknown): Record<string, boolean> {
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
return { ...DEFAULT_ENABLED }
}
const o = raw as Record<string, unknown>
const out: Record<string, boolean> = { ...DEFAULT_ENABLED }
for (const k of Object.keys(DEFAULT_ENABLED) as L1Module[]) {
if (k in o && typeof o[k] === "boolean") {
out[k] = o[k] as boolean
}
}
return out
}
export function isModuleEnabled(
modules: Record<string, boolean>,
module: L1Module,
): boolean {
if (module === "UNKNOWN") return true
return modules[module] !== false
}

View File

@ -0,0 +1,80 @@
import { groqChatCompletion } from "@/lib/ai/groqClient"
import { logger } from "@/lib/logger"
import type { L1Module } from "@/lib/ai/orchestrator/types"
const MODULES: L1Module[] = [
"FAQ",
"ORDER_STATUS",
"ORDER_SUMMARY",
"SHIPPING_TRACKING",
"INVENTORY_STOCK",
"CATEGORY_METADETAILS",
"PRODUCT_FIT",
"RETURN_EXCHANGE",
"ORDER_CHANGE",
"COMPLAINT",
"NOT_RECEIVED_MARKED_DELIVERED",
"SHIPMENT_STUCK_OR_DELAYED",
"PAYMENT_PROBLEM",
"UNKNOWN",
]
const ROUTER_PROMPT = `You route ecommerce customer messages to exactly one module.
Modules:
- FAQ: general policies, how shipping works, refund policy (not status), hours, contact.
- ORDER_STATUS: where is my order, processing time, not shipped yet.
- ORDER_SUMMARY: receipt, what did I order, line items recap.
- SHIPPING_TRACKING: tracking number, carrier, delivered, ETA.
- INVENTORY_STOCK: is item in stock, availability.
- CATEGORY_METADETAILS: materials, voltage, category-level specs from catalog.
- PRODUCT_FIT: sizing, fit, compatibility (may overlap FAQ).
- RETURN_EXCHANGE: start a return or exchange.
- ORDER_CHANGE: change address/items/qty.
- COMPLAINT: strong dissatisfaction.
- NOT_RECEIVED_MARKED_DELIVERED: tracking says delivered but customer did not receive.
- SHIPMENT_STUCK_OR_DELAYED: package not moving / very late.
- PAYMENT_PROBLEM: double charge, wrong amount, promo failed.
- UNKNOWN: does not fit above.
Return JSON only: {"module":"<ONE_FROM_LIST>","missing_slots":["order_ref"|"product_query"|"reason"|"issue"]}
Use missing_slots: order_ref if order # would help; product_query if product name/SKU needed.`
export type RoutedIntent = {
module: L1Module
missing_slots: string[]
}
function parseModule(s: string | undefined): L1Module {
const t = (s || "").trim().toUpperCase()
const found = MODULES.find((m) => m === t)
return found ?? "UNKNOWN"
}
export async function routeCustomerIntent(userMessage: string): Promise<RoutedIntent> {
const { content, error } = await groqChatCompletion(
[
{ role: "system", content: ROUTER_PROMPT },
{ role: "user", content: userMessage.slice(0, 4000) },
],
{ jsonObject: true, temperature: 0.1, maxTokens: 256 },
)
if (!content) {
logger.warn("intentRouter: groq failed", { error })
return { module: "FAQ", missing_slots: [] }
}
try {
const j = JSON.parse(content) as { module?: string; missing_slots?: string[] }
const module = parseModule(j.module)
const slots = Array.isArray(j.missing_slots)
? j.missing_slots.filter((x) => typeof x === "string")
: []
return { module, missing_slots: slots }
} catch {
logger.warn("intentRouter: invalid JSON", { preview: content.slice(0, 120) })
return { module: "FAQ", missing_slots: [] }
}
}

View File

@ -0,0 +1,28 @@
import type { L1Module, L1Pillar } from "@/lib/ai/orchestrator/types"
export type ModuleManifest = {
pillar: L1Pillar
/** Slots still needed beyond verification email. */
typicalSlots: string[]
}
export const MODULE_MANIFESTS: Record<L1Module, ModuleManifest> = {
FAQ: { pillar: "K", typicalSlots: [] },
ORDER_STATUS: { pillar: "T", typicalSlots: ["order_ref"] },
ORDER_SUMMARY: { pillar: "T", typicalSlots: ["order_ref"] },
SHIPPING_TRACKING: { pillar: "T", typicalSlots: ["order_ref"] },
INVENTORY_STOCK: { pillar: "T", typicalSlots: ["product_query"] },
CATEGORY_METADETAILS: { pillar: "CAT", typicalSlots: ["product_query"] },
PRODUCT_FIT: { pillar: "K", typicalSlots: ["product_query"] },
RETURN_EXCHANGE: { pillar: "X", typicalSlots: ["order_ref", "reason"] },
ORDER_CHANGE: { pillar: "X", typicalSlots: ["order_ref", "change_request"] },
COMPLAINT: { pillar: "X", typicalSlots: ["issue"] },
NOT_RECEIVED_MARKED_DELIVERED: { pillar: "X", typicalSlots: ["order_ref"] },
SHIPMENT_STUCK_OR_DELAYED: { pillar: "X", typicalSlots: ["order_ref"] },
PAYMENT_PROBLEM: { pillar: "X", typicalSlots: ["order_ref", "issue"] },
UNKNOWN: { pillar: "K", typicalSlots: [] },
}
export function pillarForModule(module: L1Module): L1Pillar {
return MODULE_MANIFESTS[module]?.pillar ?? "K"
}

View File

@ -0,0 +1,356 @@
import { groqChatCompletion } from "@/lib/ai/groqClient"
import { logger } from "@/lib/logger"
import { getBlendedStorefrontContext } from "@/lib/storefront-chat/retrieval"
import { extractOrderNumberHints } from "@/lib/ai/gmailContext"
import {
formatOrderCandidatesForPrompt,
getOrderDetailForVerifiedEmail,
listOrdersForCustomerEmail,
orderNeedsDisambiguation,
} from "@/lib/ai/tools/orderTools"
import {
getProductMetafieldsForL1,
searchProductsForStore,
} from "@/lib/ai/tools/productTools"
import { isModuleEnabled, parseAiModulesJson } from "@/lib/ai/orchestrator/aiModules"
import { pillarForModule } from "@/lib/ai/orchestrator/manifests"
import { routeCustomerIntent } from "@/lib/ai/orchestrator/intentRouter"
import type { L1Channel, L1Module, L1RunMeta } from "@/lib/ai/orchestrator/types"
const GENERIC_NO_ORDER_EMAIL =
"We could not find a recent order associated with this email address. If you placed an order, please contact us from the same email you used at checkout, or reply with your order number from your confirmation email."
const WIDGET_VERIFY_PROMPT =
"To look up order details or account-specific information, please enter the email you used when placing your order. We will send you a one-time code to verify."
function isWidgetSessionValid(
verifiedEmail: string | null | undefined,
expiresAt: Date | null | undefined,
): boolean {
if (!verifiedEmail?.trim()) return false
if (!expiresAt) return false
return expiresAt.getTime() > Date.now()
}
export type RunL1Input = {
channel: L1Channel
storeId: string
storeName: string
message: string
history: Array<{ senderType?: "CUSTOMER" | "AI"; role?: string; content: string }>
categoryMetadetailsEnabled: boolean
aiModulesJson: unknown
emailFrom?: string | null
widgetVerifiedEmail?: string | null
widgetSessionExpiresAt?: Date | null
boundShopifyOrderId?: string | null
}
export type RunL1Result = {
text: string
meta: L1RunMeta
}
function historyToGroq(
history: RunL1Input["history"],
): { role: "user" | "assistant"; content: string }[] {
return history.slice(-8).map((m) => {
if (m.senderType === "AI" || m.role === "assistant") {
return { role: "assistant" as const, content: m.content }
}
return { role: "user" as const, content: m.content }
})
}
async function composeReply(params: {
storeName: string
facts: string
message: string
history: { role: "user" | "assistant"; content: string }[]
}): Promise<string> {
const system = [
`You are the customer support assistant for ${params.storeName}.`,
"Use ONLY information in the Facts section. If Facts are insufficient, say what is missing in one short sentence.",
"Do not invent order numbers, tracking URLs, prices, or inventory counts not present in Facts.",
"Be concise, friendly, and plain text (no markdown).",
].join(" ")
const user = [`Facts:\n${params.facts}`, "", `Latest message:\n${params.message}`].join(
"\n",
)
const messages: Parameters<typeof groqChatCompletion>[0] = [
{ role: "system", content: system },
...params.history,
{ role: "user", content: user },
]
const out = await groqChatCompletion(messages, {
temperature: 0.2,
maxTokens: 500,
timeoutMs: 28_000,
})
const text = (out.content || "").trim()
if (!text) {
return "Thanks for your message. Our team will get back to you shortly."
}
return text
}
function ticketHandoffText(module: L1Module, storeName: string): string {
return [
`Thanks for reaching out to ${storeName}.`,
"I've passed your request to our team with the details you shared. A human will follow up by email with next steps.",
"If you have not already, please include your order number and a brief description of the issue in your reply.",
].join(" ")
}
export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result> {
const modules = parseAiModulesJson(input.aiModulesJson)
const retrieval = await getBlendedStorefrontContext({
storeId: input.storeId,
query: input.message,
})
const kbContext =
retrieval.items.length > 0
? retrieval.items
.map((i, idx) => `[${idx + 1}] (${i.source}) ${i.title}: ${i.content}`)
.join("\n")
: ""
const routed = await routeCustomerIntent(input.message)
let module: L1Module = routed.module
if (!isModuleEnabled(modules, module)) {
module = "FAQ"
}
const pillar = pillarForModule(module)
const usedTools: string[] = []
let customerEmail: string | null = null
if (input.channel === "email") {
customerEmail = input.emailFrom?.trim().toLowerCase() || null
} else {
if (isWidgetSessionValid(input.widgetVerifiedEmail, input.widgetSessionExpiresAt)) {
customerEmail = input.widgetVerifiedEmail!.trim().toLowerCase()
}
}
const needsFiber =
pillar === "T" || pillar === "X" || module === "CATEGORY_METADETAILS"
if (input.channel === "widget" && needsFiber && !customerEmail) {
return {
text: WIDGET_VERIFY_PROMPT,
meta: {
module,
pillar,
usedTools: [],
kbHits: retrieval.kbCount,
productHits: retrieval.productCount,
},
}
}
if (
(pillar === "T" || pillar === "X") &&
input.channel === "email" &&
!customerEmail
) {
return {
text: GENERIC_NO_ORDER_EMAIL,
meta: {
module,
pillar,
usedTools,
kbHits: retrieval.kbCount,
productHits: retrieval.productCount,
},
}
}
const ordersForEmail = customerEmail
? await listOrdersForCustomerEmail(input.storeId, customerEmail)
: []
if (
(pillar === "T" || pillar === "X") &&
ordersForEmail.length === 0 &&
customerEmail
) {
const facts = [
kbContext ? `Knowledge:\n${kbContext}` : "",
"No orders on file for this email in our cache.",
]
.filter(Boolean)
.join("\n\n")
if (pillar === "X") {
return {
text: ticketHandoffText(module, input.storeName),
meta: {
module,
pillar,
usedTools: ["ticket_intake"],
kbHits: retrieval.kbCount,
productHits: retrieval.productCount,
},
}
}
const text = await composeReply({
storeName: input.storeName,
facts,
message: input.message,
history: historyToGroq(input.history),
})
return {
text,
meta: {
module,
pillar,
usedTools,
kbHits: retrieval.kbCount,
productHits: retrieval.productCount,
},
}
}
if (pillar === "X" && ordersForEmail.length > 0) {
return {
text: ticketHandoffText(module, input.storeName),
meta: {
module,
pillar,
usedTools: ["ticket_intake"],
kbHits: retrieval.kbCount,
productHits: retrieval.productCount,
},
}
}
const hints = extractOrderNumberHints(input.message)
const hintText =
input.channel === "email"
? `${input.message}\n${(input.history || []).map((h) => h.content).join("\n")}`
: input.message
const disambiguate =
customerEmail &&
orderNeedsDisambiguation(customerEmail, hintText, ordersForEmail)
if (
(module === "ORDER_STATUS" ||
module === "ORDER_SUMMARY" ||
module === "SHIPPING_TRACKING") &&
customerEmail &&
disambiguate &&
!input.boundShopifyOrderId
) {
const list = formatOrderCandidatesForPrompt(ordersForEmail)
return {
text: `We found more than one order for your email. Which order do you mean?\n${list}\n\nReply with the order number (e.g. #1002).`,
meta: {
module,
pillar,
usedTools: ["list_orders"],
kbHits: retrieval.kbCount,
productHits: retrieval.productCount,
},
}
}
let orderFacts = ""
if (
customerEmail &&
(module === "ORDER_STATUS" ||
module === "ORDER_SUMMARY" ||
module === "SHIPPING_TRACKING")
) {
const detail = await getOrderDetailForVerifiedEmail(input.storeId, customerEmail, {
shopifyOrderId: input.boundShopifyOrderId ?? undefined,
orderNumberHint: hints[0],
})
usedTools.push("get_order_detail")
if (detail) {
orderFacts = [
`Order ${detail.orderNumber} (${detail.shopifyOrderId})`,
`Created: ${detail.shopifyCreatedAt ?? "unknown"}`,
`Financial: ${detail.financialStatus ?? "unknown"}`,
`Fulfillment: ${detail.fulfillmentStatus ?? "unknown"}`,
`Total: ${detail.totalPrice} ${detail.currency ?? ""}`,
detail.trackingNumber || detail.trackingUrl
? `Tracking: ${detail.trackingCompany ?? ""} ${detail.trackingNumber ?? ""} ${detail.trackingUrl ?? ""}`.trim()
: "Tracking: not available in our cache yet.",
`Line items (JSON): ${JSON.stringify(detail.lineItems).slice(0, 4000)}`,
].join("\n")
} else {
orderFacts =
"Order detail could not be loaded (missing order reference or no match for this email)."
}
}
let productFacts = ""
if (
module === "INVENTORY_STOCK" ||
module === "CATEGORY_METADETAILS" ||
module === "PRODUCT_FIT"
) {
const hits = await searchProductsForStore(input.storeId, input.message, 5)
usedTools.push("search_products")
if (hits.length) {
productFacts = hits
.map(
(h) =>
`- ${h.title} (SKU ${h.sku ?? "n/a"}) | ${h.inventorySummary ?? "inventory n/a"}${h.metafieldsSummary ? ` | ${h.metafieldsSummary}` : ""}`,
)
.join("\n")
}
if (module === "CATEGORY_METADETAILS" && input.categoryMetadetailsEnabled && hits[0]) {
const mf = await getProductMetafieldsForL1(
input.storeId,
hits[0].shopifyProductId,
true,
)
usedTools.push("product_metafields")
if (mf.metafieldsSummary) {
productFacts += `\n\nMetafield details (category/catalog):\n${mf.metafieldsSummary}`
}
} else if (module === "CATEGORY_METADETAILS" && !input.categoryMetadetailsEnabled) {
productFacts +=
"\n\n(Store has category metadetails disabled — use KB only for category facts.)"
}
}
const facts = [
kbContext ? `Knowledge:\n${kbContext}` : "",
orderFacts ? `Order data:\n${orderFacts}` : "",
productFacts ? `Product search:\n${productFacts}` : "",
]
.filter(Boolean)
.join("\n\n")
const text = await composeReply({
storeName: input.storeName,
facts: facts || "No knowledge base or order context was retrieved.",
message: input.message,
history: historyToGroq(input.history),
})
logger.info("[L1_ORCHESTRATOR]", {
storeId: input.storeId,
channel: input.channel,
module,
pillar,
usedTools,
})
return {
text,
meta: {
module,
pillar,
usedTools,
kbHits: retrieval.kbCount,
productHits: retrieval.productCount,
},
}
}

View File

@ -0,0 +1,27 @@
export type L1Channel = "widget" | "email"
export type L1Pillar = "K" | "T" | "X" | "CAT"
export type L1Module =
| "FAQ"
| "ORDER_STATUS"
| "ORDER_SUMMARY"
| "SHIPPING_TRACKING"
| "INVENTORY_STOCK"
| "CATEGORY_METADETAILS"
| "PRODUCT_FIT"
| "RETURN_EXCHANGE"
| "ORDER_CHANGE"
| "COMPLAINT"
| "NOT_RECEIVED_MARKED_DELIVERED"
| "SHIPMENT_STUCK_OR_DELAYED"
| "PAYMENT_PROBLEM"
| "UNKNOWN"
export type L1RunMeta = {
module: L1Module
pillar: L1Pillar
usedTools: string[]
kbHits?: number
productHits?: number
}

View File

@ -1,6 +1,5 @@
import { logger } from "@/lib/logger" import { logger } from "@/lib/logger"
import { groqChatCompletion, type GroqChatMessage } from "@/lib/ai/groqClient" import { runL1Orchestrator } from "@/lib/ai/orchestrator/runL1"
import { getBlendedStorefrontContext } from "@/lib/storefront-chat/retrieval"
type HistoryMessage = { type HistoryMessage = {
senderType: "CUSTOMER" | "AI" senderType: "CUSTOMER" | "AI"
@ -9,78 +8,63 @@ type HistoryMessage = {
export async function generateStorefrontReply(params: { export async function generateStorefrontReply(params: {
storeId: string storeId: string
storeName: string
query: string query: string
visitorId: string visitorId: string
history: HistoryMessage[] history: HistoryMessage[]
categoryMetadetailsEnabled: boolean
aiModulesJson: unknown
widgetVerifiedEmail: string | null
widgetSessionExpiresAt: Date | null
boundShopifyOrderId: string | null
}) { }) {
const startedAt = Date.now() const startedAt = Date.now()
const { storeId, query, visitorId, history } = params const {
storeId,
storeName,
query,
visitorId,
history,
categoryMetadetailsEnabled,
aiModulesJson,
widgetVerifiedEmail,
widgetSessionExpiresAt,
boundShopifyOrderId,
} = params
const retrieval = await getBlendedStorefrontContext({ storeId, query }) const result = await runL1Orchestrator({
const contextLines = retrieval.items.map( channel: "widget",
(item, idx) => `[${idx + 1}] (${item.source}) ${item.title}: ${item.content}` storeId,
) storeName,
const contextBlock = contextLines.length message: query,
? contextLines.join("\n") history,
: "No relevant KB/FAQ/product context found." categoryMetadetailsEnabled,
aiModulesJson,
const shortHistory = history.slice(-8).map((m) => ({ widgetVerifiedEmail,
role: m.senderType === "AI" ? "assistant" : "user", widgetSessionExpiresAt,
content: m.content, boundShopifyOrderId,
})) as GroqChatMessage[]
const system = [
"You are JUNO storefront assistant for an ecommerce store.",
"Use the provided context first (products, FAQs, knowledge).",
"If context is insufficient, ask one concise clarifying question.",
"Do not invent exact prices, stock or policy details that are not present.",
"Keep responses concise, friendly, and sales-supportive.",
"Return plain text only.",
].join(" ")
const user = [
"Context:",
contextBlock,
"",
`Visitor ID: ${visitorId}`,
`Customer question: ${query}`,
].join("\n")
const messages: GroqChatMessage[] = [
{ role: "system", content: system },
...shortHistory,
{ role: "user", content: user },
]
const out = await groqChatCompletion(messages, {
temperature: 0.2,
maxTokens: 420,
timeoutMs: 28_000,
}) })
const fallback =
"Thanks for your message. I do not have enough details right now. Could you share the product name or what exactly you want to know?"
const content = (out.content || "").trim() || fallback
logger.info("[STOREFRONT_AI_REPLY]", { logger.info("[STOREFRONT_AI_REPLY]", {
storeId, storeId,
visitorId, visitorId,
queryLength: query.length, queryLength: query.length,
kbHits: retrieval.kbCount, kbHits: result.meta.kbHits,
productHits: retrieval.productCount, productHits: result.meta.productHits,
latencyMs: Date.now() - startedAt, latencyMs: Date.now() - startedAt,
fallbackUsed: !out.content, module: result.meta.module,
error: out.error || null, pillar: result.meta.pillar,
}) })
return { return {
content, content: result.text,
meta: { meta: {
kbHits: retrieval.kbCount, kbHits: result.meta.kbHits,
productHits: retrieval.productCount, productHits: result.meta.productHits,
fallbackUsed: !out.content, fallbackUsed: false,
module: result.meta.module,
pillar: result.meta.pillar,
usedTools: result.meta.usedTools,
}, },
} }
} }

View File

@ -0,0 +1,190 @@
import { prisma } from "@/lib/prisma"
import { logger } from "@/lib/logger"
import {
extractOrderNumberHints,
normalizeOrderNumberKey,
} from "@/lib/ai/gmailContext"
export type OrderListCandidate = {
shopifyOrderId: string
orderNumber: string
shopifyCreatedAt: string | null
financialStatus: string | null
fulfillmentStatus: string | null
totalPrice: string
currency: string | null
}
function normalizeEmail(email: string): string {
return email.trim().toLowerCase()
}
/**
* Orders for a customer email from CachedOrder (same matching rules as Gmail context).
*/
export async function listOrdersForCustomerEmail(
storeId: string,
customerEmail: string,
limit = 50,
): Promise<OrderListCandidate[]> {
const normalized = normalizeEmail(customerEmail)
if (!normalized) return []
try {
const rows = await prisma.$queryRaw<
{
shopifyOrderId: string
orderNumber: string
shopifyCreatedAt: Date | null
financialStatus: string | null
fulfillmentStatus: string | null
totalPrice: string
currency: string | null
}[]
>`
SELECT
"shopifyOrderId",
"orderNumber",
"shopifyCreatedAt",
"financialStatus",
"fulfillmentStatus",
"totalPrice",
currency
FROM "CachedOrder"
WHERE "storeId" = ${storeId}
AND (
LOWER(TRIM(COALESCE(email, ''))) = ${normalized}
OR LOWER(TRIM(COALESCE(customer->>'email', ''))) = ${normalized}
)
ORDER BY "shopifyCreatedAt" DESC NULLS LAST, "createdAt" DESC
LIMIT ${limit}
`
return rows.map((r) => ({
shopifyOrderId: r.shopifyOrderId,
orderNumber: r.orderNumber,
shopifyCreatedAt: r.shopifyCreatedAt?.toISOString() ?? null,
financialStatus: r.financialStatus,
fulfillmentStatus: r.fulfillmentStatus,
totalPrice: r.totalPrice,
currency: r.currency,
}))
} catch (e) {
logger.error("listOrdersForCustomerEmail failed", e)
return []
}
}
export type OrderDetailRow = {
shopifyOrderId: string
orderNumber: string
email: string | null
totalPrice: string
currency: string | null
financialStatus: string | null
fulfillmentStatus: string | null
trackingNumber: string | null
trackingCompany: string | null
trackingUrl: string | null
lineItems: unknown
shopifyCreatedAt: Date | string | null
}
/**
* Full order row when email matches the order (fiber rule).
*/
export async function getOrderDetailForVerifiedEmail(
storeId: string,
customerEmail: string,
opts: { shopifyOrderId?: string; orderNumberHint?: string },
): Promise<OrderDetailRow | null> {
const normalized = normalizeEmail(customerEmail)
if (!normalized) return null
const candidates = await listOrdersForCustomerEmail(storeId, customerEmail, 80)
if (candidates.length === 0) return null
let match: OrderListCandidate | undefined
if (opts.shopifyOrderId?.trim()) {
match = candidates.find((c) => c.shopifyOrderId === opts.shopifyOrderId!.trim())
} else if (opts.orderNumberHint?.trim()) {
const key = normalizeOrderNumberKey(opts.orderNumberHint.trim())
match = candidates.find(
(c) => normalizeOrderNumberKey(c.orderNumber) === key,
)
} else if (candidates.length === 1) {
match = candidates[0]
} else {
return null
}
if (!match) return null
try {
const rows = await prisma.$queryRaw<OrderDetailRow[]>`
SELECT
"shopifyOrderId",
"orderNumber",
email,
"totalPrice",
currency,
"financialStatus",
"fulfillmentStatus",
"trackingNumber",
"trackingCompany",
"trackingUrl",
"lineItems",
"shopifyCreatedAt"
FROM "CachedOrder"
WHERE "storeId" = ${storeId}
AND "shopifyOrderId" = ${match.shopifyOrderId}
AND (
LOWER(TRIM(COALESCE(email, ''))) = ${normalized}
OR LOWER(TRIM(COALESCE(customer->>'email', ''))) = ${normalized}
)
LIMIT 1
`
const row = rows[0]
if (!row) return null
const sc = row.shopifyCreatedAt
const created =
sc instanceof Date
? sc.toISOString()
: sc
? new Date(sc).toISOString()
: null
return {
...row,
shopifyCreatedAt: created,
}
} catch (e) {
logger.error("getOrderDetailForVerifiedEmail failed", e)
return null
}
}
export function orderNeedsDisambiguation(
customerEmail: string,
hintText: string | undefined,
candidates: OrderListCandidate[],
): boolean {
if (candidates.length <= 1) return false
const hints = hintText ? extractOrderNumberHints(hintText) : []
if (hints.length > 0) {
const matched = candidates.filter((c) =>
hints.some((h) => normalizeOrderNumberKey(h) === normalizeOrderNumberKey(c.orderNumber)),
)
return matched.length !== 1
}
return true
}
export function formatOrderCandidatesForPrompt(candidates: OrderListCandidate[]): string {
return candidates
.slice(0, 8)
.map(
(c, i) =>
`${i + 1}. Order ${c.orderNumber} (placed ${c.shopifyCreatedAt?.slice(0, 10) ?? "unknown"}) — ${c.financialStatus ?? "?"} / ${c.fulfillmentStatus ?? "?"}`,
)
.join("\n")
}

View File

@ -0,0 +1,130 @@
import { prisma } from "@/lib/prisma"
import { logger } from "@/lib/logger"
export type ProductSearchHit = {
shopifyProductId: string
title: string
sku: string | null
inventorySummary: string | null
metafieldsSummary: string | null
}
function metafieldsToSummary(metafields: unknown): string | null {
if (!Array.isArray(metafields) || metafields.length === 0) return null
const parts: string[] = []
for (const row of metafields) {
if (!row || typeof row !== "object") continue
const r = row as { namespace?: string; key?: string; value?: string | null }
const k = r.key
const v = r.value
if (!k || v === null || v === undefined || String(v).trim() === "") continue
const ns = r.namespace ? `${r.namespace}.` : ""
parts.push(`${ns}${k}: ${String(v).slice(0, 400)}`)
}
return parts.length ? parts.join(" | ") : null
}
function inventoryFromVariants(variants: unknown): string | null {
try {
const v =
typeof variants === "string" ? JSON.parse(variants) : variants
if (!Array.isArray(v) || v.length === 0) return null
const first = v[0] as { inventory_quantity?: number; sku?: string }
const q = first.inventory_quantity
const sku = first.sku
if (q === undefined && !sku) return null
return [sku ? `SKU ${sku}` : null, q !== undefined ? `qty ${q}` : null]
.filter(Boolean)
.join(", ")
} catch {
return null
}
}
/**
* Keyword search by title / SKU across cached products (MVP; no full-text index).
*/
export async function searchProductsForStore(
storeId: string,
query: string,
limit = 8,
): Promise<ProductSearchHit[]> {
const terms = query
.toLowerCase()
.split(/[^a-z0-9]+/g)
.map((t) => t.trim())
.filter((t) => t.length >= 2)
.slice(0, 6)
if (!terms.length) return []
try {
const products = await prisma.cachedProduct.findMany({
where: { storeId },
orderBy: { updatedAt: "desc" },
take: 400,
select: {
shopifyProductId: true,
title: true,
variants: true,
metafields: true,
},
})
const scored: { p: (typeof products)[0]; score: number }[] = []
for (const p of products) {
const hay = `${p.title} ${JSON.stringify(p.variants)}`.toLowerCase()
let score = 0
for (const t of terms) {
if (hay.includes(t)) score += 1
}
if (score > 0) scored.push({ p, score })
}
scored.sort((a, b) => b.score - a.score)
return scored.slice(0, limit).map(({ p }) => {
const variants = p.variants
let sku: string | null = null
try {
const arr =
typeof variants === "string" ? JSON.parse(variants) : variants
if (Array.isArray(arr) && arr[0]?.sku) sku = String(arr[0].sku)
} catch {
/* ignore */
}
return {
shopifyProductId: p.shopifyProductId,
title: p.title,
sku,
inventorySummary: inventoryFromVariants(p.variants),
metafieldsSummary: metafieldsToSummary(p.metafields),
}
})
} catch (e) {
logger.error("searchProductsForStore failed", e)
return []
}
}
/**
* When categoryMetadetailsEnabled, expose metafield text for grounded answers.
*/
export async function getProductMetafieldsForL1(
storeId: string,
shopifyProductId: string,
categoryMetadetailsEnabled: boolean,
): Promise<{ metafieldsSummary: string | null }> {
if (!categoryMetadetailsEnabled) {
return { metafieldsSummary: null }
}
try {
const row = await prisma.cachedProduct.findFirst({
where: { storeId, shopifyProductId },
select: { metafields: true },
})
return { metafieldsSummary: metafieldsToSummary(row?.metafields) }
} catch (e) {
logger.error("getProductMetafieldsForL1 failed", e)
return { metafieldsSummary: null }
}
}

View File

@ -106,6 +106,24 @@ export const chatMessagePostSchema = z
} }
}) })
export const storefrontOtpRequestSchema = z.object({
shop: storefrontShopSchema,
visitorId: storefrontVisitorIdSchema,
email: emailSchema,
})
export const storefrontOtpVerifySchema = z.object({
shop: storefrontShopSchema,
visitorId: storefrontVisitorIdSchema,
email: emailSchema,
code: z
.string()
.trim()
.regex(/^\d{6}$/, "Enter the 6-digit code"),
})
export type NewsletterSubscribeInput = z.infer<typeof newsletterSubscribeSchema> export type NewsletterSubscribeInput = z.infer<typeof newsletterSubscribeSchema>
export type StorefrontChatPostInput = z.infer<typeof storefrontChatPostSchema> export type StorefrontChatPostInput = z.infer<typeof storefrontChatPostSchema>
export type ChatMessagePostInput = z.infer<typeof chatMessagePostSchema> export type ChatMessagePostInput = z.infer<typeof chatMessagePostSchema>
export type StorefrontOtpRequestInput = z.infer<typeof storefrontOtpRequestSchema>
export type StorefrontOtpVerifyInput = z.infer<typeof storefrontOtpVerifySchema>

View File

@ -1,4 +1,5 @@
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { storeSelectGmailL1Context } from "@/lib/prisma/storeSelects"
import { logger } from "@/lib/logger" import { logger } from "@/lib/logger"
import { escapeHtmlForEmail, escapeTemplateContextForHtml, renderTemplate } from "@/lib/templates/simpleRenderer" import { escapeHtmlForEmail, escapeTemplateContextForHtml, renderTemplate } from "@/lib/templates/simpleRenderer"
import { classifyCustomerEmail, type ClassifyCustomerEmailResult } from "@/lib/ai/gmailGroq" import { classifyCustomerEmail, type ClassifyCustomerEmailResult } from "@/lib/ai/gmailGroq"
@ -12,6 +13,7 @@ import { looksLikeOffTopic, looksLikeSensitiveOrderTopic } from "@/lib/ai/gmailO
import { parseEnvFloat } from "@/lib/gmail/gmailAutoReplyGuards" import { parseEnvFloat } from "@/lib/gmail/gmailAutoReplyGuards"
import { findTemplateWithFallback, type TemplateRecord } from "@/lib/juno/templatesRepo" import { findTemplateWithFallback, type TemplateRecord } from "@/lib/juno/templatesRepo"
import type { GmailClassification } from "@/lib/ai/gmailTaxonomy" import type { GmailClassification } from "@/lib/ai/gmailTaxonomy"
import { runL1Orchestrator } from "@/lib/ai/orchestrator/runL1"
/** Local-part of email, title-cased for greetings (templates use {{customer_name}}). */ /** Local-part of email, title-cased for greetings (templates use {{customer_name}}). */
function greetingNameFromEmail(email: string): string { function greetingNameFromEmail(email: string): string {
@ -93,6 +95,20 @@ export function buildHintUnmatchedFallbackHtml(storeName: string, storeEmail: st
` `
} }
function buildL1HtmlEmail(plainBody: string, storeName: string): string {
const safe = escapeHtmlForEmail(plainBody).replace(/\r\n|\n/g, "<br/>")
return `
<div style="font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.6; color: #0f172a; background-color: #0b1220;">
<div style="max-width: 640px; margin: 0 auto; background: #020617; border-radius: 16px; border: 1px solid #1f2937; padding: 24px;">
<div style="color: #e5e7eb; font-size: 15px; white-space: normal;">${safe}</div>
<p style="margin: 20px 0 0; color: #9ca3af; font-size: 13px;">
— ${escapeHtmlForEmail(storeName)} Support (automated)
</p>
</div>
</div>
`
}
/** When no off_topic template exists — polite boundary, no order data. */ /** When no off_topic template exists — polite boundary, no order data. */
export function buildOffTopicFallbackHtml(storeName: string, storeEmail: string): string { export function buildOffTopicFallbackHtml(storeName: string, storeEmail: string): string {
const contact = storeEmail.trim() const contact = storeEmail.trim()
@ -161,6 +177,8 @@ export type AutoReplyPreviewOutput = {
html: string html: string
flagged: boolean flagged: boolean
flaggedReason?: string flaggedReason?: string
/** True when reply body came from the L1 orchestrator instead of templates. */
l1Used?: boolean
} }
export async function generateAutoReplyPreview( export async function generateAutoReplyPreview(
@ -200,6 +218,7 @@ export async function generateAutoReplyPreview(
html: buildMinimalFallbackHtml(emailBody, storeName), html: buildMinimalFallbackHtml(emailBody, storeName),
flagged: true, flagged: true,
flaggedReason: safety.reason, flaggedReason: safety.reason,
l1Used: false,
} }
} }
@ -276,7 +295,7 @@ export async function generateAutoReplyPreview(
const store = await prisma.store.findUnique({ const store = await prisma.store.findUnique({
where: { id: input.storeId }, where: { id: input.storeId },
select: { businessName: true, email: true }, select: storeSelectGmailL1Context,
}) })
const storeName = store?.businessName || "Your store" const storeName = store?.businessName || "Your store"
@ -327,21 +346,51 @@ export async function generateAutoReplyPreview(
order_summary: orderSummaryForContext, order_summary: orderSummaryForContext,
} }
const skipL1Orchestrator =
isOffTopic ||
messageTooSparse ||
needsOrderNumberFirst ||
hintUnmatched
let html: string | null = null let html: string | null = null
let chosenTemplateId: string | null = null let chosenTemplateId: string | null = null
let l1Used = false
try { if (!skipL1Orchestrator) {
const template = await findTemplateWithFallback( try {
input.storeId, const l1 = await runL1Orchestrator({
classification.intent, channel: "email",
classification.mood, storeId: input.storeId,
) storeName,
if (template) { message: plainForAi,
chosenTemplateId = template.id history: [],
html = renderFromTemplate(template, context) categoryMetadetailsEnabled: store?.categoryMetadetailsEnabled ?? false,
aiModulesJson: store?.aiModules ?? null,
emailFrom: senderEmail,
})
if (l1.text.trim().length > 0) {
html = buildL1HtmlEmail(l1.text.trim(), storeName)
l1Used = true
}
} catch (e) {
logger.error("generateAutoReplyPreview: L1 orchestrator failed", e)
}
}
if (!html) {
try {
const template = await findTemplateWithFallback(
input.storeId,
classification.intent,
classification.mood,
)
if (template) {
chosenTemplateId = template.id
html = renderFromTemplate(template, context)
}
} catch (e) {
logger.error("generateAutoReplyPreview: template load/render failed", e)
} }
} catch (e) {
logger.error("generateAutoReplyPreview: template load/render failed", e)
} }
let usedFallback: AutoReplyPreviewOutput["usedFallback"] = "none" let usedFallback: AutoReplyPreviewOutput["usedFallback"] = "none"
@ -381,6 +430,7 @@ export async function generateAutoReplyPreview(
usedFallback, usedFallback,
html, html,
flagged: false, flagged: false,
l1Used,
} }
} }

View File

@ -346,6 +346,10 @@ export async function processIncomingEmail(
const replySubject = emailSubject.startsWith("Re:") ? emailSubject : `Re: ${emailSubject}` const replySubject = emailSubject.startsWith("Re:") ? emailSubject : `Re: ${emailSubject}`
replied = true replied = true
const autoTrigger =
preview.l1Used === true
? "AI_L1_REPLY"
: options?.replyTrigger ?? "GMAIL_WEBHOOK"
const result = await sendGmailReply( const result = await sendGmailReply(
userId, userId,
senderEmail, senderEmail,
@ -355,7 +359,7 @@ export async function processIncomingEmail(
{ {
storeId, storeId,
isAutomated: true, isAutomated: true,
trigger: options?.replyTrigger ?? "GMAIL_WEBHOOK", trigger: autoTrigger,
originalMessageId: email.messageId, originalMessageId: email.messageId,
} }
) )

View File

@ -1,7 +1,11 @@
import { Prisma } from "@prisma/client" import { Prisma } from "@prisma/client"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
const GMAIL_AUTO_REPLY_TRIGGERS = ["GMAIL_WEBHOOK", "GMAIL_POLL"] as const const GMAIL_AUTO_REPLY_TRIGGERS = [
"GMAIL_WEBHOOK",
"GMAIL_POLL",
"AI_L1_REPLY",
] as const
export function parseEnvInt(name: string, defaultVal: number): number { export function parseEnvInt(name: string, defaultVal: number): number {
const v = process.env[name] const v = process.env[name]

View File

@ -13,10 +13,16 @@ export type KbChunkEmbeddingMetadata = {
embeddingModel: string embeddingModel: string
embeddingDims: number embeddingDims: number
sourceKey?: string sourceKey?: string
/** L1 module code for RAG filtering (e.g. FAQ, ORDER_STATUS). */
moduleCode?: string
/** Shared field id (sf_*) or legacy key. */
fieldId?: string
} }
export function kbEmbeddingMetadata( export function kbEmbeddingMetadata(
extra?: Partial<Pick<KbChunkEmbeddingMetadata, "sourceKey">>, extra?: Partial<
Pick<KbChunkEmbeddingMetadata, "sourceKey" | "moduleCode" | "fieldId">
>,
): KbChunkEmbeddingMetadata { ): KbChunkEmbeddingMetadata {
return { return {
provider: KB_EMBEDDING_PROVIDER, provider: KB_EMBEDDING_PROVIDER,

View File

@ -1,6 +1,7 @@
import { z } from "zod" import { z } from "zod"
import { SHARED_FIELD_IDS } from "@/lib/kb/sharedFieldCatalog"
/** Six fixed prompts + optional misc (open-ended). */ /** Six fixed prompts + optional misc (open-ended). Legacy v1. */
export const KNOWLEDGE_TEMPLATE_FIELD_KEYS = [ export const KNOWLEDGE_TEMPLATE_FIELD_KEYS = [
"brandVoice", "brandVoice",
"shippingReturns", "shippingReturns",
@ -73,24 +74,53 @@ export const KNOWLEDGE_TEMPLATE_ANSWER_MAX = 12_000
/** Misc notes cap. */ /** Misc notes cap. */
export const KNOWLEDGE_TEMPLATE_MISC_MAX = 16_000 export const KNOWLEDGE_TEMPLATE_MISC_MAX = 16_000
const sharedFieldsRecordSchema = z
.record(z.string().max(KNOWLEDGE_TEMPLATE_ANSWER_MAX))
.optional()
.superRefine((val, ctx) => {
if (!val) return
for (const k of Object.keys(val)) {
if (!SHARED_FIELD_IDS.has(k)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Unknown shared field id: ${k}`,
})
}
}
})
export const knowledgeTemplatePutSchema = z.object({ export const knowledgeTemplatePutSchema = z.object({
knowledgeBaseMode: z.enum(["STRUCTURED", "DOCUMENTS"]).optional(), knowledgeBaseMode: z.enum(["STRUCTURED", "DOCUMENTS"]).optional(),
confirmDestructiveSwitch: z.boolean().optional(), confirmDestructiveSwitch: z.boolean().optional(),
template: z template: z
.object({ .object({
answers: z.record(z.string().max(KNOWLEDGE_TEMPLATE_ANSWER_MAX)).optional(), answers: z.record(z.string().max(KNOWLEDGE_TEMPLATE_ANSWER_MAX)).optional(),
/** Expanded sf_* vendor policy fields (v2). */
sharedFields: sharedFieldsRecordSchema,
misc: z.string().max(KNOWLEDGE_TEMPLATE_MISC_MAX).optional(), misc: z.string().max(KNOWLEDGE_TEMPLATE_MISC_MAX).optional(),
}) })
.optional(), .optional(),
}) })
/** Normalized template for storage + embedding (v2). */
export type KnowledgeTemplateStored = { export type KnowledgeTemplateStored = {
version: number version: 2
answers: Partial<Record<KnowledgeTemplateFieldKey, string>> /** Legacy six-field answers (optional once migrated to shared fields). */
legacyAnswers: Partial<Record<KnowledgeTemplateFieldKey, string>>
/** Spec-aligned shared_field_id values. */
sharedFields: Partial<Record<string, string>>
misc?: string misc?: string
updatedAt: string updatedAt: string
} }
/** v1 shape as persisted historically */
type KnowledgeTemplateV1 = {
version?: number
answers?: Partial<Record<KnowledgeTemplateFieldKey, string>>
misc?: string
updatedAt?: string
}
export function normalizeTemplateAnswers( export function normalizeTemplateAnswers(
raw: Record<string, string> | undefined, raw: Record<string, string> | undefined,
): Partial<Record<KnowledgeTemplateFieldKey, string>> { ): Partial<Record<KnowledgeTemplateFieldKey, string>> {
@ -104,3 +134,72 @@ export function normalizeTemplateAnswers(
} }
return out return out
} }
function normalizeSharedFields(
raw: Record<string, string> | undefined,
): Partial<Record<string, string>> {
if (!raw) return {}
const out: Partial<Record<string, string>> = {}
for (const [k, v] of Object.entries(raw)) {
if (!SHARED_FIELD_IDS.has(k)) continue
if (typeof v === "string" && v.trim()) {
out[k] = v.trim()
}
}
return out
}
/**
* Coerce DB JSON to KnowledgeTemplateStored v2.
* Migrates v1 `{ version: 1, answers }` into legacyAnswers.
*/
export function parseKnowledgeTemplateJson(json: unknown): KnowledgeTemplateStored | null {
if (json == null || typeof json !== "object") return null
const o = json as Record<string, unknown>
const ver = o.version === 2 ? 2 : 1
if (ver === 2) {
const legacy = normalizeTemplateAnswers(
o.legacyAnswers as Record<string, string> | undefined,
)
const sharedRaw = o.sharedFields
const shared =
sharedRaw && typeof sharedRaw === "object" && !Array.isArray(sharedRaw)
? normalizeSharedFields(sharedRaw as Record<string, string>)
: {}
const misc =
typeof o.misc === "string" && o.misc.trim() ? o.misc.trim() : undefined
return {
version: 2,
legacyAnswers: legacy,
sharedFields: shared,
misc,
updatedAt: typeof o.updatedAt === "string" ? o.updatedAt : new Date().toISOString(),
}
}
const v1 = o as KnowledgeTemplateV1
const legacy = normalizeTemplateAnswers(v1.answers as Record<string, string> | undefined)
const misc = v1.misc?.trim() || undefined
return {
version: 2,
legacyAnswers: legacy,
sharedFields: {},
misc,
updatedAt: v1.updatedAt || new Date().toISOString(),
}
}
export function buildKnowledgeTemplateStored(input: {
answers?: Record<string, string>
sharedFields?: Record<string, string>
misc?: string
}): KnowledgeTemplateStored {
return {
version: 2,
legacyAnswers: normalizeTemplateAnswers(input.answers),
sharedFields: normalizeSharedFields(input.sharedFields),
misc: input.misc?.trim() || undefined,
updatedAt: new Date().toISOString(),
}
}

View File

@ -0,0 +1,488 @@
/**
* Vendor policy fields aligned with docs/req-AI.md §5–7 (shared_field_id concepts).
* Keys are stable; labels are for dashboard UI.
*/
export type SharedFieldGroupId =
| "ALL"
| "FAQ"
| "ORDER_STATUS"
| "INVENTORY_STOCK"
| "ORDER_CANCEL"
| "HUMAN_ESCALATION"
| "COMPLAINT"
| "ORDER_SUMMARY"
| "REFUND_STATUS"
| "SHIPPING_TRACKING"
| "WRONG_ITEM"
| "RETURN_EXCHANGE"
| "NOT_RECEIVED_MARKED_DELIVERED"
| "SHIPMENT_STUCK_OR_DELAYED"
| "PAYMENT_PROBLEM"
| "ORDER_CHANGE"
| "WARRANTY"
| "PRODUCT_FIT"
| "STORE_LOCAL"
| "FEEDBACK"
export type SharedFieldDef = {
id: string
label: string
description: string
placeholder: string
group: SharedFieldGroupId
}
/** Curated catalog (spec-mapped ids). */
export const SHARED_FIELD_CATALOG: SharedFieldDef[] = [
// ALL — §6
{
id: "sf_brand_display_name",
label: "Brand or store name (as customers see it)",
description: "Consistent identity in automated replies.",
placeholder: "e.g. Acme Outfitters",
group: "ALL",
},
{
id: "sf_store_one_liner",
label: "What you sell and who it is for",
description: "One or two sentences.",
placeholder: "e.g. Sustainable outdoor apparel for weekend hikers…",
group: "ALL",
},
{
id: "sf_official_languages",
label: "Languages you support in writing",
description: "Sets expectations for email/chat.",
placeholder: "e.g. English, Spanish",
group: "ALL",
},
{
id: "sf_support_channels",
label: "Official support channels and hours",
description: "Email, phone, chat, hours.",
placeholder: "e.g. support@… ; Mon–Fri 9–5 ET",
group: "ALL",
},
{
id: "sf_support_first_response_sla",
label: "Typical first response time",
description: "Human or bot first reply expectation.",
placeholder: "e.g. Within 24 business hours",
group: "ALL",
},
{
id: "sf_tone_and_words_to_avoid",
label: "Tone and words or promises to avoid",
description: "Brand-safe automation.",
placeholder: "e.g. Friendly; avoid guaranteed delivery dates…",
group: "ALL",
},
{
id: "sf_order_id_help",
label: "How customers find order number / confirmation",
description: "Used across order-related flows.",
placeholder: "e.g. In your confirmation email, subject line…",
group: "ALL",
},
{
id: "sf_first_message_checklist",
label: "What to include when contacting support",
description: "Faster resolution.",
placeholder: "e.g. Order #, photos for damage…",
group: "ALL",
},
{
id: "sf_policy_link_terms",
label: "Terms of sale URL",
description: "Legal grounding.",
placeholder: "https://…",
group: "ALL",
},
{
id: "sf_policy_link_privacy",
label: "Privacy policy URL",
description: "Trust.",
placeholder: "https://…",
group: "ALL",
},
{
id: "sf_policy_link_returns",
label: "Return / refund policy URL (if separate)",
description: "Canonical policy link.",
placeholder: "https://…",
group: "ALL",
},
{
id: "sf_privacy_support_paragraph",
label: "Short paragraph: how data is used in support",
description: "Privacy framing.",
placeholder: "e.g. We use your email only to…",
group: "ALL",
},
// Shipping / status (cross-cutting)
{
id: "sf_shipping_regions_served",
label: "Regions you ship to",
description: "",
placeholder: "",
group: "ORDER_STATUS",
},
{
id: "sf_shipping_regions_excluded",
label: "Regions you do not ship to",
description: "",
placeholder: "",
group: "ORDER_STATUS",
},
{
id: "sf_processing_time_before_ship",
label: "Normal processing time before shipment",
description: "",
placeholder: "",
group: "ORDER_STATUS",
},
{
id: "sf_carriers_and_tracking_policy",
label: "Carriers and when tracking is provided",
description: "",
placeholder: "",
group: "SHIPPING_TRACKING",
},
{
id: "sf_shipping_methods_and_transit",
label: "Shipping methods and realistic transit ranges",
description: "",
placeholder: "",
group: "SHIPPING_TRACKING",
},
{
id: "sf_shipping_cost_model",
label: "Shipping cost model (free / flat / dynamic)",
description: "",
placeholder: "",
group: "FAQ",
},
{
id: "sf_dispatch_cutoff",
label: "Same-day dispatch cutoff (if any)",
description: "",
placeholder: "",
group: "ORDER_STATUS",
},
{
id: "sf_customs_duties_policy",
label: "International duties disclaimer",
description: "",
placeholder: "",
group: "FAQ",
},
{
id: "sf_major_delay_communication",
label: "When delayed before shipment or major delays",
description: "",
placeholder: "",
group: "ORDER_STATUS",
},
{
id: "sf_delivered_not_received_policy",
label: "“Delivered” but not received — customer steps",
description: "",
placeholder: "",
group: "NOT_RECEIVED_MARKED_DELIVERED",
},
{
id: "sf_stuck_shipment_customer_steps",
label: "When shipment seems stuck — what customer should do first",
description: "",
placeholder: "",
group: "SHIPMENT_STUCK_OR_DELAYED",
},
// Cancellations / changes / returns
{
id: "sf_cancellation_window",
label: "When cancellation is allowed",
description: "",
placeholder: "",
group: "ORDER_CANCEL",
},
{
id: "sf_cancellation_how_to_request",
label: "How customers request cancellation",
description: "",
placeholder: "",
group: "ORDER_CANCEL",
},
{
id: "sf_order_change_allowed_until",
label: "Until when order changes are sometimes possible",
description: "",
placeholder: "",
group: "ORDER_CHANGE",
},
{
id: "sf_order_change_how_to_request",
label: "How to request an order change",
description: "",
placeholder: "",
group: "ORDER_CHANGE",
},
{
id: "sf_order_change_not_possible_next_step",
label: "After cutoff / shipped — what to tell customers",
description: "",
placeholder: "",
group: "ORDER_CHANGE",
},
{
id: "sf_return_window",
label: "Return window (days / trigger)",
description: "",
placeholder: "",
group: "RETURN_EXCHANGE",
},
{
id: "sf_return_condition_requirements",
label: "Return condition requirements",
description: "",
placeholder: "",
group: "RETURN_EXCHANGE",
},
{
id: "sf_return_shipping_payer",
label: "Who pays return shipping",
description: "",
placeholder: "",
group: "RETURN_EXCHANGE",
},
{
id: "sf_exchange_policy",
label: "Exchange policy (swap vs credit vs refund)",
description: "",
placeholder: "",
group: "RETURN_EXCHANGE",
},
{
id: "sf_how_to_start_return",
label: "How to start a return",
description: "",
placeholder: "",
group: "RETURN_EXCHANGE",
},
{
id: "sf_non_returnable_categories",
label: "Non-returnable categories",
description: "",
placeholder: "",
group: "RETURN_EXCHANGE",
},
{
id: "sf_refund_methods",
label: "Refund methods (original payment vs store credit)",
description: "",
placeholder: "",
group: "REFUND_STATUS",
},
{
id: "sf_refund_timeline_after_approval",
label: "Timeline to money back after approval",
description: "",
placeholder: "",
group: "REFUND_STATUS",
},
{
id: "sf_partial_refund_rules",
label: "Partial refund rules",
description: "",
placeholder: "",
group: "REFUND_STATUS",
},
{
id: "sf_store_credit_rules",
label: "Store credit rules",
description: "",
placeholder: "",
group: "REFUND_STATUS",
},
{
id: "sf_failed_payment_guidance",
label: "What customer should retry on failed payment",
description: "",
placeholder: "",
group: "PAYMENT_PROBLEM",
},
{
id: "sf_double_charge_intake",
label: "What to send for duplicate charge claims",
description: "",
placeholder: "",
group: "PAYMENT_PROBLEM",
},
{
id: "sf_wrong_item_report_deadline",
label: "Deadline to report wrong/damaged item",
description: "",
placeholder: "",
group: "WRONG_ITEM",
},
{
id: "sf_wrong_item_evidence",
label: "Evidence required (photos, etc.)",
description: "",
placeholder: "",
group: "WRONG_ITEM",
},
{
id: "sf_wrong_item_remedies",
label: "Remedies you offer (human executes)",
description: "",
placeholder: "",
group: "WRONG_ITEM",
},
{
id: "sf_warranty_duration_scope",
label: "Warranty duration and scope",
description: "",
placeholder: "",
group: "WARRANTY",
},
{
id: "sf_warranty_voiders",
label: "What voids warranty",
description: "",
placeholder: "",
group: "WARRANTY",
},
{
id: "sf_warranty_claim_process",
label: "Warranty claim process",
description: "",
placeholder: "",
group: "WARRANTY",
},
{
id: "sf_sizing_fit_guidance",
label: "Sizing and fit guidance",
description: "",
placeholder: "",
group: "PRODUCT_FIT",
},
{
id: "sf_compatibility_notes",
label: "Compatibility notes (devices, voltage, region)",
description: "",
placeholder: "",
group: "PRODUCT_FIT",
},
{
id: "sf_materials_care_limitations",
label: "Materials, care, limitations",
description: "",
placeholder: "",
group: "PRODUCT_FIT",
},
{
id: "sf_back_in_stock_policy",
label: "Back-in-stock / notify policy",
description: "",
placeholder: "",
group: "INVENTORY_STOCK",
},
{
id: "sf_preorder_policy",
label: "Preorder policy",
description: "",
placeholder: "",
group: "INVENTORY_STOCK",
},
{
id: "sf_physical_locations_hours",
label: "Physical locations and hours",
description: "",
placeholder: "",
group: "STORE_LOCAL",
},
{
id: "sf_pickup_rules",
label: "Pickup rules",
description: "",
placeholder: "",
group: "STORE_LOCAL",
},
{
id: "sf_instore_vs_online_inventory",
label: "In-store vs online inventory",
description: "",
placeholder: "",
group: "STORE_LOCAL",
},
{
id: "sf_human_escalation_when",
label: "When to ask a human",
description: "",
placeholder: "",
group: "HUMAN_ESCALATION",
},
{
id: "sf_complaint_acknowledgement_sla",
label: "Complaint acknowledgement timing",
description: "",
placeholder: "",
group: "COMPLAINT",
},
{
id: "sf_complaint_supervisor_escalation",
label: "When a lead reviews complaints",
description: "",
placeholder: "",
group: "COMPLAINT",
},
{
id: "sf_feedback_csat_process",
label: "Feedback / CSAT process",
description: "",
placeholder: "",
group: "FEEDBACK",
},
{
id: "sf_order_summary_tax_invoice_note",
label: "Tax / invoice limitations note",
description: "",
placeholder: "",
group: "ORDER_SUMMARY",
},
]
export const SHARED_FIELD_IDS = new Set(SHARED_FIELD_CATALOG.map((f) => f.id))
export function sharedFieldsByGroup(): Record<SharedFieldGroupId, SharedFieldDef[]> {
const out = {} as Record<SharedFieldGroupId, SharedFieldDef[]>
for (const g of [
"ALL",
"FAQ",
"ORDER_STATUS",
"INVENTORY_STOCK",
"ORDER_CANCEL",
"HUMAN_ESCALATION",
"COMPLAINT",
"ORDER_SUMMARY",
"REFUND_STATUS",
"SHIPPING_TRACKING",
"WRONG_ITEM",
"RETURN_EXCHANGE",
"NOT_RECEIVED_MARKED_DELIVERED",
"SHIPMENT_STUCK_OR_DELAYED",
"PAYMENT_PROBLEM",
"ORDER_CHANGE",
"WARRANTY",
"PRODUCT_FIT",
"STORE_LOCAL",
"FEEDBACK",
] as const) {
out[g] = []
}
for (const f of SHARED_FIELD_CATALOG) {
out[f.group].push(f)
}
return out
}

View File

@ -6,6 +6,7 @@ import {
KNOWLEDGE_TEMPLATE_FIELDS, KNOWLEDGE_TEMPLATE_FIELDS,
type KnowledgeTemplateStored, type KnowledgeTemplateStored,
} from "@/lib/kb/knowledgeTemplate" } from "@/lib/kb/knowledgeTemplate"
import { SHARED_FIELD_CATALOG } from "@/lib/kb/sharedFieldCatalog"
export async function replaceStructuredKnowledgeChunks( export async function replaceStructuredKnowledgeChunks(
storeId: string, storeId: string,
@ -16,23 +17,50 @@ export async function replaceStructuredKnowledgeChunks(
storeId, storeId,
) )
const rows: { content: string; metadata: ReturnType<typeof kbEmbeddingMetadata> }[] = const rows: {
[] content: string
metadata: ReturnType<typeof kbEmbeddingMetadata>
}[] = []
for (const field of KNOWLEDGE_TEMPLATE_FIELDS) { for (const field of KNOWLEDGE_TEMPLATE_FIELDS) {
const text = stored.answers[field.key] const text = stored.legacyAnswers[field.key]
if (text?.trim()) { if (text?.trim()) {
rows.push({ rows.push({
content: `Q: ${field.label}\nA: ${text.trim()}`, content: `Q: ${field.label}\nA: ${text.trim()}`,
metadata: kbEmbeddingMetadata({ sourceKey: field.key }), metadata: kbEmbeddingMetadata({
sourceKey: field.key,
moduleCode: "LEGACY",
fieldId: field.key,
}),
}) })
} }
} }
const catalogById = new Map(SHARED_FIELD_CATALOG.map((f) => [f.id, f]))
for (const [id, text] of Object.entries(stored.sharedFields)) {
const trimmed = text?.trim()
if (!trimmed) continue
const def = catalogById.get(id)
const label = def?.label ?? id
const moduleCode = def?.group ?? "FAQ"
rows.push({
content: `Q: ${label}\nA: ${trimmed}`,
metadata: kbEmbeddingMetadata({
sourceKey: id,
moduleCode,
fieldId: id,
}),
})
}
if (stored.misc?.trim()) { if (stored.misc?.trim()) {
rows.push({ rows.push({
content: `Q: Anything else customers should know\nA: ${stored.misc.trim()}`, content: `Q: Anything else customers should know\nA: ${stored.misc.trim()}`,
metadata: kbEmbeddingMetadata({ sourceKey: "misc" }), metadata: kbEmbeddingMetadata({
sourceKey: "misc",
moduleCode: "ALL",
fieldId: "misc",
}),
}) })
} }

View File

@ -0,0 +1,51 @@
/**
* Reusable `select` objects for `Store` and `StorefrontChatConversation`.
*
* Use plain `as const` (no `satisfies Prisma.StoreSelect` / no `Prisma.validator`) so this file
* does not fight **stale** `@prisma/client` typings in the IDE. Prisma still type-checks each
* `findMany({ select: ... })` at the call site against the **generated** client.
*
* After `prisma/schema.prisma` changes, run `npx prisma generate` (and restart the TS server).
*/
export const storeSelectStorefrontChatApi = {
id: true,
storefrontChatEnabled: true,
businessName: true,
categoryMetadetailsEnabled: true,
aiModules: true,
} as const
export const storeSelectDashboardStorefrontInbox = {
id: true,
businessName: true,
shopifyDomain: true,
isActive: true,
categoryMetadetailsEnabled: true,
} as const
export const storeSelectGmailL1Context = {
businessName: true,
email: true,
categoryMetadetailsEnabled: true,
aiModules: true,
} as const
export const storeSelectPatchStorefrontAi = {
storefrontChatEnabled: true,
storefrontChatName: true,
storefrontChatTagline: true,
storefrontChatBrandColor: true,
storefrontChatFontFamily: true,
storefrontChatLogoUrl: true,
categoryMetadetailsEnabled: true,
aiModules: true,
} as const
/** Session / OTP fields for L1 storefront replies */
export const storeSelectStorefrontConversationSession = {
id: true,
assertedEmail: true,
emailVerifiedAt: true,
sessionExpiresAt: true,
boundShopifyOrderId: true,
} as const

View File

@ -0,0 +1,24 @@
import crypto from "node:crypto"
/** OTP validity (send to email). */
export const STOREFRONT_OTP_TTL_MS = 10 * 60 * 1000
/** Verified widget session length (spec: ~1h). */
export const STOREFRONT_SESSION_TTL_MS = 60 * 60 * 1000
export function hashStorefrontOtp(
code: string,
storeId: string,
visitorId: string,
): string {
const secret =
process.env.STOREFRONT_OTP_SECRET?.trim() || "juno-storefront-otp-dev"
return crypto
.createHash("sha256")
.update(`${secret}|${storeId}|${visitorId}|${code}`)
.digest("hex")
}
export function generateSixDigitOtp(): string {
return String(Math.floor(100000 + Math.random() * 900000))
}

View File

@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest"
import {
parseKnowledgeTemplateJson,
buildKnowledgeTemplateStored,
} from "@/lib/kb/knowledgeTemplate"
describe("parseKnowledgeTemplateJson", () => {
it("migrates v1 answers into legacyAnswers", () => {
const parsed = parseKnowledgeTemplateJson({
version: 1,
answers: { brandVoice: " Hello " },
misc: "x",
updatedAt: "2020-01-01",
})
expect(parsed?.version).toBe(2)
expect(parsed?.legacyAnswers.brandVoice).toBe("Hello")
expect(parsed?.sharedFields).toEqual({})
expect(parsed?.misc).toBe("x")
})
it("reads v2 sharedFields", () => {
const parsed = parseKnowledgeTemplateJson({
version: 2,
legacyAnswers: {},
sharedFields: { sf_brand_display_name: "Acme" },
updatedAt: "2020-01-01",
})
expect(parsed?.sharedFields.sf_brand_display_name).toBe("Acme")
})
})
describe("buildKnowledgeTemplateStored", () => {
it("builds v2 payload", () => {
const s = buildKnowledgeTemplateStored({
answers: { brandVoice: "a" },
sharedFields: { sf_brand_display_name: "b" },
misc: "c",
})
expect(s.version).toBe(2)
expect(s.legacyAnswers.brandVoice).toBe("a")
expect(s.sharedFields.sf_brand_display_name).toBe("b")
expect(s.misc).toBe("c")
})
})

View File

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest"
import { orderNeedsDisambiguation } from "@/lib/ai/tools/orderTools"
import type { OrderListCandidate } from "@/lib/ai/tools/orderTools"
const mk = (n: string): OrderListCandidate => ({
shopifyOrderId: n,
orderNumber: n,
shopifyCreatedAt: null,
financialStatus: null,
fulfillmentStatus: null,
totalPrice: "1",
currency: "USD",
})
describe("orderNeedsDisambiguation", () => {
it("returns false for single candidate", () => {
expect(orderNeedsDisambiguation("a@b.com", "help", [mk("1001")])).toBe(false)
})
it("returns true for multiple candidates without hints", () => {
expect(orderNeedsDisambiguation("a@b.com", "where is my order", [mk("1001"), mk("1002")])).toBe(
true,
)
})
it("returns false when hint matches one order", () => {
expect(
orderNeedsDisambiguation("a@b.com", "order 1002", [mk("1001"), mk("1002")]),
).toBe(false)
})
})

View File

@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest"
import { hashStorefrontOtp } from "@/lib/storefront-chat/otpCrypto"
describe("hashStorefrontOtp", () => {
it("is deterministic for same inputs", () => {
const a = hashStorefrontOtp("123456", "store1", "visitor1")
const b = hashStorefrontOtp("123456", "store1", "visitor1")
expect(a).toBe(b)
expect(a.length).toBe(64)
})
it("differs when code changes", () => {
const a = hashStorefrontOtp("123456", "store1", "visitor1")
const b = hashStorefrontOtp("000000", "store1", "visitor1")
expect(a).not.toBe(b)
})
})

View File

@ -4,7 +4,7 @@ import { defineConfig } from "vitest/config"
export default defineConfig({ export default defineConfig({
test: { test: {
environment: "node", environment: "node",
include: ["src/**/*.test.ts"], include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
}, },
resolve: { resolve: {
alias: { alias: {