Files
support_backend/src/modules/ai-support/sessions/service/session.service.ts
T
saqib mirandClaude Sonnet 5 82d02bcdcd 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>
2026-09-02 17:44:52 +05:30

492 lines
20 KiB
TypeScript

import { AISupportSession, KnowledgeEntry, AIInteraction, Ticket, Problem } from '@prisma/client';
import { AppError, NotFoundError } from '@/common/errors';
import { ticketsService, problemsRepository } from '@/modules/ticketing/tickets';
import { messagesService } from '@/modules/ticketing/messages';
import { knowledgeService } from '@/modules/ai-support/knowledge';
import { escalationService, EscalationService } from '@/modules/ai-support/escalation';
import { actionRepository } from '@/modules/ai-support/tools';
import {
runbookEngineService,
RunbookEngineService,
advanceRunbookStep,
getStepText,
getStepCount,
getAttemptedStepDescriptions,
} from '@/modules/ai-support/troubleshooting';
import {
sessionRepository,
SessionRepository,
diagnosisRepository,
DiagnosisRepository,
interactionRepository,
InteractionRepository,
knowledgeReferenceRepository,
KnowledgeReferenceRepository,
} from '../repository';
import { confidencePolicyService, ConfidencePolicyService } from './confidence-policy.service';
import { decideConfidenceBand } from './confidence-band';
import { diagnose } from './diagnose';
import { generateClarifyingQuestion } from './clarify';
import { buildDiagnosisMessages } from './build-messages';
import { syncTicketStatus } from './ticket-status-sync';
import { runReasoningTurn, DiagnosisContext } from './reason';
import { classifyStepOutcome } from './classify-step-outcome';
import { ACTIVE_SESSION_STATUSES } from '../mapper';
export interface SessionTurnResult {
sessionId: string;
status: string;
message?: string | undefined;
escalationSummary?: string | undefined;
}
export class SessionsService {
constructor(
private readonly sessions: SessionRepository = sessionRepository,
private readonly diagnoses: DiagnosisRepository = diagnosisRepository,
private readonly interactions: InteractionRepository = interactionRepository,
private readonly knowledgeRefs: KnowledgeReferenceRepository = knowledgeReferenceRepository,
private readonly confidencePolicy: ConfidencePolicyService = confidencePolicyService,
private readonly escalation: EscalationService = escalationService,
private readonly runbookEngine: RunbookEngineService = runbookEngineService,
) {}
/** FR-001: called from the AI_SESSION worker after a new ticket is created. A no-op if a
* session is somehow already active for this ticket (defensive — the worker only fires once
* per ticket creation, but never assume a queue delivers exactly once). */
async runFirstTurn(ticketId: string): Promise<SessionTurnResult | null> {
const existing = await this.sessions.findActiveByTicketId(ticketId);
if (existing) return null;
const ticket = await ticketsService.getById(ticketId);
const problem = await problemsRepository.findById(ticket.problemId);
if (!problem) {
throw new AppError('Ticket problem not found.', 'NOT_FOUND', 404);
}
const session = await this.sessions.create(ticketId);
await syncTicketStatus(ticketId, 'analyzing');
return this.runDiagnosisTurn(session, ticket, problem);
}
/** FR-008/FR-009 (User Story 2): a customer reply while still "analyzing" re-runs diagnosis
* over the full conversation. Once "proceed" has happened, a reply is routed to the
* troubleshooting turn instead (User Story 3/4/5) — the two phases ask fundamentally different
* questions of the model (classify vs. act-and-verify). `404` if no active session exists —
* contracts/ai-support-contract.md guarantee 1, never silently starting a new one. */
async handleCustomerReply(ticketId: string, message: string): Promise<SessionTurnResult> {
const session = await this.sessions.findActiveByTicketId(ticketId);
if (!session) {
throw new NotFoundError('No active AI session for this ticket.');
}
const ticket = await ticketsService.getById(ticketId);
const problem = await problemsRepository.findById(ticket.problemId);
if (!problem) {
throw new AppError('Ticket problem not found.', 'NOT_FOUND', 404);
}
await this.interactions.create(session.id, 'customer', message);
await messagesService.post(ticket.id, ticket.externalUserId, 'CUSTOMER_MESSAGE', message);
if (session.status === 'analyzing') {
return this.runDiagnosisTurn(session, ticket, problem);
}
return this.runTroubleshootingTurn(session, ticket, message);
}
/** contracts/ai-support-contract.md's read route — current session if one is active,
* otherwise the most recent one, so a caller can always see how a ticket's AI involvement
* ended. */
async getSessionView(ticketId: string) {
const session =
(await this.sessions.findActiveByTicketId(ticketId)) ??
(await this.sessions.findMostRecentByTicketId(ticketId));
if (!session) return null;
const [diagnosis, sessionInteractions] = await Promise.all([
this.diagnoses.findLatestBySession(session.id),
this.interactions.findAllBySession(session.id),
]);
return { session, diagnosis, interactions: sessionInteractions };
}
/**
* FR-020/FR-021/FR-022: the single path every escalation trigger in this feature funnels
* through. Guards against re-escalating a session FR-023's hook on ticketsService.updateStatus
* already ended (a human acted first) — in that case there's nothing left to do but return the
* summary.
*/
async escalate(
session: AISupportSession,
ticketId: string,
reason: string,
stepsAttempted: string[] = [],
) {
const diagnosis = await this.diagnoses.findLatestBySession(session.id);
const result = this.escalation.buildSummary(diagnosis, reason, stepsAttempted);
if (!(ACTIVE_SESSION_STATUSES as readonly string[]).includes(session.status)) {
return result;
}
await this.sessions.updateStatus(session.id, 'escalated');
await syncTicketStatus(ticketId, 'escalated');
return result;
}
private async runDiagnosisTurn(
session: AISupportSession,
ticket: Ticket,
problem: Problem,
): Promise<SessionTurnResult> {
const priorInteractions = await this.interactions.findAllBySession(session.id);
const messages = buildDiagnosisMessages(ticket, problem, priorInteractions);
const diagnosisOutput = await diagnose(messages);
if (!diagnosisOutput) {
const result = await this.escalate(
session,
ticket.id,
'The AI reasoning provider failed or returned an unusable result.',
);
return { sessionId: session.id, status: 'escalated', escalationSummary: result.summary };
}
await this.diagnoses.create({
sessionId: session.id,
product: diagnosisOutput.product,
feature: diagnosisOutput.feature ?? undefined,
problemType: diagnosisOutput.problemType,
severity: diagnosisOutput.severity,
confidence: diagnosisOutput.confidence,
possibleCauses: diagnosisOutput.possibleCauses,
});
// FR-006: knowledge retrieval scoped by the diagnosis's own feature, driven by the ticket's
// (already-internal) productId — research.md "an in-process call, not an HTTP loopback".
const knowledgeResults = await knowledgeService.retrieve({
productId: ticket.productId,
feature: diagnosisOutput.feature ?? undefined,
});
await this.knowledgeRefs.recordMany(
session.id,
knowledgeResults.map((k) => k.id),
);
if (knowledgeResults.length === 0) {
const result = await this.escalate(
session,
ticket.id,
'No knowledge exists for this product — escalating rather than reasoning ungrounded.',
);
return { sessionId: session.id, status: 'escalated', escalationSummary: result.summary };
}
const policy = await this.confidencePolicy.resolve(
ticket.productId,
ticket.categoryId ?? undefined,
);
const band = decideConfidenceBand(diagnosisOutput.confidence, policy);
if (band === 'escalate') {
const result = await this.escalate(
session,
ticket.id,
`Diagnosis confidence ${diagnosisOutput.confidence} is below the configured threshold (${policy.lowThreshold}).`,
);
return { sessionId: session.id, status: 'escalated', escalationSummary: result.summary };
}
if (band === 'ask') {
// FR-009: once maxClarifyingQuestions have already been asked, the next "ask" outcome
// escalates instead of asking again — never an unbounded back-and-forth.
if (session.clarifyingQuestionsAsked >= policy.maxClarifyingQuestions) {
const result = await this.escalate(
session,
ticket.id,
`Reached the maximum of ${policy.maxClarifyingQuestions} clarifying questions without a confident diagnosis.`,
);
return { sessionId: session.id, status: 'escalated', escalationSummary: result.summary };
}
const question = await generateClarifyingQuestion(diagnosisOutput, knowledgeResults);
await this.interactions.create(session.id, 'ai', question);
await messagesService.post(ticket.id, 'ai', 'AI_MESSAGE', question);
await this.sessions.incrementClarifyingQuestions(session.id);
return { sessionId: session.id, status: session.status, message: question };
}
// proceed
await this.sessions.updateStatus(session.id, 'troubleshooting');
await syncTicketStatus(ticket.id, 'troubleshooting');
const troubleshootingSession = { ...session, status: 'troubleshooting' };
return this.enterTroubleshooting(
troubleshootingSession,
ticket,
diagnosisOutput,
knowledgeResults,
);
}
/** User Story 3/4: the first turn after "proceed" — matches a runbook if one exists for this
* problem type (FR-015; research.md "the app selects the step, the model only phrases and
* interprets it"; matching convention: a runbook's `key` equals the diagnosis's `problemType`
* string exactly — admins author runbooks against the same problemType vocabulary the AI's
* diagnosis call produces), then runs the tool-enabled reasoning turn. */
private async enterTroubleshooting(
session: AISupportSession,
ticket: Ticket,
diagnosis: DiagnosisContext,
knowledge: KnowledgeEntry[],
): Promise<SessionTurnResult> {
const runbook = await this.runbookEngine.matchRunbook(ticket.productId, diagnosis.problemType);
let runbookStepContext: string | undefined;
if (runbook) {
await this.sessions.setActiveRunbook(session.id, runbook.key, 0);
runbookStepContext = getStepText(runbook.steps, 0) ?? undefined;
}
const priorInteractions = await this.interactions.findAllBySession(session.id);
return this.runReasoningAndRespond(
session,
ticket,
diagnosis,
knowledge,
priorInteractions,
runbookStepContext,
);
}
/** User Story 3/4/5: every troubleshooting turn after the first — either advances/exhausts an
* active runbook step (FR-015/FR-016) or continues the tool-enabled conversation, and checks
* for a resolution claim either way (User Story 5's entry point into verification). */
private async runTroubleshootingTurn(
session: AISupportSession,
ticket: Ticket,
customerMessage: string,
): Promise<SessionTurnResult> {
const diagnosisRow = await this.diagnoses.findLatestBySession(session.id);
if (!diagnosisRow) {
const result = await this.escalate(
session,
ticket.id,
'No diagnosis found for an active troubleshooting session.',
);
return { sessionId: session.id, status: 'escalated', escalationSummary: result.summary };
}
const knowledge = await knowledgeService.retrieve({
productId: ticket.productId,
feature: diagnosisRow.feature ?? undefined,
});
const priorInteractions = await this.interactions.findAllBySession(session.id);
if (session.activeRunbookKey && session.currentStepIndex !== null) {
const runbook = await this.runbookEngine.matchRunbook(
ticket.productId,
session.activeRunbookKey,
);
if (runbook) {
const stepText = getStepText(runbook.steps, session.currentStepIndex);
const resolved = stepText ? await classifyStepOutcome(stepText, customerMessage) : false;
if (resolved) {
return this.enterVerification(
session,
ticket,
diagnosisRow,
knowledge,
priorInteractions,
);
}
const advance = advanceRunbookStep(
getStepCount(runbook.steps),
session.currentStepIndex,
false,
);
if (advance.exhausted) {
const stepsAttempted = getAttemptedStepDescriptions(
runbook.steps,
session.currentStepIndex,
);
const result = await this.escalate(
session,
ticket.id,
`Runbook "${runbook.key}" was exhausted without resolving the problem.`,
stepsAttempted,
);
return { sessionId: session.id, status: 'escalated', escalationSummary: result.summary };
}
await this.sessions.advanceRunbookStep(session.id, advance.nextIndex);
const nextStepText = getStepText(runbook.steps, advance.nextIndex) ?? undefined;
return this.runReasoningAndRespond(
session,
ticket,
diagnosisRow,
knowledge,
priorInteractions,
nextStepText,
);
}
}
// No active runbook (or none matched) — a general resolution-claim check still gates entry
// into verification (User Story 5), same signal path as the runbook case.
const claimsResolved = await classifyStepOutcome(
"the customer's reported problem",
customerMessage,
);
if (claimsResolved) {
return this.enterVerification(session, ticket, diagnosisRow, knowledge, priorInteractions);
}
return this.runReasoningAndRespond(session, ticket, diagnosisRow, knowledge, priorInteractions);
}
/**
* FR-018/FR-019 (User Story 5): moves the session into "verifying" and runs a reasoning turn
* that's nudged toward calling the verification tool. The status only advances to "resolved"
* when a durable `AIActionResult` from `verifyProductResolution` actually confirms it — never
* from the customer's claim (already recorded as a plain interaction, nothing more) or from
* anything the model says in its response text.
*/
private async enterVerification(
session: AISupportSession,
ticket: Ticket,
diagnosis: DiagnosisContext,
knowledge: KnowledgeEntry[],
priorInteractions: AIInteraction[],
): Promise<SessionTurnResult> {
await this.sessions.updateStatus(session.id, 'verifying');
await syncTicketStatus(ticket.id, 'verifying');
const verifyingSession = { ...session, status: 'verifying' };
const reasoning = await runReasoningTurn({
ticket,
sessionId: session.id,
diagnosis,
knowledge,
priorInteractions,
runbookStepContext:
'The customer indicates the problem is resolved. Use the verification tool to check before confirming this to them — do not state it is confirmed resolved unless the tool result says so.',
});
if (reasoning.escalationRequested) {
const result = await this.escalate(
verifyingSession,
ticket.id,
reasoning.escalationRequested.reason,
);
return { sessionId: session.id, status: 'escalated', escalationSummary: result.summary };
}
if (reasoning.message) {
await this.interactions.create(session.id, 'ai', reasoning.message);
await messagesService.post(ticket.id, 'ai', 'AI_MESSAGE', reasoning.message);
}
if (await this.hasVerifiedResolution(session.id)) {
await this.sessions.updateStatus(session.id, 'resolved');
await syncTicketStatus(ticket.id, 'resolved');
return { sessionId: session.id, status: 'resolved', message: reasoning.message ?? undefined };
}
return { sessionId: session.id, status: 'verifying', message: reasoning.message ?? undefined };
}
/** FR-018: the only question that decides "resolved" — a durable, structured tool result, not
* message content. */
private async hasVerifiedResolution(sessionId: string): Promise<boolean> {
const actions = await actionRepository.findAllBySession(sessionId);
return actions.some((action) => {
if (action.toolName !== 'verifyProductResolution' || action.result?.status !== 'success')
return false;
const output = action.result.output as { confirmed?: boolean } | null;
return output?.confirmed === true;
});
}
private async runReasoningAndRespond(
session: AISupportSession,
ticket: Ticket,
diagnosis: DiagnosisContext,
knowledge: KnowledgeEntry[],
priorInteractions: AIInteraction[],
runbookStepContext?: string,
): Promise<SessionTurnResult> {
const reasoning = await runReasoningTurn({
ticket,
sessionId: session.id,
diagnosis,
knowledge,
priorInteractions,
runbookStepContext,
});
if (reasoning.escalationRequested) {
const result = await this.escalate(session, ticket.id, reasoning.escalationRequested.reason);
return { sessionId: session.id, status: 'escalated', escalationSummary: result.summary };
}
if (reasoning.message) {
await this.interactions.create(session.id, 'ai', reasoning.message);
await messagesService.post(ticket.id, 'ai', 'AI_MESSAGE', reasoning.message);
}
return {
sessionId: session.id,
status: session.status,
message: reasoning.message ?? undefined,
};
}
/** Guard other flows can use to short-circuit against a session that already ended — FR-001's
* "never silently reopen" guarantee at the read side. */
isActive(status: string): boolean {
return (ACTIVE_SESSION_STATUSES as readonly string[]).includes(status);
}
/**
* FR-023: subscribed to DomainEventName.TICKET_UPDATED (src/events/handlers/index.ts) —
* ticketsService.updateStatus publishes this for every status change, any actor; this module
* decides whether it matters, keeping `tickets` unaware ai-support/sessions exists at all
* (research.md's "AISupportSession.status drives Ticket.status" avoided the reverse direction
* on purpose to prevent a circular module dependency). A no-op for the AI's own writes and for
* a ticket with no active session.
*/
async handleTicketStatusChanged(payload: { ticketId: string; actor: string }): Promise<void> {
if (payload.actor === 'ai') return;
const session = await this.sessions.findActiveByTicketId(payload.ticketId);
if (!session) return;
await this.sessions.updateStatus(session.id, 'ended_by_agent');
}
/**
* FR-018: a standalone recheck of the same evidence guard `enterVerification` applies inline —
* for a session already sitting in "verifying", re-checks whether verification evidence has
* since appeared and transitions to "resolved" if so. Not called anywhere in the reasoning
* flow itself (that already checks inline); this exists as the seam a future real
* product-signal webhook (doc 11 §A2 — not yet built) would call, and lets this exact
* transition be exercised in tests without needing to fake the LLM producing a specific tool
* call.
*/
async recheckVerification(ticketId: string): Promise<SessionTurnResult | null> {
const session = await this.sessions.findActiveByTicketId(ticketId);
if (!session || session.status !== 'verifying') return null;
if (await this.hasVerifiedResolution(session.id)) {
await this.sessions.updateStatus(session.id, 'resolved');
await syncTicketStatus(ticketId, 'resolved');
return { sessionId: session.id, status: 'resolved' };
}
return { sessionId: session.id, status: 'verifying' };
}
}
export const sessionsService = new SessionsService();