personal memory agent
1# SPDX-License-Identifier: AGPL-3.0-only
2# Copyright (c) 2026 sol pbc
3
4"""Activities system for tracking common activity types per facet.
5
6Activities provide a consistent vocabulary for tagging time segments,
7screen observations, and extracted events across the journal.
8
9Also provides utilities for activity records — completed activity spans
10stored as facets/{facet}/activities/{day}.jsonl.
11"""
12
13import json
14import logging
15import os
16import re
17from pathlib import Path
18from typing import Any
19
20from think.utils import get_journal, segment_parse
21
22logger = logging.getLogger(__name__)
23
24# ---------------------------------------------------------------------------
25# Default Activities
26#
27# These are predefined common activities that users can attach to facets.
28# They serve as a starting vocabulary - facets must explicitly attach them.
29# ---------------------------------------------------------------------------
30
31DEFAULT_ACTIVITIES: list[dict[str, str]] = [
32 {
33 "id": "meeting",
34 "name": "Meetings",
35 "description": "Video calls, in-person meetings, and conferences",
36 "icon": "📅",
37 "always_on": True,
38 "instructions": (
39 "Levels: high=actively speaking/presenting, medium=listening attentively,"
40 " low=muted or multitasking during call."
41 " Detect via: video call UI, multiple speakers, calendar event visible."
42 ),
43 },
44 {
45 "id": "coding",
46 "name": "Coding",
47 "description": "Programming, code review, and debugging",
48 "icon": "💻",
49 "instructions": (
50 "Levels: high=writing or debugging code, medium=reading/reviewing code,"
51 " low=IDE or editor open but not focused."
52 " Detect via: editors, terminals with dev tools, AI coding assistants,"
53 " git operations. Includes focused code reading and thinking."
54 ),
55 },
56 {
57 "id": "browsing",
58 "name": "Browsing",
59 "description": "Web browsing, research, and reading online",
60 "icon": "🌐",
61 "instructions": (
62 "Levels: high=actively navigating/researching, medium=reading a page,"
63 " low=browser open but idle."
64 " Detect via: browser tabs, URL changes, search queries."
65 ),
66 },
67 {
68 "id": "email",
69 "name": "Email",
70 "description": "Email reading and composition",
71 "icon": "📧",
72 "always_on": True,
73 "instructions": (
74 "Levels: high=composing or actively reading email,"
75 " medium=scanning inbox, low=email client visible but idle."
76 " Detect via: email client UI, inbox view, compose window."
77 ),
78 },
79 {
80 "id": "messaging",
81 "name": "Messaging",
82 "description": "Chat, Slack, Discord, and text messaging",
83 "icon": "💬",
84 "always_on": True,
85 "instructions": (
86 "Levels: high=active conversation, medium=reading messages,"
87 " low=chat app visible but idle."
88 " Detect via: chat app UI, message notifications, typing indicators."
89 ),
90 },
91 {
92 "id": "ai_conversation",
93 "name": "AI Conversation",
94 "description": "Conversations with AI assistants like ChatGPT, Claude, and Gemini",
95 "icon": "🤖",
96 "instructions": (
97 "Levels: high=actively prompting and reading responses,"
98 " medium=reviewing AI output or refining prompts,"
99 " low=AI chat open but idle."
100 " Detect via: AI assistant interfaces (ChatGPT, Claude, Gemini),"
101 " imported AI conversation transcripts, prompt-response patterns."
102 " Do not confuse with messaging — AI conversation involves"
103 " a human interacting with an AI model, not person-to-person chat."
104 ),
105 },
106 {
107 "id": "writing",
108 "name": "Writing",
109 "description": "Documents, notes, and long-form writing",
110 "icon": "✍️",
111 "instructions": (
112 "Levels: high=actively composing text, medium=editing/revising,"
113 " low=document open but not being edited."
114 " Detect via: document editors, note apps, text content changing."
115 ),
116 },
117 {
118 "id": "reading",
119 "name": "Reading",
120 "description": "Books, PDFs, articles, highlights, and documentation",
121 "icon": "📖",
122 "instructions": (
123 "Levels: high=focused reading, medium=skimming content,"
124 " low=document open but attention elsewhere."
125 " Detect via: PDF viewers, article pages, documentation sites,"
126 " reading apps, imported book highlights and annotations."
127 " Do not use for reading code — that is coding."
128 ),
129 },
130 {
131 "id": "video",
132 "name": "Video",
133 "description": "Watching videos and streaming content",
134 "icon": "🎬",
135 "instructions": (
136 "Levels: high=actively watching, medium=video playing while"
137 " doing something else, low=video paused or minimized."
138 " Detect via: video player UI, streaming sites, playback controls."
139 ),
140 },
141 {
142 "id": "gaming",
143 "name": "Gaming",
144 "description": "Games and entertainment",
145 "icon": "🎮",
146 "instructions": (
147 "Levels: high=actively playing, medium=in menus or waiting,"
148 " low=game open but tabbed out."
149 " Detect via: game window, controller input, game UI elements."
150 ),
151 },
152 {
153 "id": "social",
154 "name": "Social Media",
155 "description": "Social media browsing and interaction",
156 "icon": "📱",
157 "instructions": (
158 "Levels: high=posting or actively engaging, medium=scrolling feed,"
159 " low=social app open but idle."
160 " Detect via: social media sites/apps, feed content, post composition."
161 ),
162 },
163 {
164 "id": "planning",
165 "name": "Planning",
166 "description": "Scheduling, calendar management, meeting preparation, and agenda setting",
167 "icon": "📋",
168 "instructions": (
169 "Levels: high=actively scheduling or preparing agendas,"
170 " medium=reviewing calendar or event details,"
171 " low=calendar visible but not being interacted with."
172 " Detect via: calendar apps, scheduling interfaces, event creation,"
173 " imported calendar events, meeting invitations, agenda drafting."
174 " Use for scheduling and preparation work."
175 " Do not confuse with meeting — planning is the preparation,"
176 " meeting is the actual synchronous interaction."
177 ),
178 },
179 {
180 "id": "productivity",
181 "name": "Productivity",
182 "description": "Spreadsheets, slides, and task management",
183 "icon": "📊",
184 "instructions": (
185 "Levels: high=actively editing or organizing, medium=reviewing data,"
186 " low=app open but not focused."
187 " Detect via: spreadsheet/slide editors, project management tools,"
188 " task boards."
189 ),
190 },
191 {
192 "id": "terminal",
193 "name": "Terminal",
194 "description": "Command line and shell sessions",
195 "icon": "⌨️",
196 "instructions": (
197 "Levels: high=running commands or scripts, medium=reading output,"
198 " low=terminal open but idle."
199 " Detect via: shell prompts, command output, tmux/screen sessions."
200 " If terminal use is clearly coding-related, prefer coding instead."
201 ),
202 },
203 {
204 "id": "design",
205 "name": "Design",
206 "description": "Design tools and image editing",
207 "icon": "🎨",
208 "instructions": (
209 "Levels: high=actively creating or editing, medium=reviewing designs,"
210 " low=design tool open but idle."
211 " Detect via: design apps (Figma, Photoshop, etc), canvas editing."
212 ),
213 },
214 {
215 "id": "music",
216 "name": "Music",
217 "description": "Music listening and audio",
218 "icon": "🎵",
219 "instructions": (
220 "Levels: high=actively choosing or browsing music,"
221 " medium=playlist running while working, low=ambient background audio."
222 " Detect via: music player UI, audio playback indicators."
223 ),
224 },
225]
226
227
228def get_default_activities() -> list[dict[str, str]]:
229 """Return the predefined activities list.
230
231 These are common activities that users can attach to facets.
232 Returns a copy to prevent mutation.
233 """
234 return [dict(a) for a in DEFAULT_ACTIVITIES]
235
236
237def _get_activities_path(facet: str) -> Path:
238 """Get the path to a facet's activities.jsonl file."""
239 return Path(get_journal()) / "facets" / facet / "activities" / "activities.jsonl"
240
241
242def _load_activities_jsonl(facet: str) -> list[dict[str, Any]]:
243 """Load raw activities from a facet's JSONL file.
244
245 Returns empty list if file doesn't exist.
246 """
247 path = _get_activities_path(facet)
248 if not path.exists():
249 return []
250
251 activities = []
252 with open(path, "r", encoding="utf-8") as f:
253 for line_num, line in enumerate(f, 1):
254 line = line.strip()
255 if line:
256 try:
257 activities.append(json.loads(line))
258 except json.JSONDecodeError as e:
259 logger.warning(
260 "Skipping malformed line %d in %s: %s", line_num, path, e
261 )
262 continue
263 return activities
264
265
266def _save_activities_jsonl(facet: str, activities: list[dict[str, Any]]) -> None:
267 """Save activities to a facet's JSONL file."""
268 path = _get_activities_path(facet)
269 path.parent.mkdir(parents=True, exist_ok=True)
270
271 with open(path, "w", encoding="utf-8") as f:
272 for activity in activities:
273 f.write(json.dumps(activity, ensure_ascii=False) + "\n")
274
275
276def get_facet_activities(facet: str) -> list[dict[str, Any]]:
277 """Load activities attached to a facet.
278
279 Returns activities explicitly attached to the facet plus any default
280 activities marked ``always_on``. Always-on activities are auto-included
281 even if the facet's ``activities.jsonl`` does not list them.
282
283 Args:
284 facet: Facet name
285
286 Returns:
287 List of activity dicts with keys:
288 - id: Activity identifier
289 - name: Display name
290 - description: Activity description
291 - icon: Emoji icon (if predefined)
292 - priority: "high", "normal", or "low"
293 - custom: True if user-created (not in defaults)
294 - always_on: True if auto-included from defaults
295 """
296 # Build lookup for defaults
297 defaults_by_id = {a["id"]: a for a in DEFAULT_ACTIVITIES}
298
299 # Load facet-specific activities
300 facet_activities = _load_activities_jsonl(facet)
301
302 # If no explicit activities configured, use all defaults as the vocabulary
303 if not facet_activities:
304 result = []
305 for default in DEFAULT_ACTIVITIES:
306 activity = dict(default)
307 activity["custom"] = False
308 activity.setdefault("priority", "normal")
309 result.append(activity)
310 return result
311
312 seen_ids: set[str] = set()
313 result = []
314 for fa in facet_activities:
315 activity_id = fa.get("id")
316 if not activity_id:
317 continue
318
319 seen_ids.add(activity_id)
320
321 # Start with default metadata if predefined
322 if activity_id in defaults_by_id:
323 activity = dict(defaults_by_id[activity_id])
324 activity["custom"] = False
325 else:
326 activity = {"id": activity_id, "custom": True}
327
328 # Apply facet overrides
329 if "name" in fa:
330 activity["name"] = fa["name"]
331 if "description" in fa:
332 activity["description"] = fa["description"]
333 if "priority" in fa:
334 activity["priority"] = fa["priority"]
335 if "icon" in fa:
336 activity["icon"] = fa["icon"]
337 if "instructions" in fa:
338 activity["instructions"] = fa["instructions"]
339
340 # Ensure required fields have defaults
341 activity.setdefault("name", activity_id.replace("_", " ").title())
342 activity.setdefault("description", "")
343 activity.setdefault("priority", "normal")
344
345 result.append(activity)
346
347 # Auto-include always-on defaults not already attached
348 for default in DEFAULT_ACTIVITIES:
349 if default.get("always_on") and default["id"] not in seen_ids:
350 activity = dict(default)
351 activity["custom"] = False
352 activity.setdefault("priority", "normal")
353 result.append(activity)
354
355 return result
356
357
358def save_facet_activities(facet: str, activities: list[dict[str, Any]]) -> None:
359 """Save activities configuration for a facet.
360
361 Args:
362 facet: Facet name
363 activities: List of activity dicts to save. Each should have at minimum:
364 - id: Activity identifier
365 For custom activities, also include:
366 - name: Display name
367 - description: Activity description
368 Optional for all:
369 - priority: "high", "normal", or "low"
370 - icon: Emoji icon
371 - instructions: Detection/level instructions for the LLM
372 """
373 # Build lookup for defaults to determine what needs to be stored
374 defaults_by_id = {a["id"]: a for a in DEFAULT_ACTIVITIES}
375
376 entries = []
377 for activity in activities:
378 activity_id = activity.get("id")
379 if not activity_id:
380 continue
381
382 entry: dict[str, Any] = {"id": activity_id}
383
384 # For predefined activities, only store overrides
385 if activity_id in defaults_by_id:
386 default = defaults_by_id[activity_id]
387
388 # Store description only if different from default
389 if activity.get("description") and activity["description"] != default.get(
390 "description"
391 ):
392 entry["description"] = activity["description"]
393
394 # Store instructions only if different from default
395 if activity.get("instructions") and activity["instructions"] != default.get(
396 "instructions"
397 ):
398 entry["instructions"] = activity["instructions"]
399
400 # Store priority if set
401 if activity.get("priority") and activity["priority"] != "normal":
402 entry["priority"] = activity["priority"]
403
404 else:
405 # Custom activity - store all fields
406 entry["custom"] = True
407 if activity.get("name"):
408 entry["name"] = activity["name"]
409 if activity.get("description"):
410 entry["description"] = activity["description"]
411 if activity.get("instructions"):
412 entry["instructions"] = activity["instructions"]
413 if activity.get("priority"):
414 entry["priority"] = activity["priority"]
415 if activity.get("icon"):
416 entry["icon"] = activity["icon"]
417
418 entries.append(entry)
419
420 _save_activities_jsonl(facet, entries)
421
422
423def get_activity_by_id(facet: str, activity_id: str) -> dict[str, Any] | None:
424 """Look up a specific activity by ID.
425
426 Args:
427 facet: Facet name
428 activity_id: Activity identifier
429
430 Returns:
431 Activity dict if found, None otherwise
432 """
433 activities = get_facet_activities(facet)
434 for activity in activities:
435 if activity.get("id") == activity_id:
436 return activity
437 return None
438
439
440def generate_activity_id(name: str) -> str:
441 """Generate a slug ID from an activity name.
442
443 Args:
444 name: Activity display name
445
446 Returns:
447 Slug identifier (lowercase, underscores)
448 """
449 # Lowercase and replace non-alphanumeric with underscores
450 slug = re.sub(r"[^a-z0-9]+", "_", name.lower())
451 # Remove leading/trailing underscores
452 slug = slug.strip("_")
453 return slug or "activity"
454
455
456def add_activity_to_facet(
457 facet: str,
458 activity_id: str,
459 *,
460 name: str | None = None,
461 description: str | None = None,
462 instructions: str | None = None,
463 priority: str = "normal",
464 icon: str | None = None,
465) -> dict[str, Any]:
466 """Add an activity to a facet.
467
468 For predefined activities, only activity_id is required.
469 For custom activities, name and description should be provided.
470
471 Args:
472 facet: Facet name
473 activity_id: Activity identifier
474 name: Display name (required for custom activities)
475 description: Activity description
476 instructions: Detection/level instructions for the LLM
477 priority: "high", "normal", or "low"
478 icon: Emoji icon
479
480 Returns:
481 The added activity dict
482 """
483 # Check if already explicitly attached (in JSONL, not just defaults)
484 existing_raw = _load_activities_jsonl(facet)
485 for entry in existing_raw:
486 if entry.get("id") == activity_id:
487 # Already attached - return full activity with defaults merged
488 return get_activity_by_id(facet, activity_id) or entry
489
490 # Build new activity entry
491 defaults_by_id = {a["id"]: a for a in DEFAULT_ACTIVITIES}
492
493 if activity_id in defaults_by_id:
494 # Predefined activity
495 activity: dict[str, Any] = {"id": activity_id}
496 if description:
497 activity["description"] = description
498 if instructions:
499 activity["instructions"] = instructions
500 if priority and priority != "normal":
501 activity["priority"] = priority
502 else:
503 # Custom activity
504 activity = {
505 "id": activity_id,
506 "custom": True,
507 "name": name or activity_id.replace("_", " ").title(),
508 "description": description or "",
509 }
510 if instructions:
511 activity["instructions"] = instructions
512 if priority and priority != "normal":
513 activity["priority"] = priority
514 if icon:
515 activity["icon"] = icon
516
517 # Add to existing activities and save
518 existing_raw = _load_activities_jsonl(facet)
519 existing_raw.append(activity)
520 _save_activities_jsonl(facet, existing_raw)
521
522 # Return the full activity with defaults merged
523 return get_activity_by_id(facet, activity_id) or activity
524
525
526def remove_activity_from_facet(facet: str, activity_id: str) -> bool:
527 """Remove an activity from a facet.
528
529 Args:
530 facet: Facet name
531 activity_id: Activity identifier to remove
532
533 Returns:
534 True if activity was removed, False if not found
535 """
536 existing = _load_activities_jsonl(facet)
537 new_list = [a for a in existing if a.get("id") != activity_id]
538
539 if len(new_list) == len(existing):
540 # Nothing removed
541 return False
542
543 _save_activities_jsonl(facet, new_list)
544 return True
545
546
547def update_activity_in_facet(
548 facet: str,
549 activity_id: str,
550 *,
551 description: str | None = None,
552 instructions: str | None = None,
553 priority: str | None = None,
554 name: str | None = None,
555 icon: str | None = None,
556) -> dict[str, Any] | None:
557 """Update an activity's configuration in a facet.
558
559 Args:
560 facet: Facet name
561 activity_id: Activity identifier
562 description: New description (None to keep existing)
563 instructions: New detection/level instructions (None to keep existing)
564 priority: New priority (None to keep existing)
565 name: New name - only applies to custom activities
566 icon: New icon - only applies to custom activities
567
568 Returns:
569 Updated activity dict, or None if not found
570 """
571 existing = _load_activities_jsonl(facet)
572 defaults_by_id = {a["id"]: a for a in DEFAULT_ACTIVITIES}
573
574 found = False
575 for activity in existing:
576 if activity.get("id") == activity_id:
577 found = True
578
579 if description is not None:
580 if description == "" and activity_id in defaults_by_id:
581 activity.pop("description", None)
582 else:
583 activity["description"] = description
584 if instructions is not None:
585 if instructions == "" and activity_id in defaults_by_id:
586 activity.pop("instructions", None)
587 else:
588 activity["instructions"] = instructions
589 if priority is not None:
590 if priority == "normal" and activity_id in defaults_by_id:
591 activity.pop("priority", None)
592 else:
593 activity["priority"] = priority
594
595 # Only allow name/icon changes for custom activities
596 if activity.get("custom") or activity_id not in defaults_by_id:
597 if name is not None:
598 activity["name"] = name
599 if icon is not None:
600 activity["icon"] = icon
601
602 break
603
604 if not found:
605 return None
606
607 _save_activities_jsonl(facet, existing)
608 return get_activity_by_id(facet, activity_id)
609
610
611# ---------------------------------------------------------------------------
612# Activity State — per-segment activity state loading
613# ---------------------------------------------------------------------------
614
615
616def load_segment_activity_state(
617 day: str, segment: str, facet: str, activity_type: str
618) -> dict[str, Any] | None:
619 """Load activity state for a specific activity from a segment.
620
621 Reads the activity_state.json written by the activity_state generator
622 for a given segment and facet, and returns the entry matching the
623 requested activity type.
624
625 Args:
626 day: Day in YYYYMMDD format
627 segment: Segment key (HHMMSS_LEN)
628 facet: Facet name
629 activity_type: Activity type to find (e.g., "coding", "meeting")
630
631 Returns:
632 Activity state dict with keys like activity, state, description,
633 level, active_entities — or None if not found.
634 """
635 from think.cluster import _find_segment_dir
636
637 stream = os.environ.get("SOL_STREAM")
638 seg_dir = _find_segment_dir(day, segment, stream)
639 if not seg_dir:
640 return None
641
642 state_path = seg_dir / "agents" / facet / "activity_state.json"
643 if not state_path.exists():
644 return None
645
646 try:
647 with open(state_path, "r", encoding="utf-8") as f:
648 states = json.load(f)
649 except (json.JSONDecodeError, OSError):
650 return None
651
652 if not isinstance(states, list):
653 return None
654
655 for entry in states:
656 if entry.get("activity") == activity_type:
657 return entry
658
659 return None
660
661
662# ---------------------------------------------------------------------------
663# Activity Records — completed activity spans
664# ---------------------------------------------------------------------------
665
666
667def make_activity_id(activity_type: str, since_segment: str) -> str:
668 """Build activity record ID from type and start segment key.
669
670 Format: {activity_type}_{since_segment}, e.g. "coding_095809_303".
671 Used by both activity_state (live tracking) and activities (records).
672 """
673 return f"{activity_type}_{since_segment}"
674
675
676LEVEL_VALUES = {"high": 1.0, "medium": 0.5, "low": 0.25}
677
678
679def _get_records_path(facet: str, day: str) -> Path:
680 """Get path to a facet's activity records file for a day."""
681 return Path(get_journal()) / "facets" / facet / "activities" / f"{day}.jsonl"
682
683
684def get_activity_output_path(
685 facet: str,
686 day: str,
687 activity_id: str,
688 key: str,
689 output_format: str | None = None,
690) -> Path:
691 """Return output path for an activity-scheduled agent.
692
693 Output lives under the facet's activities directory, grouped by day
694 and activity record ID:
695
696 facets/{facet}/activities/{day}/{activity_id}/{agent}.{ext}
697
698 Args:
699 facet: Facet name
700 day: Day in YYYYMMDD format
701 activity_id: Activity record ID (e.g., "coding_095809_303")
702 key: Agent key (e.g., "session_review", "chat:analysis")
703 output_format: "json" for JSON, anything else for markdown
704
705 Returns:
706 Absolute path for the output file
707 """
708 from think.talent import get_output_name
709
710 output_name = get_output_name(key)
711 ext = "json" if output_format == "json" else "md"
712 return (
713 Path(get_journal())
714 / "facets"
715 / facet
716 / "activities"
717 / day
718 / activity_id
719 / f"{output_name}.{ext}"
720 )
721
722
723def load_activity_records(facet: str, day: str) -> list[dict[str, Any]]:
724 """Load activity records for a facet and day.
725
726 Returns list of record dicts, empty list if file doesn't exist.
727 """
728 path = _get_records_path(facet, day)
729 if not path.exists():
730 return []
731
732 records = []
733 with open(path, "r", encoding="utf-8") as f:
734 for line in f:
735 line = line.strip()
736 if line:
737 try:
738 records.append(json.loads(line))
739 except json.JSONDecodeError:
740 continue
741 return records
742
743
744def load_record_ids(facet: str, day: str) -> set[str]:
745 """Load just the IDs of existing activity records for idempotency checks."""
746 return {r["id"] for r in load_activity_records(facet, day) if "id" in r}
747
748
749def append_activity_record(
750 facet: str, day: str, record: dict[str, Any], *, _checked: bool = False
751) -> bool:
752 """Append an activity record to the facet's day file.
753
754 Checks for duplicate ID — returns False if record already exists.
755
756 Args:
757 facet: Facet name
758 day: Day in YYYYMMDD format
759 record: Activity record dict (must have 'id' field)
760 _checked: If True, skip the duplicate ID check (caller already verified).
761
762 Returns:
763 True if record was written, False if duplicate ID found.
764 """
765 path = _get_records_path(facet, day)
766
767 if not _checked:
768 # Check for existing ID
769 existing_ids = load_record_ids(facet, day)
770 if record.get("id") in existing_ids:
771 return False
772
773 path.parent.mkdir(parents=True, exist_ok=True)
774 with open(path, "a", encoding="utf-8") as f:
775 f.write(json.dumps(record, ensure_ascii=False) + "\n")
776 return True
777
778
779def update_record_description(
780 facet: str, day: str, record_id: str, description: str
781) -> bool:
782 """Update the description of an existing activity record.
783
784 Rewrites the JSONL file atomically (write temp + rename) with the updated
785 description for the matching record.
786
787 Returns True if record was found and updated, False otherwise.
788 """
789 import tempfile
790
791 path = _get_records_path(facet, day)
792 if not path.exists():
793 return False
794
795 lines = path.read_text(encoding="utf-8").splitlines()
796 updated = False
797 new_lines = []
798 for line in lines:
799 line = line.strip()
800 if not line:
801 continue
802 try:
803 record = json.loads(line)
804 except json.JSONDecodeError:
805 new_lines.append(line)
806 continue
807
808 if record.get("id") == record_id:
809 record["description"] = description
810 updated = True
811
812 new_lines.append(json.dumps(record, ensure_ascii=False))
813
814 if updated:
815 content = "\n".join(new_lines) + "\n"
816 fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
817 try:
818 with os.fdopen(fd, "w", encoding="utf-8") as f:
819 f.write(content)
820 os.replace(tmp, path)
821 except BaseException:
822 os.unlink(tmp)
823 raise
824
825 return updated
826
827
828def estimate_duration_minutes(segments: list[str]) -> int:
829 """Estimate total duration in minutes from a list of segment keys.
830
831 Parses each HHMMSS_LEN segment key, sums the durations, returns minutes.
832 Returns 1 as a minimum (for empty or unparseable inputs).
833 """
834 from datetime import datetime as dt
835
836 total_seconds = 0
837 for seg in segments:
838 start, end = segment_parse(seg)
839 if start is not None and end is not None:
840 dt_start = dt(2000, 1, 1, start.hour, start.minute, start.second)
841 dt_end = dt(2000, 1, 1, end.hour, end.minute, end.second)
842 total_seconds += (dt_end - dt_start).total_seconds()
843 return max(1, int(total_seconds / 60))
844
845
846def level_avg(levels: list[str]) -> float:
847 """Compute average engagement level from a list of level strings.
848
849 Maps: high=1.0, medium=0.5, low=0.25. Unknown values use 0.5.
850 Returns rounded to 2 decimal places.
851 """
852 if not levels:
853 return 0.5
854 values = [LEVEL_VALUES.get(level, 0.5) for level in levels]
855 return round(sum(values) / len(values), 2)