diff --git a/apps/chat/routes.py b/apps/chat/routes.py index 21b1bb53b..f45731dec 100644 --- a/apps/chat/routes.py +++ b/apps/chat/routes.py @@ -6,6 +6,7 @@ from __future__ import annotations import logging import os import time +from pathlib import Path from typing import Any from flask import Blueprint, jsonify, render_template, request @@ -93,13 +94,6 @@ def index(): return render_template("app.html") -TITLE_SYSTEM_INSTRUCTION = ( - "Take the user provided text and come up with a three word title that " - "concisely but uniquely identifies the user's request for quick reference " - "and recall. Output only the three word title, nothing else." -) - - def _check_provider_api_key(provider: str) -> str | None: """Check if provider API key is set and return error message if not. @@ -119,11 +113,14 @@ def _check_provider_api_key(provider: str) -> str | None: def generate_chat_title(message: str) -> str: """Generate a short title for a chat message using configured provider.""" + from think.utils import load_prompt + + prompt = load_prompt("title", base_dir=Path(__file__).parent) try: title = generate( contents=message, context="app.chat.title", - system_instruction=TITLE_SYSTEM_INSTRUCTION, + system_instruction=prompt.text, max_output_tokens=50, timeout_s=10, ).strip() diff --git a/apps/chat/title.md b/apps/chat/title.md new file mode 100644 index 000000000..9cb08a4f8 --- /dev/null +++ b/apps/chat/title.md @@ -0,0 +1,7 @@ +--- +context: app.chat.title +tier: 3 +label: Chat Title Generation +group: Apps +--- +Take the user provided text and come up with a three word title that concisely but uniquely identifies the user's request for quick reference and recall. Output only the three word title, nothing else. diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 7ddbbb3d0..5940bbd35 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -249,19 +249,18 @@ Context strings determine provider and model selection. Providers receive alread - Other contexts: `{module}.{feature}[.{operation}]` - Examples: `observe.describe.frame`, `app.chat.title` -**Dynamic discovery:** Categories and muse configs express their own tier/label/group in their configs: -- Categories: `observe/categories/*.json` - add `tier`, `label`, `group` fields +**Dynamic discovery:** All context metadata (tier/label/group) is defined in prompt .md files via YAML frontmatter: +- Prompt files: Listed in `PROMPT_PATHS` in `think/models.py` - add `context`, `tier`, `label`, `group` fields +- Categories: `observe/categories/*.md` - add `tier`, `label`, `group` fields - System muse: `muse/*.md` - add `tier`, `label`, `group` fields in frontmatter - App muse: `apps/*/muse/*.md` - add `tier`, `label`, `group` fields in frontmatter -These are discovered at runtime and merged with static defaults. Use `get_context_registry()` to get the complete context map including discovered entries. - -See `CONTEXT_DEFAULTS` in `think/models.py` for static context patterns (non-discoverable contexts like `observe.detect.*`). +All contexts are discovered at runtime. Use `get_context_registry()` to get the complete context map. **Resolution** (handled by `think/models.py` `resolve_provider()`): 1. Exact match in journal.json `providers.contexts` 2. Glob pattern match (fnmatch) with specificity ranking -3. Dynamic context registry (static defaults + discovered categories/agents) +3. Dynamic context registry (discovered prompts, categories, muse configs) 4. Default provider/tier from config Providers don't implement routing - they receive the resolved model. diff --git a/observe/describe.md b/observe/describe.md index 20022085a..8cae7409d 100644 --- a/observe/describe.md +++ b/observe/describe.md @@ -1,3 +1,9 @@ +--- +context: observe.describe.frame +tier: 3 +label: Screen Categorization +group: Observe +--- You have one job: identify the primary foreground and (if present) secondary app categories in this desktop screenshot, and return ONLY this JSON: { diff --git a/observe/describe.py b/observe/describe.py index 01d69e117..e7bcd0d7a 100644 --- a/observe/describe.py +++ b/observe/describe.py @@ -420,7 +420,7 @@ class VideoProcessor: output_file.write(json.dumps(metadata) + "\n") output_file.flush() - # Resolve model for frame description (tier comes from CONTEXT_DEFAULTS) + # Resolve model for frame description (tier from describe.md frontmatter) _, frame_model = resolve_provider("observe.describe.frame") # Create vision requests for all qualified frames diff --git a/observe/enrich.md b/observe/enrich.md index c825db963..76d53a2c7 100644 --- a/observe/enrich.md +++ b/observe/enrich.md @@ -1,3 +1,9 @@ +--- +context: observe.enrich +tier: 2 +label: Audio Enrichment +group: Observe +--- You are correcting and enriching an audio transcript. You receive numbered statements with transcribed text and corresponding audio clips. For each statement: diff --git a/observe/enrich.py b/observe/enrich.py index c72a0f89c..2563359dd 100644 --- a/observe/enrich.py +++ b/observe/enrich.py @@ -101,7 +101,7 @@ def enrich_transcript( types.Part.from_bytes(data=audio_bytes, mime_type="audio/flac") ) - # Call LLM (tier defaults to LITE via CONTEXT_DEFAULTS) + # Call LLM (tier from enrich.md frontmatter) logger.info(f"Enriching {len(statements)} statements...") t0 = time.perf_counter() diff --git a/observe/extract.md b/observe/extract.md index ba27731d2..c0bd0071e 100644 --- a/observe/extract.md +++ b/observe/extract.md @@ -1,3 +1,9 @@ +--- +context: observe.extract.selection +tier: 2 +label: Frame Selection +group: Observe +--- You are analyzing frame categorizations from a desktop screencast recording to select frames for detailed content extraction. Given a time-series of frames with categories and visual descriptions, select the frames most valuable for text extraction and content analysis. Aim to select around $max_extractions frames total, fewer if the content is repetitive. diff --git a/observe/transcribe/gemini.md b/observe/transcribe/gemini.md index bd7e3c322..23741967e 100644 --- a/observe/transcribe/gemini.md +++ b/observe/transcribe/gemini.md @@ -1,3 +1,9 @@ +--- +context: observe.transcribe.gemini +tier: 2 +label: Audio Transcription (Gemini) +group: Observe +--- You are accurately transcribing audio and indentifying distinct. Return a JSON object with individual speech segments that represent separate statements or sentences. ## Output Format diff --git a/tests/test_models.py b/tests/test_models.py index 02058d399..ff92c6433 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -9,7 +9,6 @@ from think.models import ( CLAUDE_HAIKU_4, CLAUDE_OPUS_4, CLAUDE_SONNET_4, - CONTEXT_DEFAULTS, DEFAULT_PROVIDER, DEFAULT_TIER, GEMINI_FLASH, @@ -18,6 +17,7 @@ from think.models import ( GPT_5, GPT_5_MINI, GPT_5_NANO, + PROMPT_PATHS, PROVIDER_DEFAULTS, TIER_FLASH, TIER_LITE, @@ -196,41 +196,56 @@ def test_tier_constants(): assert DEFAULT_PROVIDER == "google" -def test_context_defaults_structure(): - """Test CONTEXT_DEFAULTS has required fields for each entry.""" - required_keys = {"tier", "label", "group"} +def test_prompt_paths_exist(): + """Test all PROMPT_PATHS files exist and have valid frontmatter.""" + from pathlib import Path + + import frontmatter + + base_dir = Path(__file__).parent.parent # Project root + required_keys = {"context", "tier", "label", "group"} + + for rel_path in PROMPT_PATHS: + path = base_dir / rel_path + assert path.exists(), f"Prompt file not found: {rel_path}" + + post = frontmatter.load(path) + meta = post.metadata or {} - for context, config in CONTEXT_DEFAULTS.items(): - assert isinstance(config, dict), f"{context} should be a dict" assert required_keys <= set( - config.keys() - ), f"{context} missing keys: {required_keys - set(config.keys())}" - assert config["tier"] in ( + meta.keys() + ), f"{rel_path} missing keys: {required_keys - set(meta.keys())}" + assert meta["tier"] in ( TIER_PRO, TIER_FLASH, TIER_LITE, - ), f"{context} has invalid tier: {config['tier']}" + ), f"{rel_path} has invalid tier: {meta['tier']}" assert ( - isinstance(config["label"], str) and config["label"] - ), f"{context} has invalid label: {config['label']}" + isinstance(meta["label"], str) and meta["label"] + ), f"{rel_path} has invalid label: {meta['label']}" assert ( - isinstance(config["group"], str) and config["group"] - ), f"{context} has invalid group: {config['group']}" + isinstance(meta["group"], str) and meta["group"] + ), f"{rel_path} has invalid group: {meta['group']}" + + +def test_prompt_contexts_in_registry(): + """Test prompt contexts are discovered and in registry.""" + registry = get_context_registry() + # Verify known prompt contexts exist with correct values + assert "observe.describe.frame" in registry + assert registry["observe.describe.frame"]["tier"] == TIER_LITE + assert registry["observe.describe.frame"]["group"] == "Observe" -def test_context_defaults_known_entries(): - """Test specific known CONTEXT_DEFAULTS entries.""" - # Verify some known entries exist with correct values - assert "observe.describe.frame" in CONTEXT_DEFAULTS - assert CONTEXT_DEFAULTS["observe.describe.frame"]["tier"] == TIER_LITE - assert CONTEXT_DEFAULTS["observe.describe.frame"]["group"] == "Observe" + assert "app.chat.title" in registry + assert registry["app.chat.title"]["tier"] == TIER_LITE + assert registry["app.chat.title"]["group"] == "Apps" - # agent.* static defaults removed - now discovered from muse/*.md - assert "agent.*" not in CONTEXT_DEFAULTS + assert "observe.enrich" in registry + assert registry["observe.enrich"]["tier"] == TIER_FLASH - assert "app.chat.title" in CONTEXT_DEFAULTS - assert CONTEXT_DEFAULTS["app.chat.title"]["tier"] == TIER_LITE - assert CONTEXT_DEFAULTS["app.chat.title"]["group"] == "Apps" + assert "detect.created" in registry + assert registry["detect.created"]["tier"] == TIER_LITE def test_provider_defaults_structure(): @@ -362,30 +377,38 @@ def test_resolve_provider_invalid_tier(use_fixtures_journal, monkeypatch, tmp_pa # --------------------------------------------------------------------------- -def test_context_registry_includes_static_defaults(): - """Test that registry includes all static CONTEXT_DEFAULTS entries.""" +def test_context_registry_includes_prompt_contexts(): + """Test that registry includes all contexts from PROMPT_PATHS.""" + from pathlib import Path + + import frontmatter + registry = get_context_registry() + base_dir = Path(__file__).parent.parent + + # All prompt contexts should be in registry with correct tier + for rel_path in PROMPT_PATHS: + path = base_dir / rel_path + post = frontmatter.load(path) + meta = post.metadata or {} + context = meta.get("context") - # All static defaults should be in registry - for context in CONTEXT_DEFAULTS: - assert context in registry, f"Static default {context} not in registry" - assert registry[context]["tier"] == CONTEXT_DEFAULTS[context]["tier"] + assert context in registry, f"Prompt context {context} not in registry" + assert registry[context]["tier"] == meta["tier"] def test_context_registry_includes_categories(): """Test that registry includes discovered category contexts.""" registry = get_context_registry() - # Should have category entries (from observe/categories/*.json) + # Should have category entries (from observe/categories/*.md) category_contexts = [k for k in registry if k.startswith("observe.describe.")] - # Should have more than just the static entries (frame and wildcard) - assert len(category_contexts) > 2, "Should discover category contexts" + # Should have frame + all categories (browsing, code, gaming, etc.) + assert len(category_contexts) > 5, "Should discover category contexts" # Each category context should have required fields for context in category_contexts: - if context == "observe.describe.*": - continue # Skip wildcard assert "tier" in registry[context] assert "label" in registry[context] assert "group" in registry[context] diff --git a/think/detect_created.md b/think/detect_created.md index cb30470aa..a4a8cb6fe 100644 --- a/think/detect_created.md +++ b/think/detect_created.md @@ -1,3 +1,9 @@ +--- +context: detect.created +tier: 3 +label: Date Detection +group: Import +--- You are an expert at analyzing media file metadata to determine creation timestamps. Analyze the provided exiftool metadata output and extract the most accurate creation time. Guidelines: diff --git a/think/detect_transcript_json.md b/think/detect_transcript_json.md index f706fec8a..8bc7fa48f 100644 --- a/think/detect_transcript_json.md +++ b/think/detect_transcript_json.md @@ -1,3 +1,9 @@ +--- +context: observe.detect.json +tier: 2 +label: Normalization +group: Import +--- You are a transcript processing assistant. Convert the provided transcript segment into structured JSON format. ## Input Format: diff --git a/think/detect_transcript_segment.md b/think/detect_transcript_segment.md index d8c25d2d4..7893e6585 100644 --- a/think/detect_transcript_segment.md +++ b/think/detect_transcript_segment.md @@ -1,3 +1,9 @@ +--- +context: observe.detect.segment +tier: 2 +label: Segmentation +group: Import +--- You are a transcript analyzer that identifies 5-minute segment boundaries with absolute timestamps. TASK: Find ~5-minute segment boundaries and return their line numbers with absolute time-of-day timestamps. diff --git a/think/models.py b/think/models.py index 10d056e3d..a8a84c22b 100644 --- a/think/models.py +++ b/think/models.py @@ -92,15 +92,10 @@ class IncompleteJSONError(ValueError): # --------------------------------------------------------------------------- -# Context defaults: context pattern -> {tier, label, group} +# Prompt context discovery # -# These define the default tier for each context when not overridden in config. -# Patterns support glob-style matching (fnmatch). -# -# Each entry contains: -# - tier: Default tier (TIER_PRO, TIER_FLASH, TIER_LITE) -# - label: Human-readable name for settings UI -# - group: Category for grouping in settings UI +# Context metadata (tier, label, group) is defined in prompt .md files via +# YAML frontmatter. This eliminates duplication between code and config. # # NAMING CONVENTION: # {module}.{feature}[.{operation}] @@ -112,82 +107,31 @@ class IncompleteJSONError(ValueError): # - muse.entities.observer -> muse module, entities app, observer config # - app.chat.title -> apps module, chat app, title operation # -# DYNAMIC DISCOVERY: -# Categories (observe/categories/*.json) and agents (muse/*.md, -# apps/*/muse/*.md) can express tier/label/group in their frontmatter. -# These are discovered at runtime and merged with the static defaults below. +# DISCOVERY SOURCES: +# 1. Prompt files listed in PROMPT_PATHS (with context in frontmatter) +# 2. Categories from observe/categories/*.md (tier/label/group in frontmatter) +# 3. Muse configs from muse/*.md and apps/*/muse/*.md # # When adding new contexts: -# 1. Use module prefix matching the package (observe, think, app) -# 2. Add specific operations as suffixes when granular control is needed -# 3. Use wildcards sparingly - prefer explicit entries for clarity -# 4. If not listed here, context falls back to DEFAULT_TIER (FLASH) -# 5. For categories/agents, prefer adding tier/label/group to JSON configs +# 1. Create a .md prompt file with YAML frontmatter containing: +# context, tier, label, group +# 2. Add the path to PROMPT_PATHS +# 3. If not listed, context falls back to DEFAULT_TIER (FLASH) # --------------------------------------------------------------------------- -# Static context defaults - non-discoverable contexts only -# Categories and agents express their own tier/label/group in JSON configs -CONTEXT_DEFAULTS: Dict[str, Dict[str, Any]] = { - # Observe pipeline - screen and audio capture processing - "observe.describe.frame": { - "tier": TIER_LITE, - "label": "Screen Categorization", - "group": "Observe", - }, - # Fallback for categories without explicit tier in their JSON - "observe.describe.*": { - "tier": TIER_FLASH, - "label": "Screen Extraction", - "group": "Observe", - }, - "observe.detect.segment": { - "tier": TIER_FLASH, - "label": "Segmentation", - "group": "Import", - }, - "observe.detect.json": { - "tier": TIER_FLASH, - "label": "Normalization", - "group": "Import", - }, - "observe.enrich": { - "tier": TIER_FLASH, - "label": "Audio Enrichment", - "group": "Observe", - }, - "observe.transcribe.gemini": { - "tier": TIER_FLASH, - "label": "Audio Transcription (Gemini)", - "group": "Observe", - }, - "observe.extract.selection": { - "tier": TIER_FLASH, - "label": "Frame Selection", - "group": "Observe", - }, - "observe.summarize": { - "tier": TIER_FLASH, - "label": "Summarization", - "group": "Import", - }, - # Utilities - miscellaneous processing tasks - "detect.created": { - "tier": TIER_LITE, - "label": "Date Detection", - "group": "Import", - }, - "planner.generate": { - "tier": TIER_FLASH, - "label": "Agent Prompt Generation", - "group": "Think", - }, - # Apps - application-specific contexts - "app.chat.title": { - "tier": TIER_LITE, - "label": "Chat Title Generation", - "group": "Apps", - }, -} +# Flat list of prompt files that define context metadata in frontmatter. +# Each must have: context, tier, label, group in YAML frontmatter. +PROMPT_PATHS: List[str] = [ + "observe/describe.md", + "observe/enrich.md", + "observe/extract.md", + "observe/transcribe/gemini.md", + "think/detect_created.md", + "think/detect_transcript_segment.md", + "think/detect_transcript_json.md", + "think/planner.md", + "apps/chat/title.md", +] # --------------------------------------------------------------------------- @@ -198,6 +142,49 @@ CONTEXT_DEFAULTS: Dict[str, Dict[str, Any]] = { _context_registry: Optional[Dict[str, Dict[str, Any]]] = None +def _discover_prompt_contexts() -> Dict[str, Dict[str, Any]]: + """Load context metadata from prompt files listed in PROMPT_PATHS. + + Each file must have YAML frontmatter with: + - context: The context string (e.g., "observe.enrich") + - tier: Tier number (1=pro, 2=flash, 3=lite) + - label: Human-readable name + - group: Settings UI category + + Returns + ------- + Dict[str, Dict[str, Any]] + Mapping of context patterns to {tier, label, group} dicts. + """ + contexts = {} + base_dir = Path(__file__).parent.parent # Project root + + for rel_path in PROMPT_PATHS: + path = base_dir / rel_path + if not path.exists(): + logging.getLogger(__name__).warning(f"Prompt file not found: {path}") + continue + + try: + post = frontmatter.load(path) + meta = post.metadata or {} + + context = meta.get("context") + if not context: + logging.getLogger(__name__).warning(f"No context in {path}") + continue + + contexts[context] = { + "tier": meta.get("tier", TIER_FLASH), + "label": meta.get("label", context), + "group": meta.get("group", "Other"), + } + except Exception as e: + logging.getLogger(__name__).warning(f"Failed to load {path}: {e}") + + return contexts + + def _discover_muse_contexts() -> Dict[str, Dict[str, Any]]: """Discover muse context defaults from muse/*.md config files. @@ -265,20 +252,20 @@ def _discover_muse_contexts() -> Dict[str, Dict[str, Any]]: def _build_context_registry() -> Dict[str, Dict[str, Any]]: - """Build complete context registry from static defaults and discovered configs. + """Build complete context registry from discovered configs. Merges: - 1. Static CONTEXT_DEFAULTS (non-discoverable contexts) + 1. Prompt contexts from _discover_prompt_contexts() 2. Category contexts from observe/describe.py CATEGORIES - 3. Agent contexts from _discover_agent_contexts() + 3. Muse contexts from _discover_muse_contexts() Returns ------- Dict[str, Dict[str, Any]] Complete context registry mapping patterns to {tier, label, group}. """ - # Start with static defaults - registry = dict(CONTEXT_DEFAULTS) + # Start with prompt contexts (from PROMPT_PATHS) + registry = _discover_prompt_contexts() # Merge category contexts (lazy import to avoid circular dependency) try: @@ -336,7 +323,7 @@ def _resolve_tier(context: str) -> int: providers_config = journal_config.get("providers", {}) contexts = providers_config.get("contexts", {}) - # Get dynamic context registry (includes static defaults + discovered categories/agents) + # Get dynamic context registry (discovered prompts, categories, muse configs) registry = get_context_registry() # Check journal config contexts first (exact match) @@ -496,7 +483,7 @@ def resolve_provider(context: str) -> tuple[str, str]: # No context match - check dynamic context registry for this context if match_config is None: - # Get dynamic context registry (includes static defaults + discovered categories/agents) + # Get dynamic context registry (discovered prompts, categories, muse configs) registry = get_context_registry() # Check for matching context default (exact match first, then glob) @@ -1163,7 +1150,7 @@ __all__ = [ # Provider configuration "DEFAULT_TIER", "DEFAULT_PROVIDER", - "CONTEXT_DEFAULTS", + "PROMPT_PATHS", "get_context_registry", # Model constants (used by provider backends for defaults) "GEMINI_FLASH", diff --git a/think/planner.md b/think/planner.md index f7b6425b6..761b922d0 100644 --- a/think/planner.md +++ b/think/planner.md @@ -1,3 +1,9 @@ +--- +context: planner.generate +tier: 2 +label: Agent Prompt Generation +group: Think +--- You are a strategic research planner for the solstone journal assistant, specialized in creating comprehensive plans to research and analyze personal journal data to answer user requests. ## Core Role and Limitations