diff --git a/src/lib/insights/derived/__tests__/derived-assessment.test.ts b/src/lib/insights/derived/__tests__/derived-assessment.test.ts index dde32e8fc..1fa60c195 100644 --- a/src/lib/insights/derived/__tests__/derived-assessment.test.ts +++ b/src/lib/insights/derived/__tests__/derived-assessment.test.ts @@ -158,6 +158,41 @@ describe("resolveDeterministicAssessment", () => { expect(a!.text).toContain("Schlafmenge"); }); + it("appends a grounded next step when the weakest contributor is addressable (en)", () => { + // sleep (30) is the lowest present contributor → a behaviourally + // addressable driver, so the assessment closes with a doable pointer. + const sleepWeak: ReadinessValue = { + score: 58, + band: "yellow", + components: [ + { key: "rhr", value: 80, weight: 0.3 }, + { key: "hrv", value: 75, weight: 0.3 }, + { key: "sleep", value: 30, weight: 0.25 }, + { key: "mood", value: 70, weight: 0.15 }, + ], + }; + const a = resolveDeterministicAssessment( + "READINESS", + okDerived(sleepWeak), + "en", + NOW, + ); + expect(a!.text).toContain("Held back most by"); + expect(a!.text).toContain("An earlier night would lift this most."); + }); + + it("does NOT manufacture a step when the weakest contributor is physiology-only (en)", () => { + // rhr (40) is the lowest in the base readiness fixture → no pointer. + const a = resolveDeterministicAssessment( + "READINESS", + okDerived(readiness), + "en", + NOW, + ); + expect(a!.text).not.toContain("An earlier night"); + expect(a!.text).not.toContain("most effective lever"); + }); + it("uses the trend for a contributor-less recovery score (en)", () => { const a = resolveDeterministicAssessment( "RECOVERY_SCORE", diff --git a/src/lib/insights/derived/derived-assessment.ts b/src/lib/insights/derived/derived-assessment.ts index f7926008d..0791f9d22 100644 --- a/src/lib/insights/derived/derived-assessment.ts +++ b/src/lib/insights/derived/derived-assessment.ts @@ -247,6 +247,41 @@ function contributorLabel( return key; } +/** + * v1.21.0 (coach C1 MEDIUM-2) — a single grounded next-step pointer keyed to + * the weakest BEHAVIOURALLY ADDRESSABLE contributor. Only the contributors a + * person can actually move (sleep, mood, consistency, timing) carry a + * pointer; physiology-only contributors (rhr, hrv, respiratory) return null, + * so the assessment affirms-and-watches rather than manufacturing a step — + * matching the `base-system.ts` "do NOT manufacture a step" rule. + */ +const CONTRIBUTOR_POINTERS: Record = { + sleep: { + de: "Eine etwas frühere Nacht würde das am ehesten anheben.", + en: "An earlier night would lift this most.", + }, + sufficiency: { + de: "Etwas mehr Schlafzeit ist hier der wirksamste Hebel.", + en: "A little more time asleep is the most effective lever here.", + }, + consistency: { + de: "Gleichmäßigere Schlafzeiten über die Woche helfen am meisten.", + en: "More even sleep and wake times across the week help most.", + }, + timing: { + de: "Ein gleichmäßigerer Rhythmus zieht das am ehesten nach oben.", + en: "A steadier sleep rhythm is the most likely thing to pull this up.", + }, + mood: { + de: "Ein kurzer Moment für etwas, das dir guttut, kann hier spürbar helfen.", + en: "A small moment for something that does you good can noticeably help here.", + }, +}; + +function contributorPointer(key: string, locale: Locale): string | null { + return CONTRIBUTOR_POINTERS[key]?.[locale] ?? null; +} + /** * Compose the deterministic per-score assessment from the score's signal + * its contributors. Always returns a non-empty, factual, non-causal text. @@ -294,6 +329,11 @@ export function buildDeterministicScoreAssessment( ? `Am stärksten gedämpft durch ${joined}.` : `Held back most by ${joined}.`, ); + // v1.21.0 (MEDIUM-2) — close with ONE grounded next step drawn from the + // weakest contributor, but only when it is behaviourally addressable. + // When the weakest driver is physiology-only, no step is manufactured. + const pointer = contributorPointer(ranked[0].key, locale); + if (pointer) sentences.push(pointer); } } else if (signal.delta !== null && signal.delta !== 0) { // No contributor breakdown (recovery/stress/strain): use the trend.