personal memory agent
0

Configure Feed

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

feat(schemas): wrap 4 Class-B root-array schemas + permanent consumer unwrap (req_bfbdbux6 Lode 2/4)

WHAT: Wrap extract, detect_transcript_segment, speaker_attribution, and schedule root arrays in strict-portable one-key objects (frame_ids, segments, attributions, events). Apply the strict-portable transform at every nesting depth: strip banned schema keywords, require all object properties, set additionalProperties:false, and preserve enums, patterns, and nullable unions.

CONSUMERS: Update the four single parse-sites to unwrap the wrapper key while permanently tolerating bare-list outputs as a defensive shape-compat shim. Relocate the detect_transcript_segment schema $comment to the _SEGMENT_SCHEMA load-site code comment.

GUARDS: Promote the four Class-B ids out of PENDING_PORTABILITY. Rename the provider parity list/test generically and extend it to 17 schemas x 3 providers = 51 cases. Reconcile schema-shape and parse-site tests, including dropping the three stripped-constraint negatives by design.

SHIP NOTE: Consumer-completeness sweep found 4 single boundaries and no additional raw-list parse sites. No live path was found by which a stale cached bare-list output file is fed into schedule.post_process() or speaker_attribution.post_process(); the permanent bare-list tolerance is a defensive compatibility shim mandated by scope, not justified by a discoverable stale-cache replay path.

CI: make ci green (4922 passed, 11 skipped, 8 deselected, 1 unrelated warning).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+388 -204
+5 -1
solstone/observe/extract.py
··· 254 254 temperature=0.3, 255 255 ) 256 256 257 - # Parse response - expecting JSON array of frame_ids 257 + # Parse response - expecting wrapped frame_ids or a bare frame_id list 258 258 selected_ids = json.loads(response) 259 + # Permanent shape-compat: schema emits {"frame_ids": [...]}; tolerate pre-reshape bare-list outputs defensively. 260 + if isinstance(selected_ids, dict): 261 + selected_ids = selected_ids.get("frame_ids", []) 262 + 259 263 if not isinstance(selected_ids, list): 260 264 raise ValueError(f"Expected list of frame_ids, got {type(selected_ids)}") 261 265
+13 -3
solstone/observe/extract.schema.json
··· 1 1 { 2 - "$schema": "https://json-schema.org/draft/2020-12/schema", 3 - "type": "array", 4 - "items": {"type": "integer", "minimum": 0} 2 + "type": "object", 3 + "additionalProperties": false, 4 + "required": [ 5 + "frame_ids" 6 + ], 7 + "properties": { 8 + "frame_ids": { 9 + "type": "array", 10 + "items": { 11 + "type": "integer" 12 + } 13 + } 14 + } 5 15 }
+4
solstone/talent/schedule.py
··· 52 52 logger.error("schedule hook: failed to parse JSON: %s snippet=%r", exc, snippet) 53 53 return None 54 54 55 + # Permanent shape-compat: schema emits {"events": [...]}; tolerate pre-reshape bare-list outputs defensively. 56 + if isinstance(events, dict): 57 + events = events.get("events", []) 58 + 55 59 if not isinstance(events, list): 56 60 logger.error("schedule hook: expected top-level array") 57 61 return None
+122 -95
solstone/talent/schedule.schema.json
··· 1 1 { 2 - "$schema": "https://json-schema.org/draft/2020-12/schema", 3 - "type": "array", 4 - "items": { 5 - "type": "object", 6 - "additionalProperties": false, 7 - "required": [ 8 - "activity", 9 - "target_date", 10 - "start", 11 - "end", 12 - "title", 13 - "description", 14 - "details", 15 - "participation", 16 - "participation_confidence", 17 - "facet", 18 - "cancelled" 19 - ], 20 - "properties": { 21 - "activity": { 22 - "type": "string", 23 - "enum": [ 24 - "meeting", 25 - "call", 26 - "deadline", 27 - "appointment", 28 - "event", 29 - "travel", 30 - "reminder", 31 - "errand", 32 - "celebration", 33 - "doctor_appointment" 34 - ] 35 - }, 36 - "target_date": { 37 - "type": "string", 38 - "pattern": "^\\d{4}-\\d{2}-\\d{2}$" 39 - }, 40 - "start": { 41 - "type": ["string", "null"], 42 - "pattern": "^\\d{2}:\\d{2}:\\d{2}$" 43 - }, 44 - "end": { 45 - "type": ["string", "null"], 46 - "pattern": "^\\d{2}:\\d{2}:\\d{2}$" 47 - }, 48 - "title": { 49 - "type": "string", 50 - "minLength": 1 51 - }, 52 - "description": { 53 - "type": "string", 54 - "minLength": 1 55 - }, 56 - "details": { 57 - "type": "string" 58 - }, 59 - "participation": { 60 - "type": "array", 61 - "items": { 62 - "type": "object", 63 - "additionalProperties": false, 64 - "required": ["name", "role", "source", "confidence", "context"], 65 - "properties": { 66 - "name": { 67 - "type": "string", 68 - "minLength": 1 69 - }, 70 - "role": { 71 - "type": "string", 72 - "enum": ["attendee", "mentioned"] 73 - }, 74 - "source": { 75 - "type": "string", 76 - "enum": ["voice", "speaker_label", "transcript", "screen", "other"] 77 - }, 78 - "confidence": { 79 - "type": "number", 80 - "minimum": 0, 81 - "maximum": 1 82 - }, 83 - "context": { 84 - "type": "string" 2 + "type": "object", 3 + "additionalProperties": false, 4 + "required": [ 5 + "events" 6 + ], 7 + "properties": { 8 + "events": { 9 + "type": "array", 10 + "items": { 11 + "type": "object", 12 + "additionalProperties": false, 13 + "required": [ 14 + "activity", 15 + "target_date", 16 + "start", 17 + "end", 18 + "title", 19 + "description", 20 + "details", 21 + "participation", 22 + "participation_confidence", 23 + "facet", 24 + "cancelled" 25 + ], 26 + "properties": { 27 + "activity": { 28 + "type": "string", 29 + "enum": [ 30 + "meeting", 31 + "call", 32 + "deadline", 33 + "appointment", 34 + "event", 35 + "travel", 36 + "reminder", 37 + "errand", 38 + "celebration", 39 + "doctor_appointment" 40 + ] 41 + }, 42 + "target_date": { 43 + "type": "string", 44 + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" 45 + }, 46 + "start": { 47 + "type": [ 48 + "string", 49 + "null" 50 + ], 51 + "pattern": "^\\d{2}:\\d{2}:\\d{2}$" 52 + }, 53 + "end": { 54 + "type": [ 55 + "string", 56 + "null" 57 + ], 58 + "pattern": "^\\d{2}:\\d{2}:\\d{2}$" 59 + }, 60 + "title": { 61 + "type": "string" 62 + }, 63 + "description": { 64 + "type": "string" 65 + }, 66 + "details": { 67 + "type": "string" 68 + }, 69 + "participation": { 70 + "type": "array", 71 + "items": { 72 + "type": "object", 73 + "additionalProperties": false, 74 + "required": [ 75 + "name", 76 + "role", 77 + "source", 78 + "confidence", 79 + "context" 80 + ], 81 + "properties": { 82 + "name": { 83 + "type": "string" 84 + }, 85 + "role": { 86 + "type": "string", 87 + "enum": [ 88 + "attendee", 89 + "mentioned" 90 + ] 91 + }, 92 + "source": { 93 + "type": "string", 94 + "enum": [ 95 + "voice", 96 + "speaker_label", 97 + "transcript", 98 + "screen", 99 + "other" 100 + ] 101 + }, 102 + "confidence": { 103 + "type": "number" 104 + }, 105 + "context": { 106 + "type": "string" 107 + } 108 + } 85 109 } 110 + }, 111 + "participation_confidence": { 112 + "type": [ 113 + "number", 114 + "null" 115 + ] 116 + }, 117 + "facet": { 118 + "type": "string", 119 + "enum": [ 120 + "__RUNTIME_FACETS__" 121 + ] 122 + }, 123 + "cancelled": { 124 + "type": "boolean" 86 125 } 87 126 } 88 - }, 89 - "participation_confidence": { 90 - "type": ["number", "null"], 91 - "minimum": 0, 92 - "maximum": 1 93 - }, 94 - "facet": { 95 - "type": "string", 96 - "enum": ["__RUNTIME_FACETS__"] 97 - }, 98 - "cancelled": { 99 - "type": "boolean" 100 127 } 101 128 } 102 129 }
+4
solstone/talent/speaker_attribution.py
··· 149 149 if result: 150 150 try: 151 151 parsed = json.loads(result) 152 + # Permanent shape-compat: schema emits {"attributions": [...]}; tolerate pre-reshape bare-list outputs defensively. 153 + if isinstance(parsed, dict): 154 + parsed = parsed.get("attributions", []) 155 + 152 156 if not isinstance(parsed, list): 153 157 raise TypeError(f"expected JSON array, got {type(parsed).__name__}") 154 158 items = parsed
+28 -10
solstone/talent/speaker_attribution.schema.json
··· 1 1 { 2 - "$schema": "https://json-schema.org/draft/2020-12/schema", 3 - "type": "array", 4 - "items": { 5 - "type": "object", 6 - "additionalProperties": false, 7 - "required": ["sentence_id", "speaker", "reasoning"], 8 - "properties": { 9 - "sentence_id": {"type": "integer"}, 10 - "speaker": {"type": "string", "minLength": 1}, 11 - "reasoning": {"type": "string", "minLength": 1} 2 + "type": "object", 3 + "additionalProperties": false, 4 + "required": [ 5 + "attributions" 6 + ], 7 + "properties": { 8 + "attributions": { 9 + "type": "array", 10 + "items": { 11 + "type": "object", 12 + "additionalProperties": false, 13 + "required": [ 14 + "sentence_id", 15 + "speaker", 16 + "reasoning" 17 + ], 18 + "properties": { 19 + "sentence_id": { 20 + "type": "integer" 21 + }, 22 + "speaker": { 23 + "type": "string" 24 + }, 25 + "reasoning": { 26 + "type": "string" 27 + } 28 + } 29 + } 12 30 } 13 31 } 14 32 }
+6 -1
solstone/think/detect_transcript.py
··· 17 17 encoding="utf-8" 18 18 ) 19 19 ) 20 + # Output contract for detect_transcript_segment(). Source of truth is think/detect_transcript_segment.md. 20 21 # Source of truth is think/detect_transcript_json.md. 21 22 _JSON_SCHEMA = json.loads( 22 23 (Path(__file__).parent / "detect_transcript_json.schema.json").read_text( ··· 46 47 """Validate and return segment boundaries from ``json_text``. 47 48 48 49 Args: 49 - json_text: JSON array of {"start_at": "HH:MM:SS", "line": N} objects 50 + json_text: Wrapped or bare JSON array of boundary objects. 50 51 num_lines: Total number of lines in the transcript 51 52 52 53 Returns: ··· 57 58 except json.JSONDecodeError as exc: # pragma: no cover - network errors 58 59 logging.error("Failed to parse JSON response") 59 60 raise ValueError("invalid JSON") from exc 61 + 62 + # Permanent shape-compat: schema emits {"segments": [...]}; tolerate pre-reshape bare-list outputs defensively. 63 + if isinstance(data, dict): 64 + data = data.get("segments", []) 60 65 61 66 if not isinstance(data, list) or not data: 62 67 logging.error("JSON response is not a non-empty list")
+25 -10
solstone/think/detect_transcript_segment.schema.json
··· 1 1 { 2 - "$schema": "https://json-schema.org/draft/2020-12/schema", 3 - "$comment": "Output contract for detect_transcript_segment(). Source of truth is think/detect_transcript_segment.md.", 4 - "type": "array", 5 - "items": { 6 - "type": "object", 7 - "additionalProperties": false, 8 - "required": ["start_at", "line"], 9 - "properties": { 10 - "start_at": {"type": "string", "pattern": "^\\d{2}:\\d{2}:\\d{2}$"}, 11 - "line": {"type": "integer", "minimum": 1} 2 + "type": "object", 3 + "additionalProperties": false, 4 + "required": [ 5 + "segments" 6 + ], 7 + "properties": { 8 + "segments": { 9 + "type": "array", 10 + "items": { 11 + "type": "object", 12 + "additionalProperties": false, 13 + "required": [ 14 + "start_at", 15 + "line" 16 + ], 17 + "properties": { 18 + "start_at": { 19 + "type": "string", 20 + "pattern": "^\\d{2}:\\d{2}:\\d{2}$" 21 + }, 22 + "line": { 23 + "type": "integer" 24 + } 25 + } 26 + } 12 27 } 13 28 } 14 29 }
+28 -4
tests/integration/test_schema_provider_parity.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 - """Raw-SDK strict schema parity tests for req_bfbdbux6 Class-A schemas.""" 4 + """Raw-SDK strict schema parity tests for req_bfbdbux6 portable schemas.""" 5 5 6 6 from __future__ import annotations 7 7 ··· 19 19 REPO_ROOT = Path(__file__).resolve().parents[2] 20 20 FACET_SENTINEL = "__RUNTIME_FACETS__" 21 21 22 - CLASS_A = [ 22 + PORTABLE_SCHEMAS = [ 23 23 ( 24 24 "describe", 25 25 "solstone/observe/describe.schema.json", 26 26 "Categorize a hypothetical code-editor-with-terminal screenshot.", 27 27 ), 28 28 ( 29 + "extract", 30 + "solstone/observe/extract.schema.json", 31 + "Select frame ids 1 and 3 from hypothetical screen frames. Return frame_ids [1, 3].", 32 + ), 33 + ( 29 34 "meeting", 30 35 "solstone/observe/categories/meeting.schema.json", 31 36 "Hypothetical 2-person Zoom: Alice speaking video-on box [10,20,30,40]; " ··· 55 60 "topics/setting short.", 56 61 ), 57 62 ( 63 + "detect_transcript_segment", 64 + "solstone/think/detect_transcript_segment.schema.json", 65 + "Segment a hypothetical transcript into two boundaries: 12:00:00 line 1 " 66 + "and 12:05:00 line 3.", 67 + ), 68 + ( 58 69 "daily_schedule", 59 70 "solstone/talent/daily_schedule.schema.json", 60 71 "primary '09:00', fallback '13:30'.", ··· 77 88 "solstone/talent/story.schema.json", 78 89 "Short story body 's', topics ['t'], confidence 0.5, no commitments/" 79 90 "closures/decisions.", 91 + ), 92 + ( 93 + "speaker_attribution", 94 + "solstone/talent/speaker_attribution.schema.json", 95 + "One attribution: sentence_id 1, speaker Alice, reasoning Introduced herself.", 96 + ), 97 + ( 98 + "schedule", 99 + "solstone/talent/schedule.schema.json", 100 + "One future meeting event: target_date 2026-05-20, start 09:00:00, " 101 + "end 09:30:00, title Planning Call, description Planning call, details " 102 + "Google Meet, one attendee Alice from screen confidence 0.9 context " 103 + "calendar invite, participation_confidence 0.8, facet work, cancelled false.", 80 104 ), 81 105 ( 82 106 "segment_summary", ··· 214 238 ) 215 239 @pytest.mark.parametrize( 216 240 ("schema_name", "schema_path", "prompt"), 217 - [pytest.param(*schema_case, id=schema_case[0]) for schema_case in CLASS_A], 241 + [pytest.param(*schema_case, id=schema_case[0]) for schema_case in PORTABLE_SCHEMAS], 218 242 ) 219 - def test_class_a_schema_provider_parity( 243 + def test_schema_provider_parity( 220 244 provider_name: str, 221 245 api_key_name: str, 222 246 caller: Callable[[dict[str, Any], str, str], str],
+16
tests/test_detect_transcript.py
··· 84 84 85 85 # Returns list of (start_at, text) tuples 86 86 assert result == [("14:30:00", "a\nb"), ("14:35:00", "c\nd")] 87 + 88 + 89 + def test_detect_transcript_segment_accepts_wrapped_segments(monkeypatch): 90 + mod = importlib.import_module("solstone.think.detect_transcript") 91 + 92 + def mock_generate(**kwargs): 93 + return ( 94 + '{"segments": [{"start_at": "14:30:00", "line": 1}, ' 95 + '{"start_at": "14:35:00", "line": 3}]}' 96 + ) 97 + 98 + monkeypatch.setattr("solstone.think.models.generate", mock_generate) 99 + 100 + result = mod.detect_transcript_segment("a\nb\nc\nd", "14:30:00") 101 + 102 + assert result == [("14:30:00", "a\nb"), ("14:35:00", "c\nd")]
+9 -7
tests/test_detect_transcript_schema.py
··· 44 44 def test_detect_transcript_segment_schema_accepts_and_rejects_expected_values(): 45 45 schema = _load_detect_transcript_segment_schema() 46 46 validator = Draft202012Validator(schema) 47 - valid = [{"start_at": "12:34:56", "line": 1}] 47 + valid = {"segments": [{"start_at": "12:34:56", "line": 1}]} 48 48 49 49 assert validator.is_valid(valid) 50 - assert not validator.is_valid([{"start_at": "12:34:56"}]) 51 - assert not validator.is_valid([{"start_at": "12:34", "line": 1}]) 52 - assert not validator.is_valid([{"start_at": "12:34:56", "line": "1"}]) 53 - assert not validator.is_valid([{"start_at": "12:34:56", "line": 0}]) 54 - assert not validator.is_valid([{"start_at": "12:34:56", "line": 1, "extra": "x"}]) 50 + assert not validator.is_valid([{"start_at": "12:34:56", "line": 1}]) 51 + assert not validator.is_valid({"segments": [{"start_at": "12:34:56"}]}) 52 + assert not validator.is_valid({"segments": [{"start_at": "12:34", "line": 1}]}) 53 + assert not validator.is_valid({"segments": [{"start_at": "12:34:56", "line": "1"}]}) 54 + assert not validator.is_valid( 55 + {"segments": [{"start_at": "12:34:56", "line": 1, "extra": "x"}]} 56 + ) 55 57 56 58 57 59 def test_detect_transcript_json_schema_accepts_and_rejects_expected_values(): ··· 107 109 108 110 def fake_generate(**kwargs): 109 111 captured.update(kwargs) 110 - return '[{"start_at": "12:00:00", "line": 1}]' 112 + return '{"segments": [{"start_at": "12:00:00", "line": 1}]}' 111 113 112 114 monkeypatch.setattr(models, "generate", fake_generate) 113 115
+15
tests/test_extract.py
··· 176 176 mock_generate.assert_called_once() 177 177 178 178 179 + def test_ai_selection_accepts_wrapped_frame_ids(): 180 + """Test AI selection accepts the strict-portable wrapper shape.""" 181 + frames = _make_frames(10) 182 + categories = {"code": {"description": "Code editors"}} 183 + 184 + with patch("solstone.think.models.generate") as mock_generate: 185 + mock_generate.return_value = '{"frame_ids": [1, 3, 5]}' 186 + result = select_frames_for_extraction( 187 + frames, max_extractions=5, categories=categories 188 + ) 189 + 190 + assert result == [1, 3, 5] 191 + mock_generate.assert_called_once() 192 + 193 + 179 194 def test_ai_selection_filters_invalid_ids(): 180 195 """Test that AI selection filters out invalid frame IDs.""" 181 196 frames = _make_frames(5) # IDs 1-5
+10 -9
tests/test_extract_schema.py
··· 28 28 def test_extract_schema_accepts_and_rejects_expected_values(): 29 29 validator = Draft202012Validator(_SCHEMA) 30 30 31 - assert validator.is_valid([]) 32 - assert validator.is_valid([1, 15, 42, 89]) 33 - assert validator.is_valid([1, 0]) 34 - assert not validator.is_valid(["1"]) 35 - assert not validator.is_valid([-1]) 36 - assert not validator.is_valid([1.5]) 31 + assert validator.is_valid({"frame_ids": []}) 32 + assert validator.is_valid({"frame_ids": [1, 15, 42, 89]}) 33 + assert validator.is_valid({"frame_ids": [1, 0]}) 34 + assert not validator.is_valid([1]) 35 + assert not validator.is_valid({"frame_ids": ["1"]}) 36 + assert not validator.is_valid({"frame_ids": [1.5]}) 37 37 assert not validator.is_valid(42) 38 - assert not validator.is_valid({"ids": [1]}) 39 - assert not validator.is_valid([[1, 2]]) 38 + assert not validator.is_valid({}) 39 + assert not validator.is_valid({"frame_ids": [1], "ids": [1]}) 40 + assert not validator.is_valid({"frame_ids": [[1, 2]]}) 40 41 41 42 42 43 def test_ai_select_frames_passes_schema_to_generate(monkeypatch): ··· 44 45 45 46 def fake_generate(**kwargs): 46 47 captured.update(kwargs) 47 - return "[1]" 48 + return '{"frame_ids": [1]}' 48 49 49 50 monkeypatch.setattr(models, "generate", fake_generate) 50 51
+33
tests/test_schedule_hook.py
··· 106 106 assert record["edits"][-1]["note"] == "created by schedule" 107 107 108 108 109 + def test_schedule_post_process_accepts_wrapped_events(tmp_path, monkeypatch): 110 + from solstone.talent.schedule import post_process 111 + from solstone.think.activities import load_activity_records 112 + 113 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 114 + _write_facet(tmp_path, "work") 115 + 116 + payload = { 117 + "events": [ 118 + { 119 + "activity": "meeting", 120 + "target_date": "2026-04-23", 121 + "start": "09:00:00", 122 + "end": "09:30:00", 123 + "title": "Planning call", 124 + "description": "Planning call with the team.", 125 + "details": "Google Meet", 126 + "participation": [], 127 + "participation_confidence": 0.8, 128 + "facet": "work", 129 + "cancelled": False, 130 + } 131 + ] 132 + } 133 + 134 + post_process(json.dumps(payload), {"day": "20260418"}) 135 + 136 + records = load_activity_records("work", "20260423", include_hidden=True) 137 + assert len(records) == 1 138 + assert records[0]["id"] == "anticipated_meeting_090000_0423" 139 + assert records[0]["source"] == "anticipated" 140 + 141 + 109 142 def test_schedule_post_process_marks_cancelled_records_hidden(tmp_path, monkeypatch): 110 143 from solstone.talent.schedule import post_process 111 144 from solstone.think.activities import load_activity_records
+16 -19
tests/test_schedule_schema.py
··· 42 42 return schema 43 43 44 44 45 - def _strip_portability_annotations(value): 46 - if isinstance(value, dict): 47 - for key in ("minLength", "minimum", "maximum"): 48 - value.pop(key, None) 49 - for child in value.values(): 50 - _strip_portability_annotations(child) 51 - elif isinstance(value, list): 52 - for child in value: 53 - _strip_portability_annotations(child) 45 + def _schedule_event_schema(schema: dict | None = None) -> dict: 46 + if schema is None: 47 + schema = _load_schedule_schema() 48 + return schema["properties"]["events"]["items"] 54 49 55 50 56 51 def _expected_schedule_activity_ids() -> set[str]: ··· 161 156 162 157 def test_schedule_schema_facet_uses_runtime_sentinel_constant(): 163 158 schema = _load_schedule_schema() 164 - facet_schema = schema["items"]["properties"]["facet"] 159 + facet_schema = _schedule_event_schema(schema)["properties"]["facet"] 165 160 166 161 assert facet_schema["enum"] == [RUNTIME_FACETS_SENTINEL] 167 162 168 163 169 164 def test_schedule_activity_enum_matches_default_activities_drift_detector(): 170 165 schema = _load_schedule_schema() 171 - item_schema = schema["items"] 166 + item_schema = _schedule_event_schema(schema) 172 167 173 168 assert set(item_schema["properties"]["activity"]["enum"]) == ( 174 169 _expected_schedule_activity_ids() ··· 189 184 ] 190 185 191 186 raw_inline_items = dict( 192 - schedule_schema["items"]["properties"]["participation"]["items"] 187 + _schedule_event_schema(schedule_schema)["properties"]["participation"]["items"] 193 188 ) 194 189 assert "entity_id" in fragment["properties"] 195 190 assert "entity_id" not in raw_inline_items["properties"] 196 191 assert raw_inline_items != fragment 197 192 198 - inline_items = json.loads(json.dumps(raw_inline_items)) 199 - _strip_portability_annotations(inline_items) 200 - 201 - assert inline_items == fragment_without_schema 193 + assert raw_inline_items == fragment_without_schema 202 194 203 195 204 196 def test_schedule_schema_mirrors_hook_requirements(): 205 197 schedule_schema = _load_schedule_schema() 206 - item_schema = schedule_schema["items"] 198 + events_schema = schedule_schema["properties"]["events"] 199 + item_schema = events_schema["items"] 207 200 properties = item_schema["properties"] 208 201 participation_items = properties["participation"]["items"] 209 202 fragment = _load_json(PARTICIPATION_ENTRY_SCHEMA_PATH) 210 203 211 - assert schedule_schema["type"] == "array" 204 + assert schedule_schema["type"] == "object" 205 + assert schedule_schema["additionalProperties"] is False 206 + assert schedule_schema["required"] == ["events"] 207 + assert events_schema["type"] == "array" 212 208 assert set(item_schema["required"]) == SCHEDULE_REQUIRED_FIELDS 213 209 assert set(properties["activity"]["enum"]) == _expected_schedule_activity_ids() 214 210 assert ( ··· 229 225 validator = Draft202012Validator(hydrate_runtime_enums(_load_schedule_schema())) 230 226 231 227 for payload in _sample_schedule_payloads(): 232 - assert list(validator.iter_errors(payload)) == [] 228 + assert list(validator.iter_errors({"events": payload})) == [] 229 + assert list(validator.iter_errors(payload)) != []
-4
tests/test_schema_strict_portability.py
··· 35 35 # portabilized; Lode 4 deletes this allowlist mechanism entirely. 36 36 PENDING_PORTABILITY = frozenset( 37 37 { 38 - "solstone/observe/extract.schema.json", 39 - "solstone/talent/schedule.schema.json", 40 - "solstone/talent/speaker_attribution.schema.json", 41 - "solstone/think/detect_transcript_segment.schema.json", 42 38 "solstone/talent/chat.schema.json", 43 39 "build_rollup_schema(3)", 44 40 }
+19 -10
tests/test_speaker_attribution_hook.py
··· 214 214 } 215 215 accumulate_mock.assert_not_called() 216 216 217 - def test_wrapper_shape_yields_zero_merges(self, tmp_path): 217 + def test_wrapped_attributions_merge_layer4_attributions(self, tmp_path): 218 218 result = json.dumps( 219 - {"attributions": [{"sentence_id": 1, "speaker": "Alice", "reasoning": "x"}]} 219 + { 220 + "attributions": [ 221 + { 222 + "sentence_id": 1, 223 + "speaker": "Alice", 224 + "reasoning": "said her name", 225 + } 226 + ] 227 + } 220 228 ) 221 229 context = _post_process_context() 222 230 ··· 224 232 patch( 225 233 "solstone.apps.speakers.attribution.save_speaker_labels" 226 234 ) as save_mock, 227 - patch("solstone.apps.speakers.attribution.accumulate_voiceprints"), 235 + patch( 236 + "solstone.apps.speakers.attribution.accumulate_voiceprints" 237 + ) as accumulate_mock, 228 238 patch( 229 239 "solstone.think.entities.find_matching_entity", 230 240 side_effect=_match_entity, 231 - ) as match_mock, 241 + ), 232 242 patch( 233 243 "solstone.think.entities.journal.load_all_journal_entities", 234 244 return_value={"alice": {"id": "alice"}}, 235 - ) as load_mock, 245 + ), 236 246 patch("solstone.think.utils.segment_path", return_value=tmp_path), 237 247 ): 238 248 from solstone.talent.speaker_attribution import post_process ··· 242 252 saved_labels = save_mock.call_args[0][1] 243 253 assert saved_labels[0] == { 244 254 "sentence_id": 1, 245 - "speaker": None, 246 - "confidence": None, 247 - "method": None, 255 + "speaker": "alice", 256 + "confidence": "medium", 257 + "method": "contextual", 248 258 } 249 259 assert saved_labels[1] == { 250 260 "sentence_id": 2, ··· 252 262 "confidence": "high", 253 263 "method": "owner", 254 264 } 255 - load_mock.assert_not_called() 256 - match_mock.assert_not_called() 265 + accumulate_mock.assert_not_called() 257 266 258 267 def test_non_list_non_dict_yields_zero_merges_and_warns(self, tmp_path, caplog): 259 268 context = _post_process_context()
+35 -31
tests/test_speaker_attribution_schema.py
··· 32 32 @pytest.mark.parametrize( 33 33 "payload", 34 34 [ 35 - [{"sentence_id": 1, "speaker": "Alice", "reasoning": "Introduced herself."}], 36 - [ 37 - {"sentence_id": 1, "speaker": "Alice", "reasoning": "Introduced herself."}, 38 - {"sentence_id": 2, "speaker": "Bob", "reasoning": "Replied to Alice."}, 39 - ], 35 + { 36 + "attributions": [ 37 + { 38 + "sentence_id": 1, 39 + "speaker": "Alice", 40 + "reasoning": "Introduced herself.", 41 + } 42 + ] 43 + }, 44 + { 45 + "attributions": [ 46 + { 47 + "sentence_id": 1, 48 + "speaker": "Alice", 49 + "reasoning": "Introduced herself.", 50 + }, 51 + {"sentence_id": 2, "speaker": "Bob", "reasoning": "Replied to Alice."}, 52 + ] 53 + }, 40 54 ], 41 55 ) 42 56 def test_positive_payload_validates(payload): ··· 45 59 assert validator.is_valid(payload) 46 60 47 61 48 - def test_negative_wrapper_object_rejected(): 62 + def test_negative_bare_array_rejected(): 49 63 validator = Draft202012Validator(_load_schema()) 50 64 51 65 assert not validator.is_valid( 52 - { 53 - "attributions": [ 54 - { 55 - "sentence_id": 1, 56 - "speaker": "Alice", 57 - "reasoning": "Introduced herself.", 58 - } 59 - ] 60 - } 66 + [{"sentence_id": 1, "speaker": "Alice", "reasoning": "Introduced herself."}] 61 67 ) 62 68 63 69 64 70 def test_negative_missing_required_field_rejected(): 65 71 validator = Draft202012Validator(_load_schema()) 66 72 67 - assert not validator.is_valid([{"sentence_id": 1, "speaker": "Alice"}]) 68 - 69 - 70 - def test_negative_empty_string_fields_rejected(): 71 - validator = Draft202012Validator(_load_schema()) 72 - 73 - assert not validator.is_valid([{"sentence_id": 1, "speaker": "", "reasoning": "x"}]) 73 + assert not validator.is_valid( 74 + {"attributions": [{"sentence_id": 1, "speaker": "Alice"}]} 75 + ) 74 76 75 77 76 78 def test_negative_non_integer_sentence_id_rejected(): 77 79 validator = Draft202012Validator(_load_schema()) 78 80 79 81 assert not validator.is_valid( 80 - [{"sentence_id": "1", "speaker": "Alice", "reasoning": "x"}] 82 + {"attributions": [{"sentence_id": "1", "speaker": "Alice", "reasoning": "x"}]} 81 83 ) 82 84 83 85 ··· 85 87 validator = Draft202012Validator(_load_schema()) 86 88 87 89 assert not validator.is_valid( 88 - [ 89 - { 90 - "sentence_id": 1, 91 - "speaker": "Alice", 92 - "reasoning": "x", 93 - "confidence": "high", 94 - } 95 - ] 90 + { 91 + "attributions": [ 92 + { 93 + "sentence_id": 1, 94 + "speaker": "Alice", 95 + "reasoning": "x", 96 + "confidence": "high", 97 + } 98 + ] 99 + } 96 100 )