-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathfsapp.py
More file actions
1531 lines (1335 loc) · 69.5 KB
/
Copy pathfsapp.py
File metadata and controls
1531 lines (1335 loc) · 69.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Self-Evolving HealthClaw — 飞书 Bot 前端
双模式:WebSocket 事件推送 + API 轮询回退。
启动: python fsapp.py
"""
import os, sys, threading, time, re, json, glob, urllib.request
import importlib.util
import queue as Q
from PIL import Image
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, PROJECT_ROOT)
os.chdir(PROJECT_ROOT)
os.environ.setdefault("HEALTHCLAW_ENABLE_PHONE_MEITUAN", "1")
from cross_device_node import CrossDeviceServer
from meal_plan_service import MealPlanService, detect_meal_plan_action
def _load_mykey_module():
try:
import mykey as local_mykey
return local_mykey
except ImportError:
pass
candidates = []
explicit_path = str(os.environ.get("HEALTHCLAW_MYKEY_PATH", "") or "").strip()
explicit_dir = str(os.environ.get("HEALTHCLAW_MYKEY_DIR", "") or "").strip()
shared_home = str(os.environ.get("HEALTHCLAW_SHARED_HOME", "") or "").strip()
repo_parent = os.path.abspath(os.path.join(PROJECT_ROOT, ".."))
if explicit_path:
candidates.append(explicit_path)
if explicit_dir:
candidates.append(os.path.join(explicit_dir, "mykey.py"))
if shared_home:
candidates.append(os.path.join(shared_home, "mykey.py"))
candidates.extend(
[
os.path.join(repo_parent, "openclaw", "mykey.py"),
os.path.join(repo_parent, "healthclaw_shared", "mykey.py"),
]
)
for path in candidates:
if not path or not os.path.exists(path):
continue
try:
spec = importlib.util.spec_from_file_location("healthclaw_shared_mykey", path)
if spec and spec.loader:
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
except Exception as exc:
print(f"[HealthClaw] 加载共享 mykey 失败: {path} -> {exc}")
return None
mykey = _load_mykey_module()
_TAG_PATS = [r'<' + t + r'>.*?</' + t + r'>' for t in ('thinking', 'summary', 'tool_use', 'file_content')]
def _clean(text):
for p in _TAG_PATS:
text = re.sub(p, '', text, flags=re.DOTALL)
return re.sub(r'\n{3,}', '\n\n', text).strip() or '...'
APP_ID = getattr(mykey, 'fs_app_id', None)
APP_SECRET = getattr(mykey, 'fs_app_secret', None)
ALLOWED_USERS = set(getattr(mykey, 'fs_allowed_users', []))
EXTERNAL_ALERT_HOST = getattr(mykey, 'fs_external_alert_host', '127.0.0.1')
EXTERNAL_ALERT_PORT = int(getattr(mykey, 'fs_external_alert_port', 8787))
EXTERNAL_ALERT_TOKEN = str(getattr(mykey, 'fs_external_alert_token', '') or '')
EXTERNAL_ALERT_TARGETS = dict(getattr(mykey, 'fs_external_alert_targets', {}) or {})
ENABLE_EXTERNAL_ALERT_SERVER = bool(getattr(mykey, 'fs_enable_external_alert_server', True))
ENABLE_ALERT_EXPLAINER = bool(getattr(mykey, 'fs_enable_alert_explainer', True))
NODE_ID = str(getattr(mykey, 'cross_device_node_id', 'child_01') or 'child_01')
agent = None
agent_init_error = ""
meal_plan_service = MealPlanService()
client = None
user_tasks = {}
_ws_event_received = False
external_alert_server = None
meal_plan_scheduler = None
_alert_llm_state = {
"auth_invalid": False,
"last_error": "",
"probe_done": False,
}
def _startup_locale():
return "en" if str(os.environ.get("HEALTHCLAW_DEMO_LOCALE", "") or "").strip().lower().startswith("en") else "zh"
def _startup_online_text(polling=False):
if _startup_locale() == "en":
if polling:
return "🩺 HealthClaw is online (polling mode). You can start chatting now!"
return "🩺 HealthClaw is online. You can start chatting now!"
if polling:
return "🩺 HealthClaw 已上线(轮询模式),可以开始对话!"
return "🩺 HealthClaw 已上线,可以开始对话!"
def _feishu_application_lang():
return "en_us" if _startup_locale() == "en" else "zh_cn"
def ensure_agent_ready():
global agent, agent_init_error
if agent is not None:
return agent
if agent_init_error:
raise RuntimeError(agent_init_error)
try:
from seda_main import DiagnosisAgent
except Exception as exc:
agent_init_error = f"DiagnosisAgent unavailable: {exc}"
raise RuntimeError(agent_init_error) from exc
agent = DiagnosisAgent()
threading.Thread(target=agent.run, daemon=True).start()
return agent
def _coerce_int(value, default=0):
try:
return int(value)
except (TypeError, ValueError):
return default
def _coerce_float(value, default=0.0):
try:
return float(value)
except (TypeError, ValueError):
return default
def _strip_protocol_tags(text):
if not text:
return ""
for tag in ("thinking", "summary", "tool_use", "tool_result", "file_content"):
text = re.sub(rf"<{tag}>.*?</{tag}>", "", text, flags=re.DOTALL)
text = re.sub(r"^```(?:markdown|md|text)?\s*", "", text.strip(), flags=re.IGNORECASE)
text = re.sub(r"\s*```$", "", text.strip())
return text.strip()
def _looks_like_auth_error(text):
lowered = str(text or "").lower()
return any(
token in lowered
for token in [
"invalid api key",
"invalid_api_key",
"unauthorized",
"401",
"incorrect api key",
]
)
def _record_alert_llm_error(error_text):
if not error_text:
return
_alert_llm_state["last_error"] = str(error_text)
if _looks_like_auth_error(error_text):
_alert_llm_state["auth_invalid"] = True
def _probe_alert_llm_auth_once():
if _alert_llm_state.get("probe_done"):
return
_alert_llm_state["probe_done"] = True
cfg = getattr(mykey, "oai_config", {}) or {}
api_base = str(cfg.get("apibase", "") or "").strip().rstrip("/")
api_key = str(cfg.get("apikey", "") or "").strip()
if not api_base or not api_key:
return
try:
import requests
response = requests.get(
f"{api_base}/v1/models",
headers={"Authorization": f"Bearer {api_key}"},
timeout=15,
)
if response.status_code == 401:
body = response.text or ""
if _looks_like_auth_error(body) or "INVALID_API_KEY" in body:
_record_alert_llm_error(body or "401 Unauthorized")
except Exception:
return
def _build_direct_fallback_result(alert, error_text="", source="fallback"):
if error_text:
_record_alert_llm_error(error_text)
return {
"status": "fallback",
"source": source,
"markdown": build_fallback_alert_explanation(alert),
"error": error_text,
"error_kind": "auth_invalid" if _looks_like_auth_error(error_text) else "llm_unavailable",
"model": "",
}
def _alert_locale(alert):
if not isinstance(alert, dict):
return "zh"
locale = alert.get("locale")
if not locale and isinstance(alert.get("details"), dict):
locale = alert["details"].get("locale")
return "en" if str(locale or "").strip().lower().startswith("en") else "zh"
def build_alert_explanation_prompt(alert):
locale = _alert_locale(alert)
alert_json = json.dumps(alert, ensure_ascii=False, indent=2)
location_text = _extract_alert_location_text(alert)
if locale == "en":
location_hint = ""
if location_text:
location_hint = (
f"9. If the alert already includes a location (currently: {location_text}), "
"remind the family to contact the closest available person near that location first.\n\n"
)
return (
"You are HealthClaw's caregiver alert explainer. "
"Your job is not to decide whether the alert is valid again, but to explain an already-triggered alert to a family member without a medical background.\n\n"
"Requirements:\n"
"1. Explain only from the provided alert data. Do not invent vitals, location details, history, or test results.\n"
"2. Use plain English and avoid heavy medical jargon.\n"
"3. Do not give a definitive diagnosis. Use language such as 'may', 'could', 'worth watching', or 'should be confirmed'.\n"
"4. Output Markdown and include exactly these 4 headings:\n"
"**What This Means**\n"
"**Possible Reasons**\n"
"**What To Do Now**\n"
"**When To Call Emergency Help**\n"
"5. Under each heading, write 2-4 short bullet points.\n"
"6. If the data is limited, explicitly say that the alert alone is not enough to determine the cause.\n"
"7. For high heart rate alerts, remind the family to distinguish a brief post-activity spike from a sustained resting elevation.\n\n"
"8. Alerts may come from wearable heart rate, oxygen, fall, rhythm, temperature, or activity signals, so the explanation should match the signal type.\n\n"
f"{location_hint}"
"Alert data:\n"
f"```json\n{alert_json}\n```"
)
location_hint = ""
if location_text:
location_hint = (
f"9. 如果告警里已经给出位置(当前为:{location_text}),"
"要提醒家属可以优先联系离该位置最近的人前往查看。\n\n"
)
return (
"你是 HealthClaw 的家庭照护告警解释器。"
"你的任务不是重新判断是否报警,而是把已经触发的告警解释给非医学背景家属听。\n\n"
"要求:\n"
"1. 只依据提供的告警数据解释,不要编造未提供的生命体征、定位、病史或检查结果。\n"
"2. 用简体中文,语言直白,不使用过多专业术语。\n"
"3. 不要给出确定诊断,只能说“可能”“需要警惕”“建议确认”。\n"
"4. 输出 Markdown,并且严格包含以下 4 个标题:\n"
"**这代表什么**\n"
"**可能原因**\n"
"**建议马上做什么**\n"
"**何时立刻急救**\n"
"5. 每个标题下给 2-4 条简短要点。\n"
"6. 如果数据不足,要明确写出“单靠这条告警不足以判断病因”。\n"
"7. 如果是心率升高类告警,要提醒家属区分“活动后短暂升高”和“静息状态持续升高”。\n\n"
"8. 告警可能来自可穿戴设备的心率、血氧、跌倒、心律不齐、体温、活动量等信号,要结合对应信号解释风险。\n\n"
f"{location_hint}"
"告警数据如下:\n"
f"```json\n{alert_json}\n```"
)
def _build_fallback_alert_explanation_en(alert):
event_type = str(alert.get("event_type", "external_alert") or "external_alert")
details = alert.get("details") if isinstance(alert.get("details"), dict) else {}
heart_rate = _coerce_int(details.get("heart_rate", alert.get("heart_rate")), 0)
duration_sec = _coerce_int(details.get("duration_sec", alert.get("duration_sec")), 0)
threshold = _coerce_int((details.get("rule") or {}).get("threshold"), 0)
spo2 = _coerce_int(details.get("spo2", alert.get("spo2")), 0)
impact_g = _coerce_float(details.get("impact_g", alert.get("impact_g")), 0.0)
no_movement_sec = _coerce_int(details.get("no_movement_sec", alert.get("no_movement_sec")), 0)
episode_count = _coerce_int(details.get("episode_count", alert.get("episode_count")), 0)
resting_heart_rate = _coerce_int(details.get("resting_heart_rate", alert.get("resting_heart_rate")), 0)
body_temperature = _coerce_float(details.get("body_temperature", alert.get("body_temperature")), 0.0)
inactivity_minutes = _coerce_int(details.get("inactivity_minutes", alert.get("inactivity_minutes")), 0)
threshold_minutes = _coerce_int((details.get("rule") or {}).get("threshold_minutes"), 0)
classification = str(details.get("classification", "") or "")
if event_type == "heart_rate_alert":
threshold_text = f"{threshold} bpm" if threshold > 0 else "the configured threshold"
return "\n".join(
[
"**What This Means**",
f"- The wearable detected a heart rate around {heart_rate} bpm for about {duration_sec} seconds, which is above the alert rule.",
"- That means the older adult is worth checking on soon, but this alert alone is not enough to identify the exact cause.",
"- A sustained elevation at rest is usually more concerning than a brief spike right after activity.",
"",
"**Possible Reasons**",
"- This could reflect recent movement, stress, pain, fever, dehydration, or a loose wearable reading.",
f"- It could also reflect an arrhythmia or other acute discomfort, but the alert alone cannot confirm that and only shows a sustained rate above {threshold_text}.",
"",
"**What To Do Now**",
"- Contact the older adult and confirm they are awake, speaking normally, and not alone.",
"- Ask about chest discomfort, shortness of breath, dizziness, palpitations, a fall, or clear weakness.",
"- If possible, repeat the heart rate check after a short rest and see whether it comes down quickly.",
"",
"**When To Call Emergency Help**",
"- Call emergency help right away if the alert comes with chest pain, severe shortness of breath, fainting, confusion, or one-sided weakness.",
"- If the heart rate stays very high at rest and does not come down on repeat checks, urgent in-person care is also appropriate.",
"- If the device keeps sending high-risk alerts and the family cannot reach the older adult, treat it as an emergency.",
]
)
if event_type == "blood_oxygen_alert":
return "\n".join(
[
"**What This Means**",
f"- The wearable detected oxygen saturation around {spo2}% for about {duration_sec} seconds, which can suggest breathing or circulation risk.",
"- This alert alone is not enough to determine the cause, but sustained low oxygen deserves more attention than a brief fluctuation.",
"",
"**Possible Reasons**",
"- This could reflect a respiratory infection, COPD or asthma worsening, another lung issue, or a temporary measurement error.",
"- Loose device fit or movement during the reading can also cause a false alarm, so a repeat check matters.",
"",
"**What To Do Now**",
"- Contact the older adult and ask about shortness of breath, chest tightness, fast breathing, blue lips, or unusual fatigue.",
"- Ask them to sit upright, stay calm, and repeat the oxygen reading if possible.",
"- If home oxygen or prior respiratory history exists, share that information with whoever can check on them in person.",
"",
"**When To Call Emergency Help**",
"- Call emergency help if there is obvious breathing distress, blue lips, confusion, or the older adult cannot speak normally.",
"- If repeat checks stay very low or the family cannot reach the older adult at all, treat it as urgent.",
]
)
if event_type == "fall_detected_alert":
return "\n".join(
[
"**What This Means**",
f"- The wearable detected a possible fall with about {impact_g:.1f}g of impact and about {no_movement_sec} seconds without clear movement afterward.",
"- Fall alerts usually deserve faster on-site confirmation than a single abnormal vital sign because injuries can be hidden.",
"",
"**Possible Reasons**",
"- This may be a true fall, a slip while standing up, or occasionally a strong device impact that caused a false alarm.",
"- If there was prolonged stillness afterward, worry more about the older adult being unable to get up alone.",
"",
"**What To Do Now**",
"- Contact the older adult or the nearest caregiver immediately and confirm whether they are awake, speaking, and in pain.",
"- If someone is already there, do not force them up immediately before checking for head injury, deformity, or severe pain.",
"- If they cannot stand, have strong pain, or may have hit their head, arrange in-person help quickly.",
"",
"**When To Call Emergency Help**",
"- Call emergency help right away for loss of consciousness, head injury, suspected fracture, vomiting, or inability to stand.",
"- If the device keeps showing a post-fall no-movement state and no one can reach the older adult, treat it as an emergency.",
]
)
if event_type == "arrhythmia_alert":
return "\n".join(
[
"**What This Means**",
f"- The wearable flagged a possible irregular rhythm for about {duration_sec} seconds with about {episode_count} abnormal rhythm segments.",
f"- Resting heart rate is around {resting_heart_rate} bpm." if resting_heart_rate > 0 else "- The alert suggests rhythm instability worth checking, but it is not a confirmed diagnosis by itself.",
"- The alert suggests rhythm instability worth checking, but it is not a confirmed diagnosis by itself." if resting_heart_rate > 0 else "",
"",
"**Possible Reasons**",
"- This could reflect atrial fibrillation or another rhythm issue, but it can also come from wearable signal noise or a temporary rhythm disturbance.",
"- The alert alone is not enough to determine the exact rhythm problem without symptoms and a formal ECG.",
"",
"**What To Do Now**",
"- Contact the older adult and ask about palpitations, dizziness, chest discomfort, shortness of breath, or near-fainting.",
"- Ask them to stop activity, sit down, rest, and repeat the reading if possible.",
"- If there is prior ECG history or medication information, keep it ready for whoever follows up.",
"",
"**When To Call Emergency Help**",
"- Call emergency help right away if the alert comes with fainting, severe shortness of breath, confusion, or ongoing chest pain.",
"- If repeated irregular rhythm alerts continue and the older adult cannot be reached, arrange urgent on-site confirmation.",
]
).replace("\n- \n", "\n")
if event_type == "temperature_alert":
if classification == "low_temperature":
return "\n".join(
[
"**What This Means**",
f"- The wearable suggests a low body temperature around {body_temperature:.1f}C.",
"- Low temperature in an older adult can matter even when the cause is not yet clear.",
"",
"**Possible Reasons**",
"- This can happen with cold environment exposure, poor intake, infection, or a general decline in condition.",
"- Device error is also possible, so a repeat temperature check is important.",
"",
"**What To Do Now**",
"- Contact the older adult, confirm their mental status, and ask whether they feel cold, weak, or shaky.",
"- Repeat the temperature if possible and help them stay warm while arranging in-person follow-up if needed.",
"",
"**When To Call Emergency Help**",
"- Call emergency help if there is confusion, extreme weakness, slowed breathing, or no one can reach the older adult.",
"- If repeat checks still show low temperature, urgent in-person assessment is reasonable.",
]
)
return "\n".join(
[
"**What This Means**",
f"- The wearable suggests a high body temperature around {body_temperature:.1f}C, which may reflect fever or an acute inflammatory process.",
"- Fever does not automatically mean severe illness, but in older adults it can come with dehydration or mental status changes more easily.",
"",
"**Possible Reasons**",
"- This could reflect a respiratory infection, urinary infection, another inflammatory issue, or sometimes environmental heat or a measurement error.",
"- Symptoms such as cough, chills, painful urination, or low fluid intake make a true fever more likely.",
"",
"**What To Do Now**",
"- Contact the older adult and ask about chills, cough, breathing changes, painful urination, weakness, or reduced drinking.",
"- Repeat the temperature, encourage fluids if appropriate, and arrange an in-person check if the older adult seems worse.",
"",
"**When To Call Emergency Help**",
"- Call emergency help if there is confusion, severe shortness of breath, persistent vomiting, or inability to get up.",
"- If the family cannot reach the older adult or the temperature stays very high on repeat checks, escalate quickly.",
]
)
if event_type == "inactivity_alert":
return "\n".join(
[
"**What This Means**",
f"- The wearable detected about {inactivity_minutes} minutes without meaningful movement, which is beyond the alert threshold of about {threshold_minutes} minutes.",
"- This does not automatically mean an emergency, but it is worth checking if the older adult would not normally stay still that long.",
"",
"**Possible Reasons**",
"- This may simply reflect a nap, watching TV, taking the device off, or a tracking gap.",
"- It could also reflect a fall, acute illness, reduced consciousness, or the older adult being unable to get up.",
"",
"**What To Do Now**",
"- Contact the older adult and confirm whether they are awake, responding normally, and simply resting.",
"- If they live alone, ask a nearby family member, neighbor, or caregiver to check in person soon.",
"",
"**When To Call Emergency Help**",
"- Treat it as urgent if there is prolonged inactivity together with no response from the older adult.",
"- If an in-person check finds abnormal breathing, altered consciousness, or inability to get up, call emergency help immediately.",
]
)
return "\n".join(
[
"**What This Means**",
"- HealthClaw detected a health-related alert that deserves caregiver attention.",
"- The alert alone is not enough to determine the exact cause without checking the older adult directly.",
"",
"**Possible Reasons**",
"- The signal may reflect activity, device noise, or a real physical problem.",
"- Symptoms, duration, and repeat measurements are all needed to judge risk more accurately.",
"",
"**What To Do Now**",
"- Contact the older adult soon and confirm they are awake, responsive, and not alone.",
"- Ask about chest discomfort, breathing changes, dizziness, pain, a fall, or any other obvious problem.",
"- If practical, repeat the relevant measurement and see whether it improves.",
"",
"**When To Call Emergency Help**",
"- Call emergency help if there is altered consciousness, major breathing trouble, chest pain, injury after a fall, or no response from the older adult.",
"- If alerts keep repeating together with clear symptoms, urgent in-person care is appropriate.",
]
)
def build_fallback_alert_explanation(alert):
if _alert_locale(alert) == "en":
return _build_fallback_alert_explanation_en(alert)
event_type = str(alert.get("event_type", "external_alert") or "external_alert")
details = alert.get("details") if isinstance(alert.get("details"), dict) else {}
heart_rate = _coerce_int(details.get("heart_rate", alert.get("heart_rate")), 0)
duration_sec = _coerce_int(details.get("duration_sec", alert.get("duration_sec")), 0)
threshold = _coerce_int((details.get("rule") or {}).get("threshold"), 0)
spo2 = _coerce_int(details.get("spo2", alert.get("spo2")), 0)
impact_g = details.get("impact_g", alert.get("impact_g"))
no_movement_sec = _coerce_int(details.get("no_movement_sec", alert.get("no_movement_sec")), 0)
episode_count = _coerce_int(details.get("episode_count", alert.get("episode_count")), 0)
resting_heart_rate = _coerce_int(details.get("resting_heart_rate", alert.get("resting_heart_rate")), 0)
body_temperature = details.get("body_temperature", alert.get("body_temperature"))
inactivity_minutes = _coerce_int(details.get("inactivity_minutes", alert.get("inactivity_minutes")), 0)
threshold_minutes = _coerce_int((details.get("rule") or {}).get("threshold_minutes"), 0)
classification = str(details.get("classification", "") or "")
if event_type == "heart_rate_alert":
hr_text = f"{heart_rate} bpm" if heart_rate > 0 else "偏快"
duration_text = f"{duration_sec} 秒" if duration_sec > 0 else "一段时间"
threshold_text = f"{threshold} bpm" if threshold > 0 else "设定阈值"
return "\n".join(
[
"**这代表什么**",
f"- 系统检测到老人心率达到 {hr_text},并持续了 {duration_text},已经超过预警条件。",
"- 这说明当前值得尽快联系老人确认状态,但单靠这条告警还不足以直接判断具体病因。",
"- 如果这是在静息状态下持续升高,通常比活动后短暂升高更需要重视。",
"",
"**可能原因**",
"- 可能是刚活动、情绪紧张、疼痛、发热、脱水,或设备佩戴不稳导致读数异常。",
"- 也可能提示心律失常、感染、急性不适等情况,需要结合老人当下症状判断。",
f"- 当前只能确认“持续高于 {threshold_text} 的心率告警被触发”,不能直接等同于某一种疾病。",
"",
"**建议马上做什么**",
"- 先尽快联系老人,确认是否清醒、能否正常说话、是否独处。",
"- 问清是否有胸闷、胸痛、气短、头晕、心慌、跌倒或明显虚弱,并尽量让老人停止活动、坐下休息。",
"- 如果条件允许,重新测一次心率或观察几分钟,看是否快速回落。",
"- 如果无法联系上老人,建议尽快联系附近家人、邻居或照护人员到现场查看。",
"",
"**何时立刻急救**",
"- 如果同时出现胸痛、呼吸困难、意识模糊、晕厥、单侧肢体无力、持续剧烈不适,应立即呼叫急救。",
"- 如果老人处于静息状态却持续心率很高,且反复复测仍不下降,也应尽快线下就医。",
"- 如果设备连续多次触发高危告警而家属始终联系不上老人,也应按紧急情况处理。",
]
)
if event_type == "blood_oxygen_alert":
spo2_text = f"{spo2}%" if spo2 > 0 else "偏低"
duration_text = f"{duration_sec} 秒" if duration_sec > 0 else "一段时间"
return "\n".join(
[
"**这代表什么**",
f"- 系统检测到老人血氧下降到 {spo2_text},并持续了 {duration_text},提示可能存在呼吸或循环方面的风险。",
"- 单靠这条告警不足以判断具体病因,但持续低血氧通常比短暂波动更值得警惕。",
"- 如果老人本身有肺部疾病、感染或正在静息状态下仍低血氧,需要更重视。",
"",
"**可能原因**",
"- 可能是呼吸道感染、慢阻肺/哮喘加重、肺部问题、心功能异常,或短时佩戴不稳导致读数偏差。",
"- 如果老人正在活动、刚说话或手表佩戴过松,也可能造成短时误报,需要复测确认。",
"",
"**建议马上做什么**",
"- 先联系老人,确认是否气短、胸闷、呼吸急促、发绀、说话费力或明显乏力。",
"- 让老人尽量坐起、保持安静,重新测一次血氧并观察是否回升。",
"- 如果家里有制氧或既往病史资料,尽快同步给到现场照护人员或医生。",
"",
"**何时立刻急救**",
"- 如果老人出现明显呼吸困难、嘴唇发紫、意识模糊、无法完整说话,应立即呼叫急救。",
"- 如果反复复测血氧仍持续很低,或家属根本联系不上老人,也应按紧急情况处理。",
]
)
if event_type == "fall_detected_alert":
impact_text = f"{float(impact_g):.1f}g" if impact_g not in (None, "") else "较明显冲击"
no_motion_text = f"{no_movement_sec} 秒" if no_movement_sec > 0 else "短时间"
return "\n".join(
[
"**这代表什么**",
f"- 可穿戴设备检测到一次疑似跌倒事件,伴随 {impact_text} 的冲击,且之后约 {no_motion_text} 没有明显活动。",
"- 跌倒告警通常比单一生命体征异常更需要优先确认现场安全,尤其要警惕头部外伤或骨折。",
"- 单靠设备信号还不能判断受伤程度,但需要尽快确认老人是否还能自主活动和交流。",
"",
"**可能原因**",
"- 可能是真实跌倒、滑倒、从床椅起身时失衡,也可能是设备受到剧烈撞击产生误报。",
"- 如果同时伴随长时间无应答,就要警惕跌倒后无法自行起身的情况。",
"",
"**建议马上做什么**",
"- 立即电话联系老人或附近照护人员,优先确认是否清醒、能否说话、是否头晕或疼痛。",
"- 如果有人在现场,不要急着强行扶起,先确认是否头部受伤、肢体变形或明显疼痛。",
"- 如果老人表示无法起身、剧痛或疑似撞到头部,应尽快安排线下救助。",
"",
"**何时立刻急救**",
"- 如果老人失去意识、头部受伤出血、疑似骨折、无法站立或出现呕吐、嗜睡,应立即急救。",
"- 如果设备持续显示跌倒后无活动、家属又联系不上老人,也应按紧急救援处理。",
]
)
if event_type == "arrhythmia_alert":
rhythm_text = f"{episode_count} 次异常节律片段" if episode_count > 0 else "异常节律信号"
hr_text = f",静息心率约 {resting_heart_rate} bpm" if resting_heart_rate > 0 else ""
duration_text = f"{duration_sec} 秒" if duration_sec > 0 else "一段时间"
return "\n".join(
[
"**这代表什么**",
f"- 设备检测到疑似心律不齐,持续约 {duration_text},共提示 {rhythm_text}{hr_text}。",
"- 这类告警并不等于已经明确诊断某种心律失常,但说明节律波动值得进一步确认。",
"- 如果老人同时有心慌、头晕、胸闷或既往心脏病史,需要更重视。",
"",
"**可能原因**",
"- 可能是房颤等心律异常、短暂早搏增多、疲劳或情绪波动,也可能与设备采集噪声有关。",
"- 单靠可穿戴信号不足以确定具体类型,通常仍需要结合症状和正规心电图判断。",
"",
"**建议马上做什么**",
"- 尽快联系老人,确认是否有心慌、胸闷、头晕、乏力、气短或快要晕倒的感觉。",
"- 让老人先停止活动、坐下休息,并尽量再次测量心率或查看设备是否重复提示。",
"- 如果家里有既往心电图、抗凝/抗心律失常药物信息,建议一起准备好。",
"",
"**何时立刻急救**",
"- 如果伴随胸痛、晕厥、严重气短、意识模糊或持续明显心慌不缓解,应立即急救。",
"- 如果设备反复提示异常节律且老人又联系不上,也应尽快安排现场查看。",
]
)
if event_type == "temperature_alert":
if classification == "low_temperature":
temp_text = f"{float(body_temperature):.1f}℃" if body_temperature not in (None, "") else "偏低"
return "\n".join(
[
"**这代表什么**",
f"- 设备检测到老人可能体温偏低,当前约为 {temp_text},需要警惕受凉、感染或循环问题。",
"- 单靠这条告警无法判断具体原因,但低体温在老人中通常不能忽视。",
"",
"**可能原因**",
"- 可能与环境过冷、长时间卧床、感染、进食不足或全身状态变差有关。",
"- 也可能是设备测量误差,需要结合现场复测。",
"",
"**建议马上做什么**",
"- 先联系老人,确认意识、手脚温度、发抖情况和是否有明显虚弱。",
"- 尽快复测体温,并注意保暖,必要时联系现场照护人员查看。",
"",
"**何时立刻急救**",
"- 如果同时出现意识不清、极度虚弱、呼吸变慢或无法联系上老人,应立即急救。",
"- 如果复测仍持续低体温,也应尽快线下就医。",
]
)
temp_text = f"{float(body_temperature):.1f}℃" if body_temperature not in (None, "") else "偏高"
return "\n".join(
[
"**这代表什么**",
f"- 设备检测到老人体温异常升高,当前约 {temp_text},提示可能存在发热或急性炎症反应。",
"- 发热本身不等于严重疾病,但老人发热更容易合并脱水、精神状态改变或基础病波动。",
"- 单靠这条告警还不足以判断感染部位或病因,需要结合症状确认。",
"",
"**可能原因**",
"- 可能是呼吸道感染、泌尿系统感染、其他炎症,也可能是环境过热或短暂测量误差。",
"- 如果同时伴随咳嗽、尿频尿痛、寒战、精神差,通常更支持真实发热。",
"",
"**建议马上做什么**",
"- 尽快联系老人,确认是否发冷、咳嗽、气短、尿急尿痛、意识变差或进水减少。",
"- 可以先复测体温,并提醒补水、休息,必要时联系家属或照护人员上门查看。",
"- 如果已有医生指导用药史,可按既往方案参考,但要避免只凭设备自行判断。",
"",
"**何时立刻急救**",
"- 如果高热伴意识模糊、呼吸困难、持续呕吐、明显虚弱不能起身,应立即急救。",
"- 如果家属无法联系上老人,或复测后体温持续很高,也应尽快线下就医。",
]
)
if event_type == "inactivity_alert":
minutes_text = f"{inactivity_minutes} 分钟" if inactivity_minutes > 0 else "较长时间"
threshold_text = f"{threshold_minutes} 分钟" if threshold_minutes > 0 else "设定阈值"
return "\n".join(
[
"**这代表什么**",
f"- 设备检测到老人连续 {minutes_text} 没有明显活动,已经超过 {threshold_text} 的提醒阈值。",
"- 这不一定代表突发疾病,但如果老人平时不会长时间静止,就需要尽快确认是否失联或行动受限。",
"- 如果当前并不是睡眠时段,这类异常静止更值得警惕。",
"",
"**可能原因**",
"- 可能只是午休、长时间看电视、忘记佩戴,或者设备没有正常记录到活动。",
"- 也可能提示跌倒后无法起身、突发不适、意识差或手机/手表离身。",
"",
"**建议马上做什么**",
"- 先联系老人,确认是否清醒、能否正常应答、是否只是休息或摘下了设备。",
"- 如果老人平时独居,建议尽快联系附近家人、邻居或照护人员到现场确认。",
"- 如果有家中门磁、摄像头或其他设备信息,也可以同步交叉确认。",
"",
"**何时立刻急救**",
"- 如果长时间无活动同时伴随联系不上老人、其他设备也无响应,应按紧急失联处理。",
"- 如果现场查看发现老人无法起身、意识改变或呼吸异常,应立即急救。",
]
)
return "\n".join(
[
"**这代表什么**",
"- 系统检测到一条需要家属关注的健康异常提醒。",
"- 单靠这条告警不足以判断具体病因,需要结合老人现场状态进一步确认。",
"",
"**可能原因**",
"- 可能与当时活动状态、设备误差或真实身体不适有关。",
"- 只有把症状、持续时间和复测结果结合起来,才能更准确判断风险。",
"",
"**建议马上做什么**",
"- 尽快联系老人,确认当前是否清醒、能否交流、是否独处。",
"- 询问是否伴随胸闷、气短、头晕、疼痛、跌倒或其他明显不适。",
"- 如果方便,尽快复测相关指标并观察是否恢复。",
"",
"**何时立刻急救**",
"- 如果出现意识改变、明显呼吸困难、胸痛、跌倒受伤或无法联系上老人,应立即求助。",
"- 如果告警持续出现且伴随明显症状,建议尽快线下就医。",
]
)
def _default_alert_llm_ask(prompt):
from seda_main import _build_llm_sessions
sessions = _build_llm_sessions()
if not sessions:
raise RuntimeError("no_llm_backend_configured")
errors = []
for session in sessions:
model_name = f"{type(session).__name__}/{getattr(session, 'default_model', '?')}"
try:
text = session.ask(prompt, stream=False)
except Exception as exc:
errors.append(f"{model_name}: {exc}")
continue
text = _strip_protocol_tags(text)
if text and not text.startswith("Error:"):
return {"text": text, "model": model_name}
errors.append(f"{model_name}: {text[:120] if text else 'empty_response'}")
raise RuntimeError("; ".join(errors) or "all_llm_backends_failed")
def explain_alert_for_caregiver(alert, llm_ask=None):
prompt = build_alert_explanation_prompt(alert)
fallback_markdown = build_fallback_alert_explanation(alert)
ask = llm_ask or _default_alert_llm_ask
try:
result = ask(prompt)
if isinstance(result, dict):
text = _strip_protocol_tags(result.get("text", ""))
model = result.get("model", "")
else:
text = _strip_protocol_tags(str(result))
model = ""
if text:
return {
"status": "ok",
"source": "llm",
"markdown": text,
"model": model,
}
except Exception as exc:
_record_alert_llm_error(str(exc))
return {
"status": "fallback",
"source": "fallback",
"markdown": fallback_markdown,
"error": str(exc),
"error_kind": "auth_invalid" if _looks_like_auth_error(exc) else "llm_unavailable",
"model": "",
}
return {
"status": "fallback",
"source": "fallback",
"markdown": fallback_markdown,
"error": "empty_llm_response",
"error_kind": "empty_response",
"model": "",
}
def create_client():
import lark_oapi as lark
return lark.Client.builder().app_id(APP_ID).app_secret(APP_SECRET) \
.log_level(lark.LogLevel.INFO).build()
def _card(text):
return json.dumps({
"config": {"wide_screen_mode": True},
"elements": [{"tag": "markdown", "content": text}]
})
def send_message(open_id, content, msg_type="text", use_card=False):
from lark_oapi.api.im.v1 import CreateMessageRequest, CreateMessageRequestBody
if use_card:
ct, mt = _card(content), "interactive"
else:
ct, mt = json.dumps({"text": content}), "text"
body = CreateMessageRequest.builder().receive_id_type("open_id").request_body(
CreateMessageRequestBody.builder()
.receive_id(open_id).msg_type(mt).content(ct).build()
).build()
r = client.im.v1.message.create(body)
if r.success():
return r.data.message_id
print(f"[飞书] 发送失败: {r.code}, {r.msg}")
return None
def upload_image(image_path):
from lark_oapi.api.im.v1 import CreateImageRequest, CreateImageRequestBody
src = os.path.abspath(image_path)
stem, _ext = os.path.splitext(os.path.basename(src))
prepared_path = os.path.join(PROJECT_ROOT, "temp", f"{stem}_feishu.jpg")
with Image.open(src) as image:
image.convert("RGB").save(prepared_path, format="JPEG", quality=92, optimize=True)
with open(prepared_path, "rb") as image_file:
body = CreateImageRequestBody.builder() \
.image_type("message") \
.image(image_file) \
.build()
req = CreateImageRequest.builder().request_body(body).build()
r = client.im.v1.image.create(req)
if r.success():
return r.data.image_key
print(f"[飞书] 图片上传失败: {r.code}, {r.msg} path={image_path}")
return None
def send_image_message(open_id, image_path):
from lark_oapi.api.im.v1 import CreateMessageRequest, CreateMessageRequestBody
image_key = upload_image(image_path)
if not image_key:
return None
body = CreateMessageRequest.builder().receive_id_type("open_id").request_body(
CreateMessageRequestBody.builder()
.receive_id(open_id)
.msg_type("image")
.content(json.dumps({"image_key": image_key}, ensure_ascii=False))
.build()
).build()
r = client.im.v1.message.create(body)
if r.success():
return r.data.message_id
print(f"[飞书] 图片消息发送失败: {r.code}, {r.msg} path={image_path}")
return None
def send_meal_artifact_bundle(open_id, artifacts):
labels = [
("搜索结果页截图", artifacts.get("result_screenshot", "")),
("店铺页截图", artifacts.get("merchant_screenshot", "")),
("购物车页截图", artifacts.get("cart_screenshot", "")),
]
sent = []
for label, image_path in labels:
if not image_path or not os.path.exists(image_path):
continue
text_msg_id = send_message(open_id, label)
image_msg_id = send_image_message(open_id, image_path)
sent.append({"label": label, "text_message_id": text_msg_id, "image_message_id": image_msg_id})
return sent
def update_message(message_id, content):
from lark_oapi.api.im.v1 import PatchMessageRequest, PatchMessageRequestBody
body = PatchMessageRequest.builder().message_id(message_id).request_body(
PatchMessageRequestBody.builder().content(_card(content)).build()
).build()
r = client.im.v1.message.patch(body)
if not r.success():
print(f"[飞书] 更新消息失败: {r.code}, {r.msg}")
return r.success()
def _resolve_external_alert_open_id(alert):
open_id = (alert.get("recipient_open_id") or alert.get("open_id") or "").strip()
if open_id:
return open_id
target_id = (alert.get("target_id") or "").strip()
if target_id:
return (EXTERNAL_ALERT_TARGETS.get(target_id) or "").strip()
return ""
def _extract_alert_location_text(alert):
location = alert.get("location")
if not location and isinstance(alert.get("details"), dict):
location = alert["details"].get("location")
if isinstance(location, str):
return location.strip()
if isinstance(location, dict):
return str(location.get("location_text", "") or "").strip()
return ""
def _format_external_alert(alert, explanation=None, explanation_source="", explanation_pending=False):
locale = _alert_locale(alert)
event_type = alert.get("event_type", "external_alert")
sender_id = alert.get("sender_id", "unknown")
timestamp = alert.get("timestamp", time.strftime("%Y-%m-%d %H:%M:%S"))
severity = alert.get("severity", "warning")
message = alert.get("message", "")
details = alert.get("details")
location_text = _extract_alert_location_text(alert)
if locale == "en":
lines = [
"**Cross-device Alert**",
f"Event type: `{event_type}`",
f"Source device: `{sender_id}`",
f"Severity: `{severity}`",
f"Time: `{timestamp}`",
]
else:
lines = [
"**跨设备告警通知**",
f"事件类型: `{event_type}`",
f"来源设备: `{sender_id}`",
f"告警等级: `{severity}`",
f"时间: `{timestamp}`",
]
if location_text:
lines.append(f"Location: `{location_text}`" if locale == "en" else f"定位: `{location_text}`")
lines.extend(["", message])
if explanation_pending:
lines.extend(
["", "_HealthClaw is preparing an explanation for this alert. Please wait a moment..._"]
if locale == "en"
else ["", "_HealthClaw 正在补充这条告警的含义解读,请稍候..._"]
)
elif explanation:
title = "**HealthClaw Interpretation**" if locale == "en" else "**HealthClaw 解读**"
if explanation_source == "fallback_auth_invalid":
title = "**HealthClaw Baseline Interpretation (LLM temporarily unavailable)**" if locale == "en" else "**HealthClaw 基础解读(LLM 暂不可用)**"
elif explanation_source == "fallback":
title = "**HealthClaw Baseline Interpretation**" if locale == "en" else "**HealthClaw 基础解读**"
lines.extend(["", title, explanation.strip()])
if explanation_source == "fallback_auth_invalid":
lines.append(
"_HealthClaw has automatically switched to rule-enhanced fallback interpretation and will return to live LLM generation once credentials recover._"
if locale == "en"
else "_当前已自动切换为规则增强解读,待 LLM 凭证恢复后会自动恢复实时生成。_"
)
if details:
details_text = json.dumps(details, ensure_ascii=False, indent=2)
lines.extend(["", f"```json\n{details_text}\n```"])
return "\n".join(lines)
def _update_alert_explanation_async(message_id, alert):
try:
result = explain_alert_for_caregiver(alert)
explanation_source = result.get("source", "")
if result.get("error_kind") == "auth_invalid":
explanation_source = "fallback_auth_invalid"
content = _format_external_alert(
alert,
explanation=result.get("markdown", ""),
explanation_source=explanation_source,
explanation_pending=False,
)
if not update_message(message_id, content):
print(f"[飞书] 告警解释更新失败: message_id={message_id}")
return
print(
"[飞书] 告警解释已更新: "
f"message_id={message_id}, source={result.get('source')}, model={result.get('model', '')}"
)
except Exception as e:
print(f"[飞书] 告警解释生成失败: {e}")
def handle_external_alert(alert):
if client is None:
raise RuntimeError("Feishu client not initialized")
open_id = _resolve_external_alert_open_id(alert)
if not open_id:
raise ValueError("missing recipient_open_id/open_id or unmapped target_id")
if ENABLE_ALERT_EXPLAINER:
_probe_alert_llm_auth_once()
direct_result = None
if ENABLE_ALERT_EXPLAINER and _alert_llm_state.get("auth_invalid"):
direct_result = _build_direct_fallback_result(
alert,
error_text=_alert_llm_state.get("last_error", ""),
source="fallback_auth_invalid",
)
content = _format_external_alert(
alert,
explanation=(direct_result or {}).get("markdown", ""),
explanation_source=(direct_result or {}).get("source", ""),
explanation_pending=ENABLE_ALERT_EXPLAINER and direct_result is None,
)
msg_id = send_message(open_id, content, use_card=True)
if not msg_id:
raise RuntimeError("failed to send Feishu alert")
if ENABLE_ALERT_EXPLAINER and direct_result is None:
threading.Thread(
target=_update_alert_explanation_async,
args=(msg_id, dict(alert)),
daemon=True,
).start()
return {
"status": "delivered",
"open_id": open_id,
"message_id": msg_id,
"event_type": alert.get("event_type", "external_alert"),
"explanation_enqueued": ENABLE_ALERT_EXPLAINER and direct_result is None,