mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-12 02:18:18 +00:00
feat: migrate AI to Vercel AI SDK, add OpenAI provider, fix worklist
- Replace raw @anthropic-ai/sdk with Vercel AI SDK (generateText, tool, generateObject) - Add provider abstraction (ai-provider.ts) — swap OpenAI/Anthropic via env var - AI chat controller: dynamic KB from platform (clinics, packages, insurance), zero hardcoding - AI enrichment service: use generateObject with Zod schema instead of manual JSON parsing - Worklist: resolve agent name from platform currentUser API instead of JWT decode - Worklist: fix GraphQL field names to match platform remapping (source, status, direction, etc.) - Config: add AI_PROVIDER, AI_MODEL, OPENAI_API_KEY env vars Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
import { Controller, Get, Headers, HttpException, Logger } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { WorklistService } from './worklist.service';
|
||||
|
||||
@Controller('api/worklist')
|
||||
export class WorklistController {
|
||||
private readonly logger = new Logger(WorklistController.name);
|
||||
|
||||
constructor(private readonly worklist: WorklistService) {}
|
||||
constructor(
|
||||
private readonly worklist: WorklistService,
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async getWorklist(@Headers('authorization') authHeader: string) {
|
||||
@@ -13,33 +17,25 @@ export class WorklistController {
|
||||
throw new HttpException('Authorization required', 401);
|
||||
}
|
||||
|
||||
// Decode the JWT to extract the agent name
|
||||
// The platform JWT payload contains user info — we extract the name
|
||||
const agentName = this.extractAgentName(authHeader);
|
||||
if (!agentName) {
|
||||
throw new HttpException('Could not determine agent identity from token', 400);
|
||||
}
|
||||
|
||||
const agentName = await this.resolveAgentName(authHeader);
|
||||
this.logger.log(`Fetching worklist for agent: ${agentName}`);
|
||||
|
||||
return this.worklist.getWorklist(agentName, authHeader);
|
||||
}
|
||||
|
||||
private extractAgentName(authHeader: string): string | null {
|
||||
private async resolveAgentName(authHeader: string): Promise<string> {
|
||||
try {
|
||||
const token = authHeader.replace(/^Bearer\s+/i, '');
|
||||
// JWT payload is the second segment, base64url-encoded
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(token.split('.')[1], 'base64url').toString('utf8'),
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`{ currentUser { workspaceMember { name { firstName lastName } } } }`,
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
// The platform JWT includes sub (userId) and workspace info
|
||||
// The agent name comes from firstName + lastName in the token
|
||||
const firstName = payload.firstName ?? payload.given_name ?? '';
|
||||
const lastName = payload.lastName ?? payload.family_name ?? '';
|
||||
const fullName = `${firstName} ${lastName}`.trim();
|
||||
return fullName || payload.email || payload.sub || null;
|
||||
} catch {
|
||||
return null;
|
||||
const name = data.currentUser?.workspaceMember?.name;
|
||||
const full = `${name?.firstName ?? ''} ${name?.lastName ?? ''}`.trim();
|
||||
if (full) return full;
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to resolve agent name: ${err}`);
|
||||
}
|
||||
throw new HttpException('Could not determine agent identity', 400);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@ export class WorklistService {
|
||||
|
||||
async getWorklist(agentName: string, authHeader: string): Promise<WorklistResponse> {
|
||||
const [missedCalls, followUps, marketingLeads] = await Promise.all([
|
||||
this.getMissedCallsWithToken(agentName, authHeader),
|
||||
this.getPendingFollowUpsWithToken(agentName, authHeader),
|
||||
this.getAssignedLeadsWithToken(agentName, authHeader),
|
||||
this.getMissedCalls(agentName, authHeader),
|
||||
this.getPendingFollowUps(agentName, authHeader),
|
||||
this.getAssignedLeads(agentName, authHeader),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -29,108 +29,64 @@ export class WorklistService {
|
||||
};
|
||||
}
|
||||
|
||||
private async getAssignedLeadsWithToken(agentName: string, authHeader: string): Promise<any[]> {
|
||||
private async getAssignedLeads(agentName: string, authHeader: string): Promise<any[]> {
|
||||
try {
|
||||
const data = await this.platform.queryWithAuth<{
|
||||
leads: { edges: { node: any }[] };
|
||||
}>(
|
||||
`query GetAssignedLeads($filter: LeadFilterInput, $first: Int, $orderBy: [LeadOrderByInput]) {
|
||||
leads(filter: $filter, first: $first, orderBy: $orderBy) {
|
||||
edges {
|
||||
node {
|
||||
id createdAt
|
||||
contactName { firstName lastName }
|
||||
contactPhone { number callingCode }
|
||||
contactEmail { address }
|
||||
leadSource leadStatus interestedService
|
||||
assignedAgent campaignId adId
|
||||
contactAttempts spamScore isSpam
|
||||
aiSummary aiSuggestedAction
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{
|
||||
filter: { assignedAgent: { eq: agentName } },
|
||||
first: 20,
|
||||
orderBy: [{ createdAt: 'AscNullsLast' }],
|
||||
},
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`{ leads(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }, orderBy: [{ createdAt: AscNullsLast }]) { edges { node {
|
||||
id createdAt
|
||||
contactName { firstName lastName }
|
||||
contactPhone { primaryPhoneNumber }
|
||||
contactEmail { primaryEmail }
|
||||
source status interestedService
|
||||
assignedAgent campaignId
|
||||
contactAttempts spamScore isSpam
|
||||
aiSummary aiSuggestedAction
|
||||
} } } }`,
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
|
||||
return data.leads.edges.map((e) => e.node);
|
||||
return data.leads.edges.map((e: any) => e.node);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch assigned leads: ${err}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async getPendingFollowUpsWithToken(agentName: string, authHeader: string): Promise<any[]> {
|
||||
private async getPendingFollowUps(agentName: string, authHeader: string): Promise<any[]> {
|
||||
try {
|
||||
const data = await this.platform.queryWithAuth<{
|
||||
followUps: { edges: { node: any }[] };
|
||||
}>(
|
||||
`query GetPendingFollowUps($filter: FollowUpFilterInput, $first: Int) {
|
||||
followUps(filter: $filter, first: $first) {
|
||||
edges {
|
||||
node {
|
||||
id createdAt
|
||||
followUpType followUpStatus
|
||||
scheduledAt completedAt
|
||||
priority assignedAgent
|
||||
patientId callId
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{
|
||||
filter: {
|
||||
assignedAgent: { eq: agentName },
|
||||
followUpStatus: { in: ['PENDING', 'OVERDUE'] },
|
||||
},
|
||||
first: 20,
|
||||
},
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`{ followUps(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }) { edges { node {
|
||||
id name createdAt
|
||||
typeCustom status scheduledAt completedAt
|
||||
priority assignedAgent
|
||||
patientId callId
|
||||
} } } }`,
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
|
||||
return data.followUps.edges.map((e) => e.node);
|
||||
// Filter to PENDING/OVERDUE client-side since platform may not support in-filter on remapped fields
|
||||
return data.followUps.edges
|
||||
.map((e: any) => e.node)
|
||||
.filter((f: any) => f.status === 'PENDING' || f.status === 'OVERDUE');
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch follow-ups: ${err}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async getMissedCallsWithToken(agentName: string, authHeader: string): Promise<any[]> {
|
||||
private async getMissedCalls(agentName: string, authHeader: string): Promise<any[]> {
|
||||
try {
|
||||
const data = await this.platform.queryWithAuth<{
|
||||
calls: { edges: { node: any }[] };
|
||||
}>(
|
||||
`query GetMissedCalls($filter: CallFilterInput, $first: Int, $orderBy: [CallOrderByInput]) {
|
||||
calls(filter: $filter, first: $first, orderBy: $orderBy) {
|
||||
edges {
|
||||
node {
|
||||
id createdAt
|
||||
callDirection callStatus
|
||||
callerNumber { number callingCode }
|
||||
agentName startedAt endedAt
|
||||
durationSeconds disposition
|
||||
callNotes leadId
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{
|
||||
filter: {
|
||||
callStatus: { eq: 'MISSED' },
|
||||
agentName: { eq: agentName },
|
||||
},
|
||||
first: 20,
|
||||
orderBy: [{ createdAt: 'AscNullsLast' }],
|
||||
},
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`{ calls(first: 20, filter: { agentName: { eq: "${agentName}" }, callStatus: { eq: "MISSED" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||
id name createdAt
|
||||
direction callStatus callerNumber agentName
|
||||
startedAt endedAt durationSec
|
||||
disposition leadId
|
||||
} } } }`,
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
|
||||
return data.calls.edges.map((e) => e.node);
|
||||
return data.calls.edges.map((e: any) => e.node);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch missed calls: ${err}`);
|
||||
return [];
|
||||
|
||||
Reference in New Issue
Block a user