From 1c65f72ccf888cb33331c6606c4186f993bf905e Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Mon, 29 Jun 2026 19:36:49 +0300 Subject: [PATCH] backend: render patient card from searchPatients on a unique match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Show me 's medical record" relied on the model chaining searchPatients → getPatient, but Gemini Flash often calls searchPatients then emits the canned closing line ("Here's the record.") without the second tool call, so no data-patientCard part is ever written and no card renders. (The prior Gemini fix only covered the empty-schema list tools.) searchPatients now writes the record card (data-patientCard, or data-recordGraph in graph mode) and a source directly when EXACTLY ONE patient matches — mirroring getPatient — so the common name-lookup flow no longer depends on a second tool call. Multiple/zero matches keep returning the disambiguation list. The tool description and system prompt tell the model the card is already shown on a unique match so it doesn't double-render. Co-Authored-By: Claude Opus 4.8 --- backend/src/routes/chat.ts | 5 +++- backend/src/services/ai/tools.ts | 43 ++++++++++++++++++++++++++------ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 7bebd1a..19c5177 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -107,7 +107,10 @@ function systemPrompt( "", "Display tools (read-only):", "- getPatient: when asked about a specific patient by file number / MRN.", - "- searchPatients: when given a name; then getPatient on the match.", + "- searchPatients: when given a name. If exactly one patient matches it", + " already shows that patient's record card — don't call getPatient again,", + " just confirm. Only call getPatient yourself for a direct file number / MRN,", + " or to pick one of several matches it returns.", "- getPatientLabs: when asked about labs/results/trends.", "- listAppointments: when asked to see the schedule / upcoming visits.", "- listTasks: when asked to see open tasks / to-dos.", diff --git a/backend/src/services/ai/tools.ts b/backend/src/services/ai/tools.ts index f831700..d9a83ac 100644 --- a/backend/src/services/ai/tools.ts +++ b/backend/src/services/ai/tools.ts @@ -189,10 +189,12 @@ export function createChatTools(ctx: ToolContext) { }, }), - // Search the clinic's patients by name or file number. + // Search the clinic's patients by name or file number. On a unique match this + // also displays that patient's record card directly (see below), so the common + // "show me 's record" flow doesn't depend on a follow-up getPatient call. searchPatients: tool({ description: - "Search the clinic's patients by name fragment. Returns matches with file numbers so you can then call getPatient.", + "Search the clinic's patients by name fragment. If EXACTLY ONE patient matches, this already displays their full record card — do NOT call getPatient again; just confirm in one sentence. If multiple match, it returns the matches (with file numbers) so you can disambiguate or call getPatient on the right one.", inputSchema: z.object({ query: z.string().describe("Name or file-number fragment to match"), }), @@ -204,17 +206,42 @@ export function createChatTools(ctx: ToolContext) { scopeProviderId, ); const q = query.trim().toLowerCase(); - const matches = all + // Keep the full Patient objects so a unique match can render a card. + const matched = all .filter( (p) => p.name.toLowerCase().includes(q) || p.fileNumber.toLowerCase().includes(q), ) - .slice(0, 10) - .map((p) => { - const r = veil.redactPatient(p); - return { fileNumber: r.fileNumber, name: r.name, status: p.status }; - }); + .slice(0, 10); + + // Unique match → show the record card right here. Gemini often skips the + // search→getPatient hand-off and just emits a canned sentence, leaving the + // clinician with no card; rendering it directly removes that dependency. + if (matched.length === 1) { + const patient = matched[0]!; + step(`Looking up patient ${patient.fileNumber}`); + writer.write( + mode === "graph" + ? { type: "data-recordGraph", data: patient } + : { type: "data-patientCard", data: patient }, + ); + const sourceId = addSource( + `${patient.name} · MRN ${patient.fileNumber}`, + "patient", + ); + return { + count: 1, + shownCard: true, + sourceId, + patient: forModel(veil.redactPatient(patient)), + }; + } + + const matches = matched.map((p) => { + const r = veil.redactPatient(p); + return { fileNumber: r.fileNumber, name: r.name, status: p.status }; + }); step(`Found ${matches.length} match(es)`); return { count: matches.length, matches }; },