feat: implement AI support agent (005) — diagnosis, tools, runbooks, verification

Real Anthropic Claude integration per explicit product decision: a
ticket's AI session diagnoses the problem via a structured-output call,
applies a DB-configurable confidence-band policy (FR-005), and on
"proceed" reasons and acts through a small permission/risk-gated tool
system (FR-011/FR-012), optionally walking a matching runbook step by
step with the application — never the model — owning the step index
(FR-015/FR-016). Resolution requires real tool evidence, never customer
claims alone (FR-018) — verifyProductResolution is a documented
fail-closed placeholder mirroring the existing malware-scanner precedent,
since no real per-product operational signal exists yet.

AISupportSession.status mirrors onto Ticket.status through 003-ticketing's
existing AI_ANALYZING/AI_TROUBLESHOOTING/AI_VERIFYING/AI_RESOLVED/
HUMAN_ESCALATION state machine, discovered during planning to have been
built anticipating this exact feature. Two circular module dependencies
(escalation<->sessions, tools<->sessions) were designed around rather than
found as bugs: escalation is a pure summary formatter with no state
dependencies of its own, and tools stays a clean leaf module with zero
dependency on ai-support/sessions. Ticket creation enqueues the first
diagnosis turn via the existing queue infrastructure (off the hot path of
the inbound SaaS integration endpoint); a human actor changing ticket
status ends the AI session via the event-bus scaffold that existed in
this codebase but had never been wired to anything.

A real Prisma limitation was found and fixed before it reached tests:
compound-unique upsert rejects null for a nullable key column, so
AIConfidencePolicy uses find-then-update/create instead, same fix class
004 already used for the same underlying limitation.

Adds 9 unit tests (confidence-band, tool-policy-gate, runbook-step-
advance) and 6 integration test files, including the two constitution-
required standing E2E scenarios. AI-independent tests were run against
real Postgres/Redis/MinIO (88 passed, 0 failed across the full suite,
including every pre-existing 002/003/004 test). The AI-dependent tests
compile and skip cleanly via describe.skipIf but were not run against a
live model — no ANTHROPIC_API_KEY was available in this session; a real
key must be supplied before this feature can actually run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
saqib mir
2026-09-02 17:44:52 +05:30
co-authored by Claude Sonnet 5
parent 49eaa4bc58
commit 82d02bcdcd
77 changed files with 3679 additions and 77 deletions
@@ -0,0 +1,132 @@
-- CreateTable
CREATE TABLE "ai_support_sessions" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"activeRunbookKey" TEXT,
"currentStepIndex" INTEGER,
"clarifyingQuestionsAsked" INTEGER NOT NULL DEFAULT 0,
"toolCallCount" INTEGER NOT NULL DEFAULT 0,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"endedAt" TIMESTAMP(3),
CONSTRAINT "ai_support_sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ai_diagnoses" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"product" TEXT NOT NULL,
"feature" TEXT,
"problemType" TEXT NOT NULL,
"severity" TEXT NOT NULL,
"confidence" DOUBLE PRECISION NOT NULL,
"possibleCauses" TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ai_diagnoses_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ai_interactions" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"role" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ai_interactions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ai_actions" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"toolName" TEXT NOT NULL,
"input" JSONB NOT NULL,
"riskLevel" TEXT NOT NULL,
"evaluationOutcome" TEXT NOT NULL,
"refusalReason" TEXT,
"approvedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ai_actions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ai_action_results" (
"id" TEXT NOT NULL,
"actionId" TEXT NOT NULL,
"output" JSONB NOT NULL,
"status" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ai_action_results_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ai_knowledge_references" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"knowledgeId" TEXT NOT NULL,
"relevanceScore" DOUBLE PRECISION,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ai_knowledge_references_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ai_confidence_policies" (
"id" TEXT NOT NULL,
"productId" TEXT,
"categoryId" TEXT,
"highThreshold" DOUBLE PRECISION NOT NULL,
"lowThreshold" DOUBLE PRECISION NOT NULL,
"maxClarifyingQuestions" INTEGER NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ai_confidence_policies_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ai_support_sessions_ticketId_status_idx" ON "ai_support_sessions"("ticketId", "status");
-- CreateIndex
CREATE INDEX "ai_diagnoses_sessionId_idx" ON "ai_diagnoses"("sessionId");
-- CreateIndex
CREATE INDEX "ai_interactions_sessionId_createdAt_idx" ON "ai_interactions"("sessionId", "createdAt");
-- CreateIndex
CREATE INDEX "ai_actions_sessionId_createdAt_idx" ON "ai_actions"("sessionId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "ai_action_results_actionId_key" ON "ai_action_results"("actionId");
-- CreateIndex
CREATE INDEX "ai_knowledge_references_sessionId_idx" ON "ai_knowledge_references"("sessionId");
-- CreateIndex
CREATE UNIQUE INDEX "ai_confidence_policies_productId_categoryId_key" ON "ai_confidence_policies"("productId", "categoryId");
-- AddForeignKey
ALTER TABLE "ai_support_sessions" ADD CONSTRAINT "ai_support_sessions_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_diagnoses" ADD CONSTRAINT "ai_diagnoses_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "ai_support_sessions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_interactions" ADD CONSTRAINT "ai_interactions_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "ai_support_sessions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_actions" ADD CONSTRAINT "ai_actions_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "ai_support_sessions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_action_results" ADD CONSTRAINT "ai_action_results_actionId_fkey" FOREIGN KEY ("actionId") REFERENCES "ai_actions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_knowledge_references" ADD CONSTRAINT "ai_knowledge_references_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "ai_support_sessions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_confidence_policies" ADD CONSTRAINT "ai_confidence_policies_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+117 -8
View File
@@ -34,14 +34,15 @@ model Product {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
categories Category[]
integration ProductIntegration?
problems Problem[]
tickets Ticket[]
knowledgeEntries KnowledgeEntry[]
errorCodes ErrorCode[]
knownIssues KnownIssue[]
runbooks Runbook[]
categories Category[]
integration ProductIntegration?
problems Problem[]
tickets Ticket[]
knowledgeEntries KnowledgeEntry[]
errorCodes ErrorCode[]
knownIssues KnownIssue[]
runbooks Runbook[]
aiConfidencePolicies AIConfidencePolicy[]
@@map("products")
}
@@ -141,6 +142,7 @@ model Ticket {
category Category? @relation(fields: [categoryId], references: [id])
messages TicketMessage[]
attachments TicketAttachment[]
aiSessions AISupportSession[]
@@unique([productId, idempotencyKey])
@@index([productId, status])
@@ -273,3 +275,110 @@ model AuditLog {
@@map("audit_logs")
}
model AISupportSession {
id String @id @default(cuid())
ticketId String
ticket Ticket @relation(fields: [ticketId], references: [id])
status String // analyzing | troubleshooting | verifying | resolved | escalated |
// ended_by_agent — mirrored onto Ticket.status through the existing 003 state machine, see
// specs/005-ai-support/research.md "AISupportSession.status drives Ticket.status"
activeRunbookKey String?
currentStepIndex Int?
clarifyingQuestionsAsked Int @default(0)
toolCallCount Int @default(0)
startedAt DateTime @default(now())
endedAt DateTime?
diagnoses AIDiagnosis[]
interactions AIInteraction[]
actions AIAction[]
knowledgeRefs AIKnowledgeReference[]
@@index([ticketId, status])
@@map("ai_support_sessions")
}
model AIDiagnosis {
id String @id @default(cuid())
sessionId String
session AISupportSession @relation(fields: [sessionId], references: [id])
product String
feature String?
problemType String
severity String
confidence Float
possibleCauses String[]
createdAt DateTime @default(now())
@@index([sessionId])
@@map("ai_diagnoses")
}
model AIInteraction {
id String @id @default(cuid())
sessionId String
session AISupportSession @relation(fields: [sessionId], references: [id])
role String // customer | ai
content String
createdAt DateTime @default(now())
@@index([sessionId, createdAt])
@@map("ai_interactions")
}
model AIAction {
id String @id @default(cuid())
sessionId String
session AISupportSession @relation(fields: [sessionId], references: [id])
toolName String
input Json
riskLevel String // low | medium | high — copied from the registry at evaluation time
evaluationOutcome String // approved | pending_approval | refused
refusalReason String?
approvedBy String? // system-policy | agentId | null while pending_approval
createdAt DateTime @default(now())
result AIActionResult?
@@index([sessionId, createdAt])
@@map("ai_actions")
}
model AIActionResult {
id String @id @default(cuid())
actionId String @unique
action AIAction @relation(fields: [actionId], references: [id])
output Json
status String // success | failed
createdAt DateTime @default(now())
@@map("ai_action_results")
}
model AIKnowledgeReference {
id String @id @default(cuid())
sessionId String
session AISupportSession @relation(fields: [sessionId], references: [id])
knowledgeId String // KnowledgeEntry.id — resolved through ai-support/knowledge's public
// index.ts, not a cross-module DB-level FK (Constitution Principle III)
relevanceScore Float?
createdAt DateTime @default(now())
@@index([sessionId])
@@map("ai_knowledge_references")
}
model AIConfidencePolicy {
id String @id @default(cuid())
productId String? // null = system-wide default row
product Product? @relation(fields: [productId], references: [id])
categoryId String? // null = applies to every category of productId
highThreshold Float
lowThreshold Float
maxClarifyingQuestions Int
updatedAt DateTime @updatedAt
@@unique([productId, categoryId])
@@map("ai_confidence_policies")
}