003-ticketing's ticket-state-machine.ts already defines AI_ANALYZING/ AI_TROUBLESHOOTING/AI_VERIFYING/AI_RESOLVED/HUMAN_ESCALATION ticket statuses, clearly authored anticipating this feature. Corrects the plan before implementation: AISupportSession.status now drives Ticket.status through the existing ticketsService.updateStatus (reusing its optimistic concurrency), instead of an isolated status field the rest of the system never sees. Also clarifies that knowledge retrieval is an in-process service call through knowledge's index.ts, not an HTTP loopback. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
6.9 KiB
Phase 1 Data Model: AI Support Agent
All new models use cuid() ids. Refines docs/06-database-schema.md's conceptual
AISupportSession/AIDiagnosis/AIInteraction/AIAction/AIActionResult/
AIKnowledgeReference shapes; adds AIConfidencePolicy (research.md — not in doc 06, required
by FR-005).
AISupportSession
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| ticketId | String | FK → Ticket. One active session per ticket at a time (FR-001) — enforced in the repository (partial-condition check, not a DB constraint, since a ticket accumulates multiple ended sessions over time if re-escalated and re-opened) |
| status | String | analyzing | troubleshooting | verifying | resolved | escalated | ended_by_agent (doc 06's enum, plus ended_by_agent for FR-023). Every transition except ended_by_agent is mirrored onto Ticket.status via the existing 003 state machine (AI_ANALYZING/AI_TROUBLESHOOTING/AI_VERIFYING/AI_RESOLVED/HUMAN_ESCALATION) through ticketsService.updateStatus(..., 'ai') — research.md "AISupportSession.status drives Ticket.status" |
| activeRunbookKey | String? | Set when a diagnosis matches a runbook (research.md "Runbook engine") |
| currentStepIndex | Int? | App-owned index into the active runbook's steps; null when no runbook is active |
| clarifyingQuestionsAsked | Int @default(0) | Counted against AIConfidencePolicy.maxClarifyingQuestions (FR-009) |
| toolCallCount | Int @default(0) | Counted against the per-session hard cap (doc 11 §B2 — Assumptions) |
| startedAt | DateTime @default(now()) | |
| endedAt | DateTime? |
Relations: diagnoses AIDiagnosis[], interactions AIInteraction[], actions AIAction[],
knowledgeRefs AIKnowledgeReference[].
Index: (ticketId, status) — the exact shape the "one active session per ticket" check and
the ticket-detail view both query on.
AIDiagnosis
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| sessionId | String | FK → AISupportSession |
| product | String | Echoes the ticket's product for readability; not a second source of truth for scoping (the session's ticketId → Ticket.productId remains authoritative) |
| feature | String? | |
| problemType | String | Matched against Runbook.key for the runbook-engine trigger (research.md) |
| severity | String | |
| confidence | Float | 0–1; the value the confidence-band policy (FR-004) is applied to |
| possibleCauses | String[] | |
| createdAt | DateTime @default(now()) |
Never updated in place — a session accumulates one row per diagnosis attempt (initial + each re-diagnosis after a customer reply), matching FR-002's "never overwriting a prior one."
AIInteraction
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| sessionId | String | FK → AISupportSession |
| role | String | customer | ai |
| content | String | |
| createdAt | DateTime @default(now()) |
An AIInteraction with role: ai that's a clarifying question or guided-step message is also
written as a TicketMessage (type: AI_MESSAGE) via the existing messages module — AIInteraction
is the session's own ordered transcript for reasoning-call context; TicketMessage is the
customer-visible record. They're intentionally two records: the session transcript may include
turns not meant to duplicate onto the ticket (e.g., an internal re-diagnosis triggered by a tool
result, with no new customer-facing text).
AIAction / AIActionResult
Field (AIAction) |
Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| sessionId | String | FK → AISupportSession |
| toolName | String | Must match a name in the code-defined registry (research.md) |
| input | Json | The model's proposed input, before any validation |
| riskLevel | String | Copied from the registry at evaluation time (low/medium/high) — a durable record of what risk tier applied, independent of later registry changes |
| evaluationOutcome | String | approved | pending_approval | refused — the deterministic gate's decision (research.md), always recorded even when nothing executes |
| refusalReason | String? | Set when evaluationOutcome = refused (unknown tool, product not in scope, etc.) |
| approvedBy | String? | system-policy for auto-approved low-risk; null while pending_approval; an agent id if a future approval UI fills it in |
| createdAt | DateTime @default(now()) |
Field (AIActionResult) |
Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| actionId | String @unique | FK → AIAction — only exists when evaluationOutcome = approved and execution actually ran |
| output | Json | |
| status | String | success | failed |
| createdAt | DateTime @default(now()) |
A pending_approval or refused AIAction has no AIActionResult row — the absence itself is
the record of "never executed" (FR-013 requires the proposal+evaluation to be recorded either
way, not that every proposal produces a result).
AIKnowledgeReference
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| sessionId | String | FK → AISupportSession |
| knowledgeId | String | KnowledgeEntry.id (004) — not a DB-level FK across module boundaries per this codebase's convention of modules only depending on each other's index.ts, but a plain string reference resolved through knowledge's exported repository |
| relevanceScore | Float? | Null for now — 004's retrieval doesn't emit a numeric score (structured filtering + validation-status ranking, not a similarity score); reserved for a future semantic-retrieval layer (spec.md Assumptions) |
| createdAt | DateTime @default(now()) |
One row per knowledge entry actually included in a reasoning call's context — the durable record of what the AI was actually shown, satisfying doc 03 §9's "AI must never invent... expose internal notes" concern from the audit side (you can always answer "what knowledge did the AI see for this session").
AIConfidencePolicy
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| productId | String? | Null = system-wide default row (optional; env-var defaults cover the no-row case too — research.md) |
| categoryId | String? | Null = applies to every category of productId |
| highThreshold | Float | confidence >= highThreshold → proceed |
| lowThreshold | Float | confidence < lowThreshold → escalate; between the two → ask |
| maxClarifyingQuestions | Int | FR-009's cap |
| updatedAt | DateTime @updatedAt |
Constraints: @@unique([productId, categoryId]). highThreshold > lowThreshold is validated
at the service layer (Zod refinement), not the DB.
Ticket (relation added by this feature)
aiSessions AISupportSession[] — the forward relation doc 06 already specified on Ticket but
that couldn't be added until AISupportSession existed (same pattern 004 used for Product's
relations).