feat(insights): wire curated medical references into the live insight paths

The reference-grounding builder (buildSystemPromptWithReferences) and its
SOURCES list shipped to no one: both live paths called the plain system prompt,
so the model was asked to cite referenceId values it was never shown — and the
parser rejects invented ids, so the citation machinery the schema and UI
footnote already support stayed dark. Wire the builder into both consumers,
passing the metric sections the snapshot actually carries (a weight-only run
never lists BP guidance). Apply the fenced-JSON parse fallback here too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-05 13:58:28 +02:00
1 parent e6ef9f2a3e
commit 005f4ba2be
3 files changed
+63 -6

No files matched your search

+15 -2
View File
@@ -20,7 +20,8 @@ import {
buildUserPrompt,
type ComparisonSnapshot,
} from "@/lib/ai/prompts/insight-system-prompt";
import { getStrictInsightsSystemPrompt } from "@/lib/ai/prompts/insight-generator";
import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator";
import { metricsFromPresentSections } from "@/lib/ai/medical-references";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import {
resolveDashboardLayout,
@@ -517,6 +518,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
userPrompt += buildDerivedBriefingPrompt(derivedBriefing, locale);
}
// v1.12.7 (B5) — inject the curated SOURCES block for the metric sections
// this briefing actually carries, so a normative claim can cite a real
// `referenceId` the schema + UI footnote already support. Returns the plain
// prompt unchanged when no applicable metric section is present.
const referenceMetrics = metricsFromPresentSections({
bloodPressure: features.bloodPressure != null,
weight: features.weight != null,
pulse: features.pulse != null,
mood: features.mood != null,
medication: (features.medications?.length ?? 0) > 0,
});
let result;
let workingProviderType: string;
let fallbackHopCount = 0;
@@ -536,7 +549,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// tolerates the strict shape via `insightResultSchema.safeParse`'s
// soft fallback to `parsed` and `passthrough()` on the strict
// schema, so switching the prompt does not break legacy callers.
systemPrompt: getStrictInsightsSystemPrompt(locale),
systemPrompt: buildSystemPromptWithReferences(locale, referenceMetrics),
userPrompt,
temperature: 0.3,
maxTokens: 1500,
+25
View File
@@ -163,6 +163,31 @@ export function getMedicalReferenceById(
return MEDICAL_REFERENCES.find((r) => r.id === id);
}
/**
* Map the set of feature/snapshot sections present for a generation onto
* the reference metric buckets. Lets the insight consumers pick the
* applicable curated SOURCES without re-encoding the bucket names.
*
* `present` carries one boolean per high-level section the snapshot
* actually contains; absent / false sections drop their bucket so a
* weight-only generation never lists ESH BP guidance.
*/
export function metricsFromPresentSections(present: {
bloodPressure?: boolean;
weight?: boolean;
pulse?: boolean;
mood?: boolean;
medication?: boolean;
}): MedicalReferenceMetric[] {
const out: MedicalReferenceMetric[] = [];
if (present.bloodPressure) out.push("bp");
if (present.weight) out.push("weight");
if (present.pulse) out.push("pulse");
if (present.mood) out.push("mood");
if (present.medication) out.push("medication");
return out;
}
/**
* Pick the references whose `metricApplicability` overlaps any of the
* supplied metric buckets. Order preserved from the canonical bundle
+23 -4
View File
@@ -33,7 +33,8 @@ import {
buildUserPrompt,
type ComparisonSnapshot,
} from "@/lib/ai/prompts/insight-system-prompt";
import { getStrictInsightsSystemPrompt } from "@/lib/ai/prompts/insight-generator";
import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator";
import { metricsFromPresentSections } from "@/lib/ai/medical-references";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import {
resolveDashboardLayout,
@@ -52,7 +53,10 @@ import {
enqueueStatusGeneration,
type InsightStatusScope,
} from "@/lib/jobs/insight-status-generate-shared";
import { normalizeLocale } from "@/lib/insights/status-shared";
import {
normalizeLocale,
stripJsonFences,
} from "@/lib/insights/status-shared";
import {
isTimeoutStub,
statusCacheAction,
@@ -524,6 +528,18 @@ export async function generateComprehensiveInsight(
userPrompt += buildGlp1PlateauPrompt(plateauContext, locale);
}
// v1.12.7 (B5) — inject the curated SOURCES block for the metric sections
// this generation actually carries, so a normative claim ("target < 140/90")
// can cite a real `referenceId` the schema + UI footnote already support.
// Returns the plain prompt unchanged when no applicable metric is present.
const referenceMetrics = metricsFromPresentSections({
bloodPressure: features.bloodPressure != null,
weight: features.weight != null,
pulse: features.pulse != null,
mood: features.mood != null,
medication: (features.medications?.length ?? 0) > 0,
});
let result;
let workingProviderType: string;
try {
@@ -531,7 +547,7 @@ export async function generateComprehensiveInsight(
userId,
providers: chain,
params: {
systemPrompt: getStrictInsightsSystemPrompt(locale),
systemPrompt: buildSystemPromptWithReferences(locale, referenceMetrics),
userPrompt,
temperature: 0.3,
maxTokens: 1500,
@@ -548,7 +564,10 @@ export async function generateComprehensiveInsight(
let insights: InsightResult | Record<string, unknown>;
try {
const parsed = JSON.parse(result.content);
// Anthropic + local have no native JSON mode, so a ```json-fenced or
// sentence-prefixed reply would otherwise fail the whole generation.
// Strip the fence before parsing; clean JSON passes through unchanged.
const parsed = JSON.parse(stripJsonFences(result.content));
const validated = insightResultSchema.safeParse(parsed);
insights = validated.success ? validated.data : parsed;
} catch {