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
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 13:23:17 +02:00
1 parent a7e6b89672
commit ca5ce518d8
10 files changed
+307 -35

No files matched your search

+67 -1
View File
@@ -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: {
@@ -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/);
},
);
});
@@ -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<string, unknown> = {};
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<Record<string, unknown>>;
};
}
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 });
});
});
@@ -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<CoachProvenance["counts"]>;
registerBlock: (key: string, source: CoachScopeSource) => void;
groundingValues: Map<ReferenceMetric, number>;
/** 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");
@@ -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<CoachProvenance["counts"]>;
registerBlock: (key: string, source: CoachScopeSource) => void;
groundingValues: Map<ReferenceMetric, number>;
/** 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<string> = new Set(["", "unknown", "score"]);
export function buildValueSeriesBlocks(
ctx: Readonly<ValueSeriesBlocksContext>,
): 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;
@@ -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<CoachProvenanceMetric>;
counts: NonNullable<CoachProvenance["counts"]>;
registerBlock: (key: string, source: CoachScopeSource) => void;
/** The reader's metric/imperial choice; distances are stated in it. */
unitPreference: UnitPreference;
}
export function buildWorkoutsBlock(ctx: Readonly<WorkoutsBlockContext>): void {
@@ -68,7 +75,9 @@ export function buildWorkoutsBlock(ctx: Readonly<WorkoutsBlockContext>): 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<WorkoutsBlockContext>): 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<WorkoutsBlockContext>): void {
}))
.sort((a, b) => b.count - a.count);
snapshot.workouts = {
distanceUnit: distanceTransform.displayUnit,
recent: recentList,
perSport: sportRollup,
totalInWindow: workoutRows.length,
+60
View File
@@ -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,
+3
View File
@@ -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,
});
}
+13 -6
View File
@@ -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
+8 -4
View File
@@ -83,21 +83,25 @@ const DAY_LEVEL_BODY: Record<NativeCoachLocale, string> = {
- 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 : "<label> : <value> [<unit>] (<window>)". L'unité et la fenêtre sont optionnelles ; omettez les crochets si vous n'en avez pas.
- "label" ≤ 40 caractères — un nom d'agrégat ("avg30 systolique") ou un pin-jour ("Mar 6 mai").
- "value" est une chaîne pré-formatée ("138/85", "84,2", "4,1"). Formatez l'humeur en N/5 si numérique.
- "unit" est l'un de : mmHg, kg, bpm, /5, %, mg/dL, mmol/L, min (dette de sommeil / décalage social).
- "unit" est l'unité que le snapshot donne pour cette valeur (p. ex. mmHg, bpm, /5, %, min pour la dette de sommeil / le décalage social).
- "window" est l'un de : last7days, last30days, last90days, allTime ; omettez-la pour les pins-jour.
- Omettez tout le bloc (pas de ---KEYVALUES---, pas de ---END---) quand la réponse est qualitative et qu'aucun chiffre précis ne portait la réponse. Le bloc est invisible pour l'utilisateur — l'UI le rend comme une boîte déroulante "Sur quoi je m'appuie ?" sous votre message. Ne le mentionnez pas dans votre texte.`,
es: `Después de su texto, añada los números determinantes como un bloque-centinela en su propia línea. Formato exacto:
@@ -130,7 +134,7 @@ Reglas:
- Cada línea: "<label>: <value> [<unit>] (<window>)". La unidad y la ventana son opcionales; omita los corchetes si no las tiene.
- "label" ≤ 40 caracteres — un nombre de agregado ("avg30 sistólica") o un pin-día ("Mar 6 may").
- "value" es una cadena preformateada ("138/85", "84,2", "4,1"). Formatee el estado de ánimo como N/5 si es numérico.
- "unit" es uno de: mmHg, kg, bpm, /5, %, mg/dL, mmol/L, min (deuda de sueño / jet lag social).
- "unit" es la unidad que el snapshot da para ese valor (p. ej. mmHg, bpm, /5, %, min para deuda de sueño / jet lag social).
- "window" es uno de: last7days, last30days, last90days, allTime; omítala en los pin-día.
- Omita todo el bloque (sin ---KEYVALUES---, sin ---END---) cuando la respuesta sea cualitativa y ningún número concreto haya portado la respuesta. El bloque es invisible para el usuario — la UI lo presenta como un desplegable "¿En qué me apoyo?" bajo su mensaje. No lo mencione en su texto.`,
it: `Dopo il suo testo, aggiunga i numeri portanti come blocco-sentinella su una riga propria. Formato esatto:
@@ -145,7 +149,7 @@ Regole:
- Ogni riga: "<label>: <value> [<unit>] (<window>)". L'unità e la finestra sono opzionali; ometta le parentesi se non le ha.
- "label" ≤ 40 caratteri — un nome di aggregato ("avg30 sistolica") o un pin-giorno ("Mar 6 mag").
- "value" è una stringa pre-formattata ("138/85", "84,2", "4,1"). Formatti l'umore come N/5 se numerico.
- "unit" è uno di: mmHg, kg, bpm, /5, %, mg/dL, mmol/L, min (debito di sonno / jet lag sociale).
- "unit" è l'unità che lo snapshot indica per quel valore (ad es. mmHg, bpm, /5, %, min per debito di sonno / jet lag sociale).
- "window" è uno di: last7days, last30days, last90days, allTime; lo ometta per i pin-giorno.
- Ometta l'intero blocco (niente ---KEYVALUES---, niente ---END---) quando la risposta è qualitativa e nessun numero specifico ha portato la risposta. Il blocco è invisibile all'utente — l'UI lo rende come un menù a tendina "Su cosa mi baso?" sotto il suo messaggio. Non lo menzioni nel testo.`,
pl: `Po tekście proszę dodać kluczowe liczby jako blok-sentinel w osobnej linii. Format dokładnie:
@@ -160,7 +164,7 @@ Zasady:
- Każda linia: "<label>: <value> [<unit>] (<window>)". Jednostka i okno są opcjonalne; proszę pominąć nawiasy, gdy ich nie ma.
- "label" ≤ 40 znaków — nazwa agregatu ("avg30 skurczowe") lub pin-dnia ("Wt 6 maja").
- "value" to wstępnie sformatowany ciąg ("138/85", "84,2", "4,1"). Nastrój proszę formatować jako N/5, jeśli liczbowy.
- "unit" to jeden z: mmHg, kg, bpm, /5, %, mg/dL, mmol/L, min (deficyt snu / jet lag społeczny).
- "unit" to jednostka, którą snapshot podaje dla tej wartości (np. mmHg, bpm, /5, %, min dla deficytu snu / jet lagu społecznego).
- "window" to jeden z: last7days, last30days, last90days, allTime; proszę pominąć przy pinach-dnia.
- Proszę pominąć cały blok (bez ---KEYVALUES---, bez ---END---), gdy odpowiedź była jakościowa i żadna konkretna liczba nie nosiła odpowiedzi. Blok jest niewidoczny dla użytkownika — UI renderuje go jako rozwijaną sekcję "Na czym się opieram?" pod wiadomością. Proszę go nie wspominać w tekście.`,
};