diff --git a/docs/SCREEN_CATEGORIES.md b/docs/SCREEN_CATEGORIES.md index 7a3f760aa..bbbeb0474 100644 --- a/docs/SCREEN_CATEGORIES.md +++ b/docs/SCREEN_CATEGORIES.md @@ -13,7 +13,8 @@ Defines the category with JSON frontmatter and optional extraction prompt: ```markdown { "description": "One-line description for categorization prompt", - "output": "markdown" + "output": "markdown", + "max_output_tokens": 4096 } Optional extraction prompt content goes here... @@ -23,6 +24,7 @@ Optional extraction prompt content goes here... |-------|----------|---------|-------------| | `description` | Yes | - | Single-line description used in the categorization prompt | | `output` | No | `"markdown"` | Response format for extraction: `"json"` or `"markdown"` | +| `max_output_tokens` | No | `4096` | Maximum output tokens for category-specific extraction | Model selection is handled via the providers configuration in `journal.json`. Each category uses the context pattern `observe.describe.` for routing. See [config.md](../talent/journal/references/config.md) for details on configuring providers per context. @@ -30,7 +32,18 @@ Categories with prompt content after the frontmatter are "extractable" - they ca - Analyze the screenshot for this specific category - Return content in the format specified by `output` (markdown or JSON) -### 2. `.py` (optional) +### 2. `.schema.json` (required for JSON output) + +Defines the strict structured-output schema for categories with `"output": "json"`. The file is discovered by filename convention next to the markdown prompt (`.schema.json`). + +JSON category schemas are checked by `scripts/check_schema_bounds.py` and `tests/test_schema_strict_portability.py`: +- Every array must have `maxItems` +- Every free-text string must have `maxLength` +- Every object must set `additionalProperties:false` +- Every object must list all properties in `required` +- Do not use `oneOf`; express nullability with type lists such as `["string", "null"]` + +### 3. `.py` (optional) Custom formatter for rich markdown output. If not provided, default formatting applies: - Markdown content: displayed with category header diff --git a/solstone/observe/categories/calendar.md b/solstone/observe/categories/calendar.md index 35b6e1547..ea0cc2905 100644 --- a/solstone/observe/categories/calendar.md +++ b/solstone/observe/categories/calendar.md @@ -1,35 +1,54 @@ { "description": "Calendar and scheduling interfaces: day/week/month views, agenda lists, event details, event creation forms, booking pages, availability grids, and RSVP/scheduling workflows", - "output": "markdown", + "output": "json", "extraction": "Extract when the visible date range, event detail, availability grid, booking page, or scheduling workflow changes", - "importance": "high" + "importance": "high", + "max_output_tokens": 8192 } -# Calendar Text Extraction +# Calendar Extraction -Extract text from this calendar or scheduling screenshot. +Extract structured scheduling information from this calendar or scheduling screenshot. -## Header +Return JSON matching this shape: -`# [Calendar/App - View or Date Range]` - -## Content Focus - -Extract the scheduling information that is visible: - -- **Calendar views**: Preserve date range, visible days, event titles, times, locations, calendars/colors if meaningful, and attendee/status hints. -- **Event detail/edit forms**: Include title, start/end time, date, location, conferencing link/platform, guests/attendees, description, recurrence, reminders, RSVP/status, and calendar name when visible. -- **Availability/booking pages**: Include host/service name, timezone, available slots, selected slot, duration, location/meeting method, form fields, and booking state. -- **Scheduling assistants**: Preserve participant names, availability blocks, conflicts, proposed times, and selected time. +```json +{ + "app": "Google Calendar", + "view": "week", + "range": "Apr 13 - Apr 19, 2026", + "events": [ + { + "title": "Planning review", + "start": "Tue 10:00 AM", + "end": "11:00 AM", + "location": "Conference Room A", + "conferencing": "Google Meet", + "guests": ["Alice", "Bob"], + "status": "accepted", + "recurrence": null, + "calendar": "Work", + "description": "Visible event notes" + } + ], + "availability": ["Tue 2:00 PM", "Wed 10:30 AM"], + "notes": "Timezone, booking state, host/service, or visible form fields" +} +``` -## Quality +## Field Notes -- Preserve chronological order. -- Keep timezones and recurrence details when visible. +- Set `app` to the visible calendar, scheduling, or booking app. +- Set `view` to `day`, `week`, `month`, `agenda`, `event_detail`, `availability`, or `unknown`. +- Use `range` for the visible date range when present; otherwise use null. +- Preserve chronological order in `events`. +- Include event title, start/end, location, conferencing, guests, status, recurrence, calendar name, and description when visible. +- Put booking slots or availability labels in `availability`. +- Use `notes` for host/service names, timezone, booking state, form fields, selected duration, or other visible scheduling context that does not belong to an event. - Mark unclear text with `[unclear]`. - Mark cut-off text with `...`. - Skip unrelated app chrome unless it identifies the calendar account, date range, or scheduling state. -Return ONLY the formatted markdown. +Return ONLY the JSON object. diff --git a/solstone/observe/categories/calendar.py b/solstone/observe/categories/calendar.py new file mode 100644 index 000000000..ac8fd4933 --- /dev/null +++ b/solstone/observe/categories/calendar.py @@ -0,0 +1,97 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Formatter for calendar category content.""" + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def _text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + +def format(content: Any, context: dict) -> str: + """Format calendar analysis to markdown.""" + if not isinstance(content, dict): + return "" + + app = _text(content.get("app")) or "unknown" + view = _text(content.get("view")) or "unknown" + lines = [f"**Calendar** ({app} - {view})", ""] + + date_range = _text(content.get("range")) + if date_range: + lines.append(f"*{date_range}*") + lines.append("") + + events = content.get("events", []) + if not isinstance(events, list): + events = [] + + for event in events: + if not isinstance(event, dict): + logger.warning("calendar formatter: skipping non-dict event: %r", event) + continue + + title = _text(event.get("title")) or "Untitled event" + start = _text(event.get("start")) + end = _text(event.get("end")) + time_label = "" + if start and end: + time_label = f"{start} - {end}" + elif start: + time_label = start + elif end: + time_label = end + + status = _text(event.get("status")) + event_line = f"- **{title}**" + if time_label: + event_line += f" ({time_label})" + if status and status != "unknown": + event_line += f" [{status}]" + lines.append(event_line) + + location = _text(event.get("location")) + if location: + lines.append(f" - Location: {location}") + conferencing = _text(event.get("conferencing")) + if conferencing: + lines.append(f" - Conferencing: {conferencing}") + guests = event.get("guests", []) + if isinstance(guests, list): + guest_text = ", ".join(_text(guest) for guest in guests if _text(guest)) + if guest_text: + lines.append(f" - Guests: {guest_text}") + recurrence = _text(event.get("recurrence")) + if recurrence: + lines.append(f" - Recurrence: {recurrence}") + calendar_name = _text(event.get("calendar")) + if calendar_name: + lines.append(f" - Calendar: {calendar_name}") + description = _text(event.get("description")) + if description: + lines.append(f" - Description: {description}") + + availability = content.get("availability", []) + if isinstance(availability, list): + availability_text = ", ".join( + _text(slot) for slot in availability if _text(slot) + ) + if availability_text: + if lines[-1] != "": + lines.append("") + lines.append(f"**Availability:** {availability_text}") + + notes = _text(content.get("notes")) + if notes: + if lines[-1] != "": + lines.append("") + lines.append(notes) + + return "\n".join(lines) diff --git a/solstone/observe/categories/calendar.schema.json b/solstone/observe/categories/calendar.schema.json new file mode 100644 index 000000000..885a2a32f --- /dev/null +++ b/solstone/observe/categories/calendar.schema.json @@ -0,0 +1,145 @@ +{ + "type": "object", + "additionalProperties": false, + "required": [ + "app", + "view", + "range", + "events", + "availability", + "notes" + ], + "properties": { + "app": { + "type": "string", + "maxLength": 64 + }, + "view": { + "type": "string", + "enum": [ + "day", + "week", + "month", + "agenda", + "event_detail", + "availability", + "unknown" + ] + }, + "range": { + "type": [ + "string", + "null" + ], + "maxLength": 128 + }, + "events": { + "type": "array", + "maxItems": 40, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "title", + "start", + "end", + "location", + "conferencing", + "guests", + "status", + "recurrence", + "calendar", + "description" + ], + "properties": { + "title": { + "type": "string", + "maxLength": 200 + }, + "start": { + "type": [ + "string", + "null" + ], + "maxLength": 64 + }, + "end": { + "type": [ + "string", + "null" + ], + "maxLength": 64 + }, + "location": { + "type": [ + "string", + "null" + ], + "maxLength": 200 + }, + "conferencing": { + "type": [ + "string", + "null" + ], + "maxLength": 128 + }, + "guests": { + "type": "array", + "maxItems": 20, + "items": { + "type": "string", + "maxLength": 128 + } + }, + "status": { + "type": "string", + "enum": [ + "accepted", + "declined", + "tentative", + "needs_action", + "unknown" + ] + }, + "recurrence": { + "type": [ + "string", + "null" + ], + "maxLength": 128 + }, + "calendar": { + "type": [ + "string", + "null" + ], + "maxLength": 96 + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 1000 + } + } + } + }, + "availability": { + "type": "array", + "maxItems": 40, + "items": { + "type": "string", + "maxLength": 96 + } + }, + "notes": { + "type": [ + "string", + "null" + ], + "maxLength": 1000 + } + } +} diff --git a/solstone/observe/categories/messaging.md b/solstone/observe/categories/messaging.md index 377f0c09e..72e727010 100644 --- a/solstone/observe/categories/messaging.md +++ b/solstone/observe/categories/messaging.md @@ -1,38 +1,47 @@ { "description": "Chat or email apps (Slack, Discord, Messages/iMessage, Gmail, etc.)", - "output": "markdown", + "output": "json", "extraction": "Extract when conversation partner, channel, or messaging app changes", - "importance": "high" + "importance": "high", + "max_output_tokens": 8192 } -# Messaging App Text Extraction +# Messaging Extraction -Extract text from this messaging or email screenshot (Slack, Discord, Messages, Gmail, Teams, etc.). +Extract structured text from this messaging or email screenshot (Slack, Discord, Messages, Gmail, Teams, etc.). -## Header +Return JSON matching this shape: -`# [App Name - Channel/Conversation]` - -## Conversation Format - -Extract messages with sender attribution: - -```markdown -**Alice**: Hey, how's it going? -**Bob**: Pretty good, working on the new feature +```json +{ + "app": "Gmail", + "thread": "Inbox", + "view": "inbox", + "messages": [ + { + "sender": "Alice", + "timestamp": "2:34 PM", + "subject": "Project update", + "text": "Latest visible message or email snippet" + } + ] +} ``` -Include timestamps if visible: `**Alice** (2:34 PM): message` - -Use `>` blockquotes for quoted/forwarded messages. Use code fences for code snippets. - -## Quality - -- Focus on message content, skip UI chrome -- Preserve conversation order and flow -- Mark unclear text with `[unclear]` -- Mark cut-off text with `...` - -Return ONLY the formatted markdown. +## Field Notes + +- Set `app` to the visible app or service name. +- Set `thread` to the visible channel, conversation, inbox, or list name. +- Set `view` to `inbox` for email/message list views, `conversation` for threaded chats, and `unknown` only when the surface is ambiguous. +- Preserve conversation order and flow in `messages`. +- For inbox rows, put the email subject line in `subject` and the snippet/body preview in `text`. +- For conversations, set `subject` to null. +- Put timestamps in `timestamp` when visible; otherwise use null. +- `text` may contain markdown. Use `>` blockquotes for quoted/forwarded content and code fences for code snippets. +- Mark unclear text with `[unclear]` inside `text`. +- Mark cut-off text with `...` inside `text`. +- Focus on message content and skip unrelated UI chrome. + +Return ONLY the JSON object. diff --git a/solstone/observe/categories/messaging.py b/solstone/observe/categories/messaging.py new file mode 100644 index 000000000..a7aba4960 --- /dev/null +++ b/solstone/observe/categories/messaging.py @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Formatter for messaging category content.""" + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def _label(app: Any, thread: Any) -> str: + app_text = str(app or "").strip() + thread_text = str(thread or "").strip() + if app_text and thread_text: + return f"{app_text} - {thread_text}" + if app_text: + return app_text + if thread_text: + return thread_text + return "unknown" + + +def _text(value: Any) -> str: + if value is None: + return "" + return str(value) + + +def format(content: Any, context: dict) -> str: + """Format messaging analysis to markdown.""" + if not isinstance(content, dict): + return "" + + lines = [] + lines.append(f"**Messaging** ({_label(content.get('app'), content.get('thread'))})") + lines.append("") + + messages = content.get("messages", []) + if not isinstance(messages, list): + messages = [] + + for message in messages: + if not isinstance(message, dict): + logger.warning( + "messaging formatter: skipping non-dict message: %r", message + ) + continue + + sender = _text(message.get("sender") or "Unknown") + timestamp = _text(message.get("timestamp")).strip() + subject = _text(message.get("subject")).strip() + text = _text(message.get("text")) + + body = f"{subject} - {text}" if subject and text else subject or text + if timestamp: + lines.append(f"**{sender}** ({timestamp}): {body}") + else: + lines.append(f"**{sender}**: {body}") + + return "\n".join(lines) diff --git a/solstone/observe/categories/messaging.schema.json b/solstone/observe/categories/messaging.schema.json new file mode 100644 index 000000000..8166dd656 --- /dev/null +++ b/solstone/observe/categories/messaging.schema.json @@ -0,0 +1,66 @@ +{ + "type": "object", + "additionalProperties": false, + "required": [ + "app", + "thread", + "view", + "messages" + ], + "properties": { + "app": { + "type": "string", + "maxLength": 64 + }, + "thread": { + "type": "string", + "maxLength": 192 + }, + "view": { + "type": "string", + "enum": [ + "conversation", + "inbox", + "unknown" + ] + }, + "messages": { + "type": "array", + "maxItems": 60, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "sender", + "timestamp", + "subject", + "text" + ], + "properties": { + "sender": { + "type": "string", + "maxLength": 128 + }, + "timestamp": { + "type": [ + "string", + "null" + ], + "maxLength": 96 + }, + "subject": { + "type": [ + "string", + "null" + ], + "maxLength": 256 + }, + "text": { + "type": "string", + "maxLength": 4000 + } + } + } + } + } +} diff --git a/solstone/observe/describe.py b/solstone/observe/describe.py index 81f4a4b81..fcccd462f 100644 --- a/solstone/observe/describe.py +++ b/solstone/observe/describe.py @@ -164,6 +164,7 @@ def _discover_categories() -> dict[str, dict]: # Apply defaults for tier routing # tier: 1=pro, 2=flash, 3=lite (default: flash) metadata.setdefault("tier", 2) + metadata.setdefault("max_output_tokens", 4096) # label: Human-readable name (default: title-cased category name) metadata.setdefault("label", category.replace("_", " ").title()) # group: Settings UI grouping (default: Screen Analysis) @@ -1091,7 +1092,7 @@ class VideoProcessor: system_instruction=cat_meta["prompt"] + redact_instruction, json_output=is_json, json_schema=cat_meta.get("json_schema"), - max_output_tokens=4096, + max_output_tokens=cat_meta["max_output_tokens"], thinking_budget=6144 if is_json else 4096, context=cat_meta["context"], ) diff --git a/tests/test_calendar_schema.py b/tests/test_calendar_schema.py new file mode 100644 index 000000000..0df883c0f --- /dev/null +++ b/tests/test_calendar_schema.py @@ -0,0 +1,153 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +import json +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest +from jsonschema import Draft202012Validator + +from solstone.observe import describe as describe_mod +from solstone.observe.categories import calendar as calendar_mod +from solstone.think.batch import Batch +from solstone.think.schema_bounds import unbounded_nodes + + +def _load_schema() -> dict: + return json.loads( + ( + Path(describe_mod.__file__).resolve().parent + / "categories" + / "calendar.schema.json" + ).read_text(encoding="utf-8") + ) + + +def _valid_payload() -> dict: + return { + "app": "Google Calendar", + "view": "week", + "range": "Apr 13 - Apr 19, 2026", + "events": [ + { + "title": "Planning review", + "start": "Tue 10:00 AM", + "end": "11:00 AM", + "location": "Conference Room A", + "conferencing": "Google Meet", + "guests": ["Alice", "Bob"], + "status": "accepted", + "recurrence": None, + "calendar": "Work", + "description": "Visible event notes", + }, + ], + "availability": ["Tue 2:00 PM"], + "notes": "Timezone: America/Denver", + } + + +def test_calendar_schema_file_is_valid_draft_2020_12(): + Draft202012Validator.check_schema(_load_schema()) + + +def test_calendar_schema_accepts_and_rejects_expected_values(): + validator = Draft202012Validator(_load_schema()) + + assert validator.is_valid(_valid_payload()) + + bad_enum = _valid_payload() + bad_enum["view"] = "list" + assert not validator.is_valid(bad_enum) + + extra_property = _valid_payload() + extra_property["extra"] = True + assert not validator.is_valid(extra_property) + + missing_required = _valid_payload() + del missing_required["events"] + assert not validator.is_valid(missing_required) + + wrong_item_type = _valid_payload() + wrong_item_type["events"] = ["Planning review"] + assert not validator.is_valid(wrong_item_type) + + +def test_discover_categories_attaches_calendar_schema(): + expected = _load_schema() + + assert describe_mod.CATEGORIES["calendar"]["json_schema"] == expected + assert describe_mod.CATEGORIES["calendar"]["output"] == "json" + + +@pytest.mark.asyncio +@patch("solstone.think.batch.agenerate", new_callable=AsyncMock) +async def test_calendar_extract_batch_call_passes_schema(mock_agenerate): + mock_agenerate.return_value = json.dumps(_valid_payload()) + + cat_meta = describe_mod.CATEGORIES["calendar"] + batch = Batch(max_concurrent=1) + req = batch.create( + contents="Analyze this calendar screenshot.", + context=cat_meta["context"], + json_schema=cat_meta["json_schema"], + ) + batch.add(req) + + results = [] + async for completed_req in batch.drain_batch(): + results.append(completed_req) + + assert len(results) == 1 + assert mock_agenerate.call_args.kwargs["json_schema"] == _load_schema() + + +def test_calendar_schema_has_no_unbounded_nodes(): + assert unbounded_nodes(_load_schema()) == [] + + +def test_calendar_formatter_renders_valid_dict(): + result = calendar_mod.format(_valid_payload(), {}) + + assert "**Calendar** (Google Calendar - week)" in result + assert "*Apr 13 - Apr 19, 2026*" in result + assert "- **Planning review** (Tue 10:00 AM - 11:00 AM) [accepted]" in result + assert " - Location: Conference Room A" in result + assert " - Conferencing: Google Meet" in result + assert " - Guests: Alice, Bob" in result + assert " - Calendar: Work" in result + assert " - Description: Visible event notes" in result + assert "**Availability:** Tue 2:00 PM" in result + assert "Timezone: America/Denver" in result + + +def test_calendar_formatter_returns_empty_for_non_dict(): + assert calendar_mod.format("# [Calendar - Week]", {}) == "" + + +def test_calendar_formatter_skips_non_dict_event(caplog): + payload = _valid_payload() + payload["events"] = [ + "Planning review", + { + "title": "Follow-up", + "start": None, + "end": None, + "location": None, + "conferencing": None, + "guests": [], + "status": "unknown", + "recurrence": None, + "calendar": None, + "description": None, + }, + ] + + with caplog.at_level("WARNING", logger="solstone.observe.categories.calendar"): + result = calendar_mod.format(payload, {}) + + assert "**Calendar** (Google Calendar - week)" in result + assert "- **Follow-up**" in result + assert "Planning review" not in result + assert "skipping non-dict event" in caplog.text diff --git a/tests/test_describe_config.py b/tests/test_describe_config.py index 2f7647fc1..1c2db4420 100644 --- a/tests/test_describe_config.py +++ b/tests/test_describe_config.py @@ -34,6 +34,11 @@ def test_categories_have_required_fields(): assert "context" in metadata, f"Category {category} missing 'context'" assert metadata["context"].startswith("observe.describe.") + # Every category should have an output-token budget + assert "max_output_tokens" in metadata + assert isinstance(metadata["max_output_tokens"], int) + assert metadata["max_output_tokens"] > 0 + def test_extractable_categories_have_prompts(): """Test that extractable categories have valid prompts loaded.""" @@ -50,6 +55,15 @@ def test_extractable_categories_have_prompts(): assert extractable_count > 0, "No extractable categories found" +def test_category_max_output_token_defaults_and_overrides(): + """Test category output-token defaults and explicit overrides.""" + CATEGORIES = describe_module.CATEGORIES + + assert CATEGORIES["browsing"]["max_output_tokens"] == 4096 + assert CATEGORIES["messaging"]["max_output_tokens"] == 8192 + assert CATEGORIES["calendar"]["max_output_tokens"] == 8192 + + def test_categorization_prompt_built(): """Test that categorization prompt is built correctly.""" prompt = describe_module.CATEGORIZATION_PROMPT diff --git a/tests/test_meeting_schema.py b/tests/test_meeting_schema.py index 097ae5dd8..393729650 100644 --- a/tests/test_meeting_schema.py +++ b/tests/test_meeting_schema.py @@ -117,11 +117,13 @@ def test_discover_categories_attaches_meeting_schema(): expected = _load_schema() assert describe_mod.CATEGORIES["meeting"]["json_schema"] == expected - assert [ + assert { + name for name, meta in describe_mod.CATEGORIES.items() if "json_schema" in meta + } == { name for name, meta in describe_mod.CATEGORIES.items() - if name != "meeting" and "json_schema" in meta - ] == [] + if meta["output"] == "json" + } @pytest.mark.asyncio diff --git a/tests/test_messaging_schema.py b/tests/test_messaging_schema.py new file mode 100644 index 000000000..157bd8ab7 --- /dev/null +++ b/tests/test_messaging_schema.py @@ -0,0 +1,158 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +import json +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest +from jsonschema import Draft202012Validator + +from solstone.observe import describe as describe_mod +from solstone.observe.categories import messaging as messaging_mod +from solstone.think.batch import Batch +from solstone.think.schema_bounds import unbounded_nodes + + +def _load_schema() -> dict: + return json.loads( + ( + Path(describe_mod.__file__).resolve().parent + / "categories" + / "messaging.schema.json" + ).read_text(encoding="utf-8") + ) + + +def _valid_payload() -> dict: + return { + "app": "Signal", + "thread": "Bluesky Board ++", + "view": "conversation", + "messages": [ + { + "sender": "Alice", + "timestamp": "2:34 PM", + "subject": None, + "text": "Hello\n> quoted text", + }, + ], + } + + +def test_messaging_schema_file_is_valid_draft_2020_12(): + Draft202012Validator.check_schema(_load_schema()) + + +def test_messaging_schema_accepts_and_rejects_expected_values(): + validator = Draft202012Validator(_load_schema()) + + assert validator.is_valid(_valid_payload()) + + bad_enum = _valid_payload() + bad_enum["view"] = "thread" + assert not validator.is_valid(bad_enum) + + extra_property = _valid_payload() + extra_property["extra"] = True + assert not validator.is_valid(extra_property) + + missing_required = _valid_payload() + del missing_required["thread"] + assert not validator.is_valid(missing_required) + + wrong_item_type = _valid_payload() + wrong_item_type["messages"] = ["Alice: hello"] + assert not validator.is_valid(wrong_item_type) + + +def test_discover_categories_attaches_messaging_schema(): + expected = _load_schema() + + assert describe_mod.CATEGORIES["messaging"]["json_schema"] == expected + assert describe_mod.CATEGORIES["messaging"]["output"] == "json" + + +@pytest.mark.asyncio +@patch("solstone.think.batch.agenerate", new_callable=AsyncMock) +async def test_messaging_extract_batch_call_passes_schema(mock_agenerate): + mock_agenerate.return_value = json.dumps(_valid_payload()) + + cat_meta = describe_mod.CATEGORIES["messaging"] + batch = Batch(max_concurrent=1) + req = batch.create( + contents="Analyze this messaging screenshot.", + context=cat_meta["context"], + json_schema=cat_meta["json_schema"], + ) + batch.add(req) + + results = [] + async for completed_req in batch.drain_batch(): + results.append(completed_req) + + assert len(results) == 1 + assert mock_agenerate.call_args.kwargs["json_schema"] == _load_schema() + + +def test_messaging_schema_has_no_unbounded_nodes(): + assert unbounded_nodes(_load_schema()) == [] + + +def test_messaging_formatter_renders_valid_dict(): + result = messaging_mod.format( + { + "app": "Gmail", + "thread": "Inbox", + "view": "inbox", + "messages": [ + { + "sender": "Alice", + "timestamp": "2:34 PM", + "subject": "Project update", + "text": "Latest visible message", + }, + { + "sender": "Bob", + "timestamp": None, + "subject": None, + "text": "Reply\n> quoted context", + }, + ], + }, + {}, + ) + + assert "**Messaging** (Gmail - Inbox)" in result + assert "**Alice** (2:34 PM): Project update - Latest visible message" in result + assert "**Bob**: Reply\n> quoted context" in result + + +def test_messaging_formatter_returns_empty_for_non_dict(): + assert messaging_mod.format("**Alice**: Hello", {}) == "" + + +def test_messaging_formatter_skips_non_dict_message(caplog): + with caplog.at_level("WARNING", logger="solstone.observe.categories.messaging"): + result = messaging_mod.format( + { + "app": "Signal", + "thread": "Team", + "view": "conversation", + "messages": [ + "Alice: hello", + { + "sender": "Bob", + "timestamp": None, + "subject": None, + "text": "Hi there", + }, + ], + }, + {}, + ) + + assert "**Messaging** (Signal - Team)" in result + assert "**Bob**: Hi there" in result + assert "Alice: hello" not in result + assert "skipping non-dict message" in caplog.text diff --git a/tests/test_screen_formatter.py b/tests/test_screen_formatter.py index 3db8925d8..ab415480b 100644 --- a/tests/test_screen_formatter.py +++ b/tests/test_screen_formatter.py @@ -442,11 +442,11 @@ def test_format_screen_falls_back_for_missing_formatter(): { "timestamp": 0, "analysis": { - "primary": "messaging", - "visual_description": "Chat app", + "primary": "browsing", + "visual_description": "Web page", }, "content": { - "messaging": "**Alice**: Hello!\n**Bob**: Hi there!", + "browsing": "# Example Page\n\nVisible text", }, }, ] @@ -457,8 +457,124 @@ def test_format_screen_falls_back_for_missing_formatter(): markdown = chunks[0]["markdown"] # Should use default text formatting - assert "**Messaging:**" in markdown - assert "**Alice**: Hello!" in markdown + assert "**Browsing:**" in markdown + assert "# Example Page" in markdown + + +def test_format_screen_preserves_legacy_and_json_messaging_content(): + """Test that legacy messaging strings and JSON dicts both render.""" + legacy_frames = [ + { + "timestamp": 0, + "analysis": { + "primary": "messaging", + "visual_description": "Chat app", + }, + "content": { + "messaging": "**Alice**: Hello!\n**Bob**: Hi there!", + }, + }, + ] + json_frames = [ + { + "timestamp": 0, + "analysis": { + "primary": "messaging", + "visual_description": "Chat app", + }, + "content": { + "messaging": { + "app": "Signal", + "thread": "Bluesky Board ++", + "view": "conversation", + "messages": [ + { + "sender": "Alice", + "timestamp": "2:34 PM", + "subject": None, + "text": "Hello!", + } + ], + }, + }, + }, + ] + + legacy_chunks, _meta = format_screen( + legacy_frames, {"include_entity_context": False} + ) + json_chunks, _meta = format_screen(json_frames, {"include_entity_context": False}) + + legacy_markdown = legacy_chunks[0]["markdown"] + json_markdown = json_chunks[0]["markdown"] + assert "**Messaging:**" in legacy_markdown + assert "**Alice**: Hello!" in legacy_markdown + assert "```json" not in legacy_markdown + assert "**Messaging** (Signal - Bluesky Board ++)" in json_markdown + assert "**Alice** (2:34 PM): Hello!" in json_markdown + assert "```json" not in json_markdown + + +def test_format_screen_preserves_legacy_and_json_calendar_content(): + """Test that legacy calendar strings and JSON dicts both render.""" + legacy_frames = [ + { + "timestamp": 0, + "analysis": { + "primary": "calendar", + "visual_description": "Calendar app", + }, + "content": { + "calendar": "# [Google Calendar - Week]\n**Monday**: Planning", + }, + }, + ] + json_frames = [ + { + "timestamp": 0, + "analysis": { + "primary": "calendar", + "visual_description": "Calendar app", + }, + "content": { + "calendar": { + "app": "Google Calendar", + "view": "week", + "range": "Apr 13 - Apr 19, 2026", + "events": [ + { + "title": "Planning", + "start": "Mon 9:00 AM", + "end": "10:00 AM", + "location": None, + "conferencing": None, + "guests": [], + "status": "unknown", + "recurrence": None, + "calendar": "Work", + "description": None, + } + ], + "availability": [], + "notes": None, + }, + }, + }, + ] + + legacy_chunks, _meta = format_screen( + legacy_frames, {"include_entity_context": False} + ) + json_chunks, _meta = format_screen(json_frames, {"include_entity_context": False}) + + legacy_markdown = legacy_chunks[0]["markdown"] + json_markdown = json_chunks[0]["markdown"] + assert "**Calendar:**" in legacy_markdown + assert "# [Google Calendar - Week]" in legacy_markdown + assert "```json" not in legacy_markdown + assert "**Calendar** (Google Calendar - week)" in json_markdown + assert "- **Planning** (Mon 9:00 AM - 10:00 AM)" in json_markdown + assert "```json" not in json_markdown def test_format_screen_handles_multiple_categories():