From ff0c70b44f33cc67bf12401531efecd8d6213b04 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Sun, 19 Apr 2026 20:51:31 -0600 Subject: [PATCH] observe/describe: schema-constrain meeting extraction; drop legacy participant fallback - add observe/categories/meeting.schema.json (Draft 2020-12 contract) - _discover_categories() now loads co-located .schema.json - thread json_schema through both Phase 3 batch dispatch sites - drop bare-string participant branch in meeting.format(); add skip+warn guard - prompt reinforcement line in meeting.md - tests/test_meeting_schema.py covers schema validity, accept/reject matrix, loader wiring, dispatcher wiring, formatter guard - tests/test_formatters.py::test_format_screen_meeting updated to dict participants Co-Authored-By: Claude Opus 4.7 (1M context) --- observe/categories/meeting.md | 2 + observe/categories/meeting.py | 21 ++-- observe/categories/meeting.schema.json | 56 +++++++++ observe/describe.py | 9 +- tests/test_formatters.py | 10 +- tests/test_meeting_schema.py | 168 +++++++++++++++++++++++++ 6 files changed, 254 insertions(+), 12 deletions(-) create mode 100644 observe/categories/meeting.schema.json create mode 100644 tests/test_meeting_schema.py diff --git a/observe/categories/meeting.md b/observe/categories/meeting.md index 8a4c555a2..a6725aa30 100644 --- a/observe/categories/meeting.md +++ b/observe/categories/meeting.md @@ -48,3 +48,5 @@ Respond with JSON describing the meeting state: - **formatted_text**: Complete text extraction from the presented screen/slide, formatted in markdown. Preserve structure with headings, bullets, code blocks, etc. Focus on accuracy. If information isn't visible or is unclear, use "unknown" or null. + +Return the JSON object with dict participants; do not use bare name strings. diff --git a/observe/categories/meeting.py b/observe/categories/meeting.py index 01b790318..e210aa83f 100644 --- a/observe/categories/meeting.py +++ b/observe/categories/meeting.py @@ -6,8 +6,11 @@ Renders meeting analysis JSON to rich markdown with participants and screen share. """ +import logging from typing import Any +logger = logging.getLogger(__name__) + def format(content: Any, context: dict) -> str: """Format meeting analysis to markdown. @@ -34,15 +37,15 @@ def format(content: Any, context: dict) -> str: if participants: lines.append("**Participants:**") for p in participants: - # Handle both dict format (new) and string format (legacy) - if isinstance(p, dict): - name = p.get("name", "Unknown") - status = p.get("status", "unknown") - video = "📹" if p.get("video") else "🔇" - lines.append(f"- {video} {name} ({status})") - else: - # Legacy: participant is just a name string - lines.append(f"- {p}") + if not isinstance(p, dict): + logger.warning( + "meeting formatter: skipping non-dict participant: %r", p + ) + continue + name = p.get("name", "Unknown") + status = p.get("status", "unknown") + video = "📹" if p.get("video") else "🔇" + lines.append(f"- {video} {name} ({status})") lines.append("") # Screen share diff --git a/observe/categories/meeting.schema.json b/observe/categories/meeting.schema.json new file mode 100644 index 000000000..65ab7619d --- /dev/null +++ b/observe/categories/meeting.schema.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$comment": "Meeting extraction output contract. Source of truth for the shape is observe/categories/meeting.md.", + "type": "object", + "additionalProperties": false, + "required": ["platform", "participants", "screen_share"], + "properties": { + "platform": { + "type": "string", + "enum": ["zoom", "meet", "teams", "slack", "discord", "webex", "other"] + }, + "participants": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "status", "video"], + "properties": { + "name": {"type": "string", "minLength": 1}, + "status": { + "type": "string", + "enum": ["speaking", "muted", "active", "presenting", "unknown"] + }, + "video": {"type": "boolean"}, + "box_2d": { + "type": "array", + "items": {"type": "integer", "minimum": 0}, + "minItems": 4, + "maxItems": 4 + } + } + } + }, + "screen_share": { + "oneOf": [ + {"type": "null"}, + { + "type": "object", + "additionalProperties": false, + "required": ["box_2d", "presenter", "description", "formatted_text"], + "properties": { + "box_2d": { + "type": "array", + "items": {"type": "integer", "minimum": 0}, + "minItems": 4, + "maxItems": 4 + }, + "presenter": {"type": ["string", "null"]}, + "description": {"type": "string"}, + "formatted_text": {"type": "string"} + } + } + ] + } + } +} diff --git a/observe/describe.py b/observe/describe.py index 1c0a07881..07f712ac3 100644 --- a/observe/describe.py +++ b/observe/describe.py @@ -111,6 +111,10 @@ def _discover_categories() -> dict[str, dict]: if prompt_content.text.strip(): metadata["prompt"] = prompt_content.text + schema_path = md_path.with_suffix(".schema.json") + if schema_path.exists(): + metadata["json_schema"] = json.loads(schema_path.read_text("utf-8")) + categories[category] = metadata extractable = "prompt" in metadata logger.debug(f"Loaded category: {category} (extractable={extractable})") @@ -723,7 +727,9 @@ class VideoProcessor: else: # Create new request for secondary extraction extract_req = batch.create( - contents=[], context=cat_meta["context"] + contents=[], + context=cat_meta["context"], + json_schema=cat_meta.get("json_schema"), ) extract_req.frame_id = req.frame_id extract_req.timestamp = req.timestamp @@ -752,6 +758,7 @@ class VideoProcessor: model=cat_model, system_instruction=cat_meta["prompt"] + redact_instruction, json_output=is_json, + json_schema=cat_meta.get("json_schema"), max_output_tokens=10240 if is_json else 8192, thinking_budget=6144 if is_json else 4096, context=cat_meta["context"], diff --git a/tests/test_formatters.py b/tests/test_formatters.py index 946a08d05..81d14b493 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -324,7 +324,12 @@ class TestFormatScreen: "timestamp": 0, "analysis": {"primary": "meeting"}, "content": { - "meeting": {"participants": ["Alice", "Bob"]}, + "meeting": { + "participants": [ + {"name": "Alice", "status": "active", "video": True}, + {"name": "Bob", "status": "muted", "video": False}, + ] + }, }, } ] @@ -333,7 +338,8 @@ class TestFormatScreen: # New meeting formatter uses "**Meeting** (platform)" format assert "**Meeting**" in chunks[0]["markdown"] - assert "Alice" in chunks[0]["markdown"] + assert "📹 Alice (active)" in chunks[0]["markdown"] + assert "🔇 Bob (muted)" in chunks[0]["markdown"] def test_format_screen_extracts_metadata(self): """Test that metadata line is extracted and not treated as a frame.""" diff --git a/tests/test_meeting_schema.py b/tests/test_meeting_schema.py new file mode 100644 index 000000000..7153f5d81 --- /dev/null +++ b/tests/test_meeting_schema.py @@ -0,0 +1,168 @@ +# 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 observe import describe as describe_mod +from observe.categories import meeting as meeting_mod +from think.batch import Batch + + +def _load_schema() -> dict: + return json.loads( + ( + Path(describe_mod.__file__).resolve().parent + / "categories" + / "meeting.schema.json" + ).read_text(encoding="utf-8") + ) + + +def test_meeting_schema_file_is_valid_draft_2020_12(): + Draft202012Validator.check_schema(_load_schema()) + + +def test_meeting_schema_accepts_and_rejects_expected_values(): + validator = Draft202012Validator(_load_schema()) + + assert validator.is_valid( + { + "platform": "zoom", + "participants": [ + {"name": "Alice", "status": "active", "video": True}, + ], + "screen_share": None, + } + ) + assert validator.is_valid( + { + "platform": "teams", + "participants": [ + { + "name": "Bob", + "status": "presenting", + "video": True, + "box_2d": [0, 10, 20, 30], + }, + ], + "screen_share": { + "box_2d": [40, 50, 60, 70], + "presenter": "Bob", + "description": "Showing a roadmap deck.", + "formatted_text": "# Roadmap", + }, + } + ) + assert not validator.is_valid( + { + "platform": "hangouts", + "participants": [ + {"name": "Alice", "status": "active", "video": True}, + ], + "screen_share": None, + } + ) + assert not validator.is_valid( + { + "platform": "zoom", + "participants": [ + {"name": "Alice", "status": "talking", "video": True}, + ], + "screen_share": None, + } + ) + assert not validator.is_valid( + { + "platform": "zoom", + "participants": ["Alice"], + "screen_share": None, + } + ) + assert not validator.is_valid( + { + "platform": "zoom", + "participants": [ + {"name": "Alice", "status": "active", "video": True}, + ], + "screen_share": None, + "extra": True, + } + ) + assert not validator.is_valid( + { + "platform": "zoom", + "participants": [ + {"status": "active", "video": True}, + ], + "screen_share": None, + } + ) + assert not validator.is_valid( + { + "platform": "zoom", + "participants": [ + {"name": "", "status": "active", "video": True}, + ], + "screen_share": None, + } + ) + + +def test_discover_categories_attaches_meeting_schema(): + expected = _load_schema() + + assert describe_mod.CATEGORIES["meeting"]["json_schema"] == expected + assert [ + name + for name, meta in describe_mod.CATEGORIES.items() + if name != "meeting" and "json_schema" in meta + ] == [] + + +@pytest.mark.asyncio +@patch("think.batch.agenerate", new_callable=AsyncMock) +async def test_meeting_extract_batch_call_passes_schema(mock_agenerate): + mock_agenerate.return_value = ( + '{"platform":"zoom","participants":[{"name":"Alice","status":"active",' + '"video":true}],"screen_share":null}' + ) + + cat_meta = describe_mod.CATEGORIES["meeting"] + batch = Batch(max_concurrent=1) + req = batch.create( + contents="Analyze this meeting 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_meeting_formatter_skips_non_dict_participant(caplog): + with caplog.at_level("WARNING", logger="observe.categories.meeting"): + result = meeting_mod.format( + { + "platform": "zoom", + "participants": [ + "Alice", + {"name": "Bob", "status": "active", "video": False}, + ], + "screen_share": None, + }, + {}, + ) + + assert "🔇 Bob (active)" in result + assert "Alice" not in result + assert "skipping non-dict participant" in caplog.text -- 2.51.2