personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Refactor category system to be fully configurable via JSON

- Add description, followup, output, and iq fields to category JSON schema
- Build categorization prompt dynamically from discovered categories
- Create JSON files for non-followup categories (terminal, code, media, gaming)
- DRY category discovery by having screen.py import from describe.py
- Update messaging to use flash model for better text extraction
- Remove hardcoded category list from describe.txt (now uses ${CATEGORIES})
- Remove manually maintained category table from README (reduces maintenance)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+276 -195
+21 -23
observe/categories/README.md
··· 1 1 # Screen Description Categories 2 2 3 - This directory contains category prompts and formatters for vision analysis of screencast frames. 3 + This directory contains category definitions for vision analysis of screencast frames. 4 4 5 5 ## Adding a New Category 6 6 7 - Each category requires 2-3 files: 7 + Each category requires a `.json` file with metadata, and optionally a `.txt` prompt file for follow-up analysis. 8 8 9 9 ### 1. `<category>.json` (required) 10 10 11 - Metadata specifying the output format: 11 + Defines the category and its behavior: 12 12 13 13 ```json 14 14 { 15 - "output": "markdown" 15 + "description": "One-line description for categorization prompt", 16 + "followup": true, 17 + "output": "markdown", 18 + "iq": "lite" 16 19 } 17 20 ``` 18 21 19 - Set `"output": "json"` if the prompt produces structured JSON. 22 + | Field | Required | Default | Description | 23 + |-------|----------|---------|-------------| 24 + | `description` | Yes | - | Single-line description used in the categorization prompt | 25 + | `followup` | No | `false` | Whether to run follow-up analysis for this category | 26 + | `output` | No | `"markdown"` | Response format: `"json"` or `"markdown"` | 27 + | `iq` | No | `"lite"` | Model tier: `"lite"`, `"flash"`, or `"pro"` | 20 28 21 - ### 2. `<category>.txt` (required) 29 + ### 2. `<category>.txt` (required if `followup: true`) 22 30 23 - The vision prompt template sent to the model. Should instruct the model to: 31 + The vision prompt template sent to the model for detailed analysis. Should instruct the model to: 24 32 - Analyze the screenshot for this specific category 25 - - Return content in the format specified by `.json` (markdown or JSON) 33 + - Return content in the format specified by `output` (markdown or JSON) 26 34 27 35 ### 3. `<category>.py` (optional) 28 36 ··· 50 58 return "**Header:**\n\nFormatted content..." 51 59 ``` 52 60 53 - ## Current Categories 54 - 55 - | Category | Output | Formatter | Description | 56 - |----------|--------|-----------|-------------| 57 - | meeting | json | ✓ | Video conferencing with participants | 58 - | messaging | markdown | - | Chat and email apps | 59 - | browsing | markdown | - | Web browsing content | 60 - | reading | markdown | - | Documents and PDFs | 61 - | productivity | markdown | - | Spreadsheets, calendars, etc. | 62 - 63 61 ## How It Works 64 62 65 - 1. `observe/describe.py` runs initial categorization to identify primary/secondary categories 66 - 2. For categories with prompts here, a follow-up request extracts detailed content 67 - 3. Results are stored in JSONL under the category name (e.g., `"meeting": {...}`) 68 - 4. `observe/screen.py` formats JSONL to markdown, using custom formatters when available 69 - 5. The formatter also supports legacy keys (`meeting_analysis`, `extracted_text`) for older data 63 + 1. `observe/describe.py` discovers all `.json` files and builds the categorization prompt dynamically 64 + 2. Initial categorization identifies primary/secondary categories from the screenshot 65 + 3. For categories with `followup: true`, a follow-up request extracts detailed content using the `.txt` prompt 66 + 4. Results are stored in JSONL under the category name (e.g., `"meeting": {...}`) 67 + 5. `observe/screen.py` formats JSONL to markdown, using custom formatters when available
+2 -2
observe/categories/__init__.py
··· 1 1 """Category prompts and formatters for screen description. 2 2 3 3 This package contains: 4 - - <category>.json: Metadata (output format: json/markdown) 5 - - <category>.txt: Vision prompt template 4 + - <category>.json: Metadata (description, followup, output, iq) 5 + - <category>.txt: Vision prompt template (required if followup=true) 6 6 - <category>.py: Optional formatter for rich markdown output 7 7 8 8 See README.md for adding new categories.
+2
observe/categories/browsing.json
··· 1 1 { 2 + "description": "General web browsing, social feeds, shopping", 3 + "followup": true, 2 4 "output": "markdown" 3 5 }
+3
observe/categories/code.json
··· 1 + { 2 + "description": "Code editors and IDEs" 3 + }
+3
observe/categories/gaming.json
··· 1 + { 2 + "description": "Video games, puzzles, idle games" 3 + }
+3
observe/categories/media.json
··· 1 + { 2 + "description": "Video players/streams, YouTube, image/video-heavy feeds" 3 + }
+4 -1
observe/categories/meeting.json
··· 1 1 { 2 - "output": "json" 2 + "description": "Video calls/conferencing (Zoom, Meet, Teams, Webex, etc.)", 3 + "followup": true, 4 + "output": "json", 5 + "iq": "flash" 3 6 }
+4 -1
observe/categories/messaging.json
··· 1 1 { 2 - "output": "markdown" 2 + "description": "Chat or email apps (Slack, Discord, Messages/iMessage, Gmail, etc.)", 3 + "followup": true, 4 + "output": "markdown", 5 + "iq": "flash" 3 6 }
+2
observe/categories/productivity.json
··· 1 1 { 2 + "description": "Spreadsheets, slides, document editors, calendars, task and issue tracking tools, other workplace desktop or web apps and professional tools", 3 + "followup": true, 2 4 "output": "markdown" 3 5 }
+2
observe/categories/reading.json
··· 1 1 { 2 + "description": "Documents, articles, PDFs, documentation", 3 + "followup": true, 2 4 "output": "markdown" 3 5 }
+3
observe/categories/terminal.json
··· 1 + { 2 + "description": "Command line interfaces, logs, shell" 3 + }
+105 -45
observe/describe.py
··· 57 57 return segment, suffix 58 58 59 59 60 - def _discover_category_prompts() -> dict[str, dict]: 60 + def _discover_categories() -> dict[str, dict]: 61 61 """ 62 - Discover available category prompts from categories/ directory. 62 + Discover all categories from categories/ directory. 63 + 64 + Each category has a .json metadata file with: 65 + - description (required): Single-line description for categorization prompt 66 + - followup (optional, default: false): Whether to run follow-up analysis 67 + - output (optional, default: "markdown"): Response format if followup=true 68 + - iq (optional, default: "lite"): Model tier for follow-up ("lite", "flash", "pro") 63 69 64 - Each category has a .txt prompt and .json metadata file. 70 + If followup=true, a matching .txt file contains the follow-up prompt. 65 71 66 72 Returns 67 73 ------- 68 74 dict[str, dict] 69 - Mapping of category name to metadata (including 'prompt' text) 75 + Mapping of category name to metadata (including 'prompt' if followup=true) 70 76 """ 71 - describe_dir = Path(__file__).parent / "categories" 72 - if not describe_dir.exists(): 73 - logger.warning(f"Category prompts directory not found: {describe_dir}") 77 + from think.models import GEMINI_FLASH, GEMINI_LITE, GEMINI_PRO 78 + 79 + # Map iq values to model constants 80 + iq_to_model = { 81 + "lite": GEMINI_LITE, 82 + "flash": GEMINI_FLASH, 83 + "pro": GEMINI_PRO, 84 + } 85 + 86 + categories_dir = Path(__file__).parent / "categories" 87 + if not categories_dir.exists(): 88 + logger.warning(f"Categories directory not found: {categories_dir}") 74 89 return {} 75 90 76 91 categories = {} 77 - for json_path in describe_dir.glob("*.json"): 92 + for json_path in categories_dir.glob("*.json"): 78 93 category = json_path.stem 79 - txt_path = describe_dir / f"{category}.txt" 80 - 81 - if not txt_path.exists(): 82 - logger.warning(f"Missing prompt file for category {category}: {txt_path}") 83 - continue 84 94 85 95 try: 86 96 with open(json_path) as f: 87 97 metadata = json.load(f) 88 - metadata["prompt"] = txt_path.read_text() 98 + 99 + # Validate required field 100 + if "description" not in metadata: 101 + logger.warning(f"Category {category} missing 'description' field") 102 + continue 103 + 104 + # Apply defaults 105 + metadata.setdefault("followup", False) 106 + metadata.setdefault("output", "markdown") 107 + metadata.setdefault("iq", "lite") 108 + 109 + # Map iq to model constant 110 + iq = metadata["iq"] 111 + if iq not in iq_to_model: 112 + logger.warning( 113 + f"Category {category} has invalid iq '{iq}', using 'lite'" 114 + ) 115 + iq = "lite" 116 + metadata["model"] = iq_to_model[iq] 117 + 118 + # Load prompt if followup is enabled 119 + if metadata["followup"]: 120 + txt_path = categories_dir / f"{category}.txt" 121 + if not txt_path.exists(): 122 + logger.warning( 123 + f"Category {category} has followup=true but no {category}.txt" 124 + ) 125 + continue 126 + metadata["prompt"] = txt_path.read_text() 127 + 89 128 categories[category] = metadata 90 - logger.debug(f"Loaded category prompt: {category}") 129 + logger.debug( 130 + f"Loaded category: {category} (followup={metadata['followup']})" 131 + ) 132 + 91 133 except Exception as e: 92 134 logger.warning(f"Failed to load category {category}: {e}") 93 135 94 136 return categories 95 137 96 138 97 - # Discover category prompts at module level 98 - CATEGORY_PROMPTS = _discover_category_prompts() 139 + def _build_categorization_prompt() -> str: 140 + """ 141 + Build the categorization prompt from template and discovered categories. 142 + 143 + Returns 144 + ------- 145 + str 146 + Complete prompt with category list substituted 147 + """ 148 + template_path = Path(__file__).parent / "describe.txt" 149 + if not template_path.exists(): 150 + raise FileNotFoundError(f"Prompt template not found: {template_path}") 151 + 152 + template = template_path.read_text() 153 + 154 + # Build category list (alphabetical order) 155 + category_lines = [] 156 + for name in sorted(CATEGORIES.keys()): 157 + description = CATEGORIES[name]["description"] 158 + category_lines.append(f"- {name}: {description}") 159 + 160 + category_list = "\n".join(category_lines) 161 + 162 + return template.replace("${CATEGORIES}", category_list) 163 + 164 + 165 + # Discover categories at module level 166 + CATEGORIES = _discover_categories() 167 + 168 + # Build categorization prompt from template 169 + CATEGORIZATION_PROMPT = _build_categorization_prompt() 99 170 100 171 101 172 class VideoProcessor: ··· 254 325 img.save(buf, format="PNG", compress_level=1) 255 326 return buf.getvalue() 256 327 257 - def _get_category_prompt(self, category: str) -> Optional[dict]: 328 + def _get_category_metadata(self, category: str) -> Optional[dict]: 258 329 """ 259 - Get category prompt metadata if available. 330 + Get category metadata if follow-up is enabled. 260 331 261 332 Parameters 262 333 ---------- ··· 266 337 Returns 267 338 ------- 268 339 Optional[dict] 269 - Category metadata with 'prompt' and 'output' keys, or None if no follow-up 340 + Category metadata with 'prompt', 'output', 'model' keys, or None if no follow-up 270 341 """ 271 - return CATEGORY_PROMPTS.get(category) 342 + cat_meta = CATEGORIES.get(category) 343 + if cat_meta and cat_meta.get("followup"): 344 + return cat_meta 345 + return None 272 346 273 347 def _user_contents(self, prompt: str, image, entities: bool = False) -> list: 274 348 """Build contents list with optional entity context.""" ··· 299 373 300 374 async def process_with_vision( 301 375 self, 302 - use_prompt: str = "describe.txt", 303 376 max_concurrent: int = 10, 304 377 output_path: Optional[Path] = None, 305 378 ) -> None: ··· 308 381 309 382 Parameters 310 383 ---------- 311 - use_prompt : str 312 - Prompt template filename to use (default: describe.txt) 313 384 max_concurrent : int 314 385 Maximum number of concurrent API requests (default: 10) 315 386 output_path : Optional[Path] 316 387 Path to write JSONL output (when None, no output file is written) 317 388 """ 318 389 from think.batch import GeminiBatch 319 - from think.models import GEMINI_FLASH, GEMINI_LITE 390 + from think.models import GEMINI_LITE 320 391 321 - # Load primary categorization prompt 322 - prompt_path = Path(__file__).parent / use_prompt 323 - if not prompt_path.exists(): 324 - raise FileNotFoundError(f"Prompt template not found: {prompt_path}") 325 - 326 - system_instruction = prompt_path.read_text() 392 + # Use dynamically built categorization prompt 393 + system_instruction = CATEGORIZATION_PROMPT 327 394 328 395 # Process video to get qualified frames (synchronous) 329 396 qualified_frames = self.process() ··· 411 478 elif req.request_type == RequestType.CATEGORY: 412 479 # Handle category-specific follow-up result 413 480 category = req.follow_up_category 414 - cat_meta = self._get_category_prompt(category) 481 + cat_meta = self._get_category_metadata(category) 415 482 if cat_meta and cat_meta.get("output") == "json": 416 483 try: 417 484 result = json.loads(req.response) ··· 462 529 secondary = req.json_analysis.get("secondary", "none") 463 530 overlap = req.json_analysis.get("overlap", True) 464 531 465 - # Determine which categories have follow-up prompts 466 - primary_meta = self._get_category_prompt(primary) 532 + # Determine which categories have follow-up enabled 533 + primary_meta = self._get_category_metadata(primary) 467 534 secondary_meta = ( 468 - self._get_category_prompt(secondary) 535 + self._get_category_metadata(secondary) 469 536 if secondary != "none" 470 537 else None 471 538 ) 472 539 473 - # Build follow-up list: each category with a prompt gets a follow-up 474 - # Primary always triggers if it has a prompt 540 + # Build follow-up list: each category with followup=true gets analyzed 541 + # Primary always triggers if followup is enabled 475 542 # Secondary triggers only if overlap=false 476 543 follow_ups = [] 477 544 ··· 518 585 full_img, 519 586 entities=True, 520 587 ), 521 - model=GEMINI_FLASH, 588 + model=cat_meta["model"], 522 589 system_instruction=cat_meta["prompt"], 523 590 json_output=is_json, 524 591 max_output_tokens=10240 if is_json else 8192, ··· 680 747 help="Path to video file to process", 681 748 ) 682 749 parser.add_argument( 683 - "--prompt", 684 - type=str, 685 - default="describe.txt", 686 - help="Prompt template to use (default: describe.txt)", 687 - ) 688 - parser.add_argument( 689 750 "-j", 690 751 "--jobs", 691 752 type=int, ··· 734 795 else: 735 796 # New behavior: process with vision analysis 736 797 await processor.process_with_vision( 737 - use_prompt=args.prompt, 738 798 max_concurrent=args.jobs, 739 799 output_path=output_path, 740 800 )
+1 -9
observe/describe.txt
··· 15 15 - Only set a category for secondary if it is very visible and occupies more than 30% of the screen. 16 16 17 17 Categories (choose one): 18 - - terminal: Command line interfaces, logs, shell 19 - - code: Code editors and IDEs 20 - - messaging: Chat or email apps (Slack, Discord, Messages/iMessage, Gmail, etc.) 21 - - meeting: Video calls/conferencing (Zoom, Meet, Teams, Webex, etc.) 22 - - browsing: General web browsing, social feeds, shopping 23 - - reading: Documents, articles, PDFs, documentation 24 - - media: Video players/streams, YouTube, image/video-heavy feeds 25 - - gaming: Video games, puzzles, idle games 26 - - productivity: Spreadsheets, slides, document editors, calendars, task and issue tracking tools, other workplace desktop or web apps and professional tools 18 + ${CATEGORIES}
+4 -18
observe/screen.py
··· 15 15 from pathlib import Path 16 16 from typing import Any, Callable 17 17 18 + from observe.describe import CATEGORIES 18 19 from observe.utils import load_analysis_frames, parse_screen_filename 19 20 20 21 logger = logging.getLogger(__name__) 21 22 23 + # Re-export CATEGORIES for consumers that import from screen 24 + __all__ = ["CATEGORIES", "format_screen", "format_screen_text"] 25 + 22 26 # Cache for discovered category formatters 23 27 _formatter_cache: dict[str, Callable | None] = {} 24 - 25 - 26 - def _discover_categories() -> list[str]: 27 - """Discover available categories from observe/categories/ directory. 28 - 29 - Categories are defined by .json metadata files in the describe/ package. 30 - 31 - Returns: 32 - List of category names (e.g., ["meeting", "messaging", ...]) 33 - """ 34 - describe_dir = Path(__file__).parent / "categories" 35 - if not describe_dir.exists(): 36 - return [] 37 - return sorted(p.stem for p in describe_dir.glob("*.json")) 38 - 39 - 40 - # Discover categories at module load time 41 - CATEGORIES = _discover_categories() 42 28 43 29 44 30 def _load_category_formatter(category: str) -> Callable | None:
+101 -78
tests/test_describe_config.py
··· 1 - """Tests for observe/describe.py category prompt discovery.""" 1 + """Tests for observe/describe.py category discovery and configuration.""" 2 2 3 - from pathlib import Path 4 - from unittest.mock import patch 3 + from observe import describe as describe_module 5 4 6 - import pytest 7 5 8 - # Import the processor module 9 - from observe import describe as describe_module 6 + def test_categories_discovered(): 7 + """Test that categories are discovered on import.""" 8 + CATEGORIES = describe_module.CATEGORIES 10 9 10 + # Should have discovered all 9 categories 11 + assert len(CATEGORIES) == 9 11 12 12 - def test_category_prompts_discovered(): 13 - """Test that category prompts are discovered on import.""" 14 - CATEGORY_PROMPTS = describe_module.CATEGORY_PROMPTS 13 + # All expected categories should be present 14 + expected = [ 15 + "terminal", 16 + "code", 17 + "messaging", 18 + "meeting", 19 + "browsing", 20 + "reading", 21 + "media", 22 + "gaming", 23 + "productivity", 24 + ] 25 + for cat in expected: 26 + assert cat in CATEGORIES, f"Expected category {cat} not found" 15 27 16 - # Should have discovered some category prompts 17 - assert len(CATEGORY_PROMPTS) > 0 18 - # Meeting should be one of them 19 - assert "meeting" in CATEGORY_PROMPTS 20 28 29 + def test_categories_have_required_fields(): 30 + """Test that all categories have required metadata.""" 31 + CATEGORIES = describe_module.CATEGORIES 21 32 22 - def test_category_prompts_have_required_fields(): 23 - """Test that discovered categories have required metadata.""" 24 - CATEGORY_PROMPTS = describe_module.CATEGORY_PROMPTS 33 + for category, metadata in CATEGORIES.items(): 34 + # Every category must have description 35 + assert "description" in metadata, f"Category {category} missing 'description'" 36 + assert isinstance(metadata["description"], str) 37 + assert len(metadata["description"]) > 0 25 38 26 - for category, metadata in CATEGORY_PROMPTS.items(): 27 - # Each category should have 'output' and 'prompt' fields 28 - assert "output" in metadata, f"Category {category} missing 'output' field" 29 - assert "prompt" in metadata, f"Category {category} missing 'prompt' field" 30 - # Output should be 'json' or 'markdown' 31 - assert metadata["output"] in ( 32 - "json", 33 - "markdown", 34 - ), f"Category {category} has invalid output: {metadata['output']}" 35 - # Prompt should be non-empty string 36 - assert isinstance(metadata["prompt"], str) 37 - assert len(metadata["prompt"]) > 0 39 + # Every category should have followup field (defaulted if not set) 40 + assert "followup" in metadata, f"Category {category} missing 'followup'" 41 + assert isinstance(metadata["followup"], bool) 42 + 43 + # Every category should have output field (defaulted if not set) 44 + assert "output" in metadata, f"Category {category} missing 'output'" 45 + assert metadata["output"] in ("json", "markdown") 46 + 47 + # Every category should have model field (mapped from iq) 48 + assert "model" in metadata, f"Category {category} missing 'model'" 38 49 39 50 40 - def test_meeting_category_is_json(): 41 - """Test that meeting category outputs JSON.""" 42 - CATEGORY_PROMPTS = describe_module.CATEGORY_PROMPTS 51 + def test_followup_categories_have_prompts(): 52 + """Test that categories with followup=true have prompt loaded.""" 53 + CATEGORIES = describe_module.CATEGORIES 43 54 44 - assert "meeting" in CATEGORY_PROMPTS 45 - assert CATEGORY_PROMPTS["meeting"]["output"] == "json" 55 + for category, metadata in CATEGORIES.items(): 56 + if metadata["followup"]: 57 + assert ( 58 + "prompt" in metadata 59 + ), f"Category {category} has followup=true but no prompt" 60 + assert isinstance(metadata["prompt"], str) 61 + assert len(metadata["prompt"]) > 0 46 62 47 63 48 - def test_text_categories_are_markdown(): 49 - """Test that text-based categories output markdown.""" 50 - CATEGORY_PROMPTS = describe_module.CATEGORY_PROMPTS 64 + def test_non_followup_categories(): 65 + """Test that non-followup categories don't have prompts.""" 66 + CATEGORIES = describe_module.CATEGORIES 51 67 52 - text_categories = ["messaging", "browsing", "reading", "productivity"] 53 - for category in text_categories: 54 - if category in CATEGORY_PROMPTS: 55 - assert ( 56 - CATEGORY_PROMPTS[category]["output"] == "markdown" 57 - ), f"Category {category} should output markdown" 68 + non_followup = ["terminal", "code", "media", "gaming"] 69 + for category in non_followup: 70 + assert category in CATEGORIES 71 + assert CATEGORIES[category]["followup"] is False 72 + assert "prompt" not in CATEGORIES[category] 58 73 59 74 60 - def test_discover_category_prompts_with_missing_dir(tmp_path): 61 - """Test that discovery handles missing directory gracefully.""" 62 - _discover_category_prompts = describe_module._discover_category_prompts 75 + def test_meeting_category_config(): 76 + """Test that meeting category has correct configuration.""" 77 + CATEGORIES = describe_module.CATEGORIES 63 78 64 - with patch.object(describe_module, "Path") as mock_path: 65 - # Mock to point to non-existent directory 66 - mock_describe_dir = tmp_path / "nonexistent" 67 - mock_path.return_value.parent.__truediv__.return_value = mock_describe_dir 79 + assert "meeting" in CATEGORIES 80 + meeting = CATEGORIES["meeting"] 81 + assert meeting["followup"] is True 82 + assert meeting["output"] == "json" 83 + assert meeting["iq"] == "flash" # Meeting needs flash for complex JSON output 68 84 69 - result = _discover_category_prompts() 70 - assert result == {} 71 85 86 + def test_text_categories_config(): 87 + """Test that text-based categories have correct configuration.""" 88 + CATEGORIES = describe_module.CATEGORIES 72 89 73 - def test_discover_category_prompts_with_valid_dir(tmp_path): 74 - """Test that discovery works with valid category files.""" 75 - _discover_category_prompts = describe_module._discover_category_prompts 90 + text_categories = ["messaging", "browsing", "reading", "productivity"] 91 + for category in text_categories: 92 + assert category in CATEGORIES 93 + cat_meta = CATEGORIES[category] 94 + assert cat_meta["followup"] is True 95 + assert cat_meta["output"] == "markdown" 76 96 77 - # Create test category directory 78 - categories_dir = tmp_path / "categories" 79 - categories_dir.mkdir() 97 + # Messaging uses flash for better text extraction 98 + assert CATEGORIES["messaging"]["iq"] == "flash" 99 + # Others default to lite 100 + for category in ["browsing", "reading", "productivity"]: 101 + assert CATEGORIES[category].get("iq", "lite") == "lite" 80 102 81 - # Create test category files 82 - (categories_dir / "test.json").write_text('{"output": "markdown"}') 83 - (categories_dir / "test.txt").write_text("Test prompt content") 84 103 85 - with patch.object(describe_module, "Path") as mock_path: 86 - mock_path.return_value.parent.__truediv__.return_value = categories_dir 104 + def test_categorization_prompt_built(): 105 + """Test that categorization prompt is built correctly.""" 106 + prompt = describe_module.CATEGORIZATION_PROMPT 87 107 88 - result = _discover_category_prompts() 89 - assert "test" in result 90 - assert result["test"]["output"] == "markdown" 91 - assert result["test"]["prompt"] == "Test prompt content" 108 + # Should contain all category descriptions 109 + for category, metadata in describe_module.CATEGORIES.items(): 110 + assert f"- {category}:" in prompt 111 + assert metadata["description"] in prompt 92 112 113 + # Should have the template structure 114 + assert "primary" in prompt 115 + assert "secondary" in prompt 116 + assert "overlap" in prompt 117 + assert "Categories (choose one):" in prompt 93 118 94 - def test_discover_category_prompts_skips_incomplete(tmp_path): 95 - """Test that discovery skips categories without matching txt file.""" 96 - _discover_category_prompts = describe_module._discover_category_prompts 97 119 98 - # Create test category directory 99 - categories_dir = tmp_path / "categories" 100 - categories_dir.mkdir() 120 + def test_categorization_prompt_alphabetical(): 121 + """Test that categories in prompt are alphabetically ordered.""" 122 + prompt = describe_module.CATEGORIZATION_PROMPT 101 123 102 - # Create JSON without matching txt 103 - (categories_dir / "incomplete.json").write_text('{"output": "json"}') 124 + # Extract category lines from prompt 125 + lines = prompt.split("\n") 126 + category_lines = [l for l in lines if l.startswith("- ") and ":" in l] 104 127 105 - with patch.object(describe_module, "Path") as mock_path: 106 - mock_path.return_value.parent.__truediv__.return_value = categories_dir 128 + # Extract category names 129 + categories = [l.split(":")[0].replace("- ", "") for l in category_lines] 107 130 108 - result = _discover_category_prompts() 109 - assert "incomplete" not in result 131 + # Should be sorted 132 + assert categories == sorted(categories)
+16 -18
tests/test_screen_formatter.py
··· 4 4 5 5 from observe.screen import ( 6 6 CATEGORIES, 7 - _discover_categories, 8 7 _load_category_formatter, 9 8 format_screen, 10 9 format_screen_text, ··· 410 409 assert "| Task | Status |" in markdown 411 410 412 411 413 - def test_categories_list(): 414 - """Test that CATEGORIES includes expected values.""" 415 - assert "meeting" in CATEGORIES 416 - assert "messaging" in CATEGORIES 417 - assert "browsing" in CATEGORIES 418 - assert "reading" in CATEGORIES 419 - assert "productivity" in CATEGORIES 420 - 421 - 422 - def test_discover_categories_finds_json_files(): 423 - """Test that _discover_categories finds categories from .json files.""" 424 - categories = _discover_categories() 425 - # Should find categories defined by .json files in describe/ 426 - assert len(categories) > 0 427 - assert "meeting" in categories 428 - # Should be sorted 429 - assert categories == sorted(categories) 412 + def test_categories_includes_all_expected(): 413 + """Test that CATEGORIES includes all expected values.""" 414 + expected = [ 415 + "terminal", 416 + "code", 417 + "messaging", 418 + "meeting", 419 + "browsing", 420 + "reading", 421 + "media", 422 + "gaming", 423 + "productivity", 424 + ] 425 + for cat in expected: 426 + assert cat in CATEGORIES, f"Expected category {cat} not found" 427 + assert len(CATEGORIES) == 9