@@ -96,12 +96,22 @@ type YesterdayInputs struct {
9696// design. The RECOVERY prompt itself focuses on autonomic trajectories and
9797// does not reference EnergyBank.
9898type RecoveryInputs struct {
99- LastDate string
100- HRV []float64
101- RHR []float64
102- VO2 []float64
103- Sleep []float64
104- Readiness * float64
99+ LastDate string
100+ HRV []float64
101+ RHR []float64
102+ VO2 []float64
103+ Sleep []float64
104+ Context InsightContext
105+ }
106+
107+ type InsightContext struct {
108+ ReadinessScore int `json:"readiness_score,omitempty"`
109+ ReadinessRawScore int `json:"readiness_raw_score,omitempty"`
110+ ReadinessConfidence string `json:"readiness_confidence,omitempty"`
111+ ReadinessCapReason string `json:"readiness_cap_reason,omitempty"`
112+ AIAdviceMode string `json:"ai_advice_mode,omitempty"`
113+ CheckinStatus string `json:"checkin_status,omitempty"`
114+ CheckinAnswer string `json:"checkin_answer,omitempty"`
105115}
106116
107117// HashSleep / HashYesterday / HashRecovery extract and hash per-block subsets.
@@ -135,18 +145,18 @@ func HashYesterday(r *health.RawMetrics) string {
135145 })
136146}
137147
138- func HashRecovery (r * health.RawMetrics , eb * health.EnergyBank , readiness * float64 ) string {
148+ func HashRecovery (r * health.RawMetrics , eb * health.EnergyBank , ctx InsightContext ) string {
139149 if r == nil {
140150 return ""
141151 }
142152 _ = eb // see RecoveryInputs doc — intra-day fields would defeat the cache
143153 return hashInputs (RecoveryInputs {
144- LastDate : r .LastDate ,
145- HRV : r .HRV ,
146- RHR : r .RHR ,
147- VO2 : r .VO2 ,
148- Sleep : r .Sleep ,
149- Readiness : readiness ,
154+ LastDate : r .LastDate ,
155+ HRV : r .HRV ,
156+ RHR : r .RHR ,
157+ VO2 : r .VO2 ,
158+ Sleep : r .Sleep ,
159+ Context : ctx ,
150160 })
151161}
152162
@@ -163,7 +173,7 @@ func HashRecovery(r *health.RawMetrics, eb *health.EnergyBank, readiness *float6
163173// EnergyBank fields. Passing the sequence lets RECOMMENDATION pick up
164174// patterns like "3 rest days in a row" without re-hitting Gemini just
165175// because today's verdict is unchanged.
166- func HashRecommendation (sleepText , yesterdayText , recoveryText string , eb * health.EnergyBank , verdictHistory []string ) string {
176+ func HashRecommendation (sleepText , yesterdayText , recoveryText string , eb * health.EnergyBank , verdictHistory []string , ctx InsightContext ) string {
167177 verdict := ""
168178 var flags []string
169179 if eb != nil {
@@ -180,8 +190,9 @@ func HashRecommendation(sleepText, yesterdayText, recoveryText string, eb *healt
180190 // to surface the rest guidance, even if leaf texts and the
181191 // verdict label are unchanged.
182192 StressFlags []string
193+ Context InsightContext
183194 }
184- return hashInputs (rec {sleepText , yesterdayText , recoveryText , verdict , verdictHistory , flags })
195+ return hashInputs (rec {sleepText , yesterdayText , recoveryText , verdict , verdictHistory , flags , ctx })
185196}
186197
187198// ─── orchestrator ─────────────────────────────────────────────────────────
@@ -200,7 +211,7 @@ type BlockResult struct {
200211//
201212// hashes carry the inputs_hash for each block so callers can compare against
202213// the cache and skip Gemini when nothing has changed since last generation.
203- func GenerateLeafBlocks (apiKey , model string , maxTokens int , rawMetricsJSON []byte , lang string ,
214+ func GenerateLeafBlocks (apiKey , model string , maxTokens int , payloadForBlock func ( block string ) []byte , lang string ,
204215 skipBlock func (block string ) bool ) []BlockResult {
205216
206217 results := make ([]BlockResult , 0 , len (LeafBlocks ))
@@ -214,7 +225,11 @@ func GenerateLeafBlocks(apiKey, model string, maxTokens int, rawMetricsJSON []by
214225 go func (block string ) {
215226 defer wg .Done ()
216227 prompt := BuildBlockPrompt (block )
217- text , _ , err := generateWithPrompt (apiKey , model , maxTokens , prompt , rawMetricsJSON , lang )
228+ payload := []byte (nil )
229+ if payloadForBlock != nil {
230+ payload = payloadForBlock (block )
231+ }
232+ text , _ , err := generateWithPrompt (apiKey , model , maxTokens , prompt , payload , lang )
218233 resultsMu .Lock ()
219234 results = append (results , BlockResult {Block : block , Text : text , Err : err })
220235 resultsMu .Unlock ()
@@ -236,15 +251,35 @@ func GenerateLeafBlocks(apiKey, model string, maxTokens int, rawMetricsJSON []by
236251// proper rest" instead of treating each day in isolation. Empty slice
237252// is fine — the line is simply omitted from the prompt context.
238253func GenerateRecommendation (apiKey , model string , maxTokens int , rawMetricsJSON []byte , lang string ,
239- sleepText , yesterdayText , recoveryText string , verdictHistory []string , stressFlags []string ) (string , error ) {
254+ sleepText , yesterdayText , recoveryText string , verdictHistory []string , stressFlags []string , ctx InsightContext ) (string , error ) {
240255 prompt := BuildBlockPrompt (BlockRecommendation )
256+ context := BuildRecommendationContext (sleepText , yesterdayText , recoveryText , verdictHistory , stressFlags , ctx )
257+ text , _ , err := generateWithPrompt (apiKey , model , maxTokens , prompt , append (rawMetricsJSON , []byte (context )... ), lang )
258+ return text , err
259+ }
260+
261+ func BuildRecommendationContext (sleepText , yesterdayText , recoveryText string , verdictHistory []string , stressFlags []string , ctx InsightContext ) string {
241262 leafSummary := fmt .Sprintf (
242263 "\n \n LEAF BLOCKS (already generated for the user):\n \n SLEEP\n %s\n \n YESTERDAY\n %s\n \n RECOVERY\n %s" ,
243264 sleepText , yesterdayText , recoveryText )
244265 if len (verdictHistory ) > 0 {
245266 leafSummary += "\n \n ENERGYBANK_VERDICT_HISTORY (oldest→newest, last 7 EOD snapshots): " +
246267 strings .Join (verdictHistory , ", " )
247268 }
269+ if ctx .AIAdviceMode != "" {
270+ leafSummary += "\n \n READINESS_EVIDENCE_CONTEXT: advice_mode=" + ctx .AIAdviceMode +
271+ fmt .Sprintf (", readiness=%d, raw_readiness=%d, confidence=%s, cap_reason=%s" ,
272+ ctx .ReadinessScore , ctx .ReadinessRawScore , ctx .ReadinessConfidence , ctx .ReadinessCapReason )
273+ if ctx .CheckinStatus != "" || ctx .CheckinAnswer != "" {
274+ leafSummary += fmt .Sprintf (", checkin_status=%s, checkin_answer=%s" , ctx .CheckinStatus , ctx .CheckinAnswer )
275+ }
276+ }
277+ switch ctx .AIAdviceMode {
278+ case "withheld" :
279+ leafSummary += "\n INSTRUCTION: Do not provide a training or recovery recommendation. Explain briefly that the system is waiting for today's recovery data."
280+ case "provisional_explanation_only" , "needs_regeneration_after_sync" :
281+ leafSummary += "\n INSTRUCTION: Treat today's readiness as provisional. Explain what is known, what is missing, and how the user's check-in aligns or conflicts with objective signals. Do NOT give confident push-hard advice."
282+ }
248283 // v2.2 §4.3 hard guard for the recommendation prose — the model
249284 // has a known failure mode where verdict=active_recovery but the
250285 // prose still says "you can push if you feel up to it". When
@@ -264,6 +299,5 @@ func GenerateRecommendation(apiKey, model string, maxTokens int, rawMetricsJSON
264299 leafSummary += "\n \n STRESS_FLAGS (active multi-channel signals): " +
265300 strings .Join (stressFlags , ", " )
266301 }
267- text , _ , err := generateWithPrompt (apiKey , model , maxTokens , prompt , append (rawMetricsJSON , []byte (leafSummary )... ), lang )
268- return text , err
302+ return leafSummary
269303}
0 commit comments