
    !	0jR              
       :   S r SSKrSSKrSSKrSSKrSSKrSSKrSSKJ	r	JrJ
r
  \R                  " S5      r\R                  R                  \R                  R                  \R                  R!                  \5      5      SS5      r\R&                  " S\5      r\R                  R                  \S5      rSrS	r\R&                  " S
S5      r\R&                  " SS5      r\R&                  " SS5      rSr\R&                  " S\5      rSrSrSrSr Sr!Sr"\#" \R&                  " SS5      5      r$\#" \R&                  " SS5      5      r%S\&4S jr'S\(S-  4S jr)SAS\(S\(S\*S\+\,   4S  jjr-S!\,S\.4S" jr/S#\+\#   S\#S-  4S$ jr0S%\+\,   S\,4S& jr1S%\+\,   S'\(S(\+\(   4S) jr2S%\+\,   S'\(S(\+\(   S\+\#   4S* jr3SBS+\+\#   4S, jjr4S\(S-  4S- jr5S%\+\,   4S. jr6S\,S-  4S/ jr7S\,S-  4S0 jr8S%\+\,   S'\(S(\+\(   S\,4S1 jr9S%\+\,   S\,4S2 jr:SCS3\(S4\(S\(4S5 jjr;S\(4S6 jr<S7S8.S\(4S9 jjr=S\(4S: jr>SDS; jr?\@S<:X  ae  SSKArAS=\AR                  ;   a  \?" 5         gS>\AR                  ;   a"  \R                  " \R                  S?9  \7" 5         g\E" \>" 5       =(       d    S@5        gg)EuY  Google Health API → Reflect health feed.

Pulls the wearer's recent sleep, HRV, resting heart rate and SpO2 from the
**Google Health API** (the successor to the Fitbit Web API, which is retired in
September 2026) and distils it into a short, plain-English block the therapist
reads at the start of a session — so Reflect can open with "you slept rough and
your HRV's down, how are you doing?" rather than starting blind.

Design goals, mirroring therapist.py's memory blocks:
  * NEVER breaks the session. Every failure path returns "" / a cached value.
  * Reads from a local cache (`health_snapshot.json`) so building the agent's
    instructions never blocks on the network. A separate refresh writes it.

Auth: standard OAuth 2.0. You authorise once in a browser (see oauth_setup.py),
store the refresh token as a Fly secret, and this module silently exchanges it
for short-lived access tokens server-side.

Field names and data-type IDs below were confirmed live against James's own
Fitbit Air account (June 2026). Data-type IDs are kebab-case. Re-probe any time:
  `uv run python -m src.health --probe`
    N)datedatetimetimezonehealthz..R1_DATA_DIRzhealth_snapshot.jsonz#https://oauth2.googleapis.com/tokenz https://health.googleapis.com/v4GOOGLE_HEALTH_CLIENT_ID GOOGLE_HEALTH_CLIENT_SECRETGOOGLE_HEALTH_REFRESH_TOKENzhttps://www.googleapis.com/auth/googlehealth.sleep.readonly https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonlyGOOGLE_HEALTH_SCOPESsleepzdaily-heart-rate-variabilityzdaily-resting-heart-ratezdaily-oxygen-saturationz#daily-sleep-temperature-derivationszdaily-respiratory-rateGH_HTTP_TIMEOUT8GH_SNAPSHOT_MAX_AGE_H18returnc                  T    [        [        =(       a    [        =(       a    [        5      $ N)bool	CLIENT_IDCLIENT_SECRETREFRESH_TOKEN     /app/agent/src/health.pyis_configuredr   C   s    	=m=>>r   c                  *   [        5       (       d  g[        R                  R                  [        [
        [        SS.5      R                  5       n  [        R                  R                  [        U S9n[        R                  R                  U[        S9 n[        R                  " UR                  5       R!                  5       5      R#                  S5      sSSS5        $ ! , (       d  f       g= f! [$         a    [&        R)                  S5         gf = f)zEExchange the long-lived refresh token for a short-lived access token.Nrefresh_token)	client_idclient_secretr   
grant_type)datatimeoutaccess_tokenz#Google Health: token refresh failed)r   urllibparse	urlencoder   r   r   encoderequestRequest	TOKEN_URLurlopenHTTP_TIMEOUTjsonloadsreaddecodeget	Exceptionlogger	exception)r"   reqresps      r   _get_access_tokenr9   J   s    ??<<!!"**)		
 fh 	nn$$YT$:^^##C#>$::diik002377G ?>> >?s2   AC0 AC	C0 
C-)C0 -C0 0DD	data_typetoken	page_sizec                    [          SU  S[        R                  R                  SU05       3n [        R                  R                  USSU 30S9n[        R                  R                  U[        S9 n[        R                  " UR                  5       R                  5       5      R                  S/ 5      =(       d    / sS	S	S	5        $ ! , (       d  f       g	= f! [         a    [        R                  S
U 5        / s $ f = f)zIList recent data points for a data type (newest first, no filter needed).z/users/me/dataTypes/z/dataPoints?pageSizeAuthorizationzBearer )headersr#   
dataPointsNz"Google Health: fetch for %s failed)API_BASEr&   r'   r(   r*   r+   r-   r.   r/   r0   r1   r2   r3   r4   r5   r6   )r:   r;   r<   urlr7   r8   s         r   _list_pointsrD   _   s     *( 4LL""J	#:;<	> nn$$S?geWDU2V$W^^##C#>$::diik002377bIOR ?>> =yI	s1   AC 4AC	?	C 	
CC C "C?>C?dc                 l    U R                  SS5      U R                  SS5      U R                  SS5      4$ )Nyearr   monthdayr3   )rE   s    r   	_date_keyrK   q   s/    EE&!aeeGQ/uaAAr   valuesc                     U  Vs/ s H"  n[        U[        [        45      (       d  M   UPM$     nnU(       a!  [        [	        U5      [        U5      -  S5      $ S $ s  snf )N   )
isinstanceintfloatroundsumlen)rL   vnumss      r   _meanrW   u   sI    =v!AU|!<AvD=.25TSY&*<< >s
   AApointsc           
      r   U  Vs/ s H.  n[        UR                  S5      [        5      (       d  M)  US   PM0     nnU(       d  0 $ [        US S9nUR                  S0 5      nUR                  S/ 5       Vs0 s H.  nUR                  S5      [	        UR                  SS5      5      _M0     nnS	 nUR                  S
0 5      R                  S5      U" UR                  S5      5      U" UR                  S5      5      UR                  S5      UR                  S5      UR                  S5      S.$ s  snf s  snf )z9Latest sleep session: minutes asleep + per-stage minutes.r   c                 F    U R                  S0 5      R                  SS5      $ )NintervalendTimer	   rJ   )ss    r   <lambda>_parse_sleep.<locals>.<lambda>   s    z2)>)B)B9b)Qr   )keysummarystagesSummarytypeminutesr   c                 F     [        U 5      $ ! [        [        4 a     g f = fr   )rP   	TypeError
ValueError)rU   s    r   _i_parse_sleep.<locals>._i   s'    	q6M:& 		s   
   r[   r\   minutesAsleepminutesAwakeDEEPREMLIGHT)end_time
asleep_min	awake_mindeep_minrem_min	light_min)rO   r3   dictmaxrP   )rX   psessionslatestra   r]   stagesrh   s           r   _parse_sleepr{   z   s   $*OFqjw.N
'
FHO	QRFjjB'G _b11A 	
fs155A.//1  
 JJz2.229=_56N34JJv&::e$ZZ( ! P
s   (D/	D/65D4r`   
value_pathc           	         / nU  H  nUR                  U5      n[        U[        5      (       d  M+  UnU H+  n[        U[        5      (       a  UR                  U5      OSnM-     Uc  Mc   [        U5      nUR                  [        UR                  S0 5      5      U45        M     U(       d  gUR                  5         US   S   n[        USS  V	V
s/ s H  u  pU
PM	     sn
n	5      nX4$ ! [        [
        4 a     M  f = fs  sn
n	f )zIReturn (latest_value, recent_average) for a daily metric, latest by date.Nr   NNrN   i)
r3   rO   ru   rQ   rf   rg   appendrK   sortrW   )rX   r`   r|   rowsrw   nodevalsegry   _rU   baselines               r   _parse_dailyr      s    DuuSz$%%C",S$"7"7#''#,TC ;	*C 	Ytxx34c:;  IIK"Xa[FDBK0KDAaK01H :& 		 1s   )C*D
*C>=C>c                     [        XU5      n[        UR                  5       5       VVs/ s H  u  pEUPM	     snn$ s  snnf )zFAscending-by-date list of values for a daily metric (for trend maths).)_daily_by_datesorteditems)rX   r`   r|   by_dater   rU   s         r   _seriesr      s5    V*5G 121$!A1222s   ;valsc                 n    [        U 5      US-   :  a  g[        X* S 5      n[        XU-   * U*  5      nX44$ )z>(mean of last `recent_n` days, mean of the ~week before that).   r~   N)rT   rW   )r   recent_nprior_nrecentpriors        r   _recent_vs_priorr      sG    
4y8a<4	
#$F$G+,hY78E=r   c                 ^   / n[        U 5      u  pVU(       a   U(       a  XV-
  S:  a  UR                  S5        [        U5      u  pxU(       a#  U(       a  X-
  U-  S:  a  UR                  S5        U(       a2  [        USS 5      b#  [        USS 5      S:  a  UR                  S5        [        U5      u  pU	(       a   U
(       a  X-
  S	:  a  UR                  S
5        U(       d  g[        U5      S:X  a  US   OSR	                  USS 5      S-   US   -   n[        U5      S:  a  SU S3$ SU S3$ )ae  Look across the last few days for signals drifting the 'unwell/strained'
way. Returns a short heads-up sentence, or None if nothing stands out.

The illness/strain cluster: resting HR up, HRV down, skin temp above
baseline, breathing rate up. One signal is soft; two or more together is
worth flagging as a possible early sign of getting run down or unwell.g      @z5resting heart rate has been running higher than usualgQ?zHRV has been lower than usualNg333333?z-skin temperature has been above your baselineg      ?zbreathing rate has crept uprN   r   , r   z and r   u1   EARLY-WARNING PATTERN over the last few days — u  . Several signals are pointing the same way, which can mean they're getting run down, overstrained, or coming down with something. Hold this gently and, if it fits, check in on how they've been feeling and whether they need to ease off — do not alarm them or diagnose.z+One thing drifting over the last few days: z6. Worth keeping in mind as a soft signal, not a worry.)r   r   rW   rT   join)rhrhrv
temp_deltar8   signalsr_recentr_priorh_recenth_priorp_recentp_priorjoineds               r   _assess_trendsr      s<    G(-HG 2c 9NO(-HG!3w >$ F67 eJrsO,8U:bc?=SWZ=ZFG(.HG 2c 945w<1,WQZ		'#2,')GBK7  7|qCF8 LS S 	T
 :& B2 2 3r   c           	         / nU  H  nUR                  S5      n[        U[        5      (       d  M+  UR                  S5      nUR                  S5      n[        U[        [        45      (       a  [        U[        [        45      (       d  M  UR                  [        UR                  S0 5      5      [        XE-
  S5      45        M     U(       d  S/ 4$ UR                  5         US   S   U VVs/ s H  u  pgUPM	     snn4$ s  snnf )	uJ   (latest nightly−baseline delta in °C, ascending list of recent deltas). dailySleepTemperatureDerivationsnightlyTemperatureCelsiusbaselineTemperatureCelsiusr   r   Nr   rN   )	r3   rO   ru   rP   rQ   r   rK   rR   r   )rX   r   rw   r   nightlybaser   rU   s           r   _parse_tempr      s    Duu78$%%((67xx45'C<00
4#u8V8VYtxx34eGNA6NOP  RxIIK8A;t,ttqt,,,,s   -D c                     [        5       n U (       d  g[        [        U 5      n[        [        U 5      n[        [        U 5      n[        [
        U 5      n[        [        [        U SS95      n[        USS/5      u  pg[        USS/5      u  p[        [        [        U 5      SS	/5      u  p[        US
S/5      u  p[        U5      u  p[        [        USS/5      [        USS/5      U[        US
S/5      5      n[        R                  " [        R                   5      R#                  S5      [$        R$                  " 5       UUUUU	U
UUUUS.n ['        [(        S5       n[*        R,                  " UUSS9  SSS5        [.        R1                  S5        U$ ! , (       d  f       N%= f! [2         a    [.        R5                  S5         U$ f = f)zPull recent metrics and write health_snapshot.json. Returns the snapshot,
or None if not configured / fetch failed (the old cache is then kept).N   r<   dailyHeartRateVariability'averageHeartRateVariabilityMillisecondsdailyRestingHeartRatebeatsPerMinutedailyOxygenSaturationaveragePercentagedailyRespiratoryRatebreathsPerMinutez%Y-%m-%d %H:%M UTC)
fetched_attsr   hrv_mshrv_baseline_ms
resting_hrresting_hr_baselinespo2_pct	resp_rateresp_baselinetemp_delta_cheads_upwr   indentz!Google Health: snapshot refreshedz'Google Health: failed to write snapshot)r9   rD   DT_HRVDT_RHRDT_RESPDT_TEMPr{   DT_SLEEPr   DT_SPO2r   r   r   r   nowr   utcstrftimetimeopenSNAPSHOT_FILEr/   dumpr5   infor4   r6   )r;   hrv_ptsrhr_ptsresp_ptstemp_ptsr   r   hrv_baser   rhr_basespo2r   r8   	resp_baser   temp_seriesr   snapshotfs                      r   refresh_snapshotr      s    E65)G65)GGU+HGU+HhCDE ,	23MC !(+;*<MC We$&=@S?TGD #(+=*>OD *(3J03C2DE4:;	=03E2FGH ll8<<099:NOiik#'""HD-%IIh!, &78 O &%  DBCODs*   F# F3F# 
F F# #GGc                      [         R                  R                  [        5      (       d  g  [	        [        5       n [
        R                  " U 5      sS S S 5        $ ! , (       d  f       g = f! [         a     g f = fr   )ospathexistsr   r   r/   loadr4   )r   s    r   _load_snapshotr   9  sO    77>>-((- A99Q< !   s.   A+ A	A+ 
A($A+ (A+ +
A87A8c                    0 nU  H  nUR                  U5      n[        U[        5      (       d  M+  UR                  S0 5      n [        US   US   US   5      R	                  5       nUnU H+  n	[        U[        5      (       a  UR                  U	5      OSnM-     Uc  M   [        U5      X7'   M     U$ ! [
        [        [        4 a     M  f = f! [        [        4 a     M  f = f)z+Map {YYYY-MM-DD: value} for a daily metric.r   rG   rH   rI   N)	r3   rO   ru   r   	isoformatKeyErrorrf   rg   rQ   )
rX   r`   r|   outrw   r   rE   rI   r   r   s
             r   r   r   F  s    
CuuSz$%%HHVR 	qy!G*ah7AACC C",S$"7"7#''#,TC ;	SzCH $ J )Z0 		 :& 		s$   $B3 C3CCC#"C#c                 8   0 nU  H  nUR                  S5      n[        U[        5      (       d  M+  UR                  S0 5      R                  SS5      nUSS nU(       d  M[  UR                  S0 5      nUR                  S/ 5       Vs0 s H.  nUR                  S	5      [        UR                  S
S5      5      _M0     nn [        UR                  S5      5      n	U	(       d  M  XQ;  d  XU   S   :  d  M  XS.X'   M     U$ s  snf ! [        [
        4 a    Sn	 N@f = f)zEMap {YYYY-MM-DD (morning it ended): summary dict} for sleep sessions.r   r[   r\   r	   N
   ra   rb   rc   rd   r   rj   rp   )rp   rz   )r3   rO   ru   rP   rf   rg   )
rX   r   rw   r]   endrI   ra   xrz   asleeps
             r   _sleep_by_dater   ^  s   
CEE'N!T""eeJ#''	26#2h%%	2&";;;=;a %%-QUU9a%8!99; 	 =	_56F 6s~c(<2H)H&,?CH# $ J= :& 	F	s   5C?>DDD
start_dateend_datec                 (  ^ [        5       nU(       d  g [        R                  " U S5      R                  5       n[        R                  " U=(       d    U S5      R                  5       nXC:  a  XCpC[        [        [        USS95      n[        [        [        US5      SS/5      n[        [        [        US5      SS	/5      n[        [        [        US5      S
S/5      n[        UR                  5       UR                  5       S-   5       V	s/ s H  oR                  U	5      PM     n
n	/ nU
 GH  nUR                  5       n/ nX;   aT  X]   S   mSR!                  U4S jS 5       5      nUR#                  S[%        X]   S   5       3U(       a  SU S3OS-   5        X;   a  UR#                  SXm   S S35        X;   a   UR#                  S['        X}   5       S35        X;   a  UR#                  SX   S S35        U(       d  M  UR)                  S5      nUR#                  U S3SR!                  U5      -   S -   5        GM     U(       d  S!X4:X  a  S" S$3$ S# S$3$ [+        U5      S:X  a  SOS%nUS&R!                  U5      -   $ ! [         a     gf = fs  sn	f )'zPlain-English summary of sleep + daily vitals for a past date or range.
Dates are 'YYYY-MM-DD'. Returns a sentence the therapist reads aloud.z(I can't reach your health data just now.z%Y-%m-%dz3I need the date in YYYY-MM-DD form to look that up.x   r   r   r   r   r   r   r   rN   rz   r   c              3      >#    U  H@  u  pTR                  U5      (       d  M  UR                  5        S [        TU   5       3v   MB     g7f) N)r3   lower_fmt_dur).0lbltsts      r   	<genexpr>"summarise_range.<locals>.<genexpr>  sA      "TFC66!9 3399;-q"Q% 12Ts
   A(A))deeprl   )rm   rm   )lightrn   zslept rp    ()r	   zHRV g mszresting HR  bpmzblood oxygen %z	%A %-d %Bz: z; .zNo Fitbit data is recorded for zthat dayzthat periodu0    — the watch may not have been worn or synced.z!Here's what the Fitbit recorded:

)r9   r   strptimer   rg   r   rD   r   r   r   r   r   range	toordinalfromordinalr   r   r   r   rP   r   rT   )r   r   r;   r]   er   r   r   r   odayslinesrE   isobits	stage_strlabelheaderr   s                     @r   summarise_ranger  v  s    E9Ej*5::<h4*jAFFH 	u1 <%3GHE
feS94CDFC feS903C2DFC,ws;14G3HJD ',AKKM1;;=1;L&MN&MMM!&MDNEkkm<H%B		 "T" I
 KK&%*\*B!C DE09R	{!,rC D:KK$sxl#./:KK+c#(m_D9:;KK-	!}A674JJ{+ELLE7"		$7#=>) , 1!":; <,, 	--:; <,, 	- u:?R*LFDIIe$$$Y  EDE Os   AI? J?
JJc                 j    U (       d  g[        [        U 5      S5      u  pU(       a	  U SUS S3$ U S3$ )Nr	   <   zh 02dm)divmodrP   )rd   hr  s      r   r   r     s<    #g,#DA aS1S'-1g-r   Flower_is_betterc                    U (       a  U(       d  gX-
  U-  n[        U5      S:  a  SUS S3$ US:  a  SOSnS	U S
US S3$ )z@A gentle 'vs usual' tag, only when the gap is meaningful (>10%).r	   g?z (about your usual ~r  r  r   zup onzdown onr  z your recent ~)abs)valuer   r  delta	directions        r   _trendr$    sX    )E
5zC%hq\33 19)I	{.!A66r   c            	          [        5       n U (       d  g[        R                  " 5       U R                  SS5      -
  S-  [        :  a  gU R                  S5      =(       d    0 n/ nUR                  S5      (       a  / nS H>  u  pEUR                  U5      (       d  M  UR	                  U S[        X   5       35        M@     UR                  S	5      (       a   UR	                  S
[        US	   5       35        U(       a  SSR                  U5       S3OSnUR	                  S[        US   5       SU S35        U R                  S5      (       a;  UR	                  SU S   S S3[        U S   U R                  S5      5      -   S-   5        U R                  S5      (       aB  UR	                  S[        U S   5       S3[        U S   U R                  S5      SS9-   S-   5        U R                  S5      (       a;  UR	                  SU S   S S3[        U S   U R                  S5      5      -   S-   5        U R                  S 5      bJ  U S    n[        U5      S!:  a  UR	                  S"5        O$UR	                  S#US$ S%3US&:  a  S'OS-   S-   5        U R                  S(5      (       a  UR	                  S)U S(   S S*35        U(       d  gS+nUS,-   SR                  U5      -   n	U R                  S-5      (       a  U	S,U S-   -   -  n	U	$ ).zPlain-English read of the wearer's recent body data for the system prompt.
Returns "" when there's nothing usable, so Reflect is wholly unaffected.r	   r   r   i  r   rp   ))r   rr   )rm   rs   )r   rt   r   rq   zawake r  r   r  zSlept z last nightr  r   zOvernight HRV r  r  r   r   zResting heart rate r  r   Tr  r   zBreathing rate z/minr   r   g?z+Skin temperature about your usual baseline.zSkin temperature z+.1fu    °C vs your baselineg?z (notably warm)r   zBlood oxygen z%.u  RECENT BODY DATA from their Fitbit. Two ways to use it:
1) If they ASK directly about their sleep, heart rate, HRV, blood oxygen or recovery, answer the specific question plainly and accurately FIRST — give the actual figure (e.g. 'your resting heart rate was 73, right around your usual') — then you can reflect on what it means.
2) Otherwise, use it like a perceptive friend who can tell they're run-down, NOT a dashboard: weave it in gently and only if it fits, without reciting numbers unprompted. Lower HRV, a higher resting heart rate, short/broken sleep, a raised breathing rate or a skin temperature above their baseline usually mean they're tired, depleted, more stressed or possibly coming down with something; the opposite means well-rested.
Below are last night's sleep and today's latest daily readings. For any OTHER date or a multi-day trend, use your health-lookup tool. You do NOT have live/right-now readings, so say so plainly if asked. Never diagnose — you notice and gently reflect, you don't medically assess.
The readings:r  r   )
r   r   r3   SNAPSHOT_MAX_AGE_Hr   r   r   r$  rP   r   )
snapr   partsrz   r  r`   r  rE   guidanceblocks
             r   format_health_blockr+    s    D		dhhtQ''4/2DDHHW#EEyy\JEyy~~q%*)=(>?@ ] 99[!!MMF8E+,>#?"@AB17b6*+1-R	vhu\':;<K	{RSTUxxT(^A.c2T(^TXX.?%@ABDGH	
 xx!#d<&8"9!:$?T,'2G)H%)++-01	

 xxd;/2$7T+&(ABCEHI	
 xx+ q6C<LLFGLL,QtH4IJ12c-rCEHI Jxx
}T*%5a$8;<	 $ tOchhuo-Exx
Z(((Lr   c                  "   [         R                  " [         R                  S9  [        5       (       d  [	        S5        g [	        S5        [        5       n [	        [        R                  " U SS95        [	        S[        5       =(       d    S-   5        g )Nlevelz\Not configured. Set GOOGLE_HEALTH_CLIENT_ID / _SECRET / _REFRESH_TOKEN (see oauth_setup.py).z*Refreshing snapshot from Google Health...
r   r   z#
Formatted block Reflect will see:
z(empty))	loggingbasicConfigINFOr   printr   r/   dumpsr+  )r'  s    r   _prober4    sf    gll+?? 5 	6	
78D	$**T!
$%	
15H5J5Wi
XYr   __main__z--probez	--refreshr-  z(no health block))   )      )r	   )r   N)F__doc__r/   r/  r   r   urllib.parser&   urllib.requestr   r   r   	getLoggerr5   r   r   dirnameabspath__file__PROJECT_ROOTgetenvDATA_DIRr   r,   rB   r   r   r   DEFAULT_SCOPESSCOPESr   r   r   r   r   r   rQ   r.   r&  r   r   strr9   rP   listru   rD   tuplerK   rW   r{   r   r   r   r   r   r   r   r   r   r  r   r$  r+  r4  __name__sysargvr0  r1  r2  r   r   r   <module>rK     sC  ,   	    - -			8	$ww||BGGOOBGGOOH,EFdS99]L1X'=> 2	-II/4			7<		7<Q 
 
)>	: 	'	#
#
/
"RYY0#67299%<dCD ?t ?3: *C   T$Z $B B% B=$u+ =%$, =
d  8d # 49 83DJ 3S 3d3i 3DK 34; %3#* %3V-T
 -$8$+ 8vt 4: C T#Y 4 04: $ 05% 5%s 5%C 5%v. . 05 7 7HS H\	Z zCHH		 ',,/!#:':; r   