From ca5ce518d8b9aa632dc0b07f91d9aa41b6434e35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 12:55:13 +0200 Subject: [PATCH] fix(coach): state the snapshot's weight, body and distance figures in the reader's units The Coach snapshot carried weight, body composition, temperatures and walking speed and distance as stored: kilograms, degrees Celsius, metres per second and metres, with no unit on the block. The evidence-block rules then listed kg and both glucose units as the units to cite. A reader on pounds, Fahrenheit or miles was answered in the units they do not use. Every value block now converts its timeline (and the weight block its aggregate and coarse tail) into the reader's units after aggregating on the canonical value, and names the unit on the block; the grounding scalars stay canonical. Workout distances arrive in kilometres or miles with the unit named once. The prompt in every locale tells the model that a block's figures are already in the reader's unit and takes the evidence-block unit from the block instead of a fixed list. Refs #1067 --- src/lib/ai/coach/__tests__/snapshot.test.ts | 68 ++++++++++++++++++- .../ai/coach/__tests__/system-prompt.test.ts | 36 ++++++++-- .../__tests__/workouts-block-units.test.ts | 54 +++++++++++++++ .../snapshot-blocks/core-metrics-block.ts | 34 +++++++--- .../snapshot-blocks/value-series-blocks.ts | 41 ++++++++--- .../coach/snapshot-blocks/workouts-block.ts | 15 +++- src/lib/ai/coach/snapshot-series.ts | 60 ++++++++++++++++ src/lib/ai/coach/snapshot.ts | 3 + src/lib/ai/coach/system-prompt.ts | 19 ++++-- src/lib/ai/prompts/native-prompts.ts | 12 ++-- 10 files changed, 307 insertions(+), 35 deletions(-) create mode 100644 src/lib/ai/coach/snapshot-blocks/__tests__/workouts-block-units.test.ts diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index e2355d6e8..4ca883b76 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -322,6 +322,7 @@ describe("buildCoachSnapshot", () => { ], "weekly": [], }, + "unit": "kg/m²", }, "dayStrain": { "days": 2, @@ -410,6 +411,7 @@ describe("buildCoachSnapshot", () => { }, ], }, + "unit": "°C", }, "steps": { "timeline": { @@ -428,14 +430,23 @@ describe("buildCoachSnapshot", () => { }, ], }, + "unit": "steps", }, "weight": { "aggregate": { + "allTimeAvg": undefined, + "allTimeMax": undefined, + "allTimeMin": undefined, + "avg30": undefined, + "avg7": undefined, + "avg90": undefined, "bmi": 24.7, "coverage": { "count": 2, }, "latest": 80.8, + "slope30": undefined, + "unit": "kg", }, "timeline": { "recent": [ @@ -453,6 +464,7 @@ describe("buildCoachSnapshot", () => { }, ], }, + "unit": "kg", }, }, "snapshotJson": "{ @@ -490,12 +502,14 @@ describe("buildCoachSnapshot", () => { } }, "weight": { + "unit": "kg", "aggregate": { "latest": 80.8, "bmi": 24.7, "coverage": { "count": 2 - } + }, + "unit": "kg" }, "timeline": { "recent": [ @@ -563,6 +577,7 @@ describe("buildCoachSnapshot", () => { } }, "bodyMassIndex": { + "unit": "kg/m²", "timeline": { "recent": [ { @@ -580,6 +595,7 @@ describe("buildCoachSnapshot", () => { } }, "steps": { + "unit": "steps", "timeline": { "recent": [ { @@ -598,6 +614,7 @@ describe("buildCoachSnapshot", () => { } }, "skinTemperature": { + "unit": "°C", "timeline": { "recent": [ { @@ -657,6 +674,55 @@ describe("buildCoachSnapshot", () => { expect(parsed.scope.window).toBe("last30days"); }); + it("states weight, temperature and the grounding bands in an imperial reader's units", async () => { + prismaMock.user.findUnique.mockResolvedValue({ + coachPrefsJson: null, + timezone: "UTC", + unitPreference: "imperial", + glucoseUnit: "mmol/L", + hasDiabetes: false, + }); + featuresMock.mockResolvedValue({ + weight: { + latest: 80, + avg7: 80.5, + bmi: 24.7, + coverage: { count: 2 }, + }, + }); + prismaMock.measurement.findMany.mockResolvedValue([ + daysAgo(4, 80, "WEIGHT"), + daysAgo(2, 81, "WEIGHT"), + daysAgo(3, 36.6, "BODY_TEMPERATURE"), + daysAgo(1, 37, "BODY_TEMPERATURE"), + ]); + + const out = await buildCoachSnapshot("imperial-user", { + window: "last30days", + sources: ["weight", "body_temp"], + }); + const parsed = JSON.parse(out.snapshotJson); + expect(parsed.weight.unit).toBe("lb"); + expect(parsed.weight.aggregate).toMatchObject({ + unit: "lb", + latest: 176.4, + avg7: 177.5, + bmi: 24.7, + }); + expect( + parsed.weight.timeline.recent.map((r: { value: number }) => r.value), + ).toEqual([176.4, 178.6]); + expect(parsed.bodyTemperature.unit).toBe("°F"); + expect( + parsed.bodyTemperature.timeline.recent.map( + (r: { value: number }) => r.value, + ), + ).toEqual([97.9, 98.6]); + // The band beside it is in °F too, placed on the canonical °C mean. + expect(out.referenceGrounding).toContain("96.3–99.3 °F"); + expect(out.referenceGrounding).not.toContain("°C"); + }); + it("includes day-level BP rows with weekday labels for the recent window", async () => { featuresMock.mockResolvedValue({ bloodPressure: { diff --git a/src/lib/ai/coach/__tests__/system-prompt.test.ts b/src/lib/ai/coach/__tests__/system-prompt.test.ts index 41f47706c..d40440bc0 100644 --- a/src/lib/ai/coach/__tests__/system-prompt.test.ts +++ b/src/lib/ai/coach/__tests__/system-prompt.test.ts @@ -120,6 +120,15 @@ describe("getCoachSystemPrompt — EN", () => { expect(prompt).not.toMatch(/70–180 mg\/dL/); }); + it("tells the model every block is already in the reader's unit", () => { + expect(prompt).toMatch( + /A block's "unit" \(and the workouts block's "distanceUnit"\)/, + ); + expect(prompt).toMatch(/never convert them/); + // No fixed list that offers kg (or both glucose units) for one quantity. + expect(prompt).not.toMatch(/"unit" is one of/); + }); + it("documents the WHOOP-native dayStrain block vs the computed proxy", () => { // v1.17.0 — native 0–21 strain preferred over the computed 0–100 proxy. expect(prompt).toMatch(/dayStrain/); @@ -204,12 +213,16 @@ describe("getCoachSystemPrompt — DE", () => { expect(prompt).toMatch(/außerhalb dessen, womit ich helfen kann/); }); - it("lists glucose + duration units in the evidence-block enumeration", () => { - // v1.17.0 — the DE unit list must sanction mg/dL, mmol/L and min so a - // German coach can cite glucose + duration values with a valid token. - expect(prompt).toMatch(/mg\/dL/); - expect(prompt).toMatch(/mmol\/L/); - expect(prompt).toMatch(/min[\s\S]*\(Schlafschuld/); + it("takes the evidence-block unit from the snapshot, not from a fixed list", () => { + // The list used to be fixed (mmHg, kg, …, mg/dL, mmol/L): a reader on + // pounds had no sanctioned token, and both glucose units were offered + // for one quantity. The unit now comes from the block that holds the + // value; minutes stay named for the sleep-debt figures. + expect(prompt).toMatch( + /"unit" ist die Einheit, die der Snapshot für diesen Wert angibt/, + ); + expect(prompt).toMatch(/min für Schlafschuld/); + expect(prompt).not.toMatch(/"unit" ist eines aus/); }); it("carries the v1.21.2 narrative-presence clauses (A4/A5/A6/A7)", () => { @@ -310,3 +323,14 @@ describe("conditional trajectory ground rule (v1.11.0 Epic B, Pillar 3)", () => expect(de).toMatch(/datiertes\s+Ereignis/i); }); }); + +describe("getCoachSystemPrompt — native locales name the reader's unit", () => { + it.each(["fr", "es", "it", "pl"] as const)( + "%s carries the block-unit rule and no fixed unit list", + (locale) => { + const prompt = getCoachSystemPrompt(locale); + expect(prompt).toContain('"distanceUnit"'); + expect(prompt).not.toMatch(/mmHg, kg, bpm/); + }, + ); +}); diff --git a/src/lib/ai/coach/snapshot-blocks/__tests__/workouts-block-units.test.ts b/src/lib/ai/coach/snapshot-blocks/__tests__/workouts-block-units.test.ts new file mode 100644 index 000000000..9ed3415a4 --- /dev/null +++ b/src/lib/ai/coach/snapshot-blocks/__tests__/workouts-block-units.test.ts @@ -0,0 +1,54 @@ +/** + * Workout distances reach the Coach in the reader's distance unit, named once + * on the block, instead of raw metres under a metre-named key. + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +import { buildWorkoutsBlock } from "../workouts-block"; +import type { UnitPreference } from "@/lib/measurements/display-transform"; + +function build(unitPreference: UnitPreference) { + const snapshot: Record = {}; + buildWorkoutsBlock({ + workoutRows: [ + { + sportType: "running", + startedAt: new Date("2026-09-20T07:00:00Z"), + durationSec: 1800, + totalEnergyKcal: 300, + totalDistanceM: 5000, + avgHeartRate: 150, + maxHeartRate: 170, + source: "APPLE_HEALTH", + }, + ], + sourcePriorityJson: null, + userTz: "UTC", + snapshot, + metrics: new Set(), + counts: {}, + registerBlock: () => {}, + unitPreference, + }); + return snapshot.workouts as { + distanceUnit: string; + recent: Array>; + }; +} + +describe("buildWorkoutsBlock — distance unit", () => { + it("states distance in miles for an imperial reader", () => { + const block = build("imperial"); + expect(block.distanceUnit).toBe("mi"); + expect(block.recent[0]).toMatchObject({ distance: 3.11 }); + expect(block.recent[0]).not.toHaveProperty("distanceM"); + }); + + it("states distance in kilometres for a metric reader", () => { + const block = build("metric"); + expect(block.distanceUnit).toBe("km"); + expect(block.recent[0]).toMatchObject({ distance: 5 }); + }); +}); diff --git a/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts b/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts index 2a3e23493..eda25227a 100644 --- a/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts +++ b/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts @@ -1,10 +1,16 @@ import type { AggregatedFeatures } from "@/lib/insights/features"; +import { weightFeatureInReaderUnits } from "@/lib/insights/features-units"; +import { + getReadingTransform, + type UnitPreferences, +} from "@/lib/measurements/display-transform"; import type { ReferenceMetric } from "@/lib/reference-ranges"; import { bpBandFromRows, bucketWeekly, buildDailyBpRows, buildDailyValueRows, + timelineInUnit, type CoarseTimelineTail, } from "../snapshot-series"; import type { @@ -38,6 +44,8 @@ interface CoreMetricsBlockContext { counts: NonNullable; registerBlock: (key: string, source: CoachScopeSource) => void; groundingValues: Map; + /** The reader's units; the weight block is stated in them. */ + units: UnitPreferences; } export function buildCoreMetricsBlocks( @@ -93,16 +101,24 @@ export function buildCoreMetricsBlocks( if (ctx.sources.has("weight") && ctx.features.weight) { const rows = byType("WEIGHT"); + const aggregate = weightFeatureInReaderUnits( + ctx.features.weight, + ctx.units, + ); ctx.snapshot.weight = { - aggregate: ctx.features.weight, - timeline: { - recent: buildDailyValueRows(rows, ctx.recentCutoff, ctx.userTz), - weekly: bucketWeekly( - rows.filter((row) => row.measuredAt < ctx.recentCutoff), - ctx.userTz, - ), - ...(ctx.coarseTails.weight ? { coarse: ctx.coarseTails.weight } : {}), - }, + unit: aggregate.unit, + aggregate, + timeline: timelineInUnit( + { + recent: buildDailyValueRows(rows, ctx.recentCutoff, ctx.userTz), + weekly: bucketWeekly( + rows.filter((row) => row.measuredAt < ctx.recentCutoff), + ctx.userTz, + ), + ...(ctx.coarseTails.weight ? { coarse: ctx.coarseTails.weight } : {}), + }, + getReadingTransform("WEIGHT", ctx.units), + ), }; ctx.metrics.add("weight"); ctx.windows.add("last7days"); diff --git a/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts b/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts index df6c25b88..a31da2d04 100644 --- a/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts +++ b/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts @@ -1,7 +1,15 @@ import { annotate } from "@/lib/logging/context"; +import { + getReadingTransform, + type UnitPreferences, +} from "@/lib/measurements/display-transform"; import type { ReferenceMetric } from "@/lib/reference-ranges"; import { sourceCluster } from "../clusters"; -import { bucketWeekly, buildDailyValueRows } from "../snapshot-series"; +import { + bucketWeekly, + buildDailyValueRows, + timelineInUnit, +} from "../snapshot-series"; import type { CoachProvenance, CoachProvenanceMetric, @@ -241,8 +249,13 @@ interface ValueSeriesBlocksContext { counts: NonNullable; registerBlock: (key: string, source: CoachScopeSource) => void; groundingValues: Map; + /** The reader's units; every block is stated and labelled in them. */ + units: UnitPreferences; } +/** Unit tokens that are not a unit a reader could be quoted. */ +const UNLABELLED_UNITS: ReadonlySet = new Set(["", "unknown", "score"]); + export function buildValueSeriesBlocks( ctx: Readonly, ): void { @@ -270,14 +283,26 @@ export function buildValueSeriesBlocks( } const recentRows = buildDailyValueRows(rows, ctx.recentCutoff, ctx.userTz); + // Aggregated on the canonical value (the grounding below reads that), + // stated in the reader's unit. The two HRV estimators share one unit. + const transform = getReadingTransform( + typeof block.type === "string" ? block.type : block.type[0], + ctx.units, + ); ctx.snapshot[block.snapshotKey] = { - timeline: { - recent: recentRows, - weekly: bucketWeekly( - rows.filter((row) => row.measuredAt < ctx.recentCutoff), - ctx.userTz, - ), - }, + ...(UNLABELLED_UNITS.has(transform.displayUnit) + ? {} + : { unit: transform.displayUnit }), + timeline: timelineInUnit( + { + recent: recentRows, + weekly: bucketWeekly( + rows.filter((row) => row.measuredAt < ctx.recentCutoff), + ctx.userTz, + ), + }, + transform, + ), }; ctx.metrics.add(block.metric); ctx.counts[block.metric] = rows.length; diff --git a/src/lib/ai/coach/snapshot-blocks/workouts-block.ts b/src/lib/ai/coach/snapshot-blocks/workouts-block.ts index 2a396428d..c44a8be52 100644 --- a/src/lib/ai/coach/snapshot-blocks/workouts-block.ts +++ b/src/lib/ai/coach/snapshot-blocks/workouts-block.ts @@ -20,6 +20,11 @@ * the UI. Run the SAME picker here, keyed off the user's own * `sourcePriorityJson`, so both surfaces agree. */ +import { + applyDisplayTransform, + getQuantityTransform, + type UnitPreference, +} from "@/lib/measurements/display-transform"; import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; import type { MeasurementSource } from "@/generated/prisma/client"; import { annotate } from "@/lib/logging/context"; @@ -57,6 +62,8 @@ interface WorkoutsBlockContext { metrics: Set; counts: NonNullable; registerBlock: (key: string, source: CoachScopeSource) => void; + /** The reader's metric/imperial choice; distances are stated in it. */ + unitPreference: UnitPreference; } export function buildWorkoutsBlock(ctx: Readonly): void { @@ -68,7 +75,9 @@ export function buildWorkoutsBlock(ctx: Readonly): void { metrics, counts, registerBlock, + unitPreference, } = ctx; + const distanceTransform = getQuantityTransform("distance", unitPreference); // The workout sessions are read in parallel by the builder, raw // (every source, every duplicate). Collapse cross-source duplicates // with the same picker the UI uses before narrating anything. @@ -92,7 +101,10 @@ export function buildWorkoutsBlock(ctx: Readonly): void { sport: w.sportType, durationMin: Math.round(w.durationSec / 60), energyKcal: w.totalEnergyKcal ?? null, - distanceM: w.totalDistanceM ?? null, + distance: + w.totalDistanceM == null + ? null + : applyDisplayTransform(w.totalDistanceM, distanceTransform), avgHr: w.avgHeartRate ?? null, maxHr: w.maxHeartRate ?? null, })); @@ -121,6 +133,7 @@ export function buildWorkoutsBlock(ctx: Readonly): void { })) .sort((a, b) => b.count - a.count); snapshot.workouts = { + distanceUnit: distanceTransform.displayUnit, recent: recentList, perSport: sportRollup, totalInWindow: workoutRows.length, diff --git a/src/lib/ai/coach/snapshot-series.ts b/src/lib/ai/coach/snapshot-series.ts index 4df20de6e..f06fc3328 100644 --- a/src/lib/ai/coach/snapshot-series.ts +++ b/src/lib/ai/coach/snapshot-series.ts @@ -8,6 +8,10 @@ * is one-way (snapshot → series), never back. */ import { userDayKey } from "@/lib/tz/format"; +import { + applyDisplayTransform, + type DisplayTransform, +} from "@/lib/measurements/display-transform"; import { buildTieredSeries } from "@/lib/rollups/tiered-context"; import type { MeasurementType } from "@/generated/prisma/client"; import { buildBaselineBand } from "@/lib/insights/derived/baseline"; @@ -257,6 +261,62 @@ export async function buildCoarseTimelineTail( }; } +/** + * A built timeline in the reader's unit. The series are aggregated on the + * canonical value and converted once at the end, so a mean in pounds is the + * converted mean of the kilograms, not a mean of rounded pound readings. + * Identity for every transform that does not rescale. + */ +export function timelineInUnit( + timeline: { + recent: DailyValueRow[]; + weekly: WeeklyBucket[]; + coarse?: CoarseTimelineTail; + }, + transform: DisplayTransform, +): { + recent: DailyValueRow[]; + weekly: WeeklyBucket[]; + coarse?: CoarseTimelineTail; +} { + const v = (value: number) => applyDisplayTransform(value, transform); + return { + recent: timeline.recent.map((row) => ({ ...row, value: v(row.value) })), + weekly: timeline.weekly.map((bucket) => ({ + ...bucket, + mean: v(bucket.mean), + })), + ...(timeline.coarse + ? { + coarse: { + monthly: timeline.coarse.monthly.map( + ([start, mean, min, max]) => + [start, v(mean), v(min), v(max)] as [ + string, + number, + number, + number, + ], + ), + yearly: timeline.coarse.yearly.map( + ([start, mean, min, max]) => + [start, v(mean), v(min), v(max)] as [ + string, + number, + number, + number, + ], + ), + anomalies: timeline.coarse.anomalies.map((a) => ({ + ...a, + value: v(a.value), + })), + }, + } + : {}), + }; +} + /** * Pair systolic + diastolic into one row per day. Days with only one * side measured are dropped — the Coach is asked about "BP" as a pair, diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index bcc502e9f..4f7ed8b79 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -882,6 +882,7 @@ async function buildCoachSnapshotImpl( counts, registerBlock, groundingValues, + units, }); // Medication compliance lives outside the structured features — the block // is assembled in `snapshot-blocks/compliance-block.ts` from the ledger @@ -911,6 +912,7 @@ async function buildCoachSnapshotImpl( counts, registerBlock, groundingValues, + units, }); // ── v1.7.0 sleep block (with optional per-stage enrichment) ─────── // Assembled in `snapshot-blocks/sleep-block.ts` from the dedicated @@ -976,6 +978,7 @@ async function buildCoachSnapshotImpl( metrics, counts, registerBlock, + unitPreference: units.system, }); } diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index d1e9427fe..349a100a3 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -337,13 +337,16 @@ ISO-week means. - The SNAPSHOT is ADDITIVE. Beyond blood pressure, weight, pulse, mood, and medication, it MAY carry body-composition, activity, workouts, sleep (with per-stage minutes), glucose (split by - context: fasting / postprandial / random / bedtime, carrying its - own display "unit" of mg/dL or mmol/L), mobility & + context: fasting / postprandial / random / bedtime), mobility & gait, and environmental-exposure blocks — but only when the user enabled the cluster AND has data. A block that is absent means the user has no such data or did not share it; never invent or assume a cluster the SNAPSHOT does not contain. The "workouts" block carries the recent sessions plus a per-sport rollup, not every session. +- A block's "unit" (and the workouts block's "distanceUnit") is the + unit the user reads their numbers in, and every figure in that block + is already in it. Quote the figures in that unit; never convert them + and never name a different unit for the same quantity. - The SNAPSHOT MAY carry a "derived" block: compact wellness summaries the app already computed (readiness, recovery, sleep score, stress, @@ -460,8 +463,8 @@ Rules: or a day-pin ("Tue 6 May"). - "value" is a pre-formatted display string ("138/85", "84.2", "4.1"). Format mood as N/5 when numeric. -- "unit" is one of mmHg, kg, bpm, /5, %, mg/dL, mmol/L, min (sleep - debt / social jetlag). +- "unit" is the unit the snapshot gives for that value (e.g. mmHg, + bpm, /5, %, min for sleep debt / social jetlag). - "window" is one of last7days, last30days, last90days, allTime; omit it for day-level pins. - Omit the entire block (no ---KEYVALUES--- line, no ---END--- line) @@ -917,6 +920,10 @@ ISO-Wochenmittel zusammen. das der SNAPSHOT nicht enthält. Der "workouts"-Block führt die jüngsten Einheiten plus eine Zusammenfassung je Sportart, nicht jede Einheit. +- Die "unit" eines Blocks (und "distanceUnit" im "workouts"-Block) + ist die Einheit, in der der Nutzer seine Werte liest; jede Zahl im + Block steht schon in ihr. Nenne die Zahlen in dieser Einheit, rechne + sie nie um und nenne nie eine andere Einheit für dieselbe Größe. - Der "memory"-Block des SNAPSHOT kann eine "facts"-Liste tragen — dauerhafte Dinge, die du über diesen Nutzer gelernt hast (stabile Vorlieben, vom Nutzer selbst genannte gesundheitliche Umstände, @@ -987,8 +994,8 @@ Regeln: oder ein Tages-Pin ("Di 6. Mai"). - "value" ist ein vorformatierter Anzeigestring ("138/85", "84,2", "4,1"). Stimmung als N/5 formatieren, wenn numerisch. -- "unit" ist eines aus mmHg, kg, bpm, /5, %, mg/dL, mmol/L, min - (Schlafschuld / sozialer Jetlag). +- "unit" ist die Einheit, die der Snapshot für diesen Wert angibt + (z. B. mmHg, bpm, /5, %, min für Schlafschuld / sozialen Jetlag). - "window" ist eines aus last7days, last30days, last90days, allTime; bei Tages-Pins weglassen. - Lass den ganzen Block weg (KEINE ---KEYVALUES---Zeile, KEINE diff --git a/src/lib/ai/prompts/native-prompts.ts b/src/lib/ai/prompts/native-prompts.ts index 8f8e76d77..aa0a79503 100644 --- a/src/lib/ai/prompts/native-prompts.ts +++ b/src/lib/ai/prompts/native-prompts.ts @@ -83,21 +83,25 @@ const DAY_LEVEL_BODY: Record = { - Pour une question portant sur un jour ou un jour-de-semaine précis ("pourquoi la tension était-elle plus élevée lundi dernier ?"), répondez depuis "timeline.recent" — citez la lecture réelle de ce jour avec la date et le jour-de-semaine. - Quand aucune ligne n'existe pour le jour mentionné, dites-le clairement ("je n'ai pas de lecture pour lundi 5 mai") et proposez le jour disponible le plus proche. - Les questions plus anciennes (>14 jours) basculent sur "timeline.weekly" ou, à défaut, sur l'agrégat. +- Le "unit" d'un bloc (et le "distanceUnit" du bloc "workouts") est l'unité dans laquelle l'utilisateur lit ses valeurs, et chaque chiffre du bloc y est déjà. Citez les chiffres dans cette unité ; ne les convertissez jamais et ne nommez jamais une autre unité pour la même grandeur. - L'objet "scope" nomme la fenêtre choisie et les sources. Quand l'utilisateur interroge une métrique absente de scope.sources, dites qu'elle n'est pas dans le périmètre de cette conversation.`, es: `Cada métrica en el SNAPSHOT lleva un objeto "timeline" junto al bloque "aggregate". "timeline.recent" es una lista ordenada de filas-día de los últimos 14 días; cada fila lleva una "date" (YYYY-MM-DD), una etiqueta "weekday" (Lun/Mar/Mié/...) y los valores de la métrica para ese día. "timeline.weekly" agrupa los días más antiguos en medias ISO-semanales. - Ante preguntas sobre un día concreto o un día de la semana ("¿por qué la tensión estuvo más alta el lunes pasado?"), responda desde "timeline.recent" — cite la lectura real de ese día con fecha y día de la semana. - Cuando no exista fila para el día mencionado, dígalo claramente ("no tengo lectura del lunes 5 de mayo") y ofrezca el día más cercano disponible. - Las preguntas más antiguas (>14 días) acuden a "timeline.weekly" o, si no, al agregado. +- La "unit" de un bloque (y la "distanceUnit" del bloque "workouts") es la unidad en la que el usuario lee sus valores, y cada cifra del bloque ya está en ella. Cite las cifras en esa unidad; nunca las convierta ni nombre otra unidad para la misma magnitud. - El objeto "scope" nombra la ventana seleccionada y las fuentes. Si el usuario pregunta por una métrica que no está en scope.sources, indique que está fuera del alcance de esta conversación.`, it: `Ogni metrica nello SNAPSHOT porta un oggetto "timeline" accanto al blocco "aggregate". "timeline.recent" è una lista ordinata di righe-giorno per gli ultimi 14 giorni; ogni riga porta una "date" (YYYY-MM-DD), un'etichetta "weekday" (Lun/Mar/Mer/...) e i valori della metrica per quel giorno. "timeline.weekly" raggruppa i giorni più vecchi in medie ISO-settimanali. - Per domande su un giorno o giorno-della-settimana specifico ("perché la pressione era più alta lunedì scorso?"), risponda da "timeline.recent" — citi la lettura reale di quel giorno con data e giorno-della-settimana. - Quando non esiste una riga per il giorno nominato, lo dica chiaramente ("non ho una lettura per lunedì 5 maggio") e proponga il giorno più vicino disponibile. - Le domande più vecchie (>14 giorni) ripiegano su "timeline.weekly" o, in mancanza, sull'aggregato. +- La "unit" di un blocco (e la "distanceUnit" del blocco "workouts") è l'unità in cui l'utente legge i propri valori, e ogni cifra del blocco è già in essa. Citi le cifre in quell'unità; non le converta mai e non nomini mai un'altra unità per la stessa grandezza. - L'oggetto "scope" nomina la finestra scelta dall'utente e le fonti. Se l'utente chiede di una metrica non presente in scope.sources, dica che non rientra nello scopo di questa conversazione.`, pl: `Każda metryka w SNAPSHOT zawiera obiekt "timeline" obok bloku "aggregate". "timeline.recent" to uporządkowana lista wierszy-dnia za ostatnie 14 dni; każdy wiersz ma "date" (YYYY-MM-DD), etykietę "weekday" (Pn/Wt/Śr/...) i wartości metryki dla tego dnia. "timeline.weekly" grupuje starsze dni w średnie ISO-tygodniowe. - Przy pytaniach o konkretny dzień lub dzień tygodnia ("dlaczego ciśnienie było wyższe w zeszły poniedziałek?"), proszę odpowiadać z "timeline.recent" — cytować rzeczywisty odczyt z tego dnia z datą i dniem tygodnia. - Gdy nie ma wiersza dla wymienionego dnia, proszę powiedzieć to wprost ("nie mam odczytu z poniedziałku 5 maja") i zaproponować najbliższy dostępny dzień. - Starsze pytania (>14 dni) sięgają do "timeline.weekly" lub, w razie braku, do agregatu. +- "unit" bloku (oraz "distanceUnit" bloku "workouts") to jednostka, w której użytkownik odczytuje swoje wartości, i każda liczba w bloku jest już w niej podana. Proszę cytować liczby w tej jednostce; nigdy ich nie przeliczać i nigdy nie podawać innej jednostki dla tej samej wielkości. - Obiekt "scope" wymienia wybrane okno i źródła. Jeśli użytkownik pyta o metrykę nieobecną w scope.sources, proszę powiedzieć, że nie należy ona do zakresu tej rozmowy.`, }; @@ -115,7 +119,7 @@ Règles : - Chaque ligne : "