diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 5929bba4b..3415c1109 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -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, diff --git a/src/lib/ai/medical-references.ts b/src/lib/ai/medical-references.ts index 99fd5dd8f..07b824f5b 100644 --- a/src/lib/ai/medical-references.ts +++ b/src/lib/ai/medical-references.ts @@ -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 diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 25681b7f9..4f1953c0c 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -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; 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 {