This commit is contained in:
Saad Mohsin 2026-04-12 04:15:11 +05:00
parent 4e83c12ddd
commit 80b53535c1
51 changed files with 3529 additions and 2914 deletions

View File

@ -21,13 +21,5 @@ npx prisma db push --accept-data-loss
echo "Regenerating Prisma Client..." echo "Regenerating Prisma Client..."
npx prisma generate npx prisma generate
if [ "$RUN_TEMPLATE_SEED_ON_START" = "true" ]; then
echo "Seeding predefined email templates..."
# Run seed script; if it fails, log the error but continue starting the app
if ! npx tsx scripts/seed-templates.ts; then
echo "Template seeding failed, continuing without seeded templates."
fi
fi
echo "Starting application..." echo "Starting application..."
exec "$@" exec "$@"

View File

@ -0,0 +1,53 @@
# AI features — configuration and flows (high level)
This document describes how customer-facing AI is wired today: entry points, major pipeline steps, configuration stored on `Store`, and known gaps. Use it when changing ticket creation, Gmail behavior, or module gating.
## Surfaces
| Surface | Primary code | Notes |
|--------|----------------|------|
| **Gmail inbound** | [`src/lib/gmail/emailProcessor.ts`](../src/lib/gmail/emailProcessor.ts), webhook [`src/app/api/webhooks/gmail/route.ts`](../src/app/api/webhooks/gmail/route.ts) | Processes one unread message; builds a reply via [`generateAutoReplyPreview`](../src/lib/gmail/autoReplyPreview.ts) and sends it. |
| **Storefront widget** | [`src/app/api/storefront-chat/messages/route.ts`](../src/app/api/storefront-chat/messages/route.ts), [`src/lib/ai/storefrontGroq.ts`](../src/lib/ai/storefrontGroq.ts) | Public API; resolves store by `shop` domain; runs the same L1 orchestrator as email with `channel: "widget"`. |
## Gmail reply composition (`generateAutoReplyPreview`)
Rough order:
1. **Safety** — [`screenInboundEmailForAbuse`](../src/lib/ai/gmailSafety.ts); flagged messages get a minimal HTML response and no order data.
2. **Classification** — [`classifyCustomerEmail`](../src/lib/ai/gmailGroq.ts) (Groq) yields Gmail intent/mood for logging and guardrails; order-context lines are built with heuristics in the same file (when to load cached orders, when to ask for order number first, off-topic detection, etc.).
3. **L1 orchestrator** — If not skipped (off-topic, too sparse, needs order id first, hint unmatched), [`runL1Orchestrator`](../src/lib/ai/orchestrator/runL1.ts) runs with `channel: "email"` and `emailFrom` set to the sender.
4. **HTML fallbacks** — If L1 returns empty text, built-in HTML snippets apply (off-topic boundary, need order id, hint unmatched, minimal acknowledgment). Vendor-editable HTML templates were removed; there is no DB template fallback.
Relevant store fields on this path: `businessName`, `email`, `aiModules`, `modulePolicies` (see below).
## L1 orchestrator (`runL1Orchestrator`)
Single implementation for **widget** and **email**:
1. **Retrieval** — [`getBlendedStorefrontContext`](../src/lib/storefront-chat/retrieval.ts) blends knowledge chunks / FAQs for the prompt context.
2. **Intent → module** — [`routeCustomerIntent`](../src/lib/ai/orchestrator/intentRouter.ts) (Groq JSON) maps the customer message to an [`L1Module`](../src/lib/ai/orchestrator/types.ts).
3. **Module gating** — [`parseAiModulesJson`](../src/lib/ai/orchestrator/aiModules.ts) + [`isModuleEnabled`](../src/lib/ai/orchestrator/aiModules.ts). Disabled modules fall back to **FAQ**. **`FAQ` is always treated as enabled** (cannot be turned off via JSON). **`UNKNOWN`** stays enabled for routing stability.
4. **Pillar** — Each module has a pillar in [`MODULE_MANIFESTS`](../src/lib/ai/orchestrator/manifests.ts): `K` (knowledge), `T` (transactional / DB), `X` (ticket handoff copy), `CAT` (catalog metafields).
5. **Verification** — Widget: OTP-verified session on [`StorefrontChatConversation`](../prisma/schema.prisma) (asserted email + expiry). Email: trust `From` as customer email for order lookup (see `runL1` and order tools).
6. **Tools** — Orders: [`orderTools`](../src/lib/ai/tools/orderTools.ts). Products: [`productTools`](../src/lib/ai/tools/productTools.ts) for search + product metafield summaries in L1 (no separate category-metadetails path in the vendor UI).
7. **Reply** — Mostly [`composeReply`](../src/lib/ai/orchestrator/runL1.ts) (Groq) over assembled facts. For pillar **`X`**, the customer sees fixed **handoff** copy from `ticketHandoffText` — **no ticket row is created in the database**.
Vendor UI for `aiModules` and `modulePolicies`: dashboard **JUNO Engine → Modules**; persistence via `GET`/`PATCH` [`/api/stores/[storeId]`](../src/app/api/stores/[storeId]/route.ts).
## Knowledge base (separate from module toggles)
- **Mode and content** — [`Store.knowledgeBaseMode`](../prisma/schema.prisma), `knowledgeTemplate` JSON, documents, FAQs, structured chunks. APIs under [`src/app/api/stores/[storeId]/knowledge-template`](../src/app/api/stores/[storeId]/knowledge-template/route.ts) and KB subroutes.
- **Public query** — e.g. [`src/app/api/stores/[storeId]/kb/query/route.ts`](../src/app/api/stores/[storeId]/kb/query/route.ts) for RAG-style use.
Module toggles do **not** turn off the knowledge base; they only restrict which **L1 modules** can run after routing.
## Known gaps (intentional follow-ups)
1. **Ticket-led (`X`) flows** — The orchestrator acknowledges and promises human follow-up but **does not create a ticket entity**. Vendor **Tickets** in the dashboard are derived from [`EmailLog`](../src/app/api/juno-engine/tickets/route.ts), not from L1.
2. **Spec vs router** — Vendor policy text is stored per [`L1Module`](../src/lib/ai/orchestrator/types.ts) in `Store.modulePolicies` (AI modules UI). The [`intentRouter`](../src/lib/ai/orchestrator/intentRouter.ts) only emits declared modules. Extending automation requires adding a module in types, `MODULE_MANIFESTS`, `DEFAULT_ENABLED` in `aiModules.ts`, and orchestrator branches in `runL1.ts`.
3. **Gmail vs widget parity** — Same L1 core; Gmail adds a pre-L1 classification layer and different skip rules for when L1 runs.
## Environment / models
- Groq and related env vars are used by `groqClient`, `gmailGroq`, `intentRouter`, and `composeReply`.
- Rate limits and leases for Gmail inbound: [`gmailAutoReplyGuards`](../src/lib/gmail/gmailAutoReplyGuards.ts).

View File

@ -619,12 +619,11 @@ You can paste this into **variables** metadata in the UI if your editor supports
- **Intent/mood:** Groq classifies the incoming message (`classifyCustomerEmail` in `src/lib/ai/gmailGroq.ts`). Returns `source: "groq" \| "fallback"` when the API or JSON parse fails (defaults to `general` + `neutral`). - **Intent/mood:** Groq classifies the incoming message (`classifyCustomerEmail` in `src/lib/ai/gmailGroq.ts`). Returns `source: "groq" \| "fallback"` when the API or JSON parse fails (defaults to `general` + `neutral`).
- **Sensitive-topic heuristic:** `looksLikeSensitiveOrderTopic` in `src/lib/ai/gmailOrderDisclosure.ts` aligns disclosure with shipping/refund/billing wording when the classifier misses. - **Sensitive-topic heuristic:** `looksLikeSensitiveOrderTopic` in `src/lib/ai/gmailOrderDisclosure.ts` aligns disclosure with shipping/refund/billing wording when the classifier misses.
- **Hints:** `stripQuotedEmailTrailForHints` + `extractOrderNumberHints` in `src/lib/ai/gmailContext.ts` reduce false hints from quoted threads. - **Hints:** `stripQuotedEmailTrailForHints` + `extractOrderNumberHints` in `src/lib/ai/gmailContext.ts` reduce false hints from quoted threads.
- **Reply body:** `processIncomingEmail` in `src/lib/gmail/emailProcessor.ts` loads a template with `findTemplateWithFallback` and renders `{{variables}}`. **No** `generateReplyAndTemplateFields` / **no** auto-`insertTemplate` from Gmail. - **Reply body:** `processIncomingEmail` calls `generateAutoReplyPreview` (`src/lib/gmail/autoReplyPreview.ts`): L1 orchestrator when guardrails allow, else built-in fallback HTML. Vendor-editable DB templates and `generateReplyAndTemplateFields` were removed.
- Templates are keyed by `storeId` + `intent` + `mood` (see `src/lib/juno/templatesRepo.ts`).
- **Order context:** `buildOrderSummaryForEmail` reads **CachedOrder** only; keep cache in sync with Shopify. - **Order context:** `buildOrderSummaryForEmail` reads **CachedOrder** only; keep cache in sync with Shopify.
- **Fallback HTML:** `buildOffTopicFallbackHtml`, `buildNeedOrderIdFallbackHtml`, `buildHintUnmatchedFallbackHtml`, or `buildMinimalFallbackHtml` if no template matches. - **Fallback HTML:** `buildOffTopicFallbackHtml`, `buildNeedOrderIdFallbackHtml`, `buildHintUnmatchedFallbackHtml`, or `buildMinimalFallbackHtml` when L1 is skipped or returns empty.
- **Abuse / idempotency:** Per-sender caps via `EmailLog` (`checkGmailAutoReplyGuards`); duplicate inbound Gmail `message` ids use `GmailInboundAutoReply` so the same message is not auto-replied twice. Env: `GMAIL_AUTO_REPLY_MAX_PER_SENDER_PER_HOUR` (default 15), `GMAIL_AUTO_REPLY_BURST_MAX_PER_10MIN` (default 6); set to `0` to disable that cap. - **Abuse / idempotency:** Per-sender caps via `EmailLog` (`checkGmailAutoReplyGuards`); duplicate inbound Gmail `message` ids use `GmailInboundAutoReply` so the same message is not auto-replied twice. Env: `GMAIL_AUTO_REPLY_MAX_PER_SENDER_PER_HOUR` (default 15), `GMAIL_AUTO_REPLY_BURST_MAX_PER_10MIN` (default 6); set to `0` to disable that cap.
--- ---
*Generated for JUNOHUB — intents/moods aligned with `src/lib/ai/gmailTaxonomy.ts` and `prisma/schema.prisma` `Template` model.* *Historical doc: intents/moods still align with `src/lib/ai/gmailTaxonomy.ts`. The `Template` table may exist in older DBs but is no longer used by the Gmail reply path.*

View File

@ -0,0 +1,773 @@
# 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).
### 2.4 Module anatomy: **three types** of configuration / questions
Every **use-case module** is implemented as a bundle of **three** layers. The vendor dashboard and engineering manifests should reflect these explicitly (tabs, accordions, or sections).
| Type | Name | Who fills it | What it controls |
|------|------|----------------|------------------|
| **Type 1** | **Policy & messaging** | **Vendor** (questionnaire) | Text the agent may cite or blend into replies: tone-adjacent facts, “what we tell customers,” links, windows, exclusions — stored as `shared_field_id` (or your equivalent). **Does not** replace DB facts for transactional answers. |
| **Type 2** | **Verification & prerequisites** | **Platform rules** (+ minimal vendor toggles if you add them later) | **Who** is allowed to run this flow and **which slots** must be collected first: `From` email vs order email, widget **email + OTP + session TTL**, `order_id`, `product_name`, disambiguation when multiple orders match, etc. |
| **Type 3** | **L1 automation & ticketing** | **Engineering** (manifest) + **vendor enable** | After Type 2 passes: **tool calls** / DB reads, optional **writes** (e.g. cancel), composition of the reply; and **when** to **open a ticket**, what to attach, and that **humans** own ticket resolution. |
**How they work together (example: `ORDER_STATUS`):**
- **Type 1:** Vendor answers how long processing usually takes, what you say if pre-ship delay, shipping regions, how customers find order # — so the reply can **wrap** factual status with **on-brand** messaging.
- **Type 2:** Customer must be verified (**email** on channel = email on order, or **widget OTP**); **`order_id`** required or disambiguated from a short list.
- **Type 3:** L1 **reads** order + fulfillment from DB via tools, **replies** with status + tracking snippets + Type 1 copy; if the case is **outside playbook** (data missing, anomaly), **open ticket** for a human.
**Modules that are “mostly FAQ”** still have all three types logically: **Type 1** = full questionnaire; **Type 2** = often **none** (no identity); **Type 3** = RAG/KB retrieval only, plus **optional** “suggest human / ticket” if out of scope.
**Modules that are “mostly ticket”** have heavy **Type 3** ticket path: L1 may only **intake + acknowledge + create ticket**; **Type 1** still supplies policy snippets in the auto-reply.
---
## 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 |
### 3.1 Per module: Type 1 (policy) · Type 2 (verification) · Type 3 (L1 / ticket)
Use this matrix with **§2.4** and **§7**. **Type 1** detail lives in the **§7** tables (`shared_field_id`). **Type 2** and **Type 3** are the minimum spec for manifests and runtime.
| Module | **Type 1 — Policy & messaging** (vendor Qs) | **Type 2 — Verification & prerequisites** | **Type 3 — L1 automation & ticketing** |
|--------|---------------------------------------------|---------------------------------------------|--------------------------------------|
| `ALL` | Global G1–G12 (applies across modules) | N/A at module level | Wires into prompts for every reply |
| `FAQ` | FQ1–FQ8 | **None** for anonymous FAQ | RAG / structured FAQ; OOD → suggest email or ticket |
| `CATEGORY_METADETAILS` | **None** (store toggle only) | Vendor **enables** feature; **product/category** resolution (name/SKU → category) | **Tool calls** read category metadetails from DB; if off/empty → fallback KB or ticket |
| `PRODUCT_FIT` | PF1–PF4 | **Optional** product/slot for specificity | KB + optional metadetails tools; edge → ticket |
| `STORE_LOCAL` | SL1–SL4 | **None** | KB-only replies |
| `FEEDBACK` | FB1–FB3 | Optional link to prior ticket/context | Send CSAT / link; negative path → human |
| `HUMAN_ESCALATION` | HE1–HE4 | **None** or minimal context | Ack + routing; **create ticket** if policy requires |
| `ORDER_STATUS` | OS1–OS5 (+ ALL for order# help) | **Email = order email** OR **widget OTP session**; **`order_id`** or **multi-order pick** | **L1:** tool read order/fulfillment → reply; **Ticket:** anomaly / missing data / out-of-playbook |
| `ORDER_SUMMARY` | SM1–SM4 (+ ALL) | Same as order-scoped flows | **L1:** read line items/totals → reply; **Ticket:** tax/invoice edge cases per vendor policy |
| `SHIPPING_TRACKING` | ST1–ST6 (+ ALL) | Same as `ORDER_STATUS` | **L1:** read tracking fields → reply; **Ticket:** carrier dispute / claim handoff |
| `ORDER_CANCEL` | CX1–CX5 (+ ALL) | Same as `ORDER_STATUS` | **L1:** eligibility + cancel if allowed; else KB + **Ticket** if ambiguous |
| `REFUND_STATUS` | RF1–RF6 (+ ALL) | Verified identity + **order/return** reference | **L1:** read refund state if in DB; else ack + **Ticket** |
| `INVENTORY_STOCK` | IV1–IV5 | **`product_name`/SKU**; widget OTP **optional** per product policy | **L1:** inventory tool read; heavy disambiguation → clarify or ticket |
| `NOT_RECEIVED_MARKED_DELIVERED` | NR1–NR2 (+ ALL) | Same as order-scoped | **L1:** tracking snapshot + policy text + **always open ticket** for human |
| `SHIPMENT_STUCK_OR_DELAYED` | SD1–SD3 (+ ALL) | Same as order-scoped | **L1:** last scan + policy + **open ticket** |
| `PAYMENT_PROBLEM` | PP1–PP6 (+ ALL) | Verified identity; **order id** when payment is order-scoped; else intake fields | **L1:** structured intake + **ticket**; no automated money movement |
| `ORDER_CHANGE` | OC1–OC4 (+ ALL) | Same as order-scoped + **requested change** captured | **L1:** **open ticket** + ack only; human decides feasibility |
| `RETURN_EXCHANGE` | RE1–RE6 (+ ALL) | Same as order-scoped + **reason** optional | **L1:** policy in reply + **open ticket** (or portal later) |
| `WARRANTY` | WA1–WA5 (+ ALL) | Verified identity + proof-of-purchase / order | **L1:** intake + **ticket**; human adjudicates |
| `WRONG_ITEM` | WI1–WI6 (+ ALL) | Same as order-scoped + **issue type**; email may attach **photos** | **L1:** ack + policy + **ticket** with structured fields |
| `COMPLAINT` | CP1–CP6 (+ ALL) | **Strict** same as other “fiber” flows | **L1:** empathetic ack + **ticket** + severity; human owns |
**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.
- In the **product UI**, each enabled module should expose **three areas** aligned with **§2.4**: **(1) Policy & messaging** (vendor questionnaire), **(2) Verification & prerequisites** (read-only copy of platform rules + any future vendor toggles), **(3) L1 & ticketing** (read-only summary or advanced: what automation does vs when tickets open) — exact layout is product design.
- **Type 1** complete: enabled when **required** vendor questions (`shared_field_id`) for that module are filled (where the module has a questionnaire).
- **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
Each subsection follows **§2.4** (three types):
| Type | Role in this section |
|------|----------------------|
| **Type 1** | **Policy & messaging** — vendor questionnaire rows in the table (`shared_field_id`). Completing required Type 1 fields gates **Knowledge** for that module. |
| **Type 2** | **Verification & prerequisites** — who may run the flow and which **slots** must be filled (**§1–§2**); summarized here per module. |
| **Type 3** | **L1 automation & ticketing** — tool reads/writes, reply shape, and **when tickets open**; humans own ticket resolution. |
**§3.1** is the **at-a-glance** matrix for all modules; **§7** is the **detailed** breakdown.
### 7.1 `ORDER_STATUS` — Order status
#### Type 1 — Policy & messaging (vendor questionnaire)
| # | 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` |
#### Type 2 — Verification & prerequisites
- **Email:** `From` = **order email**; **multi-order disambiguation** (**§1.1**).
- **Widget:** **email + OTP** + **session TTL** (**§1.2**).
- **Slots:** **`order_id`** (or pick from candidate list).
#### Type 3 — L1 automation & ticketing
- **L1:** Read **order / fulfillment** from DB after Type 2 passes; compose reply with **Type 1** messaging.
- **Ticket:** Data missing, anomaly, or **outside playbook** → **create ticket** (no full auto-resolution).
---
### 7.2 `INVENTORY_STOCK` — Inventory / stock
#### Type 1 — Policy & messaging (vendor questionnaire)
| # | 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 |
#### Type 2 — Verification & prerequisites
- **Slots:** **`product_name` / SKU** (and disambiguation if many matches).
- **Widget:** May allow **anonymous** stock checks, or require **OTP session** if tied to “my order” — **product decision**.
#### Type 3 — L1 automation & ticketing
- **L1:** **Inventory tool** read from DB; blend **Type 1** policy snippets where relevant.
- **Ticket / clarify:** Heavy disambiguation or conflicting data → ask clarifying questions or **ticket**.
---
### 7.3 `FAQ` — General FAQ (includes **refund policy**, not refund status)
#### Type 1 — Policy & messaging (vendor questionnaire)
| # | Question | `shared_field_id` |
|---|----------|-------------------|
| FQ1–FQ8 | Shipping, returns, **refunds policy summary**, payment, promo, tax, contact, policy links | *(as in prior doc)* |
#### Type 2 — Verification & prerequisites
- **None** for **anonymous** FAQ-only path on the widget (**§1.2**).
#### Type 3 — L1 automation & ticketing
- **L1:** RAG / structured FAQ from **Type 1** (+ **`ALL`** where useful).
- **Escalation:** Out of scope → suggest **email** or **ticket** per vendor channels (**`sf_support_channels`**).
---
### 7.4 `ORDER_CANCEL` — Order cancellation
#### Type 1 — Policy & messaging (vendor questionnaire)
| # | 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` |
#### Type 2 — Verification & prerequisites
- Same **order-scoped** verification as **`ORDER_STATUS`** (**§1**, **§2**).
#### Type 3 — L1 automation & ticketing
- **L1:** **Eligibility + cancel** when rules/DB allow; otherwise explain from **Type 1**.
- **Ticket:** Ambiguous, edge case, or cancel not permitted by rules → **open ticket** if policy requires human judgment.
---
### 7.5 `HUMAN_ESCALATION`
#### Type 1 — Policy & messaging (vendor questionnaire)
| # | Question | `shared_field_id` |
|---|----------|-------------------|
| HE1–HE4 | When to ask human, channels, SLA, checklist | `sf_human_escalation_when`, `sf_support_*`, `sf_first_message_checklist` |
#### Type 2 — Verification & prerequisites
- **Minimal** — optional context slots only; no order read required unless combined with another intent.
#### Type 3 — L1 automation & ticketing
- **L1:** Ack + routing copy from **Type 1** / **`ALL`**.
- **Ticket:** **Create ticket** when vendor policy or product rules say so.
---
### 7.6 `COMPLAINT` — Ticket-led, strict verification
#### Type 1 — Policy & messaging (vendor questionnaire)
| # | 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` |
#### Type 2 — Verification & prerequisites
- **Strict** same as other **fiber** flows: **email alignment** or **widget OTP session**; attach **order** when relevant.
#### Type 3 — L1 automation & ticketing
- **L1:** Empathetic ack + **Type 1** snippets + severity / structured intake.
- **Ticket:** **Always create ticket**; **human** resolves.
---
### 7.7 `ORDER_SUMMARY`
#### Type 1 — Policy & messaging (vendor questionnaire)
| # | 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` |
#### Type 2 — Verification & prerequisites
- Same as **`ORDER_STATUS`** (order-scoped).
#### Type 3 — L1 automation & ticketing
- **L1:** Read **line items / totals** from DB; compose recap + **Type 1** (tax/invoice disclaimers).
- **Ticket:** Tax/invoice edge cases per vendor policy → **human**.
---
### 7.8 `REFUND_STATUS` — Transactional (distinct from FAQ refund policy)
#### Type 1 — Policy & messaging (vendor questionnaire)
| # | 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` |
#### Type 2 — Verification & prerequisites
- **Verified identity** + **order / return** reference (slots as your schema defines).
#### Type 3 — L1 automation & ticketing
- **L1:** Read **refund/return pipeline state** from DB when modeled; answer with **Type 1** timelines where applicable.
- **Ticket:** State not in DB or ambiguous → ack + **ticket** after verification.
---
### 7.9 `SHIPPING_TRACKING`
#### Type 1 — Policy & messaging (vendor questionnaire)
| # | Question | `shared_field_id` |
|---|----------|-------------------|
| ST1–ST6 | Carriers, methods, cost, cutoff, regions, customs | *(as before)* |
#### Type 2 — Verification & prerequisites
- Same as **`ORDER_STATUS`** (**verified order**).
#### Type 3 — L1 automation & ticketing
- **L1:** Read **tracking fields** for that order; reply with snapshot + **Type 1** carrier/transit copy.
- **Ticket:** Carrier **claim / dispute** → **open ticket** for human.
---
### 7.10 `WRONG_ITEM` — Ticket-led
#### Type 1 — Policy & messaging (vendor questionnaire)
| # | 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` |
#### Type 2 — Verification & prerequisites
- **Order-scoped** verification + **`issue_type`** slot; **email** may include **photo** attachments.
#### Type 3 — L1 automation & ticketing
- **L1:** Ack + **Type 1** policy + structured fields for the ticket body.
- **Ticket:** **Create ticket**; human executes reship/refund/return.
---
### 7.11 `RETURN_EXCHANGE` — Ticket-led initiation
#### Type 1 — Policy & messaging (vendor questionnaire)
| # | 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_*` |
#### Type 2 — Verification & prerequisites
- **Order-scoped** verification + optional **`reason`** / return intent slot.
#### Type 3 — L1 automation & ticketing
- **L1:** Policy in reply from **Type 1** + **open ticket** (or portal link if product adds it).
- **Ticket:** **Human / ops** fulfills RMA.
---
### 7.12 `NOT_RECEIVED_MARKED_DELIVERED` — Ticket-led (replaces vague “delivery problem”)
#### Type 1 — Policy & messaging (vendor questionnaire)
| # | 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` |
#### Type 2 — Verification & prerequisites
- **Order-scoped** verification (same pattern as **`ORDER_STATUS`**).
#### Type 3 — L1 automation & ticketing
- **L1:** Last **tracking snapshot** + **Type 1** snippet → then **always open ticket** for investigation.
- **Ticket:** **Human** handles carrier / claim (**§0.1**).
---
### 7.13 `SHIPMENT_STUCK_OR_DELAYED` — Ticket-led
#### Type 1 — Policy & messaging (vendor questionnaire)
| # | 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` |
#### Type 2 — Verification & prerequisites
- **Order-scoped** verification.
#### Type 3 — L1 automation & ticketing
- **L1:** **Last scan dates** + **Type 1** → **open ticket** for human carrier/ops follow-up.
---
### 7.14 `PAYMENT_PROBLEM` — Ticket-led (prerequisites vary)
#### Type 1 — Policy & messaging (vendor questionnaire)
| # | 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` |
#### Type 2 — Verification & prerequisites
- **Order-scoped** issues: **verified identity + order id**.
- **Pre-purchase / checkout** failures: may only have **email + narrative** → ticket with available slots.
#### Type 3 — L1 automation & ticketing
- **L1:** Structured intake + **ticket**; **no** automated money movement.
- **Ticket:** **Human** + finance tools.
---
### 7.15 `ORDER_CHANGE` — Ticket-led only
#### Type 1 — Policy & messaging (vendor questionnaire)
| # | Question | `shared_field_id` |
|---|----------|-------------------|
| OC1–OC4 | Until when changes are *sometimes* possible, how to request, impossible case, cancel alternative | `sf_order_change_*`, `sf_cancellation_*` |
#### Type 2 — Verification & prerequisites
- **Order-scoped** verification + capture **requested change** (address/items/qty — slots).
#### Type 3 — L1 automation & ticketing
- **L1 v1:** **No** auto-mutate order — **ack + open ticket** only.
- **Ticket:** **Human** decides feasible vs offer cancel path from **Type 1**.
---
### 7.16 `WARRANTY` — Ticket-led
#### Type 1 — Policy & messaging (vendor questionnaire)
| # | Question | `shared_field_id` |
|---|----------|-------------------|
| WA1–WA5 | Scope, voiders, process, timeline optional, escalation | `sf_warranty_*`, `sf_complaint_supervisor_escalation` |
#### Type 2 — Verification & prerequisites
- **Verified identity** + **proof-of-purchase / order** reference.
#### Type 3 — L1 automation & ticketing
- **L1:** Intake + **Type 1** + **ticket**.
- **Ticket:** **Human** adjudicates.
---
### 7.17 `PRODUCT_FIT`
#### Type 1 — Policy & messaging (vendor questionnaire)
| # | 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` |
#### Type 2 — Verification & prerequisites
- **Optional** **product** slot (name/SKU) for specificity; no order required for pure fit FAQ.
#### Type 3 — L1 automation & ticketing
- **L1:** KB + optional **`CATEGORY_METADETAILS`** tools when enabled (**§7.20**).
- **Ticket / human:** Edge cases per **`sf_human_escalation_when`**.
---
### 7.18 `STORE_LOCAL`
#### Type 1 — Policy & messaging (vendor questionnaire)
| # | 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` |
#### Type 2 — Verification & prerequisites
- **None** for typical **Knowledge** replies.
#### Type 3 — L1 automation & ticketing
- **L1:** **KB-only** from **Type 1**; no order tools required.
---
### 7.19 `FEEDBACK`
#### Type 1 — Policy & messaging (vendor questionnaire)
| # | 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` |
#### Type 2 — Verification & prerequisites
- Optional link to **prior ticket** / context if product supports it.
#### Type 3 — L1 automation & ticketing
- **L1:** CSAT link / one-shot ask per **Type 1**.
- **Human:** Negative follow-up path if configured.
---
### 7.20 `CATEGORY_METADETAILS` — Category metadetails (store-wide, DB via tools)
This module is **special**: there is **no Type 1 questionnaire** — only a **store-level enable/disable** (see below). Policy copy for *when* to prefer PDP vs human still lives in **`PRODUCT_FIT`** / **`FAQ`**.
#### Type 1 — Policy & messaging (vendor questionnaire)
- **None.** **Vendor configuration:** **one toggle only** — **Enabled** or **Disabled** for the store. **No** per-category or per-product granularity in v1.
#### Type 2 — Verification & prerequisites
- Vendor **enables** the feature; sync pipeline persists **category metadetails** in DB.
- **Slots:** resolve **product → category** (e.g. name/SKU → category) before tool calls; tools return **fields**, not guesses.
#### Type 3 — L1 automation & ticketing
**When enabled**
- **L1** may invoke **tool calls** to read **category metadetails** (and related synced fields — exact schema in engineering doc).
- Coverage: **all products** in synced categories (e.g. sizing, materials, care, compatibility at **category** level).
**When disabled**
- Do **not** call category-metadetails tools for customer-facing answers.
- Fall back to **`PRODUCT_FIT` / FAQ**, PDP links, or escalation.
**Ticketing**
- If data missing, disabled, or intent unclear → **ticket** or clarify per **`ALL`** / **`PRODUCT_FIT`**.
---
## 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)
- [ ] Map each module to **§2.4**: **Type 1** = which `shared_field_id` keys feed prompts; **Type 2** = `verification_gate` + `required_slots`; **Type 3** = `l1_actions[]`, ticket triggers, tool allowlist.
- [ ] `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`
---
*End of document.*

1
package-lock.json generated
View File

@ -10173,7 +10173,6 @@
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"optional": true, "optional": true,
"os": [ "os": [

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Store" ADD COLUMN IF NOT EXISTS "modulePolicies" JSONB;

View File

@ -0,0 +1 @@
ALTER TABLE "Store" ADD COLUMN IF NOT EXISTS "sharedFieldAnswers" JSONB;

View File

@ -124,7 +124,11 @@ model Store {
categoryMetadetailsEnabled Boolean @default(false) categoryMetadetailsEnabled Boolean @default(false)
/** Per-module AI enablement, e.g. { "FAQ": true, "ORDER_STATUS": true }. */ /** Per-module AI enablement, e.g. { "FAQ": true, "ORDER_STATUS": true }. */
aiModules Json? aiModules Json?
/** FAQ-style policy text per L1 module for embeddings, e.g. { "ORDER_STATUS": "..." }. */
modulePolicies Json?
/** Vendor Type-1 answers keyed by shared_field_id (sf_*); one value per key store-wide (§4.2). */
sharedFieldAnswers Json?
// Relations // Relations
connections Connection[] connections Connection[]
cachedProducts CachedProduct[] cachedProducts CachedProduct[]

View File

@ -1,291 +0,0 @@
#!/usr/bin/env tsx
/**
* Seed script for predefined JUNO email templates.
*
* Usage:
* tsx scripts/seed-templates.ts
*
* Behaviour:
* - Finds a target store:
* - If SEED_STORE_ID is set, uses that storeId
* - Otherwise, uses the first active store it can find
* - Inserts a small library of templates for common intents/moods
* - Idempotent: skips creating a template if one with the same
* (storeId, intent, mood, name) already exists.
*/
import { prisma } from "../src/lib/prisma"
import { logger } from "../src/lib/logger"
import { randomUUID } from "crypto"
type SeedTemplate = {
name: string
intent: string
mood: string
description: string
body: string
}
const SEED_TEMPLATES: SeedTemplate[] = [
// Shipping delay - angry customer
{
name: "Shipping delay – angry customer",
intent: "shipping_delay",
mood: "angry",
description:
"Apology and clear acknowledgement for an angry customer asking where their order is.",
body: `Hi {{customer_name}},
I'm really sorry for the frustration here. I can see why you'd be upset, and I appreciate you taking the time to reach out.
We've reviewed your message and we're looking into the status of your order right away. {{ticket_summary}}
As soon as we have an updated timeline from our shipping partner, we'll follow up with you by email. If you have any extra details you think might help, you can reply directly to this message.
Thank you for your patience while we sort this out.
Best regards,
{{store_name}} Support Team`,
},
// Shipping delay - confused / neutral customer
{
name: "Shipping delay – confused customer",
intent: "shipping_delay",
mood: "confused",
description:
"Reassuring response for a customer who is unsure where their order is.",
body: `Hi {{customer_name}},
Thanks for reaching out about your order – I’m glad you checked in.
We’ve received your message and we’re reviewing your order details now. {{ticket_summary}}
We’re confirming the latest tracking information with our shipping partner and will follow up with an update as soon as we have it. In the meantime, if you notice any changes or receive a new tracking email, feel free to reply here so we can keep everything in one place.
Best regards,
{{store_name}} Support Team`,
},
// Refund / return request – neutral
{
name: "Refund or return – neutral",
intent: "refund_request",
mood: "neutral",
description:
"Professional acknowledgement of a refund/return request with next steps.",
body: `Hi {{customer_name}},
Thank you for contacting us about your refund/return request.
We’ve reviewed your message and we’re now looking into the order details so we can process this correctly. {{ticket_summary}}
In some cases we may need a bit more information (for example photos of the item or confirmation of the order number). If that’s needed, we’ll reply back with a short checklist so you know exactly what to send.
Once everything is confirmed, we’ll process your refund/return and email you a final confirmation.
Best regards,
{{store_name}} Support Team`,
},
// Refund / return request – appreciative / happy
{
name: "Refund or return – polite customer",
intent: "refund_request",
mood: "happy",
description:
"Warm, appreciative reply when the customer is polite and reasonable about a refund/return.",
body: `Hi {{customer_name}},
Thank you for your thoughtful message – we really appreciate how clearly you explained the situation.
We’ve started reviewing your request and the details of your order. {{ticket_summary}}
If we need anything else from you (for example photos or a bit more context), we’ll let you know right away. Otherwise, we’ll process your refund/return and follow up with a confirmation email as soon as it’s completed.
Thank you again for shopping with {{store_name}}.
Warm regards,
{{store_name}} Support Team`,
},
// Product question / general support – neutral
{
name: "Product question – we’re checking",
intent: "question",
mood: "neutral",
description:
"Neutral response letting the customer know their product question is being reviewed.",
body: `Hi {{customer_name}},
Thanks for reaching out with your question – we’re glad you asked.
We’ve received your message and we’re checking the details so we can give you a clear answer. {{ticket_summary}}
As soon as we’ve confirmed everything with the relevant team, we’ll follow up with a detailed reply. If you have any extra information or screenshots that might help, you can reply to this email and they’ll be added to your ticket.
Best regards,
{{store_name}} Support Team`,
},
// Product question – confused customer
{
name: "Product question – confused customer",
intent: "question",
mood: "confused",
description:
"Reassuring answer for a customer who is confused about how something works.",
body: `Hi {{customer_name}},
Thank you for reaching out and for explaining what’s been confusing – you’re not alone, and we’re here to help.
We’ve received your message and we’re reviewing the details so we can walk you through this step by step. {{ticket_summary}}
Once we’ve double-checked everything, we’ll follow up with a clear explanation and, if helpful, some simple steps you can follow. If you have any screenshots or additional details, feel free to reply to this email so we can see exactly what you’re seeing.
Warm regards,
{{store_name}} Support Team`,
},
// Account / billing issue – frustrated customer
{
name: "Account or billing – concerned customer",
intent: "account_billing",
mood: "angry",
description:
"Empathetic response when a customer is upset about a billing or account issue.",
body: `Hi {{customer_name}},
I’m really sorry for the worry and frustration this billing issue has caused – I completely understand how important this is.
We’ve received your message and we’re reviewing your account and recent charges as a priority. {{ticket_summary}}
If we need any additional details to resolve this, we’ll let you know exactly what we need and why. Otherwise, we’ll follow up as soon as we have a full explanation and next steps for you.
Thank you for your patience while we get to the bottom of this.
Best regards,
{{store_name}} Support Team`,
},
// Account / billing issue – neutral
{
name: "Account or billing – investigation",
intent: "account_billing",
mood: "neutral",
description:
"Calm, neutral response acknowledging an account or billing question and starting an investigation.",
body: `Hi {{customer_name}},
Thanks for getting in touch about your account/billing question.
We’ve received your message and we’re reviewing your account details and recent activity. {{ticket_summary}}
If we need any additional information from you, we’ll reply with a short list so it’s easy to provide. Once we’ve completed our review, we’ll follow up with a clear explanation of what we found and, if needed, any adjustments we’ll make.
Best regards,
{{store_name}} Support Team`,
},
// Thank-you / positive feedback – happy customer
{
name: "Thank-you – happy customer",
intent: "general",
mood: "happy",
description:
"Warm reply to a customer who sends positive feedback or a thank-you message.",
body: `Hi {{customer_name}},
Thank you so much for your kind message – it truly made our day here at {{store_name}}.
We really appreciate you taking the time to share your experience. {{ticket_summary}}
If there’s ever anything else we can help with, just reply to this email and we’ll be glad to assist.
Warm regards,
{{store_name}} Support Team`,
},
]
async function getTargetStoreId(): Promise<string> {
const explicitId = process.env.SEED_STORE_ID
if (explicitId) {
const store = await prisma.store.findUnique({
where: { id: explicitId },
select: { id: true },
})
if (!store) {
throw new Error(`SEED_STORE_ID is set, but no store found with id=${explicitId}`)
}
return store.id
}
const store = await prisma.store.findFirst({
where: { isActive: true },
select: { id: true },
})
if (!store) {
throw new Error(
"No active store found. Set SEED_STORE_ID to a valid store id before running this script.",
)
}
return store.id
}
async function seedTemplates() {
try {
logger.info("Starting template seeding...")
const storeId = await getTargetStoreId()
logger.info(`Seeding templates for storeId=${storeId}`)
for (const tpl of SEED_TEMPLATES) {
// Check if a template with the same (storeId, intent, mood, name) already exists
const existing = await prisma.$queryRaw<
{ id: string }[]
>`
SELECT "id"
FROM "Template"
WHERE "storeId" = ${storeId}
AND "intent" = ${tpl.intent}
AND "mood" = ${tpl.mood}
AND "name" = ${tpl.name}
LIMIT 1
`
if (existing.length > 0) {
logger.info(
`Skipping existing template: "${tpl.name}" (intent=${tpl.intent}, mood=${tpl.mood})`,
)
continue
}
const id = randomUUID()
await prisma.$executeRaw`
INSERT INTO "Template"
("id", "storeId", "name", "intent", "mood", "description", "body", "variables", "isHtml", "createdAt", "updatedAt")
VALUES
(${id}, ${storeId}, ${tpl.name}, ${tpl.intent}, ${tpl.mood}, ${tpl.description}, ${tpl.body}, NULL, FALSE, NOW(), NOW())
`
logger.info(
`Inserted template: "${tpl.name}" (intent=${tpl.intent}, mood=${tpl.mood})`,
)
}
logger.info("Template seeding completed.")
} catch (error) {
logger.error("Template seeding failed:", error)
process.exit(1)
} finally {
await prisma.$disconnect()
}
}
if (require.main === module) {
seedTemplates()
.then(() => {
logger.info("Seed script finished successfully.")
process.exit(0)
})
.catch((error) => {
logger.error("Seed script encountered an error:", error)
process.exit(1)
})
}

View File

@ -1,241 +0,0 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { isVendorPortalRole } from "@/lib/auth-roles"
import { prisma } from "@/lib/prisma"
import { logger } from "@/lib/logger"
import { junoTemplatePatchSchema } from "@/lib/api-schemas/vendorMutations"
import { validationErrorResponse } from "@/lib/api-schemas/validate"
type TemplateRow = {
id: string
storeId: string
name: string
intent: string
mood: string
description: string | null
body: string
variables: unknown | null
isHtml: boolean
updatedAt: Date
}
async function getActiveStoreId(userId: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
stores: {
where: { isActive: true },
take: 1,
select: { id: true },
},
},
})
return user?.stores[0]?.id ?? null
}
export async function GET(
_req: Request,
{ params }: { params: Promise<{ templateId: string }> },
) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
if (!isVendorPortalRole(session.user.role)) {
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
}
const { templateId } = await params
const storeId = await getActiveStoreId(session.user.id)
if (!storeId) {
return NextResponse.json(
{ message: "No active store found" },
{ status: 400 },
)
}
const rows = await prisma.$queryRaw<TemplateRow[]>`
SELECT
"id",
"storeId",
"name",
"intent",
"mood",
"description",
"body",
"variables",
"isHtml",
"updatedAt"
FROM "Template"
WHERE "id" = ${templateId} AND "storeId" = ${storeId}
LIMIT 1
`
const row = rows[0]
if (!row) {
return NextResponse.json({ message: "Template not found" }, { status: 404 })
}
const template = {
...row,
variables: row.variables ?? undefined,
updatedAt: row.updatedAt.toISOString(),
}
return NextResponse.json({ template })
} catch (error) {
logger.error("Failed to load template", error)
return NextResponse.json(
{ message: "Failed to load template" },
{ status: 500 },
)
}
}
export async function PUT(
req: Request,
{ params }: { params: Promise<{ templateId: string }> },
) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
if (!isVendorPortalRole(session.user.role)) {
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
}
const { templateId } = await params
const storeId = await getActiveStoreId(session.user.id)
if (!storeId) {
return NextResponse.json(
{ message: "No active store found" },
{ status: 400 },
)
}
const existingRows = await prisma.$queryRaw<Pick<TemplateRow, "id">[]>`
SELECT "id"
FROM "Template"
WHERE "id" = ${templateId} AND "storeId" = ${storeId}
LIMIT 1
`
const existing = existingRows[0]
if (!existing) {
return NextResponse.json({ message: "Template not found" }, { status: 404 })
}
const raw = await req.json().catch(() => ({}))
const parsed = junoTemplatePatchSchema.safeParse(raw)
if (!parsed.success) {
return validationErrorResponse(parsed.error)
}
const {
name,
intent,
mood,
description,
body: templateBody,
variables,
isHtml,
} = parsed.data
const rows = await prisma.$queryRaw<TemplateRow[]>`
UPDATE "Template"
SET
"name" = COALESCE(${name}, "name"),
"intent" = COALESCE(${intent}, "intent"),
"mood" = COALESCE(${mood}, "mood"),
"description" = COALESCE(${description ?? null}, "description"),
"body" = COALESCE(${templateBody}, "body"),
"variables" = COALESCE(${variables as Record<string, unknown> | null}, "variables"),
"isHtml" = COALESCE(${isHtml}, "isHtml"),
"updatedAt" = NOW()
WHERE "id" = ${existing.id}
RETURNING
"id",
"storeId",
"name",
"intent",
"mood",
"description",
"body",
"variables",
"isHtml",
"updatedAt"
`
const row = rows[0]
const updated = {
...row,
variables: row.variables ?? undefined,
updatedAt: row.updatedAt.toISOString(),
}
return NextResponse.json({ template: updated })
} catch (error) {
logger.error("Failed to update template", error)
return NextResponse.json(
{ message: "Failed to update template" },
{ status: 500 },
)
}
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ templateId: string }> },
) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
if (!isVendorPortalRole(session.user.role)) {
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
}
const { templateId } = await params
const storeId = await getActiveStoreId(session.user.id)
if (!storeId) {
return NextResponse.json(
{ message: "No active store found" },
{ status: 400 },
)
}
const existingRows = await prisma.$queryRaw<Pick<TemplateRow, "id">[]>`
SELECT "id"
FROM "Template"
WHERE "id" = ${templateId} AND "storeId" = ${storeId}
LIMIT 1
`
const existing = existingRows[0]
if (!existing) {
return NextResponse.json({ message: "Template not found" }, { status: 404 })
}
await prisma.$executeRaw`
DELETE FROM "Template"
WHERE "id" = ${existing.id}
`
return NextResponse.json({ success: true })
} catch (error) {
logger.error("Failed to delete template", error)
return NextResponse.json(
{ message: "Failed to delete template" },
{ status: 500 },
)
}
}

View File

@ -1,171 +0,0 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { isVendorPortalRole } from "@/lib/auth-roles"
import { prisma } from "@/lib/prisma"
import { logger } from "@/lib/logger"
import { randomUUID } from "crypto"
import { junoTemplateCreateSchema } from "@/lib/api-schemas/vendorMutations"
import { validationErrorResponse } from "@/lib/api-schemas/validate"
type TemplateRow = {
id: string
storeId: string
name: string
intent: string
mood: string
description: string | null
body: string
variables: unknown | null
isHtml: boolean
updatedAt: Date
}
export async function GET(req: Request) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
if (!isVendorPortalRole(session.user.role)) {
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
}
const url = new URL(req.url)
const intent = url.searchParams.get("intent") || undefined
const mood = url.searchParams.get("mood") || undefined
const user = await prisma.user.findUnique({
where: { id: session.user.id },
include: {
stores: {
where: { isActive: true },
take: 1,
select: { id: true },
},
},
})
const activeStore = user?.stores[0]
if (!activeStore) {
return NextResponse.json({ templates: [] })
}
// Use raw SQL so we don't depend on generated Prisma delegates
const rows = await prisma.$queryRaw<TemplateRow[]>`
SELECT
"id",
"storeId",
"name",
"intent",
"mood",
"description",
"body",
"variables",
"isHtml",
"updatedAt"
FROM "Template"
WHERE "storeId" = ${activeStore.id}
ORDER BY "updatedAt" DESC
`
const filtered =
intent || mood
? rows.filter((tpl: TemplateRow) => {
if (intent && tpl.intent !== intent) return false
if (mood && tpl.mood !== mood) return false
return true
})
: rows
const templates = filtered.map((tpl: TemplateRow) => ({
...tpl,
variables: tpl.variables ?? undefined,
updatedAt: tpl.updatedAt.toISOString(),
}))
return NextResponse.json({ templates })
} catch (error) {
logger.error("Failed to load templates", error)
return NextResponse.json(
{ message: "Failed to load templates", templates: [] },
{ status: 500 },
)
}
}
export async function POST(req: Request) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
if (!isVendorPortalRole(session.user.role)) {
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
include: {
stores: {
where: { isActive: true },
take: 1,
select: { id: true },
},
},
})
const activeStore = user?.stores[0]
if (!activeStore) {
return NextResponse.json(
{ message: "No active store found" },
{ status: 400 },
)
}
const raw = await req.json().catch(() => ({}))
const parsed = junoTemplateCreateSchema.safeParse(raw)
if (!parsed.success) {
return validationErrorResponse(parsed.error)
}
const { name, intent, mood, description, body: templateBody, variables, isHtml } =
parsed.data
const id = randomUUID()
const rows = await prisma.$queryRaw<TemplateRow[]>`
INSERT INTO "Template"
("id", "storeId", "name", "intent", "mood", "description", "body", "variables", "isHtml", "createdAt", "updatedAt")
VALUES
(${id}, ${activeStore.id}, ${name}, ${intent}, ${mood}, ${description ?? null}, ${templateBody}, ${variables as Record<string, unknown> | null}, ${isHtml ?? true}, NOW(), NOW())
RETURNING
"id",
"storeId",
"name",
"intent",
"mood",
"description",
"body",
"variables",
"isHtml",
"updatedAt"
`
const inserted = rows[0]
const template = {
...inserted,
variables: inserted.variables ?? undefined,
updatedAt: inserted.updatedAt.toISOString(),
}
return NextResponse.json({ template }, { status: 201 })
} catch (error) {
logger.error("Failed to create template", error)
return NextResponse.json(
{ message: "Failed to create template" },
{ status: 500 },
)
}
}

View File

@ -124,8 +124,9 @@ export async function POST(req: Request) {
senderType: m.senderType, senderType: m.senderType,
content: m.content, content: m.content,
})), })),
categoryMetadetailsEnabled: store.categoryMetadetailsEnabled,
aiModulesJson: store.aiModules, aiModulesJson: store.aiModules,
sharedFieldAnswersJson: store.sharedFieldAnswers,
categoryMetadetailsEnabled: store.categoryMetadetailsEnabled,
widgetVerifiedEmail: sessionOk ? conversation.assertedEmail : null, widgetVerifiedEmail: sessionOk ? conversation.assertedEmail : null,
widgetSessionExpiresAt: sessionOk ? conversation.sessionExpiresAt : null, widgetSessionExpiresAt: sessionOk ? conversation.sessionExpiresAt : null,
boundShopifyOrderId: conversation.boundShopifyOrderId, boundShopifyOrderId: conversation.boundShopifyOrderId,

View File

@ -4,12 +4,11 @@ import { prisma } from "@/lib/prisma"
import { logger } from "@/lib/logger" import { logger } from "@/lib/logger"
import { import {
knowledgeTemplatePutSchema, knowledgeTemplatePutSchema,
normalizeTemplateAnswers, mergeTemplateAnswers,
parseKnowledgeTemplateJson, parseKnowledgeTemplateJson,
buildKnowledgeTemplateStored,
type KnowledgeTemplateStored, type KnowledgeTemplateStored,
} from "@/lib/kb/knowledgeTemplate" } from "@/lib/kb/knowledgeTemplate"
import { replaceStructuredKnowledgeChunks } from "@/lib/kb/structuredTemplateChunks" import { replaceStructuredKnowledgeChunksForStore } from "@/lib/kb/structuredTemplateChunks"
import { import {
deleteDocumentKbForStore, deleteDocumentKbForStore,
deleteFaqKbForStore, deleteFaqKbForStore,
@ -172,35 +171,10 @@ export async function PUT(
} }
const prev = parseKnowledgeTemplateJson(store.knowledgeTemplate) const prev = parseKnowledgeTemplateJson(store.knowledgeTemplate)
const mergedAnswers = { const stored: KnowledgeTemplateStored = mergeTemplateAnswers(
...(prev?.legacyAnswers ?? {}), prev,
...normalizeTemplateAnswers(body.template.answers), body.template.answers as Record<string, string> | undefined,
} )
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
@ -213,7 +187,7 @@ export async function PUT(
}, },
}) })
await replaceStructuredKnowledgeChunks(store.id, stored) await replaceStructuredKnowledgeChunksForStore(store.id)
} }
const refreshed = await prisma.store.findFirst({ const refreshed = await prisma.store.findFirst({

View File

@ -3,6 +3,13 @@ import { auth } from "@/auth"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { Prisma } from "@prisma/client" import { Prisma } from "@prisma/client"
import { storeSelectPatchStorefrontAi } from "@/lib/prisma/storeSelects" import { storeSelectPatchStorefrontAi } from "@/lib/prisma/storeSelects"
import { modulePoliciesBodySchema, parseModulePoliciesJson } from "@/lib/kb/modulePolicies"
import {
parseSharedFieldAnswersJson,
sharedFieldAnswersBodySchema,
sharedFieldAnswersPayload,
} from "@/lib/kb/sharedFieldAnswers"
import { replaceStructuredKnowledgeChunksForStore } from "@/lib/kb/structuredTemplateChunks"
import { import {
getPublishedThemeId, getPublishedThemeId,
getThemeAsset, getThemeAsset,
@ -521,6 +528,55 @@ async function syncStorefrontTheme(params: {
console.log("[STOREFRONT_CHAT_THEME_SYNC_OK]", { storeId: params.storeId, enabled: params.enabled, themeId }) console.log("[STOREFRONT_CHAT_THEME_SYNC_OK]", { storeId: params.storeId, enabled: params.enabled, themeId })
} }
/**
* GET /api/stores/[storeId]
* Read storefront + L1 AI settings for the authenticated vendor.
*/
export async function GET(
_request: Request,
{ params }: { params: Promise<{ storeId: string }> },
) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
const { storeId } = await params
if (!storeId) {
return NextResponse.json({ message: "Store ID is required" }, { status: 400 })
}
const store = await prisma.store.findFirst({
where: { id: storeId, userId: session.user.id },
select: {
id: true,
businessName: true,
categoryMetadetailsEnabled: true,
aiModules: true,
modulePolicies: true,
sharedFieldAnswers: true,
},
})
if (!store) {
return NextResponse.json({ message: "Store not found" }, { status: 404 })
}
return NextResponse.json({
id: store.id,
businessName: store.businessName,
categoryMetadetailsEnabled: store.categoryMetadetailsEnabled,
aiModules: store.aiModules,
modulePolicies: parseModulePoliciesJson(store.modulePolicies),
sharedFieldAnswers: parseSharedFieldAnswersJson(store.sharedFieldAnswers),
})
} catch (error) {
console.error("Error reading store settings:", error)
return NextResponse.json({ message: "Failed to read store" }, { status: 500 })
}
}
export async function PATCH( export async function PATCH(
request: Request, request: Request,
{ params }: { params: Promise<{ storeId: string }> } { params }: { params: Promise<{ storeId: string }> }
@ -582,6 +638,39 @@ export async function PATCH(
updatePayload.aiModules = data.aiModules as Prisma.InputJsonValue updatePayload.aiModules = data.aiModules as Prisma.InputJsonValue
} }
let reembedStructured = false
if (data.modulePolicies !== undefined) {
const parsed = modulePoliciesBodySchema.safeParse(data.modulePolicies)
if (!parsed.success) {
return NextResponse.json(
{ message: "Invalid modulePolicies", issues: parsed.error.flatten() },
{ status: 400 },
)
}
const normalized = parseModulePoliciesJson(parsed.data)
updatePayload.modulePolicies =
Object.keys(normalized).length === 0
? Prisma.JsonNull
: (normalized as Prisma.InputJsonValue)
reembedStructured = true
}
if (data.sharedFieldAnswers !== undefined) {
const parsed = sharedFieldAnswersBodySchema.safeParse(data.sharedFieldAnswers)
if (!parsed.success) {
return NextResponse.json(
{ message: "Invalid sharedFieldAnswers", issues: parsed.error.flatten() },
{ status: 400 },
)
}
const normalized = sharedFieldAnswersPayload(parsed.data)
updatePayload.sharedFieldAnswers =
Object.keys(normalized).length === 0
? Prisma.JsonNull
: (normalized as Prisma.InputJsonValue)
reembedStructured = true
}
const updatedStore = await prisma.store.update({ const updatedStore = await prisma.store.update({
where: { id: storeId }, where: { id: storeId },
data: updatePayload, data: updatePayload,
@ -609,6 +698,14 @@ export async function PATCH(
} }
} }
if (reembedStructured) {
try {
await replaceStructuredKnowledgeChunksForStore(storeId)
} catch (e) {
console.error("[MODULE_POLICIES_REEMBED_ERR]", e)
}
}
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
storefrontChatEnabled: updatedStore.storefrontChatEnabled, storefrontChatEnabled: updatedStore.storefrontChatEnabled,
@ -619,6 +716,8 @@ export async function PATCH(
storefrontChatLogoUrl: updatedStore.storefrontChatLogoUrl, storefrontChatLogoUrl: updatedStore.storefrontChatLogoUrl,
categoryMetadetailsEnabled: updatedStore.categoryMetadetailsEnabled, categoryMetadetailsEnabled: updatedStore.categoryMetadetailsEnabled,
aiModules: updatedStore.aiModules, aiModules: updatedStore.aiModules,
modulePolicies: parseModulePoliciesJson(updatedStore.modulePolicies),
sharedFieldAnswers: parseSharedFieldAnswersJson(updatedStore.sharedFieldAnswers),
}) })
} catch (error) { } catch (error) {
console.error("Error updating store settings:", error) console.error("Error updating store settings:", error)

View File

@ -58,7 +58,7 @@ const faqs: FAQItem[] = [
{ {
id: "5", id: "5",
question: "What is JUNO Engine?", question: "What is JUNO Engine?",
answer: "JUNO Engine is our communication and support infrastructure. It includes ticket management, email flagging, Gmail integration, and email templates to help you manage customer communications efficiently.", answer: "JUNO Engine is our communication and support infrastructure. It includes ticket views from email activity, email flagging, Gmail integration, and L1 AI replies driven by your knowledge base and module settings (JUNO Engine → Modules).",
category: "general" category: "general"
}, },
{ {

View File

@ -1,612 +0,0 @@
"use client"
import { useState, useEffect, useMemo } from "react"
import { createPortal } from "react-dom"
import { motion, AnimatePresence } from "framer-motion"
import { FileText, Search, Plus, Loader2, Edit, Trash2 } from "lucide-react"
import { renderTemplate } from "@/lib/templates/simpleRenderer"
import { useTheme } from "@/components/ThemeProvider"
type TemplateIntent =
| "general"
| "shipping_delay"
| "refund_request"
| "question"
| "account_billing"
| "off_topic"
type TemplateMood = "neutral" | "angry" | "confused" | "happy"
interface Template {
id: string
storeId: string
name: string
intent: TemplateIntent | string
mood: TemplateMood | string
description: string | null
body: string
variables?: unknown
isHtml: boolean
updatedAt: string
}
const INTENT_OPTIONS: { value: TemplateIntent; label: string }[] = [
{ value: "general", label: "General" },
{ value: "shipping_delay", label: "Shipping delay" },
{ value: "refund_request", label: "Refund request" },
{ value: "question", label: "General question" },
{ value: "account_billing", label: "Account / billing" },
{ value: "off_topic", label: "Off-topic (not store-related)" },
]
const MOOD_OPTIONS: { value: TemplateMood; label: string }[] = [
{ value: "neutral", label: "Neutral" },
{ value: "angry", label: "Frustrated / Angry" },
{ value: "confused", label: "Confused" },
{ value: "happy", label: "Happy" },
]
const exampleContext = {
customer_name: "Alex",
customer_email: "alex@example.com",
store_name: "JUNOVendor",
store_email: "support@junovendor.com",
subject: "Where is my order?",
ticket_summary: "Customer is asking about a shipping delay on order #1234.",
order_summary:
"Order #1001 (2025-03-01) | financial: paid | fulfillment: fulfilled | total: 49.99 USD | items: Tee ×1",
}
function isAiGeneratedTemplate(variables: unknown): boolean {
if (!variables || typeof variables !== "object") return false
return (variables as Record<string, unknown>).source === "ai-generated"
}
export default function EmailTemplatesPage() {
const [searchTerm, setSearchTerm] = useState("")
const [templates, setTemplates] = useState<Template[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [editing, setEditing] = useState<Template | null>(null)
const [saving, setSaving] = useState(false)
const [mounted, setMounted] = useState(false)
const { theme } = useTheme()
const isLight = theme === "light"
useEffect(() => {
setMounted(true)
}, [])
const loadTemplates = async () => {
try {
setLoading(true)
setError(null)
const res = await fetch("/api/juno-engine/templates", { cache: "no-store" })
if (!res.ok) {
throw new Error("Failed to load templates")
}
const data = (await res.json()) as { templates: Template[] }
setTemplates(data.templates || [])
} catch (err) {
console.error(err)
setError("Could not load templates. Please try again.")
} finally {
setLoading(false)
}
}
useEffect(() => {
void loadTemplates()
}, [])
const filteredTemplates = useMemo(() => {
if (!searchTerm.trim()) return templates
const q = searchTerm.toLowerCase()
return templates.filter((tpl) =>
[tpl.name, tpl.intent, tpl.mood, tpl.description || ""]
.join(" ")
.toLowerCase()
.includes(q),
)
}, [templates, searchTerm])
const startNewTemplate = () => {
setEditing({
id: "",
storeId: "",
name: "",
intent: "general",
mood: "neutral",
description: "",
body:
"Hi {{customer_name}},\n\nThank you for reaching out. {{ticket_summary}}\n\nBest regards,\n{{store_name}} Support Team",
variables: undefined,
isHtml: false,
updatedAt: new Date().toISOString(),
})
}
const startEditTemplate = (template: Template) => {
setEditing(template)
}
const handleDelete = async (template: Template) => {
if (!window.confirm(`Delete template "${template.name}"?`)) return
try {
const res = await fetch(
`/api/juno-engine/templates/${encodeURIComponent(template.id)}`,
{ method: "DELETE" },
)
if (!res.ok) throw new Error("Failed to delete template")
setTemplates((prev) => prev.filter((t) => t.id !== template.id))
} catch (err) {
console.error(err)
alert("Failed to delete template. Please try again.")
}
}
const handleSave = async () => {
if (!editing) return
if (!editing.name.trim()) {
alert("Template name is required.")
return
}
if (!editing.body.trim()) {
alert("Template body is required.")
return
}
try {
setSaving(true)
const payload = {
name: editing.name.trim(),
intent: editing.intent,
mood: editing.mood,
description: editing.description || null,
body: editing.body,
variables: editing.variables,
isHtml: editing.isHtml,
}
if (!editing.id) {
const res = await fetch("/api/juno-engine/templates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error("Failed to create template")
const data = (await res.json()) as { template: Template }
setTemplates((prev) => [data.template, ...prev])
setEditing(null)
} else {
const res = await fetch(
`/api/juno-engine/templates/${encodeURIComponent(editing.id)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
)
if (!res.ok) throw new Error("Failed to update template")
const data = (await res.json()) as { template: Template }
setTemplates((prev) =>
prev.map((t) => (t.id === data.template.id ? data.template : t)),
)
setEditing(null)
}
} catch (err) {
console.error(err)
alert("Failed to save template. Please try again.")
} finally {
setSaving(false)
}
}
const currentPreview = editing
? renderTemplate(editing.body, exampleContext)
: ""
return (
<div className="h-full flex flex-col space-y-4 md:space-y-6 mt-4 md:mt-6">
<div className={`backdrop-blur-xl rounded-xl md:rounded-2xl border overflow-hidden flex flex-col flex-1 min-h-0 ${isLight ? "bg-white border-slate-200 shadow-sm" : "bg-white/5 border-white/10"
}`}>
<div className={`p-4 md:p-6 border-b flex flex-col sm:flex-row gap-3 md:gap-4 justify-between flex-shrink-0 ${isLight ? "border-slate-100 bg-slate-50/50" : "border-white/10 bg-white/5"
}`}>
<div className="relative flex-1 min-w-0">
<Search className="absolute left-3 md:left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<input
type="text"
placeholder="Search templates by name, intent, or mood..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className={`w-full h-10 md:h-11 pl-10 md:pl-11 pr-4 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500/30 transition-all text-sm font-medium ${isLight
? "bg-white border-slate-200 text-slate-900 placeholder:text-slate-400 focus:border-indigo-500"
: "bg-white/5 border-white/10 text-white placeholder:text-slate-500 focus:border-indigo-500/50"
}`}
/>
</div>
<button
onClick={startNewTemplate}
className="h-10 md:h-11 px-4 bg-indigo-500 text-white rounded-xl font-bold flex items-center gap-2 hover:bg-indigo-600 border border-indigo-400/20 transition-all text-sm shrink-0"
>
<Plus className="w-4 h-4" /> New Template
</button>
</div>
<div className="flex-1 overflow-y-auto overflow-x-auto -mx-4 md:mx-0">
<div className="min-w-full px-4 md:px-0">
{loading ? (
<div className="h-64 flex flex-col items-center justify-center text-slate-400 gap-3">
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
<p className="font-medium animate-pulse">Loading templates...</p>
</div>
) : error ? (
<div className="h-64 flex flex-col items-center justify-center text-red-400 gap-3 text-center px-4">
<p className="font-medium">{error}</p>
</div>
) : filteredTemplates.length === 0 ? (
<div className="h-64 flex flex-col items-center justify-center text-slate-400 gap-3">
<FileText className="w-10 h-10 opacity-20" />
<p className="font-medium">No email templates found.</p>
<p className="text-xs text-slate-500">
Create your first template to get started.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6 p-4 md:p-6">
{filteredTemplates.map((template) => (
<button
key={template.id}
type="button"
onClick={() => startEditTemplate(template)}
className={`backdrop-blur-xl p-4 md:p-6 rounded-xl md:rounded-2xl border transition-all group text-left ${isLight
? "bg-white border-slate-200 hover:bg-slate-50 hover:border-indigo-300 shadow-sm"
: "bg-white/5 border-white/10 hover:bg-white/[0.07] hover:border-indigo-500/30"
}`}
>
<div className="flex items-start justify-between mb-4 gap-2">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className={`font-bold text-base md:text-lg truncate group-hover:text-indigo-500 transition-colors ${isLight ? "text-slate-900" : "text-white"
}`}>
{template.name}
</h3>
{isAiGeneratedTemplate(template.variables) && (
<span
className="shrink-0 text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-md bg-violet-500/20 text-violet-300 border border-violet-500/30"
title="Created automatically from a Gmail auto-reply"
>
AI-generated
</span>
)}
</div>
<p className={`text-xs mt-1 truncate ${isLight ? "text-slate-500" : "text-slate-400"}`}>
Intent: {template.intent} • Mood:{" "}
{template.mood}
</p>
</div>
</div>
<p className={`text-xs line-clamp-3 ${isLight ? "text-slate-600" : "text-slate-300"}`}>
{template.description ||
template.body.split("\n").slice(0, 3).join(" ")}
</p>
<div className={`flex items-center justify-between pt-4 border-t mt-4 ${isLight ? "border-slate-100" : "border-white/10"
}`}>
<span className="text-[10px] text-slate-500 font-medium">
Updated{" "}
{new Date(
template.updatedAt,
).toLocaleDateString()}
</span>
<div className="flex items-center gap-2">
<span className="text-[10px] text-slate-400 uppercase tracking-wide">
Edit
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleDelete(template)
}}
className="p-1.5 hover:bg-white/10 rounded-lg text-slate-400 hover:text-red-400 transition-all"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</button>
))}
</div>
)}
</div>
</div>
</div>
{mounted && createPortal(
<AnimatePresence>
{editing && (
<div className="fixed inset-0 z-[100] flex items-center justify-center pointer-events-none p-4">
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
key="backdrop"
onClick={() => !saving && setEditing(null)}
className={`absolute inset-0 pointer-events-auto backdrop-blur-sm ${isLight ? "bg-black/40" : "bg-black/60"
}`}
/>
{/* Modal */}
<motion.div
initial={{ scale: 0.95, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.95, opacity: 0, y: 20 }}
key="modal"
className={`relative flex w-full max-h-[90vh] max-w-3xl flex-col rounded-2xl border shadow-2xl pointer-events-auto ${isLight
? "bg-white text-slate-900 border-slate-200"
: "bg-slate-900 text-slate-50 border-white/10"
}`}
>
<div
className={`flex items-center justify-between px-6 py-5 border-b ${isLight ? "border-slate-100" : "border-white/10"
}`}
>
<div>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-xl font-bold">
{editing.id ? "Edit template" : "New template"}
</h2>
{editing.id && isAiGeneratedTemplate(editing.variables) && (
<span className="text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-md bg-violet-500/20 text-violet-300 border border-violet-500/30">
AI-generated
</span>
)}
</div>
<p
className={`mt-1 text-sm ${isLight ? "text-slate-500" : "text-slate-400"
}`}
>
Use {"{{variable}}"} placeholders (e.g. {"{{order_summary}}"},{" "}
{"{{ticket_summary}}"}) to personalize replies.
</p>
</div>
<button
type="button"
onClick={() => setEditing(null)}
className={`w-10 h-10 rounded-xl flex items-center justify-center transition-colors ${isLight
? "bg-slate-100 hover:bg-slate-200 text-slate-600"
: "bg-white/10 hover:bg-white/15 text-slate-300"
}`}
>
<Plus className="w-5 h-5 rotate-45" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<label className={`block text-xs font-bold uppercase tracking-wider mb-2 ${isLight ? "text-slate-500" : "text-slate-400"
}`}>
Template Name
</label>
<input
type="text"
value={editing.name}
onChange={(e) =>
setEditing((prev) =>
prev
? { ...prev, name: e.target.value }
: prev,
)
}
placeholder="e.g. Shipping Update"
className={`w-full h-11 px-4 rounded-xl transition-all focus:outline-none focus:ring-2 focus:ring-indigo-500/30 ${isLight
? "bg-slate-50 border border-slate-200 text-slate-900 placeholder:text-slate-400 focus:border-indigo-500"
: "bg-white/5 border border-white/10 text-white placeholder:text-slate-500 focus:border-indigo-500"
}`}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className={`block text-xs font-bold uppercase tracking-wider mb-2 ${isLight ? "text-slate-500" : "text-slate-400"
}`}>
Intent
</label>
<select
value={editing.intent}
onChange={(e) =>
setEditing((prev) =>
prev
? {
...prev,
intent: e.target.value,
}
: prev,
)
}
className={`w-full h-11 px-4 rounded-xl transition-all focus:outline-none focus:ring-2 focus:ring-indigo-500/30 ${isLight
? "bg-slate-50 border border-slate-200 text-slate-900 focus:border-indigo-500"
: "bg-white/5 border border-white/10 text-white focus:border-indigo-500"
}`}
>
{INTENT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className={`block text-xs font-bold uppercase tracking-wider mb-2 ${isLight ? "text-slate-500" : "text-slate-400"
}`}>
Mood
</label>
<select
value={editing.mood}
onChange={(e) =>
setEditing((prev) =>
prev
? {
...prev,
mood: e.target.value,
}
: prev,
)
}
className={`w-full h-11 px-4 rounded-xl transition-all focus:outline-none focus:ring-2 focus:ring-indigo-500/30 ${isLight
? "bg-slate-50 border border-slate-200 text-slate-900 focus:border-indigo-500"
: "bg-white/5 border border-white/10 text-white focus:border-indigo-500"
}`}
>
{MOOD_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<div>
<label className={`block text-xs font-bold uppercase tracking-wider mb-2 ${isLight ? "text-slate-500" : "text-slate-400"
}`}>
Description (optional)
</label>
<input
type="text"
value={editing.description || ""}
onChange={(e) =>
setEditing((prev) =>
prev
? {
...prev,
description: e.target.value,
}
: prev,
)
}
placeholder="Short summary for your team"
className={`w-full h-11 px-4 rounded-xl transition-all focus:outline-none focus:ring-2 focus:ring-indigo-500/30 ${isLight
? "bg-slate-50 border border-slate-200 text-slate-900 placeholder:text-slate-400 focus:border-indigo-500"
: "bg-white/5 border border-white/10 text-white placeholder:text-slate-500 focus:border-indigo-500"
}`}
/>
</div>
</div>
<div className={`p-4 rounded-2xl border ${isLight ? "bg-indigo-50/50 border-indigo-100" : "bg-indigo-500/5 border-indigo-500/10"
}`}>
<p className={`text-xs font-bold uppercase tracking-wider mb-3 ${isLight ? "text-indigo-600" : "text-indigo-400"
}`}>
Available variables
</p>
<div className="grid grid-cols-1 gap-2">
{[
{ key: "{{customer_name}}", label: "Greeting name (from sender email, title-cased)" },
{ key: "{{customer_email}}", label: "Customer's email" },
{ key: "{{store_name}}", label: "Store name" },
{ key: "{{store_email}}", label: "Store contact email" },
{ key: "{{order_summary}}", label: "Cached orders for sender (or fallback text)" },
{ key: "{{ticket_summary}}", label: "AI summary of the message" },
{ key: "{{subject}}", label: "Email subject" },
].map((v) => (
<div key={v.key} className="flex items-center justify-between text-[11px]">
<code className={`px-2 py-0.5 rounded font-mono font-bold ${isLight ? "bg-white border border-indigo-200 text-indigo-700" : "bg-white/10 text-indigo-300"
}`}>
{v.key}
</code>
<span className={isLight ? "text-slate-500" : "text-slate-400"}>{v.label}</span>
</div>
))}
</div>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<label className={`block text-xs font-bold uppercase tracking-wider ${isLight ? "text-slate-500" : "text-slate-400"
}`}>
Template body
</label>
<span className={`text-[10px] ${isLight ? "text-slate-400" : "text-slate-500"}`}>
Markdown & HTML supported
</span>
</div>
<textarea
value={editing.body}
onChange={(e) =>
setEditing((prev) =>
prev ? { ...prev, body: e.target.value } : prev,
)
}
rows={8}
className={`w-full rounded-xl transition-all focus:outline-none focus:ring-2 focus:ring-indigo-500/30 p-4 font-mono text-xs leading-relaxed ${isLight
? "bg-slate-50 border border-slate-200 text-slate-900 focus:border-indigo-500"
: "bg-white/5 border border-white/10 text-white focus:border-indigo-500"
}`}
/>
</div>
<div className="space-y-4">
<label className={`block text-xs font-bold uppercase tracking-wider ${isLight ? "text-slate-500" : "text-slate-400"
}`}>
Preview with example data
</label>
<div className={`rounded-xl border p-5 text-sm leading-relaxed whitespace-pre-wrap ${isLight
? "bg-slate-50 border-slate-200 text-slate-800"
: "bg-slate-950 border-white/5 text-slate-300 shadow-inner"
}`}>
{currentPreview || "Preview will appear here..."}
</div>
</div>
</div>
<div className={`px-6 py-4 border-t flex items-center justify-between ${isLight ? "border-slate-100 bg-slate-50/50" : "border-white/10 bg-white/[0.02]"
}`}>
<p className={`text-[11px] font-medium ${isLight ? "text-slate-500" : "text-slate-500"}`}>
Autosaved to your draft templates.
</p>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setEditing(null)}
className={`h-10 px-6 rounded-xl font-bold transition-all border ${isLight
? "bg-white text-slate-700 hover:bg-slate-50 border-slate-200"
: "bg-white/10 text-slate-300 hover:bg-white/20 border-white/10"
}`}
>
Cancel
</button>
<button
type="button"
disabled={saving}
onClick={() => void handleSave()}
className="h-10 px-6 bg-indigo-500 text-sm font-bold text-white hover:bg-indigo-600 border border-indigo-400/20 shadow-lg shadow-indigo-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 rounded-xl"
>
{saving ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Saving...
</>
) : (
"Save template"
)}
</button>
</div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body
)}
</div>
)
}

View File

@ -198,7 +198,10 @@ function GmailConnectContent() {
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" /> <CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" />
<div> <div>
<p className="text-sm font-bold text-white">Auto Responses</p> <p className="text-sm font-bold text-white">Auto Responses</p>
<p className="text-xs text-slate-400 mt-1">Send automated replies using templates</p> <p className="text-xs text-slate-400 mt-1">
Send automated replies using L1 AI and your knowledge base (configure
under JUNO Engine → Modules)
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -133,7 +133,7 @@ export default function KnowledgeBasePage() {
const modeLabel = const modeLabel =
knowledgeBaseMode === "STRUCTURED" knowledgeBaseMode === "STRUCTURED"
? "Structured profile" ? "Store voice (structured)"
: knowledgeBaseMode === "DOCUMENTS" : knowledgeBaseMode === "DOCUMENTS"
? "Documents & FAQs" ? "Documents & FAQs"
: "Not chosen yet" : "Not chosen yet"
@ -155,8 +155,9 @@ export default function KnowledgeBasePage() {
isLight ? "text-slate-500" : "text-slate-400", isLight ? "text-slate-500" : "text-slate-400",
)} )}
> >
One source of truth per store for AI: either a guided profile or Store voice lives here; per-topic policies and automation toggles live under{" "}
uploaded docs & FAQs—not both. <span className="font-semibold">JUNO Engine → AI modules</span>. Or use uploaded
docs &amp; FAQs—not both with structured mode.
</p> </p>
</div> </div>
@ -244,7 +245,7 @@ export default function KnowledgeBasePage() {
isLight ? "text-slate-900" : "text-white", isLight ? "text-slate-900" : "text-white",
)} )}
> >
Structured profile Store voice
</p> </p>
<p <p
className={cn( className={cn(
@ -252,7 +253,7 @@ export default function KnowledgeBasePage() {
isLight ? "text-slate-500" : "text-slate-400", isLight ? "text-slate-500" : "text-slate-400",
)} )}
> >
Guided questions · opens in a panel Tone &amp; boundaries · policies in AI modules
</p> </p>
</div> </div>
<ChevronRight <ChevronRight
@ -349,13 +350,13 @@ export default function KnowledgeBasePage() {
isLight ? "text-slate-500" : "text-slate-500", isLight ? "text-slate-500" : "text-slate-500",
)} )}
> >
You&apos;re on a structured profile. Open{" "} You&apos;re on store voice (structured). Open{" "}
<button <button
type="button" type="button"
onClick={() => setStructuredOpen(true)} onClick={() => setStructuredOpen(true)}
className="font-semibold text-indigo-500 hover:underline" className="font-semibold text-indigo-500 hover:underline"
> >
Structured profile Store voice
</button>{" "} </button>{" "}
to edit, or choose{" "} to edit, or choose{" "}
<button <button
@ -377,7 +378,7 @@ export default function KnowledgeBasePage() {
isLight={isLight} isLight={isLight}
onSaved={async () => { onSaved={async () => {
await loadKb(activeStoreId!) await loadKb(activeStoreId!)
setToast("Structured profile saved and embedded.") setToast("Store voice saved and embedded.")
}} }}
/> />

View File

@ -5,7 +5,7 @@ import {
Ticket, Ticket,
Flag, Flag,
Mail, Mail,
FileText, Layers,
HelpCircle HelpCircle
} from "lucide-react" } from "lucide-react"
@ -24,9 +24,9 @@ export default function JunoEngineLayout({
icon: HelpCircle icon: HelpCircle
}, },
{ {
label: "Templates", label: "Modules",
href: "/dashboard/juno-engine/email-templates", href: "/dashboard/juno-engine/modules",
icon: FileText icon: Layers
}, },
{ {
label: "Tickets", label: "Tickets",

View File

@ -0,0 +1,915 @@
"use client"
import Link from "next/link"
import { useCallback, useEffect, useState } from "react"
import { createPortal } from "react-dom"
import { useTheme } from "@/components/ThemeProvider"
import { cn } from "@/lib/utils"
import {
moduleAnyEnabled,
parseAiModulesJson,
serializeAiModules,
type AutomationFlags,
type AutomationKey,
} from "@/lib/ai/orchestrator/aiModules"
import { automationRowsForModule } from "@/lib/ai/orchestrator/moduleAutomationUi"
import {
L1_MODULE_CARD_TITLE,
L1_MODULE_CUSTOMER_LABEL,
L1_TICKET_MODULES_UI,
} from "@/lib/ai/orchestrator/vendorModuleGroups"
import type { L1Module } from "@/lib/ai/orchestrator/types"
import { L1_MODULE_IDS } from "@/lib/ai/orchestrator/types"
import {
MODULE_THREE_TYPES,
SHARED_FIELD_SHORT_LABEL,
} from "@/lib/ai/orchestrator/moduleThreeTypes"
import { parseSharedFieldAnswersJson, sharedFieldAnswersPayload } from "@/lib/kb/sharedFieldAnswers"
import { KNOWLEDGE_TEMPLATE_ANSWER_MAX } from "@/lib/kb/knowledgeTemplate"
import {
ChevronRight,
HelpCircle,
Info,
Layers,
Loader2,
MapPin,
Package,
Ruler,
Save,
ShieldAlert,
ThumbsUp,
UserCircle,
X,
} from "lucide-react"
const FAQ_CARD_ID = "faq"
const CATEGORY_CARD_ID = "category_metadetails"
/** Single grid order — no section groupings on the dashboard. */
const DASHBOARD_CARD_ORDER: Array<
typeof FAQ_CARD_ID | typeof CATEGORY_CARD_ID | Exclude<L1Module, "UNKNOWN">
> = [
FAQ_CARD_ID,
CATEGORY_CARD_ID,
"STORE_LOCAL",
"FEEDBACK",
"HUMAN_ESCALATION",
"PRODUCT_FIT",
"ORDER_STATUS",
"ORDER_SUMMARY",
"SHIPPING_TRACKING",
"ORDER_CANCEL",
"REFUND_STATUS",
"INVENTORY_STOCK",
"RETURN_EXCHANGE",
"ORDER_CHANGE",
"COMPLAINT",
"NOT_RECEIVED_MARKED_DELIVERED",
"SHIPMENT_STUCK_OR_DELAYED",
"PAYMENT_PROBLEM",
"WARRANTY",
"WRONG_ITEM",
]
function stripDocRefs(text: string) {
return text.replace(/\s*\(§[^)]*\)/g, "").trim()
}
function categoryAutomation(on: boolean): AutomationFlags {
return { knowledge: false, verifiedLookup: on, ticket: false }
}
type ModalId = typeof FAQ_CARD_ID | typeof CATEGORY_CARD_ID | Exclude<L1Module, "UNKNOWN"> | null
const ALL_MODAL_MODULES = new Set<L1Module>(L1_MODULE_IDS)
function isModuleModalId(id: string): id is Exclude<L1Module, "UNKNOWN"> {
return ALL_MODAL_MODULES.has(id as L1Module) && id !== "UNKNOWN"
}
function moduleIcon(m: Exclude<L1Module, "UNKNOWN">) {
switch (m) {
case "CATEGORY_METADETAILS":
return Layers
case "STORE_LOCAL":
return MapPin
case "FEEDBACK":
return ThumbsUp
case "HUMAN_ESCALATION":
return UserCircle
case "PRODUCT_FIT":
return Ruler
case "INVENTORY_STOCK":
return Package
default:
if ((L1_TICKET_MODULES_UI as readonly string[]).includes(m)) return ShieldAlert
return Package
}
}
export default function JunoEngineModulesPage() {
const { theme } = useTheme()
const isLight = theme === "light"
const [mounted, setMounted] = useState(false)
const [activeStoreId, setActiveStoreId] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [toast, setToast] = useState<string | null>(null)
const [mods, setMods] = useState<Record<L1Module, AutomationFlags>>(() =>
parseAiModulesJson(null),
)
const [sharedFields, setSharedFields] = useState<Record<string, string>>({})
const [categoryMetaEnabled, setCategoryMetaEnabled] = useState(false)
const [modalId, setModalId] = useState<ModalId>(null)
const [modalSaveError, setModalSaveError] = useState<string | null>(null)
const [moduleInfoOpen, setModuleInfoOpen] = useState(false)
const load = useCallback(async (storeId: string) => {
const res = await fetch(`/api/stores/${encodeURIComponent(storeId)}`)
if (!res.ok) throw new Error("Failed to load store settings")
const data = (await res.json()) as {
aiModules?: unknown
sharedFieldAnswers?: unknown
categoryMetadetailsEnabled?: boolean
}
const parsedMods = parseAiModulesJson(data.aiModules)
const catOn = Boolean(data.categoryMetadetailsEnabled)
parsedMods.CATEGORY_METADETAILS = categoryAutomation(catOn)
setMods(parsedMods)
setCategoryMetaEnabled(catOn)
setSharedFields(parseSharedFieldAnswersJson(data.sharedFieldAnswers))
}, [])
useEffect(() => {
setMounted(true)
}, [])
useEffect(() => {
let cancelled = false
; (async () => {
try {
const res = await fetch("/api/stores")
if (!res.ok) return
const data = (await res.json()) as {
stores?: { id: string; isActive: boolean }[]
}
const active = (data.stores || []).find((s) => s.isActive)
if (!cancelled) setActiveStoreId(active?.id ?? null)
} catch {
if (!cancelled) setActiveStoreId(null)
}
})()
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (!activeStoreId) {
setLoading(false)
return
}
let cancelled = false
; (async () => {
try {
setLoading(true)
setToast(null)
await load(activeStoreId)
} catch (e) {
if (!cancelled) setToast(e instanceof Error ? e.message : "Load failed")
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [activeStoreId, load])
useEffect(() => {
if (!modalId) return
setModalSaveError(null)
setModuleInfoOpen(false)
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setModalId(null)
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [modalId])
useEffect(() => {
if (modalId) {
document.body.style.overflow = "hidden"
} else {
document.body.style.overflow = ""
}
return () => {
document.body.style.overflow = ""
}
}, [modalId])
async function saveChanges(): Promise<{ ok: boolean; error?: string }> {
if (!activeStoreId) return { ok: false, error: "No store selected" }
setSaving(true)
setToast(null)
try {
const mergedMods: Record<L1Module, AutomationFlags> = {
...mods,
CATEGORY_METADETAILS: categoryAutomation(categoryMetaEnabled),
}
const res = await fetch(`/api/stores/${encodeURIComponent(activeStoreId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
aiModules: serializeAiModules(mergedMods),
categoryMetadetailsEnabled: categoryMetaEnabled,
sharedFieldAnswers: sharedFieldAnswersPayload(sharedFields),
}),
})
const j = (await res.json().catch(() => ({}))) as {
message?: string
aiModules?: unknown
sharedFieldAnswers?: unknown
categoryMetadetailsEnabled?: boolean
}
if (!res.ok) throw new Error(j.message || "Save failed")
const nextMods = parseAiModulesJson(j.aiModules ?? serializeAiModules(mergedMods))
const catOn = Boolean(j.categoryMetadetailsEnabled)
nextMods.CATEGORY_METADETAILS = categoryAutomation(catOn)
setMods(nextMods)
setCategoryMetaEnabled(catOn)
setSharedFields(parseSharedFieldAnswersJson(j.sharedFieldAnswers))
setToast("Saved.")
return { ok: true }
} catch (e) {
const msg = e instanceof Error ? e.message : "Save failed"
setToast(msg)
return { ok: false, error: msg }
} finally {
setSaving(false)
}
}
const faqOn = mods.FAQ.knowledge
const categoryOn = categoryMetaEnabled
function setAutomation(mod: L1Module, key: AutomationKey, value: boolean) {
setMods((prev) => ({
...prev,
[mod]: { ...prev[mod], [key]: value },
}))
}
const modalModule: Exclude<L1Module, "UNKNOWN"> | null =
modalId && isModuleModalId(modalId) ? modalId : null
const cardShell = cn(
"rounded-xl border min-w-0 transition-all cursor-pointer text-left w-full",
isLight
? "border-slate-200 bg-white shadow-sm hover:bg-slate-50/90 hover:border-slate-300 hover:shadow"
: "bg-white/5 border-white/10 hover:bg-white/[0.08] hover:border-indigo-500/30",
)
const iconWrap = cn(
"w-11 h-11 rounded-xl flex items-center justify-center shrink-0 border",
isLight
? "bg-indigo-50 border-indigo-100 text-indigo-600"
: "bg-indigo-500/15 border-indigo-400/20 text-indigo-400",
)
const badgeCls = (active: boolean) =>
cn(
"text-[10px] font-bold uppercase tracking-wider px-2 py-1 rounded-md shrink-0",
active
? isLight
? "bg-emerald-100 text-emerald-800"
: "bg-emerald-500/20 text-emerald-400"
: isLight
? "bg-slate-100 text-slate-500"
: "bg-white/10 text-slate-400",
)
const panelClass = isLight
? "bg-white border-slate-200 text-slate-900 shadow-xl"
: "bg-slate-900/98 border-white/10 text-white shadow-2xl backdrop-blur-xl"
const inputSurface = isLight
? "bg-slate-50 border-slate-200 text-slate-900 placeholder:text-slate-400"
: "bg-black/30 border-white/10 text-white placeholder:text-slate-500"
function renderPolicyFields(mod: Exclude<L1Module, "UNKNOWN">) {
const spec = MODULE_THREE_TYPES[mod]
const subtle = isLight ? "text-slate-600" : "text-slate-400"
const rowBorder = isLight ? "border-slate-200 bg-slate-50/50" : "border-white/10 bg-white/[0.03]"
return (
<div className="space-y-5">
{spec.referencesGlobalAll ? (
<p className={cn("text-xs leading-relaxed", subtle)}>
Global store voice (G1–G12) lives in the{" "}
<Link
href="/dashboard/juno-engine/knowledge-base"
className="font-semibold text-indigo-500 hover:underline"
>
Knowledge base
</Link>
. Fields below are shared wherever the same keys appear.
</p>
) : null}
{spec.type1Rows.length > 0 ? (
<>
<p className={cn("text-xs", subtle)}>
Policy and messaging your customers may see (together with the knowledge base).
</p>
{spec.type1Rows.map((row) => (
<div
key={row.code}
className={cn("space-y-3 rounded-xl border p-4", rowBorder)}
>
<p
className={cn(
"text-sm font-semibold",
isLight ? "text-slate-900" : "text-white",
)}
>
{row.label}
</p>
{row.fieldKeys.map((fk) => (
<div key={fk} className="space-y-1.5">
<label
className={cn(
"text-[11px] font-semibold uppercase tracking-wide",
isLight ? "text-slate-600" : "text-slate-400",
)}
>
{SHARED_FIELD_SHORT_LABEL[fk] || fk}
</label>
<textarea
value={sharedFields[fk] ?? ""}
onChange={(e) =>
setSharedFields((s) => ({
...s,
[fk]: e.target.value.slice(0, KNOWLEDGE_TEMPLATE_ANSWER_MAX),
}))
}
rows={3}
maxLength={KNOWLEDGE_TEMPLATE_ANSWER_MAX}
disabled={saving}
className={cn(
"w-full px-3 py-2 rounded-lg border text-sm resize-y min-h-[72px] focus:outline-none focus:ring-2 focus:ring-indigo-500/30",
inputSurface,
)}
/>
</div>
))}
</div>
))}
</>
) : null}
</div>
)
}
function renderModuleInfoPanel(mod: Exclude<L1Module, "UNKNOWN">) {
const spec = MODULE_THREE_TYPES[mod]
const subtle = isLight ? "text-slate-600" : "text-slate-400"
const hasInfo = spec.type2Bullets.length > 0 || spec.type3Bullets.length > 0
if (!hasInfo) return null
return (
<div
className={cn(
"rounded-xl border p-4 text-xs space-y-4 max-h-[min(60vh,420px)] overflow-y-auto shadow-lg",
isLight ? "border-slate-200 bg-white text-slate-800" : "border-white/15 bg-slate-900 text-slate-200",
)}
>
<p className={cn("font-semibold", isLight ? "text-slate-900" : "text-white")}>
How verification &amp; automation work
</p>
<p className={subtle}>
Platform behavior only — you don&apos;t write agent scripts here. Tone still comes from your
policy fields above and the knowledge base.
</p>
{spec.type2Bullets.length > 0 ? (
<div className="space-y-2">
<p className={cn("font-medium", isLight ? "text-slate-800" : "text-slate-100")}>
Verification &amp; prerequisites
</p>
<ul className={cn("list-disc pl-4 space-y-1.5 leading-relaxed", subtle)}>
{spec.type2Bullets.map((b, i) => (
<li key={i}>{stripDocRefs(b)}</li>
))}
</ul>
</div>
) : null}
{spec.type3Bullets.length > 0 ? (
<div className="space-y-2">
<p className={cn("font-medium", isLight ? "text-slate-800" : "text-slate-100")}>
L1 answers &amp; ticketing
</p>
<ul className={cn("list-disc pl-4 space-y-1.5 leading-relaxed", subtle)}>
{spec.type3Bullets.map((b, i) => (
<li key={i}>{stripDocRefs(b)}</li>
))}
</ul>
</div>
) : null}
</div>
)
}
function renderAutomationRows(mod: Exclude<L1Module, "UNKNOWN">) {
const rowSurface = isLight
? "border-slate-200 bg-slate-50/60"
: "border-white/10 bg-white/[0.04]"
return (
<div className="space-y-3">
{automationRowsForModule(mod).map((row) => {
const checked = row.lockedOn ? true : mods[mod][row.key]
return (
<div
key={row.key}
className={cn(
"flex items-start justify-between gap-4 rounded-xl border px-4 py-3",
rowSurface,
)}
>
<div className="min-w-0 flex-1">
<p
className={cn(
"text-sm font-semibold leading-snug",
isLight ? "text-slate-900" : "text-white",
)}
>
{row.label}
</p>
{row.subtitle ? (
<p
className={cn(
"text-xs mt-1 leading-relaxed",
isLight ? "text-slate-600" : "text-slate-400",
)}
>
{row.subtitle}
</p>
) : null}
{row.lockedOn ? (
<p
className={cn(
"text-[11px] mt-1.5 font-medium uppercase tracking-wide",
isLight ? "text-slate-500" : "text-slate-500",
)}
>
Always on for safety
</p>
) : null}
</div>
<label className="shrink-0 cursor-pointer pt-0.5">
<input
type="checkbox"
className="rounded border-slate-500 w-4 h-4 accent-indigo-600"
checked={checked}
disabled={saving || row.lockedOn}
onChange={(e) => {
if (row.lockedOn) return
setAutomation(mod, row.key, e.target.checked)
if (mod === "CATEGORY_METADETAILS" && row.key === "verifiedLookup") {
setCategoryMetaEnabled(e.target.checked)
}
}}
aria-label={`Toggle ${row.label}`}
/>
</label>
</div>
)
})}
</div>
)
}
function renderModuleCard(m: Exclude<L1Module, "UNKNOWN">) {
const Icon = moduleIcon(m)
const on = moduleAnyEnabled(mods[m])
return (
<button
type="button"
className={cardShell}
onClick={() => setModalId(m)}
>
<span className="p-5 flex items-start gap-4 w-full">
<span className={iconWrap}>
<Icon className="w-5 h-5" />
</span>
<span className="flex-1 min-w-0 text-left">
<span
className={cn(
"font-black text-base leading-tight block",
isLight ? "text-slate-900" : "text-white",
)}
>
{L1_MODULE_CARD_TITLE[m]}
</span>
<span
className={cn(
"text-xs mt-1 line-clamp-2 block",
isLight ? "text-slate-500" : "text-slate-400",
)}
>
{L1_MODULE_CUSTOMER_LABEL[m]}
</span>
</span>
<span className={badgeCls(on)}>{on ? "On" : "Off"}</span>
<ChevronRight className="w-5 h-5 shrink-0 text-slate-400 self-center" />
</span>
</button>
)
}
const infoSpecMod: Exclude<L1Module, "UNKNOWN"> | null =
modalId === FAQ_CARD_ID
? "FAQ"
: modalId === CATEGORY_CARD_ID
? "CATEGORY_METADETAILS"
: modalModule
const hasModuleInfoPanel =
infoSpecMod &&
(MODULE_THREE_TYPES[infoSpecMod].type2Bullets.length > 0 ||
MODULE_THREE_TYPES[infoSpecMod].type3Bullets.length > 0)
const modal =
mounted &&
modalId &&
createPortal(
<div
className="fixed inset-0 z-[200] flex items-end sm:items-center justify-center p-0 sm:p-4"
role="dialog"
aria-modal="true"
aria-labelledby="module-modal-title"
>
<button
type="button"
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
aria-label="Close"
onClick={() => setModalId(null)}
/>
<div
className={cn(
"relative flex max-h-[min(92vh,900px)] w-full flex-col rounded-t-2xl border sm:rounded-2xl sm:max-w-2xl",
panelClass,
)}
onClick={(e) => e.stopPropagation()}
>
<div
className={cn(
"flex items-start justify-between gap-3 px-5 py-4 border-b shrink-0",
isLight ? "border-slate-100" : "border-white/10",
)}
>
<div className="min-w-0 flex-1 pr-2">
<h2
id="module-modal-title"
className={cn(
"text-lg font-bold tracking-tight",
isLight ? "text-slate-900" : "text-white",
)}
>
{modalId === FAQ_CARD_ID
? L1_MODULE_CARD_TITLE.FAQ
: modalId === CATEGORY_CARD_ID
? L1_MODULE_CARD_TITLE.CATEGORY_METADETAILS
: modalModule
? L1_MODULE_CARD_TITLE[modalModule]
: "Module"}
</h2>
<p
className={cn(
"text-xs mt-1",
isLight ? "text-slate-500" : "text-slate-400",
)}
>
{modalId === FAQ_CARD_ID
? L1_MODULE_CUSTOMER_LABEL.FAQ
: modalId === CATEGORY_CARD_ID
? L1_MODULE_CUSTOMER_LABEL.CATEGORY_METADETAILS
: modalModule
? L1_MODULE_CUSTOMER_LABEL[modalModule]
: ""}
</p>
</div>
<div className="flex items-start gap-1 shrink-0 relative">
{hasModuleInfoPanel && infoSpecMod ? (
<>
<button
type="button"
onClick={() => setModuleInfoOpen((v) => !v)}
className={cn(
"p-2 rounded-lg transition-colors",
moduleInfoOpen
? isLight
? "bg-indigo-100 text-indigo-700"
: "bg-indigo-500/20 text-indigo-300"
: isLight
? "hover:bg-slate-100 text-slate-500"
: "hover:bg-white/10 text-slate-400",
)}
aria-expanded={moduleInfoOpen}
aria-label="Verification and automation details"
>
<Info className="w-5 h-5" />
</button>
{moduleInfoOpen ? (
<div className="absolute right-0 top-full z-[220] mt-2 w-[min(calc(100vw-2rem),22rem)]">
{renderModuleInfoPanel(infoSpecMod)}
</div>
) : null}
</>
) : null}
<button
type="button"
onClick={() => setModalId(null)}
className={cn(
"p-2 rounded-lg transition-colors",
isLight
? "hover:bg-slate-100 text-slate-500"
: "hover:bg-white/10 text-slate-400",
)}
aria-label="Close"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto px-5 py-4 space-y-6">
{modalId === FAQ_CARD_ID ? (
<>
{renderAutomationRows("FAQ")}
{renderPolicyFields("FAQ")}
</>
) : modalId === CATEGORY_CARD_ID ? (
<>
{renderAutomationRows("CATEGORY_METADETAILS")}
{renderPolicyFields("CATEGORY_METADETAILS")}
</>
) : modalModule ? (
<>
{renderAutomationRows(modalModule)}
{renderPolicyFields(modalModule)}
</>
) : null}
</div>
{modalSaveError ? (
<p
className={cn(
"px-5 py-2 text-xs border-t",
isLight
? "border-amber-100 bg-amber-50 text-amber-900"
: "border-amber-500/20 bg-amber-500/10 text-amber-200",
)}
>
{modalSaveError}
</p>
) : null}
<div
className={cn(
"flex flex-wrap items-center justify-end gap-2 px-5 py-3 border-t shrink-0",
isLight ? "border-slate-100 bg-slate-50/80" : "border-white/10 bg-slate-950/50",
)}
>
<button
type="button"
onClick={() => setModalId(null)}
className={cn(
"px-4 py-2 rounded-lg text-sm font-medium",
isLight
? "text-slate-600 hover:bg-slate-200/80"
: "text-slate-300 hover:bg-white/10",
)}
>
Close
</button>
<button
type="button"
disabled={saving}
onClick={() =>
void (async () => {
setModalSaveError(null)
const r = await saveChanges()
if (r.ok) setModalId(null)
else if (r.error) setModalSaveError(r.error)
})()
}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-bold disabled:opacity-50"
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
Save changes
</button>
</div>
</div>
</div>,
document.body,
)
return (
<div className="flex flex-col min-h-0 flex-1 pb-28">
<div className="flex-shrink-0 space-y-5 mb-6">
<div>
<h1
className={cn(
"text-2xl md:text-3xl font-extrabold tracking-tight",
isLight ? "text-slate-900" : "text-white",
)}
>
AI modules
</h1>
<p
className={cn(
"text-sm mt-1 max-w-2xl",
isLight ? "text-slate-600" : "text-slate-400",
)}
>
Open a module to turn each automation on or off and edit policy fields. Use the{" "}
<span className="font-semibold">info</span> icon in the dialog for how verification
and ticketing work on the platform. Global voice:{" "}
<Link
href="/dashboard/juno-engine/knowledge-base"
className="font-semibold text-indigo-500 hover:underline"
>
Knowledge base
</Link>
.
</p>
</div>
</div>
{!activeStoreId ? (
<p className={cn("text-sm", isLight ? "text-slate-500" : "text-slate-400")}>
Select an active store to configure modules.
</p>
) : loading ? (
<div className="flex items-center gap-2 text-sm text-slate-400 py-12">
<Loader2 className="w-5 h-5 animate-spin" />
Loading…
</div>
) : (
<>
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 md:gap-6 content-start">
{DASHBOARD_CARD_ORDER.map((key) => {
if (key === FAQ_CARD_ID) {
return (
<button
key={key}
type="button"
className={cardShell}
onClick={() => setModalId(FAQ_CARD_ID)}
>
<span className="p-5 flex items-start gap-4 w-full">
<span className={iconWrap}>
<HelpCircle className="w-5 h-5" />
</span>
<span className="flex-1 min-w-0 text-left">
<span
className={cn(
"font-black text-base leading-tight block",
isLight ? "text-slate-900" : "text-white",
)}
>
{L1_MODULE_CARD_TITLE.FAQ}
</span>
<span
className={cn(
"text-xs mt-1 line-clamp-2 block",
isLight ? "text-slate-500" : "text-slate-400",
)}
>
{L1_MODULE_CUSTOMER_LABEL.FAQ}
</span>
</span>
<span className={badgeCls(faqOn)}>{faqOn ? "On" : "Off"}</span>
<ChevronRight className="w-5 h-5 shrink-0 text-slate-400 self-center" />
</span>
</button>
)
}
if (key === CATEGORY_CARD_ID) {
return (
<button
key={key}
type="button"
className={cardShell}
onClick={() => setModalId(CATEGORY_CARD_ID)}
>
<span className="p-5 flex items-start gap-4 w-full">
<span className={iconWrap}>
<Layers className="w-5 h-5" />
</span>
<span className="flex-1 min-w-0 text-left">
<span
className={cn(
"font-black text-base leading-tight block",
isLight ? "text-slate-900" : "text-white",
)}
>
{L1_MODULE_CARD_TITLE.CATEGORY_METADETAILS}
</span>
<span
className={cn(
"text-xs mt-1 line-clamp-2 block",
isLight ? "text-slate-500" : "text-slate-400",
)}
>
{L1_MODULE_CUSTOMER_LABEL.CATEGORY_METADETAILS}
</span>
</span>
<span className={badgeCls(categoryOn)}>
{categoryOn ? "On" : "Off"}
</span>
<ChevronRight className="w-5 h-5 shrink-0 text-slate-400 self-center" />
</span>
</button>
)
}
return (
<div key={key} className="contents">
{renderModuleCard(key)}
</div>
)
})}
</div>
<p
className={cn(
"text-xs mt-6",
isLight ? "text-slate-500" : "text-slate-500",
)}
>
Widget appearance:{" "}
<Link
href="/dashboard/storefront-chat"
className="font-semibold text-indigo-500 hover:underline"
>
Storefront Chat Inbox
</Link>
</p>
<div
className={cn(
"fixed bottom-0 left-0 right-0 md:left-72 z-[60] border-t px-4 py-3 md:px-8",
isLight
? "border-slate-200 bg-white/95 backdrop-blur-md"
: "border-white/10 bg-slate-950/95 backdrop-blur-md",
)}
style={{
paddingBottom: "max(0.75rem, env(safe-area-inset-bottom))",
}}
>
<div className="max-w-6xl mx-auto flex items-center justify-between gap-3">
{toast ? (
<p
className={cn(
"text-xs flex-1 min-w-0",
toast.includes("fail") || toast.includes("Failed")
? isLight
? "text-amber-700"
: "text-amber-400"
: isLight
? "text-emerald-700"
: "text-emerald-400/90",
)}
>
{toast}
</p>
) : (
<span className="text-xs text-slate-500 hidden sm:inline">
Changes are local until you save.
</span>
)}
<button
type="button"
disabled={saving}
onClick={() => void saveChanges()}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-bold disabled:opacity-50 shrink-0 shadow-lg shadow-indigo-900/20"
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
Save changes
</button>
</div>
</div>
{modal}
</>
)}
</div>
)
}

View File

@ -4,7 +4,7 @@ import { prisma } from "@/lib/prisma"
import { storeSelectDashboardStorefrontInbox } from "@/lib/prisma/storeSelects" 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" import Link from "next/link"
export default async function StorefrontChatInboxPage() { export default async function StorefrontChatInboxPage() {
const session = await auth() const session = await auth()
@ -73,10 +73,16 @@ export default async function StorefrontChatInboxPage() {
</div> </div>
</div> </div>
<StorefrontAiSettingsCard <p className="text-sm text-slate-400">
storeId={store.id} L1 AI modules and catalog metafields:{" "}
initialCategoryMetadetailsEnabled={store.categoryMetadetailsEnabled} <Link
/> href="/dashboard/juno-engine/modules"
className="font-semibold text-indigo-400 hover:underline"
>
JUNO Engine → Modules
</Link>
.
</p>
{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">

View File

@ -14,7 +14,7 @@ import {
Ticket, Ticket,
Flag, Flag,
Mail, Mail,
FileText, Layers,
HelpCircle, HelpCircle,
Store Store
} from "lucide-react" } from "lucide-react"
@ -76,7 +76,7 @@ const routes = [
color: "text-cyan-500", color: "text-cyan-500",
subRoutes: [ subRoutes: [
{ label: "Knowledge Base", href: "/dashboard/juno-engine/knowledge-base", icon: HelpCircle }, { label: "Knowledge Base", href: "/dashboard/juno-engine/knowledge-base", icon: HelpCircle },
{ label: "Templates", href: "/dashboard/juno-engine/email-templates", icon: FileText }, { label: "Modules", href: "/dashboard/juno-engine/modules", icon: Layers },
{ label: "Tickets", href: "/dashboard/juno-engine/tickets", icon: Ticket }, { label: "Tickets", href: "/dashboard/juno-engine/tickets", icon: Ticket },
{ label: "Flagged Emails", href: "/dashboard/juno-engine/flagged-email", icon: Flag }, { label: "Flagged Emails", href: "/dashboard/juno-engine/flagged-email", icon: Flag },
{ label: "Gmail", href: "/dashboard/juno-engine/gmail-connect", icon: Mail }, { label: "Gmail", href: "/dashboard/juno-engine/gmail-connect", icon: Mail },

View File

@ -3,22 +3,14 @@
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { createPortal } from "react-dom" import { createPortal } from "react-dom"
import { import {
KNOWLEDGE_TEMPLATE_FIELDS, STORE_VOICE_FIELDS,
KNOWLEDGE_TEMPLATE_ANSWER_MAX, KNOWLEDGE_TEMPLATE_ANSWER_MAX,
KNOWLEDGE_TEMPLATE_MISC_MAX, type StoreVoiceFieldKey,
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"
type KbCounts = {
documents: number
faqs: number
structuredChunks: number
}
type StructuredKnowledgeModalProps = { type StructuredKnowledgeModalProps = {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
@ -41,23 +33,19 @@ export function StructuredKnowledgeModal({
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [answers, setAnswers] = useState<Record<KnowledgeTemplateFieldKey, string>>( const [answers, setAnswers] = useState<Record<StoreVoiceFieldKey, string>>(
() => () =>
KNOWLEDGE_TEMPLATE_FIELDS.reduce( STORE_VOICE_FIELDS.reduce(
(acc, f) => { (acc, f) => {
acc[f.key] = "" acc[f.key] = ""
return acc return acc
}, },
{} as Record<KnowledgeTemplateFieldKey, string>, {} as Record<StoreVoiceFieldKey, string>,
), ),
) )
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(`/api/stores/${encodeURIComponent(storeId)}/knowledge-template`)
`/api/stores/${encodeURIComponent(storeId)}/knowledge-template`,
)
if (!res.ok) return if (!res.ok) return
const data = (await res.json()) as { const data = (await res.json()) as {
knowledgeTemplate: KnowledgeTemplateStored | null knowledgeTemplate: KnowledgeTemplateStored | null
@ -65,22 +53,12 @@ export function StructuredKnowledgeModal({
const tmpl = data.knowledgeTemplate const tmpl = data.knowledgeTemplate
setAnswers((prev) => { setAnswers((prev) => {
const next = { ...prev } const next = { ...prev }
for (const f of KNOWLEDGE_TEMPLATE_FIELDS) { for (const f of STORE_VOICE_FIELDS) {
const v = tmpl?.legacyAnswers?.[f.key] const v = tmpl?.genericAnswers?.[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 : "")
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(() => {
@ -132,8 +110,6 @@ export function StructuredKnowledgeModal({
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
template: { template: {
answers, answers,
sharedFields,
misc: misc.trim() || undefined,
}, },
} }
@ -146,14 +122,11 @@ export function StructuredKnowledgeModal({
...payload, ...payload,
...(confirmDestructiveSwitch ? { confirmDestructiveSwitch: true } : {}), ...(confirmDestructiveSwitch ? { confirmDestructiveSwitch: true } : {}),
} }
const res = await fetch( const res = await fetch(`/api/stores/${encodeURIComponent(storeId)}/knowledge-template`, {
`/api/stores/${encodeURIComponent(storeId)}/knowledge-template`, method: "PUT",
{ headers: { "Content-Type": "application/json" },
method: "PUT", body: JSON.stringify(body),
headers: { "Content-Type": "application/json" }, })
body: JSON.stringify(body),
},
)
const j = await res.json().catch(() => ({})) const j = await res.json().catch(() => ({}))
if (res.status === 400 && j.code === "CONFIRM_REQUIRED") { if (res.status === 400 && j.code === "CONFIRM_REQUIRED") {
const ok = window.confirm( const ok = window.confirm(
@ -203,7 +176,7 @@ export function StructuredKnowledgeModal({
<div <div
className={cn( className={cn(
"relative flex min-h-0 w-full max-h-[92vh] flex-col rounded-t-2xl border sm:mx-4 sm:max-h-[90vh] sm:rounded-2xl", "relative flex min-h-0 w-full max-h-[92vh] flex-col rounded-t-2xl border sm:mx-4 sm:max-h-[90vh] sm:rounded-2xl",
"sm:max-w-3xl lg:max-w-4xl", "sm:max-w-lg lg:max-w-xl",
panelClass, panelClass,
)} )}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@ -222,7 +195,7 @@ export function StructuredKnowledgeModal({
isLight ? "text-slate-900" : "text-white", isLight ? "text-slate-900" : "text-white",
)} )}
> >
Structured store profile Store voice for AI
</h2> </h2>
<p <p
className={cn( className={cn(
@ -230,8 +203,8 @@ export function StructuredKnowledgeModal({
isLight ? "text-slate-500" : "text-slate-400", isLight ? "text-slate-500" : "text-slate-400",
)} )}
> >
Answers are embedded for AI retrieval. Skip any field you A few basics about tone and boundaries. Per-topic policies live under{" "}
don&apos;t need. <span className="font-semibold">JUNO Engine → AI modules</span>.
</p> </p>
</div> </div>
<button <button
@ -277,7 +250,7 @@ export function StructuredKnowledgeModal({
</div> </div>
)} )}
{KNOWLEDGE_TEMPLATE_FIELDS.map((field) => ( {STORE_VOICE_FIELDS.map((field) => (
<div key={field.key}> <div key={field.key}>
<label <label
className={cn( className={cn(
@ -318,133 +291,6 @@ 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>
<label
className={cn(
"block text-sm font-semibold",
isLight ? "text-slate-900" : "text-white",
)}
>
Anything else?
</label>
<p
className={cn(
"text-[11px] mt-0.5 mb-1.5",
isLight ? "text-slate-500" : "text-slate-500",
)}
>
Extra notes customers should know.
</p>
<textarea
value={misc}
onChange={(e) =>
setMisc(
e.target.value.slice(0, KNOWLEDGE_TEMPLATE_MISC_MAX),
)
}
placeholder="Optional…"
rows={3}
maxLength={KNOWLEDGE_TEMPLATE_MISC_MAX}
className={cn(
"w-full px-3 py-2 rounded-lg border text-sm resize-y min-h-[72px] focus:outline-none focus:ring-2 focus:ring-indigo-500/30",
isLight
? "bg-slate-50 border-slate-200 text-slate-900 placeholder:text-slate-400"
: "bg-slate-950/50 border-white/10 text-white placeholder:text-slate-500",
)}
/>
</div>
</div> </div>
<div <div

View File

@ -1,73 +0,0 @@
"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

@ -1,12 +1,8 @@
import { groqChatCompletion } from "@/lib/ai/groqClient" import { groqChatCompletion } from "@/lib/ai/groqClient"
import { import {
GMAIL_INTENTS,
GMAIL_MOODS,
normalizeClassification, normalizeClassification,
parseJsonObject, parseJsonObject,
type GmailClassification, type GmailClassification,
type GmailIntent,
type GmailMood,
} from "@/lib/ai/gmailTaxonomy" } from "@/lib/ai/gmailTaxonomy"
import { logger } from "@/lib/logger" import { logger } from "@/lib/logger"
@ -70,82 +66,3 @@ export async function classifyCustomerEmail(
source: "groq", source: "groq",
} }
} }
export type GeneratedReplyPayload = {
html_body: string
name: string
description: string
intent: GmailIntent
mood: GmailMood
}
const GENERATE_SYSTEM = `You write professional HTML email replies for an e-commerce support team.
Rules:
- Output JSON only with keys: html_body, name, description, intent, mood
- html_body: a single customer-ready email as HTML (use simple tags: p, br, div). No external scripts. Be empathetic and concise.
- Use ONLY facts from order_context when present. If order_context is empty, do NOT invent order numbers, tracking URLs, or amounts.
- name: short internal template title (max 80 chars)
- description: one line for staff
- intent and mood must be one of the allowed values exactly.
Allowed intents: ${GMAIL_INTENTS.join(", ")}
Allowed moods: ${GMAIL_MOODS.join(", ")}`
export async function generateReplyAndTemplateFields(input: {
subject: string
bodyExcerpt: string
classification: GmailClassification
orderSummary: string
storeName: string
customerFirstName: string
}): Promise<GeneratedReplyPayload | null> {
const user = JSON.stringify({
subject: input.subject.slice(0, 500),
message_excerpt: input.bodyExcerpt.slice(0, 3500),
classified_intent: input.classification.intent,
classified_mood: input.classification.mood,
short_summary: input.classification.short_summary,
order_context: input.orderSummary || "(none — do not invent order details)",
store_name: input.storeName,
customer_name_hint: input.customerFirstName,
})
const { content, error } = await groqChatCompletion(
[
{ role: "system", content: GENERATE_SYSTEM },
{ role: "user", content: user },
],
{ jsonObject: true, temperature: 0.35, maxTokens: 1500 }
)
if (!content) {
logger.warn("Gmail reply generation failed", { error })
return null
}
const parsed = parseJsonObject<Record<string, unknown>>(content)
if (!parsed || typeof parsed.html_body !== "string") {
logger.warn("Gmail reply generation invalid JSON")
return null
}
const normalized = normalizeClassification({
intent: typeof parsed.intent === "string" ? parsed.intent : input.classification.intent,
mood: typeof parsed.mood === "string" ? parsed.mood : input.classification.mood,
})
return {
html_body: parsed.html_body.trim(),
name:
typeof parsed.name === "string"
? parsed.name.slice(0, 120)
: `AI reply — ${input.classification.intent}`,
description:
typeof parsed.description === "string"
? parsed.description.slice(0, 500)
: "Auto-generated from customer email",
intent: normalized.intent,
mood: normalized.mood,
}
}

View File

@ -1,5 +1,5 @@
/** /**
* Single source of truth for Gmail auto-reply intent/mood (aligned with seeds + JUNO templates UI). * Gmail triage: intent/mood strings used by `classifyCustomerEmail` and auto-reply guardrails.
*/ */
export const GMAIL_INTENTS = [ export const GMAIL_INTENTS = [
"general", "general",

View File

@ -1,40 +1,158 @@
import type { L1Module } from "@/lib/ai/orchestrator/types" import type { L1Module } from "@/lib/ai/orchestrator/types"
import { L1_MODULE_IDS } from "@/lib/ai/orchestrator/types"
import type { L1Pillar } from "@/lib/ai/orchestrator/types"
import { modulePillar } from "@/lib/ai/orchestrator/vendorModuleGroups"
const DEFAULT_ENABLED: Record<L1Module, boolean> = { export type AutomationKey = "knowledge" | "verifiedLookup" | "ticket"
FAQ: true,
ORDER_STATUS: true, export type AutomationFlags = Record<AutomationKey, boolean>
ORDER_SUMMARY: true,
SHIPPING_TRACKING: true, const ALL_OFF: AutomationFlags = {
INVENTORY_STOCK: true, knowledge: false,
CATEGORY_METADETAILS: true, verifiedLookup: false,
PRODUCT_FIT: true, ticket: false,
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> { const ALL_ON: AutomationFlags = {
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) { knowledge: true,
return { ...DEFAULT_ENABLED } verifiedLookup: true,
ticket: true,
}
function pillarDefaults(pillar: L1Pillar): AutomationFlags {
if (pillar === "K") {
return { knowledge: true, verifiedLookup: false, ticket: false }
} }
const o = raw as Record<string, unknown> if (pillar === "T") {
const out: Record<string, boolean> = { ...DEFAULT_ENABLED } return { knowledge: false, verifiedLookup: true, ticket: false }
for (const k of Object.keys(DEFAULT_ENABLED) as L1Module[]) { }
if (k in o && typeof o[k] === "boolean") { return { knowledge: false, verifiedLookup: true, ticket: true }
out[k] = o[k] as boolean }
function categoryDefaults(on: boolean): AutomationFlags {
return on
? { knowledge: false, verifiedLookup: true, ticket: false }
: ALL_OFF
}
function normalizeModuleValue(mod: L1Module, raw: unknown): AutomationFlags {
if (mod === "UNKNOWN") {
return ALL_ON
}
if (mod === "CATEGORY_METADETAILS") {
if (raw === false) return ALL_OFF
if (raw === true || raw == null) return categoryDefaults(true)
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
const o = raw as Record<string, unknown>
const base = categoryDefaults(true)
return {
knowledge: typeof o.knowledge === "boolean" ? o.knowledge : base.knowledge,
verifiedLookup:
typeof o.verifiedLookup === "boolean" ? o.verifiedLookup : base.verifiedLookup,
ticket: typeof o.ticket === "boolean" ? o.ticket : base.ticket,
}
} }
return categoryDefaults(true)
}
const pillar = modulePillar(mod)
const d = pillarDefaults(pillar)
let result: AutomationFlags
if (raw === false) {
result = ALL_OFF
} else if (raw === true || raw == null) {
result = { ...d }
} else if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
const o = raw as Record<string, unknown>
result = {
knowledge: typeof o.knowledge === "boolean" ? o.knowledge : d.knowledge,
verifiedLookup:
typeof o.verifiedLookup === "boolean" ? o.verifiedLookup : d.verifiedLookup,
ticket: typeof o.ticket === "boolean" ? o.ticket : d.ticket,
}
} else {
result = { ...d }
}
if (pillar === "X" && result.ticket) {
result = { ...result, verifiedLookup: true }
}
return result
}
/**
* Parse `Store.aiModules` JSON into per-module automation flags.
* Legacy `true` / `false` booleans map to pillar defaults or all-off.
*/
export function parseAiModulesJson(raw: unknown): Record<L1Module, AutomationFlags> {
const o =
raw != null && typeof raw === "object" && !Array.isArray(raw)
? (raw as Record<string, unknown>)
: {}
const out = {} as Record<L1Module, AutomationFlags>
for (const id of L1_MODULE_IDS) {
out[id] = normalizeModuleValue(id, o[id])
} }
return out return out
} }
export function moduleAnyEnabled(flags: AutomationFlags): boolean {
return flags.knowledge || flags.verifiedLookup || flags.ticket
}
export function isModuleEnabled( export function isModuleEnabled(
modules: Record<string, boolean>, modules: Record<L1Module, AutomationFlags>,
module: L1Module, module: L1Module,
): boolean { ): boolean {
if (module === "UNKNOWN") return true if (module === "UNKNOWN") return true
return modules[module] !== false return moduleAnyEnabled(modules[module])
}
export function isAutomationEnabled(
modules: Record<L1Module, AutomationFlags>,
module: L1Module,
key: AutomationKey,
): boolean {
if (module === "UNKNOWN") return true
return Boolean(modules[module][key])
}
/** Identity gate (OTP / inbox) for transactional and ticket pillars. */
export function needsIdentityGate(pillar: L1Pillar, flags: AutomationFlags): boolean {
if (pillar === "K") return false
if (pillar === "X") {
return flags.ticket
}
return flags.verifiedLookup || flags.ticket
}
/**
* Serialize for PATCH: use `false`, `true`, or explicit `{ knowledge, verifiedLookup, ticket }` when non-default.
*/
export function serializeAiModules(
modules: Record<L1Module, AutomationFlags>,
): Record<string, boolean | AutomationFlags> {
const out: Record<string, boolean | AutomationFlags> = {}
for (const id of L1_MODULE_IDS) {
if (id === "UNKNOWN") continue
const f = modules[id]
const d = id === "CATEGORY_METADETAILS" ? categoryDefaults(true) : pillarDefaults(modulePillar(id))
if (!moduleAnyEnabled(f)) {
out[id] = false
continue
}
if (f.knowledge === d.knowledge && f.verifiedLookup === d.verifiedLookup && f.ticket === d.ticket) {
out[id] = true
continue
}
out[id] = {
knowledge: f.knowledge,
verifiedLookup: f.verifiedLookup,
ticket: f.ticket,
}
}
return out
} }

View File

@ -2,39 +2,52 @@ import { groqChatCompletion } from "@/lib/ai/groqClient"
import { logger } from "@/lib/logger" import { logger } from "@/lib/logger"
import type { L1Module } from "@/lib/ai/orchestrator/types" import type { L1Module } from "@/lib/ai/orchestrator/types"
/** All routable modules except CATEGORY_METADETAILS (store toggle only; sizing routes via PRODUCT_FIT). */
const MODULES: L1Module[] = [ const MODULES: L1Module[] = [
"FAQ", "FAQ",
"STORE_LOCAL",
"FEEDBACK",
"HUMAN_ESCALATION",
"PRODUCT_FIT",
"ORDER_STATUS", "ORDER_STATUS",
"ORDER_SUMMARY", "ORDER_SUMMARY",
"SHIPPING_TRACKING", "SHIPPING_TRACKING",
"ORDER_CANCEL",
"REFUND_STATUS",
"INVENTORY_STOCK", "INVENTORY_STOCK",
"CATEGORY_METADETAILS",
"PRODUCT_FIT",
"RETURN_EXCHANGE", "RETURN_EXCHANGE",
"ORDER_CHANGE", "ORDER_CHANGE",
"COMPLAINT", "COMPLAINT",
"NOT_RECEIVED_MARKED_DELIVERED", "NOT_RECEIVED_MARKED_DELIVERED",
"SHIPMENT_STUCK_OR_DELAYED", "SHIPMENT_STUCK_OR_DELAYED",
"PAYMENT_PROBLEM", "PAYMENT_PROBLEM",
"WARRANTY",
"WRONG_ITEM",
"UNKNOWN", "UNKNOWN",
] ]
const ROUTER_PROMPT = `You route ecommerce customer messages to exactly one module. const ROUTER_PROMPT = `You route ecommerce customer messages to exactly one module.
Modules: Modules:
- FAQ: general policies, how shipping works, refund policy (not status), hours, contact. - FAQ: general policies, how shipping works, refund policy wording (not live refund/payout status for an order).
- STORE_LOCAL: hours, pickup, local/in-store, physical location.
- FEEDBACK: thanks, praise, short satisfaction (not a complaint).
- HUMAN_ESCALATION: wants a real person, speak to someone, agent.
- PRODUCT_FIT: sizing, fit, compatibility, materials for a product.
- ORDER_STATUS: where is my order, processing time, not shipped yet. - ORDER_STATUS: where is my order, processing time, not shipped yet.
- ORDER_SUMMARY: receipt, what did I order, line items recap. - ORDER_SUMMARY: receipt, what did I order, line items recap.
- SHIPPING_TRACKING: tracking number, carrier, delivered, ETA. - SHIPPING_TRACKING: tracking number, carrier, delivered, ETA.
- ORDER_CANCEL: cancel my order, can I still cancel.
- REFUND_STATUS: has my refund been issued, refund timeline for MY order (not generic policy).
- INVENTORY_STOCK: is item in stock, availability. - 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. - RETURN_EXCHANGE: start a return or exchange.
- ORDER_CHANGE: change address/items/qty. - ORDER_CHANGE: change address/items/qty.
- COMPLAINT: strong dissatisfaction. - COMPLAINT: strong dissatisfaction, angry escalation.
- NOT_RECEIVED_MARKED_DELIVERED: tracking says delivered but customer did not receive. - NOT_RECEIVED_MARKED_DELIVERED: tracking says delivered but customer did not receive.
- SHIPMENT_STUCK_OR_DELAYED: package not moving / very late. - SHIPMENT_STUCK_OR_DELAYED: package not moving / very late.
- PAYMENT_PROBLEM: double charge, wrong amount, promo failed. - PAYMENT_PROBLEM: double charge, wrong amount, promo failed, payment declined.
- WARRANTY: warranty claim, defect after return window.
- WRONG_ITEM: wrong item, damaged item, missing item from order.
- UNKNOWN: does not fit above. - UNKNOWN: does not fit above.
Return JSON only: {"module":"<ONE_FROM_LIST>","missing_slots":["order_ref"|"product_query"|"reason"|"issue"]} Return JSON only: {"module":"<ONE_FROM_LIST>","missing_slots":["order_ref"|"product_query"|"reason"|"issue"]}
@ -58,7 +71,7 @@ export async function routeCustomerIntent(userMessage: string): Promise<RoutedIn
{ role: "system", content: ROUTER_PROMPT }, { role: "system", content: ROUTER_PROMPT },
{ role: "user", content: userMessage.slice(0, 4000) }, { role: "user", content: userMessage.slice(0, 4000) },
], ],
{ jsonObject: true, temperature: 0.1, maxTokens: 256 }, { jsonObject: true, temperature: 0.1, maxTokens: 320 },
) )
if (!content) { if (!content) {
@ -68,11 +81,11 @@ export async function routeCustomerIntent(userMessage: string): Promise<RoutedIn
try { try {
const j = JSON.parse(content) as { module?: string; missing_slots?: string[] } const j = JSON.parse(content) as { module?: string; missing_slots?: string[] }
const module = parseModule(j.module) const mod = parseModule(j.module)
const slots = Array.isArray(j.missing_slots) const slots = Array.isArray(j.missing_slots)
? j.missing_slots.filter((x) => typeof x === "string") ? j.missing_slots.filter((x) => typeof x === "string")
: [] : []
return { module, missing_slots: slots } return { module: mod, missing_slots: slots }
} catch { } catch {
logger.warn("intentRouter: invalid JSON", { preview: content.slice(0, 120) }) logger.warn("intentRouter: invalid JSON", { preview: content.slice(0, 120) })
return { module: "FAQ", missing_slots: [] } return { module: "FAQ", missing_slots: [] }

View File

@ -8,18 +8,25 @@ export type ModuleManifest = {
export const MODULE_MANIFESTS: Record<L1Module, ModuleManifest> = { export const MODULE_MANIFESTS: Record<L1Module, ModuleManifest> = {
FAQ: { pillar: "K", typicalSlots: [] }, FAQ: { pillar: "K", typicalSlots: [] },
CATEGORY_METADETAILS: { pillar: "K", typicalSlots: [] },
STORE_LOCAL: { pillar: "K", typicalSlots: [] },
FEEDBACK: { pillar: "K", typicalSlots: [] },
HUMAN_ESCALATION: { pillar: "K", typicalSlots: [] },
PRODUCT_FIT: { pillar: "K", typicalSlots: ["product_query"] },
ORDER_STATUS: { pillar: "T", typicalSlots: ["order_ref"] }, ORDER_STATUS: { pillar: "T", typicalSlots: ["order_ref"] },
ORDER_SUMMARY: { pillar: "T", typicalSlots: ["order_ref"] }, ORDER_SUMMARY: { pillar: "T", typicalSlots: ["order_ref"] },
SHIPPING_TRACKING: { pillar: "T", typicalSlots: ["order_ref"] }, SHIPPING_TRACKING: { pillar: "T", typicalSlots: ["order_ref"] },
ORDER_CANCEL: { pillar: "T", typicalSlots: ["order_ref"] },
REFUND_STATUS: { pillar: "T", typicalSlots: ["order_ref"] },
INVENTORY_STOCK: { pillar: "T", typicalSlots: ["product_query"] }, 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"] }, RETURN_EXCHANGE: { pillar: "X", typicalSlots: ["order_ref", "reason"] },
ORDER_CHANGE: { pillar: "X", typicalSlots: ["order_ref", "change_request"] }, ORDER_CHANGE: { pillar: "X", typicalSlots: ["order_ref", "change_request"] },
COMPLAINT: { pillar: "X", typicalSlots: ["issue"] }, COMPLAINT: { pillar: "X", typicalSlots: ["issue"] },
NOT_RECEIVED_MARKED_DELIVERED: { pillar: "X", typicalSlots: ["order_ref"] }, NOT_RECEIVED_MARKED_DELIVERED: { pillar: "X", typicalSlots: ["order_ref"] },
SHIPMENT_STUCK_OR_DELAYED: { pillar: "X", typicalSlots: ["order_ref"] }, SHIPMENT_STUCK_OR_DELAYED: { pillar: "X", typicalSlots: ["order_ref"] },
PAYMENT_PROBLEM: { pillar: "X", typicalSlots: ["order_ref", "issue"] }, PAYMENT_PROBLEM: { pillar: "X", typicalSlots: ["order_ref", "issue"] },
WARRANTY: { pillar: "X", typicalSlots: ["order_ref", "issue"] },
WRONG_ITEM: { pillar: "X", typicalSlots: ["order_ref", "issue"] },
UNKNOWN: { pillar: "K", typicalSlots: [] }, UNKNOWN: { pillar: "K", typicalSlots: [] },
} }

View File

@ -0,0 +1,76 @@
import {
L1_MODULE_CARD_TITLE,
L1_MODULE_CUSTOMER_LABEL,
modulePillar,
} from "@/lib/ai/orchestrator/vendorModuleGroups"
import type { L1Module } from "@/lib/ai/orchestrator/types"
import type { AutomationKey } from "@/lib/ai/orchestrator/aiModules"
export type UiAutomationRow = {
key: AutomationKey
label: string
subtitle?: string
/** When true, checkbox is on and disabled (platform requirement). */
lockedOn?: boolean
}
type VendorModule = Exclude<L1Module, "UNKNOWN">
export function automationRowsForModule(mod: VendorModule): UiAutomationRow[] {
const pillar = modulePillar(mod)
const cardTitle = L1_MODULE_CARD_TITLE[mod]
const customer = L1_MODULE_CUSTOMER_LABEL[mod]
if (mod === "CATEGORY_METADETAILS") {
return [
{
key: "verifiedLookup",
label: "Use category fields from your synced catalog",
subtitle:
"When on, product-fit answers may use category metadetails from your database when tools provide them.",
},
]
}
if (pillar === "K") {
return [
{
key: "knowledge",
label: `Automated answers — ${cardTitle}`,
subtitle: customer,
},
]
}
if (pillar === "T") {
return [
{
key: "verifiedLookup",
label: `Verified data lookup — ${cardTitle}`,
subtitle:
"Uses live order or product data after the shopper matches inbox email or completes chat verification.",
},
{
key: "ticket",
label: `Support ticket when automation can’t finish — ${cardTitle}`,
subtitle:
"If data is missing or unclear, hand off to your team instead of only replying from the knowledge base.",
},
]
}
// X — ticket pillar
return [
{
key: "verifiedLookup",
label: "Verify identity before a ticket",
subtitle: "Email match on inbound mail, or email + OTP on the storefront widget.",
lockedOn: true,
},
{
key: "ticket",
label: `Support ticket — ${cardTitle}`,
subtitle: customer,
},
]
}

View File

@ -0,0 +1,749 @@
import type { L1Module } from "@/lib/ai/orchestrator/types"
/** One Type-1 questionnaire row inside a module (several rows per broad module). */
export type ModuleType1Row = {
/** Doc label e.g. OS1, FQ3 */
code: string
/** Vendor-facing prompt */
label: string
/** shared_field_id keys; one textarea each (SSoT across modules). */
fieldKeys: string[]
}
export type ModuleThreeTypeSpec = {
/** Show link to Knowledge base for G1–G12 (ALL). */
referencesGlobalAll: boolean
type1Rows: ModuleType1Row[]
/** Read-only: verification & prerequisites (platform). No vendor text here. */
type2Bullets: string[]
/** Read-only: what L1/tickets do. No vendor script — tone from Type 1 + store voice. */
type3Bullets: string[]
}
const T2_NONE_ANONYMOUS_KB = [
"None for anonymous FAQ on the widget — answers use knowledge only (§1.2).",
]
const T2_ORDER_SCOPED = [
"Email: From must match the email on the order in our database; multi-order disambiguation when needed (§1.1).",
"Widget: email + OTP + session TTL before any order-specific data (§1.2).",
"Slots: order reference or pick from a short candidate list.",
]
const T2_ORDER_SCOPED_STRICT = [
...T2_ORDER_SCOPED,
"Same strict verification as other sensitive (fiber) flows.",
]
const T2_PRODUCT_SKU = [
"Slots: product name and/or SKU; disambiguate when many products match.",
"Widget: may allow anonymous stock checks, or require OTP if tied to “my order” (product policy).",
]
const T2_OPTIONAL_PRODUCT = [
"Optional product name/SKU for specificity; no order required for general fit questions.",
]
const T2_CATEGORY_ENABLED = [
"You enable this store-wide feature; sync must persist category metadetails in the database.",
"Slots: resolve product → category (e.g. name/SKU → category) before tool calls.",
]
const T2_MINIMAL = [
"Minimal prerequisites — optional context only; no order read unless combined with another intent.",
]
const T2_FEEDBACK = [
"Optional link to prior ticket or context if your product supports it.",
]
const T2_REFUND_STATUS = [
"Verified identity plus order or return reference (per your schema).",
]
const T2_PAYMENT = [
"Order-scoped payment issues: verified identity + order id.",
"Pre-purchase / checkout failures: may only have email + narrative → ticket with available context.",
]
const T2_ORDER_CHANGE = [
...T2_ORDER_SCOPED,
"Capture requested change (address / items / qty) as slots for the ticket.",
]
const T2_RETURN = [
...T2_ORDER_SCOPED,
"Optional reason / return intent slot.",
]
const T2_WRONG_ITEM = [
...T2_ORDER_SCOPED,
"Issue type slot; email may include photo attachments.",
]
const T2_WARRANTY = [
"Verified identity plus proof-of-purchase / order reference.",
]
export const MODULE_THREE_TYPES: Record<Exclude<L1Module, "UNKNOWN">, ModuleThreeTypeSpec> = {
FAQ: {
referencesGlobalAll: true,
type1Rows: [
{
code: "FQ1",
label: "Shipping — regions, methods, costs, timelines",
fieldKeys: [
"sf_shipping_regions_served",
"sf_shipping_regions_excluded",
"sf_shipping_methods_and_transit",
"sf_shipping_cost_model",
"sf_processing_time_before_ship",
],
},
{
code: "FQ2",
label: "Returns — window, condition, non-returnables, who pays shipping",
fieldKeys: [
"sf_return_window",
"sf_return_condition_requirements",
"sf_non_returnable_categories",
"sf_return_shipping_payer",
"sf_how_to_start_return",
],
},
{
code: "FQ3",
label: "Refund policy (wording only — not live refund status)",
fieldKeys: [
"sf_refund_methods",
"sf_refund_timeline_after_approval",
"sf_partial_refund_rules",
"sf_store_credit_rules",
],
},
{
code: "FQ4",
label: "Payment methods & failed payment guidance",
fieldKeys: ["sf_payment_methods_accepted", "sf_failed_payment_guidance"],
},
{
code: "FQ5",
label: "Promo / discount rules",
fieldKeys: ["sf_promo_discount_rules"],
},
{
code: "FQ6",
label: "Tax display at checkout",
fieldKeys: ["sf_tax_display_policy"],
},
{
code: "FQ7",
label: "How customers reach you",
fieldKeys: ["sf_support_channels", "sf_support_first_response_sla"],
},
{
code: "FQ8",
label: "Policy links (terms, privacy, returns)",
fieldKeys: [
"sf_policy_link_terms",
"sf_policy_link_privacy",
"sf_policy_link_returns",
],
},
],
type2Bullets: T2_NONE_ANONYMOUS_KB,
type3Bullets: [
"L1: retrieve answers from your Type-1 text and the knowledge base (+ global store voice).",
"Out-of-scope questions: suggest email or ticket using your support channels — no fixed script from you here.",
],
},
CATEGORY_METADETAILS: {
referencesGlobalAll: false,
type1Rows: [],
type2Bullets: T2_CATEGORY_ENABLED,
type3Bullets: [
"When enabled: tool calls may read category metadetails from the database for all synced categories.",
"When disabled or empty: fall back to Product fit / FAQ, PDP links, or escalation — no metadetails tools.",
"If data is missing or unclear: clarify or ticket per global / product-fit policy.",
],
},
STORE_LOCAL: {
referencesGlobalAll: true,
type1Rows: [
{
code: "SL1",
label: "Physical locations, hours, holidays",
fieldKeys: ["sf_physical_locations_hours"],
},
{
code: "SL2",
label: "Pickup rules",
fieldKeys: ["sf_pickup_rules"],
},
{
code: "SL3",
label: "In-store vs online inventory",
fieldKeys: ["sf_instore_vs_online_inventory"],
},
{
code: "SL4",
label: "Local returns (how to start)",
fieldKeys: ["sf_how_to_start_return"],
},
],
type2Bullets: ["None — typical knowledge replies need no identity verification."],
type3Bullets: [
"L1: KB-only from Type-1 and store voice; no order tools.",
],
},
FEEDBACK: {
referencesGlobalAll: true,
type1Rows: [
{
code: "FB1",
label: "CSAT / survey process",
fieldKeys: ["sf_feedback_csat_process"],
},
{
code: "FB2",
label: "Tone & words to avoid (also in global voice)",
fieldKeys: ["sf_tone_and_words_to_avoid"],
},
],
type2Bullets: T2_FEEDBACK,
type3Bullets: [
"L1: share CSAT / survey link or one-shot ask from Type-1.",
"Negative follow-up: route to human when your process requires it.",
],
},
HUMAN_ESCALATION: {
referencesGlobalAll: true,
type1Rows: [
{
code: "HE1",
label: "When to offer a human",
fieldKeys: ["sf_human_escalation_when"],
},
{
code: "HE2",
label: "Support channels & SLA",
fieldKeys: ["sf_support_channels", "sf_support_first_response_sla"],
},
{
code: "HE3",
label: "First message checklist",
fieldKeys: ["sf_first_message_checklist"],
},
],
type2Bullets: T2_MINIMAL,
type3Bullets: [
"L1: acknowledgement + routing copy grounded in Type-1 and store voice.",
"Ticket: created when your rules require — message wording is model-chosen, not vendor-scripted.",
],
},
PRODUCT_FIT: {
referencesGlobalAll: true,
type1Rows: [
{
code: "PF1",
label: "Sizing & fit guidance",
fieldKeys: ["sf_sizing_fit_guidance"],
},
{
code: "PF2",
label: "Compatibility (devices, voltage, region)",
fieldKeys: ["sf_compatibility_notes"],
},
{
code: "PF3",
label: "Materials, care, limitations",
fieldKeys: ["sf_materials_care_limitations"],
},
{
code: "PF4",
label: "When to escalate to a human",
fieldKeys: ["sf_human_escalation_when"],
},
],
type2Bullets: T2_OPTIONAL_PRODUCT,
type3Bullets: [
"L1: KB + product search; category metadetails tools only when Category metadetails is enabled.",
"Edge cases: escalate per Type-1 — phrasing follows your store voice.",
],
},
ORDER_STATUS: {
referencesGlobalAll: true,
type1Rows: [
{
code: "OS1",
label: "Normal processing time before shipment",
fieldKeys: ["sf_processing_time_before_ship"],
},
{
code: "OS3",
label: "If delayed before shipment, what we tell customers",
fieldKeys: ["sf_major_delay_communication"],
},
{
code: "OS4",
label: "Ship to / do not ship to",
fieldKeys: ["sf_shipping_regions_served", "sf_shipping_regions_excluded"],
},
{
code: "OS5",
label: "Order # help & first-message checklist",
fieldKeys: ["sf_order_id_help", "sf_first_message_checklist"],
},
],
type2Bullets: T2_ORDER_SCOPED,
type3Bullets: [
"L1: read order/fulfillment from DB after verification; blend Type-1 messaging.",
"Ticket: data missing, anomaly, or outside playbook — no vendor-authored ticket script.",
],
},
ORDER_SUMMARY: {
referencesGlobalAll: true,
type1Rows: [
{
code: "SM1",
label: "Tax / invoice disclaimer",
fieldKeys: ["sf_order_summary_tax_invoice_note"],
},
{
code: "SM2",
label: "Order # help & checklist",
fieldKeys: ["sf_order_id_help", "sf_first_message_checklist"],
},
],
type2Bullets: T2_ORDER_SCOPED,
type3Bullets: [
"L1: read line items/totals; wrap with Type-1 disclaimers.",
"Ticket: tax/invoice edge cases per your policy — human decides.",
],
},
SHIPPING_TRACKING: {
referencesGlobalAll: true,
type1Rows: [
{
code: "ST1",
label: "Carriers & tracking policy",
fieldKeys: ["sf_carriers_and_tracking_policy"],
},
{
code: "ST2",
label: "Shipping methods & transit expectations",
fieldKeys: ["sf_shipping_methods_and_transit"],
},
{
code: "ST3",
label: "Shipping cost model & dispatch cutoff",
fieldKeys: ["sf_shipping_cost_model", "sf_dispatch_cutoff"],
},
{
code: "ST4",
label: "Regions & customs",
fieldKeys: ["sf_shipping_regions_served", "sf_customs_duties_policy"],
},
],
type2Bullets: T2_ORDER_SCOPED,
type3Bullets: [
"L1: read tracking fields for the verified order + Type-1 carrier copy.",
"Ticket: carrier claim/dispute handoff — wording is adaptive.",
],
},
ORDER_CANCEL: {
referencesGlobalAll: true,
type1Rows: [
{
code: "CX1",
label: "Cancellation window & how to request",
fieldKeys: ["sf_cancellation_window", "sf_cancellation_how_to_request"],
},
{
code: "CX2",
label: "Pre-order / digital exceptions",
fieldKeys: ["sf_cancellation_preorder_digital"],
},
{
code: "CX3",
label: "If change/cancel no longer possible — next step",
fieldKeys: ["sf_order_change_not_possible_next_step"],
},
{
code: "CX4",
label: "Refund timing (policy wording)",
fieldKeys: ["sf_refund_timeline_after_approval", "sf_refund_methods"],
},
],
type2Bullets: T2_ORDER_SCOPED,
type3Bullets: [
"L1: eligibility + cancel when DB/rules allow; else explain from Type-1.",
"Ticket: ambiguous or disallowed cases — human judgment.",
],
},
REFUND_STATUS: {
referencesGlobalAll: true,
type1Rows: [
{
code: "RF1",
label: "Refund methods & timeline (policy)",
fieldKeys: ["sf_refund_methods", "sf_refund_timeline_after_approval"],
},
{
code: "RF2",
label: "Partial refunds & store credit",
fieldKeys: ["sf_partial_refund_rules", "sf_store_credit_rules"],
},
],
type2Bullets: T2_REFUND_STATUS,
type3Bullets: [
"L1: read refund/return state from DB when modeled; align with Type-1 timelines.",
"Ticket: state missing or ambiguous after verification.",
],
},
INVENTORY_STOCK: {
referencesGlobalAll: true,
type1Rows: [
{
code: "IV1",
label: "Back in stock",
fieldKeys: ["sf_back_in_stock_policy"],
},
{
code: "IV2",
label: "Pre-orders",
fieldKeys: ["sf_preorder_policy"],
},
{
code: "IV3",
label: "Discontinued products",
fieldKeys: ["sf_discontinued_product_policy"],
},
{
code: "IV4",
label: "In-store vs online inventory",
fieldKeys: ["sf_instore_vs_online_inventory"],
},
],
type2Bullets: T2_PRODUCT_SKU,
type3Bullets: [
"L1: inventory read from DB + Type-1 snippets.",
"Heavy disambiguation: clarify or ticket — replies use your tone from global + Type-1.",
],
},
RETURN_EXCHANGE: {
referencesGlobalAll: true,
type1Rows: [
{
code: "RE1",
label: "Return window & condition",
fieldKeys: ["sf_return_window", "sf_return_condition_requirements"],
},
{
code: "RE2",
label: "Non-returnables & return shipping payer",
fieldKeys: ["sf_non_returnable_categories", "sf_return_shipping_payer"],
},
{
code: "RE3",
label: "Exchange policy & how to start",
fieldKeys: ["sf_exchange_policy", "sf_how_to_start_return"],
},
{
code: "RE4",
label: "Refund policy (wording)",
fieldKeys: ["sf_refund_methods", "sf_refund_timeline_after_approval"],
},
],
type2Bullets: T2_RETURN,
type3Bullets: [
"L1: policy in reply from Type-1 + open ticket (or portal later).",
"Human/ops fulfills RMA — no fixed customer script from you.",
],
},
ORDER_CHANGE: {
referencesGlobalAll: true,
type1Rows: [
{
code: "OC1",
label: "Until when changes are sometimes possible",
fieldKeys: ["sf_order_change_allowed_until"],
},
{
code: "OC2",
label: "How to request a change",
fieldKeys: ["sf_order_change_how_to_request"],
},
{
code: "OC3",
label: "When change is not possible — next step",
fieldKeys: ["sf_order_change_not_possible_next_step"],
},
{
code: "OC4",
label: "Cancel alternative",
fieldKeys: ["sf_cancellation_window", "sf_cancellation_how_to_request"],
},
],
type2Bullets: T2_ORDER_CHANGE,
type3Bullets: [
"L1 v1: no auto-mutate order — acknowledgement + ticket only.",
"Human decides feasibility; tone from Type-1 + store voice.",
],
},
COMPLAINT: {
referencesGlobalAll: true,
type1Rows: [
{
code: "CP1",
label: "Complaint acknowledgement SLA & escalation",
fieldKeys: [
"sf_complaint_acknowledgement_sla",
"sf_complaint_supervisor_escalation",
],
},
{
code: "CP2",
label: "Abusive contact policy",
fieldKeys: ["sf_abusive_contact_policy"],
},
{
code: "CP3",
label: "Remedies summary & support channels",
fieldKeys: ["sf_wrong_item_remedies", "sf_refund_methods", "sf_support_channels"],
},
],
type2Bullets: T2_ORDER_SCOPED_STRICT,
type3Bullets: [
"L1: empathetic acknowledgement + severity intake; Type-1 informs boundaries.",
"Ticket: always created; human owns resolution — model chooses wording within your tone.",
],
},
NOT_RECEIVED_MARKED_DELIVERED: {
referencesGlobalAll: true,
type1Rows: [
{
code: "NR1",
label: "“Delivered” but not received — steps & expectations",
fieldKeys: ["sf_delivered_not_received_policy"],
},
{
code: "NR2",
label: "Carrier / claim expectations",
fieldKeys: ["sf_carriers_and_tracking_policy"],
},
],
type2Bullets: T2_ORDER_SCOPED,
type3Bullets: [
"L1: tracking snapshot + Type-1, then always open ticket for investigation.",
"Human handles carrier/claim.",
],
},
SHIPMENT_STUCK_OR_DELAYED: {
referencesGlobalAll: true,
type1Rows: [
{
code: "SD1",
label: "What customer should do if shipment seems stuck",
fieldKeys: ["sf_stuck_shipment_customer_steps"],
},
{
code: "SD2",
label: "Major delay communication",
fieldKeys: ["sf_major_delay_communication"],
},
{
code: "SD3",
label: "Carriers reference",
fieldKeys: ["sf_carriers_and_tracking_policy"],
},
],
type2Bullets: T2_ORDER_SCOPED,
type3Bullets: [
"L1: last scan + Type-1 + open ticket for ops/carrier follow-up.",
],
},
PAYMENT_PROBLEM: {
referencesGlobalAll: true,
type1Rows: [
{
code: "PP1",
label: "Payment methods & failed payment",
fieldKeys: ["sf_payment_methods_accepted", "sf_failed_payment_guidance"],
},
{
code: "PP2",
label: "Double charge intake",
fieldKeys: ["sf_double_charge_intake"],
},
{
code: "PP3",
label: "Promo, tax, price adjustment",
fieldKeys: [
"sf_promo_discount_rules",
"sf_tax_display_policy",
"sf_price_adjustment_policy",
],
},
],
type2Bullets: T2_PAYMENT,
type3Bullets: [
"L1: structured intake + ticket; no automated money movement.",
"Finance/human resolves — replies follow your tone, not a fixed script.",
],
},
WARRANTY: {
referencesGlobalAll: true,
type1Rows: [
{
code: "WA1",
label: "Warranty scope & duration",
fieldKeys: ["sf_warranty_duration_scope"],
},
{
code: "WA2",
label: "Voiders",
fieldKeys: ["sf_warranty_voiders"],
},
{
code: "WA3",
label: "Claim process",
fieldKeys: ["sf_warranty_claim_process"],
},
{
code: "WA4",
label: "Supervisor escalation",
fieldKeys: ["sf_complaint_supervisor_escalation"],
},
],
type2Bullets: T2_WARRANTY,
type3Bullets: [
"L1: intake + Type-1 + ticket.",
"Human adjudicates.",
],
},
WRONG_ITEM: {
referencesGlobalAll: true,
type1Rows: [
{
code: "WI1",
label: "Report deadline & evidence",
fieldKeys: ["sf_wrong_item_report_deadline", "sf_wrong_item_evidence"],
},
{
code: "WI2",
label: "Remedies & carrier vs warehouse damage",
fieldKeys: ["sf_wrong_item_remedies", "sf_damage_carrier_vs_warehouse"],
},
{
code: "WI3",
label: "Return shipping & how to start return",
fieldKeys: ["sf_return_shipping_payer", "sf_how_to_start_return"],
},
{
code: "WI4",
label: "Returns policy link",
fieldKeys: ["sf_policy_link_returns"],
},
],
type2Bullets: T2_WRONG_ITEM,
type3Bullets: [
"L1: acknowledgement + Type-1 + ticket with structured fields.",
"Human executes reship/refund/return.",
],
},
}
/** Short labels for textarea placeholders (from vendor doc §5). */
export const SHARED_FIELD_SHORT_LABEL: Record<string, string> = {
sf_shipping_regions_served: "Where you ship",
sf_shipping_regions_excluded: "Where you do not ship",
sf_shipping_methods_and_transit: "Methods & transit ranges",
sf_shipping_cost_model: "Free / paid / flat / dynamic",
sf_processing_time_before_ship: "Handling time before shipment",
sf_major_delay_communication: "What you say for major delays / backlog",
sf_carriers_and_tracking_policy: "Carriers & when tracking appears",
sf_dispatch_cutoff: "Same-day dispatch rules",
sf_customs_duties_policy: "Duties / international disclaimer",
sf_order_id_help: "How customers find order #",
sf_first_message_checklist: "What to include when contacting support",
sf_return_window: "Return window",
sf_return_condition_requirements: "Condition requirements",
sf_non_returnable_categories: "Non-returnable categories",
sf_return_shipping_payer: "Who pays return shipping",
sf_exchange_policy: "Exchange vs credit vs refund",
sf_how_to_start_return: "How to start a return",
sf_refund_methods: "Refund methods",
sf_refund_timeline_after_approval: "Timeline after approval",
sf_partial_refund_rules: "Partial refund rules",
sf_store_credit_rules: "Store credit rules",
sf_payment_methods_accepted: "Payment methods accepted",
sf_failed_payment_guidance: "What customer should retry",
sf_promo_discount_rules: "Promo / discount rules",
sf_tax_display_policy: "Tax display at checkout",
sf_price_adjustment_policy: "Price match / adjustments",
sf_policy_link_terms: "Terms of sale URL",
sf_policy_link_privacy: "Privacy policy URL",
sf_policy_link_returns: "Returns policy URL",
sf_support_channels: "Support channels & hours",
sf_support_first_response_sla: "First response SLA",
sf_tone_and_words_to_avoid: "Tone & words to avoid",
sf_feedback_csat_process: "CSAT / survey process",
sf_physical_locations_hours: "Locations & hours",
sf_pickup_rules: "Pickup rules",
sf_instore_vs_online_inventory: "In-store vs online stock",
sf_sizing_fit_guidance: "Sizing & fit / size chart",
sf_compatibility_notes: "Compatibility",
sf_materials_care_limitations: "Materials & care",
sf_human_escalation_when: "When to ask a human",
sf_cancellation_window: "Cancellation window",
sf_cancellation_how_to_request: "How to request cancel",
sf_cancellation_preorder_digital: "Pre-order / digital cancel rules",
sf_order_change_allowed_until: "Until when edits are possible",
sf_order_change_how_to_request: "How to request a change",
sf_order_change_not_possible_next_step: "After cutoff / shipped — what you say",
sf_order_summary_tax_invoice_note: "Tax / invoice / VAT note",
sf_delivered_not_received_policy: "Delivered but not received",
sf_stuck_shipment_customer_steps: "Stuck shipment — customer steps",
sf_double_charge_intake: "Double charge — what to send",
sf_wrong_item_report_deadline: "Wrong item report deadline",
sf_wrong_item_evidence: "Evidence (photos, etc.)",
sf_wrong_item_remedies: "Remedies you offer",
sf_damage_carrier_vs_warehouse: "Carrier vs warehouse damage",
sf_warranty_duration_scope: "Warranty length & scope",
sf_warranty_voiders: "Warranty voiders",
sf_warranty_claim_process: "Warranty claim process",
sf_complaint_acknowledgement_sla: "Complaint acknowledgement SLA",
sf_complaint_supervisor_escalation: "When a lead reviews",
sf_abusive_contact_policy: "Abusive contact boundary",
sf_back_in_stock_policy: "Back in stock / notify",
sf_preorder_policy: "Pre-order policy",
sf_discontinued_product_policy: "Discontinued products",
}
export function collectFieldKeysForModule(mod: Exclude<L1Module, "UNKNOWN">): string[] {
const spec = MODULE_THREE_TYPES[mod]
const s = new Set<string>()
for (const row of spec.type1Rows) {
for (const k of row.fieldKeys) s.add(k)
}
return Array.from(s)
}
export function formatSharedFieldsForModule(
mod: L1Module,
answers: Record<string, string>,
): string {
if (mod === "UNKNOWN") return ""
const keys = collectFieldKeysForModule(mod)
if (!keys.length) return ""
const lines: string[] = []
for (const k of keys) {
const v = answers[k]?.trim()
if (v) {
const lab = SHARED_FIELD_SHORT_LABEL[k] || k
lines.push(`${lab} (${k}): ${v}`)
}
}
return lines.length ? `Module policy (Type 1 — ${mod}):\n${lines.join("\n")}` : ""
}

View File

@ -8,13 +8,17 @@ import {
listOrdersForCustomerEmail, listOrdersForCustomerEmail,
orderNeedsDisambiguation, orderNeedsDisambiguation,
} from "@/lib/ai/tools/orderTools" } from "@/lib/ai/tools/orderTools"
import { searchProductsForStore } from "@/lib/ai/tools/productTools"
import { import {
getProductMetafieldsForL1, isAutomationEnabled,
searchProductsForStore, isModuleEnabled,
} from "@/lib/ai/tools/productTools" needsIdentityGate,
import { isModuleEnabled, parseAiModulesJson } from "@/lib/ai/orchestrator/aiModules" parseAiModulesJson,
} from "@/lib/ai/orchestrator/aiModules"
import { pillarForModule } from "@/lib/ai/orchestrator/manifests" import { pillarForModule } from "@/lib/ai/orchestrator/manifests"
import { routeCustomerIntent } from "@/lib/ai/orchestrator/intentRouter" import { routeCustomerIntent } from "@/lib/ai/orchestrator/intentRouter"
import { formatSharedFieldsForModule } from "@/lib/ai/orchestrator/moduleThreeTypes"
import { parseSharedFieldAnswersJson } from "@/lib/kb/sharedFieldAnswers"
import type { L1Channel, L1Module, L1RunMeta } from "@/lib/ai/orchestrator/types" import type { L1Channel, L1Module, L1RunMeta } from "@/lib/ai/orchestrator/types"
const GENERIC_NO_ORDER_EMAIL = const GENERIC_NO_ORDER_EMAIL =
@ -23,6 +27,18 @@ const GENERIC_NO_ORDER_EMAIL =
const WIDGET_VERIFY_PROMPT = 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." "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."
const FAQ_DISABLED_FALLBACK =
"I don't know about this—please contact our support team for help."
/** Transactional modules that load a single order row after verification + disambiguation. */
const ORDER_DETAIL_MODULES: L1Module[] = [
"ORDER_STATUS",
"ORDER_SUMMARY",
"SHIPPING_TRACKING",
"ORDER_CANCEL",
"REFUND_STATUS",
]
function isWidgetSessionValid( function isWidgetSessionValid(
verifiedEmail: string | null | undefined, verifiedEmail: string | null | undefined,
expiresAt: Date | null | undefined, expiresAt: Date | null | undefined,
@ -38,8 +54,11 @@ export type RunL1Input = {
storeName: string storeName: string
message: string message: string
history: Array<{ senderType?: "CUSTOMER" | "AI"; role?: string; content: string }> history: Array<{ senderType?: "CUSTOMER" | "AI"; role?: string; content: string }>
categoryMetadetailsEnabled: boolean
aiModulesJson: unknown aiModulesJson: unknown
/** Type-1 vendor answers keyed by sf_* (optional). */
sharedFieldAnswersJson?: unknown
/** DB flag; when set, overrides aiModules.CATEGORY_METADETAILS for metadetails tooling. */
categoryMetadetailsEnabled?: boolean
emailFrom?: string | null emailFrom?: string | null
widgetVerifiedEmail?: string | null widgetVerifiedEmail?: string | null
widgetSessionExpiresAt?: Date | null widgetSessionExpiresAt?: Date | null
@ -107,6 +126,7 @@ function ticketHandoffText(module: L1Module, storeName: string): string {
export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result> { export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result> {
const modules = parseAiModulesJson(input.aiModulesJson) const modules = parseAiModulesJson(input.aiModulesJson)
const sharedAnswers = parseSharedFieldAnswersJson(input.sharedFieldAnswersJson)
const retrieval = await getBlendedStorefrontContext({ const retrieval = await getBlendedStorefrontContext({
storeId: input.storeId, storeId: input.storeId,
query: input.message, query: input.message,
@ -119,13 +139,32 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
: "" : ""
const routed = await routeCustomerIntent(input.message) const routed = await routeCustomerIntent(input.message)
let module: L1Module = routed.module let activeModule: L1Module = routed.module
if (!isModuleEnabled(modules, module)) { if (!isModuleEnabled(modules, activeModule)) {
module = "FAQ" activeModule = "FAQ"
}
if (!isModuleEnabled(modules, activeModule)) {
return {
text: FAQ_DISABLED_FALLBACK,
meta: {
module: "UNKNOWN",
pillar: "K",
usedTools: [],
kbHits: retrieval.kbCount,
productHits: retrieval.productCount,
},
}
} }
const pillar = pillarForModule(module) const pillar = pillarForModule(activeModule)
const moduleFlags = modules[activeModule]
const usedTools: string[] = [] const usedTools: string[] = []
const type1Facts = formatSharedFieldsForModule(activeModule, sharedAnswers)
const categoryMetadetailsOn =
input.categoryMetadetailsEnabled !== undefined &&
input.categoryMetadetailsEnabled !== null
? Boolean(input.categoryMetadetailsEnabled)
: isModuleEnabled(modules, "CATEGORY_METADETAILS")
let customerEmail: string | null = null let customerEmail: string | null = null
if (input.channel === "email") { if (input.channel === "email") {
@ -136,14 +175,13 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
} }
} }
const needsFiber = const needsFiber = needsIdentityGate(pillar, moduleFlags)
pillar === "T" || pillar === "X" || module === "CATEGORY_METADETAILS"
if (input.channel === "widget" && needsFiber && !customerEmail) { if (input.channel === "widget" && needsFiber && !customerEmail) {
return { return {
text: WIDGET_VERIFY_PROMPT, text: WIDGET_VERIFY_PROMPT,
meta: { meta: {
module, module: activeModule,
pillar, pillar,
usedTools: [], usedTools: [],
kbHits: retrieval.kbCount, kbHits: retrieval.kbCount,
@ -152,15 +190,11 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
} }
} }
if ( if (needsFiber && input.channel === "email" && !customerEmail) {
(pillar === "T" || pillar === "X") &&
input.channel === "email" &&
!customerEmail
) {
return { return {
text: GENERIC_NO_ORDER_EMAIL, text: GENERIC_NO_ORDER_EMAIL,
meta: { meta: {
module, module: activeModule,
pillar, pillar,
usedTools, usedTools,
kbHits: retrieval.kbCount, kbHits: retrieval.kbCount,
@ -169,9 +203,15 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
} }
} }
const ordersForEmail = customerEmail const shouldListOrders =
? await listOrdersForCustomerEmail(input.storeId, customerEmail) Boolean(customerEmail) &&
: [] (pillar === "X" ||
(pillar === "T" && isAutomationEnabled(modules, activeModule, "verifiedLookup")))
const ordersForEmail =
customerEmail && shouldListOrders
? await listOrdersForCustomerEmail(input.storeId, customerEmail)
: []
if ( if (
(pillar === "T" || pillar === "X") && (pillar === "T" || pillar === "X") &&
@ -179,16 +219,32 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
customerEmail customerEmail
) { ) {
const facts = [ const facts = [
type1Facts,
kbContext ? `Knowledge:\n${kbContext}` : "", kbContext ? `Knowledge:\n${kbContext}` : "",
"No orders on file for this email in our cache.", "No orders on file for this email in our cache.",
] ]
.filter(Boolean) .filter(Boolean)
.join("\n\n") .join("\n\n")
if (pillar === "X") { if (pillar === "X" && isAutomationEnabled(modules, activeModule, "ticket")) {
return { return {
text: ticketHandoffText(module, input.storeName), text: ticketHandoffText(activeModule, input.storeName),
meta: { meta: {
module, module: activeModule,
pillar,
usedTools: ["ticket_intake"],
kbHits: retrieval.kbCount,
productHits: retrieval.productCount,
},
}
}
if (
pillar === "T" &&
isAutomationEnabled(modules, activeModule, "ticket")
) {
return {
text: ticketHandoffText(activeModule, input.storeName),
meta: {
module: activeModule,
pillar, pillar,
usedTools: ["ticket_intake"], usedTools: ["ticket_intake"],
kbHits: retrieval.kbCount, kbHits: retrieval.kbCount,
@ -205,7 +261,7 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
return { return {
text, text,
meta: { meta: {
module, module: activeModule,
pillar, pillar,
usedTools, usedTools,
kbHits: retrieval.kbCount, kbHits: retrieval.kbCount,
@ -214,11 +270,15 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
} }
} }
if (pillar === "X" && ordersForEmail.length > 0) { if (
pillar === "X" &&
ordersForEmail.length > 0 &&
isAutomationEnabled(modules, activeModule, "ticket")
) {
return { return {
text: ticketHandoffText(module, input.storeName), text: ticketHandoffText(activeModule, input.storeName),
meta: { meta: {
module, module: activeModule,
pillar, pillar,
usedTools: ["ticket_intake"], usedTools: ["ticket_intake"],
kbHits: retrieval.kbCount, kbHits: retrieval.kbCount,
@ -238,18 +298,17 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
orderNeedsDisambiguation(customerEmail, hintText, ordersForEmail) orderNeedsDisambiguation(customerEmail, hintText, ordersForEmail)
if ( if (
(module === "ORDER_STATUS" || ORDER_DETAIL_MODULES.includes(activeModule) &&
module === "ORDER_SUMMARY" ||
module === "SHIPPING_TRACKING") &&
customerEmail && customerEmail &&
disambiguate && disambiguate &&
!input.boundShopifyOrderId !input.boundShopifyOrderId &&
isAutomationEnabled(modules, activeModule, "verifiedLookup")
) { ) {
const list = formatOrderCandidatesForPrompt(ordersForEmail) const list = formatOrderCandidatesForPrompt(ordersForEmail)
return { 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).`, 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: { meta: {
module, module: activeModule,
pillar, pillar,
usedTools: ["list_orders"], usedTools: ["list_orders"],
kbHits: retrieval.kbCount, kbHits: retrieval.kbCount,
@ -261,9 +320,8 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
let orderFacts = "" let orderFacts = ""
if ( if (
customerEmail && customerEmail &&
(module === "ORDER_STATUS" || ORDER_DETAIL_MODULES.includes(activeModule) &&
module === "ORDER_SUMMARY" || isAutomationEnabled(modules, activeModule, "verifiedLookup")
module === "SHIPPING_TRACKING")
) { ) {
const detail = await getOrderDetailForVerifiedEmail(input.storeId, customerEmail, { const detail = await getOrderDetailForVerifiedEmail(input.storeId, customerEmail, {
shopifyOrderId: input.boundShopifyOrderId ?? undefined, shopifyOrderId: input.boundShopifyOrderId ?? undefined,
@ -271,7 +329,7 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
}) })
usedTools.push("get_order_detail") usedTools.push("get_order_detail")
if (detail) { if (detail) {
orderFacts = [ const lines = [
`Order ${detail.orderNumber} (${detail.shopifyOrderId})`, `Order ${detail.orderNumber} (${detail.shopifyOrderId})`,
`Created: ${detail.shopifyCreatedAt ?? "unknown"}`, `Created: ${detail.shopifyCreatedAt ?? "unknown"}`,
`Financial: ${detail.financialStatus ?? "unknown"}`, `Financial: ${detail.financialStatus ?? "unknown"}`,
@ -281,7 +339,18 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
? `Tracking: ${detail.trackingCompany ?? ""} ${detail.trackingNumber ?? ""} ${detail.trackingUrl ?? ""}`.trim() ? `Tracking: ${detail.trackingCompany ?? ""} ${detail.trackingNumber ?? ""} ${detail.trackingUrl ?? ""}`.trim()
: "Tracking: not available in our cache yet.", : "Tracking: not available in our cache yet.",
`Line items (JSON): ${JSON.stringify(detail.lineItems).slice(0, 4000)}`, `Line items (JSON): ${JSON.stringify(detail.lineItems).slice(0, 4000)}`,
].join("\n") ]
if (activeModule === "ORDER_CANCEL") {
lines.push(
"Context: customer asked about cancellation — use only the statuses above; do not claim the order was cancelled unless Facts show it.",
)
}
if (activeModule === "REFUND_STATUS") {
lines.push(
"Context: customer asked about refund/payout status for this order — explain only what Financial/Fulfillment statuses imply; if unclear, say the team can confirm.",
)
}
orderFacts = lines.join("\n")
} else { } else {
orderFacts = orderFacts =
"Order detail could not be loaded (missing order reference or no match for this email)." "Order detail could not be loaded (missing order reference or no match for this email)."
@ -289,11 +358,11 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
} }
let productFacts = "" let productFacts = ""
if ( const runProductSearch =
module === "INVENTORY_STOCK" || (activeModule === "PRODUCT_FIT" &&
module === "CATEGORY_METADETAILS" || isAutomationEnabled(modules, activeModule, "knowledge")) ||
module === "PRODUCT_FIT" (activeModule === "INVENTORY_STOCK" && isModuleEnabled(modules, activeModule))
) { if (runProductSearch) {
const hits = await searchProductsForStore(input.storeId, input.message, 5) const hits = await searchProductsForStore(input.storeId, input.message, 5)
usedTools.push("search_products") usedTools.push("search_products")
if (hits.length) { if (hits.length) {
@ -304,23 +373,19 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
) )
.join("\n") .join("\n")
} }
if (module === "CATEGORY_METADETAILS" && input.categoryMetadetailsEnabled && hits[0]) { if (activeModule === "PRODUCT_FIT" && categoryMetadetailsOn) {
const mf = await getProductMetafieldsForL1( usedTools.push("category_metadetails_enabled")
input.storeId, productFacts = [
hits[0].shopifyProductId, productFacts,
true, "Store setting: category metadetails from the database are enabled for this shop when tooling provides them (use only if present in Facts).",
) ]
usedTools.push("product_metafields") .filter(Boolean)
if (mf.metafieldsSummary) { .join("\n\n")
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 = [ const facts = [
type1Facts,
kbContext ? `Knowledge:\n${kbContext}` : "", kbContext ? `Knowledge:\n${kbContext}` : "",
orderFacts ? `Order data:\n${orderFacts}` : "", orderFacts ? `Order data:\n${orderFacts}` : "",
productFacts ? `Product search:\n${productFacts}` : "", productFacts ? `Product search:\n${productFacts}` : "",
@ -338,7 +403,7 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
logger.info("[L1_ORCHESTRATOR]", { logger.info("[L1_ORCHESTRATOR]", {
storeId: input.storeId, storeId: input.storeId,
channel: input.channel, channel: input.channel,
module, module: activeModule,
pillar, pillar,
usedTools, usedTools,
}) })
@ -346,7 +411,7 @@ export async function runL1Orchestrator(input: RunL1Input): Promise<RunL1Result>
return { return {
text, text,
meta: { meta: {
module, module: activeModule,
pillar, pillar,
usedTools, usedTools,
kbHits: retrieval.kbCount, kbHits: retrieval.kbCount,

View File

@ -1,23 +1,54 @@
export type L1Channel = "widget" | "email" export type L1Channel = "widget" | "email"
export type L1Pillar = "K" | "T" | "X" | "CAT" export type L1Pillar = "K" | "T" | "X"
export type L1Module = export type L1Module =
| "FAQ" | "FAQ"
| "CATEGORY_METADETAILS"
| "STORE_LOCAL"
| "FEEDBACK"
| "HUMAN_ESCALATION"
| "PRODUCT_FIT"
| "ORDER_STATUS" | "ORDER_STATUS"
| "ORDER_SUMMARY" | "ORDER_SUMMARY"
| "SHIPPING_TRACKING" | "SHIPPING_TRACKING"
| "ORDER_CANCEL"
| "REFUND_STATUS"
| "INVENTORY_STOCK" | "INVENTORY_STOCK"
| "CATEGORY_METADETAILS"
| "PRODUCT_FIT"
| "RETURN_EXCHANGE" | "RETURN_EXCHANGE"
| "ORDER_CHANGE" | "ORDER_CHANGE"
| "COMPLAINT" | "COMPLAINT"
| "NOT_RECEIVED_MARKED_DELIVERED" | "NOT_RECEIVED_MARKED_DELIVERED"
| "SHIPMENT_STUCK_OR_DELAYED" | "SHIPMENT_STUCK_OR_DELAYED"
| "PAYMENT_PROBLEM" | "PAYMENT_PROBLEM"
| "WARRANTY"
| "WRONG_ITEM"
| "UNKNOWN" | "UNKNOWN"
export const L1_MODULE_IDS: L1Module[] = [
"FAQ",
"CATEGORY_METADETAILS",
"STORE_LOCAL",
"FEEDBACK",
"HUMAN_ESCALATION",
"PRODUCT_FIT",
"ORDER_STATUS",
"ORDER_SUMMARY",
"SHIPPING_TRACKING",
"ORDER_CANCEL",
"REFUND_STATUS",
"INVENTORY_STOCK",
"RETURN_EXCHANGE",
"ORDER_CHANGE",
"COMPLAINT",
"NOT_RECEIVED_MARKED_DELIVERED",
"SHIPMENT_STUCK_OR_DELAYED",
"PAYMENT_PROBLEM",
"WARRANTY",
"WRONG_ITEM",
"UNKNOWN",
]
export type L1RunMeta = { export type L1RunMeta = {
module: L1Module module: L1Module
pillar: L1Pillar pillar: L1Pillar

View File

@ -0,0 +1,98 @@
import type { L1Module } from "@/lib/ai/orchestrator/types"
import { MODULE_MANIFESTS } from "@/lib/ai/orchestrator/manifests"
import type { L1Pillar } from "@/lib/ai/orchestrator/types"
/** Short customer-intent labels aligned with intentRouter modules. */
export const L1_MODULE_CUSTOMER_LABEL: Record<L1Module, string> = {
FAQ: "General policies, shipping basics, refund policy (not live order/refund status)",
CATEGORY_METADETAILS:
"Store-wide: allow DB-backed category metadetails in answers (when synced). Sizing/fit flows use this with Product fit.",
STORE_LOCAL: "Store hours, pickup, local / in-person info",
FEEDBACK: "After-resolution feedback, surveys, thank-yous",
HUMAN_ESCALATION: "Customer wants a real person; routing and handoff copy",
PRODUCT_FIT:
"Sizing, fit, compatibility — knowledge search; category metadetails when that toggle is on",
ORDER_STATUS: "Where is my order, processing time, not shipped yet",
ORDER_SUMMARY: "Receipt recap, what did I order, line items",
SHIPPING_TRACKING: "Tracking number, carrier, delivery / ETA",
ORDER_CANCEL: "Cancel order eligibility and requests (DB-backed when available)",
REFUND_STATUS: "Status of my refund or return payout (not refund policy text)",
INVENTORY_STOCK: "Is this item in stock, availability",
RETURN_EXCHANGE: "Start a return or exchange (ticket)",
ORDER_CHANGE: "Change address, items, or quantity (ticket)",
COMPLAINT: "Strong dissatisfaction (ticket)",
NOT_RECEIVED_MARKED_DELIVERED: "Tracking shows delivered but parcel not received (ticket)",
SHIPMENT_STUCK_OR_DELAYED: "Shipment not moving or very late (ticket)",
PAYMENT_PROBLEM: "Double charge, wrong amount, promo failed (ticket)",
WARRANTY: "Warranty claim after return window (ticket)",
WRONG_ITEM: "Wrong, damaged, or missing item (ticket)",
UNKNOWN: "Other / uncategorized",
}
type VendorUiModule = Exclude<L1Module, "UNKNOWN">
/** Knowledge modules shown under “Knowledge” (FAQ is a separate card on the dashboard). */
export const L1_KNOWLEDGE_MODULES_UI = [
"STORE_LOCAL",
"FEEDBACK",
"HUMAN_ESCALATION",
"PRODUCT_FIT",
] as const satisfies readonly VendorUiModule[]
export const L1_TRANSACTIONAL_MODULES_UI = [
"ORDER_STATUS",
"ORDER_SUMMARY",
"SHIPPING_TRACKING",
"ORDER_CANCEL",
"REFUND_STATUS",
"INVENTORY_STOCK",
] as const satisfies readonly VendorUiModule[]
export const L1_TICKET_MODULES_UI = [
"RETURN_EXCHANGE",
"ORDER_CHANGE",
"COMPLAINT",
"NOT_RECEIVED_MARKED_DELIVERED",
"SHIPMENT_STUCK_OR_DELAYED",
"PAYMENT_PROBLEM",
"WARRANTY",
"WRONG_ITEM",
] as const satisfies readonly VendorUiModule[]
/** Short dashboard card titles (grid + modal heading). */
export const L1_MODULE_CARD_TITLE: Record<Exclude<L1Module, "UNKNOWN">, string> = {
FAQ: "General knowledge (FAQ)",
CATEGORY_METADETAILS: "Category metadetails",
STORE_LOCAL: "Store hours & local",
FEEDBACK: "Feedback & thanks",
HUMAN_ESCALATION: "Talk to a human",
PRODUCT_FIT: "Product fit & sizing",
ORDER_STATUS: "Order status",
ORDER_SUMMARY: "Order summary",
SHIPPING_TRACKING: "Shipping & tracking",
ORDER_CANCEL: "Order cancellation",
REFUND_STATUS: "Refund / payout status",
INVENTORY_STOCK: "Stock & availability",
RETURN_EXCHANGE: "Returns & exchanges",
ORDER_CHANGE: "Order changes",
COMPLAINT: "Complaints",
NOT_RECEIVED_MARKED_DELIVERED: "Delivered, not received",
SHIPMENT_STUCK_OR_DELAYED: "Stuck or delayed shipment",
PAYMENT_PROBLEM: "Payment problems",
WARRANTY: "Warranty",
WRONG_ITEM: "Wrong / damaged / missing item",
}
/** Pillars where the vendor may toggle automation per intent (not FAQ-style knowledge routing). */
export function pillarHasAutomationToggle(pillar: L1Pillar): boolean {
return pillar === "T" || pillar === "X"
}
export function modulePillar(module: L1Module): L1Pillar {
return MODULE_MANIFESTS[module]?.pillar ?? "K"
}
export function moduleShowsPerIntentToggle(module: L1Module): boolean {
if (module === "UNKNOWN") return false
return pillarHasAutomationToggle(modulePillar(module))
}

View File

@ -12,8 +12,9 @@ export async function generateStorefrontReply(params: {
query: string query: string
visitorId: string visitorId: string
history: HistoryMessage[] history: HistoryMessage[]
categoryMetadetailsEnabled: boolean
aiModulesJson: unknown aiModulesJson: unknown
sharedFieldAnswersJson?: unknown
categoryMetadetailsEnabled?: boolean
widgetVerifiedEmail: string | null widgetVerifiedEmail: string | null
widgetSessionExpiresAt: Date | null widgetSessionExpiresAt: Date | null
boundShopifyOrderId: string | null boundShopifyOrderId: string | null
@ -25,8 +26,9 @@ export async function generateStorefrontReply(params: {
query, query,
visitorId, visitorId,
history, history,
categoryMetadetailsEnabled,
aiModulesJson, aiModulesJson,
sharedFieldAnswersJson,
categoryMetadetailsEnabled,
widgetVerifiedEmail, widgetVerifiedEmail,
widgetSessionExpiresAt, widgetSessionExpiresAt,
boundShopifyOrderId, boundShopifyOrderId,
@ -38,8 +40,9 @@ export async function generateStorefrontReply(params: {
storeName, storeName,
message: query, message: query,
history, history,
categoryMetadetailsEnabled,
aiModulesJson, aiModulesJson,
sharedFieldAnswersJson,
categoryMetadetailsEnabled,
widgetVerifiedEmail, widgetVerifiedEmail,
widgetSessionExpiresAt, widgetSessionExpiresAt,
boundShopifyOrderId, boundShopifyOrderId,

View File

@ -13,11 +13,7 @@ import {
supplierProfileUpsertSchema, supplierProfileUpsertSchema,
productSyncDecisionSchema, productSyncDecisionSchema,
} from "./business" } from "./business"
import { import { notificationsMarkReadSchema } from "./vendorMutations"
junoTemplateCreateSchema,
junoTemplatePatchSchema,
notificationsMarkReadSchema,
} from "./vendorMutations"
import { webPushSubscribeSchema, webPushUnsubscribeSchema } from "./notificationsPush" import { webPushSubscribeSchema, webPushUnsubscribeSchema } from "./notificationsPush"
import { import {
authVerifyMfaSchema, authVerifyMfaSchema,
@ -124,26 +120,6 @@ describe("productSyncDecisionSchema", () => {
}) })
}) })
describe("junoTemplateCreateSchema", () => {
it("requires core fields", () => {
expect(
junoTemplateCreateSchema.safeParse({
name: "N",
intent: "i",
mood: "m",
body: "b",
}).success,
).toBe(true)
expect(junoTemplateCreateSchema.safeParse({ name: "N" }).success).toBe(false)
})
})
describe("junoTemplatePatchSchema", () => {
it("allows partial body", () => {
expect(junoTemplatePatchSchema.safeParse({ body: "x" }).success).toBe(true)
})
})
describe("notificationsMarkReadSchema", () => { describe("notificationsMarkReadSchema", () => {
it("requires markAll or ids", () => { it("requires markAll or ids", () => {
expect(notificationsMarkReadSchema.safeParse({ markAllAsRead: true }).success).toBe(true) expect(notificationsMarkReadSchema.safeParse({ markAllAsRead: true }).success).toBe(true)

View File

@ -1,60 +1,5 @@
import { z } from "zod" import { z } from "zod"
const TEMPLATE_VARS_JSON_MAX = 50_000
function refineTemplateVariables(variables: unknown, ctx: z.RefinementCtx, path: (string | number)[]) {
try {
const s = JSON.stringify(variables)
if (s.length > TEMPLATE_VARS_JSON_MAX) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path,
message: "variables JSON too large",
})
}
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path,
message: "Invalid variables",
})
}
}
/** POST /api/juno-engine/templates */
export const junoTemplateCreateSchema = z
.object({
name: z.string().trim().min(1).max(200),
intent: z.string().trim().min(1).max(100),
mood: z.string().trim().min(1).max(100),
description: z.string().trim().max(2000).nullable().optional(),
body: z.string().max(200_000),
variables: z.unknown().optional(),
isHtml: z.boolean().optional(),
})
.superRefine((data, ctx) => {
if (data.variables !== undefined) {
refineTemplateVariables(data.variables, ctx, ["variables"])
}
})
/** PUT /api/juno-engine/templates/[templateId] — partial update */
export const junoTemplatePatchSchema = z
.object({
name: z.string().trim().min(1).max(200).optional(),
intent: z.string().trim().min(1).max(100).optional(),
mood: z.string().trim().min(1).max(100).optional(),
description: z.string().trim().max(2000).nullable().optional(),
body: z.string().max(200_000).optional(),
variables: z.unknown().optional(),
isHtml: z.boolean().optional(),
})
.superRefine((data, ctx) => {
if (data.variables !== undefined) {
refineTemplateVariables(data.variables, ctx, ["variables"])
}
})
/** POST /api/notifications — mark read */ /** POST /api/notifications — mark read */
export const notificationsMarkReadSchema = z export const notificationsMarkReadSchema = z
.object({ .object({
@ -70,6 +15,4 @@ export const notificationsMarkReadSchema = z
}) })
}) })
export type JunoTemplateCreateInput = z.infer<typeof junoTemplateCreateSchema>
export type JunoTemplatePatchInput = z.infer<typeof junoTemplatePatchSchema>
export type NotificationsMarkReadInput = z.infer<typeof notificationsMarkReadSchema> export type NotificationsMarkReadInput = z.infer<typeof notificationsMarkReadSchema>

View File

@ -2,7 +2,7 @@ import nodemailer from "nodemailer"
import { logger } from "@/lib/logger" import { logger } from "@/lib/logger"
import { getBaseUrl } from "@/lib/utils" import { getBaseUrl } from "@/lib/utils"
/** Returns the absolute URL for the JUNO logo (for use in email templates) */ /** Returns the absolute URL for the JUNO logo (for use in outbound HTML emails) */
export function getEmailLogoUrl(): string { export function getEmailLogoUrl(): string {
return `${getBaseUrl()}/icon.png` return `${getBaseUrl()}/icon.png`
} }

View File

@ -1,7 +1,7 @@
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { storeSelectGmailL1Context } from "@/lib/prisma/storeSelects" 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 } from "@/lib/templates/simpleRenderer"
import { classifyCustomerEmail, type ClassifyCustomerEmailResult } from "@/lib/ai/gmailGroq" import { classifyCustomerEmail, type ClassifyCustomerEmailResult } from "@/lib/ai/gmailGroq"
import { screenInboundEmailForAbuse } from "@/lib/ai/gmailSafety" import { screenInboundEmailForAbuse } from "@/lib/ai/gmailSafety"
import { import {
@ -11,17 +11,9 @@ import {
} from "@/lib/ai/gmailContext" } from "@/lib/ai/gmailContext"
import { looksLikeOffTopic, looksLikeSensitiveOrderTopic } from "@/lib/ai/gmailOrderDisclosure" import { looksLikeOffTopic, looksLikeSensitiveOrderTopic } from "@/lib/ai/gmailOrderDisclosure"
import { parseEnvFloat } from "@/lib/gmail/gmailAutoReplyGuards" import { parseEnvFloat } from "@/lib/gmail/gmailAutoReplyGuards"
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" import { runL1Orchestrator } from "@/lib/ai/orchestrator/runL1"
/** Local-part of email, title-cased for greetings (templates use {{customer_name}}). */
function greetingNameFromEmail(email: string): string {
const local = email.split("@")[0]?.trim() || ""
if (!local) return "there"
return local.charAt(0).toUpperCase() + local.slice(1).toLowerCase()
}
export function buildMinimalFallbackHtml(emailBody: string, storeName: string): string { export function buildMinimalFallbackHtml(emailBody: string, storeName: string): string {
const safeSnippet = escapeHtmlForEmail(emailBody.replace(/<[^>]+>/g, "").slice(0, 400)) const safeSnippet = escapeHtmlForEmail(emailBody.replace(/<[^>]+>/g, "").slice(0, 400))
return ` return `
@ -49,7 +41,7 @@ ${safeSnippet || "No additional message content was detected."}
` `
} }
/** When no template exists but we need an order # before sharing order rows. */ /** Fallback when we need an order number before sharing order rows from cache. */
export function buildNeedOrderIdFallbackHtml(storeName: string, storeEmail: string): string { export function buildNeedOrderIdFallbackHtml(storeName: string, storeEmail: string): string {
const contact = storeEmail.trim() const contact = storeEmail.trim()
? `Reply with your order number, or email ${storeEmail}.` ? `Reply with your order number, or email ${storeEmail}.`
@ -109,7 +101,7 @@ function buildL1HtmlEmail(plainBody: string, storeName: string): string {
` `
} }
/** When no off_topic template exists — polite boundary, no order data. */ /** Off-topic fallback: 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()
? `If you have a question about an order from ${storeName}, reply with details or email us at ${storeEmail}.` ? `If you have a question about an order from ${storeName}, reply with details or email us at ${storeEmail}.`
@ -132,23 +124,6 @@ export function buildOffTopicFallbackHtml(storeName: string, storeEmail: string)
` `
} }
function renderFromTemplate(
template: TemplateRecord,
context: Record<string, string | number | boolean | null | undefined>,
): string {
const safeContext = escapeTemplateContextForHtml(context)
return template.isHtml
? renderTemplate(template.body, safeContext)
: `
<div style="font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.6; color: #0f172a;">
<pre style="white-space: pre-wrap; margin: 0;">${renderTemplate(
template.body,
safeContext,
)}</pre>
</div>
`
}
export type AutoReplyPreviewInput = { export type AutoReplyPreviewInput = {
storeId: string storeId: string
fromEmail: string fromEmail: string
@ -172,12 +147,11 @@ export type AutoReplyPreviewOutput = {
orderSummaryRaw: string orderSummaryRaw: string
orderSummaryForContext: string orderSummaryForContext: string
ticketSummary: string ticketSummary: string
chosenTemplateId: string | null
usedFallback: "none" | "off_topic" | "need_order_id" | "hint_unmatched" | "minimal" usedFallback: "none" | "off_topic" | "need_order_id" | "hint_unmatched" | "minimal"
html: string html: string
flagged: boolean flagged: boolean
flaggedReason?: string flaggedReason?: string
/** True when reply body came from the L1 orchestrator instead of templates. */ /** True when reply body came from the L1 orchestrator. */
l1Used?: boolean l1Used?: boolean
} }
@ -213,7 +187,6 @@ export async function generateAutoReplyPreview(
orderSummaryRaw: "", orderSummaryRaw: "",
orderSummaryForContext: "", orderSummaryForContext: "",
ticketSummary: "", ticketSummary: "",
chosenTemplateId: null,
usedFallback: "minimal", usedFallback: "minimal",
html: buildMinimalFallbackHtml(emailBody, storeName), html: buildMinimalFallbackHtml(emailBody, storeName),
flagged: true, flagged: true,
@ -336,16 +309,6 @@ export async function generateAutoReplyPreview(
orderSummaryForContext = orderSummaryRaw || noOrdersFallback orderSummaryForContext = orderSummaryRaw || noOrdersFallback
} }
const context: Record<string, string | number | boolean | null | undefined> = {
customer_name: greetingNameFromEmail(senderEmail),
customer_email: senderEmail,
subject: emailSubject,
store_name: storeName,
store_email: store?.email || "",
ticket_summary: ticketSummary,
order_summary: orderSummaryForContext,
}
const skipL1Orchestrator = const skipL1Orchestrator =
isOffTopic || isOffTopic ||
messageTooSparse || messageTooSparse ||
@ -353,7 +316,6 @@ export async function generateAutoReplyPreview(
hintUnmatched hintUnmatched
let html: string | null = null let html: string | null = null
let chosenTemplateId: string | null = null
let l1Used = false let l1Used = false
if (!skipL1Orchestrator) { if (!skipL1Orchestrator) {
@ -364,8 +326,9 @@ export async function generateAutoReplyPreview(
storeName, storeName,
message: plainForAi, message: plainForAi,
history: [], history: [],
categoryMetadetailsEnabled: store?.categoryMetadetailsEnabled ?? false,
aiModulesJson: store?.aiModules ?? null, aiModulesJson: store?.aiModules ?? null,
sharedFieldAnswersJson: store?.sharedFieldAnswers ?? null,
categoryMetadetailsEnabled: store?.categoryMetadetailsEnabled,
emailFrom: senderEmail, emailFrom: senderEmail,
}) })
if (l1.text.trim().length > 0) { if (l1.text.trim().length > 0) {
@ -377,22 +340,6 @@ export async function generateAutoReplyPreview(
} }
} }
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)
}
}
let usedFallback: AutoReplyPreviewOutput["usedFallback"] = "none" let usedFallback: AutoReplyPreviewOutput["usedFallback"] = "none"
if (!html) { if (!html) {
if (isOffTopic) { if (isOffTopic) {
@ -426,7 +373,6 @@ export async function generateAutoReplyPreview(
orderSummaryRaw, orderSummaryRaw,
orderSummaryForContext, orderSummaryForContext,
ticketSummary, ticketSummary,
chosenTemplateId,
usedFallback, usedFallback,
html, html,
flagged: false, flagged: false,

View File

@ -2,23 +2,13 @@ import { getGmailAccessToken } from "../gmail"
import { logger } from "../logger" import { logger } from "../logger"
import { sendGmailReply } from "./emailService" import { sendGmailReply } from "./emailService"
import { prisma } from "../prisma" import { prisma } from "../prisma"
import { escapeHtmlForEmail, escapeTemplateContextForHtml, renderTemplate } from "@/lib/templates/simpleRenderer"
import { classifyCustomerEmail } from "@/lib/ai/gmailGroq"
import { screenInboundEmailForAbuse } from "@/lib/ai/gmailSafety" import { screenInboundEmailForAbuse } from "@/lib/ai/gmailSafety"
import {
buildOrderSummaryForEmail,
extractOrderNumberHints,
stripQuotedEmailTrailForHints,
} from "@/lib/ai/gmailContext"
import { looksLikeOffTopic, looksLikeSensitiveOrderTopic } from "@/lib/ai/gmailOrderDisclosure"
import { generateAutoReplyPreview } from "@/lib/gmail/autoReplyPreview" import { generateAutoReplyPreview } from "@/lib/gmail/autoReplyPreview"
import { import {
checkGmailAutoReplyRateLimits, checkGmailAutoReplyRateLimits,
parseEnvFloat,
releaseGmailInboundLease, releaseGmailInboundLease,
tryAcquireGmailInboundLease, tryAcquireGmailInboundLease,
} from "@/lib/gmail/gmailAutoReplyGuards" } from "@/lib/gmail/gmailAutoReplyGuards"
import { findTemplateWithFallback, type TemplateRecord } from "@/lib/juno/templatesRepo"
export type ProcessIncomingEmailResult = { export type ProcessIncomingEmailResult = {
success: boolean success: boolean
@ -50,128 +40,8 @@ function parseSenderAddress(fromHeader: string): string {
return lone.toLowerCase() return lone.toLowerCase()
} }
/** Local-part of email, title-cased for greetings (templates use {{customer_name}}). */
function greetingNameFromEmail(email: string): string {
const local = email.split("@")[0]?.trim() || ""
if (!local) return "there"
return local.charAt(0).toUpperCase() + local.slice(1).toLowerCase()
}
function buildMinimalFallbackHtml(emailBody: string, storeName: string): string {
const safeSnippet = escapeHtmlForEmail(emailBody.replace(/<[^>]+>/g, "").slice(0, 400))
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;">
<h1 style="margin: 0 0 12px; font-size: 20px; color: #e5e7eb;">Thanks for reaching out</h1>
<p style="margin: 0 0 12px; color: #9ca3af;">
We have received your message and our team will review it shortly.
</p>
<p style="margin: 0 0 16px; color: #9ca3af;">Summary of what you sent:</p>
<blockquote style="margin: 0 0 20px; padding: 12px 16px; border-left: 3px solid #6366f1; background: #020617;">
<p style="margin: 0; white-space: pre-wrap; color: #d1d5db; font-size: 14px;">
${safeSnippet || "No additional message content was detected."}
</p>
</blockquote>
<p style="margin: 0 0 8px; color: #9ca3af;">
You can reply to this email if you have more details to share.
</p>
<p style="margin: 0; color: #e5e7eb;">
Best regards,<br/>
<span style="color: #a5b4fc;">${storeName} Support</span>
</p>
</div>
</div>
`
}
/** When no template exists but we need an order # before sharing order rows. */
function buildNeedOrderIdFallbackHtml(storeName: string, storeEmail: string): string {
const contact = storeEmail.trim()
? `Reply with your order number, or email ${storeEmail}.`
: `Reply with your order number from your confirmation email.`
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;">
<p style="margin: 0 0 12px; color: #e5e7eb;">Hello,</p>
<p style="margin: 0 0 12px; color: #9ca3af;">
We didn&apos;t see an order number in your message. To look up the right order for <strong style="color:#e5e7eb;">${storeName}</strong>,
please reply with your order number (for example <strong style="color:#e5e7eb;">#1002</strong> or <strong style="color:#e5e7eb;">1002</strong>) from your order confirmation.
</p>
<p style="margin: 0; color: #9ca3af;">${contact}</p>
<p style="margin: 16px 0 0; color: #e5e7eb;">
Best regards,<br/>
<span style="color: #a5b4fc;">${storeName} Support</span>
</p>
</div>
</div>
`
}
/** When hints did not match cache — no unrelated order rows. */
function buildHintUnmatchedFallbackHtml(storeName: string, storeEmail: string): string {
const contact = storeEmail.trim()
? `Reply with the correct order number or email us at ${storeEmail}.`
: `Reply with the order number from your confirmation email.`
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;">
<p style="margin: 0 0 12px; color: #e5e7eb;">Hello,</p>
<p style="margin: 0 0 12px; color: #9ca3af;">
We could not match the order number in your message to an order on file for <strong style="color:#e5e7eb;">${storeName}</strong> under this email address.
Please double-check the number or confirm the email you used when placing the order.
</p>
<p style="margin: 0; color: #9ca3af;">${contact}</p>
<p style="margin: 16px 0 0; color: #e5e7eb;">
Best regards,<br/>
<span style="color: #a5b4fc;">${storeName} Support</span>
</p>
</div>
</div>
`
}
/** When no off_topic template exists — polite boundary, no order data. */
function buildOffTopicFallbackHtml(storeName: string, storeEmail: string): string {
const contact = storeEmail.trim()
? `If you have a question about an order from ${storeName}, reply with details or email us at ${storeEmail}.`
: `If you have a question about an order from ${storeName}, reply with your question and order number if you have one.`
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;">
<p style="margin: 0 0 12px; color: #e5e7eb;">Hello,</p>
<p style="margin: 0 0 12px; color: #9ca3af;">
This inbox only handles orders, products, shipping, returns, and billing for <strong style="color:#e5e7eb;">${storeName}</strong>.
We can&apos;t answer unrelated requests (news, weather, other websites, or general topics).
</p>
<p style="margin: 0; color: #9ca3af;">${contact}</p>
<p style="margin: 16px 0 0; color: #e5e7eb;">
Best regards,<br/>
<span style="color: #a5b4fc;">${storeName} Support</span>
</p>
</div>
</div>
`
}
function renderFromTemplate(
template: TemplateRecord,
context: Record<string, string | number | boolean | null | undefined>
): string {
const safeContext = escapeTemplateContextForHtml(context)
return template.isHtml
? renderTemplate(template.body, safeContext)
: `
<div style="font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.6; color: #0f172a;">
<pre style="white-space: pre-wrap; margin: 0;">${renderTemplate(
template.body,
safeContext,
)}</pre>
</div>
`
}
/** /**
* Process an incoming email and generate an AI-assisted reply (Groq classification + templates). * Process an incoming email and generate an AI-assisted reply (L1 orchestrator + fallbacks).
*/ */
export async function processIncomingEmail( export async function processIncomingEmail(
userId: string, userId: string,

View File

@ -1,119 +0,0 @@
import { prisma } from "@/lib/prisma"
import { randomUUID } from "crypto"
import type { GmailIntent, GmailMood } from "@/lib/ai/gmailTaxonomy"
export type TemplateRecord = {
id: string
storeId: string
name: string
intent: string
mood: string
description: string | null
body: string
variables: unknown | null
isHtml: boolean
updatedAt: Date
}
/**
* Latest template for store + intent + mood (same semantics as JUNO Engine list).
*/
export async function findTemplateByIntentMood(
storeId: string,
intent: GmailIntent,
mood: GmailMood
): Promise<TemplateRecord | null> {
const rows = await prisma.$queryRaw<TemplateRecord[]>`
SELECT
"id",
"storeId",
"name",
"intent",
"mood",
"description",
"body",
"variables",
"isHtml",
"updatedAt"
FROM "Template"
WHERE "storeId" = ${storeId}
AND "intent" = ${intent}
AND "mood" = ${mood}
ORDER BY "updatedAt" DESC
LIMIT 1
`
return rows[0] ?? null
}
/**
* Fallback when intent/mood pair has no row: try general + same mood, then general + neutral.
*/
export async function findTemplateWithFallback(
storeId: string,
intent: GmailIntent,
mood: GmailMood
): Promise<TemplateRecord | null> {
const exact = await findTemplateByIntentMood(storeId, intent, mood)
if (exact) return exact
// Avoid general+happy (etc.) for off-topic — those templates thank the customer and may show order data in {{order_summary}}.
if (intent === "off_topic") {
if (mood !== "neutral") {
const offNeutral = await findTemplateByIntentMood(storeId, "off_topic", "neutral")
if (offNeutral) return offNeutral
}
const generalNeutral = await findTemplateByIntentMood(storeId, "general", "neutral")
if (generalNeutral) return generalNeutral
return findTemplateByIntentMood(storeId, "general", "happy")
}
if (intent !== "general") {
const generalMood = await findTemplateByIntentMood(storeId, "general", mood)
if (generalMood) return generalMood
}
if (mood !== "neutral") {
const intentNeutral = await findTemplateByIntentMood(storeId, intent, "neutral")
if (intentNeutral) return intentNeutral
}
return findTemplateByIntentMood(storeId, "general", "neutral")
}
export type InsertTemplateInput = {
storeId: string
name: string
intent: GmailIntent
mood: GmailMood
description: string | null
body: string
variables: Record<string, unknown> | null
isHtml: boolean
}
export async function insertTemplate(input: InsertTemplateInput): Promise<TemplateRecord> {
const id = randomUUID()
const variablesParam = input.variables as Record<string, unknown> | null
const rows = await prisma.$queryRaw<TemplateRecord[]>`
INSERT INTO "Template"
("id", "storeId", "name", "intent", "mood", "description", "body", "variables", "isHtml", "createdAt", "updatedAt")
VALUES
(${id}, ${input.storeId}, ${input.name}, ${input.intent}, ${input.mood}, ${input.description}, ${input.body}, ${variablesParam}, ${input.isHtml}, NOW(), NOW())
RETURNING
"id",
"storeId",
"name",
"intent",
"mood",
"description",
"body",
"variables",
"isHtml",
"updatedAt"
`
const row = rows[0]
if (!row) {
throw new Error("insertTemplate: no row returned")
}
return row
}

View File

@ -1,8 +1,53 @@
import { z } from "zod" import { z } from "zod"
import { SHARED_FIELD_IDS } from "@/lib/kb/sharedFieldCatalog"
/** Six fixed prompts + optional misc (open-ended). Legacy v1. */ /**
export const KNOWLEDGE_TEMPLATE_FIELD_KEYS = [ * Minimal “store voice” fields for structured KB mode (v3).
* Per-topic policies live under AI modules (modulePolicies on Store).
*/
export const STORE_VOICE_FIELD_KEYS = [
"aiToneHowWeSound",
"communicationFormality",
"phrasesAndPromisesToAvoid",
"whenWeDoNotKnow",
] as const
export type StoreVoiceFieldKey = (typeof STORE_VOICE_FIELD_KEYS)[number]
export const STORE_VOICE_FIELDS: {
key: StoreVoiceFieldKey
label: string
description: string
placeholder: string
}[] = [
{
key: "aiToneHowWeSound",
label: "How should the AI sound?",
description: "Tone, personality, and how you want customers to feel when they read replies.",
placeholder:
"e.g. Warm and helpful; short sentences; we say “we” and sound human, not robotic…",
},
{
key: "communicationFormality",
label: "Formality level",
description: "How formal or casual the assistant should be.",
placeholder: "e.g. Professional but friendly; avoid slang; or casual and upbeat…",
},
{
key: "phrasesAndPromisesToAvoid",
label: "Words or promises to avoid",
description: "Things the AI must not say (legal, brand, or operational).",
placeholder: "e.g. Never guarantee delivery dates; don’t say “free” unless…",
},
{
key: "whenWeDoNotKnow",
label: "When we’re not sure",
description: "How the assistant should behave when the answer isn’t in your policies or data.",
placeholder: "e.g. Say we’re not sure and invite them to email support with their order #…",
},
]
/** @deprecated Use STORE_VOICE_FIELD_KEYS — kept for migrating old DB JSON */
const LEGACY_TEMPLATE_FIELD_KEYS = [
"brandVoice", "brandVoice",
"shippingReturns", "shippingReturns",
"sizingFit", "sizingFit",
@ -11,79 +56,27 @@ export const KNOWLEDGE_TEMPLATE_FIELD_KEYS = [
"differentiators", "differentiators",
] as const ] as const
export type KnowledgeTemplateFieldKey = (typeof KNOWLEDGE_TEMPLATE_FIELD_KEYS)[number] const LEGACY_LABELS: Record<(typeof LEGACY_TEMPLATE_FIELD_KEYS)[number], string> = {
brandVoice: "Brand voice",
shippingReturns: "Shipping & returns",
sizingFit: "Sizing & fit",
materialsCare: "Materials & care",
policies: "Policies",
differentiators: "What makes you different",
}
export const KNOWLEDGE_TEMPLATE_FIELDS: {
key: KnowledgeTemplateFieldKey
label: string
description: string
placeholder: string
}[] = [
{
key: "brandVoice",
label: "Brand voice and personality",
description:
"How should your store sound? Tone, formality, phrases to use or avoid, and what makes your brand feel human.",
placeholder:
"e.g. Friendly and upbeat; we say “we” and avoid slang; we’re optimistic but never pushy…",
},
{
key: "shippingReturns",
label: "Shipping, returns, and exchanges",
description:
"Regions you ship to, typical timelines, costs, return windows, and how customers start a return.",
placeholder:
"e.g. US & Canada; 3–5 business days standard; free over $75; 30-day returns via portal…",
},
{
key: "sizingFit",
label: "Sizing and fit",
description:
"Fit notes, size guides, model references, and how customers should choose a size.",
placeholder:
"e.g. True to size; relaxed fit on hoodies; see size chart on each product page…",
},
{
key: "materialsCare",
label: "Materials, care, and quality",
description:
"Fabrics, ingredients, durability, care instructions, and sustainability claims you stand behind.",
placeholder:
"e.g. 100% organic cotton; machine wash cold; hang dry; OEKO-TEX where noted…",
},
{
key: "policies",
label: "Policies customers ask about",
description:
"Warranty, price adjustments, pre-orders, backorders, subscriptions, or limits worth stating clearly.",
placeholder:
"e.g. 1-year defect warranty; no price matching; pre-orders ship when noted on the product…",
},
{
key: "differentiators",
label: "What makes your store special",
description:
"Differentiators, origin story, craftsmanship, community, or anything that sets you apart from competitors.",
placeholder:
"e.g. Small-batch, made in Portland; we donate 1% to ocean cleanup; limited seasonal drops…",
},
]
/** Per-field answer cap (mirrored on structured KB form). */
export const KNOWLEDGE_TEMPLATE_ANSWER_MAX = 12_000 export const KNOWLEDGE_TEMPLATE_ANSWER_MAX = 12_000
/** Misc notes cap. */
export const KNOWLEDGE_TEMPLATE_MISC_MAX = 16_000
const sharedFieldsRecordSchema = z const genericAnswersRecordSchema = z
.record(z.string().max(KNOWLEDGE_TEMPLATE_ANSWER_MAX)) .record(z.string().max(KNOWLEDGE_TEMPLATE_ANSWER_MAX))
.optional() .optional()
.superRefine((val, ctx) => { .superRefine((val, ctx) => {
if (!val) return if (!val) return
for (const k of Object.keys(val)) { for (const k of Object.keys(val)) {
if (!SHARED_FIELD_IDS.has(k)) { if (!STORE_VOICE_FIELD_KEYS.includes(k as StoreVoiceFieldKey)) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: `Unknown shared field id: ${k}`, message: `Unknown template field: ${k}`,
}) })
} }
} }
@ -94,39 +87,40 @@ export const knowledgeTemplatePutSchema = z.object({
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(), /** v3 generic store-voice answers only */
/** Expanded sf_* vendor policy fields (v2). */ answers: genericAnswersRecordSchema,
sharedFields: sharedFieldsRecordSchema,
misc: z.string().max(KNOWLEDGE_TEMPLATE_MISC_MAX).optional(),
}) })
.optional(), .optional(),
}) })
/** Normalized template for storage + embedding (v2). */ /** Normalized template for storage + embedding (v3). */
export type KnowledgeTemplateStored = { export type KnowledgeTemplateStored = {
version: 2 version: 3
/** Legacy six-field answers (optional once migrated to shared fields). */ genericAnswers: Partial<Record<StoreVoiceFieldKey, string>>
legacyAnswers: Partial<Record<KnowledgeTemplateFieldKey, string>>
/** Spec-aligned shared_field_id values. */
sharedFields: Partial<Record<string, string>>
misc?: string
updatedAt: string updatedAt: string
} }
/** v1 shape as persisted historically */ type KnowledgeTemplateV2File = {
type KnowledgeTemplateV1 = {
version?: number version?: number
answers?: Partial<Record<KnowledgeTemplateFieldKey, string>> legacyAnswers?: Record<string, string>
sharedFields?: Record<string, string>
misc?: string misc?: string
updatedAt?: string updatedAt?: string
} }
export function normalizeTemplateAnswers( type KnowledgeTemplateV1 = {
version?: number
answers?: Record<string, string>
misc?: string
updatedAt?: string
}
function normalizeGenericAnswers(
raw: Record<string, string> | undefined, raw: Record<string, string> | undefined,
): Partial<Record<KnowledgeTemplateFieldKey, string>> { ): Partial<Record<StoreVoiceFieldKey, string>> {
if (!raw) return {} if (!raw) return {}
const out: Partial<Record<KnowledgeTemplateFieldKey, string>> = {} const out: Partial<Record<StoreVoiceFieldKey, string>> = {}
for (const key of KNOWLEDGE_TEMPLATE_FIELD_KEYS) { for (const key of STORE_VOICE_FIELD_KEYS) {
const v = raw[key] const v = raw[key]
if (typeof v === "string" && v.trim()) { if (typeof v === "string" && v.trim()) {
out[key] = v.trim() out[key] = v.trim()
@ -135,71 +129,119 @@ export function normalizeTemplateAnswers(
return out return out
} }
function normalizeSharedFields( function migrateLegacySixFieldsToGeneric(
raw: Record<string, string> | undefined, legacy: Record<string, string> | undefined,
): Partial<Record<string, string>> { ): Partial<Record<StoreVoiceFieldKey, string>> {
if (!raw) return {} if (!legacy) return {}
const out: Partial<Record<string, string>> = {} const parts: string[] = []
for (const [k, v] of Object.entries(raw)) { for (const key of LEGACY_TEMPLATE_FIELD_KEYS) {
if (!SHARED_FIELD_IDS.has(k)) continue const t = legacy[key]?.trim()
if (typeof v === "string" && v.trim()) { if (t) {
out[k] = v.trim() parts.push(`${LEGACY_LABELS[key]}:\n${t}`)
} }
} }
return out if (!parts.length) return {}
return { aiToneHowWeSound: parts.join("\n\n---\n\n") }
} }
/** /**
* Coerce DB JSON to KnowledgeTemplateStored v2. * Coerce DB JSON to KnowledgeTemplateStored v3.
* Migrates v1 `{ version: 1, answers }` into legacyAnswers. * Migrates v1 answers, v2 legacyAnswers/sharedFields (sharedFields discarded from template).
*/ */
export function parseKnowledgeTemplateJson(json: unknown): KnowledgeTemplateStored | null { export function parseKnowledgeTemplateJson(json: unknown): KnowledgeTemplateStored | null {
if (json == null || typeof json !== "object") return null if (json == null || typeof json !== "object") return null
const o = json as Record<string, unknown> const o = json as Record<string, unknown>
const ver = o.version === 2 ? 2 : 1 const ver = o.version === 3 ? 3 : o.version === 2 ? 2 : 1
if (ver === 2) { if (ver === 3) {
const legacy = normalizeTemplateAnswers( const gen = o.genericAnswers
o.legacyAnswers as Record<string, string> | undefined, const genericAnswers =
) gen && typeof gen === "object" && !Array.isArray(gen)
const sharedRaw = o.sharedFields ? normalizeGenericAnswers(gen as Record<string, string>)
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 { return {
version: 2, version: 3,
legacyAnswers: legacy, genericAnswers,
sharedFields: shared,
misc,
updatedAt: typeof o.updatedAt === "string" ? o.updatedAt : new Date().toISOString(), updatedAt: typeof o.updatedAt === "string" ? o.updatedAt : new Date().toISOString(),
} }
} }
if (ver === 2) {
const v2 = o as KnowledgeTemplateV2File
const legacyRaw = v2.legacyAnswers
const fromLegacy =
legacyRaw && typeof legacyRaw === "object" && !Array.isArray(legacyRaw)
? migrateLegacySixFieldsToGeneric(legacyRaw as Record<string, string>)
: {}
const gen = o.genericAnswers
const explicit =
gen && typeof gen === "object" && !Array.isArray(gen)
? normalizeGenericAnswers(gen as Record<string, string>)
: {}
const misc = typeof v2.misc === "string" && v2.misc.trim() ? v2.misc.trim() : ""
const merged: Partial<Record<StoreVoiceFieldKey, string>> = { ...fromLegacy, ...explicit }
if (misc) {
const prev = merged.whenWeDoNotKnow ?? ""
merged.whenWeDoNotKnow = [prev, `Additional notes:\n${misc}`]
.filter(Boolean)
.join("\n\n")
}
return {
version: 3,
genericAnswers: merged,
updatedAt: v2.updatedAt || new Date().toISOString(),
}
}
const v1 = o as KnowledgeTemplateV1 const v1 = o as KnowledgeTemplateV1
const legacy = normalizeTemplateAnswers(v1.answers as Record<string, string> | undefined) const legacy = v1.answers as Record<string, string> | undefined
const misc = v1.misc?.trim() || undefined const misc = v1.misc?.trim() || ""
const fromLegacy = migrateLegacySixFieldsToGeneric(legacy)
if (misc) {
const prev = fromLegacy.whenWeDoNotKnow ?? ""
fromLegacy.whenWeDoNotKnow = [prev, `Additional notes:\n${misc}`]
.filter(Boolean)
.join("\n\n")
}
return { return {
version: 2, version: 3,
legacyAnswers: legacy, genericAnswers: fromLegacy,
sharedFields: {},
misc,
updatedAt: v1.updatedAt || new Date().toISOString(), updatedAt: v1.updatedAt || new Date().toISOString(),
} }
} }
export function buildKnowledgeTemplateStored(input: { export function buildKnowledgeTemplateStored(input: {
answers?: Record<string, string> answers?: Record<string, string>
sharedFields?: Record<string, string>
misc?: string
}): KnowledgeTemplateStored { }): KnowledgeTemplateStored {
return { return {
version: 2, version: 3,
legacyAnswers: normalizeTemplateAnswers(input.answers), genericAnswers: normalizeGenericAnswers(input.answers),
sharedFields: normalizeSharedFields(input.sharedFields), updatedAt: new Date().toISOString(),
misc: input.misc?.trim() || undefined, }
}
/** Merge PUT answers onto previous template (v3). */
export function mergeTemplateAnswers(
prev: KnowledgeTemplateStored | null,
answers: Record<string, string> | undefined,
): KnowledgeTemplateStored {
const base = prev?.genericAnswers ?? {}
const next = { ...base }
if (answers) {
for (const key of STORE_VOICE_FIELD_KEYS) {
if (key in answers) {
const v = answers[key]
if (typeof v === "string" && v.trim()) {
next[key] = v.trim()
} else {
delete next[key]
}
}
}
}
return {
version: 3,
genericAnswers: next,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
} }
} }

View File

@ -0,0 +1,35 @@
import { z } from "zod"
import type { L1Module } from "@/lib/ai/orchestrator/types"
import { L1_MODULE_IDS } from "@/lib/ai/orchestrator/types"
import { KNOWLEDGE_TEMPLATE_ANSWER_MAX } from "@/lib/kb/knowledgeTemplate"
/** Full body for PATCH /api/stores/[storeId] `modulePolicies`. */
export const modulePoliciesBodySchema = z
.record(z.string().max(KNOWLEDGE_TEMPLATE_ANSWER_MAX))
.superRefine((val, ctx) => {
for (const k of Object.keys(val)) {
if (!L1_MODULE_IDS.includes(k as L1Module)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Unknown module key: ${k}`,
})
}
}
})
export function parseModulePoliciesJson(
raw: unknown,
): Partial<Record<L1Module, string>> {
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
return {}
}
const o = raw as Record<string, unknown>
const out: Partial<Record<L1Module, string>> = {}
for (const id of L1_MODULE_IDS) {
const v = o[id]
if (typeof v === "string" && v.trim()) {
out[id] = v.trim()
}
}
return out
}

View File

@ -0,0 +1,45 @@
import { z } from "zod"
import { KNOWLEDGE_TEMPLATE_ANSWER_MAX } from "@/lib/kb/knowledgeTemplate"
const SF_KEY = /^sf_[a-z0-9_]+$/
/** PATCH body for `sharedFieldAnswers` on Store. */
export const sharedFieldAnswersBodySchema = z
.record(z.string().max(KNOWLEDGE_TEMPLATE_ANSWER_MAX))
.superRefine((val, ctx) => {
for (const k of Object.keys(val)) {
if (!SF_KEY.test(k)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid shared field key: ${k}`,
})
}
}
})
export function parseSharedFieldAnswersJson(raw: unknown): Record<string, string> {
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
return {}
}
const o = raw as Record<string, unknown>
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(o)) {
if (!SF_KEY.test(k)) continue
if (typeof v === "string" && v.trim()) {
out[k] = v.trim()
}
}
return out
}
export function sharedFieldAnswersPayload(
answers: Record<string, string>,
): Record<string, string> {
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(answers)) {
if (!SF_KEY.test(k)) continue
const t = v.trim()
if (t) out[k] = t.slice(0, KNOWLEDGE_TEMPLATE_ANSWER_MAX)
}
return out
}

View File

@ -1,488 +0,0 @@
/**
* 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

@ -3,63 +3,91 @@ import { logger } from "@/lib/logger"
import { embedBatch } from "@/lib/kb/openaiEmbeddings" import { embedBatch } from "@/lib/kb/openaiEmbeddings"
import { kbEmbeddingMetadata } from "@/lib/kb/embeddingMeta" import { kbEmbeddingMetadata } from "@/lib/kb/embeddingMeta"
import { import {
KNOWLEDGE_TEMPLATE_FIELDS, STORE_VOICE_FIELDS,
parseKnowledgeTemplateJson,
type KnowledgeTemplateStored, type KnowledgeTemplateStored,
} from "@/lib/kb/knowledgeTemplate" } from "@/lib/kb/knowledgeTemplate"
import { SHARED_FIELD_CATALOG } from "@/lib/kb/sharedFieldCatalog" import { parseModulePoliciesJson } from "@/lib/kb/modulePolicies"
import { parseSharedFieldAnswersJson } from "@/lib/kb/sharedFieldAnswers"
import { L1_MODULE_CUSTOMER_LABEL } from "@/lib/ai/orchestrator/vendorModuleGroups"
import { SHARED_FIELD_SHORT_LABEL } from "@/lib/ai/orchestrator/moduleThreeTypes"
import type { L1Module } from "@/lib/ai/orchestrator/types"
export async function replaceStructuredKnowledgeChunks( /**
storeId: string, * Rebuild STRUCTURED knowledge chunks from the store's v3 template + modulePolicies + sharedFieldAnswers.
stored: KnowledgeTemplateStored, * Clears STRUCTURED rows when the store is not in STRUCTURED mode.
) { */
export async function replaceStructuredKnowledgeChunksForStore(storeId: string) {
await prisma.$executeRawUnsafe( await prisma.$executeRawUnsafe(
`DELETE FROM "KnowledgeChunk" WHERE "storeId" = $1 AND "sourceType"::text = 'STRUCTURED'`, `DELETE FROM "KnowledgeChunk" WHERE "storeId" = $1 AND "sourceType"::text = 'STRUCTURED'`,
storeId, storeId,
) )
const store = await prisma.store.findFirst({
where: { id: storeId },
select: {
knowledgeBaseMode: true,
knowledgeTemplate: true,
modulePolicies: true,
sharedFieldAnswers: true,
},
})
if (!store || store.knowledgeBaseMode !== "STRUCTURED") {
return
}
const stored: KnowledgeTemplateStored =
parseKnowledgeTemplateJson(store.knowledgeTemplate) ?? {
version: 3,
genericAnswers: {},
updatedAt: new Date().toISOString(),
}
const modulePolicies = parseModulePoliciesJson(store.modulePolicies)
const sharedFieldAnswers = parseSharedFieldAnswersJson(store.sharedFieldAnswers)
const rows: { const rows: {
content: string content: string
metadata: ReturnType<typeof kbEmbeddingMetadata> metadata: ReturnType<typeof kbEmbeddingMetadata>
}[] = [] }[] = []
for (const field of KNOWLEDGE_TEMPLATE_FIELDS) { for (const field of STORE_VOICE_FIELDS) {
const text = stored.legacyAnswers[field.key] const text = stored.genericAnswers[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({ metadata: kbEmbeddingMetadata({
sourceKey: field.key, sourceKey: field.key,
moduleCode: "LEGACY", moduleCode: "ALL",
fieldId: field.key, fieldId: field.key,
}), }),
}) })
} }
} }
const catalogById = new Map(SHARED_FIELD_CATALOG.map((f) => [f.id, f])) for (const [mod, text] of Object.entries(modulePolicies) as [L1Module, string][]) {
for (const [id, text] of Object.entries(stored.sharedFields)) { if (!text?.trim()) continue
const trimmed = text?.trim() const title = L1_MODULE_CUSTOMER_LABEL[mod] ?? mod
if (!trimmed) continue
const def = catalogById.get(id)
const label = def?.label ?? id
const moduleCode = def?.group ?? "FAQ"
rows.push({ rows.push({
content: `Q: ${label}\nA: ${trimmed}`, content: `Q: ${title} (store policy)\nA: ${text.trim()}`,
metadata: kbEmbeddingMetadata({ metadata: kbEmbeddingMetadata({
sourceKey: id, sourceKey: `policy_${mod}`,
moduleCode, moduleCode: mod,
fieldId: id, fieldId: `policy_${mod}`,
}), }),
}) })
} }
if (stored.misc?.trim()) { for (const [fieldId, text] of Object.entries(sharedFieldAnswers)) {
if (!text?.trim()) continue
const lab = SHARED_FIELD_SHORT_LABEL[fieldId] || fieldId
rows.push({ rows.push({
content: `Q: Anything else customers should know\nA: ${stored.misc.trim()}`, content: `Q: ${lab} (${fieldId})\nA: ${text.trim()}`,
metadata: kbEmbeddingMetadata({ metadata: kbEmbeddingMetadata({
sourceKey: "misc", sourceKey: fieldId,
moduleCode: "ALL", moduleCode: "ALL",
fieldId: "misc", fieldId,
}), }),
}) })
} }

View File

@ -11,8 +11,9 @@ export const storeSelectStorefrontChatApi = {
id: true, id: true,
storefrontChatEnabled: true, storefrontChatEnabled: true,
businessName: true, businessName: true,
categoryMetadetailsEnabled: true,
aiModules: true, aiModules: true,
categoryMetadetailsEnabled: true,
sharedFieldAnswers: true,
} as const } as const
export const storeSelectDashboardStorefrontInbox = { export const storeSelectDashboardStorefrontInbox = {
@ -28,6 +29,7 @@ export const storeSelectGmailL1Context = {
email: true, email: true,
categoryMetadetailsEnabled: true, categoryMetadetailsEnabled: true,
aiModules: true, aiModules: true,
sharedFieldAnswers: true,
} as const } as const
export const storeSelectPatchStorefrontAi = { export const storeSelectPatchStorefrontAi = {
@ -39,6 +41,8 @@ export const storeSelectPatchStorefrontAi = {
storefrontChatLogoUrl: true, storefrontChatLogoUrl: true,
categoryMetadetailsEnabled: true, categoryMetadetailsEnabled: true,
aiModules: true, aiModules: true,
modulePolicies: true,
sharedFieldAnswers: true,
} as const } as const
/** Session / OTP fields for L1 storefront replies */ /** Session / OTP fields for L1 storefront replies */

View File

@ -1,7 +1,7 @@
/** /**
* HTML / rich-text policy (JUNOHUB) * HTML / rich-text policy (JUNOHUB)
* *
* - **User-supplied HTML** (email templates, KB HTML mode, product body_html previews): * - **User-supplied HTML** (KB HTML mode, product body_html previews, rich email bodies):
* Prefer storing as-is only when rendered through a strict sanitizer in a follow-up. * Prefer storing as-is only when rendered through a strict sanitizer in a follow-up.
* For email footers and any interpolated user strings, use `escapeHtmlText` from `./escapeHtml`. * For email footers and any interpolated user strings, use `escapeHtmlText` from `./escapeHtml`.
* - **Widget script** (`src/app/api/stores/[storeId]/route.ts`): * - **Widget script** (`src/app/api/stores/[storeId]/route.ts`):

View File

@ -34,7 +34,7 @@ function assertScenarioInvariants(s: Scenario, out: Awaited<ReturnType<typeof ge
expect(isOffTopic, `${s.id}: off_topic`).toBe(true) expect(isOffTopic, `${s.id}: off_topic`).toBe(true)
} }
// Order lines presence/absence (raw summary is the safest signal; template HTML varies). // Order lines presence/absence (raw summary is the safest signal; HTML body varies by path).
if (s.expect.expectOrderLines) { if (s.expect.expectOrderLines) {
expect(out.orderSummaryRaw.trim().length, `${s.id}: orderSummaryRaw non-empty`).toBeGreaterThan(0) expect(out.orderSummaryRaw.trim().length, `${s.id}: orderSummaryRaw non-empty`).toBeGreaterThan(0)
} else { } else {
@ -101,7 +101,6 @@ describe("gmail auto-reply scenarios (preview mode)", () => {
orderSummaryRaw: out.orderSummaryRaw, orderSummaryRaw: out.orderSummaryRaw,
orderSummaryForContext: out.orderSummaryForContext, orderSummaryForContext: out.orderSummaryForContext,
usedFallback: out.usedFallback, usedFallback: out.usedFallback,
chosenTemplateId: out.chosenTemplateId,
html: out.html, html: out.html,
}, },
}) })

View File

@ -1,16 +1,9 @@
import "dotenv/config" import "dotenv/config"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { insertTemplate } from "@/lib/juno/templatesRepo"
function requireEnv(name: string): string {
const v = process.env[name]
if (!v) throw new Error(`Missing required env var: ${name}`)
return v
}
async function main() { async function main() {
// This relies on Prisma using DATABASE_URL from env.test (loaded by the runner script). // This relies on Prisma using DATABASE_URL from env.test (loaded by the runner script).
// Create a minimal user + active store, plus a few templates and CachedOrder rows. // Create a minimal user + active store and CachedOrder rows for L1 / Gmail preview tests.
const userEmail = process.env.TEST_VENDOR_EMAIL || "vendor@example.com" const userEmail = process.env.TEST_VENDOR_EMAIL || "vendor@example.com"
const user = await prisma.user.upsert({ const user = await prisma.user.upsert({
@ -61,42 +54,6 @@ async function main() {
data: { activeStoreId: store.id }, data: { activeStoreId: store.id },
}) })
// Minimal templates so we get stable rendering without requiring a full catalog.
// These templates reference {{ticket_summary}} and {{order_summary}}.
const templateBody = `
<div>
<p>Hello {{customer_name}},</p>
<p>{{ticket_summary}}</p>
<p>{{order_summary}}</p>
<p>— {{store_name}} Support</p>
</div>
`.trim()
const intents = [
["general", "neutral"],
["general", "happy"],
["shipping_delay", "neutral"],
["refund_request", "neutral"],
["account_billing", "neutral"],
["question", "neutral"],
["off_topic", "neutral"],
] as const
for (const [intent, mood] of intents) {
await insertTemplate({
storeId: store.id,
name: `Test template — ${intent}/${mood}`,
intent,
mood,
description: "Test harness template",
body: templateBody,
variables: { ticket_summary: true, order_summary: true },
isHtml: true,
}).catch(() => {
// If duplicate-ish templates exist, ignore; findTemplateWithFallback selects latest.
})
}
const customerEmail = process.env.TEST_CUSTOMER_EMAIL || "customer@gmail.com" const customerEmail = process.env.TEST_CUSTOMER_EMAIL || "customer@gmail.com"
const baseLineItems = [ const baseLineItems = [

View File

@ -1,44 +1,64 @@
import { describe, expect, it } from "vitest" import { describe, expect, it } from "vitest"
import { import {
parseKnowledgeTemplateJson,
buildKnowledgeTemplateStored, buildKnowledgeTemplateStored,
mergeTemplateAnswers,
parseKnowledgeTemplateJson,
} from "@/lib/kb/knowledgeTemplate" } from "@/lib/kb/knowledgeTemplate"
describe("parseKnowledgeTemplateJson", () => { describe("knowledgeTemplate v3", () => {
it("migrates v1 answers into legacyAnswers", () => { it("migrates v1 answers into genericAnswers", () => {
const parsed = parseKnowledgeTemplateJson({ const parsed = parseKnowledgeTemplateJson({
answers: { brandVoice: "Hello" },
version: 1, version: 1,
answers: { brandVoice: " Hello " },
misc: "x",
updatedAt: "2020-01-01",
}) })
expect(parsed?.version).toBe(2) expect(parsed?.version).toBe(3)
expect(parsed?.legacyAnswers.brandVoice).toBe("Hello") expect(parsed?.genericAnswers.aiToneHowWeSound).toContain("Brand voice")
expect(parsed?.sharedFields).toEqual({}) expect(parsed?.genericAnswers.aiToneHowWeSound).toContain("Hello")
expect(parsed?.misc).toBe("x")
}) })
it("reads v2 sharedFields", () => { it("migrates v2 legacyAnswers and drops sharedFields from template shape", () => {
const parsed = parseKnowledgeTemplateJson({ const parsed = parseKnowledgeTemplateJson({
version: 2, version: 2,
legacyAnswers: {}, legacyAnswers: { brandVoice: "X" },
sharedFields: { sf_brand_display_name: "Acme" }, sharedFields: { sf_brand_display_name: "ignored" },
})
expect(parsed?.version).toBe(3)
expect(parsed?.genericAnswers.aiToneHowWeSound).toContain("X")
})
it("reads v3 genericAnswers", () => {
const parsed = parseKnowledgeTemplateJson({
version: 3,
genericAnswers: { communicationFormality: "Casual" },
updatedAt: "2020-01-01", updatedAt: "2020-01-01",
}) })
expect(parsed?.sharedFields.sf_brand_display_name).toBe("Acme") expect(parsed?.genericAnswers.communicationFormality).toBe("Casual")
}) })
})
describe("buildKnowledgeTemplateStored", () => { it("buildKnowledgeTemplateStored normalizes keys", () => {
it("builds v2 payload", () => {
const s = buildKnowledgeTemplateStored({ const s = buildKnowledgeTemplateStored({
answers: { brandVoice: "a" }, answers: { aiToneHowWeSound: " hi ", unknown: "x" } as Record<string, string>,
sharedFields: { sf_brand_display_name: "b" },
misc: "c",
}) })
expect(s.version).toBe(2) expect(s.genericAnswers.aiToneHowWeSound).toBe("hi")
expect(s.legacyAnswers.brandVoice).toBe("a") expect(s.genericAnswers).not.toHaveProperty("unknown")
expect(s.sharedFields.sf_brand_display_name).toBe("b") })
expect(s.misc).toBe("c")
it("mergeTemplateAnswers patches by key", () => {
const prev = parseKnowledgeTemplateJson({
version: 3,
genericAnswers: { aiToneHowWeSound: "a" },
})
const s = mergeTemplateAnswers(prev, { whenWeDoNotKnow: "b" })
expect(s.genericAnswers.aiToneHowWeSound).toBe("a")
expect(s.genericAnswers.whenWeDoNotKnow).toBe("b")
})
it("mergeTemplateAnswers clears when empty string sent for key", () => {
const prev = parseKnowledgeTemplateJson({
version: 3,
genericAnswers: { aiToneHowWeSound: "a" },
})
const s = mergeTemplateAnswers(prev, { aiToneHowWeSound: " " })
expect(s.genericAnswers.aiToneHowWeSound).toBeUndefined()
}) })
}) })