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>
492 lines
20 KiB
TypeScript
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();
|