personal memory agent
at main 855 lines 29 kB view raw
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)