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:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+306
View File
@@ -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"
}
}
+202
View File
@@ -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 15 + 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` ~494501:
| 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`) — ~304313:
| 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** — ~316332:
| 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 anothers 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 (preagents 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/1115`, `plan-permissions/01-feature-keys.json`, `06-defaults-matrix`
+621
View File
@@ -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"
]
}
+420
View File
@@ -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
Todays 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.500.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 tickets 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 tickets** 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 &lt; 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.
+64
View File
@@ -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.
+99
View File
@@ -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.
+134
View File
@@ -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, 67
---
## 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 34:
- `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 callers `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 35)
+97
View File
@@ -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.
+105
View File
@@ -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) §§67
**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.
+145
View File
@@ -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.500.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).