mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +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 { 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 type { LanguageModel } from 'ai';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
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> {
|
private async buildKnowledgeBase(auth: string): Promise<string> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (this.knowledgeBase && now - this.kbLoadedAt < this.kbTtlMs) {
|
if (this.knowledgeBase && now - this.kbLoadedAt < this.kbTtlMs) {
|
||||||
|
|||||||
Reference in New Issue
Block a user