/opt/mawid/apps/api/src/agent
NameSizeModeActions
agent.e2e.spec.ts132960644editdlrm
agent.module.ts8320644editdlrm
agent.service.ts81630644editdlrm
availability.spec.ts84820644editdlrm
availability.ts51830644editdlrm
booking-tools.service.ts144400644editdlrm
openai.client.ts22470644editdlrm
system-prompt.ts18550644editdlrm
tool-definitions.ts36140644editdlrm
tz.ts28820644editdlrm
Edit: /opt/mawid/apps/api/src/agent/agent.service.ts (8163B)
import { Inject, Injectable, Logger, Optional } from '@nestjs/common'; import type OpenAI from 'openai'; import { t, type Language } from '@mawid/shared'; import { PrismaService } from '../prisma/prisma.service'; import { AGENT_MODEL, INTENT_MODEL, LLM_CLIENT, minReasoningEffort, type AgentLlmClient, } from './openai.client'; import { BookingToolsService, type ToolContext } from './booking-tools.service'; import { buildSystemPrompt } from './system-prompt'; import { AGENT_TOOLS } from './tool-definitions'; export interface InboundContext { clinicId: string; patientId: string; conversationId: string; text: string; } interface ConversationState { handedOff?: boolean; handoffReason?: string; agentTurnsMs?: number[]; [key: string]: unknown; } const MAX_TURNS_PER_HOUR = 15; const MAX_TOOL_ITERATIONS = 8; const HISTORY_LIMIT = 20; const OPENAI_TOOLS: OpenAI.Chat.Completions.ChatCompletionTool[] = AGENT_TOOLS.map((tool) => ({ type: 'function', function: { name: tool.name, description: tool.description, parameters: tool.input_schema, }, })); @Injectable() export class AgentService { private readonly logger = new Logger(AgentService.name); constructor( private readonly prisma: PrismaService, private readonly tools: BookingToolsService, @Optional() @Inject(LLM_CLIENT) private readonly llm: AgentLlmClient | null, ) {} get enabled(): boolean { return this.llm !== null; } /** Runs one agent turn for an inbound patient message. Returns the reply text or null. */ async handleInbound(ctx: InboundContext): Promise { if (!this.llm) return null; const conversation = await this.prisma.conversation.findFirst({ where: { id: ctx.conversationId, clinicId: ctx.clinicId }, include: { patient: true, clinic: true }, }); if (!conversation) return null; const state = (conversation.state ?? {}) as ConversationState; if (state.handedOff) return null; const lang = this.patientLang(conversation.patient.language); // Guardrail: max 15 agent turns per conversation per hour. const now = Date.now(); const turns = (state.agentTurnsMs ?? []).filter((ts) => now - ts < 3600_000); if (turns.length >= MAX_TURNS_PER_HOUR) { await this.saveState(conversation.id, { ...state, handedOff: true, handoffReason: 'rate_limit', agentTurnsMs: turns, }); await this.audit(ctx, 'agent:rate_limited', {}); return t('handoffNotice', lang); } await this.saveState(conversation.id, { ...state, agentTurnsMs: [...turns, now] }); try { const intent = await this.classifyIntent(ctx.text); const model = intent === 'booking' ? AGENT_MODEL : INTENT_MODEL; return await this.runToolLoop(ctx, conversation, model); } catch (err) { // PII discipline: log the error, never the message body. this.logger.error(`agent turn failed for conversation ${ctx.conversationId}: ${String(err)}`); return t('agentError', lang); } } private async classifyIntent(text: string): Promise<'booking' | 'faq' | 'other'> { const res = await this.llm!.chat.completions.create({ model: INTENT_MODEL, // Generous cap: reasoning tokens count toward the completion limit. max_completion_tokens: 2048, reasoning_effort: minReasoningEffort(INTENT_MODEL), messages: [ { role: 'system', content: 'Classify the patient WhatsApp message for a clinic. Reply with exactly one word: "booking" (anything about appointments: book, move, cancel, availability, waitlist, confirm), "faq" (questions about the clinic, services, prices, hours, directions), or "other".', }, { role: 'user', content: text.slice(0, 1000) }, ], }); const word = res.choices[0]?.message.content?.trim().toLowerCase() ?? ''; return word === 'booking' || word === 'faq' ? word : 'other'; } private async runToolLoop( ctx: InboundContext, conversation: { id: string; patient: { name: string | null; language: string }; clinic: { name: string; timezone: string }; }, model: string, ): Promise { const system = buildSystemPrompt({ clinicName: conversation.clinic.name, timezone: conversation.clinic.timezone, patientName: conversation.patient.name, patientLanguage: conversation.patient.language, now: new Date(), }); const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ { role: 'system', content: system }, ...(await this.loadHistory(conversation.id)), ]; const toolCtx: ToolContext = { clinicId: ctx.clinicId, patientId: ctx.patientId, conversationId: ctx.conversationId, }; for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) { const response = await this.llm!.chat.completions.create({ model, max_completion_tokens: 4096, // GPT-5.6 chat completions reject tools + reasoning_effort != 'none'. reasoning_effort: minReasoningEffort(model), messages, tools: OPENAI_TOOLS, }); const message = response.choices[0]?.message; if (!message) return null; const toolCalls = (message.tool_calls ?? []).filter( (call): call is OpenAI.Chat.Completions.ChatCompletionMessageToolCall & { type: 'function' } => call.type === 'function', ); const text = (message.content ?? '').trim(); if (toolCalls.length === 0) return text || null; messages.push({ role: 'assistant', content: message.content ?? null, tool_calls: message.tool_calls, }); for (const call of toolCalls) { let input: Record = {}; try { input = JSON.parse(call.function.arguments || '{}') as Record; } catch { // malformed arguments → run the tool with empty input; it will report the error back } const result = await this.tools.execute(toolCtx, call.function.name, input); messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result), }); } // The handoff tool ends the agent's participation; any text it produced // alongside is still delivered. if (toolCalls.some((call) => call.function.name === 'handoff_to_human')) { return text || null; } } this.logger.warn(`tool loop hit iteration cap for conversation ${ctx.conversationId}`); return null; } /** Recent transcript (from Message) as alternating chat turns. */ private async loadHistory( conversationId: string, ): Promise { const rows = await this.prisma.message.findMany({ where: { conversationId, body: { not: null } }, orderBy: { createdAt: 'desc' }, take: HISTORY_LIMIT, }); rows.reverse(); const merged: Array<{ role: 'user' | 'assistant'; content: string }> = []; for (const row of rows) { const role = row.direction === 'inbound' ? 'user' : 'assistant'; const last = merged[merged.length - 1]; if (last && last.role === role) { last.content = `${last.content}\n${row.body}`; } else { merged.push({ role, content: row.body! }); } } return merged; } private patientLang(language: string): Language { return (['ar', 'tr', 'en'] as const).includes(language as Language) ? (language as Language) : 'en'; } private async saveState(conversationId: string, state: ConversationState): Promise { await this.prisma.conversation.update({ where: { id: conversationId }, data: { state: state as object }, }); } private async audit(ctx: InboundContext, action: string, meta: object): Promise { await this.prisma.auditLog.create({ data: { clinicId: ctx.clinicId, actor: 'agent', action, meta: { conversationId: ctx.conversationId, ...meta } as object, }, }); } }