From 7b59543d360021cb6e43a6f60f3822a4b1dad4c5 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Thu, 26 Mar 2026 10:27:24 +0530 Subject: [PATCH] feat: streaming AI chat endpoint with tool calling - POST /api/ai/stream: streamText with tools, streams response via toTextStreamResponse - Tools: lookup_patient, lookup_appointments, lookup_doctor (same as existing chat endpoint) - Uses stopWhen(stepCountIs(5)) for multi-step tool execution - Streams response body to Express response manually (NestJS + AI SDK v6 compatibility) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ai/ai-chat.controller.ts | 128 ++++++++++++++++++++++++++++++++++- 1 file changed, 126 insertions(+), 2 deletions(-) diff --git a/src/ai/ai-chat.controller.ts b/src/ai/ai-chat.controller.ts index e3c9bd9..875c2ab 100644 --- a/src/ai/ai-chat.controller.ts +++ b/src/ai/ai-chat.controller.ts @@ -1,6 +1,7 @@ -import { Controller, Post, Body, Headers, HttpException, Logger } from '@nestjs/common'; +import { Controller, Post, Body, Headers, Req, Res, HttpException, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { generateText, tool, stepCountIs } from 'ai'; +import type { Request, Response } from 'express'; +import { generateText, streamText, tool, stepCountIs } from 'ai'; import type { LanguageModel } from 'ai'; import { z } from 'zod'; import { PlatformGraphqlService } from '../platform/platform-graphql.service'; @@ -61,6 +62,129 @@ export class AiChatController { } } + @Post('stream') + async stream(@Req() req: Request, @Res() res: Response, @Headers('authorization') auth: string) { + if (!auth) throw new HttpException('Authorization required', 401); + + const body = req.body; + const messages = body.messages ?? []; + if (!messages.length) throw new HttpException('messages required', 400); + + if (!this.aiModel) { + res.status(500).json({ error: 'AI not configured' }); + return; + } + + const kb = await this.buildKnowledgeBase(auth); + const systemPrompt = this.buildSystemPrompt(kb); + const platformService = this.platform; + + const result = streamText({ + model: this.aiModel, + system: systemPrompt, + messages, + stopWhen: stepCountIs(5), + tools: { + lookup_patient: tool({ + description: 'Search for a patient/lead by phone number or name. Returns their profile, lead status, AI summary.', + inputSchema: z.object({ + phone: z.string().optional().describe('Phone number to search'), + name: z.string().optional().describe('Patient/lead name to search'), + }), + execute: async ({ phone, name }) => { + const data = await platformService.queryWithAuth( + `{ leads(first: 50) { edges { node { + id name contactName { firstName lastName } + contactPhone { primaryPhoneNumber } + source status interestedService + contactAttempts lastContacted + aiSummary aiSuggestedAction patientId + } } } }`, + undefined, auth, + ); + const leads = data.leads.edges.map((e: any) => e.node); + const phoneClean = (phone ?? '').replace(/\D/g, ''); + const nameClean = (name ?? '').toLowerCase(); + + const matched = leads.filter((l: any) => { + if (phoneClean) { + const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, ''); + if (lp.endsWith(phoneClean) || phoneClean.endsWith(lp)) return true; + } + if (nameClean) { + const fn = `${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase(); + if (fn.includes(nameClean)) return true; + } + return false; + }); + + if (!matched.length) return { found: false, message: 'No patient/lead found.' }; + return { found: true, count: matched.length, leads: matched }; + }, + }), + + lookup_appointments: tool({ + description: 'Get appointments for a patient. Returns doctor, department, date, status.', + inputSchema: z.object({ + patientId: z.string().describe('Patient ID'), + }), + execute: async ({ patientId }) => { + const data = await platformService.queryWithAuth( + `{ appointments(first: 20, filter: { patientId: { eq: "${patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { + id scheduledAt status doctorName department reasonForVisit + } } } }`, + undefined, auth, + ); + return { appointments: data.appointments.edges.map((e: any) => e.node) }; + }, + }), + + lookup_doctor: tool({ + description: 'Get doctor details — schedule, clinic, fees, specialty.', + inputSchema: z.object({ + doctorName: z.string().describe('Doctor name'), + }), + execute: async ({ doctorName }) => { + const data = await platformService.queryWithAuth( + `{ doctors(first: 10) { edges { node { + id fullName { firstName lastName } + department specialty visitingHours + consultationFeeNew { amountMicros currencyCode } + clinic { clinicName } + } } } }`, + undefined, auth, + ); + const doctors = data.doctors.edges.map((e: any) => e.node); + const search = doctorName.toLowerCase(); + const matched = doctors.filter((d: any) => { + const full = `${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''}`.toLowerCase(); + return full.includes(search); + }); + if (!matched.length) return { found: false, message: `No doctor matching "${doctorName}"` }; + return { found: true, doctors: matched }; + }, + }), + }, + }); + + const response = result.toTextStreamResponse(); + res.status(response.status); + response.headers.forEach((value, key) => res.setHeader(key, value)); + if (response.body) { + const reader = response.body.getReader(); + const pump = async () => { + while (true) { + const { done, value } = await reader.read(); + if (done) { res.end(); break; } + res.write(value); + } + }; + pump().catch(() => res.end()); + } else { + res.end(); + } + } + private async buildKnowledgeBase(auth: string): Promise { const now = Date.now(); if (this.knowledgeBase && now - this.kbLoadedAt < this.kbTtlMs) {