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 { PlatformModule } from '../platform/platform.module';
|
||||
import { AiModule } from '../ai/ai.module';
|
||||
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||
import { CallEventsService } from './call-events.service';
|
||||
import { CallEventsGateway } from './call-events.gateway';
|
||||
import { CallLookupController } from './call-lookup.controller';
|
||||
import { LeadEnrichController } from './lead-enrich.controller';
|
||||
|
||||
@Module({
|
||||
imports: [PlatformModule, AiModule],
|
||||
controllers: [CallLookupController],
|
||||
// CallerResolutionModule is imported so LeadEnrichController can
|
||||
// inject CallerResolutionService to invalidate the Redis caller
|
||||
// cache after a forced re-enrichment.
|
||||
imports: [PlatformModule, AiModule, CallerResolutionModule],
|
||||
controllers: [CallLookupController, LeadEnrichController],
|
||||
providers: [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> {
|
||||
// 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 }>(
|
||||
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
||||
updateLead(id: $id, data: $data) {
|
||||
id leadStatus aiSummary aiSuggestedAction
|
||||
id aiSummary aiSuggestedAction
|
||||
}
|
||||
}`,
|
||||
{ id, data: input },
|
||||
@@ -192,6 +198,67 @@ export class PlatformGraphqlService {
|
||||
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) ---
|
||||
|
||||
async getLeadActivities(leadId: string, limit = 3): Promise<LeadActivityNode[]> {
|
||||
|
||||
Reference in New Issue
Block a user