Repository navigation
Expand file tree
/
Copy pathserver.py
More file actions
1740 lines (1612 loc) · 89.3 KB
/
Copy pathserver.py
File metadata and controls
1740 lines (1612 loc) · 89.3 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
#!/usr/bin/env python3
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, quote, parse_qs
from pathlib import Path
from datetime import datetime, timedelta
import json
import math
import os
import re
import tempfile
import threading
import urllib.error
import urllib.request
ROOT = Path(__file__).resolve().parent
CAL_FILE = ROOT / "calendars.json"
HIDDEN_CALENDAR_EVENTS_FILE = ROOT / "hidden-calendar-events.json"
HIDDEN_CALENDAR_EVENTS_LOCK = threading.RLock()
DEFAULT_CALENDAR_SOURCES = [
{
"id": "us-holidays",
"name": "US Holidays",
"url": "https://www.officeholidays.com/ics/usa",
"color": "#f59e0b",
"enabled": True
}
]
FAMILY_MESSAGES_FILE = ROOT / "family-messages.json"
DAY_NOTES_FILE = ROOT / "day-notes.json"
TODAY_CONTENT_FILE = ROOT / "today-content.json"
COUNTDOWNS_FILE = ROOT / "countdowns.json"
TIMERS_FILE = ROOT / "timers.json"
WEATHER_HISTORY_FILE = ROOT / "weather-history.json"
WEATHER_CONFIG_FILE = ROOT / "weather-config.json"
AI_CONFIG_FILE = ROOT / "ai-config.json"
TODAY_SPORTS_FILE = ROOT / "today-sports-history.json"
TODAY_OBSERVANCES_FILE = ROOT / "today-observances.json"
TODAY_OFFICIAL_HOLIDAYS_FILE = ROOT / "today-official-holidays.json"
TODAY_FUN_FACTS_FILE = ROOT / "today-fun-facts.json"
TODAY_JOKES_FILE = ROOT / "today-jokes.json"
LEGACY_FAMILY_MESSAGE_AI_PROMPT = "Write warm, specific, playful one-line dashboard messages for our family. Mention real activities, timing, or weather details when useful. Keep the voice upbeat but not cheesy."
DEFAULT_FAMILY_MESSAGE_AI_PROMPT = """Write warm, specific, playful one-line dashboard messages for our family. Mention real activities, timing, or weather details when useful. Keep the voice upbeat but not cheesy.
Prompt angles:
- Next up: Write one punchy family-message line for the next event or busiest block. Max 80 characters. Warm, specific, and playful. If the next event is tomorrow or this weekend, say that explicitly. One emoji is welcome.
- Day rhythm: Write one creative headline for today's rhythm from the schedule. Max 80 characters. Mention a concrete activity. One emoji is welcome.
- Spotlight: Write one fresh, non-generic spotlight line for the standout activity. Max 80 characters. Use a fitting emoji if natural.
- Board note: Write one clever kitchen-board note based only on today's activities. Max 80 characters. Friendly, concise, and emoji-friendly.
- Weather window: Write one vivid but accurate line about the useful weather window for today or tonight. Max 80 characters. Skip it when weather context is missing.
- Small spark: Write one lively final line that finds a fresh angle in the supplied schedule, notes, weather, or relevant highlight. Max 80 characters.
Requirements:
- Return exactly the requested message count, between 3 and 6 short messages.
- Each message must be 90 characters or fewer and fit on one dashboard line.
- Each message must be unique, creative, and based on the schedule, weather, or both.
- Give informational lines a little personality through varied phrasing, light wordplay, or a vivid image when natural. Keep the facts clear and do not turn every line into a plain status report.
- Vary the opening and sentence shape across messages so the bar does not feel repetitive.
- Blend weather with events when it is useful, for example heat before an afternoon game or rain before errands.
- Match weather language to the event time. Use each event's eventWeather/eventTempF when present instead of the daily high or overnight low.
- Only blend weather with events where weatherRelevant is true. Do not mention weather for indoor/non-weather-sensitive events like haircuts, dental appointments, piano lessons, meetings, or wrestling club.
- For evening or night events, prefer eventWeather, current/evening conditions, or tonightLowF. If referencing dayHighF, frame it as earlier-day context, for example after a hot day.
- When an event has relativeTimeLabel, prefer that wording exactly, for example this morning, later today, tonight, tomorrow morning, tomorrow afternoon, tomorrow evening, or this weekend.
- Use the weather object as already-normalized local weather from either Tempest or ZIP forecast.
- Do not mention missing weather fields, only use weather values that are present.
- Mention real activities, transitions, timing, or weather details from the provided context.
- When enabled in display settings, todayHighlights may include this-day-in-history facts, fun facts, holidays, or novelty days. Use them only when they are clearly relevant to this family dashboard, for example baseball, softball, ASU/Sun Devils, Georgia Tech, Team USA, major US sports milestones, or broadly recognizable historic moments.
- Skip history messages when the supplied history item is obscure, niche, trivia-like, or only technically historic. Do not write about horse-racing winners, generic calendar facts, or obscure old sports results.
- Do not write or invent joke messages. The dashboard adds the exact daily joke separately from the popup content.
- Do not invent holidays, historical facts, dates, or observances. Use only supplied todayHighlights values.
- Counts and count-ups are allowed when they are meaningful, for example an anniversary, birthday, streak, milestone, game/season count, school countdown, or event-specific countdown.
- Never produce generic calendar-countdown filler such as "223 days left in the year", "the 143rd day of the year", or Gregorian-calendar trivia. Those are not useful dashboard messages.
- Team loyalty comes from each event's calendarName/cheerTeam, not from opponent names in the event title.
- If an event has cheerTeam, only cheer for that cheerTeam. Never cheer for a different team because its name appears in another calendar, event summary, opponent text, or user config.
- If an event is a practice, BP, batting practice, workout, training, or clinic, do not generate a cheer message or team slogan, even when it has a cheerTeam.
- If an event has no cheerTeam, avoid team-cheer slogans for that event.
- Use event times exactly as provided. Do not convert, offset, or infer another timezone.
- Do not include addresses, street names, venue addresses, or precise location details.
- Do not give advice or make up private facts.
- Do not write reminder/nag/prep-advice lines such as pack a snack, bring water, grab sunscreen, wear a jacket, do not forget, remember to, or similar unless that exact item is explicitly in the schedule or notes.
- Prefer descriptive, upbeat status lines over instructions to the family.
- Use one tasteful emoji when it fits naturally.
- Keep each message family-friendly, concise, and playful.
- Do not include markdown or commentary.
- Follow these instructions for tone and preferences unless they conflict with privacy, JSON, length, or schedule-accuracy rules."""
FAMILY_MESSAGE_AI_PROMPTS = [
{
"id": "next-up",
"instruction": "Write one punchy family-message line for the next event or busiest block. Max 80 characters. Warm, specific, and playful. One emoji is welcome."
},
{
"id": "day-rhythm",
"instruction": "Write one creative headline for today's rhythm from the schedule. Max 80 characters. Mention a concrete activity. One emoji is welcome."
},
{
"id": "spotlight",
"instruction": "Write one fresh, non-generic spotlight line for the standout activity. Max 80 characters. Use a fitting emoji if natural."
},
{
"id": "board-note",
"instruction": "Write one clever kitchen-board note based only on today's activities. Max 80 characters. Friendly, concise, and emoji-friendly."
},
{
"id": "weather-window",
"instruction": "Write one vivid but accurate line about the useful weather window for today or tonight. Max 80 characters. Skip it when weather context is missing."
},
{
"id": "small-spark",
"instruction": "Write one lively final line that finds a fresh angle in the supplied schedule, notes, weather, or relevant highlight. Max 80 characters."
}
]
WEATHER_HISTORY_RETENTION_DAYS = 14
WEATHER_FUTURE_CACHE_DAYS = 7
RADAR_METADATA_URL = "https://api.rainviewer.com/public/weather-maps.json"
RADAR_CACHE_SECONDS = 300
RADAR_CACHE_LOCK = threading.RLock()
RADAR_CACHE = {"expiresAt": None, "payload": None}
DEFAULT_WEATHER_CONFIG = {
"mode": "public",
"public": {
"zip": "10001",
"country": "US",
"label": "New York, NY"
},
"tempest": {
"stationId": "",
"token": ""
}
}
DEFAULT_AI_CONFIG = {
"enabled": False,
"provider": "openrouter",
"baseUrl": "https://openrouter.ai/api/v1",
"model": "openai/gpt-oss-120b:free",
"referer": "http://127.0.0.1:4020",
"appTitle": "Weekaroo",
"apiKey": "",
"familyMessagePrompt": DEFAULT_FAMILY_MESSAGE_AI_PROMPT
}
OPENVERSE_IMAGE_API_URL = "https://api.openverse.org/v1/images/"
WEATHER_CODE_TEXT = {
0: "Clear", 1: "Mostly clear", 2: "Partly cloudy", 3: "Cloudy",
45: "Fog", 48: "Fog", 51: "Light drizzle", 53: "Drizzle", 55: "Heavy drizzle",
61: "Light rain", 63: "Rain", 65: "Heavy rain", 71: "Light snow", 73: "Snow",
75: "Heavy snow", 80: "Rain showers", 81: "Rain showers", 82: "Heavy showers",
95: "Thunderstorm", 96: "Storm", 99: "Severe storm"
}
class Handler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(ROOT), **kwargs)
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/calendar-data":
self.serve_calendar_data()
return
if parsed.path == "/calendar-sources":
self.serve_calendar_sources()
return
if parsed.path == "/hidden-calendar-events":
self.serve_hidden_calendar_events()
return
if parsed.path == "/weather-data":
self.serve_weather_data()
return
if parsed.path == "/radar-data":
self.serve_radar_data()
return
if parsed.path == "/weather-config":
self.serve_weather_config()
return
if parsed.path == "/ai-config":
self.serve_ai_config()
return
if parsed.path == "/family-messages-data":
self.serve_family_messages_data()
return
if parsed.path == "/family-messages-ai":
self.serve_family_messages_ai()
return
if parsed.path == "/day-notes-data":
self.serve_day_notes_data()
return
if parsed.path == "/today-content":
self.serve_today_content()
return
if parsed.path == "/countdowns-data":
self.serve_countdowns_data()
return
if parsed.path == "/timers-data":
self.serve_timers_data()
return
return super().do_GET()
def do_POST(self):
parsed = urlparse(self.path)
if parsed.path == "/family-messages-data":
self.add_family_message()
return
if parsed.path == "/family-messages-ai":
self.serve_json(self.generate_family_messages_ai_payload())
return
if parsed.path == "/calendar-sources":
self.save_calendar_sources()
return
if parsed.path == "/hidden-calendar-events":
self.hide_calendar_event()
return
if parsed.path == "/day-notes-data":
self.save_day_note()
return
if parsed.path == "/weather-config":
self.save_weather_config()
return
if parsed.path == "/ai-config":
self.save_ai_config()
return
if parsed.path == "/ai-config/test":
self.test_ai_config()
return
if parsed.path == "/timer-ai-enrich":
self.enrich_timer_with_ai()
return
if parsed.path == "/timers-data":
self.save_timers_data()
return
self.serve_json({"error": "Not found"}, status=404)
def do_DELETE(self):
parsed = urlparse(self.path)
if parsed.path == "/family-messages-data":
self.delete_family_message()
return
if parsed.path == "/day-notes-data":
self.delete_day_note()
return
if parsed.path == "/hidden-calendar-events":
self.unhide_calendar_event(parsed)
return
self.serve_json({"error": "Not found"}, status=404)
def serve_json(self, payload, status=200):
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def load_calendar_sources_payload(self):
if not CAL_FILE.exists():
data = DEFAULT_CALENDAR_SOURCES
else:
data = json.loads(CAL_FILE.read_text())
if not isinstance(data, list):
return []
cleaned = []
for index, item in enumerate(data):
if not isinstance(item, dict):
continue
name = f"{item.get('name', '')}".strip()
url = f"{item.get('url', '')}".strip()
color = f"{item.get('color', '#7dd3c7')}".strip() or "#7dd3c7"
if not name and not url:
continue
cleaned.append({
"id": f"{item.get('id') or f'calendar-{index + 1}'}",
"name": name or "Untitled calendar",
"url": url,
"color": color,
"enabled": item.get("enabled") is not False,
"exclusions": item.get("exclusions", []) if isinstance(item.get("exclusions"), list) else []
})
return cleaned
def save_calendar_sources_payload(self, payload):
CAL_FILE.write_text(json.dumps(payload, indent=2) + "\n")
def clean_hidden_calendar_event(self, item):
if not isinstance(item, dict):
return None
def text(name, limit):
return f"{item.get(name, '')}".strip()[:limit]
key = f"{item.get('key', '')}".strip()
if not key or len(key) > 1000:
return None
return {
"key": key,
"sourceId": text("sourceId", 240),
"calendarName": text("calendarName", 240),
"uid": text("uid", 1000),
"occurrence": text("occurrence", 80),
"title": text("title", 500) or "Hidden event",
"start": text("start", 80),
"end": text("end", 80),
"hiddenAt": text("hiddenAt", 80) or datetime.now().astimezone().isoformat()
}
def load_hidden_calendar_events_payload(self):
with HIDDEN_CALENDAR_EVENTS_LOCK:
if not HIDDEN_CALENDAR_EVENTS_FILE.exists():
return {"version": 1, "events": []}
try:
data = json.loads(HIDDEN_CALENDAR_EVENTS_FILE.read_text())
except (OSError, json.JSONDecodeError) as exc:
raise RuntimeError("Hidden calendar event storage is unreadable; no preferences were changed") from exc
if not isinstance(data, dict):
raise RuntimeError("Hidden calendar event storage has an invalid format; no preferences were changed")
raw_events = data.get("events")
if not isinstance(raw_events, list):
raise RuntimeError("Hidden calendar event storage has an invalid events list; no preferences were changed")
cleaned = []
seen = set()
cutoff = datetime.now().astimezone() - timedelta(days=90)
for item in raw_events:
record = self.clean_hidden_calendar_event(item)
if not record or record["key"] in seen:
continue
timestamp = record.get("end") or record.get("start")
if timestamp:
try:
event_time = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
if event_time.tzinfo is None:
event_time = event_time.astimezone()
if event_time < cutoff:
continue
except ValueError:
pass
seen.add(record["key"])
cleaned.append(record)
payload = {"version": 1, "events": cleaned[-500:]}
if len(payload["events"]) != len(raw_events):
self.save_hidden_calendar_events_payload(payload)
return payload
def save_hidden_calendar_events_payload(self, payload):
with HIDDEN_CALENDAR_EVENTS_LOCK:
file_descriptor, temp_name = tempfile.mkstemp(
prefix=".hidden-calendar-events.",
suffix=".tmp",
dir=ROOT
)
try:
with os.fdopen(file_descriptor, "w") as temp_file:
temp_file.write(json.dumps(payload, indent=2) + "\n")
temp_file.flush()
os.fsync(temp_file.fileno())
os.replace(temp_name, HIDDEN_CALENDAR_EVENTS_FILE)
finally:
if os.path.exists(temp_name):
os.unlink(temp_name)
def serve_hidden_calendar_events(self):
try:
self.serve_json(self.load_hidden_calendar_events_payload())
except Exception as exc:
self.serve_json({"error": str(exc)}, status=500)
def hide_calendar_event(self):
try:
record = self.clean_hidden_calendar_event(self.read_request_json())
if not record:
self.serve_json({"error": "A valid hidden-event key of at most 1000 characters is required"}, status=400)
return
with HIDDEN_CALENDAR_EVENTS_LOCK:
payload = self.load_hidden_calendar_events_payload()
events = [item for item in payload["events"] if item.get("key") != record["key"]]
events.append(record)
payload["events"] = events[-500:]
self.save_hidden_calendar_events_payload(payload)
self.serve_json(payload)
except Exception as exc:
self.serve_json({"error": str(exc)}, status=500)
def unhide_calendar_event(self, parsed):
try:
key = f"{parse_qs(parsed.query).get('key', [''])[0]}".strip()
if not key or len(key) > 1000:
self.serve_json({"error": "A hidden-event key of at most 1000 characters is required"}, status=400)
return
with HIDDEN_CALENDAR_EVENTS_LOCK:
payload = self.load_hidden_calendar_events_payload()
payload["events"] = [item for item in payload["events"] if item.get("key") != key]
self.save_hidden_calendar_events_payload(payload)
self.serve_json(payload)
except Exception as exc:
self.serve_json({"error": str(exc)}, status=500)
def load_family_messages_payload(self):
if not FAMILY_MESSAGES_FILE.exists():
return {"messages": [], "defaultMessages": [], "rules": []}
data = json.loads(FAMILY_MESSAGES_FILE.read_text())
if not isinstance(data, dict):
return {"messages": [], "defaultMessages": [], "rules": []}
messages = data.get("messages") if isinstance(data.get("messages"), list) else []
default_messages = data.get("defaultMessages") if isinstance(data.get("defaultMessages"), list) else []
rules = data.get("rules") if isinstance(data.get("rules"), list) else []
return {"messages": messages, "defaultMessages": default_messages, "rules": rules}
def save_family_messages_payload(self, payload):
FAMILY_MESSAGES_FILE.write_text(json.dumps(payload, indent=2) + "\n")
def load_day_notes_payload(self):
if not DAY_NOTES_FILE.exists():
return {}
data = json.loads(DAY_NOTES_FILE.read_text())
if not isinstance(data, dict):
return {}
normalized = {}
for key, value in data.items():
if isinstance(value, dict) and isinstance(value.get("text"), str):
normalized[key] = [{"id": "note-1", "text": value.get("text", "").strip()}]
elif isinstance(value, list):
cleaned = []
for index, item in enumerate(value):
if isinstance(item, dict) and isinstance(item.get("text"), str) and item.get("text", "").strip():
cleaned.append({
"id": f"{item.get('id') or f'note-{index + 1}'}",
"text": item.get("text", "").strip()
})
normalized[key] = cleaned
else:
normalized[key] = []
return normalized
def save_day_notes_payload(self, payload):
DAY_NOTES_FILE.write_text(json.dumps(payload, indent=2) + "\n")
def load_weather_history_payload(self):
if not WEATHER_HISTORY_FILE.exists():
return {"days": {}}
data = json.loads(WEATHER_HISTORY_FILE.read_text())
if not isinstance(data, dict):
return {"days": {}}
days = data.get("days") if isinstance(data.get("days"), dict) else {}
return {"days": days}
def save_weather_history_payload(self, payload):
WEATHER_HISTORY_FILE.write_text(json.dumps(payload, indent=2) + "\n")
def load_weather_config_payload(self, include_secret=False):
config = json.loads(json.dumps(DEFAULT_WEATHER_CONFIG))
if WEATHER_CONFIG_FILE.exists():
try:
data = json.loads(WEATHER_CONFIG_FILE.read_text())
if isinstance(data, dict):
if data.get("mode") in {"public", "tempest"}:
config["mode"] = data.get("mode")
if isinstance(data.get("public"), dict):
config["public"].update(data.get("public"))
if isinstance(data.get("tempest"), dict):
config["tempest"].update(data.get("tempest"))
except Exception:
pass
public = config.get("public", {})
public["zip"] = f"{public.get('zip') or DEFAULT_WEATHER_CONFIG['public']['zip']}".strip() or DEFAULT_WEATHER_CONFIG["public"]["zip"]
public["country"] = f"{public.get('country') or 'US'}".strip().upper() or "US"
public["label"] = f"{public.get('label') or ''}".strip()
tempest = config.get("tempest", {})
tempest["stationId"] = f"{tempest.get('stationId') or ''}".strip()
tempest["token"] = f"{tempest.get('token') or ''}".strip()
if include_secret:
return config
token = tempest.get("token", "")
safe = json.loads(json.dumps(config))
safe["tempest"].pop("token", None)
safe["tempest"]["hasToken"] = bool(token)
safe["tempest"]["tokenMasked"] = f"••••{token[-4:]}" if token else ""
return safe
def save_weather_config_payload(self, payload):
WEATHER_CONFIG_FILE.write_text(json.dumps(payload, indent=2) + "\n")
def read_request_json(self):
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length) if length > 0 else b"{}"
return json.loads(raw.decode("utf-8") or "{}")
def load_ai_config_payload(self, include_secret=False, include_env=True):
config = json.loads(json.dumps(DEFAULT_AI_CONFIG))
file_api_key = ""
has_custom_family_prompt = False
if AI_CONFIG_FILE.exists():
try:
data = json.loads(AI_CONFIG_FILE.read_text())
if isinstance(data, dict):
for key in ["enabled", "provider", "baseUrl", "model", "referer", "appTitle", "apiKey", "familyMessagePrompt"]:
if key in data:
config[key] = data.get(key)
has_custom_family_prompt = bool(f"{data.get('familyMessagePrompt') or ''}".strip())
file_api_key = f"{data.get('apiKey') or ''}".strip()
except Exception:
pass
env_api_key = f"{os.environ.get('OPENROUTER_API_KEY') or os.environ.get('DASHBOARD_AI_API_KEY') or ''}".strip()
selected_api_key = file_api_key or (env_api_key if include_env else "")
api_key_source = "file" if file_api_key else ("environment" if selected_api_key and env_api_key else "none")
config["enabled"] = bool(config.get("enabled"))
config["provider"] = f"{config.get('provider') or 'openrouter'}".strip() or "openrouter"
config["baseUrl"] = f"{config.get('baseUrl') or DEFAULT_AI_CONFIG['baseUrl']}".strip().rstrip("/")
config["model"] = f"{config.get('model') or DEFAULT_AI_CONFIG['model']}".strip()
config["referer"] = f"{config.get('referer') or DEFAULT_AI_CONFIG['referer']}".strip()
config["appTitle"] = f"{config.get('appTitle') or DEFAULT_AI_CONFIG['appTitle']}".strip()
config["familyMessagePrompt"] = self.clean_ai_instructions(config.get("familyMessagePrompt") or DEFAULT_FAMILY_MESSAGE_AI_PROMPT)
if config["familyMessagePrompt"] == LEGACY_FAMILY_MESSAGE_AI_PROMPT:
config["familyMessagePrompt"] = DEFAULT_FAMILY_MESSAGE_AI_PROMPT
has_custom_family_prompt = False
config["hasCustomFamilyMessagePrompt"] = has_custom_family_prompt
config["familyMessagePromptSource"] = "saved custom instructions" if has_custom_family_prompt else "default instructions"
config["apiKey"] = selected_api_key
config["apiKeySource"] = api_key_source
if include_secret:
return config
safe = json.loads(json.dumps(config))
api_key = safe.pop("apiKey", "")
safe["hasApiKey"] = bool(api_key)
safe["apiKeyMasked"] = f"••••{api_key[-4:]}" if api_key else ""
safe["defaultFamilyMessagePrompt"] = DEFAULT_FAMILY_MESSAGE_AI_PROMPT
default_ai_config = json.loads(json.dumps(DEFAULT_AI_CONFIG))
default_ai_config.pop("apiKey", None)
safe["defaultAiConfig"] = default_ai_config
return safe
def save_ai_config_payload(self, payload):
AI_CONFIG_FILE.write_text(json.dumps(payload, indent=2) + "\n")
def serve_ai_config(self):
self.serve_json(self.load_ai_config_payload(include_secret=False))
def save_ai_config(self):
try:
incoming = self.read_request_json()
existing = self.load_ai_config_payload(include_secret=True, include_env=False)
api_key = f"{incoming.get('apiKey') or ''}".strip()
if not api_key:
api_key = existing.get("apiKey", "")
cleaned = {
"enabled": bool(incoming.get("enabled")),
"provider": f"{incoming.get('provider') or existing.get('provider') or 'openrouter'}".strip(),
"baseUrl": f"{incoming.get('baseUrl') or existing.get('baseUrl') or DEFAULT_AI_CONFIG['baseUrl']}".strip().rstrip("/"),
"model": f"{incoming.get('model') or existing.get('model') or DEFAULT_AI_CONFIG['model']}".strip(),
"referer": f"{incoming.get('referer') or existing.get('referer') or DEFAULT_AI_CONFIG['referer']}".strip(),
"appTitle": f"{incoming.get('appTitle') or existing.get('appTitle') or DEFAULT_AI_CONFIG['appTitle']}".strip(),
"familyMessagePrompt": self.normalize_family_message_prompt(incoming.get("familyMessagePrompt") or DEFAULT_FAMILY_MESSAGE_AI_PROMPT),
"apiKey": api_key
}
self.save_ai_config_payload(cleaned)
self.serve_json(self.load_ai_config_payload(include_secret=False))
except Exception as exc:
self.serve_json({"error": str(exc)}, status=500)
def test_ai_config(self):
try:
incoming = self.read_request_json()
existing = self.load_ai_config_payload(include_secret=True, include_env=False)
api_key = f"{incoming.get('apiKey') or existing.get('apiKey') or ''}".strip()
if not api_key:
self.serve_json({"ok": False, "error": "AI API key is missing. Add one or set OPENROUTER_API_KEY."}, status=400)
return
provider = f"{incoming.get('provider') or existing.get('provider') or DEFAULT_AI_CONFIG['provider']}".strip()
base_url = f"{incoming.get('baseUrl') or existing.get('baseUrl') or DEFAULT_AI_CONFIG['baseUrl']}".strip().rstrip("/")
model = f"{incoming.get('model') or existing.get('model') or DEFAULT_AI_CONFIG['model']}".strip()
referer = f"{incoming.get('referer') or existing.get('referer') or DEFAULT_AI_CONFIG['referer']}".strip()
app_title = f"{incoming.get('appTitle') or existing.get('appTitle') or DEFAULT_AI_CONFIG['appTitle']}".strip()
body = json.dumps({
"model": model,
"messages": [
{"role": "system", "content": "Reply with strict JSON only."},
{"role": "user", "content": "Return {\"ok\":true}."}
],
"temperature": 0,
"max_tokens": 20,
"response_format": {"type": "json_object"}
}).encode("utf-8")
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}"
}
if provider == "openrouter":
headers["HTTP-Referer"] = referer or DEFAULT_AI_CONFIG["referer"]
headers["X-OpenRouter-Title"] = app_title or DEFAULT_AI_CONFIG["appTitle"]
req = urllib.request.Request(f"{base_url}/chat/completions", data=body, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=20) as response:
payload = json.loads(response.read().decode("utf-8"))
self.serve_json({"ok": True, "model": payload.get("model") or model})
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "ignore")[:300]
self.serve_json({"ok": False, "error": f"AI provider returned HTTP {exc.code}: {detail}"}, status=502)
except Exception as exc:
self.serve_json({"ok": False, "error": str(exc)}, status=500)
def normalize_family_message_prompt(self, value):
prompt = self.clean_ai_instructions(value or DEFAULT_FAMILY_MESSAGE_AI_PROMPT)
if prompt == LEGACY_FAMILY_MESSAGE_AI_PROMPT:
return DEFAULT_FAMILY_MESSAGE_AI_PROMPT
return prompt
def clean_ai_instructions(self, value, limit=4000):
text = f"{value or ''}".replace("\r\n", "\n").replace("\r", "\n").strip()
lines = [" ".join(line.split()) for line in text.split("\n")]
while lines and not lines[0]:
lines.pop(0)
while lines and not lines[-1]:
lines.pop()
cleaned = "\n".join(lines)
return cleaned[:limit]
def clean_ai_text(self, value, limit=140):
text = f"{value or ''}".strip()
text = " ".join(text.split())
return text[:limit]
def clean_ai_list(self, value, limit=6, item_limit=180):
if not isinstance(value, list):
return []
cleaned = []
for item in value:
text = self.clean_ai_text(item, item_limit)
if text and text not in cleaned:
cleaned.append(text)
if len(cleaned) >= limit:
break
return cleaned
def parse_ai_json(self, content):
text = f"{content or ''}".strip()
if text.startswith("```"):
text = text.strip("`").strip()
if text.lower().startswith("json"):
text = text[4:].strip()
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
text = text[start:end + 1]
return json.loads(text)
def sanitize_timer_ai_result(self, payload):
return {
"emoji": self.clean_ai_text(payload.get("emoji"), 8),
"heroTitle": self.clean_ai_text(payload.get("heroTitle"), 90),
"subtitle": self.clean_ai_text(payload.get("subtitle"), 120),
"detailLabel": self.clean_ai_text(payload.get("detailLabel"), 48),
"details": self.clean_ai_list(payload.get("details"), limit=6, item_limit=170),
"imageQueries": self.clean_ai_list(payload.get("imageQueries"), limit=5, item_limit=70),
"themeColor": self.clean_ai_text(payload.get("themeColor"), 32),
"confidence": self.clean_ai_text(payload.get("confidence"), 24) or "medium"
}
def build_timer_image_query_candidates(self, timer, suggestions, context):
timer_text = " ".join(
f"{timer.get(field) or ''}"
for field in ["title", "subtitle", "heroTitle", "detailLabel", "factsLabel"]
).lower()
suggestion_queries = self.clean_ai_list(suggestions.get("imageQueries"), limit=5, item_limit=70) if isinstance(suggestions, dict) else []
candidates = []
def add(query):
text = self.clean_ai_text(query, 120)
if text and text not in candidates:
candidates.append(text)
dashboard_name = self.clean_ai_text(context.get("dashboardName"), 80) if isinstance(context, dict) else ""
if re.search(r"\b(independence day|fourth of july|july 4|july fourth|usa 250|american flag|fireworks|patriotic)\b", timer_text):
add(f"{dashboard_name} independence day fireworks american flag parade")
add("fourth of july fireworks american flag parade")
add("patriotic american flag celebration")
elif re.search(r"\b(first day of school|school|classroom|backpack|middle school|elementary school)\b", timer_text):
add(f"{dashboard_name} first day of school backpack classroom")
add("first day of school backpack classroom")
add("students walking to school")
elif re.search(r"\b(cabin|sabbatical|vacation|trip|getaway|road trip|mountain|hiking)\b", timer_text):
add(f"{dashboard_name} mountain cabin family getaway")
add("mountain cabin getaway")
add("family hiking mountain trail")
elif re.search(r"\b(wedding|anniversary|married|love|romance|forever)\b", timer_text):
add(f"{dashboard_name} romantic anniversary couple")
add("romantic couple anniversary")
add("wedding anniversary celebration")
for query in suggestion_queries:
normalized = query.lower()
if re.search(r"\b(bridge|wave|waves|scenic|landscape|nature|mountains?|ocean|beach|sunset)\b", normalized) and not re.search(r"\b(cabin|school|flag|firework|parade|wedding|anniversary|love|trip)\b", timer_text):
continue
add(query)
if not candidates:
fallback = self.clean_ai_text(timer.get("title") or timer.get("heroTitle") or "family event", 80)
add(f"{fallback} photo")
return candidates[:6]
def search_openverse_image_urls(self, query, limit=2):
if not query:
return []
url = f"{OPENVERSE_IMAGE_API_URL}?q={quote(query)}&per_page={max(1, int(limit))}"
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (OpenClaw Dashboard)"})
with urllib.request.urlopen(req, timeout=25) as response:
payload = json.loads(response.read().decode("utf-8"))
urls = []
for result in payload.get("results", []):
image_url = f"{result.get('url') or ''}".strip()
if image_url and image_url not in urls:
urls.append(image_url)
if len(urls) >= limit:
break
return urls
def resolve_timer_images(self, timer, suggestions, context):
candidates = self.build_timer_image_query_candidates(timer, suggestions, context)
resolved = []
seen = set()
for query in candidates:
try:
urls = self.search_openverse_image_urls(query, limit=2)
except Exception:
continue
for url in urls:
if url in seen:
continue
seen.add(url)
resolved.append(url)
if len(resolved) >= 4:
return resolved
return resolved
def sanitize_family_message_ai_result(self, payload):
messages = payload.get("messages") if isinstance(payload, dict) else payload
cleaned = []
for message in messages if isinstance(messages, list) else []:
if isinstance(message, dict):
text = self.clean_ai_text(message.get("text") or message.get("message") or message.get("content"), 90)
else:
text = self.clean_ai_text(message, 90)
if self.is_low_value_family_message(text):
continue
if text and text not in cleaned:
cleaned.append(text)
if len(cleaned) >= 6:
break
return cleaned
def is_low_value_family_message(self, text):
value = f"{text or ''}".strip().lower()
if not value:
return True
prep_patterns = [
r"\bpack (a |the |some )?(snack|snacks|lunch|bag|backpack)\b",
r"\bbring (a |the |some )?(water|water bottle|snack|snacks|sunscreen|jacket|hat)\b",
r"\bgrab (a |the |some )?(water|water bottle|snack|snacks|sunscreen|jacket|hat)\b",
r"\bwear (a |the )?(jacket|hat|sunscreen|layers)\b",
r"\b(don't|do not) forget\b",
r"\bremember to\b",
r"\bmake sure to\b",
r"\bstay hydrated\b"
]
return any(re.search(pattern, value) for pattern in prep_patterns)
def clean_family_message_event_weather(self, weather):
if not isinstance(weather, dict):
return {}
def clean_number(value, decimals=0):
try:
number = float(value)
except (TypeError, ValueError):
return None
if not math.isfinite(number):
return None
return round(number, decimals)
return {
"eventTempF": clean_number(weather.get("eventTempF")),
"eventFeelsLikeF": clean_number(weather.get("eventFeelsLikeF")),
"conditions": self.clean_ai_text(weather.get("conditions"), 60),
"windMph": clean_number(weather.get("windMph")),
"gustMph": clean_number(weather.get("gustMph")),
"rainChancePercent": clean_number(weather.get("rainChancePercent")),
"forecastTime": self.clean_ai_text(weather.get("forecastTime"), 40),
"summary": self.clean_ai_text(weather.get("summary"), 180)
}
def clean_family_message_event(self, event):
if not isinstance(event, dict):
return {}
calendar_name = self.clean_ai_text(event.get("calendarName"), 90)
summary = self.clean_ai_text(event.get("summary"), 90)
cheer_team = "" if self.is_family_message_practice_event(summary) else self.derive_family_message_cheer_team(calendar_name)
return {
"summary": summary,
"start": self.clean_ai_text(event.get("start"), 40),
"end": self.clean_ai_text(event.get("end"), 40),
"startHour": event.get("startHour") if isinstance(event.get("startHour"), int) and 0 <= event.get("startHour") <= 23 else None,
"dayPart": self.clean_ai_text(event.get("dayPart"), 24),
"weatherTimingHint": self.clean_ai_text(event.get("weatherTimingHint"), 180),
"weatherRelevant": bool(event.get("weatherRelevant")),
"weatherRelevanceReason": self.clean_ai_text(event.get("weatherRelevanceReason"), 100),
"eventWeather": self.clean_family_message_event_weather(event.get("eventWeather")),
"allDay": bool(event.get("allDay")),
"location": "",
"description": "",
"calendarName": calendar_name,
"relativeDayLabel": self.clean_ai_text(event.get("relativeDayLabel"), 16),
"relativeTimeLabel": self.clean_ai_text(event.get("relativeTimeLabel"), 24),
"cheerTeam": cheer_team,
"cheerAllowed": bool(cheer_team)
}
def is_family_message_practice_event(self, summary):
return bool(re.search(r"\b(practice|bp|batting practice|workout|training|clinic)\b", f"{summary or ''}", re.I))
def derive_family_message_cheer_team(self, calendar_name):
name = self.clean_ai_text(calendar_name, 90)
if not name:
return ""
if re.search(r"\b(family|holiday|holidays|birthday|birthdays|weather|school)\b", name, re.I):
return ""
team = re.sub(r"\b(baseball|softball|calendar|schedule|games?|practices?)\b", "", name, flags=re.I)
team = " ".join(team.split()).strip(" -–—:•")
return self.clean_ai_text(team or name, 60)
def clean_family_message_note(self, note):
if not isinstance(note, dict):
return ""
return self.clean_ai_text(note.get("text"), 140)
def clean_family_message_today_highlights(self, highlights):
if not isinstance(highlights, dict):
return {}
return {
"dateLabel": self.clean_ai_text(highlights.get("dateLabel"), 80),
"history": self.clean_ai_list(highlights.get("history"), limit=2, item_limit=160),
"observances": self.clean_ai_list(highlights.get("observances"), limit=4, item_limit=80)
}
def clean_family_message_weather(self, weather):
if not isinstance(weather, dict):
return {}
def clean_number(value, decimals=0):
try:
number = float(value)
except (TypeError, ValueError):
return None
if not math.isfinite(number):
return None
return round(number, decimals)
source = self.clean_ai_text(weather.get("source"), 20).lower()
if source not in {"tempest", "zip"}:
source = "weather"
return {
"source": source,
"sourceLabel": "Tempest local station" if source == "tempest" else "ZIP code forecast" if source == "zip" else "Weather source",
"conditions": self.clean_ai_text(weather.get("conditions"), 60),
"currentTempF": clean_number(weather.get("currentTempF")),
"feelsLikeF": clean_number(weather.get("feelsLikeF")),
"dayHighF": clean_number(weather.get("dayHighF")),
"tonightLowF": clean_number(weather.get("tonightLowF")),
"highF": clean_number(weather.get("highF")),
"lowF": clean_number(weather.get("lowF")),
"temperatureFocus": self.clean_ai_text(weather.get("temperatureFocus"), 180),
"windMph": clean_number(weather.get("windMph")),
"gustMph": clean_number(weather.get("gustMph")),
"rainChancePercent": clean_number(weather.get("rainChancePercent")),
"rainTodayIn": clean_number(weather.get("rainTodayIn"), 2),
"uv": clean_number(weather.get("uv"), 1),
"summary": self.clean_ai_text(weather.get("summary"), 180)
}
def serve_family_messages_ai(self):
self.serve_json(self.generate_family_messages_ai_payload())
def generate_family_messages_ai_payload(self):
try:
config = self.load_ai_config_payload(include_secret=True)
if not config.get("enabled"):
return {"error": "AI family messages are disabled. Enable them in Settings → AI."}
if not config.get("apiKey"):
return {"error": "AI API key is missing. Add one in Settings → AI or set OPENROUTER_API_KEY."}
request_payload = self.read_request_json()
schedule = request_payload.get("schedule") if isinstance(request_payload.get("schedule"), dict) else {}
date_label = self.clean_ai_text(schedule.get("dateLabel"), 80) or "Today"
weekday = self.clean_ai_text(schedule.get("weekday"), 24)
notes = schedule.get("notes") if isinstance(schedule.get("notes"), list) else []
events = schedule.get("events") if isinstance(schedule.get("events"), list) else []
weather = self.clean_family_message_weather(schedule.get("weather"))
today_highlights = self.clean_family_message_today_highlights(schedule.get("todayHighlights"))
prompt_count = request_payload.get("messageCount")
try:
prompt_count = max(3, min(int(prompt_count or 4), 6))
except Exception:
prompt_count = 4
cleaned_events = [self.clean_family_message_event(event) for event in events][:8]
cleaned_notes = []
for note in notes:
text = self.clean_family_message_note(note)
if text:
cleaned_notes.append(text)
if len(cleaned_notes) >= 4:
break
has_weather_context = any(
weather.get(key) is not None and weather.get(key) != ""
for key in ["conditions", "currentTempF", "feelsLikeF", "dayHighF", "tonightLowF", "highF", "lowF", "windMph", "rainChancePercent", "summary"]
)
has_today_context = bool(today_highlights.get("history") or today_highlights.get("observances"))
if not cleaned_events and not cleaned_notes and not has_weather_context and not has_today_context:
return {"messages": [], "model": config.get("model"), "promptCount": prompt_count, "reason": "no schedule or weather context"}
custom_family_prompt = self.normalize_family_message_prompt(config.get("familyMessagePrompt") or DEFAULT_FAMILY_MESSAGE_AI_PROMPT)
user_prompt = {
"task": "Generate family dashboard messages from the day's schedule and weather. Return strict JSON only.",
"customInstructions": custom_family_prompt,
"schedule": {
"dateLabel": date_label,
"weekday": weekday,
"events": cleaned_events,
"notes": cleaned_notes,
"weather": weather,
"todayHighlights": today_highlights,
"messageCount": prompt_count,
"timePolicy": "Event start/end values are already local dashboard display times. Do not convert time zones or reinterpret them as UTC."
},
"promptAngles": FAMILY_MESSAGE_AI_PROMPTS[:prompt_count],
"requirements": [
f"Return exactly {prompt_count} short messages, one for each supplied prompt angle.",
"Each message must be 90 characters or fewer and fit on one dashboard line.",
"Each message must be unique, creative, and based on the schedule, weather, or both.",
"Give informational lines a little personality through varied phrasing, light wordplay, or a vivid image when natural. Keep the facts clear and do not turn every line into a plain status report.",
"Vary the opening and sentence shape across messages so the bar does not feel repetitive.",
"Blend weather with events when it is useful, for example heat before an afternoon game or rain before errands.",
"Keep currentTempF as context, but when a message mentions temperature for a specific event, prefer that event's eventWeather.eventTempF.",
"Use dayHighF for broad daytime heat messages and tonightLowF for broad evening/night cooldown messages only when no eventWeather is present.",
"Only blend weather with an event when that event has weatherRelevant=true.",
"Do not mention weather for indoor/non-weather-sensitive events such as haircuts, dental appointments, piano lessons, meetings, or wrestling club.",
"Follow weather.temperatureFocus when present; it tells you whether today's high or tonight's low is the better temperature reference right now.",
"Match weather language to each event's start time/dayPart/weatherTimingHint, not just the overall daily high.",
"For evening or night events, do not write as if the event is under blazing sun, midday heat, or a 99 degree sky. Use current/evening conditions or tonightLowF, or say after a hot day if dayHighF matters.",
"Only mention sun/sky/heat at game time when the event is daytime or the context explicitly supports it.",
"Use the weather object as already-normalized local weather from either Tempest or ZIP forecast.",
"Do not mention missing weather fields; only use weather values that are present.",
"Mention real activities, transitions, timing, or weather details from the provided context.",
"If an event has relativeTimeLabel, use it exactly when it fits the message. For example, say this morning, later today, tonight, tomorrow morning, tomorrow afternoon, tomorrow evening, or this weekend instead of making the event sound like the wrong day.",
"When todayHighlights are present, you may write brief message-bar lines about this day in history, a fun fact, a holiday, or a novelty day only when it is clearly relevant to this family dashboard, for example baseball, softball, ASU/Sun Devils, Georgia Tech, Team USA, major US sports milestones, or broadly recognizable historic moments.",
"Skip history messages when the supplied history item is obscure, niche, trivia-like, or only technically historic. Do not write about horse-racing winners, generic calendar facts, or obscure old sports results.",
"Do not write or invent joke messages. The dashboard adds the exact daily joke separately from the popup content.",
"Keep todayHighlights messages lightweight and occasional; do not let them crowd out important schedule messages.",
"Do not invent holidays, historical facts, dates, or observances. Use only the supplied todayHighlights values.",
"Counts and count-ups are allowed when meaningful, for example anniversaries, birthdays, streaks, milestones, game/season counts, school countdowns, or event-specific countdowns.",
"Never produce generic calendar-countdown filler such as days left in the year, day-of-year numbers, or Gregorian-calendar trivia.",
"Team loyalty comes from each event's calendarName and cheerTeam, not from opponent names in the event title.",
"If an event has cheerTeam, only cheer for that cheerTeam. For example, a Sun Devils calendar event may say Go Sun Devils, and a Bombers calendar event may say Go Bombers.",
"If an event is a practice, BP, batting practice, workout, training, or clinic, do not generate a cheer message or team slogan; treat it as a neutral schedule item.",
"Never cheer for Bombers, Scorpions, Sun Devils, or any other team unless that exact team is the event's cheerTeam or calendarName.",
"If an event has no cheerTeam, avoid team-cheer slogans for that event.",
"Use event times exactly as provided. Do not convert, offset, or infer another timezone.",
"Do not include addresses, street names, venue addresses, or precise location details.",
"Do not give advice or make up private facts.",
"Do not write reminder/nag/prep-advice lines such as pack a snack, bring water, grab sunscreen, wear a jacket, don't forget, remember to, or make sure to unless that exact item is explicitly in schedule notes.",
"Prefer descriptive, upbeat status lines over instructions to the family.",
"Use one tasteful emoji when it fits naturally.",
"Keep each message family-friendly, concise, and playful.",
"Do not include markdown or commentary.",
"Follow customInstructions for tone and preferences, unless they conflict with privacy, JSON, length, calendar-based team loyalty, or schedule-accuracy rules."
],
"jsonShape": {
"messages": [f"short message {index}" for index in range(1, prompt_count + 1)]
}
}
body = json.dumps({
"model": config.get("model") or DEFAULT_AI_CONFIG["model"],
"messages": [
{"role": "system", "content": "You write short, creative, family-friendly dashboard messages using schedule and weather context. Keep each line under 90 characters, use local times exactly as provided, avoid address/location details, use only calendarName/cheerTeam for team loyalty, never infer the supported team from opponent text, and output strict JSON only."},
{"role": "user", "content": json.dumps(user_prompt)}
],
"temperature": 0.75,
"max_tokens": 2000,
"reasoning": {"effort": "low", "exclude": True},
"response_format": {"type": "json_object"}
}).encode("utf-8")
url = f"{config.get('baseUrl').rstrip('/')}/chat/completions"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {config.get('apiKey')}"
}
if config.get("provider") == "openrouter":