Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
20 KiB
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
0. North star
Give support tickets richer customer context, then on create (first response only by default) attempt:
- Deterministic match against FAQ / knowledge articles or reply templates (keyword + intent).
- If match confidence is below threshold → AI assist using the platform admin
supportAI role. - If AI fails / is low-confidence / customer replies again → human handoff (queue + claim desk from 11-support-design).
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-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 / 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 |
ASSUMPTION: Extending TryAutoReplyLLM remains the single AI gate; FAQ matching is a sibling TryAutoReplyMatch (or internal stages of one orchestrator) that never calls an LLM. Explicit product gate = support_auto.enabled and ai_roles.support.enabled (inventory §4.3).
2. Richer ticket fields
2.1 Additive fields on support_tickets
| Field | Type | Required | Notes |
|---|---|---|---|
priority |
enum low|normal|high |
yes (default normal) |
Exists — preserve. |
tags |
TEXT[] or join table |
no | Max 10 tags; each ≤ 40 runes; lowercase slug-ish; staff + customer may set on create; staff may PATCH. |
related_product_id |
UUID nullable |
no | Company-scoped catalog product when resolvable. Validate company_id match — never cross-tenant. |
related_sku |
TEXT nullable |
no | Free-text SKU (≤ 128). Stored even if product id unknown; used for match features + staff UI. |
category |
taxonomy slug | yes | See §2.2. |
customer_context |
JSONB |
no (server-filled) | Snapshot at create — see §2.3. Immutable after create (staff-visible; customers see limited subset). |
auto_reply_disabled |
BOOLEAN NOT NULL DEFAULT false |
— | Staff toggle; also set automatically on human handoff / customer follow-up after auto. |
auto_reply_status |
enum | — | none|matched|ai_draft|ai_sent|skipped|failed|handed_off. |
auto_reply_attempted_at |
TIMESTAMPTZ |
— | Last orchestrator run. |
auto_reply_message_id |
UUID nullable |
— | Public auto message when posted. |
Contracts preserved: existing create body (subject, category, priority, body) remains valid. New fields are additive optional.
2.2 Category taxonomy
Today’s CHECK: billing | bug | account | other.
Chosen approach: keep seed slugs for backward compatibility; move allow-list to support_categories (platform-scoped admin taxonomy):
| Column | Purpose |
|---|---|
slug |
Stable API value (PK) |
label |
Admin/UI display |
parent_slug |
Optional hierarchy (1 level deep MVP) |
sort_order |
List order |
is_active |
Soft-disable without breaking historical tickets |
match_intents |
Optional string[] of intent keys for matcher |
Seed rows: existing four categories. Admin may add e.g. integrations, processing, export, billing_credits.
BREAKING (soft): create/update must reject unknown active slugs; historical tickets may retain inactive slugs (read-only display).
Create API: category still a string slug (default other).
2.3 Customer context snapshot
Filled server-side on Create from the authenticated company/user — never trust client JSON for authz-sensitive facts.
{
"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
signalsif 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:
{
"kind": "kb_article|template|none",
"ref_id": "uuid|null",
"confidence": 0.0,
"body": "string|null",
"label": "string"
}
- If
confidence >= settings.match_confidence_thresholdand auto enabled → post public message:author_role = "system"author_user_id = nullis_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_replyor new kindauto_reply— prefer newauto_replyfor 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_enabledand support AI configured → enqueue async AI job (§5). - Else →
auto_reply_status=skipped(orhanded_offif desk expects human-only).
Default match_confidence_threshold = 0.78 (admin-configurable 0.50–0.95).
4.4 Message labeling (customer + staff)
| Audience | Presentation |
|---|---|
| Customer | System bubble with badge “Automated” / “AI-assisted”; never look like a named human agent. |
| Staff | Same + auto_source, confidence, KB/template link; AI drafts as internal notes when delivery=draft. |
Extend support_messages (additive):
auto_source TEXT CHECK (auto_source IS NULL OR auto_source IN ('kb','template','ai')),
auto_confidence REAL,
auto_ref_type TEXT,
auto_ref_id UUID,
is_auto_reply BOOLEAN NOT NULL DEFAULT false
5. AI assist
5.1 Provider resolution (discovered)
| Symbol / path | Role |
|---|---|
platformsettings.AIRoleSupport ("support") |
Admin slot in ai_roles. |
platformsettings.ResolveAIConfig(ctx, AIRoleSupport) |
Decrypt key; provider/base_url/model/enabled. |
aiprovider.RoleSupport / ResolveCompleterForRole(ctx, companyID, RoleSupport) |
Completer; platform-only for support (no env fallback). |
support.TryAutoReplyLLM(ctx, ticketID) |
Sole product gate for ticket LLM sends/drafts. |
| Admin UI | /admin/settings + admin-ai-roles.ts role support. |
| API | GET/PUT /api/admin/settings → ai_roles.support. |
ASSUMPTION: Support auto uses platform ai_roles.support, never tenant BYOK. companyID passed to resolver is the ticket’s company for future bindings only; MVP ignores company BYOK for RoleSupport (already the case in ResolveCompleterForRole switch).
5.2 Admin AI override vs global
Under support-auto settings (§8):
| Setting | Behavior |
|---|---|
ai_use_global_support_role: true (default) |
Use ResolveCompleterForRole(..., RoleSupport) as configured in admin. |
ai_model_override / ai_provider_override / ai_base_url_override |
Optional; applied only when ai_use_global_support_role is true and overrides non-empty — still uses the same API key from AIRoleSupport (never a second secret store). |
| Role disabled / missing key | AI stage → failed → human handoff; log without secrets. |
5.3 Prompt contract
System prompt (server-owned, not admin free-text MVP):
- Answer only from provided KB snippets + ticket text.
- No inventing billing credits, invoices, or other tenants’ data.
- If unsure, say so and ask one clarifying question OR signal
handoff=true. - Output JSON:
{ "body": "...", "confidence": 0-1, "handoff": bool, "citations": ["kb:slug"] }.
User/content payload:
- Redacted ticket subject + body (§6).
- Category, tags, related_sku (company-scoped).
- Top-N KB snippets from cheap lexical retrieval (even if below FAQ auto-send threshold).
- Customer context snapshot sanitized (no emails of other users; plan slug ok).
5.4 Delivery modes
ai_delivery |
Action |
|---|---|
draft (default, safer) |
Insert internal note (is_internal_note=true, author_role=system, is_auto_reply=true); notify assignee or unassigned queue via staff notification; auto_reply_status=ai_draft. Customer sees nothing yet. |
auto_send |
Post public system message (labeled AI-assisted); status → pending; auto_reply_status=ai_sent. |
Require ai_confidence >= settings.ai_confidence_threshold (default 0.65) and handoff=false to send/draft; else handoff.
5.5 Async job (preferred)
Sync path forbidden for LLM on the HTTP create request.
Job sketch (support_auto_jobs or reuse processing-style pending table):
| Column | Notes |
|---|---|
id, ticket_id, company_id |
Tenant key for isolation |
status |
pending|running|done|failed |
attempt, last_error |
No PII in error; truncate |
created_at, updated_at |
Worker:
- Skip if
auto_reply_disabledor ticket closed/resolved or already matched/sent. - Rate-limit check (§6).
- Call
TryAutoReplyLLM(implementation loads ticket, snippets, completer, posts draft/send). - Idempotent: unique partial index on
(ticket_id) WHERE status IN ('pending','running')or ticket-level lock.
ASSUMPTION: Follow existing jobs.Queue + worker poller pattern (apps/api/internal/jobs/river.go comment: River deferred). Name the worker kind support_auto_reply.
6. Security
| Control | Rule |
|---|---|
| Tenant isolation | All ticket reads/writes filter company_id. KB is platform-global content but never includes other companies’ tickets in prompts. Related product id validated against ticket company_id. |
| Prompt isolation | Completer called with one ticket’s redacted text only; never batch multi-tenant prompts. |
| Secret redaction | Before match features + AI: strip patterns for API keys, Bearer tokens, sk-, AWS keys, private key PEM, connection strings; replace with [REDACTED]. Do not store raw secrets in customer_context or auto job logs. |
| Rate limits | Per company_id: default 10 AI jobs / hour; platform global: 30 AI jobs / minute. FAQ match not counted toward AI limits. Reuse sliding-window pattern (httpapi/ratelimit.go). Exceed → skipped/failed + handoff, HTTP create still 201. |
| Disable per ticket | Staff PATCH { "auto_reply_disabled": true }; also auto-set on handoff / customer follow-up after auto. |
| AuthZ | Settings: RequirePlatformAdmin. Disable flag: RequireSupportDesk on assigned/visible ticket. KB CRUD: platform admin. |
| Customer visibility | Never expose internal AI drafts, confidence, or admin settings. |
| Logging | No API keys, no full bodies at info level; structured ticket_id, company_id, auto_source, confidence. |
| Docs Ask | Remains rule-based; must not use AIRoleSupport / AIRoleDocsAPI (existing invariant). |
7. Performance
| Path | Guidance |
|---|---|
| FAQ/template match | Sync, bounded corpus, GIN on keywords; target < 50 ms; no external I/O. |
| AI assist | Async job only; HTTP returns before LLM. |
| Create API | Do not hold open DB transactions across LLM. |
| Indexes | (auto_reply_status), GIN tags, FK related_product_id, job (status, created_at). |
| List endpoints | Do not embed full customer_context in list — detail only (or truncated). |
| N+1 | Matcher loads published KB once per process with short TTL cache (e.g. 60s) — invalidate on admin KB write. |
8. Admin settings
Stored under platform settings (JSON key support_auto in values or first-class object on PublicView — implementers pick one; prefer first-class for typed admin UI).
| Key | Type | Default | Purpose |
|---|---|---|---|
enabled |
bool | false |
Master switch for any auto first response. |
faq_enabled |
bool | true |
Stage A. |
ai_enabled |
bool | false |
Stage B (still requires ai_roles.support configured + enabled). |
match_confidence_threshold |
number | 0.78 |
FAQ/template auto-send. |
ai_confidence_threshold |
number | 0.65 |
Min model confidence. |
ai_delivery |
draft|auto_send |
draft |
|
ai_use_global_support_role |
bool | true |
Use admin ai_roles.support. |
ai_provider_override |
string? | null | Optional; key still from support role. |
ai_model_override |
string? | null | |
ai_base_url_override |
string? | null | |
rate_limit_per_company_per_hour |
int | 10 |
AI jobs. |
rate_limit_platform_per_minute |
int | 30 |
AI jobs. |
retry_on_first_customer_reply |
bool | false |
API (additive):
| Method | Path | Auth |
|---|---|---|
| GET/PUT | /api/admin/settings (embed support_auto) |
Platform admin |
| CRUD | /api/admin/support/kb/articles* |
Platform admin |
| CRUD | /api/admin/support/templates* |
Platform admin |
| CRUD | /api/admin/support/categories* |
Platform admin |
| PATCH | /api/admin/support/tickets/{id} |
Support desk — auto_reply_disabled, tags, related_*, priority, category |
Customer create (additive body fields): tags?, related_product_id?, related_sku? (category taxonomy as today).
9. Human handoff
Set auto_reply_status=handed_off, auto_reply_disabled=true when:
- AI returns
handoff=trueor confidence below threshold. - AI provider error / timeout / unset completer.
- Rate limit exceeded for AI.
- Customer sends another message after an automated public reply.
- 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.
TryAutoReplyLLMopt-in replaces the “keep disabled” note in 11-support §11.9 when this feature ships behindsupport_auto.enabled.
11. Implementation phasing (later agents)
- Migration: ticket rich fields + message auto columns + categories + KB + templates + auto jobs.
- Admin settings + KB/template/category CRUD.
- Sync matcher + labeled system post on create.
- Implement
TryAutoReplyLLM+ async worker + redaction + rate limits. - Staff UI: disable auto, show auto metadata, AI draft notes.
- Customer UI: tags/SKU/category; automated badge on messages.
- 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_roleenum values; system used for auto.- Admin
ai_roles.supportshape; 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_replyrequires 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.