mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 10:07:22 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<any>(
|
||||
`{ 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<any>(
|
||||
`{ 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<any>(
|
||||
`{ 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<string> {
|
||||
const now = Date.now();
|
||||
if (this.knowledgeBase && now - this.kbLoadedAt < this.kbTtlMs) {
|
||||
|
||||
Reference in New Issue
Block a user