diff --git a/talent/sense.md b/talent/sense.md index 4034f1f2a..faf047dcd 100644 --- a/talent/sense.md +++ b/talent/sense.md @@ -10,6 +10,7 @@ "thinking_budget": 4096, "max_output_tokens": 4096, "output": "json", + "schema": "sense.schema.json", "load": {"transcripts": true, "percepts": true, "talents": false} } @@ -27,27 +28,7 @@ Read the transcript and screen data. Produce a JSON object with ALL of the follo ## Output Schema -```json -{ - "density": "active|low_change|idle", - "content_type": "meeting|coding|browsing|email|messaging|reading|idle|mixed", - "activity_summary": "1-3 sentence description of what happened", - "entities": [ - {"type": "Person|Company|Project|Tool", "name": "Full Name", "role": "attendee|mentioned", "source": "voice|speaker_label|transcript|screen|other", "context": "Why this entity matters in this segment"} - ], - "facets": [ - {"facet": "facet_id", "activity": "1-sentence description for this facet", "level": "high|medium|low"} - ], - "meeting_detected": false, - "speakers": [], - "recommend": { - "screen_record": false, - "speaker_attribution": false, - "pulse_update": false - }, - "emotional_register": "high_energy|tense|focused|collaborative|flat|celebratory|strained|neutral" -} -``` +Authoritative schema: `sense.schema.json`. The output is a single JSON object with these top-level fields: `density`, `content_type`, `activity_summary`, `entities`, `facets`, `meeting_detected`, `speakers`, `recommend`, `emotional_register`. See Field-by-Field Instructions below for semantics and enum values. ## Field-by-Field Instructions diff --git a/talent/sense.schema.json b/talent/sense.schema.json new file mode 100644 index 000000000..ea40cde50 --- /dev/null +++ b/talent/sense.schema.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "required": ["density","content_type","activity_summary","entities","facets","meeting_detected","speakers","recommend","emotional_register"], + "properties": { + "density": {"type": "string", "enum": ["active","low_change","idle"]}, + "content_type": {"type": "string", "enum": ["meeting","coding","browsing","email","messaging","reading","idle","mixed"]}, + "activity_summary": {"type": "string", "minLength": 1}, + "entities": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["type","name","role","source","context"], + "properties": { + "type": {"type": "string", "enum": ["Person","Company","Project","Tool"]}, + "name": {"type": "string", "minLength": 1}, + "role": {"type": "string", "enum": ["attendee","mentioned"]}, + "source": {"type": "string", "enum": ["voice","speaker_label","transcript","screen","other"]}, + "context": {"type": "string", "minLength": 1} + } + } + }, + "facets": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["facet","activity","level"], + "properties": { + "facet": {"type": "string", "minLength": 1}, + "activity": {"type": "string", "minLength": 1}, + "level": {"type": "string", "enum": ["high","medium","low"]} + } + } + }, + "meeting_detected": {"type": "boolean"}, + "speakers": {"type": "array", "items": {"type": "string"}}, + "recommend": { + "type": "object", + "additionalProperties": false, + "required": ["screen_record","speaker_attribution","pulse_update"], + "properties": { + "screen_record": {"type": "boolean"}, + "speaker_attribution": {"type": "boolean"}, + "pulse_update": {"type": "boolean"} + } + }, + "emotional_register": {"type": "string", "enum": ["high_energy","tense","focused","collaborative","flat","celebratory","strained","neutral"]} + } +} diff --git a/tests/baselines/api/stats/stats.json b/tests/baselines/api/stats/stats.json index 10b2ba412..24edfcfa6 100644 --- a/tests/baselines/api/stats/stats.json +++ b/tests/baselines/api/stats/stats.json @@ -227,6 +227,7 @@ "path": "/talent/sense.md", "priority": 5, "schedule": "segment", + "schema": "sense.schema.json", "source": "system", "thinking_budget": 4096, "tier": 3, diff --git a/tests/test_pipeline_smoke.py b/tests/test_pipeline_smoke.py index a90b98825..b5b38b143 100644 --- a/tests/test_pipeline_smoke.py +++ b/tests/test_pipeline_smoke.py @@ -19,10 +19,24 @@ SEGMENTS = [ "density": "active", "content_type": "coding", "activity_summary": "Implementing auth module", - "facets": [{"facet": "work", "level": "high"}], + "facets": [{"facet": "work", "activity": "coding", "level": "high"}], "meeting_detected": False, - "recommend": {}, - "entities": [{"name": "Acme"}], + "speakers": [], + "recommend": { + "screen_record": False, + "speaker_attribution": False, + "pulse_update": False, + }, + "entities": [ + { + "type": "Company", + "name": "Acme", + "role": "mentioned", + "source": "screen", + "context": "Codebase referenced during implementation work", + } + ], + "emotional_register": "focused", }, ), ( @@ -31,10 +45,24 @@ SEGMENTS = [ "density": "active", "content_type": "coding", "activity_summary": "Implementing auth module", - "facets": [{"facet": "work", "level": "high"}], + "facets": [{"facet": "work", "activity": "coding", "level": "high"}], "meeting_detected": False, - "recommend": {}, - "entities": [{"name": "Acme"}], + "speakers": [], + "recommend": { + "screen_record": False, + "speaker_attribution": False, + "pulse_update": False, + }, + "entities": [ + { + "type": "Company", + "name": "Acme", + "role": "mentioned", + "source": "screen", + "context": "Codebase referenced during implementation work", + } + ], + "emotional_register": "focused", }, ), ( @@ -43,11 +71,24 @@ SEGMENTS = [ "density": "active", "content_type": "meeting", "activity_summary": "Sprint planning standup", - "facets": [{"facet": "work", "level": "medium"}], + "facets": [{"facet": "work", "activity": "meeting", "level": "medium"}], "meeting_detected": True, "speakers": ["Alice", "Bob"], - "recommend": {}, - "entities": [{"name": "Acme"}], + "recommend": { + "screen_record": False, + "speaker_attribution": False, + "pulse_update": False, + }, + "entities": [ + { + "type": "Company", + "name": "Acme", + "role": "mentioned", + "source": "screen", + "context": "Organization discussed during standup", + } + ], + "emotional_register": "collaborative", }, ), ( @@ -56,11 +97,24 @@ SEGMENTS = [ "density": "active", "content_type": "meeting", "activity_summary": "Sprint planning standup", - "facets": [{"facet": "work", "level": "medium"}], + "facets": [{"facet": "work", "activity": "meeting", "level": "medium"}], "meeting_detected": True, "speakers": ["Alice", "Bob"], - "recommend": {}, - "entities": [{"name": "Acme"}], + "recommend": { + "screen_record": False, + "speaker_attribution": False, + "pulse_update": False, + }, + "entities": [ + { + "type": "Company", + "name": "Acme", + "role": "mentioned", + "source": "screen", + "context": "Organization discussed during standup", + } + ], + "emotional_register": "collaborative", }, ), ( @@ -68,11 +122,17 @@ SEGMENTS = [ { "density": "idle", "content_type": "idle", - "activity_summary": "", + "activity_summary": "Idle segment.", "facets": [], "meeting_detected": False, - "recommend": {}, + "speakers": [], + "recommend": { + "screen_record": False, + "speaker_attribution": False, + "pulse_update": False, + }, "entities": [], + "emotional_register": "neutral", }, ), ( @@ -81,10 +141,24 @@ SEGMENTS = [ "density": "active", "content_type": "coding", "activity_summary": "Reviewing PR feedback", - "facets": [{"facet": "work", "level": "low"}], + "facets": [{"facet": "work", "activity": "coding", "level": "low"}], "meeting_detected": False, - "recommend": {}, - "entities": [{"name": "Acme"}], + "speakers": [], + "recommend": { + "screen_record": False, + "speaker_attribution": False, + "pulse_update": False, + }, + "entities": [ + { + "type": "Company", + "name": "Acme", + "role": "mentioned", + "source": "screen", + "context": "Project organization referenced in review feedback", + } + ], + "emotional_register": "focused", }, ), ] diff --git a/tests/test_sense_contamination_guard.py b/tests/test_sense_contamination_guard.py index 50c58f6d2..344fd52ad 100644 --- a/tests/test_sense_contamination_guard.py +++ b/tests/test_sense_contamination_guard.py @@ -66,6 +66,24 @@ def _write_sense_json( (talents_dir / "sense.json").write_text(json.dumps(payload), encoding="utf-8") +def _sense_payload(*, meeting_detected: bool) -> dict: + return { + "density": "idle", + "content_type": "idle", + "activity_summary": "Idle segment.", + "entities": [], + "facets": [], + "meeting_detected": meeting_detected, + "speakers": [], + "recommend": { + "screen_record": False, + "speaker_attribution": False, + "pulse_update": False, + }, + "emotional_register": "neutral", + } + + def _activity_record(segments: list[str]) -> dict: return { "id": "meeting_090000_300", @@ -115,7 +133,7 @@ def test_participation_clamps_attendees_when_all_segments_are_non_meetings( ) for segment_key in segments: _write_sense_json( - tmp_path, day, stream, segment_key, {"meeting_detected": False} + tmp_path, day, stream, segment_key, _sense_payload(meeting_detected=False) ) activity = _activity_record(segments) @@ -155,8 +173,12 @@ def test_participation_preserves_attendees_when_any_segment_is_meeting( day, [{"id": "guest_speaker", "type": "Person", "name": "Guest Speaker"}], ) - _write_sense_json(tmp_path, day, stream, segments[0], {"meeting_detected": False}) - _write_sense_json(tmp_path, day, stream, segments[1], {"meeting_detected": True}) + _write_sense_json( + tmp_path, day, stream, segments[0], _sense_payload(meeting_detected=False) + ) + _write_sense_json( + tmp_path, day, stream, segments[1], _sense_payload(meeting_detected=True) + ) activity = _activity_record(segments) append_activity_record(facet, day, activity) @@ -193,7 +215,7 @@ def test_participation_clamp_is_idempotent_on_second_pass( ) for segment_key in segments: _write_sense_json( - tmp_path, day, stream, segment_key, {"meeting_detected": False} + tmp_path, day, stream, segment_key, _sense_payload(meeting_detected=False) ) activity = _activity_record(segments) @@ -243,7 +265,9 @@ def test_participation_treats_missing_sense_json_as_non_meeting( [{"id": "guest_speaker", "type": "Person", "name": "Guest Speaker"}], ) _write_sense_json(tmp_path, day, stream, segments[0], None) - _write_sense_json(tmp_path, day, stream, segments[1], {"meeting_detected": False}) + _write_sense_json( + tmp_path, day, stream, segments[1], _sense_payload(meeting_detected=False) + ) activity = _activity_record(segments) append_activity_record(facet, day, activity) diff --git a/tests/test_sense_schema.py b/tests/test_sense_schema.py index ad2ec2fe2..ed1f4f8cf 100644 --- a/tests/test_sense_schema.py +++ b/tests/test_sense_schema.py @@ -1,11 +1,15 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc +import json from pathlib import Path import frontmatter +from think.talent import get_talent + SENSE_PATH = Path(__file__).resolve().parents[1] / "talent" / "sense.md" +SENSE_SCHEMA_PATH = SENSE_PATH.with_suffix(".schema.json") def _section(text: str, start: str, end: str | None = None) -> str: @@ -21,17 +25,34 @@ def test_sense_prompt_parses_and_documents_role_and_source(): assert post.metadata["tier"] == 3 - schema = _section( + output_schema = _section( post.content, "## Output Schema", "## Field-by-Field Instructions" ) entities = _section(post.content, "### entities", "### facets") + entity_props = get_talent("sense")["json_schema"]["properties"]["entities"][ + "items" + ]["properties"] - assert '"role": "attendee|mentioned"' in schema - assert '"source": "voice|speaker_label|transcript|screen|other"' in schema + assert post.metadata["schema"] == "sense.schema.json" + assert "Authoritative schema: `sense.schema.json`." in output_schema + assert set(entity_props["role"]["enum"]) == {"attendee", "mentioned"} + assert set(entity_props["source"]["enum"]) == { + "voice", + "speaker_label", + "transcript", + "screen", + "other", + } assert "#### role" in entities assert "#### source" in entities +def test_sense_loaded_json_schema_matches_on_disk_schema(): + on_disk = json.loads(SENSE_SCHEMA_PATH.read_text(encoding="utf-8")) + + assert get_talent("sense")["json_schema"] == on_disk + + def test_role_and_source_do_not_leak_into_other_sense_sections(): content = frontmatter.load(SENSE_PATH).content diff --git a/tests/test_sense_splitter.py b/tests/test_sense_splitter.py index be59a1965..b27204228 100644 --- a/tests/test_sense_splitter.py +++ b/tests/test_sense_splitter.py @@ -32,6 +32,7 @@ def _make_sense_output(**overrides): "speaker_attribution": False, "pulse_update": False, }, + "emotional_register": "neutral", } base.update(overrides) return base @@ -75,10 +76,11 @@ class TestWriteSenseOutputs: sense_json ) - def test_preserves_raw_payload_in_sense_json(self, tmp_path): + def test_preserves_raw_payload_with_extra_keys_for_defensive_replay(self, tmp_path): from think.sense_splitter import write_sense_outputs seg_dir = Path(tmp_path) / "20260304" / "default" / "090000_300" + # Advisory validation means unexpected keys can still reach the splitter. sense_json = _make_sense_output(foo="bar") write_sense_outputs(sense_json, seg_dir) @@ -174,6 +176,7 @@ class TestEdgeCases: seg_dir = Path(tmp_path) / "20260304" / "default" / "090000_300" + # Advisory schema validation means the splitter must still tolerate degraded input. write_sense_outputs({}, seg_dir) agents_dir = seg_dir / "talents" @@ -202,6 +205,7 @@ class TestEdgeCases: "speakers": None, } + # Advisory schema validation means the splitter must still tolerate degraded input. write_sense_outputs(sense_json, seg_dir) agents_dir = seg_dir / "talents"