Files
descrybe/docs/support-auto/02-contract.md
T
greeneclipse 8580c996c3 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.
2026-08-09 22:47:43 +02:00

421 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.