mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
feat: POST /api/lead/:id/enrich for on-demand AI summary regen
Adds a new sidecar endpoint that forces regeneration of a lead's
aiSummary + aiSuggestedAction. Triggered by the call-desk Appointment
and Enquiry forms when an agent explicitly edits the caller's name —
the previous summary was built against stale identity and needs to be
refreshed from the corrected record.
Scope:
- src/call-events/lead-enrich.controller.ts (new): POST
/api/lead/:id/enrich. Fetches the lead fresh via
findLeadByIdWithToken, runs AiEnrichmentService.enrichLead() with
recent activities for context, persists the new summary via
updateLeadWithToken, and optionally invalidates the Redis
caller-resolution cache for the phone (if provided in the request
body) so the next incoming call does a fresh platform lookup.
- src/platform/platform-graphql.service.ts:
- Added findLeadByIdWithToken. Selects staging-aligned field names
(status/source/lastContacted) rather than the older
leadStatus/leadSource/lastContactedAt names — otherwise the query
is rejected by the deployed schema. Includes a fallback query
shape in case a future platform version exposes `lead(id)`
directly instead of `leads(filter: ...)`.
- Fixed updateLeadWithToken response fragment to drop the broken
`leadStatus` field selection. Every call to this method was
failing against staging because the fragment asked for a field
the schema no longer has.
- src/call-events/call-events.module.ts: registered
LeadEnrichController and imported CallerResolutionModule so the
new controller can inject CallerResolutionService for Redis cache
invalidation.
The other field-rename issues in platform-graphql.service.ts
(findLeadByPhone/findLeadByPhoneWithToken/updateLead still select
leadStatus+leadSource and will keep failing against staging) are
deliberately untouched here — separate follow-up hotfix to keep this
commit focused on the enrich flow.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,18 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PlatformModule } from '../platform/platform.module';
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
import { AiModule } from '../ai/ai.module';
|
import { AiModule } from '../ai/ai.module';
|
||||||
|
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||||
import { CallEventsService } from './call-events.service';
|
import { CallEventsService } from './call-events.service';
|
||||||
import { CallEventsGateway } from './call-events.gateway';
|
import { CallEventsGateway } from './call-events.gateway';
|
||||||
import { CallLookupController } from './call-lookup.controller';
|
import { CallLookupController } from './call-lookup.controller';
|
||||||
|
import { LeadEnrichController } from './lead-enrich.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, AiModule],
|
// CallerResolutionModule is imported so LeadEnrichController can
|
||||||
controllers: [CallLookupController],
|
// inject CallerResolutionService to invalidate the Redis caller
|
||||||
|
// cache after a forced re-enrichment.
|
||||||
|
imports: [PlatformModule, AiModule, CallerResolutionModule],
|
||||||
|
controllers: [CallLookupController, LeadEnrichController],
|
||||||
providers: [CallEventsService, CallEventsGateway],
|
providers: [CallEventsService, CallEventsGateway],
|
||||||
exports: [CallEventsService, CallEventsGateway],
|
exports: [CallEventsService, CallEventsGateway],
|
||||||
})
|
})
|
||||||
|
|||||||
122
src/call-events/lead-enrich.controller.ts
Normal file
122
src/call-events/lead-enrich.controller.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { Body, Controller, Headers, HttpException, Logger, Param, Post } from '@nestjs/common';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { AiEnrichmentService } from '../ai/ai-enrichment.service';
|
||||||
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
|
|
||||||
|
// POST /api/lead/:id/enrich
|
||||||
|
//
|
||||||
|
// Force re-generation of a lead's AI summary + suggested action. Used by
|
||||||
|
// the call-desk appointment/enquiry forms when the agent explicitly edits
|
||||||
|
// the caller's name — the previously-generated summary was built against
|
||||||
|
// the stale identity, so we discard it and run the enrichment prompt
|
||||||
|
// again with the corrected name.
|
||||||
|
//
|
||||||
|
// Optional body: `{ phone?: string }` — when provided, also invalidates
|
||||||
|
// the Redis caller-resolution cache for that phone so the NEXT incoming
|
||||||
|
// call from the same number picks up fresh data from the platform
|
||||||
|
// instead of the stale cached entry.
|
||||||
|
//
|
||||||
|
// This is distinct from the cache-miss enrichment path in
|
||||||
|
// call-lookup.controller.ts `POST /api/call/lookup` which only runs
|
||||||
|
// enrichment when `lead.aiSummary` is null. That path is fine for
|
||||||
|
// first-time lookups; this one is for explicit "the old summary is
|
||||||
|
// wrong, regenerate it" triggers.
|
||||||
|
@Controller('api/lead')
|
||||||
|
export class LeadEnrichController {
|
||||||
|
private readonly logger = new Logger(LeadEnrichController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly platform: PlatformGraphqlService,
|
||||||
|
private readonly ai: AiEnrichmentService,
|
||||||
|
private readonly callerResolution: CallerResolutionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post(':id/enrich')
|
||||||
|
async enrichLead(
|
||||||
|
@Param('id') leadId: string,
|
||||||
|
@Body() body: { phone?: string },
|
||||||
|
@Headers('authorization') authHeader: string,
|
||||||
|
) {
|
||||||
|
if (!authHeader) throw new HttpException('Authorization required', 401);
|
||||||
|
if (!leadId) throw new HttpException('leadId required', 400);
|
||||||
|
|
||||||
|
this.logger.log(`Force-enriching lead ${leadId}`);
|
||||||
|
|
||||||
|
// 1. Fetch fresh lead from platform (with the staging-aligned
|
||||||
|
// field names — see findLeadByIdWithToken comment).
|
||||||
|
let lead: any;
|
||||||
|
try {
|
||||||
|
lead = await this.platform.findLeadByIdWithToken(leadId, authHeader);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`[LEAD-ENRICH] Lead fetch failed for ${leadId}: ${err}`);
|
||||||
|
throw new HttpException(`Lead fetch failed: ${(err as Error).message}`, 500);
|
||||||
|
}
|
||||||
|
if (!lead) {
|
||||||
|
throw new HttpException(`Lead not found: ${leadId}`, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch recent activities so the prompt has conversation context.
|
||||||
|
let activities: any[] = [];
|
||||||
|
try {
|
||||||
|
activities = await this.platform.getLeadActivitiesWithToken(leadId, authHeader, 5);
|
||||||
|
} catch (err) {
|
||||||
|
// Non-fatal — enrichment just has less context.
|
||||||
|
this.logger.warn(`[LEAD-ENRICH] Activity fetch failed: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Run enrichment. LeadContext uses the legacy `leadStatus`/
|
||||||
|
// `leadSource` internal names even though the platform now
|
||||||
|
// exposes them as `status`/`source` — we just map across.
|
||||||
|
const enrichment = await this.ai.enrichLead({
|
||||||
|
firstName: lead.contactName?.firstName ?? undefined,
|
||||||
|
lastName: lead.contactName?.lastName ?? undefined,
|
||||||
|
leadSource: lead.source ?? undefined,
|
||||||
|
interestedService: lead.interestedService ?? undefined,
|
||||||
|
leadStatus: lead.status ?? undefined,
|
||||||
|
contactAttempts: lead.contactAttempts ?? undefined,
|
||||||
|
createdAt: lead.createdAt,
|
||||||
|
activities: activities.map((a) => ({
|
||||||
|
activityType: a.activityType ?? '',
|
||||||
|
summary: a.summary ?? '',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Persist the new summary back to the lead.
|
||||||
|
try {
|
||||||
|
await this.platform.updateLeadWithToken(
|
||||||
|
leadId,
|
||||||
|
{
|
||||||
|
aiSummary: enrichment.aiSummary,
|
||||||
|
aiSuggestedAction: enrichment.aiSuggestedAction,
|
||||||
|
},
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`[LEAD-ENRICH] Failed to persist enrichment for ${leadId}: ${err}`);
|
||||||
|
throw new HttpException(
|
||||||
|
`Failed to persist enrichment: ${(err as Error).message}`,
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Invalidate the caller cache so the next incoming call from
|
||||||
|
// this phone number does a fresh platform lookup (and picks
|
||||||
|
// up the corrected identity + new summary).
|
||||||
|
if (body?.phone) {
|
||||||
|
try {
|
||||||
|
await this.callerResolution.invalidate(body.phone);
|
||||||
|
this.logger.log(`[LEAD-ENRICH] Caller cache invalidated for ${body.phone}`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[LEAD-ENRICH] Failed to invalidate caller cache: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[LEAD-ENRICH] Lead ${leadId} enriched successfully`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
leadId,
|
||||||
|
aiSummary: enrichment.aiSummary,
|
||||||
|
aiSuggestedAction: enrichment.aiSuggestedAction,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -180,10 +180,16 @@ export class PlatformGraphqlService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateLeadWithToken(id: string, input: UpdateLeadInput, authHeader: string): Promise<LeadNode> {
|
async updateLeadWithToken(id: string, input: UpdateLeadInput, authHeader: string): Promise<LeadNode> {
|
||||||
|
// Response fragment deliberately excludes `leadStatus` — the staging
|
||||||
|
// platform schema has this field renamed to `status`. Selecting the
|
||||||
|
// old name rejects the whole mutation. Callers don't use the
|
||||||
|
// returned fragment today, so returning just the id + AI fields
|
||||||
|
// keeps this working across both schema shapes without a wider
|
||||||
|
// rename hotfix.
|
||||||
const data = await this.queryWithAuth<{ updateLead: LeadNode }>(
|
const data = await this.queryWithAuth<{ updateLead: LeadNode }>(
|
||||||
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
||||||
updateLead(id: $id, data: $data) {
|
updateLead(id: $id, data: $data) {
|
||||||
id leadStatus aiSummary aiSuggestedAction
|
id aiSummary aiSuggestedAction
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
{ id, data: input },
|
{ id, data: input },
|
||||||
@@ -192,6 +198,67 @@ export class PlatformGraphqlService {
|
|||||||
return data.updateLead;
|
return data.updateLead;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch a single lead by id with the caller's JWT. Used by the
|
||||||
|
// lead-enrich flow when the agent explicitly renames a caller from
|
||||||
|
// the appointment/enquiry form and we need to regenerate the lead's
|
||||||
|
// AI summary against fresh identity.
|
||||||
|
//
|
||||||
|
// The selected fields deliberately use the staging-aligned names
|
||||||
|
// (`status`, `source`, `lastContacted`) rather than the older
|
||||||
|
// `leadStatus`/`leadSource`/`lastContactedAt` names — otherwise the
|
||||||
|
// query would be rejected on staging.
|
||||||
|
async findLeadByIdWithToken(id: string, authHeader: string): Promise<any | null> {
|
||||||
|
try {
|
||||||
|
const data = await this.queryWithAuth<{ lead: any }>(
|
||||||
|
`query FindLead($id: UUID!) {
|
||||||
|
lead(filter: { id: { eq: $id } }) {
|
||||||
|
id
|
||||||
|
createdAt
|
||||||
|
contactName { firstName lastName }
|
||||||
|
contactPhone { primaryPhoneNumber primaryPhoneCallingCode }
|
||||||
|
source
|
||||||
|
status
|
||||||
|
interestedService
|
||||||
|
contactAttempts
|
||||||
|
lastContacted
|
||||||
|
aiSummary
|
||||||
|
aiSuggestedAction
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ id },
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
return data.lead ?? null;
|
||||||
|
} catch {
|
||||||
|
// Fall back to edge-style query in case the singular field
|
||||||
|
// doesn't exist on this platform version.
|
||||||
|
const data = await this.queryWithAuth<{ leads: { edges: { node: any }[] } }>(
|
||||||
|
`query FindLead($id: UUID!) {
|
||||||
|
leads(filter: { id: { eq: $id } }, first: 1) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
createdAt
|
||||||
|
contactName { firstName lastName }
|
||||||
|
contactPhone { primaryPhoneNumber primaryPhoneCallingCode }
|
||||||
|
source
|
||||||
|
status
|
||||||
|
interestedService
|
||||||
|
contactAttempts
|
||||||
|
lastContacted
|
||||||
|
aiSummary
|
||||||
|
aiSuggestedAction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ id },
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
return data.leads.edges[0]?.node ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Server-to-server versions (for webhooks, background jobs) ---
|
// --- Server-to-server versions (for webhooks, background jobs) ---
|
||||||
|
|
||||||
async getLeadActivities(leadId: string, limit = 3): Promise<LeadActivityNode[]> {
|
async getLeadActivities(leadId: string, limit = 3): Promise<LeadActivityNode[]> {
|
||||||
|
|||||||
Reference in New Issue
Block a user