Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
{
|
||||
"doc": "01-extension-points",
|
||||
"agent": "1/10",
|
||||
"generated_for": "support-auto",
|
||||
"related_docs": [
|
||||
"docs/support-auto/01-inventory.md",
|
||||
"docs/admin-roles-support/11-support-api-contract.json",
|
||||
"docs/admin-roles-support/12-support-backend.md",
|
||||
"docs/admin-roles-support/13-support-ratings.md",
|
||||
"docs/admin-roles-support/14-support-staff-ui.md",
|
||||
"docs/plan-permissions/01-feature-keys.json"
|
||||
],
|
||||
"ai_admin_settings_paths": {
|
||||
"web_ui": {
|
||||
"route": "/admin/settings",
|
||||
"file": "apps/web/src/routes/admin/settings/+page.svelte",
|
||||
"tab": "ai",
|
||||
"label": "AI roles",
|
||||
"roles_shown": ["processing", "vectorization", "docs_api", "support"]
|
||||
},
|
||||
"web_client": {
|
||||
"platform_settings": "apps/web/src/lib/admin-platform-settings.ts",
|
||||
"ai_roles": "apps/web/src/lib/admin-ai-roles.ts",
|
||||
"symbols": [
|
||||
"loadPlatformAdminSettings",
|
||||
"savePlatformAdminSettings",
|
||||
"saveAIRoles",
|
||||
"extractAIRoles",
|
||||
"testAIRole",
|
||||
"AI_ROLES",
|
||||
"AI_ROLE_META.support"
|
||||
],
|
||||
"api": {
|
||||
"get_put": "GET|PUT /api/admin/settings",
|
||||
"payload_key": "ai_roles",
|
||||
"role_probe_expected": "POST /api/admin/settings/ai-roles/{role}/test",
|
||||
"role_probe_status": "client_optional_404_skipped_not_mounted"
|
||||
}
|
||||
},
|
||||
"api": {
|
||||
"handlers": "apps/api/internal/httpapi/admin_settings_handlers.go",
|
||||
"symbols": ["handleGetAdminSettings", "handlePutAdminSettings"],
|
||||
"auth": ["RequireSession", "RequirePlatformAdmin"],
|
||||
"mount": "apps/api/internal/httpapi/server.go"
|
||||
},
|
||||
"platformsettings": {
|
||||
"package": "apps/api/internal/platformsettings",
|
||||
"role_constants": {
|
||||
"file": "apps/api/internal/platformsettings/types.go",
|
||||
"AIRoleProcessing": "processing",
|
||||
"AIRoleVectorization": "vectorization",
|
||||
"AIRoleDocsAPI": "docs_api",
|
||||
"AIRoleSupport": "support",
|
||||
"AIRoles": ["processing", "vectorization", "docs_api", "support"]
|
||||
},
|
||||
"symbols": [
|
||||
{
|
||||
"name": "ValidAIRole",
|
||||
"path": "apps/api/internal/platformsettings/ai_configs.go",
|
||||
"line": 30
|
||||
},
|
||||
{
|
||||
"name": "publicAIConfigs",
|
||||
"path": "apps/api/internal/platformsettings/ai_configs.go",
|
||||
"line": 39,
|
||||
"sym": "sym:descrybe-v2:apps/api/internal/platformsettings/ai_configs.go:39:publicAIConfigs"
|
||||
},
|
||||
{
|
||||
"name": "patchAIConfigs",
|
||||
"path": "apps/api/internal/platformsettings/ai_configs.go",
|
||||
"line": 116
|
||||
},
|
||||
{
|
||||
"name": "ResolveAIConfig",
|
||||
"path": "apps/api/internal/platformsettings/ai_configs.go",
|
||||
"line": 270
|
||||
}
|
||||
],
|
||||
"storage_note": "JSON in platform company_settings (SystemCompanyID); no dedicated AI roles table"
|
||||
},
|
||||
"aiprovider": {
|
||||
"file": "apps/api/internal/aiprovider/roles.go",
|
||||
"RoleSupport": "support",
|
||||
"symbols": [
|
||||
{
|
||||
"name": "ResolveCompleterForRole",
|
||||
"path": "apps/api/internal/aiprovider/roles.go",
|
||||
"line": 44,
|
||||
"sym": "sym:descrybe-v2:apps/api/internal/aiprovider/roles.go:44:ResolveCompleterForRole",
|
||||
"note": "May resolve RoleSupport when slot configured; ticket auto-reply must still gate via TryAutoReplyLLM"
|
||||
}
|
||||
]
|
||||
},
|
||||
"not_for_tickets": {
|
||||
"docs_ask": {
|
||||
"component": "apps/web/src/lib/components/docs/DocsAskGuide.svelte",
|
||||
"guide": "apps/web/src/lib/docs-guide",
|
||||
"rule": "rule-based only; never AIRoleDocsAPI or AIRoleSupport"
|
||||
},
|
||||
"tenant_ai_test": {
|
||||
"path": "POST /integrations/ai/test",
|
||||
"handler": "handleTestAIIntegration",
|
||||
"note": "company BYOK probe, not platform ai_roles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"support_desk_inventory": {
|
||||
"schema": [
|
||||
"apps/api/sql/schema/025_support_center.sql",
|
||||
"apps/api/sql/schema/027_capabilities_support_perf.sql",
|
||||
"apps/api/sql/schema/029_staff_roles.sql",
|
||||
"apps/api/sql/schema/030_support_desk.sql"
|
||||
],
|
||||
"service_package": "apps/api/internal/support",
|
||||
"key_symbols": [
|
||||
{
|
||||
"name": "Create",
|
||||
"path": "apps/api/internal/support/tickets.go",
|
||||
"line": 252,
|
||||
"hook_note": "Post-create extension for content auto-reply; must not call Completer directly"
|
||||
},
|
||||
{
|
||||
"name": "ReplyAsUser",
|
||||
"path": "apps/api/internal/support/tickets.go",
|
||||
"line": 306
|
||||
},
|
||||
{
|
||||
"name": "ReplyAsAgent",
|
||||
"path": "apps/api/internal/support/tickets.go",
|
||||
"line": 369,
|
||||
"hook_note": "Human-only; draft-assist UI should call TryAutoReplyLLM separately"
|
||||
},
|
||||
{
|
||||
"name": "GetAdmin",
|
||||
"path": "apps/api/internal/support/tickets.go",
|
||||
"line": 198,
|
||||
"sym": "sym:descrybe-v2:apps/api/internal/support/tickets.go:198:GetAdmin",
|
||||
"hook_note": "Richer staff detail / CSAT attach point"
|
||||
},
|
||||
{
|
||||
"name": "TryAutoReplyLLM",
|
||||
"path": "apps/api/internal/support/ai_auto_reply.go",
|
||||
"line": 18,
|
||||
"sym": "sym:descrybe-v2:apps/api/internal/support/ai_auto_reply.go:18:TryAutoReplyLLM",
|
||||
"status": "stub_refuses",
|
||||
"error": "ErrAIAutoReplyDisabled"
|
||||
},
|
||||
{
|
||||
"name": "SubmitCSAT",
|
||||
"path": "apps/api/internal/support/ratings.go",
|
||||
"line": 18
|
||||
},
|
||||
{
|
||||
"name": "AggregateCSAT",
|
||||
"path": "apps/api/internal/support/ratings.go",
|
||||
"line": 78
|
||||
},
|
||||
{
|
||||
"name": "Claim",
|
||||
"path": "apps/api/internal/support/desk.go",
|
||||
"line": 108
|
||||
},
|
||||
{
|
||||
"name": "ResolveStaffAccess",
|
||||
"path": "apps/api/internal/auth/staff.go",
|
||||
"line": 64
|
||||
}
|
||||
],
|
||||
"http_routes": {
|
||||
"customer_prefix": "/api/support",
|
||||
"staff_prefix": "/api/admin/support",
|
||||
"staff_middleware": "RequireSupportDesk",
|
||||
"admin_only_examples": [
|
||||
"GET /api/admin/support/csat",
|
||||
"PUT /api/admin/support/agents/{id}",
|
||||
"GET|PUT /api/admin/settings"
|
||||
]
|
||||
},
|
||||
"web": {
|
||||
"customer": [
|
||||
"apps/web/src/routes/support/+page.svelte",
|
||||
"apps/web/src/routes/support/new/+page.svelte",
|
||||
"apps/web/src/routes/support/[ticketId]/+page.svelte"
|
||||
],
|
||||
"staff": [
|
||||
"apps/web/src/routes/admin/support/+page.svelte",
|
||||
"apps/web/src/routes/admin/support/[id]/+page.svelte"
|
||||
],
|
||||
"clients": [
|
||||
"apps/web/src/lib/support/api.ts",
|
||||
"apps/web/src/lib/support/admin-api.ts",
|
||||
"apps/web/src/lib/support/types.ts"
|
||||
]
|
||||
},
|
||||
"plan_permission_keys": {
|
||||
"section": "support",
|
||||
"keys": [
|
||||
"support.center",
|
||||
"support.ticket_create",
|
||||
"support.ticket_thread"
|
||||
],
|
||||
"shell": ["shell.support_notifications"],
|
||||
"defaults": "ON for all public plans per docs/plan-permissions/06-defaults-matrix",
|
||||
"missing_for_auto_reply": [
|
||||
"no support.auto_reply key",
|
||||
"no support.ai_assist key",
|
||||
"CSAT not a separate feature key"
|
||||
]
|
||||
}
|
||||
},
|
||||
"extension_points": {
|
||||
"richer_ticket_detail": [
|
||||
{
|
||||
"id": "staff_get_payload",
|
||||
"target": "GetAdmin / handleAdminGetSupportTicket",
|
||||
"path": "apps/api/internal/support/tickets.go",
|
||||
"opportunity": "Already joins company/requester/assignee + messages + attachCSAT; expose resolved_by, richer org context"
|
||||
},
|
||||
{
|
||||
"id": "admin_client_types",
|
||||
"target": "SupportTicket in admin-api.ts",
|
||||
"path": "apps/web/src/lib/support/admin-api.ts",
|
||||
"gap": "Omits csat / csat_eligible / resolved_by_user_id despite API capability"
|
||||
},
|
||||
{
|
||||
"id": "staff_detail_ui",
|
||||
"target": "admin/support/[id]/+page.svelte",
|
||||
"gap": "Shows subject, category, priority, company, thread, claim/assign — no CSAT panel, no linked resources, no attachments"
|
||||
},
|
||||
{
|
||||
"id": "csat_aggregate_ui",
|
||||
"target": "GET /api/admin/support/csat",
|
||||
"gap": "API exists; no admin UI page/widget"
|
||||
},
|
||||
{
|
||||
"id": "schema_extensions",
|
||||
"note": "No attachments/metadata/product_id columns; author_role=system available for bot messages; csat_token_* unused"
|
||||
}
|
||||
],
|
||||
"auto_response_from_question_content": [
|
||||
{
|
||||
"id": "post_create_hook",
|
||||
"preferred_insert": "after successful Create commit (or async job enqueued from handleCreateSupportTicket)",
|
||||
"inputs": ["CreateInput.subject", "CreateInput.body", "CreateInput.category"],
|
||||
"pattern_reuse": "DocsAskGuide / docs-guide decision tree (rule-based, no LLM)",
|
||||
"output": "insert support_messages author_role=system OR ReplyAsAgent-equivalent with system author"
|
||||
},
|
||||
{
|
||||
"id": "post_user_reply_hook",
|
||||
"target": "ReplyAsUser",
|
||||
"note": "Same matcher for follow-ups; reopen already sets status=open"
|
||||
},
|
||||
{
|
||||
"id": "missing_today",
|
||||
"items": [
|
||||
"no FAQ/canned reply store",
|
||||
"no keyword classifier",
|
||||
"no system first-response writer"
|
||||
]
|
||||
}
|
||||
],
|
||||
"ai_fallback_when_auto_fails": [
|
||||
{
|
||||
"id": "sole_llm_gate",
|
||||
"target": "TryAutoReplyLLM",
|
||||
"path": "apps/api/internal/support/ai_auto_reply.go",
|
||||
"line": 18,
|
||||
"current": "returns ErrAIAutoReplyDisabled",
|
||||
"intended_deps": [
|
||||
"platformsettings.AIRoleSupport / ResolveAIConfig",
|
||||
"aiprovider.ResolveCompleterForRole(..., RoleSupport)"
|
||||
],
|
||||
"contract": "Create/ReplyAsUser/ReplyAsAgent must not call Completer; route AI through this function"
|
||||
},
|
||||
{
|
||||
"id": "config_prerequisite",
|
||||
"path": "/admin/settings ai_roles.support",
|
||||
"fields": ["provider", "base_url", "model", "api_key", "enabled"],
|
||||
"note": "Slot configurable today; product still needs explicit enable beyond stub"
|
||||
},
|
||||
{
|
||||
"id": "suggested_flow",
|
||||
"steps": [
|
||||
"rule/FAQ matcher on question content",
|
||||
"on miss or low confidence → TryAutoReplyLLM",
|
||||
"on LLM refuse/error → leave open for staff claim (no silent drop)",
|
||||
"optional: draft-only for agent UI vs auto-post system message"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "do_not",
|
||||
"items": [
|
||||
"wire DocsAskGuide to AIRoleSupport",
|
||||
"use AIRoleDocsAPI for tickets",
|
||||
"call ResolveCompleterForRole from ReplyAsAgent"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"gaps_summary": {
|
||||
"ai_admin": "paths exist and support role is first-class",
|
||||
"auto_reply": "stub only; no content matcher; no enable flag/job",
|
||||
"staff_detail": "basic thread UI; CSAT/org depth/attachments missing in staff client",
|
||||
"plan_keys": "tenant support.* only; no auto-reply entitlements"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
# 01 — Support desk + admin AI inventory (auto-reply / AI assist)
|
||||
|
||||
**Agent:** 1/10 · **Mode:** read-only inventory
|
||||
**Tools:** codehelper `project_context` → `kickoff` → `query`/`investigate`; Read/Grep/Shell fallback (`read_workspace_file` / `write_workspace_file` unavailable in focused MCP profile)
|
||||
**Related:** `docs/admin-roles-support/*`, `docs/plan-permissions/01-feature-keys.json`
|
||||
|
||||
---
|
||||
|
||||
## Executive summary
|
||||
|
||||
Support desk is **shipped**: customer tickets + CSAT, staff queue/claim with `support_staff`, platform admin aggregate CSAT, agents directory. Platform AI already has a dedicated **`support` role slot** (provider / base_url / model / encrypted key) editable at **`/admin/settings` → AI roles**. LLM ticket auto-reply is an intentional **safe stub** (`TryAutoReplyLLM` → always `ErrAIAutoReplyDisabled`); Create / ReplyAsUser / ReplyAsAgent must not call Completers. No FAQ/canned/rule-based ticket auto-response exists yet (docs Ask is a separate rule tree and must not use `AIRoleSupport`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Support desk — current surface
|
||||
|
||||
### 1.1 Schema
|
||||
|
||||
| Migration | Purpose |
|
||||
|-----------|---------|
|
||||
| `025_support_center.sql` | `support_tickets`, `support_messages`, `support_notifications` |
|
||||
| `027_capabilities_support_perf.sql` | assignee / activity indexes |
|
||||
| `029_staff_roles.sql` | `users.staff_role` ∈ `admin\|developer\|support_staff` |
|
||||
| `030_support_desk.sql` | `resolved_by_user_id`, CSAT table + token cols, unassigned index, `ticket_claimed` / `csat_requested` kinds |
|
||||
|
||||
**Ticket enums:** status `open|pending|resolved|closed`; category `billing|bug|account|other`; priority `low|normal|high`.
|
||||
**Message `author_role`:** `user|agent|system` (authorship, not staff RBAC).
|
||||
**CSAT:** `support_csat_ratings` — one rating per ticket (score 1–5 + comment). Public token columns exist but are **not wired**.
|
||||
|
||||
### 1.2 Domain packages
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `apps/api/internal/support/types.go` | `Ticket`, `Message`, `CSAT*`, `CreateInput`, `ReplyInput`, list scopes |
|
||||
| `apps/api/internal/support/tickets.go` | `Create`, `Get`/`GetAdmin`, `ReplyAsUser`, `ReplyAsAgent`, `UpdateAdmin`, `listMessages` |
|
||||
| `apps/api/internal/support/desk.go` | scopes, `Claim`/`Release`, `GetAdminForActor` |
|
||||
| `apps/api/internal/support/agents.go` | agents directory via `staff_role` |
|
||||
| `apps/api/internal/support/ratings.go` | `SubmitCSAT`, `AggregateCSAT`, `attachCSAT` |
|
||||
| `apps/api/internal/support/ai_auto_reply.go` | **`TryAutoReplyLLM` stub** (sole LLM gate) |
|
||||
| `apps/api/internal/support/errors.go` | `ErrAIAutoReplyDisabled` |
|
||||
|
||||
### 1.3 HTTP API
|
||||
|
||||
**Customer** (`RequireSession` + `RequireCompany`) — `server.go` ~494–501:
|
||||
|
||||
| Method | Path |
|
||||
|--------|------|
|
||||
| GET/POST | `/api/support/tickets` |
|
||||
| GET | `/api/support/tickets/{id}` |
|
||||
| POST | `/api/support/tickets/{id}/messages` |
|
||||
| POST | `/api/support/tickets/{id}/csat` |
|
||||
| GET/POST | `/api/support/notifications` (+ read / read-all) |
|
||||
|
||||
**Staff desk** (`RequireSupportDesk`) — ~304–313:
|
||||
|
||||
| Method | Path |
|
||||
|--------|------|
|
||||
| GET | `/api/admin/support/tickets` (`scope=inbox\|mine\|unassigned\|all`) |
|
||||
| GET/PATCH | `/api/admin/support/tickets/{id}` |
|
||||
| POST | `…/messages`, `…/claim`, `…/release` |
|
||||
| GET | `/api/admin/support/agents` |
|
||||
|
||||
**Platform admin only** — ~316–332:
|
||||
|
||||
| Method | Path |
|
||||
|--------|------|
|
||||
| GET | `/api/admin/support/csat?from=&to=` |
|
||||
| PUT | `/api/admin/support/agents/{id}` |
|
||||
| GET/PUT | `/api/admin/settings` (includes `ai_roles`) |
|
||||
| PATCH | `/api/admin/users/{id}/staff-role` |
|
||||
|
||||
Contract design: `docs/admin-roles-support/11-support-api-contract.json` · backend notes: `12-support-backend.md` · CSAT: `13-support-ratings.md`.
|
||||
|
||||
### 1.4 Staff access
|
||||
|
||||
| Symbol | Path | Behavior |
|
||||
|--------|------|----------|
|
||||
| `ResolveStaffAccess` | `apps/api/internal/auth/staff.go` | `support_staff` → desk only; `admin`/`developer`/legacy platform admin → full |
|
||||
| `RequireSupportDesk` | `httpapi/middleware.go` | desk routes |
|
||||
| `RequirePlatformAdmin` | same | excludes `support_staff` from billing/settings/users |
|
||||
| `requireSupportDesk` / `requirePlatformAdmin` | `apps/web/src/lib/admin-gate.ts` | UI gates |
|
||||
|
||||
Visibility: queue + claim (`inbox`/`mine`/`unassigned`; `all` = full admin). Cross-assignee GET → 404; reply on another’s claim → 409.
|
||||
|
||||
### 1.5 Web UI
|
||||
|
||||
| Persona | Routes | Client |
|
||||
|---------|--------|--------|
|
||||
| Customer | `/support`, `/support/new`, `/support/[ticketId]` | `$lib/support/api.ts`, CSAT via `SupportTicketRating.svelte` |
|
||||
| Staff | `/admin/support`, `/admin/support/[id]` | `$lib/support/admin-api.ts` |
|
||||
| Role grant | `/admin/users` | `PATCH …/staff-role` |
|
||||
| AI keys | `/admin/settings` tab **AI roles** | `$lib/admin-ai-roles.ts` |
|
||||
|
||||
Docs: `14-support-staff-ui.md`, `15-user-support-ui.md`.
|
||||
|
||||
### 1.6 Plan-permission keys (tenant Support Center)
|
||||
|
||||
From `docs/plan-permissions/01-feature-keys.json` / `plan-feature-catalog.ts` — section `support`, defaults **ON** all public plans:
|
||||
|
||||
| Key | Route |
|
||||
|-----|-------|
|
||||
| `support.center` | `/support` |
|
||||
| `support.ticket_create` | `/support/new` |
|
||||
| `support.ticket_thread` | `/support/[ticketId]` |
|
||||
| `shell.support_notifications` | notification bell |
|
||||
|
||||
No plan keys for staff desk, AI auto-reply, or CSAT (CSAT rides on thread eligibility).
|
||||
|
||||
---
|
||||
|
||||
## 2. Admin AI configuration (platform)
|
||||
|
||||
### 2.1 Role catalog
|
||||
|
||||
Stored in platform settings JSON (`company_settings` / SystemCompanyID) — **not** a dedicated SQL table.
|
||||
|
||||
| Role key | Constant | Purpose today |
|
||||
|----------|----------|---------------|
|
||||
| `processing` | `AIRoleProcessing` / `RoleProcessing` | Product pipeline chat; falls back to legacy `openai` + env |
|
||||
| `vectorization` | `AIRoleVectorization` | Embeddings; env fallback |
|
||||
| `docs_api` | `AIRoleDocsAPI` | Config slot only — **/docs Ask must not use it** |
|
||||
| `support` | `AIRoleSupport` / `RoleSupport` | **Ticket-assist slot** — configure only; auto-reply gated by stub |
|
||||
|
||||
Fields per role: `provider`, `base_url`, `model`, encrypted `api_key`, `enabled`, optional `extras`. Secrets masked on GET (`api_key_last4` / masked).
|
||||
|
||||
### 2.2 Existing AI admin settings paths
|
||||
|
||||
| Layer | Path / symbol |
|
||||
|-------|----------------|
|
||||
| **UI** | `apps/web/src/routes/admin/settings/+page.svelte` — tabs Overview / **AI roles** / Mail / Other / Admin Users |
|
||||
| **Client load/save** | `apps/web/src/lib/admin-platform-settings.ts` — `GET|PUT /api/admin/settings` |
|
||||
| **Client AI roles** | `apps/web/src/lib/admin-ai-roles.ts` — `AI_ROLES`, `saveAIRoles`, `extractAIRoles`, `testAIRole` |
|
||||
| **API handlers** | `apps/api/internal/httpapi/admin_settings_handlers.go` — `handleGetAdminSettings` / `handlePutAdminSettings` |
|
||||
| **Storage / resolve** | `apps/api/internal/platformsettings/` — `ai_configs.go` (`publicAIConfigs`, `patchAIConfigs`, `ResolveAIConfig`), `types.go` (`AIRoles`, `AIRoleSupport`) |
|
||||
| **Runtime completer** | `apps/api/internal/aiprovider/roles.go` — `ResolveCompleterForRole` (supports `RoleSupport` when configured) |
|
||||
| **Tenant BYOK test (not platform roles)** | `POST /api/…/integrations/ai/test` — `handleTestAIIntegration` |
|
||||
| **Optional role probe** | Client expects `POST /api/admin/settings/ai-roles/{role}/test` — **not mounted**; `testAIRole` treats 404/501 as skipped |
|
||||
|
||||
**ASSUMPTION:** Enabling LLM ticket assist should use `ResolveCompleterForRole(..., RoleSupport)` (or `ResolveAIConfig(AIRoleSupport)`) **only** inside/behind `TryAutoReplyLLM`, never from Create/Reply handlers directly.
|
||||
|
||||
### 2.3 Docs Ask (non-ticket, rule-based)
|
||||
|
||||
`DocsAskGuide.svelte` + `$lib/docs-guide` — decision tree only. Explicitly must never call `AIRoleDocsAPI` or `AIRoleSupport`. Useful **pattern** for content-based auto-response before LLM fallback — not wired to tickets.
|
||||
|
||||
---
|
||||
|
||||
## 3. Auto-reply / AI assist — current state
|
||||
|
||||
| Capability | Status |
|
||||
|------------|--------|
|
||||
| Admin config slot `ai_roles.support` | **Exists** (UI + API + tests) |
|
||||
| `aiprovider.ResolveCompleterForRole(RoleSupport)` | **Resolves** when slot configured |
|
||||
| `support.TryAutoReplyLLM` | **Stub refuses** (`ErrAIAutoReplyDisabled`) |
|
||||
| Call sites from Create / Reply* | **Forbidden by comments**; Create documents “do not call” |
|
||||
| FAQ / keyword / canned auto-reply | **Absent** |
|
||||
| Draft-assist UI for agents | **Absent** |
|
||||
| Job/queue for async auto-reply | **Absent** |
|
||||
| Plan/feature flag for auto-reply | **Absent** |
|
||||
| `author_role=system` messages | Schema allows; **no auto-writer** |
|
||||
|
||||
Test lock: `ai_auto_reply_test.go` — `TestTryAutoReplyLLM_refuses`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Gaps (richer detail / auto-response / AI fallback)
|
||||
|
||||
### 4.1 Richer ticket detail
|
||||
|
||||
- Staff `admin-api.ts` `SupportTicket` omits `csat`, `csat_eligible`, `resolved_by_user_id` — API can attach CSAT on GetAdmin but **admin UI/types ignore it**.
|
||||
- No admin CSAT aggregate UI for `GET /api/admin/support/csat`.
|
||||
- No attachments, linked product/feed IDs, or structured metadata on tickets/messages.
|
||||
- No SLA timers, tags, macros, or customer org context beyond company name + requester email.
|
||||
- Public CSAT token invite flow (`csat_token_hash`) unused.
|
||||
- Message list is plain body text only (no markdown/HTML sanitization layer called out for AI drafts).
|
||||
|
||||
### 4.2 Auto-response from question content
|
||||
|
||||
- No classifier on `CreateInput.subject` / `body` or category.
|
||||
- No canned/FAQ store or docs-guide-like tree for tickets.
|
||||
- No `author_role=system` first-response after create.
|
||||
- Create commits then returns — **no post-commit hook** for auto-reply (natural extension: after successful `Create` / `ReplyAsUser`, call a non-LLM matcher then optionally `TryAutoReplyLLM`).
|
||||
|
||||
### 4.3 AI fallback when auto fails
|
||||
|
||||
- Intended entry: implement `TryAutoReplyLLM` using `AIRoleSupport` completer; today always fails closed.
|
||||
- No confidence threshold, human-handoff status (`pending`), or “AI attempted” audit fields.
|
||||
- No product enable flag separate from `ai_roles.support.enabled` (comments require an **explicit** gate).
|
||||
- Docs Ask must remain separate — do not reuse docs_api for ticket LLM.
|
||||
|
||||
---
|
||||
|
||||
## 5. Prior inventory note
|
||||
|
||||
`docs/admin-roles-support/02-current-inventory.md` is **stale** relative to shipped staff roles + CSAT (pre–agents 6/12/13/14). Prefer `12`–`15` + this doc for current support/AI state.
|
||||
|
||||
---
|
||||
|
||||
## 6. Sources (codehelper + docs)
|
||||
|
||||
- Investigate: `TryAutoReplyLLM`, `publicAIConfigs`, `GetAdmin`
|
||||
- Query: support tickets/CSAT, admin AI settings, plan support keys
|
||||
- Docs: `admin-roles-support/11–15`, `plan-permissions/01-feature-keys.json`, `06-defaults-matrix`
|
||||
@@ -0,0 +1,621 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Descrybe v2 Support Auto-Response + Rich Ticket Contract",
|
||||
"version": "02.0.0-design",
|
||||
"status": "design-only",
|
||||
"related": [
|
||||
"docs/support-auto/02-contract.md",
|
||||
"docs/support-auto/01-inventory.md",
|
||||
"docs/support-auto/01-extension-points.json",
|
||||
"docs/admin-roles-support/11-support-design.md",
|
||||
"docs/admin-roles-support/11-support-api-contract.json"
|
||||
],
|
||||
"inventory_note": "Reconciled with agent-1 01-inventory + 01-extension-points; Create post-commit is the preferred orchestrator hook.",
|
||||
"discovered_symbols": {
|
||||
"ai_gate": {
|
||||
"symbol": "TryAutoReplyLLM",
|
||||
"path": "apps/api/internal/support/ai_auto_reply.go",
|
||||
"behavior_today": "returns ErrAIAutoReplyDisabled"
|
||||
},
|
||||
"create_hook": {
|
||||
"symbol": "Create",
|
||||
"path": "apps/api/internal/support/tickets.go",
|
||||
"line": 252,
|
||||
"note": "Post-commit: content matcher then optional TryAutoReplyLLM; never Completer directly"
|
||||
},
|
||||
"ai_role_constant": {
|
||||
"symbol": "AIRoleSupport",
|
||||
"value": "support",
|
||||
"path": "apps/api/internal/platformsettings/types.go"
|
||||
},
|
||||
"ai_resolve": {
|
||||
"symbol": "ResolveAIConfig",
|
||||
"path": "apps/api/internal/platformsettings/ai_configs.go"
|
||||
},
|
||||
"completer": {
|
||||
"symbol": "ResolveCompleterForRole",
|
||||
"role": "RoleSupport",
|
||||
"path": "apps/api/internal/aiprovider/roles.go",
|
||||
"notes": "Platform role only; no env fallback when unset"
|
||||
},
|
||||
"admin_api": {
|
||||
"path": "/api/admin/settings",
|
||||
"field": "ai_roles.support",
|
||||
"handlers": "apps/api/internal/httpapi/admin_settings_handlers.go",
|
||||
"web": [
|
||||
"apps/web/src/lib/admin-ai-roles.ts",
|
||||
"apps/web/src/lib/admin-platform-settings.ts",
|
||||
"apps/web/src/routes/admin/settings/+page.svelte"
|
||||
]
|
||||
},
|
||||
"ticket_type": {
|
||||
"symbol": "Ticket",
|
||||
"path": "apps/api/internal/support/types.go"
|
||||
},
|
||||
"schema": [
|
||||
"apps/api/sql/schema/025_support_center.sql",
|
||||
"apps/api/sql/schema/027_capabilities_support_perf.sql",
|
||||
"apps/api/sql/schema/029_staff_roles.sql",
|
||||
"apps/api/sql/schema/030_support_desk.sql"
|
||||
],
|
||||
"docs_ask_pattern": {
|
||||
"component": "apps/web/src/lib/components/docs/DocsAskGuide.svelte",
|
||||
"rule": "content match pattern only; never AIRoleSupport"
|
||||
},
|
||||
"jobs_pattern": {
|
||||
"symbol": "EnqueueProcessingJob",
|
||||
"path": "apps/api/internal/jobs/river.go",
|
||||
"notes": "River deferred; Postgres pending + worker poller"
|
||||
},
|
||||
"rate_limit_pattern": {
|
||||
"path": "apps/api/internal/httpapi/ratelimit.go"
|
||||
}
|
||||
},
|
||||
"conventions": {
|
||||
"timestamps": "RFC3339 UTC",
|
||||
"ids": "UUID string",
|
||||
"errors": {
|
||||
"shape": { "error": "string", "code": "string?" }
|
||||
},
|
||||
"additive_only": true
|
||||
},
|
||||
"enums": {
|
||||
"ticket_status": ["open", "pending", "resolved", "closed"],
|
||||
"ticket_priority": ["low", "normal", "high"],
|
||||
"ticket_category_seed": ["billing", "bug", "account", "other"],
|
||||
"author_role": ["user", "agent", "system"],
|
||||
"auto_source": ["kb", "template", "ai"],
|
||||
"auto_reply_status": [
|
||||
"none",
|
||||
"matched",
|
||||
"ai_draft",
|
||||
"ai_sent",
|
||||
"skipped",
|
||||
"failed",
|
||||
"handed_off"
|
||||
],
|
||||
"ai_delivery": ["draft", "auto_send"],
|
||||
"match_kind": ["kb_article", "template", "none"],
|
||||
"notification_kind_additive": ["auto_reply"],
|
||||
"support_auto_job_status": ["pending", "running", "done", "failed"]
|
||||
},
|
||||
"types": {
|
||||
"CustomerContextSnapshot": {
|
||||
"type": "object",
|
||||
"required": ["captured_at", "company_id", "user_id"],
|
||||
"properties": {
|
||||
"captured_at": { "type": "string", "format": "date-time" },
|
||||
"company_id": { "type": "string", "format": "uuid" },
|
||||
"company_name": { "type": "string" },
|
||||
"plan_slug": { "type": ["string", "null"] },
|
||||
"user_id": { "type": "string", "format": "uuid" },
|
||||
"user_email": { "type": "string" },
|
||||
"user_name": { "type": ["string", "null"] },
|
||||
"locale": { "type": ["string", "null"] },
|
||||
"timezone": { "type": ["string", "null"] },
|
||||
"related_product": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": ["string", "null"], "format": "uuid" },
|
||||
"sku": { "type": ["string", "null"], "maxLength": 128 },
|
||||
"title": { "type": ["string", "null"] }
|
||||
}
|
||||
},
|
||||
"signals": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"open_ticket_count": { "type": "integer", "minimum": 0 },
|
||||
"last_ticket_category": { "type": ["string", "null"] }
|
||||
}
|
||||
}
|
||||
},
|
||||
"constraints": {
|
||||
"filled_server_side": true,
|
||||
"immutable_after_create": true,
|
||||
"max_bytes": 4096,
|
||||
"forbidden": [
|
||||
"api_keys",
|
||||
"smtp_secrets",
|
||||
"byok_material",
|
||||
"payment_tokens",
|
||||
"other_tenant_data"
|
||||
]
|
||||
}
|
||||
},
|
||||
"SupportTicketRich": {
|
||||
"description": "Additive fields on SupportTicket from 11-contract",
|
||||
"extends": "docs/admin-roles-support/11-support-api-contract.json#/types/SupportTicket",
|
||||
"properties": {
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "maxLength": 40 },
|
||||
"maxItems": 10
|
||||
},
|
||||
"related_product_id": { "type": ["string", "null"], "format": "uuid" },
|
||||
"related_sku": { "type": ["string", "null"], "maxLength": 128 },
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "Taxonomy slug; seeds billing|bug|account|other; admin may add active slugs"
|
||||
},
|
||||
"customer_context": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/types/CustomerContextSnapshot" },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"description": "Staff detail full; customer detail may omit signals"
|
||||
},
|
||||
"auto_reply_disabled": { "type": "boolean", "default": false },
|
||||
"auto_reply_status": { "$ref": "#/enums/auto_reply_status" },
|
||||
"auto_reply_attempted_at": {
|
||||
"type": ["string", "null"],
|
||||
"format": "date-time"
|
||||
},
|
||||
"auto_reply_message_id": {
|
||||
"type": ["string", "null"],
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SupportMessageAuto": {
|
||||
"description": "Additive fields on SupportMessage",
|
||||
"properties": {
|
||||
"is_auto_reply": { "type": "boolean", "default": false },
|
||||
"auto_source": {
|
||||
"oneOf": [{ "$ref": "#/enums/auto_source" }, { "type": "null" }]
|
||||
},
|
||||
"auto_confidence": {
|
||||
"type": ["number", "null"],
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"auto_ref_type": {
|
||||
"type": ["string", "null"],
|
||||
"enum": ["kb_article", "template", null]
|
||||
},
|
||||
"auto_ref_id": { "type": ["string", "null"], "format": "uuid" }
|
||||
},
|
||||
"labeling": {
|
||||
"author_role": "system",
|
||||
"author_user_id": null,
|
||||
"customer_badge": "Automated|AI-assisted",
|
||||
"never_impersonate_human_agent": true
|
||||
}
|
||||
},
|
||||
"SupportCategory": {
|
||||
"type": "object",
|
||||
"required": ["slug", "label", "is_active"],
|
||||
"properties": {
|
||||
"slug": { "type": "string", "maxLength": 32 },
|
||||
"label": { "type": "string", "maxLength": 80 },
|
||||
"parent_slug": { "type": ["string", "null"] },
|
||||
"sort_order": { "type": "integer" },
|
||||
"is_active": { "type": "boolean" },
|
||||
"match_intents": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"SupportKbArticle": {
|
||||
"type": "object",
|
||||
"required": ["id", "slug", "title", "body_md", "is_published"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "format": "uuid" },
|
||||
"slug": { "type": "string" },
|
||||
"title": { "type": "string", "maxLength": 200 },
|
||||
"body_md": { "type": "string", "maxLength": 20000 },
|
||||
"category_slugs": { "type": "array", "items": { "type": "string" } },
|
||||
"keywords": { "type": "array", "items": { "type": "string" } },
|
||||
"intent_keys": { "type": "array", "items": { "type": "string" } },
|
||||
"is_published": { "type": "boolean" },
|
||||
"priority_weight": { "type": "integer", "default": 0 }
|
||||
}
|
||||
},
|
||||
"SupportReplyTemplate": {
|
||||
"type": "object",
|
||||
"required": ["id", "name", "body", "is_active"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "format": "uuid" },
|
||||
"name": { "type": "string", "maxLength": 120 },
|
||||
"body": { "type": "string", "maxLength": 10000 },
|
||||
"category_slugs": { "type": "array", "items": { "type": "string" } },
|
||||
"keywords": { "type": "array", "items": { "type": "string" } },
|
||||
"intent_keys": { "type": "array", "items": { "type": "string" } },
|
||||
"is_active": { "type": "boolean" },
|
||||
"placeholders_allowlist": ["subject", "related_sku", "company_name"]
|
||||
}
|
||||
},
|
||||
"MatchResult": {
|
||||
"type": "object",
|
||||
"required": ["kind", "confidence"],
|
||||
"properties": {
|
||||
"kind": { "$ref": "#/enums/match_kind" },
|
||||
"ref_id": { "type": ["string", "null"], "format": "uuid" },
|
||||
"confidence": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"body": { "type": ["string", "null"] },
|
||||
"label": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"AiAssistResult": {
|
||||
"type": "object",
|
||||
"required": ["body", "confidence", "handoff"],
|
||||
"properties": {
|
||||
"body": { "type": "string", "maxLength": 10000 },
|
||||
"confidence": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"handoff": { "type": "boolean" },
|
||||
"citations": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"SupportAutoSettings": {
|
||||
"type": "object",
|
||||
"required": ["enabled"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": false },
|
||||
"faq_enabled": { "type": "boolean", "default": true },
|
||||
"ai_enabled": { "type": "boolean", "default": false },
|
||||
"match_confidence_threshold": {
|
||||
"type": "number",
|
||||
"default": 0.78,
|
||||
"minimum": 0.5,
|
||||
"maximum": 0.95
|
||||
},
|
||||
"ai_confidence_threshold": {
|
||||
"type": "number",
|
||||
"default": 0.65,
|
||||
"minimum": 0.5,
|
||||
"maximum": 0.95
|
||||
},
|
||||
"ai_delivery": {
|
||||
"$ref": "#/enums/ai_delivery",
|
||||
"default": "draft"
|
||||
},
|
||||
"ai_use_global_support_role": { "type": "boolean", "default": true },
|
||||
"ai_provider_override": { "type": ["string", "null"], "maxLength": 64 },
|
||||
"ai_model_override": { "type": ["string", "null"], "maxLength": 128 },
|
||||
"ai_base_url_override": {
|
||||
"type": ["string", "null"],
|
||||
"maxLength": 512
|
||||
},
|
||||
"rate_limit_per_company_per_hour": {
|
||||
"type": "integer",
|
||||
"default": 10,
|
||||
"minimum": 1
|
||||
},
|
||||
"rate_limit_platform_per_minute": {
|
||||
"type": "integer",
|
||||
"default": 30,
|
||||
"minimum": 1
|
||||
},
|
||||
"retry_on_first_customer_reply": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"notes": "Overrides change provider/model/base_url only; API key always from ai_roles.support"
|
||||
}
|
||||
},
|
||||
"pipeline": {
|
||||
"name": "support_auto_first_response",
|
||||
"entry_triggers": [
|
||||
{
|
||||
"event": "ticket_created",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"event": "customer_first_reply_without_prior_auto",
|
||||
"required": false,
|
||||
"gate": "settings.retry_on_first_customer_reply"
|
||||
}
|
||||
],
|
||||
"preconditions": [
|
||||
"settings.enabled == true",
|
||||
"ticket.auto_reply_disabled == false",
|
||||
"ticket.status in open|pending",
|
||||
"no prior public is_auto_reply message OR retry flag allows"
|
||||
],
|
||||
"stages": [
|
||||
{
|
||||
"id": "A_faq_template_match",
|
||||
"mode": "sync",
|
||||
"max_cpu_ms": 50,
|
||||
"llm": false,
|
||||
"when": "settings.faq_enabled",
|
||||
"action_on_hit": {
|
||||
"condition": "confidence >= settings.match_confidence_threshold",
|
||||
"post_message": {
|
||||
"author_role": "system",
|
||||
"is_internal_note": false,
|
||||
"is_auto_reply": true,
|
||||
"auto_source": "kb|template"
|
||||
},
|
||||
"ticket_status": "pending",
|
||||
"auto_reply_status": "matched",
|
||||
"assign_agent": false,
|
||||
"notify_kind": "auto_reply"
|
||||
},
|
||||
"action_on_miss": "continue_to_B"
|
||||
},
|
||||
{
|
||||
"id": "B_ai_assist",
|
||||
"mode": "async_job",
|
||||
"job_kind": "support_auto_reply",
|
||||
"llm_gate": "support.TryAutoReplyLLM",
|
||||
"completer": "aiprovider.ResolveCompleterForRole(RoleSupport)",
|
||||
"when": "settings.ai_enabled && ai_roles.support configured",
|
||||
"prompt": {
|
||||
"inputs": [
|
||||
"redacted_subject",
|
||||
"redacted_body",
|
||||
"category",
|
||||
"tags",
|
||||
"related_sku",
|
||||
"kb_snippets_top_n",
|
||||
"sanitized_customer_context"
|
||||
],
|
||||
"output_schema": "AiAssistResult",
|
||||
"rules": [
|
||||
"answer only from snippets + ticket",
|
||||
"no cross-tenant data",
|
||||
"no inventing billing facts",
|
||||
"handoff=true when unsure"
|
||||
]
|
||||
},
|
||||
"delivery": {
|
||||
"draft": {
|
||||
"is_internal_note": true,
|
||||
"auto_reply_status": "ai_draft",
|
||||
"notify_customer": false,
|
||||
"notify_staff": true
|
||||
},
|
||||
"auto_send": {
|
||||
"is_internal_note": false,
|
||||
"auto_reply_status": "ai_sent",
|
||||
"ticket_status": "pending",
|
||||
"notify_kind": "auto_reply",
|
||||
"condition": "confidence >= ai_confidence_threshold && handoff == false"
|
||||
}
|
||||
},
|
||||
"on_failure": "human_handoff"
|
||||
}
|
||||
],
|
||||
"human_handoff": {
|
||||
"set": {
|
||||
"auto_reply_status": "handed_off",
|
||||
"auto_reply_disabled": true
|
||||
},
|
||||
"triggers": [
|
||||
"ai_handoff_true_or_low_confidence",
|
||||
"ai_provider_error_or_unset",
|
||||
"ai_rate_limited",
|
||||
"customer_reply_after_public_auto",
|
||||
"staff_public_reply_or_explicit_disable"
|
||||
],
|
||||
"optional_internal_note": "Needs human review",
|
||||
"keep_claimable": true,
|
||||
"assign_agent": false
|
||||
}
|
||||
},
|
||||
"schema_deltas_sketch": {
|
||||
"support_tickets_additive": [
|
||||
"tags TEXT[] NOT NULL DEFAULT '{}'",
|
||||
"related_product_id UUID NULL",
|
||||
"related_sku TEXT NULL",
|
||||
"customer_context JSONB NULL",
|
||||
"auto_reply_disabled BOOLEAN NOT NULL DEFAULT false",
|
||||
"auto_reply_status TEXT NOT NULL DEFAULT 'none'",
|
||||
"auto_reply_attempted_at TIMESTAMPTZ NULL",
|
||||
"auto_reply_message_id UUID NULL"
|
||||
],
|
||||
"support_messages_additive": [
|
||||
"is_auto_reply BOOLEAN NOT NULL DEFAULT false",
|
||||
"auto_source TEXT NULL",
|
||||
"auto_confidence REAL NULL",
|
||||
"auto_ref_type TEXT NULL",
|
||||
"auto_ref_id UUID NULL"
|
||||
],
|
||||
"new_tables": [
|
||||
"support_categories",
|
||||
"support_kb_articles",
|
||||
"support_reply_templates",
|
||||
"support_auto_jobs"
|
||||
],
|
||||
"category_check": "Replace hard CHECK with taxonomy table + app validation; seed billing/bug/account/other"
|
||||
},
|
||||
"endpoints_additive": [
|
||||
{
|
||||
"id": "customer.create_ticket_rich",
|
||||
"method": "POST",
|
||||
"path": "/api/support/tickets",
|
||||
"auth": "customer",
|
||||
"body_additive": {
|
||||
"tags": { "type": "string[]", "optional": true, "maxItems": 10 },
|
||||
"related_product_id": { "type": "uuid", "optional": true },
|
||||
"related_sku": { "type": "string", "optional": true, "maxLength": 128 }
|
||||
},
|
||||
"side_effects": [
|
||||
"server fills customer_context",
|
||||
"after commit: Stage A sync; maybe enqueue Stage B",
|
||||
"HTTP must not await LLM"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "staff.patch_auto_fields",
|
||||
"method": "PATCH",
|
||||
"path": "/api/admin/support/tickets/{id}",
|
||||
"auth": "support_desk",
|
||||
"body_additive": {
|
||||
"auto_reply_disabled": { "type": "boolean", "optional": true },
|
||||
"tags": { "optional": true },
|
||||
"related_product_id": { "optional": true },
|
||||
"related_sku": { "optional": true },
|
||||
"category": { "optional": true }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "admin.support_auto_settings",
|
||||
"method": "GET|PUT",
|
||||
"path": "/api/admin/settings",
|
||||
"auth": "platform_admin",
|
||||
"field": "support_auto",
|
||||
"schema_ref": "#/types/SupportAutoSettings"
|
||||
},
|
||||
{
|
||||
"id": "admin.kb_crud",
|
||||
"method": "GET|POST|PATCH|DELETE",
|
||||
"path": "/api/admin/support/kb/articles",
|
||||
"auth": "platform_admin"
|
||||
},
|
||||
{
|
||||
"id": "admin.templates_crud",
|
||||
"method": "GET|POST|PATCH|DELETE",
|
||||
"path": "/api/admin/support/templates",
|
||||
"auth": "platform_admin"
|
||||
},
|
||||
{
|
||||
"id": "admin.categories_crud",
|
||||
"method": "GET|POST|PATCH|DELETE",
|
||||
"path": "/api/admin/support/categories",
|
||||
"auth": "platform_admin"
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"tenant_isolation": [
|
||||
"All ticket queries filter company_id",
|
||||
"related_product_id must belong to ticket company_id",
|
||||
"Prompts never include other companies' tickets",
|
||||
"KB is platform content only — not tenant data"
|
||||
],
|
||||
"redaction": {
|
||||
"before_match_and_ai": true,
|
||||
"patterns": [
|
||||
"api_keys",
|
||||
"bearer_tokens",
|
||||
"sk-_openai_style",
|
||||
"aws_keys",
|
||||
"pem_private_keys",
|
||||
"connection_strings"
|
||||
],
|
||||
"replacement": "[REDACTED]"
|
||||
},
|
||||
"rate_limits": {
|
||||
"faq_match": "unlimited_within_cpu_budget",
|
||||
"ai_per_company_per_hour": "settings.rate_limit_per_company_per_hour",
|
||||
"ai_platform_per_minute": "settings.rate_limit_platform_per_minute",
|
||||
"on_exceed": "skip_ai_handoff_create_still_201"
|
||||
},
|
||||
"per_ticket_disable": {
|
||||
"field": "auto_reply_disabled",
|
||||
"actors": ["support_desk", "auto_on_handoff"]
|
||||
},
|
||||
"secrets": [
|
||||
"Never log ResolveAIConfig API keys",
|
||||
"Overrides do not store a second API key",
|
||||
"customer_context must not contain secrets"
|
||||
],
|
||||
"docs_ask_invariant": "Guided /docs Ask must never use AIRoleSupport or AIRoleDocsAPI"
|
||||
},
|
||||
"performance": {
|
||||
"faq_match": "sync_bounded",
|
||||
"ai_assist": "async_job_only",
|
||||
"create_http": "no_llm_await",
|
||||
"kb_cache_ttl_seconds": 60,
|
||||
"list_endpoints": "omit_full_customer_context",
|
||||
"indexes": [
|
||||
"GIN tags",
|
||||
"auto_reply_status",
|
||||
"support_auto_jobs (status, created_at)",
|
||||
"kb keywords GIN"
|
||||
]
|
||||
},
|
||||
"contracts_preserved": [
|
||||
"Existing customer ticket create fields remain valid without tags/SKU",
|
||||
"author_role enum unchanged; system used for auto",
|
||||
"ai_roles.support admin shape unchanged",
|
||||
"Desk claim/visibility from agent 11 unchanged",
|
||||
"TryAutoReplyLLM remains sole LLM ticket gate"
|
||||
],
|
||||
"breaking_soft": [
|
||||
{
|
||||
"change": "Category taxonomy expands beyond CHECK four",
|
||||
"migration": "Seed four; app validates active slugs; historical inactive slugs readable"
|
||||
},
|
||||
{
|
||||
"change": "notification kind auto_reply",
|
||||
"migration": "Widen support_notifications kind CHECK"
|
||||
}
|
||||
],
|
||||
"test_matrix": [
|
||||
{
|
||||
"case": "faq_above_threshold",
|
||||
"expect": "public system auto message; status pending; no AI job"
|
||||
},
|
||||
{
|
||||
"case": "faq_below_threshold_ai_draft",
|
||||
"expect": "async job; internal note; customer no public auto"
|
||||
},
|
||||
{
|
||||
"case": "ai_auto_send",
|
||||
"expect": "public labeled AI message when conf ok"
|
||||
},
|
||||
{
|
||||
"case": "ai_unset_role",
|
||||
"expect": "handoff; TryAutoReplyLLM fail closed"
|
||||
},
|
||||
{
|
||||
"case": "cross_tenant_product_id",
|
||||
"expect": "400/ignore product; never leak"
|
||||
},
|
||||
{
|
||||
"case": "secret_in_body",
|
||||
"expect": "redacted before AI; not in logs"
|
||||
},
|
||||
{
|
||||
"case": "customer_reply_after_auto",
|
||||
"expect": "auto_reply_disabled; no second AI"
|
||||
},
|
||||
{
|
||||
"case": "staff_disable_auto",
|
||||
"expect": "orchestrator no-op"
|
||||
},
|
||||
{
|
||||
"case": "rate_limit_ai",
|
||||
"expect": "handoff; create 201"
|
||||
},
|
||||
{
|
||||
"case": "master_switch_off",
|
||||
"expect": "no match post; no job"
|
||||
}
|
||||
],
|
||||
"assumptions": [
|
||||
"MVP matcher is lexical not embeddings (Docs Ask is pattern inspiration only)",
|
||||
"Support AI uses platform ai_roles.support only (no tenant BYOK)",
|
||||
"FAQ match does not auto-assign agents",
|
||||
"Extend TryAutoReplyLLM rather than a parallel LLM entry point",
|
||||
"Product gate is support_auto.enabled AND ai_roles.support.enabled (inventory 4.3)",
|
||||
"No plan-permission key for auto-reply in MVP (platform admin settings only)",
|
||||
"support_auto settings stored in platform JSON alongside ai_roles"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
# 02 — Support auto-response + rich ticket contract
|
||||
|
||||
**Status:** Design only — no application code in this document.
|
||||
**Agent:** 2/10 (`support-auto`)
|
||||
**Date:** 2026-08-05
|
||||
**Repo:** `F:/laragon/www/_MY/descrybe-v2`
|
||||
**Machine contract:** [02-contract.json](./02-contract.json)
|
||||
|
||||
---
|
||||
|
||||
## 0. North star
|
||||
|
||||
Give support tickets **richer customer context**, then on create (first response only by default) attempt:
|
||||
|
||||
1. **Deterministic match** against FAQ / knowledge articles or reply templates (keyword + intent).
|
||||
2. If match confidence is below threshold → **AI assist** using the platform admin **`support`** AI role.
|
||||
3. If AI fails / is low-confidence / customer replies again → **human handoff** (queue + claim desk from [11-support-design](../admin-roles-support/11-support-design.md)).
|
||||
|
||||
Auto replies are always **clearly labeled** (`author_role=system` + `auto_*` metadata). Staff may **disable auto per ticket**.
|
||||
|
||||
```
|
||||
Customer Create ticket (+ tags / category / optional SKU / context snapshot)
|
||||
│
|
||||
▼
|
||||
[sync] FAQ / template matcher ──confidence ≥ T_match──► post labeled system reply
|
||||
│ below threshold
|
||||
▼
|
||||
[async job] AI assist (AIRoleSupport) ──ok──► draft internal note OR auto-send (admin)
|
||||
│ fail / low conf
|
||||
▼
|
||||
Human handoff (open/unassigned queue; no further auto unless staff re-enables)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Inventory basis
|
||||
|
||||
| Source | Status |
|
||||
|--------|--------|
|
||||
| [01-inventory.md](./01-inventory.md) / [01-extension-points.json](./01-extension-points.json) | **Authoritative** desk + AI inventory (agent 1). Confirms stub gate, `ai_roles.support`, no FAQ matcher, Create post-commit as natural hook. |
|
||||
| [11-support-design.md](../admin-roles-support/11-support-design.md) / [11-support-api-contract.json](../admin-roles-support/11-support-api-contract.json) | Desk roles, claim queue, CSAT, message `author_role ∈ user\|agent\|system`. |
|
||||
| Schema `025` + `027`/`029`/`030` | Tickets/messages/notifications + desk indexes, `staff_role`, CSAT. No tags / SKU / KB / auto flags. |
|
||||
| `support.Ticket` / `CreateInput` (`apps/api/internal/support/types.go`) | Priority + category already on create; no tags/SKU/context. |
|
||||
| `support.Create` (`tickets.go`) | Post-commit extension point for matcher → optional `TryAutoReplyLLM` (never Completer in Create). |
|
||||
| `support.TryAutoReplyLLM` (`ai_auto_reply.go`) | **Only** allowed LLM entry for tickets; currently returns `ErrAIAutoReplyDisabled`. |
|
||||
| `platformsettings.AIRoleSupport` = `"support"` | Admin-configurable slot in `ai_roles` (GET/PUT `/api/admin/settings` via `admin_settings_handlers.go`). |
|
||||
| `aiprovider.ResolveCompleterForRole(..., RoleSupport)` | Platform completer for support role; **no env fallback** when unset (tests assert). |
|
||||
| Web admin | `apps/web/src/lib/admin-ai-roles.ts` — role `support`; UI `/admin/settings` → AI roles tab. |
|
||||
| Docs Ask | Rule tree (`DocsAskGuide` / `docs-guide`) — pattern for content match **before** LLM; must not use `AIRoleSupport`. |
|
||||
| FAQ today | Marketing `FaqSection` only — **no** support knowledge base / canned ticket replies. |
|
||||
| Jobs | Postgres poller / `jobs.Queue` pattern (River deferred) — prefer same style for AI auto jobs. |
|
||||
| Plan keys | `support.center|ticket_create|ticket_thread` only — **no** plan key for auto-reply (platform admin gate). |
|
||||
|
||||
**ASSUMPTION:** Extending `TryAutoReplyLLM` remains the single AI gate; FAQ matching is a sibling `TryAutoReplyMatch` (or internal stages of one orchestrator) that never calls an LLM. Explicit product gate = `support_auto.enabled` **and** `ai_roles.support.enabled` (inventory §4.3).
|
||||
|
||||
---
|
||||
|
||||
## 2. Richer ticket fields
|
||||
|
||||
### 2.1 Additive fields on `support_tickets`
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `priority` | enum `low\|normal\|high` | yes (default `normal`) | **Exists** — preserve. |
|
||||
| `tags` | `TEXT[]` or join table | no | Max 10 tags; each ≤ 40 runes; lowercase slug-ish; staff + customer may set on create; staff may PATCH. |
|
||||
| `related_product_id` | `UUID` nullable | no | Company-scoped catalog product when resolvable. Validate `company_id` match — never cross-tenant. |
|
||||
| `related_sku` | `TEXT` nullable | no | Free-text SKU (≤ 128). Stored even if product id unknown; used for match features + staff UI. |
|
||||
| `category` | taxonomy slug | yes | See §2.2. |
|
||||
| `customer_context` | `JSONB` | no (server-filled) | Snapshot at create — see §2.3. Immutable after create (staff-visible; customers see limited subset). |
|
||||
| `auto_reply_disabled` | `BOOLEAN NOT NULL DEFAULT false` | — | Staff toggle; also set automatically on human handoff / customer follow-up after auto. |
|
||||
| `auto_reply_status` | enum | — | `none\|matched\|ai_draft\|ai_sent\|skipped\|failed\|handed_off`. |
|
||||
| `auto_reply_attempted_at` | `TIMESTAMPTZ` | — | Last orchestrator run. |
|
||||
| `auto_reply_message_id` | `UUID` nullable | — | Public auto message when posted. |
|
||||
|
||||
**Contracts preserved:** existing create body (`subject`, `category`, `priority`, `body`) remains valid. New fields are additive optional.
|
||||
|
||||
### 2.2 Category taxonomy
|
||||
|
||||
Today’s CHECK: `billing | bug | account | other`.
|
||||
|
||||
**Chosen approach:** keep seed slugs for backward compatibility; move allow-list to **`support_categories`** (platform-scoped admin taxonomy):
|
||||
|
||||
| Column | Purpose |
|
||||
|--------|---------|
|
||||
| `slug` | Stable API value (PK) |
|
||||
| `label` | Admin/UI display |
|
||||
| `parent_slug` | Optional hierarchy (1 level deep MVP) |
|
||||
| `sort_order` | List order |
|
||||
| `is_active` | Soft-disable without breaking historical tickets |
|
||||
| `match_intents` | Optional string[] of intent keys for matcher |
|
||||
|
||||
Seed rows: existing four categories. Admin may add e.g. `integrations`, `processing`, `export`, `billing_credits`.
|
||||
|
||||
**BREAKING (soft):** create/update must reject unknown **active** slugs; historical tickets may retain inactive slugs (read-only display).
|
||||
|
||||
Create API: `category` still a string slug (default `other`).
|
||||
|
||||
### 2.3 Customer context snapshot
|
||||
|
||||
Filled **server-side** on `Create` from the authenticated company/user — never trust client JSON for authz-sensitive facts.
|
||||
|
||||
```json
|
||||
{
|
||||
"captured_at": "RFC3339",
|
||||
"company_id": "uuid",
|
||||
"company_name": "string",
|
||||
"plan_slug": "string|null",
|
||||
"user_id": "uuid",
|
||||
"user_email": "string",
|
||||
"user_name": "string|null",
|
||||
"locale": "string|null",
|
||||
"timezone": "string|null",
|
||||
"related_product": {
|
||||
"id": "uuid|null",
|
||||
"sku": "string|null",
|
||||
"title": "string|null"
|
||||
},
|
||||
"signals": {
|
||||
"open_ticket_count": 0,
|
||||
"last_ticket_category": "string|null"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
- Snapshot is denormalized; later plan changes do not rewrite it.
|
||||
- Customer GET may omit `signals` / email of others; staff GET sees full snapshot.
|
||||
- No API keys, SMTP secrets, BYOK material, or raw payment tokens in snapshot.
|
||||
- Cap JSON size (~4 KiB); drop optional `signals` if over budget.
|
||||
|
||||
---
|
||||
|
||||
## 3. Knowledge + templates (match corpus)
|
||||
|
||||
New platform-scoped content (not per-tenant KB for MVP — support is platform desk):
|
||||
|
||||
### 3.1 `support_kb_articles`
|
||||
|
||||
| Field | Notes |
|
||||
|-------|-------|
|
||||
| `id`, `slug`, `title`, `body_md` | Public-safe answer text |
|
||||
| `category_slugs` | Overlap boost with ticket category |
|
||||
| `keywords` | TEXT[] — lowercase tokens |
|
||||
| `intent_keys` | TEXT[] — e.g. `password_reset`, `stripe_invoice` |
|
||||
| `is_published` | Matcher ignores unpublished |
|
||||
| `priority_weight` | Tie-break |
|
||||
|
||||
### 3.2 `support_reply_templates`
|
||||
|
||||
| Field | Notes |
|
||||
|-------|-------|
|
||||
| `id`, `name`, `body` | Staff-authored reply (may include `{{subject}}` placeholders — allowlist only) |
|
||||
| `keywords`, `intent_keys`, `category_slugs` | Same as KB |
|
||||
| `is_active` | |
|
||||
|
||||
**ASSUMPTION:** MVP matcher is lexical (keyword overlap + intent + category boost), not embeddings. Vector search may reuse `AIRoleVectorization` later — out of scope here.
|
||||
|
||||
---
|
||||
|
||||
## 4. Auto-response pipeline
|
||||
|
||||
### 4.1 Triggers
|
||||
|
||||
| Event | Behavior |
|
||||
|-------|----------|
|
||||
| Ticket **create** | Primary: run orchestrator once. |
|
||||
| Customer **first reply** after create with no prior auto | Optional admin flag `retry_on_first_customer_reply` (default **false**). |
|
||||
| Customer reply **after** an auto public message | **Handoff** — set `auto_reply_disabled=true`, `auto_reply_status=handed_off`; do not re-run AI. |
|
||||
| Staff public reply | Disable further auto on that ticket. |
|
||||
| Staff sets `auto_reply_disabled=true` | Skip forever until staff clears flag. |
|
||||
|
||||
Never invoke LLM from `Create` / `ReplyAsUser` / `ReplyAsAgent` directly — only via `TryAutoReplyLLM` (AI stage) after FAQ stage.
|
||||
|
||||
### 4.2 Stage A — FAQ / template match (sync, cheap)
|
||||
|
||||
Input: subject + body + category + tags + related_sku + intent heuristics (simple regex/keyword map).
|
||||
|
||||
Output:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "kb_article|template|none",
|
||||
"ref_id": "uuid|null",
|
||||
"confidence": 0.0,
|
||||
"body": "string|null",
|
||||
"label": "string"
|
||||
}
|
||||
```
|
||||
|
||||
- If `confidence >= settings.match_confidence_threshold` and auto enabled → **post** public message:
|
||||
- `author_role = "system"`
|
||||
- `author_user_id = null`
|
||||
- `is_internal_note = false`
|
||||
- metadata: `auto_source`, `auto_confidence`, `auto_ref_type`, `auto_ref_id`
|
||||
- Prefix or footer (localized): clearly states automated reply (e.g. “Automated answer from help center”).
|
||||
- Set ticket `status` → `pending` (waiting on customer), `auto_reply_status=matched`.
|
||||
- Notify customer (`agent_reply` or new kind `auto_reply` — prefer **new** `auto_reply` for analytics; additive CHECK).
|
||||
- **Do not** assign an agent on FAQ match (stays unassigned for claim).
|
||||
|
||||
**Performance:** sync inside create transaction **after** commit of ticket+first message is preferred: commit ticket → match → insert system message in a short follow-up tx so create latency stays low. Matcher must be O(corpus) with small corpus + indexes on keywords GIN; hard cap ≤ 50 ms CPU budget or abort to AI stage.
|
||||
|
||||
### 4.3 Confidence threshold → Stage B
|
||||
|
||||
If no match or `confidence < T_match`:
|
||||
|
||||
- If `settings.ai_enabled` and support AI configured → enqueue **async** AI job (§5).
|
||||
- Else → `auto_reply_status=skipped` (or `handed_off` if desk expects human-only).
|
||||
|
||||
Default **`match_confidence_threshold` = 0.78** (admin-configurable 0.50–0.95).
|
||||
|
||||
### 4.4 Message labeling (customer + staff)
|
||||
|
||||
| Audience | Presentation |
|
||||
|----------|----------------|
|
||||
| Customer | System bubble with badge “Automated” / “AI-assisted”; never look like a named human agent. |
|
||||
| Staff | Same + `auto_source`, confidence, KB/template link; AI drafts as **internal notes** when delivery=`draft`. |
|
||||
|
||||
Extend `support_messages` (additive):
|
||||
|
||||
```sql
|
||||
auto_source TEXT CHECK (auto_source IS NULL OR auto_source IN ('kb','template','ai')),
|
||||
auto_confidence REAL,
|
||||
auto_ref_type TEXT,
|
||||
auto_ref_id UUID,
|
||||
is_auto_reply BOOLEAN NOT NULL DEFAULT false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. AI assist
|
||||
|
||||
### 5.1 Provider resolution (discovered)
|
||||
|
||||
| Symbol / path | Role |
|
||||
|---------------|------|
|
||||
| `platformsettings.AIRoleSupport` (`"support"`) | Admin slot in `ai_roles`. |
|
||||
| `platformsettings.ResolveAIConfig(ctx, AIRoleSupport)` | Decrypt key; provider/base_url/model/enabled. |
|
||||
| `aiprovider.RoleSupport` / `ResolveCompleterForRole(ctx, companyID, RoleSupport)` | Completer; platform-only for support (no env fallback). |
|
||||
| `support.TryAutoReplyLLM(ctx, ticketID)` | **Sole** product gate for ticket LLM sends/drafts. |
|
||||
| Admin UI | `/admin/settings` + `admin-ai-roles.ts` role `support`. |
|
||||
| API | `GET/PUT /api/admin/settings` → `ai_roles.support`. |
|
||||
|
||||
**ASSUMPTION:** Support auto uses **platform** `ai_roles.support`, never tenant BYOK. `companyID` passed to resolver is the ticket’s company for future bindings only; MVP ignores company BYOK for RoleSupport (already the case in `ResolveCompleterForRole` switch).
|
||||
|
||||
### 5.2 Admin AI override vs global
|
||||
|
||||
Under support-auto settings (§8):
|
||||
|
||||
| Setting | Behavior |
|
||||
|---------|----------|
|
||||
| `ai_use_global_support_role: true` (default) | Use `ResolveCompleterForRole(..., RoleSupport)` as configured in admin. |
|
||||
| `ai_model_override` / `ai_provider_override` / `ai_base_url_override` | Optional; applied only when `ai_use_global_support_role` is true **and** overrides non-empty — still uses the **same API key** from `AIRoleSupport` (never a second secret store). |
|
||||
| Role disabled / missing key | AI stage → `failed` → human handoff; log without secrets. |
|
||||
|
||||
### 5.3 Prompt contract
|
||||
|
||||
System prompt (server-owned, not admin free-text MVP):
|
||||
|
||||
- Answer only from provided KB snippets + ticket text.
|
||||
- No inventing billing credits, invoices, or other tenants’ data.
|
||||
- If unsure, say so and ask one clarifying question OR signal `handoff=true`.
|
||||
- Output JSON: `{ "body": "...", "confidence": 0-1, "handoff": bool, "citations": ["kb:slug"] }`.
|
||||
|
||||
User/content payload:
|
||||
|
||||
- Redacted ticket subject + body (§6).
|
||||
- Category, tags, related_sku (company-scoped).
|
||||
- Top-N KB snippets from cheap lexical retrieval (even if below FAQ auto-send threshold).
|
||||
- Customer context snapshot **sanitized** (no emails of other users; plan slug ok).
|
||||
|
||||
### 5.4 Delivery modes
|
||||
|
||||
| `ai_delivery` | Action |
|
||||
|---------------|--------|
|
||||
| `draft` (default, safer) | Insert **internal note** (`is_internal_note=true`, `author_role=system`, `is_auto_reply=true`); notify assignee or unassigned queue via staff notification; `auto_reply_status=ai_draft`. Customer sees nothing yet. |
|
||||
| `auto_send` | Post **public** system message (labeled AI-assisted); status → `pending`; `auto_reply_status=ai_sent`. |
|
||||
|
||||
Require `ai_confidence >= settings.ai_confidence_threshold` (default **0.65**) and `handoff=false` to send/draft; else handoff.
|
||||
|
||||
### 5.5 Async job (preferred)
|
||||
|
||||
**Sync path forbidden for LLM** on the HTTP create request.
|
||||
|
||||
Job sketch (`support_auto_jobs` or reuse processing-style pending table):
|
||||
|
||||
| Column | Notes |
|
||||
|--------|-------|
|
||||
| `id`, `ticket_id`, `company_id` | Tenant key for isolation |
|
||||
| `status` | `pending\|running\|done\|failed` |
|
||||
| `attempt`, `last_error` | No PII in error; truncate |
|
||||
| `created_at`, `updated_at` | |
|
||||
|
||||
Worker:
|
||||
|
||||
1. Skip if `auto_reply_disabled` or ticket closed/resolved or already matched/sent.
|
||||
2. Rate-limit check (§6).
|
||||
3. Call `TryAutoReplyLLM` (implementation loads ticket, snippets, completer, posts draft/send).
|
||||
4. Idempotent: unique partial index on `(ticket_id) WHERE status IN ('pending','running')` or ticket-level lock.
|
||||
|
||||
**ASSUMPTION:** Follow existing `jobs.Queue` + worker poller pattern (`apps/api/internal/jobs/river.go` comment: River deferred). Name the worker kind `support_auto_reply`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Security
|
||||
|
||||
| Control | Rule |
|
||||
|---------|------|
|
||||
| Tenant isolation | All ticket reads/writes filter `company_id`. KB is platform-global **content** but never includes other companies’ tickets in prompts. Related product id validated against ticket `company_id`. |
|
||||
| Prompt isolation | Completer called with **one ticket’s** redacted text only; never batch multi-tenant prompts. |
|
||||
| Secret redaction | Before match features + AI: strip patterns for API keys, Bearer tokens, `sk-`, AWS keys, private key PEM, connection strings; replace with `[REDACTED]`. Do not store raw secrets in `customer_context` or auto job logs. |
|
||||
| Rate limits | Per `company_id`: default 10 AI jobs / hour; platform global: 30 AI jobs / minute. FAQ match not counted toward AI limits. Reuse sliding-window pattern (`httpapi/ratelimit.go`). Exceed → `skipped`/`failed` + handoff, HTTP create still 201. |
|
||||
| Disable per ticket | Staff PATCH `{ "auto_reply_disabled": true }`; also auto-set on handoff / customer follow-up after auto. |
|
||||
| AuthZ | Settings: `RequirePlatformAdmin`. Disable flag: `RequireSupportDesk` on assigned/visible ticket. KB CRUD: platform admin. |
|
||||
| Customer visibility | Never expose internal AI drafts, confidence, or admin settings. |
|
||||
| Logging | No API keys, no full bodies at info level; structured `ticket_id`, `company_id`, `auto_source`, `confidence`. |
|
||||
| Docs Ask | Remains rule-based; must not use `AIRoleSupport` / `AIRoleDocsAPI` (existing invariant). |
|
||||
|
||||
---
|
||||
|
||||
## 7. Performance
|
||||
|
||||
| Path | Guidance |
|
||||
|------|----------|
|
||||
| FAQ/template match | **Sync**, bounded corpus, GIN on keywords; target < 50 ms; no external I/O. |
|
||||
| AI assist | **Async** job only; HTTP returns before LLM. |
|
||||
| Create API | Do not hold open DB transactions across LLM. |
|
||||
| Indexes | `(auto_reply_status)`, GIN `tags`, FK `related_product_id`, job `(status, created_at)`. |
|
||||
| List endpoints | Do not embed full `customer_context` in list — detail only (or truncated). |
|
||||
| N+1 | Matcher loads published KB once per process with short TTL cache (e.g. 60s) — invalidate on admin KB write. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Admin settings
|
||||
|
||||
Stored under platform settings (JSON key `support_auto` in `values` **or** first-class object on PublicView — implementers pick one; prefer first-class for typed admin UI).
|
||||
|
||||
| Key | Type | Default | Purpose |
|
||||
|-----|------|---------|---------|
|
||||
| `enabled` | bool | `false` | Master switch for any auto first response. |
|
||||
| `faq_enabled` | bool | `true` | Stage A. |
|
||||
| `ai_enabled` | bool | `false` | Stage B (still requires `ai_roles.support` configured + enabled). |
|
||||
| `match_confidence_threshold` | number | `0.78` | FAQ/template auto-send. |
|
||||
| `ai_confidence_threshold` | number | `0.65` | Min model confidence. |
|
||||
| `ai_delivery` | `draft\|auto_send` | `draft` | |
|
||||
| `ai_use_global_support_role` | bool | `true` | Use admin `ai_roles.support`. |
|
||||
| `ai_provider_override` | string? | null | Optional; key still from support role. |
|
||||
| `ai_model_override` | string? | null | |
|
||||
| `ai_base_url_override` | string? | null | |
|
||||
| `rate_limit_per_company_per_hour` | int | `10` | AI jobs. |
|
||||
| `rate_limit_platform_per_minute` | int | `30` | AI jobs. |
|
||||
| `retry_on_first_customer_reply` | bool | `false` | |
|
||||
|
||||
API (additive):
|
||||
|
||||
| Method | Path | Auth |
|
||||
|--------|------|------|
|
||||
| GET/PUT | `/api/admin/settings` (embed `support_auto`) | Platform admin |
|
||||
| CRUD | `/api/admin/support/kb/articles*` | Platform admin |
|
||||
| CRUD | `/api/admin/support/templates*` | Platform admin |
|
||||
| CRUD | `/api/admin/support/categories*` | Platform admin |
|
||||
| PATCH | `/api/admin/support/tickets/{id}` | Support desk — `auto_reply_disabled`, tags, related_*, priority, category |
|
||||
|
||||
Customer create (additive body fields): `tags?`, `related_product_id?`, `related_sku?` (category taxonomy as today).
|
||||
|
||||
---
|
||||
|
||||
## 9. Human handoff
|
||||
|
||||
Set `auto_reply_status=handed_off`, `auto_reply_disabled=true` when:
|
||||
|
||||
1. AI returns `handoff=true` or confidence below threshold.
|
||||
2. AI provider error / timeout / unset completer.
|
||||
3. Rate limit exceeded for AI.
|
||||
4. Customer sends another message after an automated public reply.
|
||||
5. Staff claims/replies or explicitly disables auto.
|
||||
|
||||
Effects: ticket remains `open` (or current status); stays claimable; optional internal system note “Needs human review” (not customer-visible). No further auto jobs for that ticket unless staff sets `auto_reply_disabled=false` (rare; audit).
|
||||
|
||||
---
|
||||
|
||||
## 10. Alignment with support desk (agent 11)
|
||||
|
||||
- Auto does **not** assign `assignee_admin_user_id` (preserves unassigned queue + claim).
|
||||
- Public auto reply may move status `open` → `pending` (same as agent public reply default).
|
||||
- Internal AI drafts do **not** change status / do **not** notify customer.
|
||||
- CSAT / claim / staff roles unchanged.
|
||||
- `TryAutoReplyLLM` opt-in replaces the “keep disabled” note in 11-support §11.9 when this feature ships behind `support_auto.enabled`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Implementation phasing (later agents)
|
||||
|
||||
1. Migration: ticket rich fields + message auto columns + categories + KB + templates + auto jobs.
|
||||
2. Admin settings + KB/template/category CRUD.
|
||||
3. Sync matcher + labeled system post on create.
|
||||
4. Implement `TryAutoReplyLLM` + async worker + redaction + rate limits.
|
||||
5. Staff UI: disable auto, show auto metadata, AI draft notes.
|
||||
6. Customer UI: tags/SKU/category; automated badge on messages.
|
||||
7. Tests: tenant isolation, redaction, threshold branches, handoff on user reply, AI disabled stub, rate limit.
|
||||
|
||||
**Out of scope:** per-tenant KB, embedding retrieval, multi-turn AI bot, attachments, SLA clocks.
|
||||
|
||||
---
|
||||
|
||||
## 12. Contracts preserved / breaking
|
||||
|
||||
**Preserved**
|
||||
|
||||
- Existing customer/staff ticket JSON fields; additive only.
|
||||
- `author_role` enum values; system used for auto.
|
||||
- Admin `ai_roles.support` shape; no new secret store.
|
||||
- Desk visibility / claim rules from agent 11.
|
||||
|
||||
**BREAKING (soft)**
|
||||
|
||||
- Category allow-list may grow; clients must tolerate unknown **historical** categories.
|
||||
- New notification kind `auto_reply` requires CHECK constraint migration.
|
||||
|
||||
**Reconciled with 01-inventory:** symbols/paths match; Create post-commit hook preferred; settings stay in platform JSON (`support_auto` + existing `ai_roles.support`); no plan-permission key for auto-reply in MVP.
|
||||
@@ -0,0 +1,64 @@
|
||||
# 03 — KB / FAQ auto-reply (agent 3/10)
|
||||
|
||||
## Goal
|
||||
|
||||
Platform knowledge base + reply templates with a **sync lexical matcher** that can post a labeled `author_role=system` first response when confidence clears the threshold. No LLM on this path (agent 4 owns `TryAutoReplyLLM`).
|
||||
|
||||
## Schema
|
||||
|
||||
Migration: `apps/api/sql/schema/032_support_kb_auto_reply.sql`
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `support_kb_articles` | Published FAQ articles (`slug`, `title`, `body_md`, `keywords`, `intent_keys`, `category_slugs`, `priority_weight`) |
|
||||
| `support_reply_templates` | Canned replies with same match fields; placeholders allowlist: `{{subject}}`, `{{category}}` |
|
||||
| `support_auto_config` | Singleton: `enabled`, `faq_enabled`, `match_confidence_threshold` (default **0.78**), `retry_on_first_customer_reply` |
|
||||
|
||||
Ticket/message auto columns (`auto_reply_*`, `is_auto_reply`, …) come from **`031_support_ticket_detail.sql`** (agent 5). Do not re-ALTER them here.
|
||||
|
||||
## Service API
|
||||
|
||||
Package: `apps/api/internal/support`
|
||||
|
||||
| Symbol | Role |
|
||||
|--------|------|
|
||||
| `MatchAutoReply(ctx, ticket) → MatchAutoReplyResult` | Scores corpus; `{matched, confidence, reply_body, article_id, template_id, kind}` — never posts, never LLM |
|
||||
| `PostMatchedAutoReply` | Posts system message when `confidence >= threshold`; sets status `pending`, `auto_reply_status=matched`; notifies `auto_reply` |
|
||||
| `MaybeAutoReplyOnCreate` | Stage A after create (respects config) |
|
||||
| `MaybeAutoReplyOnCustomerReply` | Optional retry when `retry_on_first_customer_reply`; handoff if prior public auto exists |
|
||||
| KB / template CRUD | `Create/Update/Delete/List/Get` for articles + templates |
|
||||
| `GetAutoConfig` / `UpdateAutoConfig` | FAQ switchboard |
|
||||
|
||||
Matching: keyword overlap (0.70) + intent phrase (0.15) + category boost (0.15) + small priority weight. Secrets redacted before score (`RedactSecretsForMatch`). Corpus cached ~60s; invalidated on admin writes.
|
||||
|
||||
## HTTP (platform admin)
|
||||
|
||||
Mounted under `RequirePlatformAdmin`:
|
||||
|
||||
| Method | Path |
|
||||
|--------|------|
|
||||
| GET/POST | `/api/admin/support/kb/articles` |
|
||||
| GET/PATCH/DELETE | `/api/admin/support/kb/articles/{id}` |
|
||||
| GET/POST | `/api/admin/support/templates` |
|
||||
| GET/PATCH/DELETE | `/api/admin/support/templates/{id}` |
|
||||
| GET/PUT | `/api/admin/support/auto-config` |
|
||||
|
||||
Customer create (`POST /api/support/tickets`) calls `MaybeAutoReplyOnCreate` after successful create. Customer reply calls `MaybeAutoReplyOnCustomerReply` (no-op unless retry flag).
|
||||
|
||||
## Defaults / ops
|
||||
|
||||
- Master `enabled=false` until admin turns FAQ auto on.
|
||||
- Enable + publish articles/templates + set threshold → create a ticket whose subject/body hits keywords → expect system message with footer “Automated answer from help center”.
|
||||
- Below threshold → `auto_reply_status=skipped` (agent 4 may enqueue AI).
|
||||
|
||||
## Tests
|
||||
|
||||
- Unit: `apps/api/internal/support/match_auto_reply_test.go` (scoring, redaction, placeholders, footer).
|
||||
- Auth probes include KB/template/auto-config paths in `support_auth_test.go`.
|
||||
|
||||
## Coordination
|
||||
|
||||
- Agent 4: on match miss / low confidence → `TryAutoReplyLLM` async; reuse message auto_* columns.
|
||||
- Agent 5: ticket detail fields + activity; FAQ posts `ActivityAutoReply` via `insertActivity`.
|
||||
- Agent 6: admin UI for KB + auto-config.
|
||||
- Agent 10: migrate `032_*`, end-to-end verify.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 04 — AI fallback after FAQ miss
|
||||
|
||||
**Agent:** 4/10 (`support-auto`)
|
||||
**Status:** Implemented
|
||||
**Coordinates with:** [02-contract.md](./02-contract.md), [01-inventory.md](./01-inventory.md), agent 3 `MatchAutoReply`
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
When Stage A FAQ/template match fails or confidence is below threshold, call the **admin-configured platform AI** (`ai_roles.support`) to draft or auto-send a labeled reply. On AI failure → leave the ticket **claimable** for humans.
|
||||
|
||||
No parallel BYOK secret store — reuse `platformsettings.AIRoleSupport` / `aiprovider.ResolveCompleterForRole(..., RoleSupport)`.
|
||||
|
||||
---
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
MaybeAutoReplyOnCreate (after ticket Create)
|
||||
├─ [sync] MatchAutoReply ≥ threshold → PostMatchedAutoReply (KB/template)
|
||||
└─ miss / low conf / FAQ off
|
||||
└─ EnqueueAIFallback → support_auto_jobs (async)
|
||||
└─ worker ProcessPendingAutoJobs
|
||||
└─ TryAutoReplyLLM → CompleterSupportAI.RunAutoReply
|
||||
├─ draft (default) → internal system note (AI-assisted label)
|
||||
├─ auto_send → public system message + footer
|
||||
└─ fail / low conf / handoff → auto_reply_disabled + handed_off
|
||||
```
|
||||
|
||||
HTTP create **never awaits** the LLM. If migration `034` is missing, enqueue falls back to sync-with-timeout (`AutoReplyTimeout` = 25s).
|
||||
|
||||
---
|
||||
|
||||
## Settings (`support_auto_config`)
|
||||
|
||||
| Key | Default | Role |
|
||||
|-----|---------|------|
|
||||
| `enabled` | false | Master switch |
|
||||
| `ai_enabled` | false | Stage B |
|
||||
| `ai_delivery` | `draft` | `draft` \| `auto_send` |
|
||||
| `ai_confidence_threshold` | 0.65 | Min model confidence |
|
||||
| `ai_use_global_support_role` | true | Use admin `ai_roles.support` |
|
||||
| `ai_model_override` / `ai_base_url_override` | empty | Same API key; optional endpoint tweak |
|
||||
|
||||
Also requires `/admin/settings` → AI roles → **support** enabled with key + model.
|
||||
|
||||
---
|
||||
|
||||
## Symbols
|
||||
|
||||
| Symbol | Path |
|
||||
|--------|------|
|
||||
| `TryAutoReplyLLM` | `apps/api/internal/support/ai_auto_reply.go` |
|
||||
| `CompleterSupportAI` | `apps/api/internal/support/ai_fallback.go` |
|
||||
| `EnqueueAIFallback` / `ProcessPendingAutoJobs` | `apps/api/internal/support/auto_jobs.go` |
|
||||
| `MaybeAutoReplyOnCreate` (miss → enqueue) | `apps/api/internal/support/match_auto_reply.go` |
|
||||
| `BuildAutoReplyMessages` | `apps/api/internal/support/auto_prompt.go` |
|
||||
| Migration | `apps/api/sql/schema/034_support_auto_jobs.sql` |
|
||||
|
||||
Wiring: `httpapi.NewServer` and `cmd/worker` set `Support.SupportAI = NewCompleterSupportAI(aiSvc)`.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
- Tenant isolation: ticket load checks `company_id`; prompts never mix tenants.
|
||||
- Ticket text wrapped as untrusted; secrets redacted before prompt/logs.
|
||||
- Rate limits: 10 AI jobs/company/hour, 30/platform/minute → handoff.
|
||||
- Logs: `ticket_id`, `company_id`, `delivery`, `confidence` — no bodies/keys/emails.
|
||||
- Docs Ask stays rule-based (never `AIRoleSupport`).
|
||||
|
||||
---
|
||||
|
||||
## Labels
|
||||
|
||||
- Public AI reply footer: “AI-assisted reply…”
|
||||
- Draft prefix: `[AI draft — not visible to customer]`
|
||||
- Handoff internal note: “Needs human review…”
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
```powershell
|
||||
cd apps/api
|
||||
go test ./internal/support/ -count=1 -run "TestTryAutoReplyLLM|TestCompleterSupportAI|TestParseAIAssist|TestAIRate|TestBuildAutoReply|TestLabelAI"
|
||||
```
|
||||
|
||||
Mock Completer covers draft JSON parse, low-confidence handoff, and redaction — no live OpenAI calls.
|
||||
|
||||
---
|
||||
|
||||
## Manual check
|
||||
|
||||
1. Enable `support_auto.enabled` + `ai_enabled`, set `ai_delivery=draft` or `auto_send`.
|
||||
2. Configure AI role **support** in admin settings.
|
||||
3. Create a ticket that does **not** match FAQ → job row → draft/send or handoff.
|
||||
4. Confirm ticket stays unassigned/claimable on AI failure.
|
||||
@@ -0,0 +1,134 @@
|
||||
# 05 — Ticket detail enrichment (tags, categories, activity, auto flags)
|
||||
|
||||
**Agent:** 5/10 · **Series:** `support-auto`
|
||||
**Migration:** `apps/api/sql/schema/031_support_ticket_detail.sql`
|
||||
**Contract:** [02-contract.md](./02-contract.md) §§2, 4.4, 6–7
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Enrich support tickets for “more detailed support” without breaking existing create/list/get contracts:
|
||||
|
||||
| Capability | Storage / API |
|
||||
|------------|---------------|
|
||||
| Tags | `support_tickets.tags TEXT[]` — create + staff PATCH (`set_tags`) |
|
||||
| Better categories | `support_categories` taxonomy; seeds expand beyond `billing\|bug\|account\|other` |
|
||||
| Related resource refs | `related_product_id` → `processed_products` (tenant-checked), `related_sku` |
|
||||
| Customer context snapshot | `customer_context JSONB` — server-filled on create; staff GET full; customer GET redacted |
|
||||
| Disable auto-reply | `auto_reply_disabled` — staff PATCH; auto-set on human reply / customer follow-up after auto |
|
||||
| Match / AI metadata | `auto_reply_status`, `auto_reply_meta`, `auto_reply_*` message columns |
|
||||
| Activity timeline | `support_ticket_activity` — auto / AI / human events on **staff GET** |
|
||||
|
||||
---
|
||||
|
||||
## Migration coordination (agents 3 / 4)
|
||||
|
||||
**One additive series — do not fork ticket columns.**
|
||||
|
||||
| File | Owner | Contents |
|
||||
|------|-------|----------|
|
||||
| `031_support_ticket_detail.sql` | **agent 5** | Ticket rich fields, message `auto_*` columns, `support_categories`, `support_ticket_activity`, notification kind `auto_reply` |
|
||||
| `032_*` (KB / templates / jobs) | **agent 3** | `support_kb_articles`, `support_reply_templates`, matcher tables/jobs — **do not** re-`ALTER` ticket/message columns from 031 |
|
||||
| AI fallback | **agent 4** | Reuse `RecordAutoReplyOutcome`, message `auto_source='ai'`, statuses `ai_draft` / `ai_sent` / `failed` / `handed_off` |
|
||||
|
||||
Shared helpers for agents 3–4:
|
||||
|
||||
- `support.RecordAutoReplyOutcome(ctx, ticketID, status, disabled, messageID, meta, activityKind)`
|
||||
- Message columns: `is_auto_reply`, `auto_source` ∈ `kb|template|ai`, `auto_confidence`, `auto_ref_type`, `auto_ref_id`
|
||||
- Ticket: `auto_reply_status` ∈ `none|matched|ai_draft|ai_sent|skipped|failed|handed_off`
|
||||
|
||||
---
|
||||
|
||||
## API changes (additive)
|
||||
|
||||
### Customer create — `POST /api/support/tickets`
|
||||
|
||||
Existing: `subject`, `category`, `priority`, `body`.
|
||||
|
||||
Additive optional:
|
||||
|
||||
```json
|
||||
{
|
||||
"tags": ["woo", "export"],
|
||||
"related_product_id": "uuid",
|
||||
"related_sku": "SKU-123"
|
||||
}
|
||||
```
|
||||
|
||||
`related_product_id` must belong to the caller’s `company_id` or the API returns `invalid related_product_id`.
|
||||
|
||||
### List — customer + staff
|
||||
|
||||
Returns (in addition to prior fields): `tags`, `related_product_id`, `related_sku`, `auto_reply_disabled`, `auto_reply_status`, `resolved_by_user_id`.
|
||||
|
||||
**Does not** embed full `customer_context`, `auto_reply_meta`, or `activity` (detail-only).
|
||||
|
||||
### GET customer — `GET /api/support/tickets/{id}`
|
||||
|
||||
Detail fields + messages (with `is_auto_reply` / `auto_source` when present).
|
||||
`customer_context` redacts `signals` and `user_email`.
|
||||
`auto_reply_meta` and `activity` omitted.
|
||||
|
||||
### GET staff — `GET /api/admin/support/tickets/{id}`
|
||||
|
||||
Full snapshot + `auto_reply_meta` + `activity[]` timeline + messages including internal notes and auto metadata.
|
||||
|
||||
### Staff PATCH — `PATCH /api/admin/support/tickets/{id}`
|
||||
|
||||
Additive fields on `AdminUpdateInput`:
|
||||
|
||||
| Field | Notes |
|
||||
|-------|-------|
|
||||
| `category` | Active taxonomy slug |
|
||||
| `tags` + `set_tags: true` | Replace tag set (omit `set_tags` to leave unchanged) |
|
||||
| `related_product_id` / `clear_related_product` | Tenant-scoped |
|
||||
| `related_sku` | Optional string |
|
||||
| `auto_reply_disabled` | Staff kill-switch for auto pipeline |
|
||||
|
||||
---
|
||||
|
||||
## Activity timeline kinds
|
||||
|
||||
`created`, `customer_message`, `agent_message`, `system_message`, `auto_reply`, `ai_draft`, `ai_sent`, `ai_failed`, `handed_off`, `claimed`, `released`, `status_changed`, `auto_disabled`, `auto_enabled`, `note`
|
||||
|
||||
---
|
||||
|
||||
## Behavior hooks
|
||||
|
||||
1. **Create** — writes `customer_context`, activity `created`; leaves `auto_reply_status=none` for agent 3 matcher.
|
||||
2. **Customer reply after matched/ai_sent** — sets `auto_reply_disabled=true`, `auto_reply_status=handed_off`.
|
||||
3. **Staff public reply** — sets `auto_reply_disabled=true`.
|
||||
4. **Staff toggles `auto_reply_disabled`** — activity `auto_disabled` / `auto_enabled`.
|
||||
|
||||
Create / Reply\* still **must not** call LLM completers directly (agent 4 owns `TryAutoReplyLLM`).
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
| Test | File |
|
||||
|------|------|
|
||||
| Tag / SKU / category validation | `ticket_detail_test.go` |
|
||||
| Context redaction | `ticket_detail_test.go` |
|
||||
| Create rejects bad tags (ClientError) | `ticket_detail_test.go` |
|
||||
| Existing CRUD auth / claim / CSAT | unchanged; pre-031 column fallbacks keep them green until goose up |
|
||||
|
||||
---
|
||||
|
||||
## Verify locally
|
||||
|
||||
```bash
|
||||
# from apps/api
|
||||
goose -dir sql/schema postgres "$DATABASE_URL" up
|
||||
go test ./internal/support/ -count=1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ASSUMPTIONS
|
||||
|
||||
- ASSUMPTION: Agent 3 lands KB in `032_*` without duplicating 031 ticket ALTERs. (**Confirmed:** `032_support_kb_auto_reply.sql` references 031.)
|
||||
- ASSUMPTION: `plans.name` is stored as `customer_context.plan_slug` (plans have no slug column).
|
||||
- ASSUMPTION: List payloads stay lean; UI agents read detail GET for timeline / context.
|
||||
- NOTE for agent 10: two files share version `033_*` (`033_support_auto_ai_config.sql` and `033_support_auto_security_perf.sql`) — rename one before goose up.
|
||||
@@ -0,0 +1,98 @@
|
||||
# 06 — Admin KB + auto-reply settings UI
|
||||
|
||||
**Agent:** 6/10 (`support-auto`)
|
||||
**Date:** 2026-08-05
|
||||
**Status:** Implemented (frontend + AI config columns)
|
||||
|
||||
---
|
||||
|
||||
## PROBLEM
|
||||
|
||||
Full platform admins need a clean place to manage support knowledge articles/templates, toggle FAQ auto-match + AI fallback (enable, thresholds, draft vs auto-send), and see which admin AI role powers fallback — without giving `support_staff` settings mutation.
|
||||
|
||||
---
|
||||
|
||||
## Routes & auth
|
||||
|
||||
| Surface | Path | Gate |
|
||||
|---------|------|------|
|
||||
| UI | `/admin/support/knowledge` | `requirePlatformAdmin()` |
|
||||
| Nav | Admin shell **Manage → Support KB** | `fullAdminOnly` |
|
||||
| Inbox shortcut | Support Center actions (full admin) | `fullAdmin` |
|
||||
| API articles | `/api/admin/support/kb/articles*` | `RequirePlatformAdmin` |
|
||||
| API templates | `/api/admin/support/templates*` | `RequirePlatformAdmin` |
|
||||
| API settings | `/api/admin/support/auto-config` | `RequirePlatformAdmin` |
|
||||
|
||||
**ASSUMPTION:** Contract lists KB CRUD as platform-admin only; staff do **not** get a read-only KB UI in this pass (inbox remains their surface).
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
| File | Intent |
|
||||
|------|--------|
|
||||
| `apps/web/src/lib/support/admin-kb-api.ts` | Typed client for articles, templates, auto-config |
|
||||
| `apps/web/src/routes/admin/support/knowledge/+page.svelte` | Tabs: Articles · Templates · Auto-reply |
|
||||
| `apps/web/src/lib/components/AdminNav.svelte` | Support KB nav + active-state split from ticket inbox |
|
||||
| `apps/web/src/routes/admin/support/+page.svelte` | Link to knowledge page for full admins |
|
||||
| `apps/api/sql/schema/033_support_auto_ai_config.sql` | AI columns on `support_auto_config` |
|
||||
| `apps/api/internal/support/kb_types.go` | Extended `AutoConfig` / `AutoConfigInput` |
|
||||
| `apps/api/internal/support/match_auto_reply.go` | Get/UpdateAutoConfig read/write AI fields |
|
||||
| `apps/api/internal/support/errors.go` | `ErrInvalidAIThreshold`, `ErrInvalidAIDelivery` |
|
||||
|
||||
Backend CRUD handlers already from agent 3: `support_kb_handlers.go` + mounts in `server.go`.
|
||||
|
||||
---
|
||||
|
||||
## UX
|
||||
|
||||
1. **Articles** — list, create/edit dialog (slug, markdown body, keywords, intents, categories, publish, weight), delete.
|
||||
2. **Templates** — same pattern; placeholders `{{subject}}` / `{{category}}`.
|
||||
3. **Auto-reply** — two cards:
|
||||
- FAQ: master enable, faq_enabled, match threshold, retry-on-first-reply
|
||||
- AI: ai_enabled, delivery (`draft` \| `auto_send`), AI threshold, use global support role, optional overrides
|
||||
4. **Support AI role card** — loads `ai_roles.support` from `GET /api/admin/settings`; links to `/admin/settings` AI roles tab. Key always from that role; overrides only provider/model/base URL.
|
||||
|
||||
Matches admin shell: `PageShell`, dark `.dark` tokens, `TableShell` / `Tabs` / `Dialog` / `Card`.
|
||||
|
||||
---
|
||||
|
||||
## Settings contract mapping
|
||||
|
||||
| UI control | `AutoConfig` JSON field | Default |
|
||||
|------------|-------------------------|---------|
|
||||
| Enable automatic first responses | `enabled` | false |
|
||||
| FAQ / template matching | `faq_enabled` | true |
|
||||
| Match confidence | `match_confidence_threshold` | 0.78 |
|
||||
| Retry on first customer reply | `retry_on_first_customer_reply` | false |
|
||||
| Enable AI fallback | `ai_enabled` | false |
|
||||
| Delivery mode | `ai_delivery` | `draft` |
|
||||
| AI confidence | `ai_confidence_threshold` | 0.65 |
|
||||
| Use platform support role | `ai_use_global_support_role` | true |
|
||||
| Overrides | `ai_*_override` | empty |
|
||||
|
||||
Rate-limit fields from contract §8 are **not** in this UI yet (table has no columns); agent 4/9 can add later.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
```powershell
|
||||
cd apps\web
|
||||
npm run check
|
||||
```
|
||||
|
||||
Manual:
|
||||
|
||||
1. Full admin → Manage → Support KB → create published article with keywords → save auto settings (FAQ on, AI draft).
|
||||
2. Confirm Platform settings → AI roles → Support shows as the fallback provider card.
|
||||
3. `support_staff` → Support KB nav hidden; deep link `/admin/support/knowledge` → forbidden empty state.
|
||||
4. After migrate 032+033, settings persist via `PUT /api/admin/support/auto-config`.
|
||||
|
||||
---
|
||||
|
||||
## Notes for integrator (agent 10)
|
||||
|
||||
- Migration order: `032_support_kb_auto_reply.sql` then `033_support_auto_ai_config.sql`.
|
||||
- `GetAutoConfig` falls back to FAQ-only SELECT if AI columns are missing (pre-033).
|
||||
- No git commit from this agent.
|
||||
@@ -0,0 +1,83 @@
|
||||
# 07 — User support detail UI (rich tickets)
|
||||
|
||||
**Agent:** 7/10 (`support-auto`)
|
||||
**Date:** 2026-08-05
|
||||
**Contract:** [02-contract.md](./02-contract.md) §2 / §4.4 / §9
|
||||
**Surfaces:** `/support/new`, `/support/[ticketId]` (list labels shared)
|
||||
|
||||
---
|
||||
|
||||
## Outcome
|
||||
|
||||
Customer Support Center UX for richer tickets:
|
||||
|
||||
1. **Create form** — category help text, priority help, optional SKU + tags (disclosure), existing subject/body counters.
|
||||
2. **Thread** — distinct bubbles for you / human support / automated (KB|template) / AI-assisted / system; badges never impersonate a named agent.
|
||||
3. **Progress** — status + `auto_reply_status` drive a polite “we're on it” / “quick answer sent” banner.
|
||||
4. **CSAT** — unchanged (`SupportTicketRating` when resolved/closed).
|
||||
|
||||
Polish: `aria-describedby` on category/priority/optional fields; thread remains `role="log"` + visibility-aware 20s poll.
|
||||
|
||||
---
|
||||
|
||||
## Client changes
|
||||
|
||||
| File | Intent |
|
||||
|------|--------|
|
||||
| `apps/web/src/lib/support/types.ts` | Taxonomy categories + help; auto message/ticket fields; create `tags` / `related_sku` |
|
||||
| `apps/web/src/lib/support/display.ts` | Labels, tag normalize, `messageKind*`, `ticketProgressState` |
|
||||
| `apps/web/src/lib/support/api.ts` | Parse additive fields; POST optional tags/SKU |
|
||||
| `apps/web/src/routes/support/new/+page.svelte` | Clearer create form |
|
||||
| `apps/web/src/routes/support/[ticketId]/+page.svelte` | Thread + progress banner + tags/SKU chips |
|
||||
| `apps/web/src/routes/support/+page.svelte` | Shared category/priority labels |
|
||||
|
||||
**ASSUMPTION:** Backend agent 5 exposes additive JSON (`tags`, `related_sku`, `auto_reply_status`, message `is_auto_reply` / `auto_source`). UI tolerates missing fields (progress falls back to status-only; bubbles fall back to role).
|
||||
|
||||
**ASSUMPTION:** Internal AI drafts (`ai_draft` / internal notes) stay stripped by existing `asTicket` filter — customers never see drafts.
|
||||
|
||||
---
|
||||
|
||||
## Message labeling (customer)
|
||||
|
||||
| Kind | Detection | Badge |
|
||||
|------|-----------|-------|
|
||||
| You | `author_role=user` | — |
|
||||
| Support | `author_role=agent` | — |
|
||||
| Automated | `is_auto_reply` + source `kb`/`template` (or system auto) | Automated answer |
|
||||
| AI-assisted | `auto_source=ai` | AI-assisted |
|
||||
| System | other `system` | — |
|
||||
|
||||
---
|
||||
|
||||
## Progress copy
|
||||
|
||||
| Condition | Title |
|
||||
|-----------|-------|
|
||||
| `closed` | Ticket closed |
|
||||
| `resolved` | Marked resolved |
|
||||
| `auto_reply_status` ∈ matched \| ai_sent | Quick answer sent |
|
||||
| handed_off \| failed \| `pending` | We're on it |
|
||||
| `open` (else) | Ticket received |
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/web && npm run check
|
||||
```
|
||||
|
||||
Manual:
|
||||
|
||||
1. `/support/new` — change category/priority; help text updates; optional details expand; create with SKU/tags.
|
||||
2. Thread — mock or real auto/AI system messages show distinct styles + badges.
|
||||
3. Open → pending → resolved — progress banner + CSAT still submits.
|
||||
4. Plan gate — `support.ticket_create` upgrade panel unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Staff approve/edit AI drafts (agent 8)
|
||||
- Admin KB settings UI (agent 6)
|
||||
- Backend match/AI (agents 3–5)
|
||||
@@ -0,0 +1,97 @@
|
||||
# 08 — Staff auto / AI assist UI
|
||||
|
||||
**Agent:** 8/10 · **Status:** Implemented
|
||||
**Coordinates with:** `02-contract` (fields), `05-ticket-detail` (schema + PATCH), `03`/`04` (match + AI drafts), `14-support-staff-ui` (desk shell)
|
||||
|
||||
---
|
||||
|
||||
## PROBLEM
|
||||
|
||||
Staff need to see auto-match / AI assist outcomes on a ticket, approve or edit `draft_only` AI drafts, disable further auto per ticket, and filter the queue for `needs_human` / `ai_draft` — without elevating `support_staff` beyond the existing desk gate.
|
||||
|
||||
---
|
||||
|
||||
## WHAT SHIPPED
|
||||
|
||||
### Access (least privilege preserved)
|
||||
|
||||
| Actor | Surfaces |
|
||||
|-------|----------|
|
||||
| `support_staff` | `/admin/support/**` only via `requireSupportDesk` / `RequireSupportDesk` |
|
||||
| Full admin | Same desk + assign picker (unchanged) |
|
||||
|
||||
- New endpoints mount under the **same** `RequireSupportDesk` group as claim/reply.
|
||||
- Approve/discard/get still call `staffMayAccessTicket` (support_staff: own claim or unassigned open/pending; cross-assignee → 404 / 409).
|
||||
- No KB/settings/billing routes exposed to support_staff.
|
||||
|
||||
### Queue (`/admin/support`)
|
||||
|
||||
- Filter chips: **Any auto state** · **Needs human** · **AI draft** (`?flag=`).
|
||||
- Persisted in URL with existing `scope` / `status`.
|
||||
- **Auto** column: status badge or “Auto off”.
|
||||
- API: `GET /api/admin/support/tickets?flag=needs_human|ai_draft`
|
||||
- `ai_draft` → `auto_reply_status = ai_draft`
|
||||
- `needs_human` → `handed_off|failed|skipped`
|
||||
|
||||
### Ticket detail (`/admin/support/[id]`)
|
||||
|
||||
- **Auto-match / AI assist** panel: status, last attempt, disable / re-enable auto (`PATCH { auto_reply_disabled }`).
|
||||
- When `auto_reply_status=ai_draft`: edit draft body → **Approve & send** or **Discard draft**.
|
||||
- Thread bubbles label auto/KB/AI messages; drafts stay internal-note style.
|
||||
- Client falls back to public reply + disable if approve/discard routes are not yet mounted (404/501).
|
||||
|
||||
### API client
|
||||
|
||||
`$lib/support/admin-api.ts` + `$lib/support/auto-assist.ts`
|
||||
|
||||
| Method | Path |
|
||||
|--------|------|
|
||||
| list (+ `flag`) | `GET …/tickets` |
|
||||
| update (incl. `auto_reply_disabled`) | `PATCH …/tickets/{id}` |
|
||||
| approve draft | `POST …/tickets/{id}/ai-draft/approve` |
|
||||
| discard draft | `POST …/tickets/{id}/ai-draft/discard` |
|
||||
|
||||
Domain: `apps/api/internal/support/staff_auto.go` (`ApproveAIDraft`, `DiscardAIDraft`, `ApplyQueueFlag`).
|
||||
|
||||
---
|
||||
|
||||
## FILES
|
||||
|
||||
| Area | Path |
|
||||
|------|------|
|
||||
| Helpers | `apps/web/src/lib/support/auto-assist.ts` |
|
||||
| Client | `apps/web/src/lib/support/admin-api.ts` |
|
||||
| Queue UI | `apps/web/src/routes/admin/support/+page.svelte` |
|
||||
| Detail UI | `apps/web/src/routes/admin/support/[id]/+page.svelte` |
|
||||
| Domain | `apps/api/internal/support/staff_auto.go`, `types.go` (`ListFilter.Flag`), `errors.go` |
|
||||
| List filter | `apps/api/internal/support/tickets.go` (`ApplyQueueFlag`) |
|
||||
| HTTP | `support_handlers.go`, `server.go` (approve/discard mounts) |
|
||||
|
||||
---
|
||||
|
||||
## VERIFICATION
|
||||
|
||||
```text
|
||||
cd apps/web && npm run check
|
||||
cd apps/api && go test ./internal/support/ ./internal/httpapi/ -count=1
|
||||
```
|
||||
|
||||
Manual:
|
||||
|
||||
1. As `support_staff`: open `/admin/support?flag=needs_human` and `?flag=ai_draft` — no billing/settings nav.
|
||||
2. Ticket with FAQ/AI outcome: panel shows status; **Disable auto** sticks across refresh.
|
||||
3. `ai_draft` ticket: edit draft → Approve & send → customer-visible agent reply; draft queue clears.
|
||||
4. Discard → `handed_off` / auto disabled; ticket remains claimable.
|
||||
5. Ticket assigned to another agent → get/approve → 404/409 (not full admin leak).
|
||||
|
||||
---
|
||||
|
||||
## ASSUMPTIONS
|
||||
|
||||
- ASSUMPTION: Ticket auto columns come from agent 5 migration `031_support_ticket_detail` (+ agent 3 `032` KB). UI tolerates missing fields as empty/`none`.
|
||||
- ASSUMPTION: `draft_only` AI posts internal notes with `is_auto_reply` + `auto_source=ai` and sets `auto_reply_status=ai_draft` (agent 4).
|
||||
- ASSUMPTION: Public staff reply already sets `auto_reply_disabled` (existing `ReplyAsAgent`).
|
||||
|
||||
## ROLLBACK
|
||||
|
||||
Revert staff UI/client + `staff_auto.go` + approve/discard routes + `ListFilter.Flag`. Leave schema migrations in place.
|
||||
@@ -0,0 +1,105 @@
|
||||
# 09 — Security & performance: auto-reply + AI fallback
|
||||
|
||||
**Agent:** 9/10 · **Scope:** harden FAQ match + AI fallback (no git)
|
||||
**Contract:** [02-contract.md](./02-contract.md) §§6–7
|
||||
**Related:** [01-inventory.md](./01-inventory.md), `docs/security-notes.md`
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Make automatic first responses (FAQ match → AI fallback) safe under abuse:
|
||||
|
||||
| Control | Requirement |
|
||||
|---------|-------------|
|
||||
| Rate limits | AI jobs: 10 / company / hour + 30 / platform / minute (FAQ match excluded) |
|
||||
| Prompt injection | Ticket subject/body treated as **untrusted data** (delimiters + soft filters) |
|
||||
| Tenant isolation | Ticket reads/writes always filter `company_id`; KB snippets with foreign `Company` dropped |
|
||||
| Secret redaction | Strip keys/tokens/PEM/DSNs before match features, prompts, and logs |
|
||||
| Timeouts | `AutoReplyTimeout` = 25s on `TryAutoReplyLLM` |
|
||||
| Idempotency | No double public auto-post (row lock + unique partial index) |
|
||||
| Indexes | See migration `035_support_auto_security_perf.sql` |
|
||||
|
||||
---
|
||||
|
||||
## Implementation map
|
||||
|
||||
| Piece | Path |
|
||||
|-------|------|
|
||||
| Secret redact + untrusted wrap | `apps/api/internal/security/ticket_prompt.go` |
|
||||
| FAQ match uses shared redact | `RedactSecretsForMatch` → `security.RedactSecrets` |
|
||||
| AI rate limiter | `apps/api/internal/support/auto_ratelimit.go` |
|
||||
| Prompt builder (isolation) | `apps/api/internal/support/auto_prompt.go` → `BuildAutoReplyMessages` |
|
||||
| Idempotent claim / insert | `apps/api/internal/support/auto_idempotency.go` |
|
||||
| Gated LLM entry | `TryAutoReplyLLM` in `ai_auto_reply.go` |
|
||||
| Idempotency indexes | `apps/api/sql/schema/035_support_auto_security_perf.sql` |
|
||||
| AI jobs table | `apps/api/sql/schema/034_support_auto_jobs.sql` (agent 4) |
|
||||
| Abuse tests | `security/ticket_prompt_test.go`, `support/ai_auto_reply_test.go`, `support/auto_security_test.go` |
|
||||
|
||||
### `TryAutoReplyLLM` gate order
|
||||
|
||||
1. `context.WithTimeout(..., AutoReplyTimeout)`
|
||||
2. Load `company_id` for ticket (fail closed if no pool)
|
||||
3. **`SupportAI == nil` → `ErrAIAutoReplyDisabled`** (product still opt-in; agent 4 wires runner)
|
||||
4. `AIRateLimiter.Allow(companyID)` → else `ErrAIRateLimited`
|
||||
5. `ClaimAutoReplyAttempt` (FOR UPDATE; already-posted / disabled / closed)
|
||||
6. `SupportAI.RunAutoReply` with nested timeout; errors logged via `RedactForAutoLog`
|
||||
|
||||
### Prompt contract
|
||||
|
||||
- Fixed `AutoReplySystemPrompt` (server-owned; not admin free-text).
|
||||
- Customer text wrapped in `<<<UNTRUSTED_*_START/END>>>` after `SanitizeUntrustedTicketText`.
|
||||
- Platform KB only (`Company == uuid.Nil`) or matching `companyID`; never other tenants’ tickets.
|
||||
|
||||
### Idempotency
|
||||
|
||||
- Application: `PostMatchedAutoReply` + `ClaimAutoReplyAttempt` / `InsertAutoSystemMessage` check `auto_reply_message_id`, status, and existing public `is_auto_reply` rows; unique violations treated as soft skip.
|
||||
- DB: unique partial index `support_messages_one_public_auto_per_ticket_uidx` on `(ticket_id) WHERE is_auto_reply AND NOT is_internal_note`.
|
||||
- AI jobs: unique partial `support_auto_jobs_ticket_active_uidx` in `034_support_auto_jobs.sql` for `pending|running`.
|
||||
|
||||
---
|
||||
|
||||
## Threat model (abuse cases covered by tests)
|
||||
|
||||
| Case | Test |
|
||||
|------|------|
|
||||
| Injection phrases in ticket body | `TestSanitizeUntrustedTicketTextFiltersInjection`, `TestBuildAutoReplyMessages_treatsBodyAsUntrusted` |
|
||||
| API keys / Stripe / PEM / DSN in body | `TestRedactSecretsTicketAbuse`, `TestRedactSecretsForMatch_delegates` |
|
||||
| Cross-tenant KB snippet | `TestFilterKBSnippetsForCompany_blocksCrossTenant`, `TestBuildAutoReplyMessages_dropsForeignCompanySnippets` |
|
||||
| Rate limit company / platform | `TestAIRateLimiter_*` |
|
||||
| Secrets in log strings | `TestRedactForAutoLog_stripsSecrets` |
|
||||
| LLM disabled by default | `TestTryAutoReplyLLM_refuses` |
|
||||
|
||||
---
|
||||
|
||||
## Performance notes
|
||||
|
||||
| Path | Guidance |
|
||||
|------|----------|
|
||||
| FAQ match | Sync, corpus cache (agent 3), GIN on keywords (032), no external I/O |
|
||||
| AI | Async preferred (jobs table in 033); HTTP create must not await LLM |
|
||||
| Lists | Do not embed full `customer_context` (agent 5) |
|
||||
| Rate limits | In-process only — effective RPM ≈ N × replicas (same as marketing/processing) |
|
||||
|
||||
---
|
||||
|
||||
## ASSUMPTIONs
|
||||
|
||||
1. Platform KB has no `company_id` (global help center). Per-tenant KB remains out of scope; if added later, every query **must** filter `company_id` and prompt builders must keep using `FilterKBSnippetsForCompany`.
|
||||
2. Agent 4 implements `SupportAIRunner` using `BuildAutoReplyMessages` + `InsertAutoSystemMessage` + `aiprovider.ResolveCompleterForRole(..., RoleSupport)` — no second secret store.
|
||||
3. FAQ match stays free of AI rate limits (contract §6).
|
||||
|
||||
---
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
cd apps/api
|
||||
go test ./internal/security ./internal/support -count=1 -run "Redact|SanitizeUntrusted|WrapUntrusted|AIRate|BuildAutoReply|FilterKB|TryAutoReplyLLM_refuses|AutoReplyTimeout"
|
||||
```
|
||||
|
||||
Apply migrations through `035_support_auto_security_perf.sql` (and `034_support_auto_jobs.sql`) before relying on unique indexes / jobs table.
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert `035_*` down migration; remove SupportAI / AIRateLimiter fields usage; restore stub-only `TryAutoReplyLLM` if needed. FAQ match continues to work without AI runner.
|
||||
@@ -0,0 +1,36 @@
|
||||
# 10 — Support KB product content (agent 6/10)
|
||||
|
||||
**Scope:** User-facing Support Knowledge articles about Descrybe product, workflows, how-tos, and troubleshooting.
|
||||
**Storage:** `support_kb_articles` (goose `032_support_kb_auto_reply.sql`). Admin UI: `/admin/support/knowledge`.
|
||||
|
||||
## Seed
|
||||
|
||||
JSON corpus: `scripts/seed/support-kb-articles.json`
|
||||
Command:
|
||||
|
||||
```powershell
|
||||
cd apps/api
|
||||
go run ./cmd/seed-support-kb -postgres $env:DATABASE_URL
|
||||
# or rely on DATABASE_URL from the environment / root .env
|
||||
```
|
||||
|
||||
Idempotent upsert on `slug`. Articles are published with keywords/intent_keys for FAQ auto-match.
|
||||
|
||||
## Images
|
||||
|
||||
Markdown bodies reference existing `apps/web/static` assets (no generated images):
|
||||
|
||||
| Asset | Used in |
|
||||
|-------|---------|
|
||||
| `/descrybe_preview.png` | What is Descrybe |
|
||||
| `/descrybe_logo.png` | What is Descrybe |
|
||||
| `/import_suppliers.png` | Getting started, import feeds |
|
||||
| `/enhance_products.png` | Getting started, AI titles/descriptions |
|
||||
| `/export_data.png` | Getting started, export feeds |
|
||||
| `/import_taxonomy.png` | Feed mapping |
|
||||
|
||||
## Accuracy sources
|
||||
|
||||
README, `apps/web/src/lib/components/site/data.ts`, pricing-data Free/credits FAQ, `docs/getting-started.md`, `docs/free-tier.md`, `docs/store-connectors.md`, `docs/eprel.md`, `docs/tutorial.md`, `docs/marketing-suite-user-guide.md`, processing `ParseV1ProcessingType` / EAN validation in `v1_process_handlers.go`.
|
||||
|
||||
**Explicit non-claims:** FTP unsupported; Amazon export preset is marketing graphic / backlog (shipped presets: Google Shopping, Meta, custom); marketing throughput stats from homepage are not repeated as verified benchmarks in KB bodies.
|
||||
@@ -0,0 +1,145 @@
|
||||
# Support auto-reply (FAQ → AI fallback)
|
||||
|
||||
End-to-end path: **ticket create → `MatchAutoReply` (FAQ/template) → AI fallback (`TryAutoReplyLLM`) → human queue on failure**.
|
||||
|
||||
## Status (agent 10 integration)
|
||||
|
||||
| Gate | Result |
|
||||
|------|--------|
|
||||
| Migrations `031`–`035` | Applied (`goose` → version **35**) |
|
||||
| `go test ./internal/support/... ./internal/httpapi/...` | Pass |
|
||||
| `npm run check` (svelte-check) | Pass (0 errors) |
|
||||
| `go build ./cmd/api ./cmd/worker` | Pass |
|
||||
|
||||
**Wiring filled by agent 10:** duplicate `031` migrations renumbered; goose `StatementBegin` around `DO $$`; worker + API poll `ProcessPendingAutoJobs` / `RunAutoJobsLoop` so AI jobs drain.
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
POST /api/support/tickets
|
||||
│
|
||||
▼
|
||||
Create (tags/SKU/context from 031)
|
||||
│
|
||||
▼
|
||||
MaybeAutoReplyOnCreate
|
||||
│
|
||||
├─ FAQ enabled + confidence ≥ threshold
|
||||
│ → PostMatchedAutoReply (system, labeled)
|
||||
│
|
||||
├─ miss / below threshold + ai_enabled
|
||||
│ → EnqueueAIFallback → support_auto_jobs
|
||||
│ → poller: TryAutoReplyLLM → CompleterSupportAI
|
||||
│ ├─ draft (internal note) or auto_send (public)
|
||||
│ └─ fail / low conf → handoff (needs_human)
|
||||
│
|
||||
└─ auto off → skipped → human inbox
|
||||
```
|
||||
|
||||
## Configure FAQ / templates
|
||||
|
||||
1. Platform admin → **`/admin/support/knowledge`** (or APIs below).
|
||||
2. Create a **KB article** with `keywords` / `intent_keys` / `category_slugs`, set **`is_published=true`**.
|
||||
3. Optionally add a **reply template** (`is_active=true`). Placeholders: `{{subject}}`, `{{category}}`.
|
||||
4. Open **auto-config** and set:
|
||||
- `enabled: true`
|
||||
- `faq_enabled: true`
|
||||
- `match_confidence_threshold` (default **0.78**, range 0.50–0.95)
|
||||
|
||||
APIs (RequirePlatformAdmin):
|
||||
|
||||
| Method | Path |
|
||||
|--------|------|
|
||||
| GET/POST | `/api/admin/support/kb/articles` |
|
||||
| GET/PATCH/DELETE | `/api/admin/support/kb/articles/{id}` |
|
||||
| GET/POST | `/api/admin/support/templates` |
|
||||
| GET/PATCH/DELETE | `/api/admin/support/templates/{id}` |
|
||||
| GET/PUT | `/api/admin/support/auto-config` |
|
||||
|
||||
Client: `apps/web/src/lib/support/admin-kb-api.ts`.
|
||||
|
||||
## Configure admin AI (fallback)
|
||||
|
||||
1. **`/admin/settings` → AI roles** → role **`support`**.
|
||||
2. Set provider / base URL / model / API key; enable the role.
|
||||
3. In support auto-config:
|
||||
- `ai_enabled: true`
|
||||
- `ai_delivery`: `draft` (staff-only note) or `auto_send` (customer-visible, labeled)
|
||||
- `ai_confidence_threshold` (default **0.65**)
|
||||
- `ai_use_global_support_role: true` (uses platform `ai_roles.support`; no parallel BYOK)
|
||||
|
||||
Runtime gate: `support.TryAutoReplyLLM` → `CompleterSupportAI` → `aiprovider.ResolveCompleterForRole(..., RoleSupport)`.
|
||||
|
||||
## Runtime / migrate
|
||||
|
||||
```bash
|
||||
cd apps/api
|
||||
make migrate-up # DATABASE_URL from Makefile / .env
|
||||
go test ./internal/support/... ./internal/httpapi/...
|
||||
go build ./cmd/api ./cmd/worker
|
||||
```
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
npm run check
|
||||
```
|
||||
|
||||
**Processes:** API starts a lightweight `RunAutoJobsLoop` (2s / batch 3). Worker also calls `ProcessPendingAutoJobs` each poll tick. For production, keep the **worker** running so AI jobs are not only processed by the API process.
|
||||
|
||||
## Manual verification checklist
|
||||
|
||||
### A. FAQ match → auto reply
|
||||
|
||||
1. Enable auto-config (`enabled` + `faq_enabled`).
|
||||
2. Publish a KB article with distinctive keywords (e.g. `refund policy`).
|
||||
3. As a customer, create a ticket whose subject/body includes those keywords.
|
||||
4. **Expect:** thread shows a **system** message (`is_auto_reply`, footer “Automated answer from help center”); `auto_reply_status=matched`.
|
||||
|
||||
### B. Obscure question → AI reply or draft
|
||||
|
||||
1. Keep FAQ on; enable `ai_enabled`; configure AI role **`support`**.
|
||||
2. Create a ticket with nonsense / no keyword overlap.
|
||||
3. Wait a few seconds for the job poller (API or worker).
|
||||
4. **Expect:**
|
||||
- `ai_delivery=draft` → internal AI draft note (`auto_reply_status=ai_draft`); staff can approve at `/admin/support/[id]`.
|
||||
- `ai_delivery=auto_send` → public labeled AI reply (`ai_sent`).
|
||||
|
||||
### C. AI fail → human queue
|
||||
|
||||
1. Disable the `support` AI role **or** point it at a bad key/model, with `ai_enabled=true`.
|
||||
2. Create an unmatched ticket.
|
||||
3. **Expect:** job fails / handoff; `auto_reply_status` in `handed_off` / `failed` / `skipped`; ticket appears in staff inbox filters **needs_human** (`flag=needs_human`). No customer-facing fake answer.
|
||||
|
||||
## Doc index
|
||||
|
||||
| Doc | Agent | Topic |
|
||||
|-----|-------|-------|
|
||||
| [01-inventory.md](./01-inventory.md) | 1 | Desk + admin AI inventory |
|
||||
| [02-contract.md](./02-contract.md) | 2 | Product contract |
|
||||
| [03-kb-auto-reply.md](./03-kb-auto-reply.md) | 3 | FAQ matcher |
|
||||
| [04-ai-fallback.md](./04-ai-fallback.md) | 4 | AI assist |
|
||||
| [05-ticket-detail.md](./05-ticket-detail.md) | 5 | Rich ticket fields |
|
||||
| [06-admin-kb-settings-ui.md](./06-admin-kb-settings-ui.md) | 6 | Admin KB UI |
|
||||
| [07-user-support-detail-ui.md](./07-user-support-detail-ui.md) | 7 | Customer UX |
|
||||
| [08-staff-auto-ai-ui.md](./08-staff-auto-ai-ui.md) | 8 | Staff drafts / filters |
|
||||
| [09-security-perf.md](./09-security-perf.md) | 9 | Rate limits / idempotency |
|
||||
|
||||
## Schema series
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `031_support_ticket_detail.sql` | Tags, categories, activity, auto_* ticket/message cols |
|
||||
| `032_support_kb_auto_reply.sql` | KB + templates + `support_auto_config` |
|
||||
| `033_support_auto_ai_config.sql` | AI columns on auto-config |
|
||||
| `034_support_auto_jobs.sql` | Async AI job queue |
|
||||
| `035_support_auto_security_perf.sql` | One public auto-reply per ticket + indexes |
|
||||
|
||||
## Remaining blockers / caveats
|
||||
|
||||
1. **Manual E2E against a live LLM was not executed here** — needs a real `support` AI key and browser/session. Use the checklist above.
|
||||
2. **CSAT public tokens** remain unwired (pre-existing inventory gap).
|
||||
3. **Admin AI role probe** `POST /api/admin/settings/ai-roles/{role}/test` still optional/not mounted (404 skipped in client).
|
||||
4. Default **`enabled=false`** / **`ai_enabled=false`** — auto-reply stays off until an admin turns it on (safe default).
|
||||
5. If only the API runs and the process is under heavy load, prefer also running **`cmd/worker`** so AI jobs keep draining.
|
||||
|
||||
No git commit (per task).
|
||||
Reference in New Issue
Block a user