From c9ed213279154350b39cac04ab477eeddfd12ad4 Mon Sep 17 00:00:00 2001 From: Martinxmaina Date: Thu, 25 Jun 2026 18:27:16 +0300 Subject: [PATCH 1/8] docs(ai-assistant): add design spec for WhatsApp AI assistant LLM-wiki RAG knowledge base, autonomous reply with confidence gating, human-in-the-loop escalation, editable prompts. Anthropic-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-25-wacrm-ai-assistant-design.md | 378 ++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-25-wacrm-ai-assistant-design.md diff --git a/docs/superpowers/specs/2026-06-25-wacrm-ai-assistant-design.md b/docs/superpowers/specs/2026-06-25-wacrm-ai-assistant-design.md new file mode 100644 index 0000000000..8b0a9de4f1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-wacrm-ai-assistant-design.md @@ -0,0 +1,378 @@ +# AI Assistant for WhatsApp — Design Spec + +- **Date:** 2026-06-25 +- **Status:** Approved design — ready for implementation plan +- **Author:** brainstormed with the maintainer +- **Feature branch (planned):** `feat/ai-assistant` +- **Migration:** `027_ai_assistant.sql` + +--- + +## 1. Goal + +Add an AI assistant that answers inbound WhatsApp messages, **grounded +only in a per-account knowledge base** ("LLM wiki"). When the model is +confident the knowledge base covers the question, it **replies +autonomously**. When it is unsure, it **escalates to a human** and goes +silent on that conversation. Admins manage the knowledge base and edit +the assistant's prompt from Settings. + +This is a customer-facing, autonomous-send feature on the official +WhatsApp Business API — so the bias everywhere is **fail safe to a +human**: any doubt, any error, any cap hit → escalate, never guess and +never go silent. + +## 2. Locked decisions (from brainstorming) + +| Decision | Choice | Rationale | +|---|---|---| +| Audience / channel | Customer-facing on WhatsApp | Highest value; builds on existing webhook + inbox | +| Engagement model | AI is first responder; autonomous send when confident; clean hand-off + escalate when unsure; goes silent once a human takes over; agent can hand back | Matches "responds immediately; if unsure add a human" | +| Knowledge approach | **LLM wiki (long-context + prompt caching)**, not vector/RAG | One provider, no pgvector/embeddings/chunking, ships faster; fits a typical company KB. RAG deferred with an upgrade path | +| Confidence detection | **LLM self-reported grounding + deterministic guardrails** (both must pass to auto-send) | The retrieval half of the original "hybrid" is N/A since we send the whole KB; guardrails cover the deterministic half | +| Provider | **Anthropic only** (Claude) | One key; LLM-wiki needs no embeddings provider | +| Where logic lives | Dedicated `src/lib/ai/` module wired into the existing WhatsApp webhook | Lowest latency, self-contained, testable; designed so it could later be a Flows node | +| Default model | `claude-sonnet-4-6` (configurable; Haiku 4.5 for cheaper/faster) | Good support-reply quality/cost balance | +| Cost guard | Per-account **daily reply cap** (default 500/day, editable) | Prevents runaway spend; cap hit → escalate | + +## 3. Scope + +**In v1** +- DB migration: AI config table, knowledge base table, conversation AI + columns, AI reply audit log. +- `src/lib/ai/` module: prompt assembly, decision core, Anthropic + client wrapper, escalation logic — pure/mockable where possible. +- Webhook integration: AI auto-reply dispatched from `processMessage`, + gated on `!flowConsumed` and `ai_handling`. +- Settings → "AI Assistant" tab: enable toggle, prompt editor, handoff + message, escalation keywords, business name + logo, **knowledge base + manager** (CRUD + `.txt`/`.md`/PDF upload → text), KB size meter. +- Inbox hand-off UX: "Needs human" badge, AI message tag (`sender_type + = 'bot'`), Take over / Hand back to AI controls. +- API routes for AI config + knowledge base CRUD (account-scoped, + admin+). +- Unit tests for the decision core and prompt assembly. + +**Deferred (not in v1; upgrade paths designed in)** +- Vector/RAG retrieval (turn on when a KB exceeds the context budget). +- Usage/cost analytics dashboard (data is logged in v1 via + `ai_reply_log`; the UI comes later). +- Customer-facing logo surface / web chat widget (logo stored in v1, + used only as persona context). +- Multi-language tuning, voice-note transcription, image understanding. + +## 4. Data model — `supabase/migrations/027_ai_assistant.sql` + +Follows existing conventions: `account_id` tenancy column, RLS enabled, +`is_account_member(account_id, min_role)` for policies (mirror +migrations 017–026). Service-role writes from the webhook bypass RLS as +the other engines already do. + +### 4.1 `ai_assistant_config` — one row per account +| Column | Type | Notes | +|---|---|---| +| `id` | uuid pk | | +| `account_id` | uuid unique not null | FK → accounts, `ON DELETE CASCADE` | +| `enabled` | boolean not null default false | Master switch; off by default (opt-in) | +| `system_prompt` | text not null | Editable prompt; seeded with a strong default (see §7.2) | +| `handoff_message` | text | Sent to the customer on escalation; nullable = send nothing | +| `escalation_keywords` | text[] not null default `'{}'` | Seeded: refund, cancel, complaint, lawyer, legal, human, agent, manager | +| `business_name` | text | Persona context | +| `logo_url` | text | Stored via existing storage bucket; persona context only in v1 | +| `model` | text not null default `'claude-sonnet-4-6'` | Configurable | +| `daily_reply_cap` | integer not null default 500 | Cap hit → escalate | +| `created_at` / `updated_at` | timestamptz default now() | | + +**RLS:** select/insert/update for `is_account_member(account_id, +'admin')`. (Read could be widened to any member if the UI needs it; +keep admin-only in v1.) + +### 4.2 `knowledge_base_entries` — many per account (the "wiki pages") +| Column | Type | Notes | +|---|---|---| +| `id` | uuid pk | | +| `account_id` | uuid not null | FK → accounts, cascade | +| `title` | text not null | | +| `content` | text not null | Markdown/plain text; what gets fed to the model | +| `source_type` | text not null default `'manual'` | CHECK in (`manual`, `file`) | +| `source_filename` | text | Original filename when `source_type='file'` | +| `enabled` | boolean not null default true | Disabled entries are excluded from the prompt | +| `token_estimate` | integer | Cached ~token count for the size meter (chars/4 heuristic) | +| `created_by_user_id` | uuid | Audit | +| `created_at` / `updated_at` | timestamptz default now() | | + +**RLS:** all ops for `is_account_member(account_id, 'admin')`. + +### 4.3 `conversations` — add AI hand-off columns (ALTER) +| Column | Type | Notes | +|---|---|---| +| `ai_handling` | boolean not null default true | Is the AI still driving this conversation? Set false when escalated or when a human replies | +| `ai_escalated_at` | timestamptz | Set on escalation | +| `ai_escalation_reason` | text | `low_confidence` / `keyword` / `error` / `cap_reached` / `human_takeover` | + +(No change to the `status` CHECK. On escalation we also set +`status='pending'`, which is already an allowed value and reads as +"needs attention" in the inbox.) + +### 4.4 `ai_reply_log` — one row per AI decision (audit + future cost view) +| Column | Type | Notes | +|---|---|---| +| `id` | uuid pk | | +| `account_id` | uuid not null | cascade | +| `conversation_id` | uuid | | +| `message_id` | uuid | the inbound `messages.id` that triggered it | +| `decision` | text not null | CHECK in (`replied`, `escalated`, `skipped`, `error`) | +| `confident` | boolean | model's self-report (null when skipped pre-LLM) | +| `reason` | text | escalation reason / error summary | +| `model` | text | | +| `input_tokens` / `output_tokens` / `cache_read_tokens` | integer | from `usage` | +| `latency_ms` | integer | | +| `created_at` | timestamptz default now() | | + +**RLS:** select for `is_account_member(account_id, 'admin')`; inserts +are service-role only. + +## 5. Module structure — `src/lib/ai/` + +| File | Responsibility | I/O | +|---|---|---| +| `admin-client.ts` | Lazy service-role Supabase client | mirrors `src/lib/flows/admin-client.ts` | +| `config.ts` | Load `ai_assistant_config` for an account | DB read | +| `knowledge-base.ts` | Load enabled KB entries; assemble the KB text block; estimate tokens | DB read + pure | +| `prompt.ts` | **Pure.** Build the system block (persona + KB, cache-marked) and the message array from conversation history + inbound | pure | +| `guardrails.ts` | **Pure.** Deterministic escalation rules (keyword match, explicit human request) | pure | +| `decide.ts` | **Pure.** Given the model's structured response + guardrail result + cap state → `{action: 'reply'|'escalate', text?, reason?}` | pure | +| `anthropic.ts` | Thin wrapper around `@anthropic-ai/sdk`: forces the `submit_answer` tool, returns `{answer, confident, reason, usage}`. Injectable for tests | network | +| `reply.ts` | Orchestrator: `maybeReplyToInbound(...)` — loads config/KB, runs guardrails, calls the model, applies `decide`, sends or escalates, writes `ai_reply_log`. Wraps everything in try/catch → escalate on error | composes the above | +| `send.ts` | Send an AI reply via the existing Meta send path and insert the outbound `messages` row with `sender_type='bot'` | network + DB | +| `escalate.ts` | Set `ai_handling=false`, `ai_escalated_at`, `ai_escalation_reason`, `status='pending'`; optionally send `handoff_message` | DB + network | + +The pure modules (`prompt`, `guardrails`, `decide`) hold all the +logic worth testing; `anthropic`/`send`/DB are thin and mocked. + +## 6. Runtime flow + +Integration point: `src/app/api/whatsapp/webhook/route.ts` → +`processMessage()`, **after** the flow dispatch resolves `flowConsumed` +and after the inbound `messages` row is inserted. The webhook already +runs `processWebhook(...)` fire-and-forget before acking Meta 200, so +no change to the ack path is needed (no double-send risk). The AI call +is fire-and-forget with its own try/catch, exactly like the automation +dispatch. + +``` +inbound message stored + │ + ├─ flowConsumed? ── yes ─▶ skip AI (customer is in a bot menu/flow) + │ no + ▼ +maybeReplyToInbound({ accountId, conversationId, contactId, messageId, inboundText }) + │ + load ai_assistant_config + ├─ !enabled ─▶ skip + load conversation + ├─ ai_handling === false ─▶ skip (human owns it) + guardrails(inboundText, keywords) + ├─ keyword / "talk to a human" ─▶ escalate (reason=keyword), no LLM call + daily cap check (count today's `replied` in ai_reply_log) + ├─ cap reached ─▶ escalate (reason=cap_reached) + ▼ + build prompt (persona + cached KB block + recent history + inbound) + call Claude, force submit_answer tool → { answer, confident, reason, usage } + ▼ + decide(modelResult) + ├─ confident && answer ─▶ send (sender_type='bot') + log replied + └─ else ─▶ escalate (reason=low_confidence) + log escalated + ▼ + any throw anywhere ─▶ escalate (reason=error) + log error +``` + +**Human-takeover detection:** when an agent sends a manual reply +(`POST /api/whatsapp/send`), set `ai_handling=false` on that +conversation in the same handler. This guarantees the AI stays silent +the instant a human engages, even mid-stream. + +## 7. Prompt & decision contract + +### 7.1 Structured output +Call Claude with a single forced tool `submit_answer` +(`tool_choice: { type: 'tool', name: 'submit_answer' }`) whose +`input_schema` is: +```json +{ + "answer": "string — the reply to send the customer, empty if not confident", + "confident": "boolean — true ONLY if the knowledge base fully answers the question", + "reason": "string — short rationale, for the audit log" +} +``` +This makes the decision reliably parseable (no JSON-in-text scraping). + +### 7.2 System block (cache-marked) +`system` is an array; the **KB block carries +`cache_control: { type: 'ephemeral' }`** so it bills at ~10% on cache +hits (5-min TTL). Order: stable persona/instructions first, then the +assembled KB (most cacheable last-stable content). + +Seeded default `system_prompt` (editable): +> You are the customer-support assistant for {business_name}. Answer +> ONLY using the information in the KNOWLEDGE BASE below. If the +> knowledge base does not clearly and fully answer the customer's +> question, you MUST set `confident` to false and leave `answer` empty — +> do not guess, do not use outside knowledge, do not make promises about +> pricing, refunds, delivery dates, or policies that aren't written +> here. Be concise, friendly, and match the customer's language. + +The assembled KB block is the concatenation of enabled +`knowledge_base_entries` (`## {title}\n{content}`), wrapped in clear +delimiters. + +### 7.3 Conversation history +Include the last N inbound/outbound messages (default N=10) for context, +oldest→newest, mapped to `user`/`assistant` roles. History is NOT +cached (it changes every turn); only the KB block is. + +### 7.4 Account isolation (critical) +`prompt.ts` receives KB entries already filtered by `account_id`. A unit +test asserts that entries from another account never appear in the +assembled block. No global/shared KB in v1. + +## 8. Escalation & inbox UX + +- **Conversation list:** conversations with `ai_escalated_at` set (and + `status='pending'`) show a **"🙋 Needs human"** badge. Surfaces live + via the existing Supabase realtime subscription. +- **Thread:** messages with `sender_type='bot'` render with a small + **"AI"** tag so agents see what the bot said. +- **Controls:** a per-conversation **"Take over"** (sets + `ai_handling=false`) and **"Hand back to AI"** (sets + `ai_handling=true`, clears escalation fields) toggle. Sending a manual + reply implicitly takes over. +- No new conversation status value introduced; reuse `pending`. + +## 9. Settings → "AI Assistant" tab + +Plugs into the existing settings rail (`settings-sections.ts` + +`settings-rail.tsx`). Gated by `canEditSettings(role)` (admin+), mirror +`whatsapp-config.tsx` / `template-manager.tsx` patterns. + +Sections: +1. **Status & enable** — master toggle; shows whether `ANTHROPIC_API_KEY` + is configured (server-reported boolean, never the key). +2. **Prompt** — textarea for `system_prompt` (seeded default), handoff + message, escalation-keyword chips. +3. **Persona** — business name, logo upload (existing storage helper). +4. **Knowledge base manager** — list entries (title, size, enabled + toggle), add/edit (title + markdown content), delete, and **file + upload** (`.txt`/`.md` read directly; PDF → text extraction). A + **size meter** shows total estimated tokens vs. a soft budget + (e.g. 150k) with a warning band — the signal to enable RAG later. +5. **Model** — dropdown (Sonnet 4.6 / Haiku 4.5), daily reply cap input. + +## 10. API routes (account-scoped, admin+ via `requireRole`) + +| Route | Methods | Purpose | +|---|---|---| +| `src/app/api/ai/config/route.ts` | GET, PUT | Read/update `ai_assistant_config` | +| `src/app/api/ai/knowledge/route.ts` | GET, POST | List / create KB entries | +| `src/app/api/ai/knowledge/[id]/route.ts` | PATCH, DELETE | Edit / delete a KB entry | +| `src/app/api/ai/knowledge/upload/route.ts` | POST | Accept a file, extract text, create an entry | + +All use the user-session Supabase server client (RLS enforces tenancy) +plus an explicit role check. The conversation hand-off toggle reuses / +extends the existing conversation-update path used by the inbox. + +## 11. Environment & config + +Add to `.env.local.example` (and document in the CI dummy-env block): +``` +# AI assistant (Anthropic). Leave blank to disable AI replies. +ANTHROPIC_API_KEY= +# Optional override of the default model. +AI_DEFAULT_MODEL=claude-sonnet-4-6 +``` +`anthropic.ts` reads `ANTHROPIC_API_KEY` lazily (like the Supabase admin +clients) so a missing key never crashes the build — it just means AI is +effectively off (treated as `enabled=false` / escalate). + +## 12. Security & safety + +- API key server-side only; never sent to the client; Settings shows + only a configured/not-configured boolean. +- RLS on every new table; admin+ for config and KB; `ai_reply_log` + inserts are service-role only, reads admin-only. +- **Strict per-account KB isolation** in prompt assembly (unit-tested). +- **Fail safe to human**: missing key, API error, timeout, cap reached, + or low confidence all escalate; the customer is never left silent and + the bot never guesses. +- No CSP change: all Anthropic calls are server-side (mirrors how Meta + calls already work). +- The bot cannot perform destructive actions — it only sends a text + reply or escalates. No tool access to the DB beyond logging. + +## 13. Cost controls + +- **Prompt caching** on the KB block (primary lever). +- **Daily reply cap** per account (escalate when exceeded). +- History window capped (default 10 messages). +- `ai_reply_log` records token usage per call for a future cost view. + +## 14. Testing + +Vitest, matching the `src/lib/**/*.test.ts` convention. + +- `prompt.test.ts` — KB assembly, cache_control placement, account + isolation, history mapping/order, history cap. +- `guardrails.test.ts` — keyword matching (case/word-boundary), explicit + human-request detection, empty-KB behavior. +- `decide.test.ts` — confident+answer → reply; not-confident → escalate; + empty answer despite confident → escalate; reason propagation. +- `reply.test.ts` — orchestration with a mocked `anthropic` + mocked DB: + disabled→skip, human-owned→skip, keyword→escalate (no LLM call), + cap→escalate, error→escalate, happy path→send+log. +- Anthropic SDK and Supabase admin client are injected/mocked; no + network in tests. + +CI (`.github/workflows/ci.yml`) gains a dummy `ANTHROPIC_API_KEY` in the +env block so `next build` and module-load are satisfied (mirrors the +existing `META_APP_SECRET` pattern). + +## 15. Build orchestration (workflow + worktrees) + +Implementation runs as a **Workflow** inside a dedicated **git +worktree** on branch `feat/ai-assistant`, so it never disturbs the main +checkout and lands as one reviewable branch. + +Phased pipeline (dependency-ordered; later phases consume earlier +outputs): + +1. **Schema** — write `027_ai_assistant.sql` + extend `src/types`. +2. **Lib core** (parallelizable once types exist) — the pure modules + (`prompt`, `guardrails`, `decide`) **with their tests written first**, + then `anthropic`, `config`, `knowledge-base`, `send`, `escalate`, + `reply`. +3. **Webhook integration** — wire `maybeReplyToInbound` into + `processMessage`; set `ai_handling=false` in the send route. +4. **API routes** — config + knowledge CRUD + upload. +5. **UI** — Settings "AI Assistant" tab + KB manager; inbox badge, AI + tag, take-over/hand-back controls. +6. **Verify** — `npm run lint && npm run typecheck && npm test && + npm run build`; iterate to green. +7. **Review** — adversarial review pass (security + correctness) before + opening a PR. + +Agents that mutate overlapping files run sequentially; independent files +(e.g. the three pure libs, the API route files) fan out in parallel. +Every agent is told to **read the relevant `node_modules/next/dist/docs/` +guide before writing Next.js code** (per `AGENTS.md` — this is a modified +Next.js 16) and to follow existing patterns in the files referenced +above. + +## 16. Future / upgrade path + +- **RAG:** when the KB size meter trips the budget, add + `knowledge_base_chunks` (pgvector) + an embedding step; `prompt.ts` + swaps "whole KB" for "top-k retrieved chunks". Nothing else in the + flow changes — `decide`, guardrails, escalation, logging are reused. +- **Flows node:** expose `maybeReplyToInbound` as an "AI Reply" node so + it can be composed visually. +- **Cost dashboard:** read `ai_reply_log` into the existing dashboard. From 480b74a17f2afc2c43bb7e421c2f50025a749f73 Mon Sep 17 00:00:00 2001 From: Martinxmaina Date: Thu, 25 Jun 2026 19:13:52 +0300 Subject: [PATCH 2/8] =?UTF-8?q?feat(ai):=20WhatsApp=20AI=20assistant=20bac?= =?UTF-8?q?kend=20=E2=80=94=20KB-grounded=20auto-reply=20+=20escalation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the customer-facing AI assistant engine (spec §1–§7, §12–§14): - migration 027: ai_assistant_config, knowledge_base_entries, ai_reply_log, and conversations.ai_handling/ai_escalated_at/ ai_escalation_reason (account-scoped, RLS, is_account_member gating) - src/lib/ai/: pure prompt/guardrails/decide (+ tests), anthropic forced-tool wrapper, config/knowledge-base/admin-client data access, send (sender_type='bot') + escalate, and the maybeReplyToInbound orchestrator (+ mocked tests) - webhook: fire-and-forget AI dispatch gated on !flowConsumed - send route: manual agent reply sets ai_handling=false (human takeover) - types + ANTHROPIC_API_KEY / unpdf deps Fail-safe to human on any doubt/error; autonomous send only when the model self-reports confidence and guardrails + daily cap pass. typecheck/lint/test(526)/build all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- package-lock.json | 89 ++++- package.json | 4 +- src/app/api/whatsapp/send/route.ts | 7 +- src/app/api/whatsapp/webhook/route.ts | 69 +++- src/lib/ai/admin-client.ts | 16 + src/lib/ai/anthropic.ts | 247 ++++++++++++ src/lib/ai/config.ts | 43 +++ src/lib/ai/decide.test.ts | 145 +++++++ src/lib/ai/decide.ts | 65 ++++ src/lib/ai/escalate.ts | 143 +++++++ src/lib/ai/guardrails.test.ts | 155 ++++++++ src/lib/ai/guardrails.ts | 184 +++++++++ src/lib/ai/knowledge-base.ts | 78 ++++ src/lib/ai/prompt.test.ts | 315 +++++++++++++++ src/lib/ai/prompt.ts | 187 +++++++++ src/lib/ai/reply.test.ts | 470 +++++++++++++++++++++++ src/lib/ai/reply.ts | 386 +++++++++++++++++++ src/lib/ai/send.ts | 146 +++++++ src/types/index.ts | 153 ++++++++ supabase/migrations/027_ai_assistant.sql | 214 +++++++++++ 20 files changed, 3092 insertions(+), 24 deletions(-) create mode 100644 src/lib/ai/admin-client.ts create mode 100644 src/lib/ai/anthropic.ts create mode 100644 src/lib/ai/config.ts create mode 100644 src/lib/ai/decide.test.ts create mode 100644 src/lib/ai/decide.ts create mode 100644 src/lib/ai/escalate.ts create mode 100644 src/lib/ai/guardrails.test.ts create mode 100644 src/lib/ai/guardrails.ts create mode 100644 src/lib/ai/knowledge-base.ts create mode 100644 src/lib/ai/prompt.test.ts create mode 100644 src/lib/ai/prompt.ts create mode 100644 src/lib/ai/reply.test.ts create mode 100644 src/lib/ai/reply.ts create mode 100644 src/lib/ai/send.ts create mode 100644 supabase/migrations/027_ai_assistant.sql diff --git a/package-lock.json b/package-lock.json index 7de68320f7..3458154a33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.2.2", "license": "MIT", "dependencies": { + "@anthropic-ai/sdk": "^0.106.0", "@base-ui/react": "^1.5.0", "@dagrejs/dagre": "^3.0.0", "@dnd-kit/core": "^6.3.1", @@ -29,7 +30,8 @@ "shadcn": "^4.11.0", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "unpdf": "^1.6.2" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -61,6 +63,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.106.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.106.0.tgz", + "integrity": "sha512-ufwVvYNDBj2dzOGupBCTaNzBLxqcTnGOzI4z8Wouxlt+mT3J3HuOmatgCy1VmwCHOUueqZ41ERhm0O99OUcbWA==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -2248,6 +2271,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2951,7 +2980,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2961,7 +2990,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -4526,7 +4555,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/d3-array": { @@ -5900,6 +5929,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -7293,6 +7328,19 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -8850,6 +8898,7 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, "license": "MIT" }, "node_modules/react-redux": { @@ -9654,6 +9703,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -10080,6 +10139,12 @@ "node": ">=0.6" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -10253,7 +10318,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -10334,6 +10399,20 @@ "node": ">= 10.0.0" } }, + "node_modules/unpdf": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/unpdf/-/unpdf-1.6.2.tgz", + "integrity": "sha512-zQ80ySoPuPHOsvIoRp/nJyQt8TOUoTh1+WBCGcBvlddQNgKDLRwm0AY3x8Q35I7+kIiRSgqMx+Ma2pl9McIp7A==", + "license": "MIT", + "peerDependencies": { + "@napi-rs/canvas": "^0.1.69" + }, + "peerDependenciesMeta": { + "@napi-rs/canvas": { + "optional": true + } + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index 20d01a2493..2f9a3c8455 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "test:watch": "vitest" }, "dependencies": { + "@anthropic-ai/sdk": "^0.106.0", "@base-ui/react": "^1.5.0", "@dagrejs/dagre": "^3.0.0", "@dnd-kit/core": "^6.3.1", @@ -59,7 +60,8 @@ "shadcn": "^4.11.0", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "unpdf": "^1.6.2" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/src/app/api/whatsapp/send/route.ts b/src/app/api/whatsapp/send/route.ts index 107c493257..6ab574aed8 100644 --- a/src/app/api/whatsapp/send/route.ts +++ b/src/app/api/whatsapp/send/route.ts @@ -388,13 +388,18 @@ export async function POST(request: Request) { ) } - // Update conversation + // Update conversation. Setting `ai_handling=false` is the human- + // takeover signal: an agent replying by hand owns the conversation + // from here on, so the AI assistant goes silent on it immediately + // (mirrors the flow pause below — the strongest "human is here" + // signal). "Hand back to AI" in the inbox flips this back to true. await supabase .from('conversations') .update({ last_message_text: content_text || `[${message_type}]`, last_message_at: new Date().toISOString(), updated_at: new Date().toISOString(), + ai_handling: false, }) .eq('id', conversation_id) diff --git a/src/app/api/whatsapp/webhook/route.ts b/src/app/api/whatsapp/webhook/route.ts index ba404f83e7..b5ecddc30e 100644 --- a/src/app/api/whatsapp/webhook/route.ts +++ b/src/app/api/whatsapp/webhook/route.ts @@ -7,6 +7,7 @@ import { findExistingContact, isUniqueViolation } from '@/lib/contacts/dedupe' import { verifyMetaWebhookSignature } from '@/lib/whatsapp/webhook-signature' import { runAutomationsForTrigger } from '@/lib/automations/engine' import { dispatchInboundToFlows } from '@/lib/flows/engine' +import { maybeReplyToInbound } from '@/lib/ai/reply' import { handleTemplateWebhookChange, isTemplateWebhookField, @@ -275,7 +276,10 @@ async function processWebhook(body: { entry?: WhatsAppWebhookEntry[] }) { // inserts that need it for NOT NULL FK compliance. Always // the admin who saved the WhatsApp config. config.user_id, - decryptedAccessToken + decryptedAccessToken, + // Meta phone-number id — the AI assistant needs it to send an + // autonomous reply via the same Meta send path. + config.phone_number_id ) } } @@ -509,7 +513,11 @@ async function processMessage( // (contacts, conversations). Always the admin who saved the // WhatsApp config; the choice is arbitrary post-017 but stable. configOwnerUserId: string, - accessToken: string + accessToken: string, + // Meta phone-number id from the matched whatsapp_config row. Passed + // through to the AI assistant so an autonomous reply goes out on the + // same number the inbound arrived on. + phoneNumberId: string ) { const senderPhone = normalizePhone(message.from) const contactName = contact.profile.name @@ -595,21 +603,28 @@ async function processMessage( .eq('sender_type', 'customer') const isFirstInboundMessage = (priorCustomerMsgCount ?? 0) === 0 - const { error: msgError } = await supabaseAdmin().from('messages').insert({ - conversation_id: conversation.id, - sender_type: 'customer', - content_type: contentType, - content_text: contentText, - media_url: mediaUrl, - message_id: message.id, - status: 'delivered', - created_at: new Date(parseInt(message.timestamp) * 1000).toISOString(), - reply_to_message_id: replyToInternalId, - // Only populated for content_type='interactive'. Migration 010 added - // the column; null for every other content_type so existing inserts - // behave identically. - interactive_reply_id: interactiveReplyId, - }) + const { data: insertedMessage, error: msgError } = await supabaseAdmin() + .from('messages') + .insert({ + conversation_id: conversation.id, + sender_type: 'customer', + content_type: contentType, + content_text: contentText, + media_url: mediaUrl, + message_id: message.id, + status: 'delivered', + created_at: new Date(parseInt(message.timestamp) * 1000).toISOString(), + reply_to_message_id: replyToInternalId, + // Only populated for content_type='interactive'. Migration 010 added + // the column; null for every other content_type so existing inserts + // behave identically. + interactive_reply_id: interactiveReplyId, + }) + // `.select().single()` so the inserted UUID is available for the AI + // assistant's `ai_reply_log` audit row (the inbound message that + // triggered the decision — see maybeReplyToInbound dispatch below). + .select('id') + .single() if (msgError) { console.error('Error inserting message:', msgError) @@ -713,6 +728,26 @@ async function processMessage( }, }).catch((err) => console.error('[automations] dispatch failed:', err)) } + + // AI assistant dispatch. Suppressed when a flow consumed the message — + // the customer is navigating a bot menu/flow, so the assistant must not + // also reply (same `!flowConsumed` gate as the content-level automation + // triggers above). Fire-and-forget like the automation dispatch: + // maybeReplyToInbound has its own try/catch, never throws, and runs off + // the webhook's ack path so a slow or failing AI call can't delay Meta's + // 200. Its own internal gates (enabled / ai_handling / guardrails / cap) + // decide whether it actually replies or escalates. + if (!flowConsumed) { + maybeReplyToInbound({ + accountId, + conversationId: conversation.id, + contactId: contactRecord.id, + messageId: insertedMessage.id, + inboundText, + accessToken, + phoneNumberId, + }).catch(console.error) + } } async function parseMessageContent( diff --git a/src/lib/ai/admin-client.ts b/src/lib/ai/admin-client.ts new file mode 100644 index 0000000000..6bd23ad6c6 --- /dev/null +++ b/src/lib/ai/admin-client.ts @@ -0,0 +1,16 @@ +import { createClient, type SupabaseClient } from '@supabase/supabase-js' + +// Lazy, shared service-role client for the AI assistant engine. +// Mirrors src/lib/flows/admin-client.ts — same shape so anyone +// reading either file picks up the convention immediately. +let _adminClient: SupabaseClient | null = null + +export function supabaseAdmin(): SupabaseClient { + if (!_adminClient) { + _adminClient = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY!, + ) + } + return _adminClient +} diff --git a/src/lib/ai/anthropic.ts b/src/lib/ai/anthropic.ts new file mode 100644 index 0000000000..7458211b8a --- /dev/null +++ b/src/lib/ai/anthropic.ts @@ -0,0 +1,247 @@ +/** + * Thin wrapper around `@anthropic-ai/sdk` (spec §5/§7.1, §11). + * + * The only place in `src/lib/ai/` that talks to Anthropic. It forces a + * single `submit_answer` tool so the model's decision comes back as a + * structured `tool_use` block (no JSON-in-text scraping — spec §7.1), + * parses that block into the canonical `AiModelResult`, and surfaces the + * token `usage` the orchestrator records in `ai_reply_log`. + * + * Deliberately thin: prompt assembly lives in `prompt.ts`, the + * reply/escalate verdict in `decide.ts`, orchestration in `reply.ts`. + * This module just does the network round-trip and the parse, so it is + * trivially mockable — `callAssistant` accepts an injected `client` + * (default a real `Anthropic` instance) and the reply tests pass a stub + * instead of hitting the network (spec §14). + * + * The API key is read LAZILY (spec §11): the SDK is constructed only on + * first real call, so a missing `ANTHROPIC_API_KEY` never crashes + * `next build` or module load — it just means AI is effectively off. A + * missing key throws the typed `MissingApiKeyError`; the orchestrator's + * try/catch turns that (like any other failure) into a fail-safe + * escalation to a human (spec §6, §12). + */ + +import Anthropic from "@anthropic-ai/sdk"; + +import { type AiModelResult } from "@/types"; + +/** The forced tool's name (spec §7.1). */ +export const SUBMIT_ANSWER_TOOL_NAME = "submit_answer"; + +/** + * Thrown when `ANTHROPIC_API_KEY` is absent at call time. Typed (not a + * bare `Error`) so callers can distinguish "AI is not configured" from a + * transient API failure if they ever need to — and so it reads clearly + * in the audit log. Mirrors the typed-error convention in + * `src/lib/auth/account.ts`. + */ +export class MissingApiKeyError extends Error { + constructor(message = "ANTHROPIC_API_KEY is not configured") { + super(message); + this.name = "MissingApiKeyError"; + } +} + +/** + * Token usage from the Anthropic `usage` block, projected to the three + * counts the audit log cares about (spec §4.4 / §13). The SDK reports + * `input_tokens` / `cache_read_input_tokens` as nullable; we normalise + * `null` to `0` so the caller always gets numbers. + */ +export interface AiUsage { + input_tokens: number; + output_tokens: number; + cache_read_input_tokens: number; +} + +/** Arguments for {@link callAssistant}. */ +export interface CallAssistantArgs { + /** Anthropic model id (e.g. `claude-sonnet-4-6`) — from account config. */ + model: string; + /** + * The `system` array built by `prompt.buildSystemBlocks` (persona + + * cache-marked KB block). Passed straight through. + */ + system: Anthropic.TextBlockParam[]; + /** + * The `messages` array built by `prompt.buildMessages` (history + + * inbound). Passed straight through. + */ + messages: Anthropic.MessageParam[]; + /** + * Optional injected client (default a real `Anthropic` instance, built + * lazily). Tests pass a stub so no network call happens (spec §14). + */ + client?: Pick; +} + +/** The wrapper's result: the model's verdict plus the token usage. */ +export interface CallAssistantResult extends AiModelResult { + usage: AiUsage; +} + +/** + * Cap on the model's output. The `submit_answer` payload is small (a + * single support reply plus a short rationale), so this is generous — + * it bounds runaway generation without truncating a normal reply. + */ +const MAX_OUTPUT_TOKENS = 1024; + +/** + * The single tool the model is forced to call (spec §7.1). Its + * `input_schema` is the structured decision contract: the reply to send, + * a strict confidence flag, and a short rationale for the audit log. + */ +const SUBMIT_ANSWER_TOOL: Anthropic.Tool = { + name: SUBMIT_ANSWER_TOOL_NAME, + description: + "Submit your decision for this customer message. Call this exactly " + + "once. Set `confident` to true ONLY if the knowledge base fully and " + + "clearly answers the question; otherwise set it to false and leave " + + "`answer` empty so the conversation is handed to a human.", + input_schema: { + type: "object", + properties: { + answer: { + type: "string", + description: + "The reply to send the customer. Empty string if you are not " + + "confident the knowledge base answers the question.", + }, + confident: { + type: "boolean", + description: + "True ONLY if the knowledge base fully answers the question.", + }, + reason: { + type: "string", + description: "Short rationale for the decision, for the audit log.", + }, + }, + required: ["answer", "confident", "reason"], + }, +}; + +/** + * Force Claude to answer through the single `submit_answer` tool + * (spec §7.1). + */ +const SUBMIT_ANSWER_TOOL_CHOICE: Anthropic.ToolChoiceTool = { + type: "tool", + name: SUBMIT_ANSWER_TOOL_NAME, +}; + +/** Process-wide lazily-constructed real client. Reused across calls. */ +let _client: Anthropic | null = null; + +/** + * Lazily construct (and memoise) the real Anthropic client. + * + * Reads `ANTHROPIC_API_KEY` at CALL TIME, not module load (spec §11): a + * missing key throws {@link MissingApiKeyError} here rather than crashing + * the build. The key is passed explicitly so the failure mode is our + * typed error, not the SDK's own "missing key" throw. + */ +function getClient(): Anthropic { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey || apiKey.trim().length === 0) { + throw new MissingApiKeyError(); + } + if (!_client) { + _client = new Anthropic({ apiKey }); + } + return _client; +} + +/** + * Coerce the SDK's nullable usage counts into a plain numeric + * {@link AiUsage}. `input_tokens` / `cache_read_input_tokens` can be + * `null`; normalise to `0`. + */ +function toUsage(usage: Anthropic.Usage): AiUsage { + return { + input_tokens: usage.input_tokens ?? 0, + output_tokens: usage.output_tokens ?? 0, + cache_read_input_tokens: usage.cache_read_input_tokens ?? 0, + }; +} + +/** + * Extract and validate the `submit_answer` payload from the response + * content. Throws if no matching `tool_use` block is present or its input + * is not the expected shape — the orchestrator's try/catch turns that + * into a fail-safe escalation rather than sending a malformed reply. + */ +function parseToolUse(content: Anthropic.ContentBlock[]): AiModelResult { + const block = content.find( + (b): b is Anthropic.ToolUseBlock => + b.type === "tool_use" && b.name === SUBMIT_ANSWER_TOOL_NAME, + ); + + if (!block) { + throw new Error( + `Anthropic response contained no '${SUBMIT_ANSWER_TOOL_NAME}' tool_use block`, + ); + } + + const input = block.input; + if (typeof input !== "object" || input === null) { + throw new Error( + `'${SUBMIT_ANSWER_TOOL_NAME}' tool_use input was not an object`, + ); + } + + const { answer, confident, reason } = input as Record; + if ( + typeof answer !== "string" || + typeof confident !== "boolean" || + typeof reason !== "string" + ) { + throw new Error( + `'${SUBMIT_ANSWER_TOOL_NAME}' tool_use input did not match the expected schema`, + ); + } + + return { answer, confident, reason }; +} + +/** + * Call Claude for a single customer message and return its structured + * decision plus token usage (spec §7.1). + * + * Forces the `submit_answer` tool via + * `tool_choice: { type: 'tool', name: 'submit_answer' }`, so the model + * MUST respond with one `tool_use` block whose `input` is + * `{ answer, confident, reason }`. The block is parsed and validated; + * anything unexpected (missing block, wrong shape) throws so the caller + * escalates instead of guessing. + * + * Pass `client` to inject a stub in tests; omit it to use the real, + * lazily-built client (which reads `ANTHROPIC_API_KEY` and throws + * {@link MissingApiKeyError} if it is absent). + */ +export async function callAssistant({ + model, + system, + messages, + client, +}: CallAssistantArgs): Promise { + const anthropic = client ?? getClient(); + + const response = await anthropic.messages.create({ + model: model as Anthropic.Model, + max_tokens: MAX_OUTPUT_TOKENS, + system, + messages, + tools: [SUBMIT_ANSWER_TOOL], + tool_choice: SUBMIT_ANSWER_TOOL_CHOICE, + }); + + const result = parseToolUse(response.content); + + return { + ...result, + usage: toUsage(response.usage), + }; +} diff --git a/src/lib/ai/config.ts b/src/lib/ai/config.ts new file mode 100644 index 0000000000..8895b13392 --- /dev/null +++ b/src/lib/ai/config.ts @@ -0,0 +1,43 @@ +/** + * Load the per-account AI assistant configuration (spec §4.1, §5). + * + * A thin service-role read of the single `ai_assistant_config` row for an + * account. The webhook path runs as service-role and bypasses RLS exactly + * as the Flows and automations engines already do (spec §4 / §12). + * + * Returns `null` when no config row exists (the account never opened the + * AI Settings tab) or on any DB error — the caller treats a missing config + * as "AI off" and skips, which is the safe default (spec §6: `!enabled → + * skip`, and the broader fail-safe-to-human bias of §1). + */ + +import { type AiAssistantConfig } from "@/types"; + +import { supabaseAdmin } from "./admin-client"; + +/** + * Load `ai_assistant_config` for one account. + * + * `.maybeSingle()` returns `null` (not an error) when there is no row, so + * an account without a config simply reads as "no AI configured". The + * `account_id` column is UNIQUE in the DB (spec §4.1), so at most one row + * ever matches. + */ +export async function loadAiConfig( + accountId: string, +): Promise { + const db = supabaseAdmin(); + + const { data, error } = await db + .from("ai_assistant_config") + .select("*") + .eq("account_id", accountId) + .maybeSingle(); + + if (error) { + console.error("[ai] loadAiConfig error:", error.message); + return null; + } + + return (data as AiAssistantConfig | null) ?? null; +} diff --git a/src/lib/ai/decide.test.ts b/src/lib/ai/decide.test.ts new file mode 100644 index 0000000000..3b6cf184ed --- /dev/null +++ b/src/lib/ai/decide.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect } from "vitest"; + +import { decide } from "./decide"; +import { type AiModelResult } from "@/types"; + +describe("decide — confident with an answer → reply", () => { + it("replies with the answer text when confident and answer is non-empty", () => { + const result: AiModelResult = { + answer: "We're open 9am–5pm, Monday to Friday.", + confident: true, + reason: "Opening hours are listed in the KB.", + }; + expect(decide(result)).toEqual({ + action: "reply", + text: "We're open 9am–5pm, Monday to Friday.", + }); + }); + + it("passes the answer through verbatim (no trimming of the sent text)", () => { + const result: AiModelResult = { + answer: " Sure — here you go. ", + confident: true, + reason: "answered", + }; + expect(decide(result)).toEqual({ + action: "reply", + text: " Sure — here you go. ", + }); + }); + + it("does not leak a `reason` onto a reply result", () => { + const decision = decide({ + answer: "Yes, we ship to Kenya.", + confident: true, + reason: "shipping coverage is in the KB", + }); + expect(decision).not.toHaveProperty("reason"); + }); +}); + +describe("decide — not confident → escalate", () => { + it("escalates with reason 'low_confidence' when confident is false", () => { + const result: AiModelResult = { + answer: "", + confident: false, + reason: "KB does not cover refund timelines.", + }; + expect(decide(result)).toEqual({ + action: "escalate", + reason: "low_confidence", + }); + }); + + it("escalates when not confident even if an answer was supplied", () => { + // Belt-and-braces: a non-empty answer must NOT override a false + // self-report — the model said it wasn't sure, so we don't send it. + const result: AiModelResult = { + answer: "I think it's probably 30 days?", + confident: false, + reason: "guessing", + }; + expect(decide(result)).toEqual({ + action: "escalate", + reason: "low_confidence", + }); + }); +}); + +describe("decide — confident but empty answer → escalate", () => { + it("escalates when confident but the answer is empty", () => { + const result: AiModelResult = { + answer: "", + confident: true, + reason: "no content", + }; + expect(decide(result)).toEqual({ + action: "escalate", + reason: "low_confidence", + }); + }); + + it("escalates when confident but the answer is whitespace-only", () => { + const result: AiModelResult = { + answer: " \n\t ", + confident: true, + reason: "blank", + }; + expect(decide(result)).toEqual({ + action: "escalate", + reason: "low_confidence", + }); + }); +}); + +describe("decide — malformed / degenerate input → escalate", () => { + it("escalates when confident is a truthy non-true value (strict check)", () => { + const result = { + answer: "Anything.", + confident: "yes", + reason: "string-not-boolean", + } as unknown as AiModelResult; + expect(decide(result)).toEqual({ + action: "escalate", + reason: "low_confidence", + }); + }); + + it("escalates when answer is a non-string value", () => { + const result = { + answer: 42, + confident: true, + reason: "answer-not-string", + } as unknown as AiModelResult; + expect(decide(result)).toEqual({ + action: "escalate", + reason: "low_confidence", + }); + }); + + it("escalates safely on a null / undefined model result", () => { + expect(decide(null as unknown as AiModelResult)).toEqual({ + action: "escalate", + reason: "low_confidence", + }); + expect(decide(undefined as unknown as AiModelResult)).toEqual({ + action: "escalate", + reason: "low_confidence", + }); + }); +}); + +describe("decide — determinism / purity", () => { + it("returns the same result for the same input and does not mutate it", () => { + const result: AiModelResult = { + answer: "Hello!", + confident: true, + reason: "greeting", + }; + const snapshot = { ...result }; + const first = decide(result); + const second = decide(result); + expect(first).toEqual(second); + expect(result).toEqual(snapshot); + }); +}); diff --git a/src/lib/ai/decide.ts b/src/lib/ai/decide.ts new file mode 100644 index 0000000000..ca278f141d --- /dev/null +++ b/src/lib/ai/decide.ts @@ -0,0 +1,65 @@ +/** + * The decision core (spec §5/§6). + * + * The second, post-LLM half of the assistant's "fail safe to a human" + * bias. Once the model has answered via the forced `submit_answer` tool, + * `decide` reduces its structured `AiModelResult` to a single, parseable + * verdict: reply autonomously or escalate to a human. + * + * Per spec §6 the contract is deliberately strict — auto-send requires + * BOTH halves of the model's self-report to hold: + * - `confident === true` (the KB fully answers the question), AND + * - a non-empty `answer` (there is actually something to send). + * + * Anything else — not confident, or confident but with an empty/blank + * answer (a degenerate "confident-but-said-nothing" response) — escalates + * with `reason: 'low_confidence'`. The customer is never left silent and + * the bot never sends an empty message. + * + * Pure and fully deterministic: no I/O, no clock, no randomness, no + * mutation of the input. All the logic worth testing lives here so it can + * be exercised without a Supabase / Anthropic mock (mirrors + * `src/lib/ai/guardrails.ts` and `src/lib/flows/fallback.ts`). + */ + +import { type AiEscalationReason, type AiModelResult } from "@/types"; + +/** The two terminal actions the assistant can take on an inbound message. */ +export type DecideAction = "reply" | "escalate"; + +/** + * The verdict produced from a model result. + * + * - `{ action: 'reply', text }` — send `text` to the customer as a `bot` + * message. `text` is always present and non-empty when `action` is + * `'reply'`. + * - `{ action: 'escalate', reason }` — hand the conversation to a human. + * `reason` is always the `'low_confidence'` escalation reason (the only + * post-LLM escalation `decide` itself produces; keyword / cap / error + * escalations are decided upstream). It maps straight onto + * `ai_reply_log.reason` / `conversations.ai_escalation_reason`. + */ +export type DecideResult = + | { action: "reply"; text: string } + | { action: "escalate"; reason: AiEscalationReason }; + +/** + * Reduce the model's structured `submit_answer` result to a reply / + * escalate decision. + * + * Returns `{ action: 'reply', text: answer }` ONLY when the model both + * reported `confident === true` and supplied a non-empty (non-whitespace) + * `answer`. Every other case — not confident, missing/blank answer, or a + * malformed result object — escalates with `reason: 'low_confidence'`, + * honouring the "any doubt → escalate, never send an empty reply" bias. + */ +export function decide(modelResult: AiModelResult): DecideResult { + const confident = modelResult?.confident === true; + const answer = typeof modelResult?.answer === "string" ? modelResult.answer : ""; + + if (confident && answer.trim().length > 0) { + return { action: "reply", text: answer }; + } + + return { action: "escalate", reason: "low_confidence" }; +} diff --git a/src/lib/ai/escalate.ts b/src/lib/ai/escalate.ts new file mode 100644 index 0000000000..bfb963aac2 --- /dev/null +++ b/src/lib/ai/escalate.ts @@ -0,0 +1,143 @@ +/** + * Conversation escalation (spec §5: `escalate.ts`, §6, §8). + * + * The single place the AI hands a conversation to a human. Called from the + * orchestrator (`reply.ts`) for every non-reply outcome — keyword match, + * daily-cap hit, low confidence, or any thrown error — honouring the + * "fail safe to a human" bias of spec §1. + * + * It does two things, in this order: + * + * 1. Flips the conversation out of AI control via the service-role admin + * client (RLS-bypassing, like the rest of the AI/Flows engine): + * - `ai_handling = false` → AI goes silent on this thread + * - `ai_escalated_at = now()` → drives the "🙋 Needs human" badge + * - `ai_escalation_reason = reason` → audit + inbox surfacing + * - `status = 'pending'` → reuses an existing status value + * that reads as "needs attention" + * (spec §4.3, §8 — no new status value is introduced.) + * + * 2. If `config.handoff_message` is set, sends it to the customer over the + * SAME Meta path the inbox composer + Flows engine use + * (`sendTextMessage` from `src/lib/whatsapp/meta-api.ts`), so the + * customer is never left silent. + * + * The DB flip is the load-bearing safety step and runs FIRST: if the + * optional handoff send fails, the conversation is still correctly escalated + * and silent. The handoff send is therefore best-effort — its failure is + * logged, not thrown — so a Meta hiccup never leaves the AI "handling" a + * conversation it has already decided to abandon. + * + * Note: the handoff send does NOT persist a `messages` row. It is a system + * notice to the customer, not part of the agent/bot transcript; keeping it + * out of `messages` avoids it reading as an AI reply (which carries the + * `sender_type='bot'` "AI" tag) once a human has taken over. + */ + +import { + sanitizePhoneForMeta, + isValidE164, + phoneVariants, + isRecipientNotAllowedError, +} from '@/lib/whatsapp/phone-utils' +import { sendTextMessage } from '@/lib/whatsapp/meta-api' +import { type AiAssistantConfig, type AiEscalationReason } from '@/types' + +import { supabaseAdmin } from './admin-client' + +export interface EscalateConversationArgs { + /** Conversation to hand to a human. */ + conversationId: string + /** Why we escalated — written to `conversations.ai_escalation_reason`. */ + reason: AiEscalationReason + /** + * The account's AI config. Only `handoff_message` is consulted here; the + * full object is passed so the caller doesn't have to re-shape it. + */ + config: AiAssistantConfig + /** Decrypted Meta access token, resolved by the caller. */ + accessToken: string + /** Meta phone-number id, resolved by the caller. */ + phoneNumberId: string + /** The customer's phone number, to send the optional handoff message to. */ + customerPhone: string +} + +/** + * Escalate a conversation to a human and optionally notify the customer. + * + * Always flips the conversation's AI hand-off columns; sends + * `config.handoff_message` to the customer only when it is set. Never + * throws on a handoff-send failure — the escalation itself is what must + * succeed (spec §1, §6). + */ +export async function escalateConversation( + args: EscalateConversationArgs, +): Promise { + const db = supabaseAdmin() + + // 1. Flip the conversation out of AI control. This is the safety-critical + // step — do it first so a later handoff-send failure can't leave the + // AI still "handling" a conversation it has abandoned. + const now = new Date().toISOString() + const { error: updateErr } = await db + .from('conversations') + .update({ + ai_handling: false, + ai_escalated_at: now, + ai_escalation_reason: args.reason, + status: 'pending', + updated_at: now, + }) + .eq('id', args.conversationId) + if (updateErr) { + // The DB flip is load-bearing; surface its failure so the caller logs + // an `error` decision and a human can be alerted another way. + throw new Error(`escalation DB update failed: ${updateErr.message}`) + } + + // 2. Optionally notify the customer. Nothing to send when no handoff + // message is configured (nullable = send nothing, spec §4.1). + const handoff = args.config.handoff_message?.trim() + if (!handoff) return + + const sanitized = sanitizePhoneForMeta(args.customerPhone) + if (!isValidE164(sanitized)) { + // Can't message an invalid number — the conversation is still correctly + // escalated, so just log and return rather than throwing. + console.error( + `[ai] escalate: customer phone invalid, skipping handoff send: ${args.customerPhone}`, + ) + return + } + + // Best-effort handoff send over the same Meta path as the composer / flows + // engine, with the same phone-variant retry. A failure here is logged, not + // thrown — the escalation has already taken effect. + try { + const variants = phoneVariants(sanitized) + let lastError: unknown = null + for (const v of variants) { + try { + await sendTextMessage({ + phoneNumberId: args.phoneNumberId, + accessToken: args.accessToken, + to: v, + text: handoff, + }) + lastError = null + break + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (!isRecipientNotAllowedError(msg)) throw err + lastError = err + } + } + if (lastError) throw lastError + } catch (err) { + console.error( + '[ai] escalate: handoff message send failed:', + err instanceof Error ? err.message : err, + ) + } +} diff --git a/src/lib/ai/guardrails.test.ts b/src/lib/ai/guardrails.test.ts new file mode 100644 index 0000000000..c2cd6fce89 --- /dev/null +++ b/src/lib/ai/guardrails.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; + +import { shouldForceEscalate } from "./guardrails"; + +// The seeded default keyword list from the spec (§4.1 / §7). +const DEFAULT_KEYWORDS = [ + "refund", + "cancel", + "complaint", + "lawyer", + "legal", + "human", + "agent", + "manager", +]; + +describe("shouldForceEscalate — configured keywords", () => { + it("escalates with reason 'keyword' on a whole-word match", () => { + expect( + shouldForceEscalate("I want a refund please", DEFAULT_KEYWORDS), + ).toEqual({ escalate: true, reason: "keyword" }); + }); + + it("matches case-insensitively", () => { + expect(shouldForceEscalate("REFUND NOW", DEFAULT_KEYWORDS).escalate).toBe( + true, + ); + expect( + shouldForceEscalate("Filing a CoMpLaInT", DEFAULT_KEYWORDS).escalate, + ).toBe(true); + }); + + it("anchors on word boundaries — no substring false positives", () => { + // "legal" must not fire inside "illegal". + expect( + shouldForceEscalate("Is this illegal?", DEFAULT_KEYWORDS).escalate, + ).toBe(false); + // "cancel" must not fire inside "cancellation" boundaries that are + // still distinct words — but it DOES fire as its own word. + expect( + shouldForceEscalate("scancelled the order", DEFAULT_KEYWORDS).escalate, + ).toBe(false); + // "agent" must not fire inside "agentic". + expect( + shouldForceEscalate("an agentic workflow", DEFAULT_KEYWORDS).escalate, + ).toBe(false); + }); + + it("matches a keyword adjacent to punctuation", () => { + expect( + shouldForceEscalate("refund, now!", DEFAULT_KEYWORDS).escalate, + ).toBe(true); + expect( + shouldForceEscalate("Where is my refund?", DEFAULT_KEYWORDS).escalate, + ).toBe(true); + }); + + it("does not escalate on unrelated text", () => { + expect( + shouldForceEscalate("What are your opening hours?", DEFAULT_KEYWORDS), + ).toEqual({ escalate: false }); + }); + + it("matches a multi-word configured keyword across any whitespace", () => { + expect( + shouldForceEscalate("I have a billing dispute", ["billing dispute"]) + .escalate, + ).toBe(true); + }); +}); + +describe("shouldForceEscalate — empty / malformed inputs", () => { + it("does not escalate when the keyword list is empty (no explicit request)", () => { + expect(shouldForceEscalate("what are your hours?", [])).toEqual({ + escalate: false, + }); + }); + + it("ignores blank / whitespace-only keywords (no match-everything bug)", () => { + expect(shouldForceEscalate("hello there", ["", " "])).toEqual({ + escalate: false, + }); + }); + + it("returns no-escalate for empty or whitespace-only inbound text", () => { + expect(shouldForceEscalate("", DEFAULT_KEYWORDS)).toEqual({ + escalate: false, + }); + expect(shouldForceEscalate(" ", DEFAULT_KEYWORDS)).toEqual({ + escalate: false, + }); + }); + + it("treats a non-array keyword argument as empty", () => { + expect( + shouldForceEscalate( + "what are your hours?", + undefined as unknown as string[], + ), + ).toEqual({ escalate: false }); + }); + + it("treats non-string inbound text as no-escalate", () => { + expect( + shouldForceEscalate(null as unknown as string, DEFAULT_KEYWORDS), + ).toEqual({ escalate: false }); + }); +}); + +describe("shouldForceEscalate — explicit human-request detection", () => { + it("escalates on 'talk to a human' even with no configured keywords", () => { + expect(shouldForceEscalate("I want to talk to a human", [])).toEqual({ + escalate: true, + reason: "keyword", + }); + }); + + it("detects a range of hand-off phrasings", () => { + const phrases = [ + "Can I speak to a person?", + "please connect me to an agent", + "I need a real person", + "let me talk to a representative", + "transfer me to a manager", + "I'd like a human agent", + "get me customer service", + "I want to speak with someone real", + ]; + for (const text of phrases) { + expect(shouldForceEscalate(text, []).escalate).toBe(true); + } + }); + + it("is case-insensitive for human requests", () => { + expect( + shouldForceEscalate("TALK TO A HUMAN PLEASE", []).escalate, + ).toBe(true); + }); + + it("does not escalate on a passing mention of a person without a request", () => { + // No request verb paired with the noun → not an explicit hand-off. + expect( + shouldForceEscalate("your agent called me yesterday", []).escalate, + ).toBe(false); + expect( + shouldForceEscalate("the manager was very kind", []).escalate, + ).toBe(false); + }); + + it("tolerates extra whitespace inside a request phrase", () => { + expect( + shouldForceEscalate("talk to a human", []).escalate, + ).toBe(true); + }); +}); diff --git a/src/lib/ai/guardrails.ts b/src/lib/ai/guardrails.ts new file mode 100644 index 0000000000..3c02ed36d0 --- /dev/null +++ b/src/lib/ai/guardrails.ts @@ -0,0 +1,184 @@ +/** + * Deterministic escalation guardrails (spec §6/§7). + * + * The first, pre-LLM half of the assistant's "fail safe to a human" + * bias: before any Anthropic call we scan the inbound text for + * account-configured escalation keywords and for explicit + * "talk to a human" style requests. A hit forces an escalation with no + * model call at all — cheaper, faster, and immune to a confused model. + * + * Pure and fully deterministic: no I/O, no clock, no randomness. All + * the logic worth testing lives here so it can be exercised without a + * Supabase / Anthropic mock (mirrors `src/lib/flows/fallback.ts`). + * + * Matching rules: + * - Case-insensitive. + * - Word-boundary anchored, so a keyword like `legal` matches + * "I need legal help" but NOT "illegal" or "delegal" — substring + * false-positives would silently route innocent traffic to a human. + * - The built-in "talk to a human/agent/person/..." detection is always + * on, independent of the configured keyword list, so an account that + * trimmed its keywords still honours an explicit hand-off request. + * + * The returned `reason` is the literal `AiEscalationReason` value + * `'keyword'` (spec §6: "keyword / 'talk to a human' → escalate + * (reason=keyword)"), so callers can write it straight to + * `ai_reply_log.reason` / `conversations.ai_escalation_reason`. + */ + +import { type AiEscalationReason } from "@/types"; + +/** Outcome of the deterministic guardrail scan. */ +export interface GuardrailResult { + /** True when the inbound text must be escalated without an LLM call. */ + escalate: boolean; + /** + * Why we escalated — always the `'keyword'` reason for guardrail + * hits (covers both configured keywords and explicit human requests). + * Omitted when `escalate` is false. + */ + reason?: AiEscalationReason; +} + +/** + * Phrases that explicitly ask to be handed to a person. Always checked, + * regardless of the account's configured keyword list. Each entry is a + * lowercase token sequence; we match it word-boundary anchored so + * "speak to an agent" hits but "user agent" / "real estate agent" + * patterns still rely on the surrounding verb to qualify. + * + * We intentionally pair a request verb ("talk to", "speak to", etc.) + * with a human noun ("human", "agent", "person", ...) rather than + * matching the noun alone — the bare nouns are already (optionally) in + * the configured keyword list, and matching them unconditionally here + * would double-cover and over-escalate (e.g. "your agent called me"). + */ +const HUMAN_REQUEST_VERBS = [ + "talk to", + "speak to", + "speak with", + "talk with", + "chat with", + "connect me to", + "connect me with", + "transfer me to", + "put me through to", + "get me", + "i want", + "i need", + "i'd like", + "i would like", + "can i talk to", + "can i speak to", + "let me talk to", + "let me speak to", +] as const; + +const HUMAN_REQUEST_NOUNS = [ + "a human", + "a real human", + "a real person", + "a person", + "a real agent", + "an agent", + "a live agent", + "a human agent", + "a representative", + "a rep", + "a manager", + "a real human being", + "customer service", + "customer support", + "support agent", + "someone real", + "a real one", +] as const; + +/** + * Escape a string for safe use inside a `RegExp`, and collapse runs of + * whitespace in the source phrase into `\s+` so multi-word phrases + * tolerate any amount/kind of inter-word whitespace in the input. + */ +function phraseToPattern(phrase: string): string { + return phrase + .trim() + .split(/\s+/) + .map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join("\\s+"); +} + +/** + * Build a single case-insensitive, word-boundary-anchored regex for a + * phrase. `\b` is used where the phrase edge is a word character; for + * phrases whose edges are non-word characters (e.g. "i'd like") we lean + * on the surrounding structure instead — but every entry here begins + * and ends with a word character, so `\b` on both sides is sound. + */ +function phraseRegex(phrase: string): RegExp { + return new RegExp(`\\b${phraseToPattern(phrase)}\\b`, "i"); +} + +/** + * Pre-built explicit-human-request matchers: the cross product of + * request verbs and human nouns ("talk to a human", "i need a manager", + * …). Built once at module load — the lists are static. + */ +const HUMAN_REQUEST_REGEXES: RegExp[] = (() => { + const out: RegExp[] = []; + for (const verb of HUMAN_REQUEST_VERBS) { + for (const noun of HUMAN_REQUEST_NOUNS) { + out.push(phraseRegex(`${verb} ${noun}`)); + } + } + return out; +})(); + +/** True when the text explicitly asks to be handed to a person. */ +function requestsHuman(text: string): boolean { + return HUMAN_REQUEST_REGEXES.some((re) => re.test(text)); +} + +/** + * True when any configured escalation keyword appears in the text as a + * whole word (case-insensitive). Blank / whitespace-only keywords are + * skipped so a stray empty string in the array can't match everything. + */ +function matchesKeyword(text: string, keywords: readonly string[]): boolean { + for (const keyword of keywords) { + if (typeof keyword !== "string") continue; + const trimmed = keyword.trim(); + if (trimmed.length === 0) continue; + if (phraseRegex(trimmed).test(text)) return true; + } + return false; +} + +/** + * Decide whether an inbound customer message must be escalated to a + * human BEFORE any LLM call. + * + * Returns `{ escalate: true, reason: 'keyword' }` when either: + * - the text contains a configured escalation keyword as a whole word + * (case-insensitive, word-boundary anchored), OR + * - the text is an explicit "talk to a human / agent / person" request. + * + * Otherwise returns `{ escalate: false }` and the caller proceeds to the + * model. Empty / non-string input and an empty keyword list are handled + * safely (no escalation, unless an explicit human request is present). + */ +export function shouldForceEscalate( + inboundText: string, + escalationKeywords: readonly string[], +): GuardrailResult { + if (typeof inboundText !== "string" || inboundText.trim().length === 0) { + return { escalate: false }; + } + + const keywords = Array.isArray(escalationKeywords) ? escalationKeywords : []; + + if (matchesKeyword(inboundText, keywords) || requestsHuman(inboundText)) { + return { escalate: true, reason: "keyword" }; + } + + return { escalate: false }; +} diff --git a/src/lib/ai/knowledge-base.ts b/src/lib/ai/knowledge-base.ts new file mode 100644 index 0000000000..873c8ac3fc --- /dev/null +++ b/src/lib/ai/knowledge-base.ts @@ -0,0 +1,78 @@ +/** + * Knowledge-base data access for the AI assistant (spec §4.2, §5). + * + * Two pieces: + * + * 1. `loadEnabledEntries` — a thin service-role read of the *enabled* + * `knowledge_base_entries` for one account. Strict per-account + * isolation is enforced here at the query level (`.eq("account_id", + * accountId)`) and again, structurally, in `prompt.ts` — there is no + * global/shared KB in v1 (spec §7.4, §12). + * + * 2. `estimateTokens` — the chars/4 heuristic used for the Settings size + * meter and the `token_estimate` cache column (spec §4.2, §9). Pure. + * + * The webhook path runs as service-role and bypasses RLS exactly as the + * Flows and automations engines already do (spec §4 / §12). + */ + +import { type KnowledgeBaseEntry } from "@/types"; + +import { supabaseAdmin } from "./admin-client"; + +/** + * Divisor for the rough token estimate. ~4 characters per token is the + * standard English heuristic; it intentionally over- rather than + * under-counts so the size meter trips conservatively (spec §4.2 / §9). + */ +const CHARS_PER_TOKEN = 4; + +/** + * Load the *enabled* knowledge-base entries for an account, ordered + * oldest→newest (`created_at` ascending) so the assembled prompt is + * stable and reproducible across calls. + * + * Disabled entries are excluded at the query level (spec §4.2: "Disabled + * entries are excluded from the prompt") — `prompt.ts` also filters + * defensively, but not sending them over the wire keeps the payload lean. + * + * Returns an empty array on any DB error so the caller can still proceed: + * an empty KB drives the model to `confident: false` rather than guessing + * (spec §7.2), which is the safe fail-to-human outcome (spec §1). + */ +export async function loadEnabledEntries( + accountId: string, +): Promise { + const db = supabaseAdmin(); + + const { data, error } = await db + .from("knowledge_base_entries") + .select("*") + .eq("account_id", accountId) + .eq("enabled", true) + .order("created_at", { ascending: true }); + + if (error) { + console.error("[ai] loadEnabledEntries error:", error.message); + return []; + } + + return (data as KnowledgeBaseEntry[] | null) ?? []; +} + +/** + * Estimate the token count of a piece of text with the chars/4 heuristic + * (spec §4.2). Pure and deterministic — no tokenizer, no I/O. Used for the + * cached `token_estimate` column and the Settings size meter. + * + * Returns `0` for empty / non-string input so callers can sum estimates + * across entries without guarding each one. Uses `Math.ceil` so any + * non-empty text counts as at least one token. + */ +export function estimateTokens(text: string): number { + if (typeof text !== "string" || text.length === 0) { + return 0; + } + + return Math.ceil(text.length / CHARS_PER_TOKEN); +} diff --git a/src/lib/ai/prompt.test.ts b/src/lib/ai/prompt.test.ts new file mode 100644 index 0000000000..a51679dbd3 --- /dev/null +++ b/src/lib/ai/prompt.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, it } from "vitest"; + +import { + HISTORY_MESSAGE_LIMIT, + buildMessages, + buildSystemBlocks, + type PromptHistoryMessage, +} from "./prompt"; +import { type AiAssistantConfig, type KnowledgeBaseEntry } from "@/types"; + +// ------------------------------------------------------------------ +// Fixtures +// ------------------------------------------------------------------ + +function makeConfig(overrides: Partial = {}): AiAssistantConfig { + return { + id: "cfg-1", + account_id: "acct-A", + enabled: true, + system_prompt: + "You are the support assistant for {business_name}. Answer only from the knowledge base.", + escalation_keywords: [], + business_name: "Acme Co", + model: "claude-sonnet-4-6", + daily_reply_cap: 500, + created_at: "2026-06-25T00:00:00Z", + updated_at: "2026-06-25T00:00:00Z", + ...overrides, + }; +} + +let kbSeq = 0; +function makeEntry(overrides: Partial = {}): KnowledgeBaseEntry { + kbSeq += 1; + return { + id: `kb-${kbSeq}`, + account_id: "acct-A", + title: `Title ${kbSeq}`, + content: `Content ${kbSeq}`, + source_type: "manual", + enabled: true, + created_at: "2026-06-25T00:00:00Z", + updated_at: "2026-06-25T00:00:00Z", + ...overrides, + }; +} + +/** The single cache-marked block is always the KB block (index 1). */ +function kbBlock(config: AiAssistantConfig, entries: KnowledgeBaseEntry[]) { + return buildSystemBlocks(config, entries)[1]; +} + +// ------------------------------------------------------------------ +// buildSystemBlocks — persona block +// ------------------------------------------------------------------ + +describe("buildSystemBlocks — persona block", () => { + it("emits exactly two text blocks: persona then KB", () => { + const blocks = buildSystemBlocks(makeConfig(), [makeEntry()]); + expect(blocks).toHaveLength(2); + expect(blocks[0].type).toBe("text"); + expect(blocks[1].type).toBe("text"); + }); + + it("substitutes {business_name} into the persona prompt", () => { + const blocks = buildSystemBlocks( + makeConfig({ business_name: "Globex" }), + [], + ); + expect(blocks[0].text).toContain("Globex"); + expect(blocks[0].text).not.toContain("{business_name}"); + }); + + it("replaces every {business_name} occurrence, not just the first", () => { + const blocks = buildSystemBlocks( + makeConfig({ + system_prompt: "{business_name} cares. Welcome to {business_name}.", + business_name: "Initech", + }), + [], + ); + expect(blocks[0].text).toBe("Initech cares. Welcome to Initech."); + }); + + it("falls back to a neutral name when business_name is missing/blank", () => { + for (const business_name of [undefined, "", " "]) { + const blocks = buildSystemBlocks(makeConfig({ business_name }), []); + expect(blocks[0].text).not.toContain("{business_name}"); + expect(blocks[0].text).toContain("our business"); + } + }); + + it("does NOT cache-mark the persona block (KB is the breakpoint)", () => { + const blocks = buildSystemBlocks(makeConfig(), [makeEntry()]); + expect(blocks[0].cache_control).toBeUndefined(); + }); +}); + +// ------------------------------------------------------------------ +// buildSystemBlocks — KB block assembly +// ------------------------------------------------------------------ + +describe("buildSystemBlocks — KB assembly", () => { + it("renders each entry as '## {title}\\n{content}'", () => { + const entry = makeEntry({ title: "Refund policy", content: "30 days." }); + const text = kbBlock(makeConfig(), [entry]).text; + expect(text).toContain("## Refund policy\n30 days."); + }); + + it("wraps the KB in clear start/end delimiters", () => { + const text = kbBlock(makeConfig(), [makeEntry()]).text; + expect(text).toContain("KNOWLEDGE BASE (START)"); + expect(text).toContain("KNOWLEDGE BASE (END)"); + // Delimiters bracket the body. + expect(text.indexOf("(START)")).toBeLessThan(text.indexOf("(END)")); + }); + + it("emits a non-empty, still-delimited block when there are no entries", () => { + const text = kbBlock(makeConfig(), []).text; + expect(text).toContain("KNOWLEDGE BASE (START)"); + expect(text).toContain("KNOWLEDGE BASE (END)"); + expect(text.trim().length).toBeGreaterThan(0); + }); +}); + +// ------------------------------------------------------------------ +// buildSystemBlocks — cache_control placement (spec §7.2) +// ------------------------------------------------------------------ + +describe("buildSystemBlocks — cache_control placement", () => { + it("marks the KB block with cache_control { type: 'ephemeral' }", () => { + const block = kbBlock(makeConfig(), [makeEntry()]); + expect(block.cache_control).toEqual({ type: "ephemeral" }); + }); + + it("marks the KB block even when empty (stable cacheable prefix)", () => { + expect(kbBlock(makeConfig(), []).cache_control).toEqual({ + type: "ephemeral", + }); + }); + + it("places cache_control on exactly one block — the last (KB) one", () => { + const blocks = buildSystemBlocks(makeConfig(), [makeEntry(), makeEntry()]); + const marked = blocks.filter((b) => b.cache_control !== undefined); + expect(marked).toHaveLength(1); + expect(marked[0]).toBe(blocks[blocks.length - 1]); + }); +}); + +// ------------------------------------------------------------------ +// Account isolation invariant (spec §7.4): only-and-all provided +// entries appear, in order. The function is blind to account_id. +// ------------------------------------------------------------------ + +describe("buildSystemBlocks — account isolation (only-and-all, in order)", () => { + it("includes every provided enabled entry, in the order given", () => { + const entries = [ + makeEntry({ title: "A", content: "alpha" }), + makeEntry({ title: "B", content: "bravo" }), + makeEntry({ title: "C", content: "charlie" }), + ]; + const text = kbBlock(makeConfig(), entries).text; + expect(text.indexOf("## A")).toBeGreaterThanOrEqual(0); + expect(text.indexOf("## A")).toBeLessThan(text.indexOf("## B")); + expect(text.indexOf("## B")).toBeLessThan(text.indexOf("## C")); + }); + + it("includes ONLY the provided entries — nothing else leaks in", () => { + const entries = [ + makeEntry({ title: "Hours", content: "9 to 5" }), + makeEntry({ title: "Shipping", content: "Free over $50" }), + ]; + const text = kbBlock(makeConfig(), entries).text; + // Count the entry headers: exactly the two we supplied. + expect(text.match(/^## /gm)).toHaveLength(2); + expect(text).toContain("## Hours"); + expect(text).toContain("## Shipping"); + }); + + it("does NOT filter, reorder, or dedup by account_id — it is account-blind", () => { + // Two entries tagged with DIFFERENT account_ids. The caller is + // responsible for pre-filtering; if it hands us a foreign-account + // entry, we faithfully include it (and the caller's test catches the + // leak upstream). This asserts the function adds no hidden filter + // that could mask a caller bug, and preserves given order verbatim. + const entries = [ + makeEntry({ account_id: "acct-A", title: "Mine", content: "x" }), + makeEntry({ account_id: "acct-OTHER", title: "Theirs", content: "y" }), + ]; + const text = kbBlock(makeConfig({ account_id: "acct-A" }), entries).text; + expect(text.match(/^## /gm)).toHaveLength(2); + expect(text.indexOf("## Mine")).toBeLessThan(text.indexOf("## Theirs")); + }); + + it("excludes disabled entries but preserves the order of the rest", () => { + const entries = [ + makeEntry({ title: "One", enabled: true }), + makeEntry({ title: "Two", enabled: false }), + makeEntry({ title: "Three", enabled: true }), + ]; + const text = kbBlock(makeConfig(), entries).text; + expect(text).toContain("## One"); + expect(text).not.toContain("## Two"); + expect(text).toContain("## Three"); + expect(text.match(/^## /gm)).toHaveLength(2); + expect(text.indexOf("## One")).toBeLessThan(text.indexOf("## Three")); + }); +}); + +// ------------------------------------------------------------------ +// buildMessages — role mapping & ordering (spec §7.3) +// ------------------------------------------------------------------ + +describe("buildMessages — role mapping & ordering", () => { + it("maps customer→user and agent/bot→assistant, oldest→newest, then appends inbound", () => { + const history: PromptHistoryMessage[] = [ + { sender_type: "customer", content_text: "hi" }, + { sender_type: "bot", content_text: "hello, how can I help?" }, + { sender_type: "agent", content_text: "this is a human agent" }, + ]; + expect(buildMessages(history, "what are your hours?")).toEqual([ + { role: "user", content: "hi" }, + { role: "assistant", content: "hello, how can I help?" }, + { role: "assistant", content: "this is a human agent" }, + { role: "user", content: "what are your hours?" }, + ]); + }); + + it("always appends the inbound text as the final user turn", () => { + const result = buildMessages([], "first contact"); + expect(result).toEqual([{ role: "user", content: "first contact" }]); + }); + + it("preserves history order verbatim (no sorting)", () => { + const history: PromptHistoryMessage[] = [ + { sender_type: "customer", content_text: "msg-1" }, + { sender_type: "customer", content_text: "msg-2" }, + { sender_type: "customer", content_text: "msg-3" }, + ]; + const contents = buildMessages(history, "msg-4").map((m) => m.content); + expect(contents).toEqual(["msg-1", "msg-2", "msg-3", "msg-4"]); + }); + + it("drops history turns with empty/blank/missing text", () => { + const history: PromptHistoryMessage[] = [ + { sender_type: "customer", content_text: "keep me" }, + { sender_type: "bot", content_text: "" }, + { sender_type: "bot", content_text: " " }, + { sender_type: "customer", content_text: null }, + { sender_type: "customer" }, + ]; + expect(buildMessages(history, "inbound")).toEqual([ + { role: "user", content: "keep me" }, + { role: "user", content: "inbound" }, + ]); + }); + + it("treats a non-array history as empty", () => { + expect( + buildMessages(undefined as unknown as PromptHistoryMessage[], "hi"), + ).toEqual([{ role: "user", content: "hi" }]); + }); +}); + +// ------------------------------------------------------------------ +// buildMessages — history cap (spec §7.3 / §13) +// ------------------------------------------------------------------ + +describe("buildMessages — history cap", () => { + it("keeps only the last HISTORY_MESSAGE_LIMIT history turns + the inbound", () => { + const history: PromptHistoryMessage[] = Array.from( + { length: 25 }, + (_unused, i) => ({ + sender_type: "customer" as const, + content_text: `h${i}`, + }), + ); + + const result = buildMessages(history, "INBOUND"); + + // N history turns + the appended inbound. + expect(result).toHaveLength(HISTORY_MESSAGE_LIMIT + 1); + // The kept turns are the most-recent N (h15..h24), in order. + expect(result[0].content).toBe(`h${25 - HISTORY_MESSAGE_LIMIT}`); + expect(result[HISTORY_MESSAGE_LIMIT - 1].content).toBe("h24"); + // Inbound is last. + expect(result[result.length - 1]).toEqual({ + role: "user", + content: "INBOUND", + }); + }); + + it("applies the cap AFTER dropping blank turns (caps real turns only)", () => { + // 12 real turns interleaved with blanks. After dropping blanks we + // have 12 usable; the cap keeps the last 10 of those. + const history: PromptHistoryMessage[] = []; + for (let i = 0; i < 12; i += 1) { + history.push({ sender_type: "customer", content_text: `real-${i}` }); + history.push({ sender_type: "bot", content_text: " " }); // blank + } + + const result = buildMessages(history, "INBOUND"); + + expect(result).toHaveLength(HISTORY_MESSAGE_LIMIT + 1); + expect(result[0].content).toBe("real-2"); // 12 real, last 10 → real-2..real-11 + expect(result[HISTORY_MESSAGE_LIMIT - 1].content).toBe("real-11"); + }); + + it("does not pad when history is shorter than the cap", () => { + const history: PromptHistoryMessage[] = [ + { sender_type: "customer", content_text: "only one" }, + ]; + expect(buildMessages(history, "two")).toHaveLength(2); + }); +}); diff --git a/src/lib/ai/prompt.ts b/src/lib/ai/prompt.ts new file mode 100644 index 0000000000..c9b8bc5588 --- /dev/null +++ b/src/lib/ai/prompt.ts @@ -0,0 +1,187 @@ +/** + * Prompt assembly for the WhatsApp AI assistant (spec §7). + * + * Pure and fully deterministic: no I/O, no clock, no randomness, no + * network. Given an account's config + its KB entries + the recent + * conversation history + the inbound text, it builds the two pieces the + * Anthropic wrapper hands to `messages.create`: + * + * 1. `buildSystemBlocks` → the `system` array. A stable + * persona/instructions text block first, then the assembled + * knowledge-base block carrying `cache_control: { type: 'ephemeral' }` + * so it bills at ~10% on cache hits (spec §7.2, §13). Ordering is + * deliberate: stable instructions first, the most-cacheable + * last-stable content (the KB) last. + * 2. `buildMessages` → the `messages` array: the last N (default 10) + * history turns mapped oldest→newest to `user`/`assistant` roles, + * then the inbound customer text appended as the final `user` turn. + * History is NOT cached — it changes every turn (spec §7.3). + * + * Account isolation (spec §7.4, critical): KB entries arrive + * ALREADY filtered by `account_id`. This module is deliberately blind to + * tenancy — it includes exactly the entries it is handed, in the order + * it is handed them, and never reaches for a global/shared KB. The unit + * test asserts that "only and all provided entries appear, in order", + * which is the testable form of the isolation guarantee. + * + * The shapes returned are the canonical Anthropic SDK param types + * (`Anthropic.TextBlockParam`, `Anthropic.MessageParam`) so the wrapper + * can pass them straight through with no remapping. + */ + +import type Anthropic from "@anthropic-ai/sdk"; + +import { type AiAssistantConfig, type KnowledgeBaseEntry } from "@/types"; + +/** + * Default number of conversation history turns to include for context + * (spec §7.3 / §13). Oldest→newest; the cap keeps token cost bounded. + */ +export const HISTORY_MESSAGE_LIMIT = 10; + +/** + * Delimiters around the assembled knowledge base. Clear, unambiguous + * fences (spec §7.2: "wrapped in clear delimiters") so the model can + * tell exactly where the grounding material begins and ends and can't + * confuse KB content with instructions. + */ +const KB_HEADER = "===== KNOWLEDGE BASE (START) ====="; +const KB_FOOTER = "===== KNOWLEDGE BASE (END) ====="; + +/** + * Shown in place of the KB body when an account has no enabled entries. + * The block is still emitted (and still cache-marked) so the prompt + * structure is stable; the empty marker tells the model it has nothing + * to ground on — which, combined with the "answer ONLY from the KB" + * system prompt, drives it to `confident: false` rather than guessing. + */ +const KB_EMPTY_PLACEHOLDER = "(No knowledge base entries are available.)"; + +/** + * Minimal history-message shape consumed by `buildMessages`. Decoupled + * from the DB `Message` row on purpose — the caller projects whatever it + * loaded down to just the sender + text, so this stays a pure function + * with no dependency on the persistence layer. + */ +export interface PromptHistoryMessage { + /** Who sent it: `customer` → `user`; `agent` / `bot` → `assistant`. */ + sender_type: "customer" | "agent" | "bot"; + /** The text body. Empty / missing entries are dropped (see below). */ + content_text?: string | null; +} + +/** + * Substitute the `{business_name}` placeholder in the system prompt with + * the configured business name (spec §7.2). Falls back to a neutral + * phrase when no name is set so the prompt never renders a literal + * `{business_name}` to the model. `replace` with a string pattern only + * swaps the first occurrence, so we use a global regex. + */ +function renderPersona(config: AiAssistantConfig): string { + const businessName = + typeof config.business_name === "string" && + config.business_name.trim().length > 0 + ? config.business_name.trim() + : "our business"; + + return config.system_prompt.replace(/\{business_name\}/g, businessName); +} + +/** + * Assemble the knowledge-base text from the enabled entries, in the + * exact order received. Each entry renders as `## {title}\n{content}` + * (spec §7.2); entries are separated by a blank line for readability. + * + * Disabled entries are skipped (spec §4.2: "Disabled entries are + * excluded from the prompt"). Order is otherwise preserved verbatim — + * no sorting, no dedup — so the assembled block is a faithful, ordered + * projection of exactly what the caller passed. + */ +function assembleKnowledgeBase(entries: readonly KnowledgeBaseEntry[]): string { + const rendered = entries + .filter((entry) => entry.enabled) + .map((entry) => `## ${entry.title}\n${entry.content}`); + + const body = rendered.length > 0 ? rendered.join("\n\n") : KB_EMPTY_PLACEHOLDER; + + return `${KB_HEADER}\n${body}\n${KB_FOOTER}`; +} + +/** + * Build the Anthropic `system` array (spec §7.2). + * + * Returns two text blocks, in order: + * 1. The stable persona/instructions block (`config.system_prompt` with + * `{business_name}` substituted). NOT cache-marked here — the KB is + * the cache breakpoint, and a breakpoint caches everything up to and + * including it, so a single marker on the trailing KB block covers + * the whole stable prefix. + * 2. The assembled knowledge base, carrying + * `cache_control: { type: 'ephemeral' }`. + * + * KB entries MUST already be filtered to the calling account (spec + * §7.4). This function includes exactly the entries it is given. + */ +export function buildSystemBlocks( + config: AiAssistantConfig, + kbEntries: readonly KnowledgeBaseEntry[], +): Anthropic.TextBlockParam[] { + const persona: Anthropic.TextBlockParam = { + type: "text", + text: renderPersona(config), + }; + + const knowledgeBase: Anthropic.TextBlockParam = { + type: "text", + text: assembleKnowledgeBase(kbEntries), + cache_control: { type: "ephemeral" }, + }; + + return [persona, knowledgeBase]; +} + +/** + * Map a stored sender type to an Anthropic message role (spec §7.3): + * the customer is the `user`; everything we send back (a human `agent` + * or the `bot` itself) is the `assistant`. + */ +function roleFor(senderType: PromptHistoryMessage["sender_type"]): "user" | "assistant" { + return senderType === "customer" ? "user" : "assistant"; +} + +/** + * Build the Anthropic `messages` array (spec §7.3). + * + * Takes the conversation `history` (any length, oldest→newest), keeps + * the last `HISTORY_MESSAGE_LIMIT` turns, maps each to a `user` / + * `assistant` role, then appends `inboundText` as the final `user` + * message. History turns with empty / whitespace-only text are dropped + * (an empty `content` is not a valid message turn) BEFORE the cap is + * applied, so the cap counts only real turns. + * + * The inbound message is always appended verbatim as the last `user` + * turn — it is the message we are answering and must never be trimmed. + */ +export function buildMessages( + history: readonly PromptHistoryMessage[], + inboundText: string, +): Anthropic.MessageParam[] { + const safeHistory = Array.isArray(history) ? history : []; + + const usable = safeHistory.filter( + (msg): msg is PromptHistoryMessage => + typeof msg?.content_text === "string" && + msg.content_text.trim().length > 0, + ); + + const recent = usable.slice(-HISTORY_MESSAGE_LIMIT); + + const messages: Anthropic.MessageParam[] = recent.map((msg) => ({ + role: roleFor(msg.sender_type), + content: msg.content_text as string, + })); + + messages.push({ role: "user", content: inboundText }); + + return messages; +} diff --git a/src/lib/ai/reply.test.ts b/src/lib/ai/reply.test.ts new file mode 100644 index 0000000000..d666cbbc4b --- /dev/null +++ b/src/lib/ai/reply.test.ts @@ -0,0 +1,470 @@ +/** + * Orchestration tests for `maybeReplyToInbound` (spec §6, §14). + * + * The Anthropic SDK and the service-role Supabase admin client are mocked; + * `callAssistant` is injected per call (spec §14). No network, no DB. We + * drive the engine entirely through programmable mock state and assert the + * terminal effects: + * disabled → skip (no model call, no send, no escalate) + * human-owned → skip + * keyword → escalate, model NOT called + * cap reached → escalate, model NOT called + * low confidence → escalate + log escalated + * happy path → send + log replied (with usage + latency) + * thrown error → escalate + log error (never throws) + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { type AiAssistantConfig } from '@/types' + +// ---- Programmable service-role admin-client mock -------------------------- +// Lives in a hoisted block so the vi.mock factory below can close over it. +// Mirrors the builder-style mock in src/lib/automations/engine.test.ts. +const h = vi.hoisted(() => ({ + state: { + config: null as AiAssistantConfig | null, + conversation: { ai_handling: true } as { ai_handling?: boolean } | null, + contactPhone: '+254700111222' as string | null, + kbEntries: [] as Record[], + history: [] as Record[], + repliesTodayCount: 0 as number, + // Force an error on a specific table read to exercise the catch path. + failTable: null as string | null, + // Captured effects. + insertedLogs: [] as Record[], + fromCalls: [] as string[], + }, +})) + +vi.mock('./admin-client', () => { + const { state } = h + + function resolve(ops: { + table: string + type: string + payload?: unknown + head: boolean + }): { data: unknown; error: unknown; count?: number } { + const { table, type } = ops + + if (state.failTable === table) { + return { data: null, error: { message: `forced failure on ${table}` } } + } + + if (table === 'ai_assistant_config') { + return { data: state.config, error: null } + } + if (table === 'conversations') { + if (type === 'select') return { data: state.conversation, error: null } + return { data: null, error: null } // update (escalate is mocked, but be safe) + } + if (table === 'contacts') { + return { data: state.contactPhone ? { phone: state.contactPhone } : null, error: null } + } + if (table === 'knowledge_base_entries') { + return { data: state.kbEntries, error: null } + } + if (table === 'messages') { + return { data: state.history, error: null } + } + if (table === 'ai_reply_log') { + if (type === 'insert') { + state.insertedLogs.push((ops.payload as Record) ?? {}) + return { data: null, error: null } + } + // count query (head: true) + return { data: null, error: null, count: state.repliesTodayCount } + } + return { data: null, error: null } + } + + function builder(table: string) { + const ops = { table, type: 'select', payload: undefined as unknown, head: false } + const b: Record = { + select: (_cols?: unknown, opts?: { head?: boolean }) => { + if (opts?.head) ops.head = true + return b + }, + insert: (p: unknown) => ((ops.type = 'insert'), (ops.payload = p), b), + update: (p: unknown) => ((ops.type = 'update'), (ops.payload = p), b), + eq: () => b, + gte: () => b, + order: () => b, + limit: () => Promise.resolve(resolve(ops)), + maybeSingle: () => Promise.resolve(resolve(ops)), + single: () => Promise.resolve(resolve(ops)), + then: (onF: (v: unknown) => unknown, onR?: (e: unknown) => unknown) => + Promise.resolve(resolve(ops)).then(onF, onR), + } + return b + } + + return { + supabaseAdmin: () => ({ + from: (t: string) => { + h.state.fromCalls.push(t) + return builder(t) + }, + }), + } +}) + +// Mock the network-touching siblings so we assert calls without Meta I/O. +const sendAiReply = vi.fn( + async (_args: unknown) => ({ whatsapp_message_id: 'wamid.1' }), +) +const escalateConversation = vi.fn(async (_args: unknown) => {}) +vi.mock('./send', () => ({ sendAiReply: (a: unknown) => sendAiReply(a) })) +vi.mock('./escalate', () => ({ + escalateConversation: (a: unknown) => escalateConversation(a), +})) + +// Guard: the real Anthropic SDK must never be constructed in tests. +vi.mock('@anthropic-ai/sdk', () => ({ + default: class { + constructor() { + throw new Error('Anthropic SDK was constructed in a test') + } + }, +})) + +import { maybeReplyToInbound } from './reply' + +// ---- Fixtures ------------------------------------------------------------- + +function config(overrides: Partial = {}): AiAssistantConfig { + return { + id: 'cfg-1', + account_id: 'acct-1', + enabled: true, + system_prompt: 'You are support for {business_name}.', + handoff_message: 'A human will be with you shortly.', + escalation_keywords: ['refund', 'lawyer'], + business_name: 'Acme', + model: 'claude-sonnet-4-6', + daily_reply_cap: 500, + created_at: '2026-06-25T00:00:00Z', + updated_at: '2026-06-25T00:00:00Z', + ...overrides, + } +} + +const BASE_ARGS = { + accountId: 'acct-1', + conversationId: 'conv-1', + contactId: 'contact-1', + messageId: 'msg-1', + accessToken: 'token-xyz', + phoneNumberId: 'pn-1', +} + +/** A confident, non-empty model verdict — drives the happy path. */ +function confidentResult() { + return { + answer: 'We are open 9am to 5pm, Monday to Friday.', + confident: true, + reason: 'Opening hours are in the KB.', + usage: { input_tokens: 1200, output_tokens: 40, cache_read_input_tokens: 1000 }, + } +} + +/** A not-confident verdict — drives the low-confidence escalation. */ +function unsureResult() { + return { + answer: '', + confident: false, + reason: 'KB does not cover this.', + usage: { input_tokens: 1200, output_tokens: 10, cache_read_input_tokens: 1000 }, + } +} + +beforeEach(() => { + h.state.config = config() + h.state.conversation = { ai_handling: true } + h.state.contactPhone = '+254700111222' + h.state.kbEntries = [ + { + id: 'kb-1', + account_id: 'acct-1', + title: 'Hours', + content: 'Open 9-5 Mon-Fri.', + source_type: 'manual', + enabled: true, + created_at: '2026-06-01T00:00:00Z', + updated_at: '2026-06-01T00:00:00Z', + }, + ] + h.state.history = [] + h.state.repliesTodayCount = 0 + h.state.failTable = null + h.state.insertedLogs = [] + h.state.fromCalls = [] + sendAiReply.mockClear() + escalateConversation.mockClear() +}) + +function lastLog() { + return h.state.insertedLogs[h.state.insertedLogs.length - 1] +} + +// ---- Tests ---------------------------------------------------------------- + +describe('maybeReplyToInbound — disabled → skip', () => { + it('skips when no config row exists (AI never configured)', async () => { + h.state.config = null + const callAssistant = vi.fn() + + await maybeReplyToInbound({ ...BASE_ARGS, inboundText: 'hi', callAssistant }) + + expect(callAssistant).not.toHaveBeenCalled() + expect(sendAiReply).not.toHaveBeenCalled() + expect(escalateConversation).not.toHaveBeenCalled() + expect(lastLog()).toMatchObject({ decision: 'skipped', reason: 'no_config' }) + }) + + it('skips when config exists but enabled is false', async () => { + h.state.config = config({ enabled: false }) + const callAssistant = vi.fn() + + await maybeReplyToInbound({ ...BASE_ARGS, inboundText: 'hi', callAssistant }) + + expect(callAssistant).not.toHaveBeenCalled() + expect(sendAiReply).not.toHaveBeenCalled() + expect(escalateConversation).not.toHaveBeenCalled() + expect(lastLog()).toMatchObject({ decision: 'skipped', reason: 'disabled' }) + }) +}) + +describe('maybeReplyToInbound — human-owned → skip', () => { + it('skips when the conversation has ai_handling=false (a human took over)', async () => { + h.state.conversation = { ai_handling: false } + const callAssistant = vi.fn() + + await maybeReplyToInbound({ ...BASE_ARGS, inboundText: 'hello', callAssistant }) + + expect(callAssistant).not.toHaveBeenCalled() + expect(sendAiReply).not.toHaveBeenCalled() + expect(escalateConversation).not.toHaveBeenCalled() + expect(lastLog()).toMatchObject({ decision: 'skipped', reason: 'human_takeover' }) + }) +}) + +describe('maybeReplyToInbound — keyword → escalate (no model call)', () => { + it('escalates on a configured keyword WITHOUT calling the model', async () => { + const callAssistant = vi.fn() + + await maybeReplyToInbound({ + ...BASE_ARGS, + inboundText: 'I want a refund please', + callAssistant, + }) + + // The model must NOT be called on a guardrail hit (spec §6). + expect(callAssistant).not.toHaveBeenCalled() + expect(sendAiReply).not.toHaveBeenCalled() + expect(escalateConversation).toHaveBeenCalledTimes(1) + expect(escalateConversation).toHaveBeenCalledWith( + expect.objectContaining({ conversationId: 'conv-1', reason: 'keyword' }), + ) + expect(lastLog()).toMatchObject({ decision: 'escalated', reason: 'keyword' }) + }) + + it('escalates on an explicit "talk to a human" request without the model', async () => { + const callAssistant = vi.fn() + + await maybeReplyToInbound({ + ...BASE_ARGS, + inboundText: 'Can I talk to a human?', + callAssistant, + }) + + expect(callAssistant).not.toHaveBeenCalled() + expect(escalateConversation).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'keyword' }), + ) + }) +}) + +describe('maybeReplyToInbound — daily cap → escalate', () => { + it('escalates with reason cap_reached when today’s replies hit the cap', async () => { + h.state.config = config({ daily_reply_cap: 5 }) + h.state.repliesTodayCount = 5 + const callAssistant = vi.fn() + + await maybeReplyToInbound({ + ...BASE_ARGS, + inboundText: 'what are your hours?', + callAssistant, + }) + + expect(callAssistant).not.toHaveBeenCalled() + expect(sendAiReply).not.toHaveBeenCalled() + expect(escalateConversation).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'cap_reached' }), + ) + expect(lastLog()).toMatchObject({ decision: 'escalated', reason: 'cap_reached' }) + }) + + it('does NOT escalate on the cap when still below it', async () => { + h.state.config = config({ daily_reply_cap: 500 }) + h.state.repliesTodayCount = 499 + const callAssistant = vi.fn(async () => confidentResult()) + + await maybeReplyToInbound({ + ...BASE_ARGS, + inboundText: 'what are your hours?', + callAssistant, + }) + + expect(callAssistant).toHaveBeenCalledTimes(1) + expect(sendAiReply).toHaveBeenCalledTimes(1) + }) +}) + +describe('maybeReplyToInbound — low confidence → escalate', () => { + it('escalates with low_confidence and logs the model usage', async () => { + const callAssistant = vi.fn(async () => unsureResult()) + + await maybeReplyToInbound({ + ...BASE_ARGS, + inboundText: 'do you offer financing?', + callAssistant, + }) + + expect(callAssistant).toHaveBeenCalledTimes(1) + expect(sendAiReply).not.toHaveBeenCalled() + expect(escalateConversation).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'low_confidence' }), + ) + expect(lastLog()).toMatchObject({ + decision: 'escalated', + reason: 'low_confidence', + confident: false, + model: 'claude-sonnet-4-6', + input_tokens: 1200, + output_tokens: 10, + cache_read_tokens: 1000, + }) + }) +}) + +describe('maybeReplyToInbound — happy path → send + log', () => { + it('sends the reply and logs decision=replied with usage + latency', async () => { + const callAssistant = vi.fn(async () => confidentResult()) + + await maybeReplyToInbound({ + ...BASE_ARGS, + inboundText: 'what are your opening hours?', + callAssistant, + }) + + expect(callAssistant).toHaveBeenCalledTimes(1) + // Sent over the bot path with the model's answer text. + expect(sendAiReply).toHaveBeenCalledTimes(1) + expect(sendAiReply).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: 'acct-1', + conversationId: 'conv-1', + contactId: 'contact-1', + text: 'We are open 9am to 5pm, Monday to Friday.', + accessToken: 'token-xyz', + phoneNumberId: 'pn-1', + }), + ) + expect(escalateConversation).not.toHaveBeenCalled() + + const log = lastLog() + expect(log).toMatchObject({ + decision: 'replied', + confident: true, + model: 'claude-sonnet-4-6', + input_tokens: 1200, + output_tokens: 40, + cache_read_tokens: 1000, + }) + expect(typeof log.latency_ms).toBe('number') + expect(log.latency_ms as number).toBeGreaterThanOrEqual(0) + }) + + it('passes the assembled system + messages to the model wrapper', async () => { + const callAssistant = vi.fn(async (_args: unknown) => confidentResult()) + + await maybeReplyToInbound({ + ...BASE_ARGS, + inboundText: 'what are your opening hours?', + callAssistant, + }) + + const arg = callAssistant.mock.calls[0][0] as { + model: string + system: { text: string }[] + messages: { role: string; content: string }[] + } + expect(arg.model).toBe('claude-sonnet-4-6') + // KB block is the last system block and carries the entry content. + expect(arg.system[arg.system.length - 1].text).toContain('Open 9-5 Mon-Fri.') + // The inbound text is the final user message. + const last = arg.messages[arg.messages.length - 1] + expect(last).toEqual({ role: 'user', content: 'what are your opening hours?' }) + }) +}) + +describe('maybeReplyToInbound — thrown error → escalate (never throws)', () => { + it('escalates with reason=error and logs when the model call throws', async () => { + const callAssistant = vi.fn(async () => { + throw new Error('anthropic 529 overloaded') + }) + + await expect( + maybeReplyToInbound({ + ...BASE_ARGS, + inboundText: 'what are your hours?', + callAssistant, + }), + ).resolves.toBeUndefined() + + expect(sendAiReply).not.toHaveBeenCalled() + expect(escalateConversation).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'error' }), + ) + expect(lastLog()).toMatchObject({ decision: 'error' }) + expect((lastLog().reason as string) ?? '').toContain('anthropic 529 overloaded') + }) + + it('escalates on a send failure (sent decision but Meta/DB threw)', async () => { + sendAiReply.mockRejectedValueOnce(new Error('sent to Meta but DB insert failed')) + const callAssistant = vi.fn(async () => confidentResult()) + + await maybeReplyToInbound({ + ...BASE_ARGS, + inboundText: 'what are your hours?', + callAssistant, + }) + + expect(escalateConversation).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'error' }), + ) + expect(lastLog()).toMatchObject({ decision: 'error' }) + }) + + it('never throws even if the cap-count read fails', async () => { + h.state.failTable = 'ai_reply_log' + const callAssistant = vi.fn(async () => confidentResult()) + + // The first ai_reply_log access is the cap count (head select); forcing + // it to error sends the run down the catch path. Must resolve, not throw. + await expect( + maybeReplyToInbound({ + ...BASE_ARGS, + inboundText: 'what are your hours?', + callAssistant, + }), + ).resolves.toBeUndefined() + + expect(callAssistant).not.toHaveBeenCalled() + expect(escalateConversation).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'error' }), + ) + }) +}) diff --git a/src/lib/ai/reply.ts b/src/lib/ai/reply.ts new file mode 100644 index 0000000000..c54f0304ad --- /dev/null +++ b/src/lib/ai/reply.ts @@ -0,0 +1,386 @@ +/** + * The orchestrator (spec §5: `reply.ts`, §6 runtime flow). + * + * `maybeReplyToInbound` is the single entry point the WhatsApp webhook + * calls (fire-and-forget, after the inbound `messages` row is stored and + * the Flows dispatch resolves `!flowConsumed`). It composes the rest of + * `src/lib/ai/` into the gated decision pipeline of spec §6: + * + * load config → !enabled ─▶ skip + * load conversation → ai_handling === false ─▶ skip (human owns it) + * guardrails → keyword / "talk to human" ─▶ escalate(keyword), NO LLM call + * daily cap → today's `replied` >= cap ─▶ escalate(cap_reached) + * build prompt + call Claude (forced submit_answer) + * decide(result) → reply ─▶ send(bot) + log replied + * → else ─▶ escalate(low_confidence) + log escalated + * ANY throw anywhere ─▶ escalate(error) + log error + * + * The whole body is wrapped in try/catch and this function NEVER throws — + * any failure (missing API key, Anthropic error, malformed tool_use, DB + * hiccup, send failure) falls through to a fail-safe escalation to a + * human, honouring the "any doubt, any error → escalate, never go silent" + * bias of spec §1. The webhook already runs this off the ack path, so a + * slow or failing AI call can never delay the Meta 200. + * + * Everything that talks to the network or the DB lives in the thin + * sibling modules (`anthropic`, `send`, `escalate`, `config`, + * `knowledge-base`) and the service-role admin client — all mocked in + * `reply.test.ts`. `callAssistant` is injectable so the tests drive the + * model verdict without a network round-trip (spec §14). + */ + +import { type AiReplyDecision } from '@/types' + +import { supabaseAdmin } from './admin-client' +import { + callAssistant as defaultCallAssistant, + type CallAssistantArgs, + type CallAssistantResult, +} from './anthropic' +import { loadAiConfig } from './config' +import { decide } from './decide' +import { escalateConversation } from './escalate' +import { shouldForceEscalate } from './guardrails' +import { loadEnabledEntries } from './knowledge-base' +import { buildMessages, buildSystemBlocks, type PromptHistoryMessage } from './prompt' +import { sendAiReply } from './send' + +/** Arguments for {@link maybeReplyToInbound}. */ +export interface MaybeReplyToInboundArgs { + /** Tenancy key — scopes config, KB, cap count, contact + reply log. */ + accountId: string + /** Conversation the inbound message belongs to. */ + conversationId: string + /** Contact who sent the inbound message (send target + phone lookup). */ + contactId: string + /** The inbound `messages.id` that triggered this evaluation (audit). */ + messageId: string + /** The inbound customer text we are deciding how to answer. */ + inboundText: string + /** Decrypted Meta access token, resolved by the webhook caller. */ + accessToken: string + /** Meta phone-number id, resolved by the webhook caller. */ + phoneNumberId: string + /** + * Injectable model wrapper (default the real {@link defaultCallAssistant}). + * Tests pass a stub so no network call happens (spec §14). + */ + callAssistant?: (args: CallAssistantArgs) => Promise +} + +/** + * How many recent conversation messages to load for the model's context. + * The prompt assembler caps this again at {@link HISTORY_MESSAGE_LIMIT} + * (default 10) after dropping empty turns; we over-fetch slightly so a + * couple of media/empty rows don't starve the usable history window. + */ +const HISTORY_FETCH_LIMIT = 20 + +/** Fields recorded for one `ai_reply_log` row, beyond the always-set ones. */ +interface LogAiReplyArgs { + accountId: string + conversationId: string + messageId: string + decision: AiReplyDecision + /** Model self-report; omit (null) when the decision was made pre-LLM. */ + confident?: boolean + /** Escalation reason / error summary. */ + reason?: string + model?: string + input_tokens?: number + output_tokens?: number + cache_read_tokens?: number + latency_ms?: number +} + +/** + * Insert one audit row into `ai_reply_log` via the service-role client + * (RLS-bypassing, like the rest of the AI engine — spec §4.4 inserts are + * service-role only). Best-effort: a logging failure is swallowed so it + * can never turn a successful reply/escalation into a thrown error. The + * orchestrator's own try/catch is the safety net for the real work. + */ +async function logAiReply(args: LogAiReplyArgs): Promise { + try { + const db = supabaseAdmin() + const { error } = await db.from('ai_reply_log').insert({ + account_id: args.accountId, + conversation_id: args.conversationId, + message_id: args.messageId, + decision: args.decision, + confident: args.confident ?? null, + reason: args.reason ?? null, + model: args.model ?? null, + input_tokens: args.input_tokens ?? null, + output_tokens: args.output_tokens ?? null, + cache_read_tokens: args.cache_read_tokens ?? null, + latency_ms: args.latency_ms ?? null, + }) + if (error) { + console.error('[ai] logAiReply insert error:', error.message) + } + } catch (err) { + console.error( + '[ai] logAiReply failed:', + err instanceof Error ? err.message : err, + ) + } +} + +/** Count today's autonomous replies for an account (UTC-midnight window). */ +async function countRepliesToday(accountId: string): Promise { + const db = supabaseAdmin() + const since = startOfUtcDay() + const { count, error } = await db + .from('ai_reply_log') + .select('id', { count: 'exact', head: true }) + .eq('account_id', accountId) + .eq('decision', 'replied') + .gte('created_at', since) + if (error) { + // A failed cap read must not let us blow past the cap silently. Treat + // it as "can't verify" and surface it — the orchestrator's catch turns + // an unverifiable cap into a fail-safe escalation rather than a send. + throw new Error(`daily cap count failed: ${error.message}`) + } + return count ?? 0 +} + +/** ISO timestamp for 00:00:00 UTC today — the daily-cap window start. */ +function startOfUtcDay(): string { + const now = new Date() + return new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), + ).toISOString() +} + +/** + * Load the recent conversation history projected to the minimal + * {@link PromptHistoryMessage} shape `prompt.ts` consumes. Oldest→newest. + * Returns `[]` on any DB error — an empty history is harmless (the model + * still has the inbound text + the KB), so we don't fail the whole run. + */ +async function loadHistory( + conversationId: string, +): Promise { + const db = supabaseAdmin() + const { data, error } = await db + .from('messages') + .select('sender_type, content_text, created_at') + .eq('conversation_id', conversationId) + .order('created_at', { ascending: false }) + .limit(HISTORY_FETCH_LIMIT) + if (error) { + console.error('[ai] loadHistory error:', error.message) + return [] + } + const rows = (data as PromptHistoryMessage[] | null) ?? [] + // Fetched newest→oldest for the LIMIT; reverse to oldest→newest for the + // prompt (buildMessages expects chronological order). + return rows.slice().reverse() +} + +/** Load the contact's phone (account-scoped) for the escalation handoff send. */ +async function loadContactPhone( + accountId: string, + contactId: string, +): Promise { + const db = supabaseAdmin() + const { data } = await db + .from('contacts') + .select('phone') + .eq('id', contactId) + .eq('account_id', accountId) + .maybeSingle() + return (data as { phone?: string } | null)?.phone ?? '' +} + +/** + * Evaluate one inbound customer message and either reply autonomously or + * escalate to a human (spec §6). Fire-and-forget from the webhook; this + * function NEVER throws — every failure path escalates and logs. + */ +export async function maybeReplyToInbound( + args: MaybeReplyToInboundArgs, +): Promise { + const { + accountId, + conversationId, + contactId, + messageId, + inboundText, + accessToken, + phoneNumberId, + } = args + const callAssistant = args.callAssistant ?? defaultCallAssistant + + try { + // 1. Load config. Missing / disabled config → AI is off for this + // account; skip silently (spec §6: `!enabled → skip`). + const config = await loadAiConfig(accountId) + if (!config || !config.enabled) { + await logAiReply({ + accountId, + conversationId, + messageId, + decision: 'skipped', + reason: !config ? 'no_config' : 'disabled', + }) + return + } + + // 2. Load the conversation. If a human has taken over (ai_handling === + // false) the AI stays silent (spec §6: human owns it → skip). + const db = supabaseAdmin() + const { data: conversation } = await db + .from('conversations') + .select('id, ai_handling') + .eq('id', conversationId) + .maybeSingle() + if (conversation && (conversation as { ai_handling?: boolean }).ai_handling === false) { + await logAiReply({ + accountId, + conversationId, + messageId, + decision: 'skipped', + reason: 'human_takeover', + }) + return + } + + // The customer phone is needed only by escalate's optional handoff + // send; resolve it once up front so every escalation branch can use it. + const customerPhone = await loadContactPhone(accountId, contactId) + const escalate = (reason: 'keyword' | 'cap_reached' | 'low_confidence' | 'error') => + escalateConversation({ + conversationId, + reason, + config, + accessToken, + phoneNumberId, + customerPhone, + }) + + // 3. Deterministic guardrails — escalate on a keyword / explicit human + // request WITHOUT calling the model (spec §6, cheaper + immune to a + // confused model). + const guard = shouldForceEscalate(inboundText, config.escalation_keywords) + if (guard.escalate) { + await escalate('keyword') + await logAiReply({ + accountId, + conversationId, + messageId, + decision: 'escalated', + reason: 'keyword', + }) + return + } + + // 4. Daily reply cap — escalate when today's autonomous replies have + // hit the per-account cap (spec §6, §13). Counted pre-LLM so a + // capped account never even calls the model. + const repliesToday = await countRepliesToday(accountId) + if (repliesToday >= config.daily_reply_cap) { + await escalate('cap_reached') + await logAiReply({ + accountId, + conversationId, + messageId, + decision: 'escalated', + reason: 'cap_reached', + }) + return + } + + // 5. Build the prompt (persona + cached KB block + recent history + + // inbound) and call Claude through the forced `submit_answer` tool. + const [kbEntries, history] = await Promise.all([ + loadEnabledEntries(accountId), + loadHistory(conversationId), + ]) + const system = buildSystemBlocks(config, kbEntries) + const messages = buildMessages(history, inboundText) + + const startedAt = Date.now() + const result = await callAssistant({ model: config.model, system, messages }) + const latencyMs = Date.now() - startedAt + + // 6. Reduce the model's structured result to a reply / escalate verdict. + const decision = decide(result) + if (decision.action === 'reply') { + await sendAiReply({ + accountId, + conversationId, + contactId, + text: decision.text, + accessToken, + phoneNumberId, + }) + await logAiReply({ + accountId, + conversationId, + messageId, + decision: 'replied', + confident: result.confident, + reason: result.reason, + model: config.model, + input_tokens: result.usage.input_tokens, + output_tokens: result.usage.output_tokens, + cache_read_tokens: result.usage.cache_read_input_tokens, + latency_ms: latencyMs, + }) + return + } + + // Not confident (or confident-but-empty) → hand to a human. + await escalate('low_confidence') + await logAiReply({ + accountId, + conversationId, + messageId, + decision: 'escalated', + confident: result.confident, + reason: 'low_confidence', + model: config.model, + input_tokens: result.usage.input_tokens, + output_tokens: result.usage.output_tokens, + cache_read_tokens: result.usage.cache_read_input_tokens, + latency_ms: latencyMs, + }) + } catch (err) { + // 7. Fail safe to a human (spec §1, §6). ANY throw above — missing API + // key, Anthropic error, malformed tool_use, send failure, DB hiccup + // — lands here. Escalate and log; never rethrow. + const summary = err instanceof Error ? err.message : String(err) + console.error('[ai] maybeReplyToInbound error:', summary) + try { + const config = await loadAiConfig(accountId) + const customerPhone = await loadContactPhone(accountId, contactId) + if (config) { + await escalateConversation({ + conversationId, + reason: 'error', + config, + accessToken, + phoneNumberId, + customerPhone, + }) + } + } catch (escErr) { + // Even the fail-safe escalation failed — log and swallow. The webhook + // must not see this function throw under any circumstances. + console.error( + '[ai] maybeReplyToInbound escalation-on-error failed:', + escErr instanceof Error ? escErr.message : escErr, + ) + } + await logAiReply({ + accountId, + conversationId, + messageId, + decision: 'error', + reason: summary, + }) + } +} diff --git a/src/lib/ai/send.ts b/src/lib/ai/send.ts new file mode 100644 index 0000000000..1b2a2b4085 --- /dev/null +++ b/src/lib/ai/send.ts @@ -0,0 +1,146 @@ +/** + * Outbound AI reply sender (spec §5: `send.ts`). + * + * Sends one autonomous AI reply to the customer over the SAME Meta Cloud + * API path the inbox composer (`POST /api/whatsapp/send`) and the Flows + * engine (`src/lib/flows/meta-send.ts`) already use — `sendTextMessage` + * from `src/lib/whatsapp/meta-api.ts` — then persists the outgoing row to + * `messages` with `sender_type='bot'` so the inbox renders it with the + * "AI" tag (spec §8) and the conversation list preview updates. + * + * The orchestrator (`reply.ts`) has already loaded + decrypted the account's + * `whatsapp_config`, so the resolved `accessToken` + `phoneNumberId` are + * passed in directly rather than re-fetched here. This keeps the single + * config read on the hot path in one place and lets the handoff send in + * `escalate.ts` reuse the same already-decrypted token. + * + * Mirrors `engineSendText` in `src/lib/flows/meta-send.ts`: same + * phone-variant retry (`phoneVariants` + `isRecipientNotAllowedError`), + * the same auto-correct-contact-phone-on-success step, and the same + * "sent to Meta but DB insert failed" hard error. The only differences + * are the pre-resolved credentials and `sender_type='bot'` for an AI send. + * + * Throws on any failure (contact missing, all phone variants rejected, + * DB insert failed) so the caller's try/catch can fall back to escalation + * — the "fail safe to a human" bias of spec §1 / §6. + */ + +import { + sanitizePhoneForMeta, + isValidE164, + phoneVariants, + isRecipientNotAllowedError, +} from '@/lib/whatsapp/phone-utils' +import { sendTextMessage } from '@/lib/whatsapp/meta-api' + +import { supabaseAdmin } from './admin-client' + +export interface SendAiReplyArgs { + /** Account-level tenancy key — scopes the contact lookup + auto-correct. */ + accountId: string + /** Conversation the reply belongs to; the outgoing row + preview attach here. */ + conversationId: string + /** Contact whose phone the message is sent to (resolved + validated here). */ + contactId: string + /** The reply text to send the customer (already decided non-empty upstream). */ + text: string + /** Decrypted Meta access token, resolved by the caller from whatsapp_config. */ + accessToken: string + /** Meta phone-number id, resolved by the caller from whatsapp_config. */ + phoneNumberId: string +} + +/** + * Send an AI-authored text reply to the customer and persist it. + * + * Returns the Meta message id on success. The bot's message lands in + * `messages` with `sender_type='bot'`, `content_type='text'`, + * `status='sent'`, exactly like the Flows engine's text sends — so the + * "AI" tag in the inbox keys off the same `sender_type='bot'` discriminator. + */ +export async function sendAiReply( + args: SendAiReplyArgs, +): Promise<{ whatsapp_message_id: string }> { + const db = supabaseAdmin() + + // Resolve + validate the contact phone. Scoped by account_id for the + // same defense-in-depth reason as flows/automations meta-send. + const { data: contact, error: contactErr } = await db + .from('contacts') + .select('id, phone') + .eq('id', args.contactId) + .eq('account_id', args.accountId) + .maybeSingle() + if (contactErr || !contact?.phone) { + throw new Error('contact not found for this account') + } + + const sanitized = sanitizePhoneForMeta(contact.phone) + if (!isValidE164(sanitized)) { + throw new Error(`contact phone invalid: ${contact.phone}`) + } + + const attempt = async (phone: string): Promise => { + const r = await sendTextMessage({ + phoneNumberId: args.phoneNumberId, + accessToken: args.accessToken, + to: phone, + text: args.text, + }) + return r.messageId + } + + // Phone-variant retry: numbers registered with/without a trunk 0 plus + // Meta's sandbox quirks need this to reliably land. Only the specific + // "recipient not in allowed list" failure is retried; any other error + // (bad token, invalid recipient) bubbles up so the caller escalates. + const variants = phoneVariants(sanitized) + let workingPhone = sanitized + let waMessageId = '' + let lastError: unknown = null + for (const v of variants) { + try { + waMessageId = await attempt(v) + workingPhone = v + lastError = null + break + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (!isRecipientNotAllowedError(msg)) throw err + lastError = err + } + } + if (lastError) throw lastError + + // Persist the working variant back to the contact so future sends go + // straight through on the first attempt. + if (workingPhone !== sanitized) { + await db.from('contacts').update({ phone: workingPhone }).eq('id', contact.id) + } + + // Persist the bot's reply. sender_type='bot' is what the inbox keys the + // "AI" tag off (spec §8); content_type='text'/status='sent' match the + // composer + flows text sends (migration 001 messages schema). + const { error: msgErr } = await db.from('messages').insert({ + conversation_id: args.conversationId, + sender_type: 'bot', + content_type: 'text', + content_text: args.text, + message_id: waMessageId, + status: 'sent', + }) + if (msgErr) { + throw new Error(`sent to Meta but DB insert failed: ${msgErr.message}`) + } + + await db + .from('conversations') + .update({ + last_message_text: args.text, + last_message_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + .eq('id', args.conversationId) + + return { whatsapp_message_id: waMessageId } +} diff --git a/src/types/index.ts b/src/types/index.ts index 66e0b8a958..5a0e2d3113 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -156,6 +156,23 @@ export interface Conversation { created_at: string; updated_at: string; contact?: Contact; + /** + * Is the AI assistant still driving this conversation? Added by + * `027_ai_assistant.sql` with DB default `true`, so a freshly-created + * conversation is eligible for the bot. Flipped to `false` on + * escalation or the instant a human replies (human-takeover). When + * `false` the assistant stays silent on this thread. + */ + ai_handling?: boolean; + /** Set when the assistant handed this conversation off to a human. */ + ai_escalated_at?: string; + /** + * Why the assistant escalated: + * `low_confidence` | `keyword` | `error` | `cap_reached` | `human_takeover`. + * Free-form text in the DB; this union documents the values the + * engine writes. + */ + ai_escalation_reason?: AiEscalationReason; } export type SenderType = 'customer' | 'agent' | 'bot'; @@ -542,3 +559,139 @@ export interface AutomationLog { created_at: string; contact?: Contact; } + +// ============================================================ +// AI assistant (027_ai_assistant.sql) +// ============================================================ + +/** + * Per-account configuration for the WhatsApp AI assistant ("LLM + * wiki"). Exactly one row per account (UNIQUE account_id). Off by + * default — the assistant is strictly opt-in. Settings-class: + * readable/writable by admin+ only. The `ANTHROPIC_API_KEY` itself is + * NEVER on this type — it lives server-side in env; Settings reports + * only a configured/not-configured boolean. + */ +export interface AiAssistantConfig { + id: string; + /** Tenancy key — one config per account (NOT NULL, UNIQUE in the DB). */ + account_id: string; + /** Master switch. DB default `false` (opt-in). */ + enabled: boolean; + /** + * Editable system prompt. Seeded with the strong "answer ONLY from + * the KB" default (see spec §7.2); `{business_name}` is substituted + * at prompt-assembly time. + */ + system_prompt: string; + /** Sent to the customer on escalation; nullable = send nothing. */ + handoff_message?: string; + /** + * Keywords that always escalate to a human pre-LLM (no model call). + * Seeded with: refund, cancel, complaint, lawyer, legal, human, + * agent, manager. DB default `'{}'`-typed but seeded non-empty. + */ + escalation_keywords: string[]; + /** Persona context (business name) injected into the prompt. */ + business_name?: string; + /** + * Stored via the existing storage bucket; persona context only in + * v1 (no customer-facing surface yet). + */ + logo_url?: string; + /** Anthropic model id. DB default `'claude-sonnet-4-6'`. */ + model: string; + /** Per-account daily reply cap; cap hit → escalate. DB default 500. */ + daily_reply_cap: number; + created_at: string; + updated_at: string; +} + +/** Where a knowledge-base entry came from. */ +export type KnowledgeBaseSourceType = 'manual' | 'file'; + +/** + * One "wiki page" of the per-account knowledge base. The model is fed + * the concatenation of every `enabled` entry for the account; disabled + * entries are excluded from the prompt. Settings-class: admin+ only. + */ +export interface KnowledgeBaseEntry { + id: string; + /** Tenancy key — strict per-account isolation in prompt assembly. */ + account_id: string; + title: string; + /** Markdown / plain text fed verbatim to the model. */ + content: string; + /** Hand-authored (`manual`) vs created from an upload (`file`). */ + source_type: KnowledgeBaseSourceType; + /** Original filename when `source_type === 'file'`. */ + source_filename?: string; + /** Disabled entries are excluded from the assembled prompt. */ + enabled: boolean; + /** + * Cached ~token count (chars/4 heuristic) for the Settings size + * meter. Nullable until first computed. + */ + token_estimate?: number; + /** Author, for audit. NULL after the user is deleted (ON DELETE SET NULL). */ + created_by_user_id?: string; + created_at: string; + updated_at: string; +} + +/** Outcome recorded for one evaluated inbound message. */ +export type AiReplyDecision = 'replied' | 'escalated' | 'skipped' | 'error'; + +/** + * Why the assistant escalated a conversation to a human. Written to + * both `conversations.ai_escalation_reason` and `ai_reply_log.reason`. + */ +export type AiEscalationReason = + | 'low_confidence' + | 'keyword' + | 'error' + | 'cap_reached' + | 'human_takeover'; + +/** + * One row per AI decision — audit trail and the data source for a + * future cost view (spec §16; no UI reads it in v1). Inserted by the + * webhook engine via the service-role client (RLS-bypassing); + * admin-read only. + */ +export interface AiReplyLog { + id: string; + account_id: string; + conversation_id?: string; + /** The inbound `messages.id` that triggered this decision. */ + message_id?: string; + decision: AiReplyDecision; + /** Model's self-report; NULL when the decision was made pre-LLM. */ + confident?: boolean; + /** Escalation reason / error summary. */ + reason?: string; + model?: string; + /** Token usage from the Anthropic `usage` block; NULL when no LLM call. */ + input_tokens?: number; + output_tokens?: number; + cache_read_tokens?: number; + latency_ms?: number; + created_at: string; +} + +/** + * The structured result the model returns via the forced + * `submit_answer` tool (spec §7.1), shared between the Anthropic + * wrapper, the pure `decide` core, and their tests. The deterministic + * guardrails and `decide` consume this to choose reply vs. escalate; + * auto-send requires BOTH `confident === true` and a non-empty + * `answer`. + */ +export interface AiModelResult { + /** The reply to send the customer; empty when not confident. */ + answer: string; + /** True ONLY if the knowledge base fully answers the question. */ + confident: boolean; + /** Short rationale, recorded in the audit log. */ + reason: string; +} diff --git a/supabase/migrations/027_ai_assistant.sql b/supabase/migrations/027_ai_assistant.sql new file mode 100644 index 0000000000..69cb4b467e --- /dev/null +++ b/supabase/migrations/027_ai_assistant.sql @@ -0,0 +1,214 @@ +-- ============================================================ +-- 027_ai_assistant.sql — AI assistant for WhatsApp ("LLM wiki") +-- +-- Adds the data model for an AI assistant that answers inbound +-- WhatsApp messages grounded only in a per-account knowledge base. +-- When the model is confident the KB covers the question it replies +-- autonomously; otherwise it escalates to a human and goes silent on +-- that conversation. See docs/superpowers/specs/2026-06-25-wacrm-ai- +-- assistant-design.md §4 for the source-of-truth design. +-- +-- What this migration does +-- 1. `ai_assistant_config` — one row per account: master enable, +-- editable system prompt, persona, model, daily reply cap. +-- 2. `knowledge_base_entries` — the "wiki pages" fed to the model. +-- 3. ALTERs `conversations` with the AI hand-off columns +-- (`ai_handling`, `ai_escalated_at`, `ai_escalation_reason`). +-- 4. `ai_reply_log` — one row per AI decision (audit + future cost +-- view). Inserts are service-role only. +-- +-- Tenancy + RLS follow migrations 017–026: every table carries an +-- `account_id` FK to `accounts` (ON DELETE CASCADE), RLS is enabled, +-- and policies gate access via the `is_account_member(account_id, +-- min_role)` SECURITY DEFINER helper from 017. Config + knowledge +-- are admin+ (settings-class, mirroring `tags` / `api_keys`). +-- `ai_reply_log` is admin-read, service-role-write (mirroring +-- `automation_logs`). Service-role writes from the webhook bypass +-- RLS as the other engines already do. +-- +-- Idempotent — safe to run multiple times. Tables use IF NOT +-- EXISTS; columns use ADD COLUMN IF NOT EXISTS; policies are dropped +-- before recreate (Postgres has no CREATE POLICY IF NOT EXISTS). +-- ============================================================ + +-- ============================================================ +-- AI_ASSISTANT_CONFIG — one row per account +-- +-- One config row per account (UNIQUE account_id). `enabled` is off +-- by default: the assistant is strictly opt-in. `system_prompt` is +-- seeded with the strong "answer ONLY from the KB" default from +-- §7.2; admins may edit it. `escalation_keywords` is seeded with the +-- safety vocabulary that always hands off to a human pre-LLM. +-- `logo_url` is persona context only in v1 (no customer-facing +-- surface yet). +-- ============================================================ +CREATE TABLE IF NOT EXISTS ai_assistant_config ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + account_id UUID NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + system_prompt TEXT NOT NULL DEFAULT + 'You are the customer-support assistant for {business_name}. Answer ONLY using the information in the KNOWLEDGE BASE below. If the knowledge base does not clearly and fully answer the customer''s question, you MUST set `confident` to false and leave `answer` empty — do not guess, do not use outside knowledge, do not make promises about pricing, refunds, delivery dates, or policies that aren''t written here. Be concise, friendly, and match the customer''s language.', + handoff_message TEXT, + escalation_keywords TEXT[] NOT NULL DEFAULT + '{refund,cancel,complaint,lawyer,legal,human,agent,manager}', + business_name TEXT, + logo_url TEXT, + model TEXT NOT NULL DEFAULT 'claude-sonnet-4-6', + daily_reply_cap INTEGER NOT NULL DEFAULT 500, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- account_id is the lookup key (one row per account; loaded on every +-- inbound the assistant evaluates). UNIQUE already creates an index, +-- but spell it out so intent survives a future drop of the UNIQUE. +CREATE INDEX IF NOT EXISTS idx_ai_assistant_config_account + ON ai_assistant_config(account_id); + +ALTER TABLE ai_assistant_config ENABLE ROW LEVEL SECURITY; + +DROP TRIGGER IF EXISTS set_updated_at ON ai_assistant_config; +CREATE TRIGGER set_updated_at BEFORE UPDATE ON ai_assistant_config + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- RLS: settings-class. admin+ for select/insert/update (mirrors the +-- `tags` / `whatsapp_config` policies in 017). No DELETE policy — +-- the row is removed only by the account cascade. +DROP POLICY IF EXISTS ai_assistant_config_select ON ai_assistant_config; +CREATE POLICY ai_assistant_config_select ON ai_assistant_config FOR SELECT + USING (is_account_member(account_id, 'admin')); + +DROP POLICY IF EXISTS ai_assistant_config_insert ON ai_assistant_config; +CREATE POLICY ai_assistant_config_insert ON ai_assistant_config FOR INSERT + WITH CHECK (is_account_member(account_id, 'admin')); + +DROP POLICY IF EXISTS ai_assistant_config_update ON ai_assistant_config; +CREATE POLICY ai_assistant_config_update ON ai_assistant_config FOR UPDATE + USING (is_account_member(account_id, 'admin')) + WITH CHECK (is_account_member(account_id, 'admin')); + +-- ============================================================ +-- KNOWLEDGE_BASE_ENTRIES — many per account (the "wiki pages") +-- +-- Each row is one page of the LLM wiki. `content` is the markdown / +-- plain text fed verbatim to the model. `source_type` distinguishes +-- a hand-authored entry from one created by a file upload (the +-- original filename is kept for display). Disabled entries are +-- excluded from the assembled prompt. `token_estimate` caches a +-- chars/4 heuristic for the Settings size meter. +-- ============================================================ +CREATE TABLE IF NOT EXISTS knowledge_base_entries ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + title TEXT NOT NULL, + content TEXT NOT NULL, + source_type TEXT NOT NULL DEFAULT 'manual' CHECK (source_type IN ('manual', 'file')), + source_filename TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + token_estimate INTEGER, + created_by_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- account_id: prompt assembly loads "this account's enabled entries". +-- The partial composite is tuned for that hot path (enabled only). +CREATE INDEX IF NOT EXISTS idx_knowledge_base_entries_account + ON knowledge_base_entries(account_id); +CREATE INDEX IF NOT EXISTS idx_knowledge_base_entries_account_enabled + ON knowledge_base_entries(account_id) + WHERE enabled = TRUE; + +ALTER TABLE knowledge_base_entries ENABLE ROW LEVEL SECURITY; + +DROP TRIGGER IF EXISTS set_updated_at ON knowledge_base_entries; +CREATE TRIGGER set_updated_at BEFORE UPDATE ON knowledge_base_entries + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- RLS: settings-class. All ops admin+ (mirrors `custom_fields` in 017). +DROP POLICY IF EXISTS knowledge_base_entries_select ON knowledge_base_entries; +CREATE POLICY knowledge_base_entries_select ON knowledge_base_entries FOR SELECT + USING (is_account_member(account_id, 'admin')); + +DROP POLICY IF EXISTS knowledge_base_entries_insert ON knowledge_base_entries; +CREATE POLICY knowledge_base_entries_insert ON knowledge_base_entries FOR INSERT + WITH CHECK (is_account_member(account_id, 'admin')); + +DROP POLICY IF EXISTS knowledge_base_entries_update ON knowledge_base_entries; +CREATE POLICY knowledge_base_entries_update ON knowledge_base_entries FOR UPDATE + USING (is_account_member(account_id, 'admin')) + WITH CHECK (is_account_member(account_id, 'admin')); + +DROP POLICY IF EXISTS knowledge_base_entries_delete ON knowledge_base_entries; +CREATE POLICY knowledge_base_entries_delete ON knowledge_base_entries FOR DELETE + USING (is_account_member(account_id, 'admin')); + +-- ============================================================ +-- CONVERSATIONS — AI hand-off columns (ALTER) +-- +-- `ai_handling` is the "is the bot still driving this thread?" flag. +-- Defaults TRUE so a freshly-created conversation is eligible for the +-- assistant; set FALSE on escalation or the instant a human replies +-- (human-takeover detection in the send route). `ai_escalated_at` / +-- `ai_escalation_reason` record the hand-off for the inbox badge. +-- +-- No change to the `status` CHECK: on escalation the engine also sets +-- status='pending', already an allowed value that reads as "needs +-- attention" in the inbox. +-- ============================================================ +ALTER TABLE conversations + ADD COLUMN IF NOT EXISTS ai_handling BOOLEAN NOT NULL DEFAULT TRUE, + ADD COLUMN IF NOT EXISTS ai_escalated_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS ai_escalation_reason TEXT; + +-- Surfaces the "🙋 Needs human" list filter: escalated conversations +-- still awaiting a human. Partial index keeps it small. +CREATE INDEX IF NOT EXISTS idx_conversations_ai_escalated + ON conversations(account_id, ai_escalated_at) + WHERE ai_escalated_at IS NOT NULL; + +-- ============================================================ +-- AI_REPLY_LOG — one row per AI decision (audit + future cost view) +-- +-- Written by the webhook engine via the service-role client on every +-- evaluated inbound: a reply sent, an escalation, a pre-LLM skip, or +-- an error. `confident` is the model's self-report (NULL when the +-- decision was made before any LLM call — e.g. a keyword/cap skip). +-- The token + latency columns feed a future cost dashboard (§16); no +-- UI reads them in v1. +-- ============================================================ +CREATE TABLE IF NOT EXISTS ai_reply_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL, + message_id UUID REFERENCES messages(id) ON DELETE SET NULL, + decision TEXT NOT NULL CHECK (decision IN ('replied', 'escalated', 'skipped', 'error')), + confident BOOLEAN, + reason TEXT, + model TEXT, + input_tokens INTEGER, + output_tokens INTEGER, + cache_read_tokens INTEGER, + latency_ms INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- account_id: the future cost view and the daily-cap count both scope +-- by account. The cap check counts today's `replied` rows, so a +-- composite on (account_id, created_at) WHERE decision='replied' +-- answers it from one index lookup. +CREATE INDEX IF NOT EXISTS idx_ai_reply_log_account + ON ai_reply_log(account_id); +CREATE INDEX IF NOT EXISTS idx_ai_reply_log_account_replied + ON ai_reply_log(account_id, created_at) + WHERE decision = 'replied'; + +ALTER TABLE ai_reply_log ENABLE ROW LEVEL SECURITY; + +-- RLS: admin-read, service-role-write (mirrors `automation_logs` in +-- 017). The webhook engine inserts with the service-role client, +-- which bypasses RLS — so there is no client INSERT/UPDATE/DELETE +-- policy. +DROP POLICY IF EXISTS ai_reply_log_select ON ai_reply_log; +CREATE POLICY ai_reply_log_select ON ai_reply_log FOR SELECT + USING (is_account_member(account_id, 'admin')); From ed4619d97f0da7555aa78148780d497b6ed5d6bc Mon Sep 17 00:00:00 2001 From: Martinxmaina Date: Thu, 25 Jun 2026 21:33:27 +0300 Subject: [PATCH 3/8] =?UTF-8?q?feat(ai):=20/api/ai=20routes=20=E2=80=94=20?= =?UTF-8?q?config=20+=20knowledge=20base=20CRUD=20+=20file=20upload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin-gated (requireRole('admin') + canEditSettings + RLS), rate-limited, input-bounded dashboard endpoints (spec §10): - GET/PUT /api/ai/config (seeds default row; returns apiKeyConfigured boolean, never the key) - GET/POST /api/ai/knowledge ; PATCH/DELETE /api/ai/knowledge/[id] - POST /api/ai/knowledge/upload (.txt/.md direct, PDF via unpdf, runtime=nodejs, 10MB cap, 422 on extraction failure) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/api/ai/config/route.ts | 396 +++++++++++++++++++++++ src/app/api/ai/knowledge/[id]/route.ts | 229 +++++++++++++ src/app/api/ai/knowledge/route.ts | 150 +++++++++ src/app/api/ai/knowledge/upload/route.ts | 219 +++++++++++++ 4 files changed, 994 insertions(+) create mode 100644 src/app/api/ai/config/route.ts create mode 100644 src/app/api/ai/knowledge/[id]/route.ts create mode 100644 src/app/api/ai/knowledge/route.ts create mode 100644 src/app/api/ai/knowledge/upload/route.ts diff --git a/src/app/api/ai/config/route.ts b/src/app/api/ai/config/route.ts new file mode 100644 index 0000000000..3528fec64f --- /dev/null +++ b/src/app/api/ai/config/route.ts @@ -0,0 +1,396 @@ +// ============================================================ +// /api/ai/config +// +// GET — read this account's AI assistant config (spec §4.1, §9, +// §10). Seeds a default row the first time the AI Settings +// tab is opened (mirroring how other settings rows are +// lazily created), so the UI always gets a config to bind. +// Adds a server-computed `apiKeyConfigured` boolean — +// whether `ANTHROPIC_API_KEY` is set in the environment. +// The key ITSELF is never returned (spec §9.1, §12). +// PUT — update the editable fields. Admin+ only. +// +// Both verbs go through the cookie-session Supabase server client, +// so RLS enforces tenancy. On top of that the `ai_assistant_config` +// RLS policies are admin-only (migration 027), but we ALSO gate the +// route with `requireRole('admin')` — the same belt-and-braces +// pattern as `/api/account/api-keys` (explicit role check + RLS), +// so an under-privileged caller gets a clean 403 instead of an +// opaque empty result from RLS. +// ============================================================ + +import { NextResponse } from 'next/server'; + +import { requireRole, toErrorResponse } from '@/lib/auth/account'; +import { + checkRateLimit, + rateLimitResponse, + RATE_LIMITS, +} from '@/lib/rate-limit'; +import type { AiAssistantConfig } from '@/types'; + +// Columns the client may read. Mirrors `AiAssistantConfig`; there is +// no secret column on this table (the Anthropic key lives in env, not +// the DB), so we select everything the row holds. +const CONFIG_COLUMNS = + 'id, account_id, enabled, system_prompt, handoff_message, escalation_keywords, business_name, logo_url, model, daily_reply_cap, created_at, updated_at'; + +// Bounds on caller-supplied free text, so a runaway paste can't bloat +// the row (and, downstream, the prompt). Generous — these are admin +// settings, not user input. +const MAX_PROMPT_LEN = 20_000; +const MAX_HANDOFF_LEN = 2_000; +const MAX_BUSINESS_NAME_LEN = 200; +const MAX_LOGO_URL_LEN = 2_000; +const MAX_MODEL_LEN = 100; +const MAX_KEYWORDS = 100; +const MAX_KEYWORD_LEN = 80; +// Daily reply cap is a positive integer; clamp the ceiling so a typo +// can't disable the cost guard entirely. +const MAX_DAILY_REPLY_CAP = 100_000; + +/** + * Whether the Anthropic key is present in the server environment. + * Computed per-request and surfaced as a plain boolean so Settings can + * show a "key configured / not configured" status WITHOUT the key ever + * crossing to the client (spec §9.1, §12). + */ +function apiKeyConfigured(): boolean { + return !!process.env.ANTHROPIC_API_KEY; +} + +export async function GET() { + try { + // Settings-class read: admin+ only, matching the table's RLS. + const ctx = await requireRole('admin'); + + // Try to read the (at most one) config row for this account. + const { data: existing, error: selectError } = await ctx.supabase + .from('ai_assistant_config') + .select(CONFIG_COLUMNS) + .eq('account_id', ctx.accountId) + .maybeSingle(); + + if (selectError) { + console.error('[GET /api/ai/config] fetch error:', selectError); + return NextResponse.json( + { error: 'Failed to load AI configuration' }, + { status: 500 } + ); + } + + if (existing) { + return NextResponse.json({ + config: existing as AiAssistantConfig, + apiKeyConfigured: apiKeyConfigured(), + }); + } + + // No row yet — the account has never opened this tab. Seed a + // default row so the UI always has something to bind to. Every + // column has a DB default (migration 027), so we only supply + // `account_id`; Postgres fills the seeded prompt, keywords, + // model, and cap. RLS `ai_assistant_config_insert` permits this + // because `requireRole('admin')` already proved the caller is + // admin+ for this account. + const { data: seeded, error: insertError } = await ctx.supabase + .from('ai_assistant_config') + .insert({ account_id: ctx.accountId }) + .select(CONFIG_COLUMNS) + .single(); + + if (insertError || !seeded) { + console.error('[GET /api/ai/config] seed error:', insertError); + return NextResponse.json( + { error: 'Failed to initialize AI configuration' }, + { status: 500 } + ); + } + + return NextResponse.json({ + config: seeded as AiAssistantConfig, + apiKeyConfigured: apiKeyConfigured(), + }); + } catch (err) { + return toErrorResponse(err); + } +} + +export async function PUT(request: Request) { + try { + const ctx = await requireRole('admin'); + + // Per-admin limit on settings mutations — bounds accidental abuse + // (a loop) and a compromised session, with its own bucket so it + // doesn't starve other admin endpoints. + const limit = checkRateLimit( + `admin:aiConfigUpdate:${ctx.userId}`, + RATE_LIMITS.adminAction + ); + if (!limit.success) return rateLimitResponse(limit); + + const body = (await request.json().catch(() => null)) as { + enabled?: unknown; + system_prompt?: unknown; + handoff_message?: unknown; + escalation_keywords?: unknown; + business_name?: unknown; + logo_url?: unknown; + model?: unknown; + daily_reply_cap?: unknown; + } | null; + + if (!body || typeof body !== 'object') { + return NextResponse.json( + { error: 'Request body must be a JSON object' }, + { status: 400 } + ); + } + + // Build the update from only the editable fields that were + // supplied. Validate each; reject the whole request on any bad + // field rather than silently dropping it. `id`, `account_id`, and + // the timestamps are never writable here. + const updates: Record = {}; + + if (body.enabled !== undefined) { + if (typeof body.enabled !== 'boolean') { + return NextResponse.json( + { error: "'enabled' must be a boolean" }, + { status: 400 } + ); + } + updates.enabled = body.enabled; + } + + if (body.system_prompt !== undefined) { + if (typeof body.system_prompt !== 'string') { + return NextResponse.json( + { error: "'system_prompt' must be a string" }, + { status: 400 } + ); + } + const prompt = body.system_prompt.trim(); + // NOT NULL in the DB — an empty prompt would strip the safety + // instructions, so require non-empty. + if (prompt.length === 0) { + return NextResponse.json( + { error: "'system_prompt' cannot be empty" }, + { status: 400 } + ); + } + if (prompt.length > MAX_PROMPT_LEN) { + return NextResponse.json( + { + error: `'system_prompt' must be ${MAX_PROMPT_LEN} characters or fewer`, + }, + { status: 400 } + ); + } + updates.system_prompt = prompt; + } + + if (body.handoff_message !== undefined) { + // Nullable column — null/empty means "send nothing on escalation". + if (body.handoff_message === null) { + updates.handoff_message = null; + } else if (typeof body.handoff_message === 'string') { + const msg = body.handoff_message.trim(); + if (msg.length > MAX_HANDOFF_LEN) { + return NextResponse.json( + { + error: `'handoff_message' must be ${MAX_HANDOFF_LEN} characters or fewer`, + }, + { status: 400 } + ); + } + updates.handoff_message = msg.length === 0 ? null : msg; + } else { + return NextResponse.json( + { error: "'handoff_message' must be a string or null" }, + { status: 400 } + ); + } + } + + if (body.escalation_keywords !== undefined) { + if (!Array.isArray(body.escalation_keywords)) { + return NextResponse.json( + { error: "'escalation_keywords' must be an array of strings" }, + { status: 400 } + ); + } + if (body.escalation_keywords.length > MAX_KEYWORDS) { + return NextResponse.json( + { error: `At most ${MAX_KEYWORDS} escalation keywords are allowed` }, + { status: 400 } + ); + } + const keywords: string[] = []; + for (const raw of body.escalation_keywords) { + if (typeof raw !== 'string') { + return NextResponse.json( + { error: "'escalation_keywords' must be an array of strings" }, + { status: 400 } + ); + } + const kw = raw.trim().toLowerCase(); + if (kw.length === 0) continue; // drop blanks + if (kw.length > MAX_KEYWORD_LEN) { + return NextResponse.json( + { + error: `Each escalation keyword must be ${MAX_KEYWORD_LEN} characters or fewer`, + }, + { status: 400 } + ); + } + if (!keywords.includes(kw)) keywords.push(kw); // de-dupe + } + updates.escalation_keywords = keywords; + } + + if (body.business_name !== undefined) { + if (body.business_name === null) { + updates.business_name = null; + } else if (typeof body.business_name === 'string') { + const name = body.business_name.trim(); + if (name.length > MAX_BUSINESS_NAME_LEN) { + return NextResponse.json( + { + error: `'business_name' must be ${MAX_BUSINESS_NAME_LEN} characters or fewer`, + }, + { status: 400 } + ); + } + updates.business_name = name.length === 0 ? null : name; + } else { + return NextResponse.json( + { error: "'business_name' must be a string or null" }, + { status: 400 } + ); + } + } + + if (body.logo_url !== undefined) { + if (body.logo_url === null) { + updates.logo_url = null; + } else if (typeof body.logo_url === 'string') { + const url = body.logo_url.trim(); + if (url.length > MAX_LOGO_URL_LEN) { + return NextResponse.json( + { + error: `'logo_url' must be ${MAX_LOGO_URL_LEN} characters or fewer`, + }, + { status: 400 } + ); + } + updates.logo_url = url.length === 0 ? null : url; + } else { + return NextResponse.json( + { error: "'logo_url' must be a string or null" }, + { status: 400 } + ); + } + } + + if (body.model !== undefined) { + if (typeof body.model !== 'string') { + return NextResponse.json( + { error: "'model' must be a string" }, + { status: 400 } + ); + } + const model = body.model.trim(); + // NOT NULL with a DB default — never allow it to be blanked. + if (model.length === 0) { + return NextResponse.json( + { error: "'model' cannot be empty" }, + { status: 400 } + ); + } + if (model.length > MAX_MODEL_LEN) { + return NextResponse.json( + { error: `'model' must be ${MAX_MODEL_LEN} characters or fewer` }, + { status: 400 } + ); + } + updates.model = model; + } + + if (body.daily_reply_cap !== undefined) { + const cap = body.daily_reply_cap; + if ( + typeof cap !== 'number' || + !Number.isInteger(cap) || + cap < 1 || + cap > MAX_DAILY_REPLY_CAP + ) { + return NextResponse.json( + { + error: `'daily_reply_cap' must be an integer between 1 and ${MAX_DAILY_REPLY_CAP}`, + }, + { status: 400 } + ); + } + updates.daily_reply_cap = cap; + } + + if (Object.keys(updates).length === 0) { + return NextResponse.json( + { error: 'No editable fields supplied' }, + { status: 400 } + ); + } + + // Update the account's row. RLS `ai_assistant_config_update` allows + // this because `requireRole('admin')` already proved the caller is + // admin+ for this account; `.eq('account_id')` keeps it scoped even + // so. If the row doesn't exist yet (the tab was never opened), seed + // it from the supplied values merged over the column defaults, then + // re-run as an insert so PUT is usable without a prior GET. + const { data: updated, error: updateError } = await ctx.supabase + .from('ai_assistant_config') + .update(updates) + .eq('account_id', ctx.accountId) + .select(CONFIG_COLUMNS) + .maybeSingle(); + + if (updateError) { + console.error('[PUT /api/ai/config] update error:', updateError); + return NextResponse.json( + { error: 'Failed to update AI configuration' }, + { status: 500 } + ); + } + + if (updated) { + return NextResponse.json({ + config: updated as AiAssistantConfig, + apiKeyConfigured: apiKeyConfigured(), + }); + } + + // No existing row matched — seed one with the supplied values laid + // over the DB column defaults. + const { data: inserted, error: insertError } = await ctx.supabase + .from('ai_assistant_config') + .insert({ account_id: ctx.accountId, ...updates }) + .select(CONFIG_COLUMNS) + .single(); + + if (insertError || !inserted) { + console.error('[PUT /api/ai/config] seed error:', insertError); + return NextResponse.json( + { error: 'Failed to update AI configuration' }, + { status: 500 } + ); + } + + return NextResponse.json({ + config: inserted as AiAssistantConfig, + apiKeyConfigured: apiKeyConfigured(), + }); + } catch (err) { + return toErrorResponse(err); + } +} diff --git a/src/app/api/ai/knowledge/[id]/route.ts b/src/app/api/ai/knowledge/[id]/route.ts new file mode 100644 index 0000000000..e318e61cde --- /dev/null +++ b/src/app/api/ai/knowledge/[id]/route.ts @@ -0,0 +1,229 @@ +// ============================================================ +// /api/ai/knowledge/[id] +// +// PATCH — update a single knowledge_base_entry (title, content, +// and/or enabled). Recomputes `token_estimate` whenever +// `content` changes. +// DELETE — remove a single entry. +// +// These are the *dashboard* endpoints for editing one KB page +// (spec §10), so they authenticate the normal way (cookie session) +// and go through the RLS client. Both methods are admin+ only: the +// KB is account-settings-class data (spec §4.2 RLS: all ops for +// `is_account_member(account_id, 'admin')`), so we enforce the same +// admin floor on the wire that the table's RLS policies enforce in +// the database — `requireRole('admin')` plus the `canEditSettings` +// capability predicate (defence in depth alongside RLS). +// +// Tenancy: every query is scoped by BOTH `id` and `account_id`, so an +// admin can never touch another account's entry by guessing a UUID. +// RLS already enforces this; the explicit `.eq('account_id', …)` +// filter is belt-and-braces and makes the "0 rows → 404" path precise. +// +// `token_estimate` is recomputed server-side from the new content via +// the shared chars/4 heuristic (`estimateTokens`, spec §4.2 / §9), so +// the Settings size meter stays consistent regardless of caller and +// matches the value the collection route's POST writes. +// ============================================================ + +import { NextResponse } from 'next/server'; + +import { + ForbiddenError, + requireRole, + toErrorResponse, +} from '@/lib/auth/account'; +import { canEditSettings } from '@/lib/auth/roles'; +import { estimateTokens } from '@/lib/ai/knowledge-base'; +import { + checkRateLimit, + rateLimitResponse, + RATE_LIMITS, +} from '@/lib/rate-limit'; + +const MAX_TITLE_LEN = 200; +// Hard ceiling on a single entry's content. A KB page far longer +// than this is almost certainly a misuse (paste a whole site) and +// would blow the prompt budget on its own — reject early rather than +// silently truncate. Mirrors the collection route's POST. +const MAX_CONTENT_LEN = 200_000; + +// Columns safe to expose. Mirrors the `KnowledgeBaseEntry` shape and +// the collection route's `SAFE_COLUMNS`. +const SAFE_COLUMNS = + 'id, account_id, title, content, source_type, source_filename, enabled, token_estimate, created_by_user_id, created_at, updated_at'; + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const ctx = await requireRole('admin'); + // Defence in depth: the capability predicate is the single source + // of truth for "can edit account settings" (the KB is settings- + // class), alongside the role floor above and the table RLS policy. + if (!canEditSettings(ctx.role)) { + throw new ForbiddenError('This action requires the admin role or higher'); + } + + const limit = checkRateLimit( + `admin:knowledgeUpdate:${ctx.userId}`, + RATE_LIMITS.adminAction + ); + if (!limit.success) return rateLimitResponse(limit); + + const { id } = await params; + + const body = (await request.json().catch(() => null)) as { + title?: unknown; + content?: unknown; + enabled?: unknown; + } | null; + if (!body) { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + // Build the patch from only the fields the caller actually sent, so + // a partial update (e.g. just toggling `enabled`) leaves the rest + // untouched. Each field is validated as it's added. + const update: Record = {}; + + if ('title' in body) { + const rawTitle = + typeof body.title === 'string' ? body.title.trim() : ''; + if (!rawTitle) { + return NextResponse.json( + { error: "'title' must be a non-empty string" }, + { status: 400 } + ); + } + if (rawTitle.length > MAX_TITLE_LEN) { + return NextResponse.json( + { error: `Title must be ${MAX_TITLE_LEN} characters or fewer` }, + { status: 400 } + ); + } + update.title = rawTitle; + } + + if ('content' in body) { + const rawContent = + typeof body.content === 'string' ? body.content.trim() : ''; + if (!rawContent) { + return NextResponse.json( + { error: "'content' must be a non-empty string" }, + { status: 400 } + ); + } + if (rawContent.length > MAX_CONTENT_LEN) { + return NextResponse.json( + { error: `Content must be ${MAX_CONTENT_LEN} characters or fewer` }, + { status: 400 } + ); + } + update.content = rawContent; + // Recompute the cached estimate whenever content changes so the + // size meter never drifts from the stored text (spec §4.2 / §9). + update.token_estimate = estimateTokens(rawContent); + } + + if ('enabled' in body) { + if (typeof body.enabled !== 'boolean') { + return NextResponse.json( + { error: "'enabled' must be a boolean" }, + { status: 400 } + ); + } + update.enabled = body.enabled; + } + + if (Object.keys(update).length === 0) { + return NextResponse.json( + { + error: + "Nothing to update — provide at least one of 'title', 'content', or 'enabled'", + }, + { status: 400 } + ); + } + + // Scope the update by account_id as well as id so an admin can never + // edit another account's entry by guessing a UUID. (RLS already + // enforces this; the explicit filter makes the "0 rows → 404" path + // precise.) + const { data, error } = await ctx.supabase + .from('knowledge_base_entries') + .update(update) + .eq('id', id) + .eq('account_id', ctx.accountId) + .select(SAFE_COLUMNS) + .maybeSingle(); + + if (error) { + console.error('[PATCH /api/ai/knowledge/[id]] update error:', error); + return NextResponse.json( + { error: 'Failed to update knowledge base entry' }, + { status: 500 } + ); + } + if (!data) { + return NextResponse.json( + { error: 'Knowledge base entry not found' }, + { status: 404 } + ); + } + + return NextResponse.json({ entry: data }); + } catch (err) { + return toErrorResponse(err); + } +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const ctx = await requireRole('admin'); + if (!canEditSettings(ctx.role)) { + throw new ForbiddenError('This action requires the admin role or higher'); + } + + const limit = checkRateLimit( + `admin:knowledgeDelete:${ctx.userId}`, + RATE_LIMITS.adminAction + ); + if (!limit.success) return rateLimitResponse(limit); + + const { id } = await params; + + // Account-scoped delete (id + account_id) for the same tenancy + // reason as the PATCH above. `.select('id').maybeSingle()` lets us + // tell "deleted" from "no such entry in this account" → 404. + const { data, error } = await ctx.supabase + .from('knowledge_base_entries') + .delete() + .eq('id', id) + .eq('account_id', ctx.accountId) + .select('id') + .maybeSingle(); + + if (error) { + console.error('[DELETE /api/ai/knowledge/[id]] delete error:', error); + return NextResponse.json( + { error: 'Failed to delete knowledge base entry' }, + { status: 500 } + ); + } + if (!data) { + return NextResponse.json( + { error: 'Knowledge base entry not found' }, + { status: 404 } + ); + } + + return NextResponse.json({ success: true }); + } catch (err) { + return toErrorResponse(err); + } +} diff --git a/src/app/api/ai/knowledge/route.ts b/src/app/api/ai/knowledge/route.ts new file mode 100644 index 0000000000..67d7b97036 --- /dev/null +++ b/src/app/api/ai/knowledge/route.ts @@ -0,0 +1,150 @@ +// ============================================================ +// /api/ai/knowledge +// +// GET — list this account's knowledge_base_entries. +// POST — create a manual entry (title, content). +// +// These are the *dashboard* endpoints for managing the AI +// assistant's knowledge base (spec §10), so they authenticate the +// normal way (cookie session) and go through the RLS client. Both +// methods are admin+ only: the KB is account-settings-class data +// (spec §4.2 RLS: all ops for `is_account_member(account_id, +// 'admin')`), so we enforce the same admin floor on the wire that +// the table's RLS policies enforce in the database. +// +// `token_estimate` is computed server-side from the content via the +// shared chars/4 heuristic (`estimateTokens`, spec §4.2 / §9) so the +// Settings size meter has a consistent value regardless of caller. +// `source_type` is fixed to 'manual' here; file-sourced entries come +// in through the dedicated upload route. +// ============================================================ + +import { NextResponse } from 'next/server'; + +import { + ForbiddenError, + requireRole, + toErrorResponse, +} from '@/lib/auth/account'; +import { canEditSettings } from '@/lib/auth/roles'; +import { estimateTokens } from '@/lib/ai/knowledge-base'; +import { + checkRateLimit, + rateLimitResponse, + RATE_LIMITS, +} from '@/lib/rate-limit'; + +const MAX_TITLE_LEN = 200; +// Hard ceiling on a single entry's content. A KB page far longer +// than this is almost certainly a misuse (paste a whole site) and +// would blow the prompt budget on its own — reject early rather than +// silently truncate. +const MAX_CONTENT_LEN = 200_000; + +// Columns safe to expose. Mirrors the `KnowledgeBaseEntry` shape. +const SAFE_COLUMNS = + 'id, account_id, title, content, source_type, source_filename, enabled, token_estimate, created_by_user_id, created_at, updated_at'; + +export async function GET() { + try { + const ctx = await requireRole('admin'); + // Defence in depth: the capability predicate is the single source + // of truth for "can edit account settings" (the KB is settings- + // class), alongside the role floor above and the table RLS policy. + if (!canEditSettings(ctx.role)) { + throw new ForbiddenError('This action requires the admin role or higher'); + } + + const { data, error } = await ctx.supabase + .from('knowledge_base_entries') + .select(SAFE_COLUMNS) + .eq('account_id', ctx.accountId) + .order('created_at', { ascending: false }); + + if (error) { + console.error('[GET /api/ai/knowledge] fetch error:', error); + return NextResponse.json( + { error: 'Failed to load knowledge base entries' }, + { status: 500 } + ); + } + + return NextResponse.json({ entries: data ?? [] }); + } catch (err) { + return toErrorResponse(err); + } +} + +export async function POST(request: Request) { + try { + const ctx = await requireRole('admin'); + if (!canEditSettings(ctx.role)) { + throw new ForbiddenError('This action requires the admin role or higher'); + } + + const limit = checkRateLimit( + `admin:knowledgeCreate:${ctx.userId}`, + RATE_LIMITS.adminAction + ); + if (!limit.success) return rateLimitResponse(limit); + + const body = (await request.json().catch(() => null)) as { + title?: unknown; + content?: unknown; + } | null; + + const rawTitle = typeof body?.title === 'string' ? body.title.trim() : ''; + if (!rawTitle) { + return NextResponse.json( + { error: "'title' is required" }, + { status: 400 } + ); + } + if (rawTitle.length > MAX_TITLE_LEN) { + return NextResponse.json( + { error: `Title must be ${MAX_TITLE_LEN} characters or fewer` }, + { status: 400 } + ); + } + + const rawContent = + typeof body?.content === 'string' ? body.content.trim() : ''; + if (!rawContent) { + return NextResponse.json( + { error: "'content' is required" }, + { status: 400 } + ); + } + if (rawContent.length > MAX_CONTENT_LEN) { + return NextResponse.json( + { error: `Content must be ${MAX_CONTENT_LEN} characters or fewer` }, + { status: 400 } + ); + } + + const { data, error } = await ctx.supabase + .from('knowledge_base_entries') + .insert({ + account_id: ctx.accountId, + title: rawTitle, + content: rawContent, + source_type: 'manual', + token_estimate: estimateTokens(rawContent), + created_by_user_id: ctx.userId, + }) + .select(SAFE_COLUMNS) + .single(); + + if (error || !data) { + console.error('[POST /api/ai/knowledge] insert error:', error); + return NextResponse.json( + { error: 'Failed to create knowledge base entry' }, + { status: 500 } + ); + } + + return NextResponse.json({ entry: data }, { status: 201 }); + } catch (err) { + return toErrorResponse(err); + } +} diff --git a/src/app/api/ai/knowledge/upload/route.ts b/src/app/api/ai/knowledge/upload/route.ts new file mode 100644 index 0000000000..b7823f15d8 --- /dev/null +++ b/src/app/api/ai/knowledge/upload/route.ts @@ -0,0 +1,219 @@ +// ============================================================ +// /api/ai/knowledge/upload +// +// POST — accept an uploaded file, extract its text, and create a +// `source_type='file'` knowledge_base_entries row. +// +// This is the file-import counterpart of the manual-create endpoint +// (`POST /api/ai/knowledge`, spec §9.4 / §10). It authenticates the +// dashboard way (cookie session → RLS client) and is admin+ only: +// the KB is account-settings-class data (spec §4.2 RLS: all ops for +// `is_account_member(account_id, 'admin')`), so we enforce the same +// admin floor on the wire that the table's RLS policies enforce in +// the database — the exact guard the sibling `route.ts` uses. +// +// Supported inputs (spec §9.4): +// .txt / .md — read directly as UTF-8 text. +// .pdf — text extracted server-side with `unpdf` (a serverless +// PDF.js build). On any extraction failure we return a +// clear 422 telling the user to paste the text manually, +// rather than persisting an empty/garbage entry. +// +// `token_estimate` is computed from the extracted text via the shared +// chars/4 heuristic (`estimateTokens`, spec §4.2 / §9), identical to +// the manual route, so the Settings size meter stays consistent +// regardless of how an entry was created. +// +// Runtime note: PDF extraction runs Mozilla's PDF.js, which needs the +// Node.js runtime — pin it explicitly so this route never gets pushed +// onto the Edge runtime where those APIs are unavailable. +// ============================================================ + +import { NextResponse } from 'next/server'; + +import { + ForbiddenError, + requireRole, + toErrorResponse, +} from '@/lib/auth/account'; +import { canEditSettings } from '@/lib/auth/roles'; +import { estimateTokens } from '@/lib/ai/knowledge-base'; +import { + checkRateLimit, + rateLimitResponse, + RATE_LIMITS, +} from '@/lib/rate-limit'; + +export const runtime = 'nodejs'; + +const MAX_TITLE_LEN = 200; +// Hard ceiling on a single entry's content, mirroring the manual +// create route. Extracted text longer than this is almost certainly a +// misuse (a whole manual / site dump) and would blow the prompt budget +// on its own — reject rather than silently truncate. +const MAX_CONTENT_LEN = 200_000; + +// Cap the raw upload so a single request can't pull a huge file into +// memory before we even look at it. 10 MB comfortably covers a text +// KB page or a reasonable PDF while bounding the PDF.js workload. +const MAX_FILE_BYTES = 10 * 1024 * 1024; + +// Columns safe to expose. Mirrors the `KnowledgeBaseEntry` shape and +// the SAFE_COLUMNS list in the sibling `route.ts`. +const SAFE_COLUMNS = + 'id, account_id, title, content, source_type, source_filename, enabled, token_estimate, created_by_user_id, created_at, updated_at'; + +/** Classify an upload by its filename extension. */ +type FileKind = 'text' | 'pdf' | 'unsupported'; + +function classify(filename: string): FileKind { + const lower = filename.toLowerCase(); + if (lower.endsWith('.txt') || lower.endsWith('.md')) return 'text'; + if (lower.endsWith('.pdf')) return 'pdf'; + return 'unsupported'; +} + +/** + * Extract text from a PDF buffer using `unpdf` (serverless PDF.js). + * Returns the merged text, or `null` on any failure / empty result so + * the caller can surface a clean 422 rather than persisting nothing. + */ +async function extractPdfText(bytes: Uint8Array): Promise { + try { + const { extractText, getDocumentProxy } = await import('unpdf'); + const pdf = await getDocumentProxy(bytes); + const { text } = await extractText(pdf, { mergePages: true }); + const trimmed = text.trim(); + return trimmed.length > 0 ? trimmed : null; + } catch (err) { + console.error('[POST /api/ai/knowledge/upload] PDF extract error:', err); + return null; + } +} + +export async function POST(request: Request) { + try { + const ctx = await requireRole('admin'); + // Defence in depth: the capability predicate is the single source + // of truth for "can edit account settings" (the KB is settings- + // class), alongside the role floor above and the table RLS policy. + if (!canEditSettings(ctx.role)) { + throw new ForbiddenError('This action requires the admin role or higher'); + } + + const limit = checkRateLimit( + `admin:knowledgeUpload:${ctx.userId}`, + RATE_LIMITS.adminAction + ); + if (!limit.success) return rateLimitResponse(limit); + + const form = await request.formData().catch(() => null); + const file = form?.get('file'); + if (!(file instanceof File)) { + return NextResponse.json( + { error: "A 'file' field is required" }, + { status: 400 } + ); + } + + const filename = file.name?.trim() || 'upload'; + const kind = classify(filename); + if (kind === 'unsupported') { + return NextResponse.json( + { error: 'Unsupported file type. Upload a .txt, .md, or .pdf file.' }, + { status: 400 } + ); + } + + if (file.size > MAX_FILE_BYTES) { + return NextResponse.json( + { + error: `File is too large. Maximum size is ${Math.floor( + MAX_FILE_BYTES / (1024 * 1024) + )} MB.`, + }, + { status: 400 } + ); + } + + const bytes = new Uint8Array(await file.arrayBuffer()); + if (bytes.byteLength === 0) { + return NextResponse.json( + { error: 'The uploaded file is empty.' }, + { status: 400 } + ); + } + + let content: string; + if (kind === 'pdf') { + const extracted = await extractPdfText(bytes); + if (extracted === null) { + // Fail safe: don't persist an empty/garbage entry. Tell the + // user exactly what to do instead (spec §1 fail-safe bias). + return NextResponse.json( + { + error: + "Couldn't extract text from this PDF. It may be scanned, " + + 'image-only, or encrypted. Copy the text and paste it in ' + + 'manually instead.', + }, + { status: 422 } + ); + } + content = extracted; + } else { + content = new TextDecoder('utf-8').decode(bytes).trim(); + } + + if (!content) { + return NextResponse.json( + { + error: + "Couldn't read any text from this file. Copy the text and " + + 'paste it in manually instead.', + }, + { status: 422 } + ); + } + if (content.length > MAX_CONTENT_LEN) { + return NextResponse.json( + { + error: `Extracted text is too long (${content.length} characters). Maximum is ${MAX_CONTENT_LEN}. Split it into smaller entries.`, + }, + { status: 400 } + ); + } + + // Derive a title from the filename (strip the extension), clamped + // to the same ceiling the manual route enforces. Falls back to the + // raw filename if stripping leaves nothing. + const baseTitle = filename.replace(/\.[^.]+$/, '').trim() || filename; + const title = baseTitle.slice(0, MAX_TITLE_LEN); + + const { data, error } = await ctx.supabase + .from('knowledge_base_entries') + .insert({ + account_id: ctx.accountId, + title, + content, + source_type: 'file', + source_filename: filename.slice(0, MAX_TITLE_LEN), + token_estimate: estimateTokens(content), + created_by_user_id: ctx.userId, + }) + .select(SAFE_COLUMNS) + .single(); + + if (error || !data) { + console.error('[POST /api/ai/knowledge/upload] insert error:', error); + return NextResponse.json( + { error: 'Failed to create knowledge base entry' }, + { status: 500 } + ); + } + + return NextResponse.json({ entry: data }, { status: 201 }); + } catch (err) { + return toErrorResponse(err); + } +} From 95d42f41a6778052bcb2f777428f5e56e4a0a77d Mon Sep 17 00:00:00 2001 From: Martinxmaina Date: Fri, 26 Jun 2026 00:05:32 +0300 Subject: [PATCH 4/8] feat(ai): Settings AI Assistant tab + knowledge base manager + inbox hand-off UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI layer for the assistant (spec §8, §9): - Settings → "AI Assistant": enable toggle, system-prompt editor, handoff message, escalation-keyword chips, persona (business name + logo upload via the existing account storage helper), model dropdown, daily reply cap; admin+ gated (RequireRole). Shows an "API key not configured" notice from the server-reported boolean. - KnowledgeBaseManager: CRUD + enable toggle + file import (.txt/.md/PDF), with a token-size meter vs a 150k soft budget (the RAG-upgrade signal). - Inbox: "🙋 Needs human" badge on escalated conversations, "AI" tag on bot-sent messages, and Take over / Hand back to AI header controls (reuse the existing conversations.update path; optimistic + realtime). typecheck + lint(0 errors) + test(526) + build all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/(dashboard)/inbox/page.tsx | 18 + src/app/(dashboard)/settings/page.tsx | 2 + src/components/inbox/conversation-list.tsx | 10 + src/components/inbox/message-bubble.tsx | 8 + src/components/inbox/message-thread.tsx | 97 +++ .../settings/ai-assistant-settings.tsx | 675 ++++++++++++++++++ .../settings/knowledge-base-manager.tsx | 620 ++++++++++++++++ src/components/settings/settings-overview.tsx | 5 + src/components/settings/settings-sections.ts | 3 + 9 files changed, 1438 insertions(+) create mode 100644 src/components/settings/ai-assistant-settings.tsx create mode 100644 src/components/settings/knowledge-base-manager.tsx diff --git a/src/app/(dashboard)/inbox/page.tsx b/src/app/(dashboard)/inbox/page.tsx index 7ec389549b..38eea74d17 100644 --- a/src/app/(dashboard)/inbox/page.tsx +++ b/src/app/(dashboard)/inbox/page.tsx @@ -537,6 +537,23 @@ export default function InboxPage() { [activeConversation] ); + // Reflect a Take over / Hand back to AI toggle. Mirrors the status / + // assign handlers: patch the list row + active conversation optimistically + // so the header control and the "Needs human" badge update instantly; the + // realtime conversations.UPDATE the Supabase write emits converges the + // same fields as a no-op. + const handleAiHandlingChange = useCallback( + (conversationId: string, updates: Partial) => { + setConversations((prev) => + prev.map((c) => (c.id === conversationId ? { ...c, ...updates } : c)) + ); + if (activeConversation?.id === conversationId) { + setActiveConversation((prev) => (prev ? { ...prev, ...updates } : prev)); + } + }, + [activeConversation] + ); + // On mobile (, appearance: , whatsapp: , + ai: , templates: , fields: , deals: , diff --git a/src/components/inbox/conversation-list.tsx b/src/components/inbox/conversation-list.tsx index 69f51dd6fe..8068d8458d 100644 --- a/src/components/inbox/conversation-list.tsx +++ b/src/components/inbox/conversation-list.tsx @@ -283,6 +283,16 @@ function ConversationItem({ {conversation.last_message_text || "No messages yet"}

+ {/* AI handed this thread off to a human — surfaces live via the + existing conversations realtime UPDATE (page bumps the row's + ai_* fields into state). Tied to status==='pending' so a + taken-over/handed-back thread (escalation cleared) drops it. */} + {conversation.ai_escalated_at && + conversation.status === "pending" && ( + + 🙋 Needs human + + )} {conversation.unread_count > 0 && ( {conversation.unread_count} diff --git a/src/components/inbox/message-bubble.tsx b/src/components/inbox/message-bubble.tsx index 510f25c4cd..d3b2f2ffed 100644 --- a/src/components/inbox/message-bubble.tsx +++ b/src/components/inbox/message-bubble.tsx @@ -249,6 +249,7 @@ export function MessageBubble({ onToggleReaction, }: MessageBubbleProps) { const isAgent = message.sender_type === "agent" || message.sender_type === "bot"; + const isBot = message.sender_type === "bot"; const time = format(new Date(message.created_at), "HH:mm"); // Row alignment + width cap are owned by so its hover @@ -282,6 +283,13 @@ export function MessageBubble({ isAgent ? "justify-end" : "justify-start", )} > + {/* Mark assistant-sent replies so agents can tell at a glance + which messages the AI sent (sender_type==='bot'). */} + {isBot && ( + + AI + + )} void; + /** + * Reflect an AI hand-off toggle (Take over / Hand back to AI). Patches + * the affected conversation's `ai_*` fields in the page's state so the + * header + list badge update immediately; the realtime UPDATE that the + * Supabase write emits converges the same fields as a no-op. + */ + onAiHandlingChange: ( + conversationId: string, + updates: Partial, + ) => void; /** * On mobile, the thread is shown full-screen with the conversation list * hidden. This callback lets the page deselect the active conversation @@ -159,6 +171,7 @@ export function MessageThread({ onUpdateMessage, onStatusChange, onAssignChange, + onAiHandlingChange, onBack, resyncToken = 0, onRefresh, @@ -778,6 +791,55 @@ export function MessageThread({ [conversation, onAssignChange], ); + // Take over (human owns the thread; AI goes silent) / Hand back to AI + // (re-arm the bot and clear the escalation). Reuses the same + // conversations.update() path the status/assign controls use; the + // backend send route already flips ai_handling=false on a manual reply, + // so this just covers the explicit toggle. Issue: spec §8. + const handleAiHandlingChange = useCallback( + async (handling: boolean) => { + if (!conversation) return; + + const updates: Partial = handling + ? { + // Hand back: re-arm the bot and clear the escalation markers so + // the "Needs human" badge drops and the thread is bot-eligible. + ai_handling: true, + ai_escalated_at: undefined, + ai_escalation_reason: undefined, + } + : { ai_handling: false }; + + const supabase = createClient(); + const { error } = await supabase + .from("conversations") + // null clears the columns in the DB; the optimistic `updates` + // patch above uses `undefined` to drop them from the client row. + .update( + handling + ? { + ai_handling: true, + ai_escalated_at: null, + ai_escalation_reason: null, + } + : { ai_handling: false }, + ) + .eq("id", conversation.id); + + if (error) { + console.error("Failed to update AI handling:", error); + toast.error( + handling ? "Failed to hand back to AI" : "Failed to take over", + ); + return; + } + + onAiHandlingChange(conversation.id, updates); + toast.success(handling ? "Handed back to AI" : "You've taken over"); + }, + [conversation, onAiHandlingChange], + ); + // Empty state — same WhatsApp-style doodle background as the active // thread below, so swapping between empty/selected doesn't change the // pattern under the user's eye. @@ -808,6 +870,14 @@ export function MessageThread({ ? (currentAssignee?.full_name ?? "Assigned") : "Assign"; + // AI hand-off state for the header controls. `ai_handling` defaults to + // true at the DB level, so an undefined value reads as "AI is driving". + // The bot is considered active only while it's handling AND hasn't + // escalated; once escalated or taken over we offer "Hand back to AI". + const aiHandling = conversation.ai_handling !== false; + const aiEscalated = !!conversation.ai_escalated_at; + const aiActive = aiHandling && !aiEscalated; + return ( // `min-w-0` is load-bearing: the page already puts min-w-0 on the // thread's flex *wrapper* (issue #165), but this root keeps the @@ -905,6 +975,33 @@ export function MessageThread({ )} + {/* AI hand-off control — matches the other header pills. While + the AI is driving (handling && not escalated) we offer "Take + over"; once escalated or a human has taken over we offer + "Hand back to AI". Sending a manual reply also takes over + (handled in the backend send route). Spec §8. */} + {aiActive ? ( + + ) : ( + + )} + {/* Status dropdown */} ) rendered at the bottom. +// +// Loads GET /api/ai/config (which lazily seeds a default row, so the +// form always binds to something) and saves the editable fields via +// PUT. The server reports `apiKeyConfigured` as a plain boolean — the +// Anthropic key itself never reaches the client (spec §9.1 / §12) — so +// we surface a "key not configured" notice when it's false: the +// assistant can't reply without it. +// +// Admin+ only. The settings page slots this behind the rail item; we +// also guard locally with so a non-admin who deep-links +// gets a clean message instead of failing API calls. Mirrors the save/ +// load/toast conventions of whatsapp-config.tsx and the logo upload of +// profile-form.tsx (via the shared account-scoped storage helper). +// ============================================================ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { toast } from 'sonner'; +import { + Loader2, + Upload, + Trash2, + X, + Plus, + AlertTriangle, + Bot, + ShieldAlert, +} from 'lucide-react'; + +import { uploadAccountMedia } from '@/lib/storage/upload-media'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Switch } from '@/components/ui/switch'; +import { Badge } from '@/components/ui/badge'; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from '@/components/ui/avatar'; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from '@/components/ui/card'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { RequireRole } from '@/components/auth/require-role'; +import { SettingsPanelHead } from './settings-panel-head'; +import { KnowledgeBaseManager } from './knowledge-base-manager'; +import type { AiAssistantConfig } from '@/types'; + +// The two models offered in the dropdown (spec §9.5 / §2). The stored +// `model` string may be something else (set via the API) — we render it +// as a fallback option so a custom value never silently disappears. +const MODEL_OPTIONS: { value: string; label: string }[] = [ + { value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6 — best quality' }, + { value: 'claude-haiku-4-5', label: 'Claude Haiku 4.5 — cheaper & faster' }, +]; + +// Logo upload: reuse the account-scoped storage helper + bucket the +// inbox/templates already use. The bucket accepts PNG/JPEG/WebP (see +// migration 023), so restrict to those — GIF is not accepted there. +const LOGO_BUCKET = 'chat-media'; +const LOGO_MAX_BYTES = 2 * 1024 * 1024; +const LOGO_ALLOWED_MIME = new Set([ + 'image/png', + 'image/jpeg', + 'image/webp', +]); + +// Bounds mirrored from the PUT /api/ai/config validator so the UI +// stops obvious over-length input before a round-trip. +const MAX_KEYWORDS = 100; +const MAX_KEYWORD_LEN = 80; +const MAX_DAILY_REPLY_CAP = 100_000; + +export function AiAssistantSettings() { + return ( + + + + + +

+ Only admins can configure the AI assistant. +

+

+ Ask an account admin if you need changes made here. +

+
+
+ + } + > + +
+ ); +} + +function AiAssistantSettingsInner() { + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [apiKeyConfigured, setApiKeyConfigured] = useState(true); + + // The persisted config (last-saved snapshot), used to compute the + // dirty state. `null` until the first load resolves. + const [config, setConfig] = useState(null); + + // Editable form fields. + const [enabled, setEnabled] = useState(false); + const [systemPrompt, setSystemPrompt] = useState(''); + const [handoffMessage, setHandoffMessage] = useState(''); + const [keywords, setKeywords] = useState([]); + const [keywordDraft, setKeywordDraft] = useState(''); + const [businessName, setBusinessName] = useState(''); + const [model, setModel] = useState('claude-sonnet-4-6'); + const [dailyReplyCap, setDailyReplyCap] = useState('500'); + + // Logo: existing URL + a staged (unsaved) replacement/removal. + const [logoUrl, setLogoUrl] = useState(null); + const [uploadingLogo, setUploadingLogo] = useState(false); + const logoInputRef = useRef(null); + + const seedFromConfig = useCallback((c: AiAssistantConfig) => { + setConfig(c); + setEnabled(c.enabled); + setSystemPrompt(c.system_prompt ?? ''); + setHandoffMessage(c.handoff_message ?? ''); + setKeywords(c.escalation_keywords ?? []); + setBusinessName(c.business_name ?? ''); + setModel(c.model ?? 'claude-sonnet-4-6'); + setDailyReplyCap(String(c.daily_reply_cap ?? 500)); + setLogoUrl(c.logo_url ?? null); + }, []); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await fetch('/api/ai/config', { cache: 'no-store' }); + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + toast.error(payload.error || 'Failed to load AI configuration'); + return; + } + const data = (await res.json()) as { + config: AiAssistantConfig; + apiKeyConfigured: boolean; + }; + seedFromConfig(data.config); + setApiKeyConfigured(data.apiKeyConfigured); + } catch (err) { + console.error('[AiAssistantSettings] load error:', err); + toast.error('Could not reach the server'); + } finally { + setLoading(false); + } + }, [seedFromConfig]); + + useEffect(() => { + void load(); + }, [load]); + + function addKeyword(raw: string) { + const kw = raw.trim().toLowerCase(); + if (!kw) return; + if (kw.length > MAX_KEYWORD_LEN) { + toast.error(`Keywords must be ${MAX_KEYWORD_LEN} characters or fewer`); + return; + } + if (keywords.includes(kw)) { + setKeywordDraft(''); + return; + } + if (keywords.length >= MAX_KEYWORDS) { + toast.error(`At most ${MAX_KEYWORDS} keywords`); + return; + } + setKeywords((prev) => [...prev, kw]); + setKeywordDraft(''); + } + + function removeKeyword(kw: string) { + setKeywords((prev) => prev.filter((k) => k !== kw)); + } + + function onKeywordKeyDown(e: React.KeyboardEvent) { + // Enter or comma commits the draft chip; Backspace on an empty + // draft pops the last chip (familiar tag-input affordances). + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + addKeyword(keywordDraft); + } else if (e.key === 'Backspace' && keywordDraft === '' && keywords.length) { + e.preventDefault(); + setKeywords((prev) => prev.slice(0, -1)); + } + } + + async function onPickLogo(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + e.target.value = ''; // reset so the same file can be re-picked + if (!file) return; + if (!LOGO_ALLOWED_MIME.has(file.type)) { + toast.error('Unsupported image type', { + description: 'Use PNG, JPG, or WebP.', + }); + return; + } + if (file.size > LOGO_MAX_BYTES) { + toast.error('Image is too large', { description: 'Maximum 2 MB.' }); + return; + } + setUploadingLogo(true); + try { + const { publicUrl } = await uploadAccountMedia(LOGO_BUCKET, file); + setLogoUrl(publicUrl); + toast.success('Logo uploaded. Save to apply.'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Upload failed'); + } finally { + setUploadingLogo(false); + } + } + + function removeLogo() { + setLogoUrl(null); + } + + async function handleSave() { + const prompt = systemPrompt.trim(); + if (!prompt) { + toast.error('The system prompt cannot be empty'); + return; + } + const cap = Number(dailyReplyCap); + if ( + !Number.isInteger(cap) || + cap < 1 || + cap > MAX_DAILY_REPLY_CAP + ) { + toast.error( + `Daily reply cap must be a whole number between 1 and ${MAX_DAILY_REPLY_CAP}`, + ); + return; + } + + setSaving(true); + try { + const res = await fetch('/api/ai/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + enabled, + system_prompt: prompt, + handoff_message: handoffMessage.trim() || null, + escalation_keywords: keywords, + business_name: businessName.trim() || null, + logo_url: logoUrl, + model, + daily_reply_cap: cap, + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data?.error || `Save failed (HTTP ${res.status})`); + } + seedFromConfig(data.config as AiAssistantConfig); + setApiKeyConfigured(Boolean(data.apiKeyConfigured)); + toast.success('AI assistant settings saved'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to save'); + } finally { + setSaving(false); + } + } + + // Dirty check — enables the Save button only when something changed + // from the last-saved snapshot. Keyword order is preserved on both + // sides, so a JSON compare is exact. + const dirty = + !!config && + (enabled !== config.enabled || + systemPrompt.trim() !== (config.system_prompt ?? '') || + handoffMessage.trim() !== (config.handoff_message ?? '') || + JSON.stringify(keywords) !== + JSON.stringify(config.escalation_keywords ?? []) || + businessName.trim() !== (config.business_name ?? '') || + (logoUrl ?? null) !== (config.logo_url ?? null) || + model !== (config.model ?? 'claude-sonnet-4-6') || + Number(dailyReplyCap) !== config.daily_reply_cap); + + // Render the stored model as an option even if it's outside the known + // list, so a custom value set via the API isn't silently dropped. + const modelOptions = MODEL_OPTIONS.some((m) => m.value === model) + ? MODEL_OPTIONS + : [...MODEL_OPTIONS, { value: model, label: model }]; + + const initial = (businessName || 'A').charAt(0).toUpperCase(); + + if (loading) { + return ( +
+ +
+ +
+
+ ); + } + + return ( +
+ + + {/* API key notice — the assistant can't reply without it. */} + {!apiKeyConfigured && ( + +
+ +
+ + Anthropic API key not configured + + + The server has no ANTHROPIC_API_KEY{' '} + set, so the assistant can't send replies — every message + will be handed to a human. Set the key in the server + environment to enable auto-replies. You can still configure + everything else here. + +
+
+
+ )} + + {/* Status & enable */} + + +
+ + + +
+

+ Auto-reply to inbound WhatsApp messages +

+

+ When on, the assistant answers confidently-grounded + questions and escalates anything it's unsure about to + a human. Off by default. +

+
+
+
+ + {enabled ? 'Enabled' : 'Disabled'} + + setEnabled(next === true)} + aria-label="Enable the AI assistant" + /> +
+
+
+ + {/* Prompt */} + + + Prompt & behaviour + + The system prompt steers tone and the grounding rules. The + default already instructs the assistant to answer only from + your knowledge base and to escalate when unsure. + + + +
+ +