personal memory agent
0

Configure Feed

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

feat(schemas): name-first relational layer for story + entity_observer

Add a bounded relational layer to two generation schemas. Models emit entity
names; Python resolves them to entity IDs after generation. An unresolved name
never becomes a fabricated ID.

story.schema.json gains a required top-level `relations` array
({from, to, kind, note, quote}) and a required nullable `counterparty` on
decisions. story.py resolves relation endpoints and decision counterparties
through the existing find_matching_entity(fuzzy_threshold=90) path, leaving
*_entity_id as None on a miss while the name and note/quote evidence survive
to disk. Relations with a kind outside the closed 8-value enum, or kind
"other" with no note, are skipped with a warning -- mirroring the existing
ALLOWED_RESOLUTIONS precedent.

entity_observer operations gain an optional nullable `relation` component with
a model-emitted target_name. An unresolvable target drops the whole op, logs a
warning, and increments a `relation_unresolved` counter surfaced in
<day>_observer_outcome.json. A relation-bearing op lands complete or not at
all.

Relation persistence stays with the L2 write owners: activity records via
activities.py::merge_story_fields, observations via
entities/observations.py::_new_observation. The app hook writes neither.

The relation-kind vocabulary lives in exactly one Python frozenset
(story.ALLOWED_RELATION_KINDS) plus the two JSON enums, with parity tests.

Both schemas are now fully bounded (maxItems on every array, maxLength on
every free-text string, including nullable ones), so their
check_schema_bounds.py allowlist entries are deleted -- the guard fails CI on
stale entries. max_output_tokens=12288 on the three story talents is derived
from the bounded schema's 31192-char theoretical maximum (~8912 tokens, within
0.8x the budget); the resulting LLM request timeout is 286s.

test_schema_prep.py's byte-identity snapshot asserted that no shipped schema
carries provider-stripped keywords -- a claim the schema-bounds ratchet is
designed to falsify. Replace it with the durable invariant: prep always yields
a provider-supported subset, is a no-op exactly when the schema has no
unsupported keywords, and demonstrably rewrites the schema when it does. Add
explicit provider-behavior tests for the newly bounded schemas (local keeps
bounds; openai/google lose maxLength; anthropic loses maxLength and maxItems),
guarded against passing vacuously.

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

+836 -76
-4
scripts/check_schema_bounds.py
··· 28 28 "solstone/apps/entities/talent/entities_review.schema.json": ( 29 29 "entity_observer follow-on lode" 30 30 ), 31 - "solstone/apps/entities/talent/entity_observer.schema.json": ( 32 - "entity_observer follow-on lode" 33 - ), 34 31 "solstone/apps/timeline/talent/segment_summary.schema.json": ( 35 32 "documents follow-on lode" 36 33 ), ··· 53 50 "unbounded pending KG schema enrichment arc" 54 51 ), 55 52 "solstone/talent/steward.schema.json": "morning_briefing follow-on lode", 56 - "solstone/talent/story.schema.json": "story follow-on lode", 57 53 "solstone/think/detect_created.schema.json": ( 58 54 "unbounded pending KG schema enrichment arc" 59 55 ),
+14 -5
solstone/apps/entities/talent/entity_observer.md
··· 76 76 77 77 Rules: 78 78 - Use the `entity_id` from context. 79 - - Include every field on each operation; set non-applicable fields (`target_index`, `content`, `target_quote`) to `null`. 79 + - Include every field on each operation; set non-applicable fields (`target_index`, `content`, `target_quote`, `relation`) to `null`. 80 80 - Prefer `update` or `drop` over adding a near-duplicate observation. 81 81 - For `update` and `drop`, include a short verbatim `target_quote` from the target observation. 82 82 - At most one operation may target a given observation index for an entity. 83 83 - Use `add` only for facts that pass the durability litmus. 84 84 - One fact per observation — no compound sentences. 85 + - `relation` is `null` unless the observation asserts a relationship. `target_name` is the other entity's NAME, never an id. `kind` must be one of `works-with`, `works-at`, `reports-to`, `family-of`, `knows`, `uses`, `created`, `other`. `note` explains the relationship and is required when `kind` is `"other"`. 85 86 - The `reasoning` field is for audit only. 86 87 - Empty operations are valid when no changes are needed for an entity. 87 88 ··· 100 101 "target_index": 0, 101 102 "content": "The revised durable observation text", 102 103 "target_quote": "short exact quote from the old observation", 103 - "reasoning": "Why this update is warranted" 104 + "reasoning": "Why this update is warranted", 105 + "relation": null 104 106 }, 105 107 { 106 108 "op": "add", 107 109 "target_index": null, 108 110 "content": "A new durable observation text", 109 111 "target_quote": null, 110 - "reasoning": "Why this qualifies" 112 + "reasoning": "Why this qualifies", 113 + "relation": { 114 + "kind": "works-with", 115 + "target_name": "Bob Lee", 116 + "note": "" 117 + } 111 118 }, 112 119 { 113 120 "op": "drop", 114 121 "target_index": 2, 115 122 "content": null, 116 123 "target_quote": "short exact quote from the old observation", 117 - "reasoning": "Why this should be removed" 124 + "reasoning": "Why this should be removed", 125 + "relation": null 118 126 }, 119 127 { 120 128 "op": "keep", 121 129 "target_index": 3, 122 130 "content": null, 123 131 "target_quote": null, 124 - "reasoning": "Why this should remain unchanged" 132 + "reasoning": "Why this should remain unchanged", 133 + "relation": null 125 134 } 126 135 ] 127 136 }
+88 -15
solstone/apps/entities/talent/entity_observer.py
··· 15 15 import json 16 16 import logging 17 17 18 + from solstone.talent.story import ALLOWED_RELATION_KINDS 18 19 from solstone.think.entities.context import assemble_observer_context 19 20 from solstone.think.entities.loading import detected_entities_path, load_entities 21 + from solstone.think.entities.matching import find_matching_entity 20 22 from solstone.think.entities.observations import record_observation_ops 21 23 from solstone.think.journal_io import LockTimeout 22 24 from solstone.think.utils import now_ms ··· 35 37 36 38 37 39 def _empty_counts() -> dict[str, int]: 38 - return {"update": 0, "add": 0, "drop": 0, "keep": 0, "skipped": 0} 40 + return { 41 + "update": 0, 42 + "add": 0, 43 + "drop": 0, 44 + "keep": 0, 45 + "skipped": 0, 46 + "relation_unresolved": 0, 47 + } 39 48 40 49 41 50 def _write_outcome( ··· 65 74 return value or None 66 75 67 76 68 - def _clean_operation(item: object, seen_indexes: set[int]) -> tuple[dict | None, bool]: 77 + def _clean_relation( 78 + value: object, 79 + op: str, 80 + entities: list[dict], 81 + ) -> tuple[dict | None, str | None]: 82 + if value is None or op in {"drop", "keep"}: 83 + return None, None 84 + if not isinstance(value, dict): 85 + logger.warning("entity_observer: invalid relation payload for %s", op) 86 + return None, "skipped" 87 + 88 + kind = value.get("kind") 89 + target_name = value.get("target_name") 90 + note = value.get("note") 91 + if ( 92 + not isinstance(kind, str) 93 + or not isinstance(target_name, str) 94 + or not isinstance(note, str) 95 + ): 96 + logger.warning("entity_observer: invalid relation fields for %s", op) 97 + return None, "skipped" 98 + if kind not in ALLOWED_RELATION_KINDS: 99 + logger.warning("entity_observer: invalid relation kind %r", kind) 100 + return None, "skipped" 101 + if kind == "other" and not note.strip(): 102 + logger.warning("entity_observer: relation kind 'other' requires note") 103 + return None, "skipped" 104 + 105 + match = find_matching_entity(target_name, entities, fuzzy_threshold=90) 106 + if not match: 107 + logger.warning( 108 + "entity_observer: unresolved relation target %r for %s op", 109 + target_name, 110 + op, 111 + ) 112 + return None, "relation_unresolved" 113 + 114 + return { 115 + "kind": kind, 116 + "target_entity_id": match["id"], 117 + "target_name": target_name, 118 + "note": note, 119 + }, None 120 + 121 + 122 + def _clean_operation( 123 + item: object, seen_indexes: set[int], entities: list[dict] 124 + ) -> tuple[dict | None, str | None]: 69 125 if not isinstance(item, dict): 70 - return None, True 126 + return None, "skipped" 71 127 72 128 op = item.get("op") 73 129 if op == "add": 74 130 content = item.get("content") 75 131 if not isinstance(content, str) or not content.strip(): 76 - return None, True 77 - return {"op": "add", "content": content.strip()}, False 132 + return None, "skipped" 133 + relation, status = _clean_relation(item.get("relation"), op, entities) 134 + if status is not None: 135 + return None, status 136 + cleaned = {"op": "add", "content": content.strip()} 137 + if relation is not None: 138 + cleaned["relation"] = relation 139 + return cleaned, None 78 140 79 141 if op not in {"update", "drop", "keep"}: 80 - return None, True 142 + return None, "skipped" 81 143 82 144 target_index = _target_index(item.get("target_index")) 83 145 if target_index is None: 84 - return None, True 146 + return None, "skipped" 85 147 content: str | None = None 86 148 if op == "update": 87 149 raw_content = item.get("content") 88 150 if not isinstance(raw_content, str) or not raw_content.strip(): 89 - return None, True 151 + return None, "skipped" 90 152 content = raw_content.strip() 91 153 raw_quote = item.get("target_quote") 92 154 if raw_quote is not None and not isinstance(raw_quote, str): 93 - return None, True 155 + return None, "skipped" 94 156 quote = _target_quote(raw_quote) 95 157 if target_index in seen_indexes: 96 - return None, True 158 + return None, "skipped" 97 159 seen_indexes.add(target_index) 98 160 99 161 cleaned: dict = {"op": op, "target_index": target_index} ··· 103 165 if content is not None: 104 166 cleaned["content"] = content 105 167 106 - return cleaned, False 168 + relation, status = _clean_relation(item.get("relation"), op, entities) 169 + if status is not None: 170 + return None, status 171 + if relation is not None: 172 + cleaned["relation"] = relation 173 + 174 + return cleaned, None 107 175 108 176 109 177 def _merge_counts(target: dict[str, int], source: dict[str, int]) -> None: ··· 138 206 logger.warning("entity_observer: entities is not a list") 139 207 return None 140 208 209 + attached_entities = load_entities(facet) 141 210 valid_entity_ids = { 142 - entity.get("id") for entity in load_entities(facet) if entity.get("id") 211 + entity.get("id") for entity in attached_entities if entity.get("id") 143 212 } 144 213 145 214 for entry in entities: ··· 155 224 counts["skipped"] += len(operations) 156 225 logger.debug("Skipping entity entry with invalid entity_id: %r", entry) 157 226 continue 227 + # entity_id is enumerated in $observer_context and validated with load_entities(facet). 228 + # Name-first would touch assemble_observer_context, prompt numbering, record_observation_ops. 158 229 if entity_id not in valid_entity_ids: 159 230 counts["skipped"] += len(operations) 160 231 logger.debug("Skipping unrecognized entity_id: %s", entity_id) ··· 163 234 clean_ops: list[dict] = [] 164 235 seen_indexes: set[int] = set() 165 236 for item in operations: 166 - clean_op, skipped = _clean_operation(item, seen_indexes) 167 - if skipped: 168 - counts["skipped"] += 1 237 + clean_op, status = _clean_operation( 238 + item, seen_indexes, attached_entities 239 + ) 240 + if status is not None: 241 + counts[status] += 1 169 242 continue 170 243 if clean_op is not None: 171 244 clean_ops.append(clean_op)
+49 -6
solstone/apps/entities/talent/entity_observer.schema.json
··· 8 8 "properties": { 9 9 "entities": { 10 10 "type": "array", 11 + "maxItems": 24, 11 12 "items": { 12 13 "type": "object", 13 14 "additionalProperties": false, ··· 17 18 ], 18 19 "properties": { 19 20 "entity_id": { 20 - "type": "string" 21 + "type": "string", 22 + "maxLength": 160 21 23 }, 22 24 "operations": { 23 25 "type": "array", 26 + "maxItems": 20, 24 27 "items": { 25 28 "type": "object", 26 29 "additionalProperties": false, ··· 29 32 "target_index", 30 33 "content", 31 34 "target_quote", 32 - "reasoning" 35 + "reasoning", 36 + "relation" 33 37 ], 34 38 "properties": { 35 39 "op": { ··· 51 55 "type": [ 52 56 "string", 53 57 "null" 54 - ] 58 + ], 59 + "maxLength": 600 55 60 }, 56 61 "target_quote": { 57 62 "type": [ 58 63 "string", 59 64 "null" 60 - ] 65 + ], 66 + "maxLength": 300 61 67 }, 62 68 "reasoning": { 63 - "type": "string" 69 + "type": "string", 70 + "maxLength": 300 71 + }, 72 + "relation": { 73 + "type": [ 74 + "object", 75 + "null" 76 + ], 77 + "additionalProperties": false, 78 + "required": [ 79 + "kind", 80 + "target_name", 81 + "note" 82 + ], 83 + "properties": { 84 + "kind": { 85 + "type": "string", 86 + "enum": [ 87 + "works-with", 88 + "works-at", 89 + "reports-to", 90 + "family-of", 91 + "knows", 92 + "uses", 93 + "created", 94 + "other" 95 + ] 96 + }, 97 + "target_name": { 98 + "type": "string", 99 + "maxLength": 120 100 + }, 101 + "note": { 102 + "type": "string", 103 + "maxLength": 300 104 + } 105 + } 64 106 } 65 107 } 66 108 } ··· 69 111 } 70 112 }, 71 113 "summary": { 72 - "type": "string" 114 + "type": "string", 115 + "maxLength": 500 73 116 } 74 117 } 75 118 }
+9 -5
solstone/talent/conversation.md
··· 8 8 "priority": 20, 9 9 "tier": 3, 10 10 "output": "json", 11 + "max_output_tokens": 12288, 11 12 "schema": "story.schema.json", 12 13 "hook": {"post": "story"}, 13 14 "degradation_check": true, ··· 32 33 Participation and entity extraction already happened upstream. Reuse that context; 33 34 do not re-extract people or entities into new structures. 34 35 35 - Return exactly this six-field JSON object: 36 + Return exactly this seven-field JSON object: 36 37 - `body`: string narrative prose covering what was discussed, what moved, and any commitments. 37 38 - `topics`: array of short string tags; use `[]` when there are no durable topics worth preserving. 38 39 - `confidence`: float from 0.0 to 1.0. ··· 40 41 Example: `{"owner":"Mina","action":"send the revised deck","counterparty":"Ravi","when":"Friday morning","context":"Mina committed to send the deck before the investor follow-up."}` 41 42 - `closures`: array of objects with required string fields `owner`, `action`, `counterparty`, `resolution`, `context`. `resolution` must be one of `sent`, `done`, `signed`, `dropped`, `deferred`. 42 43 Example: `{"owner":"Ravi","action":"intro email","counterparty":"Mina","resolution":"sent","context":"Ravi confirmed the intro email already went out during the call."}` 43 - - `decisions`: array of objects with required string fields `owner`, `action`, `context`. 44 - Example: `{"owner":"Team","action":"schedule the launch review for next Tuesday","context":"The group agreed to move the review to Tuesday after checking calendars."}` 44 + - `decisions`: array of objects with required string fields `owner`, `action`, `context`, plus nullable `counterparty`; emit `null` when there is no counterparty. 45 + Example: `{"owner":"Team","action":"schedule the launch review for next Tuesday","counterparty":null,"context":"The group agreed to move the review to Tuesday after checking calendars."}` 46 + - `relations`: array of objects with required fields `from`, `to`, `kind`, `note`, `quote`. Use entity NAMES, not ids. `kind` must be one of `works-with`, `works-at`, `reports-to`, `family-of`, `knows`, `uses`, `created`, `other`. 47 + Example: `{"from":"Mina","to":"Ravi","kind":"works-with","note":"","quote":"Mina and Ravi will co-own the investor follow-up."}` 48 + Use `[]` unless a relationship is actually evidenced in the content. `note` is required; use `""` when the kind speaks for itself, but explain the relationship when `kind` is `"other"`. 45 49 46 - Return `[]` if you do not observe a clear commitment / closure / decision. Better to omit than invent. 50 + Return `[]` if you do not observe a clear commitment / closure / decision / relation. Better to omit than invent. 47 51 48 52 Body requirements: 49 53 - Write one tight paragraph in chronological order. ··· 53 57 - If the activity mixes channels, unify them into one narrative rather than 54 58 listing separate threads. 55 59 56 - Output a single JSON object with all six required fields: `body`, `topics`, `confidence`, `commitments`, `closures`, and `decisions`. 60 + Output a single JSON object with all seven required fields: `body`, `topics`, `confidence`, `commitments`, `closures`, `decisions`, and `relations`.
+9 -5
solstone/talent/event.md
··· 7 7 "activities": ["appointment", "event", "travel", "errand", "celebration", "deadline", "reminder"], 8 8 "priority": 20, 9 9 "output": "json", 10 + "max_output_tokens": 12288, 10 11 "schema": "story.schema.json", 11 12 "hook": {"post": "story"}, 12 13 "degradation_check": true, ··· 32 33 upstream. Use that context; do not re-extract people or entities into new 33 34 structures. 34 35 35 - Return exactly this six-field JSON object: 36 + Return exactly this seven-field JSON object: 36 37 - `body`: string narrative prose describing what happened and any outcome. 37 38 - `topics`: array of short string tags; use `[]` when there are no durable topics worth preserving. 38 39 - `confidence`: float from 0.0 to 1.0. ··· 40 41 Example: `{"owner":"Jordan","action":"send the updated itinerary","counterparty":"Taylor","when":"tonight","context":"Jordan said the revised travel plan would be sent after the delay was confirmed."}` 41 42 - `closures`: array of objects with required string fields `owner`, `action`, `counterparty`, `resolution`, `context`. `resolution` must be one of `sent`, `done`, `signed`, `dropped`, `deferred`. 42 43 Example: `{"owner":"Jordan","action":"hotel confirmation","counterparty":"Taylor","resolution":"signed","context":"Jordan completed and signed the hotel check-in form during the event."}` 43 - - `decisions`: array of objects with required string fields `owner`, `action`, `context`. 44 - Example: `{"owner":"Travel group","action":"take the shuttle instead of renting a car","context":"After the delay, the group agreed the shuttle was the fastest remaining option."}` 44 + - `decisions`: array of objects with required string fields `owner`, `action`, `context`, plus nullable `counterparty`; emit `null` when there is no counterparty. 45 + Example: `{"owner":"Travel group","action":"take the shuttle instead of renting a car","counterparty":null,"context":"After the delay, the group agreed the shuttle was the fastest remaining option."}` 46 + - `relations`: array of objects with required fields `from`, `to`, `kind`, `note`, `quote`. Use entity NAMES, not ids. `kind` must be one of `works-with`, `works-at`, `reports-to`, `family-of`, `knows`, `uses`, `created`, `other`. 47 + Example: `{"from":"Jordan","to":"Taylor","kind":"family-of","note":"","quote":"Jordan checked in with Taylor before the shuttle left."}` 48 + Use `[]` unless a relationship is actually evidenced in the content. `note` is required; use `""` when the kind speaks for itself, but explain the relationship when `kind` is `"other"`. 45 49 46 - Return `[]` if you do not observe a clear commitment / closure / decision. Better to omit than invent. 50 + Return `[]` if you do not observe a clear commitment / closure / decision / relation. Better to omit than invent. 47 51 48 52 Body requirements: 49 53 - Write one tight paragraph in chronological order. ··· 51 55 - Prefer what actually occurred over generic labels from the activity type. 52 56 - If evidence is thin, keep the narrative modest and confidence honest. 53 57 54 - Output a single JSON object with all six required fields: `body`, `topics`, `confidence`, `commitments`, `closures`, and `decisions`. 58 + Output a single JSON object with all seven required fields: `body`, `topics`, `confidence`, `commitments`, `closures`, `decisions`, and `relations`.
+72
solstone/talent/story.py
··· 17 17 logger = logging.getLogger(__name__) 18 18 19 19 ALLOWED_RESOLUTIONS = frozenset({"sent", "done", "signed", "dropped", "deferred"}) 20 + ALLOWED_RELATION_KINDS = frozenset( 21 + { 22 + "works-with", 23 + "works-at", 24 + "reports-to", 25 + "family-of", 26 + "knows", 27 + "uses", 28 + "created", 29 + "other", 30 + } 31 + ) 20 32 21 33 22 34 def _normalize_topics(value: Any) -> list[str] | None: ··· 96 108 commitments = data.get("commitments") 97 109 closures = data.get("closures") 98 110 decisions = data.get("decisions") 111 + relations = data.get("relations") 99 112 100 113 if not isinstance(body, str) or not body.strip(): 101 114 logger.warning("story hook: missing body") ··· 115 128 if not isinstance(decisions, list): 116 129 logger.warning("story hook: missing decisions list") 117 130 return "" 131 + if not isinstance(relations, list): 132 + logger.warning("story hook: missing relations list") 133 + return "" 118 134 119 135 activity = context.get("activity") 120 136 if not isinstance(activity, dict): ··· 201 217 index, 202 218 ) 203 219 continue 220 + counterparty = entry.get("counterparty") 221 + if counterparty is not None and not isinstance(counterparty, str): 222 + logger.warning( 223 + "story hook: skipping decision[%d]: invalid counterparty field", 224 + index, 225 + ) 226 + continue 204 227 resolved_decision = dict(normalized) 228 + resolved_decision["counterparty"] = counterparty 205 229 resolved_decision["owner_entity_id"] = _resolve_entity_id( 206 230 normalized["owner"], entities 207 231 ) 232 + resolved_decision["counterparty_entity_id"] = ( 233 + _resolve_entity_id(counterparty, entities) 234 + if isinstance(counterparty, str) and counterparty.strip() 235 + else None 236 + ) 208 237 resolved_decisions.append(resolved_decision) 209 238 239 + resolved_relations: list[dict[str, Any]] = [] 240 + for index, entry in enumerate(relations): 241 + if not isinstance(entry, dict): 242 + logger.warning("story hook: skipping relation[%d]: expected object", index) 243 + continue 244 + normalized = _validate_fields(entry, ("from", "to", "kind", "note")) 245 + if normalized is None: 246 + logger.warning( 247 + "story hook: skipping relation[%d]: missing required string field", 248 + index, 249 + ) 250 + continue 251 + if normalized["kind"] not in ALLOWED_RELATION_KINDS: 252 + logger.warning( 253 + "story hook: skipping relation[%d]: invalid kind '%s'", 254 + index, 255 + normalized["kind"], 256 + ) 257 + continue 258 + if normalized["kind"] == "other" and not normalized["note"].strip(): 259 + logger.warning( 260 + "story hook: skipping relation[%d]: other kind requires note", 261 + index, 262 + ) 263 + continue 264 + quote = entry.get("quote") 265 + if quote is not None and not isinstance(quote, str): 266 + logger.warning( 267 + "story hook: skipping relation[%d]: invalid quote field", 268 + index, 269 + ) 270 + continue 271 + resolved_relation = dict(normalized) 272 + resolved_relation["quote"] = quote 273 + resolved_relation["from_entity_id"] = _resolve_entity_id( 274 + normalized["from"], entities 275 + ) 276 + resolved_relation["to_entity_id"] = _resolve_entity_id( 277 + normalized["to"], entities 278 + ) 279 + resolved_relations.append(resolved_relation) 280 + 210 281 talent_name = context.get("name") or "" 211 282 if not talent_name: 212 283 logger.warning("story hook: missing talent name in context") ··· 226 297 commitments=resolved_commitments, 227 298 closures=resolved_closures, 228 299 decisions=resolved_decisions, 300 + relations=resolved_relations, 229 301 actor="story", 230 302 note=None, 231 303 )
+91 -15
solstone/talent/story.schema.json
··· 7 7 "confidence", 8 8 "commitments", 9 9 "closures", 10 - "decisions" 10 + "decisions", 11 + "relations" 11 12 ], 12 13 "properties": { 13 14 "body": { 14 - "type": "string" 15 + "type": "string", 16 + "maxLength": 4000 15 17 }, 16 18 "topics": { 17 19 "type": "array", 20 + "maxItems": 10, 18 21 "items": { 19 - "type": "string" 22 + "type": "string", 23 + "maxLength": 48 20 24 } 21 25 }, 22 26 "confidence": { ··· 24 28 }, 25 29 "commitments": { 26 30 "type": "array", 31 + "maxItems": 6, 27 32 "items": { 28 33 "type": "object", 29 34 "additionalProperties": false, ··· 36 41 ], 37 42 "properties": { 38 43 "owner": { 39 - "type": "string" 44 + "type": "string", 45 + "maxLength": 200 40 46 }, 41 47 "action": { 42 - "type": "string" 48 + "type": "string", 49 + "maxLength": 200 43 50 }, 44 51 "counterparty": { 45 - "type": "string" 52 + "type": "string", 53 + "maxLength": 200 46 54 }, 47 55 "when": { 48 - "type": "string" 56 + "type": "string", 57 + "maxLength": 200 49 58 }, 50 59 "context": { 51 - "type": "string" 60 + "type": "string", 61 + "maxLength": 400 52 62 } 53 63 } 54 64 } 55 65 }, 56 66 "closures": { 57 67 "type": "array", 68 + "maxItems": 6, 58 69 "items": { 59 70 "type": "object", 60 71 "additionalProperties": false, ··· 67 78 ], 68 79 "properties": { 69 80 "owner": { 70 - "type": "string" 81 + "type": "string", 82 + "maxLength": 200 71 83 }, 72 84 "action": { 73 - "type": "string" 85 + "type": "string", 86 + "maxLength": 200 74 87 }, 75 88 "counterparty": { 76 - "type": "string" 89 + "type": "string", 90 + "maxLength": 200 77 91 }, 78 92 "resolution": { 79 93 "type": "string", ··· 86 100 ] 87 101 }, 88 102 "context": { 89 - "type": "string" 103 + "type": "string", 104 + "maxLength": 400 90 105 } 91 106 } 92 107 } 93 108 }, 94 109 "decisions": { 95 110 "type": "array", 111 + "maxItems": 6, 96 112 "items": { 97 113 "type": "object", 98 114 "additionalProperties": false, 99 115 "required": [ 100 116 "owner", 101 117 "action", 118 + "counterparty", 102 119 "context" 103 120 ], 104 121 "properties": { 105 122 "owner": { 106 - "type": "string" 123 + "type": "string", 124 + "maxLength": 200 107 125 }, 108 126 "action": { 109 - "type": "string" 127 + "type": "string", 128 + "maxLength": 200 129 + }, 130 + "counterparty": { 131 + "type": [ 132 + "string", 133 + "null" 134 + ], 135 + "maxLength": 200 110 136 }, 111 137 "context": { 112 - "type": "string" 138 + "type": "string", 139 + "maxLength": 400 140 + } 141 + } 142 + } 143 + }, 144 + "relations": { 145 + "type": "array", 146 + "maxItems": 6, 147 + "items": { 148 + "type": "object", 149 + "additionalProperties": false, 150 + "required": [ 151 + "from", 152 + "to", 153 + "kind", 154 + "note", 155 + "quote" 156 + ], 157 + "properties": { 158 + "from": { 159 + "type": "string", 160 + "maxLength": 120 161 + }, 162 + "to": { 163 + "type": "string", 164 + "maxLength": 120 165 + }, 166 + "kind": { 167 + "type": "string", 168 + "enum": [ 169 + "works-with", 170 + "works-at", 171 + "reports-to", 172 + "family-of", 173 + "knows", 174 + "uses", 175 + "created", 176 + "other" 177 + ] 178 + }, 179 + "note": { 180 + "type": "string", 181 + "maxLength": 300 182 + }, 183 + "quote": { 184 + "type": [ 185 + "string", 186 + "null" 187 + ], 188 + "maxLength": 400 113 189 } 114 190 } 115 191 }
+9 -5
solstone/talent/work.md
··· 7 7 "activities": ["coding", "browsing", "reading"], 8 8 "priority": 20, 9 9 "output": "json", 10 + "max_output_tokens": 12288, 10 11 "schema": "story.schema.json", 11 12 "hook": {"post": "story"}, 12 13 "degradation_check": true, ··· 31 32 the activity. Participation and entity extraction already happened upstream. 32 33 Use that context; do not re-extract people or entities into new structures. 33 34 34 - Return exactly this six-field JSON object: 35 + Return exactly this seven-field JSON object: 35 36 - `body`: string narrative prose about the work performed and what changed. 36 37 - `topics`: array of short string tags; use `[]` when there are no durable topics worth preserving. 37 38 - `confidence`: float from 0.0 to 1.0. ··· 39 40 Example: `{"owner":"Avery","action":"post the benchmark results","counterparty":"Priya","when":"after lunch","context":"Avery said the new retry benchmark would be shared once the run completed."}` 40 41 - `closures`: array of objects with required string fields `owner`, `action`, `counterparty`, `resolution`, `context`. `resolution` must be one of `sent`, `done`, `signed`, `dropped`, `deferred`. 41 42 Example: `{"owner":"Avery","action":"follow-up PR","counterparty":"Priya","resolution":"done","context":"Avery noted the cleanup PR was merged during this work block."}` 42 - - `decisions`: array of objects with required string fields `owner`, `action`, `context`. 43 - Example: `{"owner":"Avery","action":"switch the retry path to queue-backed backoff","context":"The work session concluded that queue-backed backoff was simpler than the timer-based branch."}` 43 + - `decisions`: array of objects with required string fields `owner`, `action`, `context`, plus nullable `counterparty`; emit `null` when there is no counterparty. 44 + Example: `{"owner":"Avery","action":"switch the retry path to queue-backed backoff","counterparty":null,"context":"The work session concluded that queue-backed backoff was simpler than the timer-based branch."}` 45 + - `relations`: array of objects with required fields `from`, `to`, `kind`, `note`, `quote`. Use entity NAMES, not ids. `kind` must be one of `works-with`, `works-at`, `reports-to`, `family-of`, `knows`, `uses`, `created`, `other`. 46 + Example: `{"from":"Avery","to":"Queue Worker","kind":"created","note":"","quote":"Avery finished wiring the queue worker into the retry path."}` 47 + Use `[]` unless a relationship is actually evidenced in the content. `note` is required; use `""` when the kind speaks for itself, but explain the relationship when `kind` is `"other"`. 44 48 45 - Return `[]` if you do not observe a clear commitment / closure / decision. Better to omit than invent. 49 + Return `[]` if you do not observe a clear commitment / closure / decision / relation. Better to omit than invent. 46 50 47 51 Body requirements: 48 52 - Write one tight paragraph in chronological order. ··· 51 55 - If evidence is partial, describe the most defensible story and keep the 52 56 confidence honest. 53 57 54 - Output a single JSON object with all six required fields: `body`, `topics`, `confidence`, `commitments`, `closures`, and `decisions`. 58 + Output a single JSON object with all seven required fields: `body`, `topics`, `confidence`, `commitments`, `closures`, `decisions`, and `relations`.
+9 -1
solstone/think/activities.py
··· 1216 1216 commitments: list[dict], 1217 1217 closures: list[dict], 1218 1218 decisions: list[dict], 1219 + relations: list[dict], 1219 1220 actor: str, 1220 1221 note: str | None = None, 1221 1222 ) -> bool: ··· 1233 1234 merged["commitments"] = [dict(entry) for entry in commitments] 1234 1235 merged["closures"] = [dict(entry) for entry in closures] 1235 1236 merged["decisions"] = [dict(entry) for entry in decisions] 1237 + merged["relations"] = [dict(entry) for entry in relations] 1236 1238 merged = append_edit( 1237 1239 merged, 1238 1240 actor=actor, 1239 - fields=["story", "commitments", "closures", "decisions"], 1241 + fields=[ 1242 + "story", 1243 + "commitments", 1244 + "closures", 1245 + "decisions", 1246 + "relations", 1247 + ], 1240 1248 note=note, 1241 1249 ) 1242 1250 new_records.append(merged)
+8 -3
solstone/think/entities/observations.py
··· 257 257 return target_quote.strip().casefold() in content.casefold() 258 258 259 259 260 - def _new_observation(content: str, source_day: str | None) -> dict[str, Any]: 260 + def _new_observation( 261 + content: str, source_day: str | None, relation: dict[str, Any] | None = None 262 + ) -> dict[str, Any]: 261 263 observation: dict[str, Any] = { 262 264 "content": content, 263 265 "observed_at": now_ms(), 264 266 } 265 267 if source_day is not None: 266 268 observation["source_day"] = source_day 269 + if relation is not None: 270 + observation["relation"] = dict(relation) 267 271 return observation 268 272 269 273 ··· 284 288 continue 285 289 286 290 action = op.get("op") 291 + relation = op.get("relation") 287 292 if action == "add": 288 293 content = op.get("content") 289 294 if not isinstance(content, str) or not content.strip(): 290 295 counts["skipped"] += 1 291 296 continue 292 - additions.append(_new_observation(content.strip(), source_day)) 297 + additions.append(_new_observation(content.strip(), source_day, relation)) 293 298 counts["add"] += 1 294 299 changed = True 295 300 continue ··· 322 327 if not isinstance(content, str) or not content.strip(): 323 328 counts["skipped"] += 1 324 329 continue 325 - updates[target_index] = _new_observation(content.strip(), source_day) 330 + updates[target_index] = _new_observation(content.strip(), source_day, relation) 326 331 drops.discard(target_index) 327 332 counts["update"] += 1 328 333 changed = True
+3
tests/baselines/api/stats/stats.json
··· 35 35 "talents": false, 36 36 "transcripts": true 37 37 }, 38 + "max_output_tokens": 12288, 38 39 "mtime": 0, 39 40 "output": "json", 40 41 "path": "<PROJECT>/solstone/talent/conversation.md", ··· 209 210 "talents": false, 210 211 "transcripts": true 211 212 }, 213 + "max_output_tokens": 12288, 212 214 "mtime": 0, 213 215 "output": "json", 214 216 "path": "<PROJECT>/solstone/talent/event.md", ··· 462 464 "talents": false, 463 465 "transcripts": true 464 466 }, 467 + "max_output_tokens": 12288, 465 468 "mtime": 0, 466 469 "output": "json", 467 470 "path": "<PROJECT>/solstone/talent/work.md",
+154 -1
tests/test_entity_observer_context.py
··· 62 62 return Path("facets") / facet / "entities" / entity_id / "observations.jsonl" 63 63 64 64 65 - COUNT_KEYS = ("update", "add", "drop", "keep", "skipped") 65 + COUNT_KEYS = ("update", "add", "drop", "keep", "skipped", "relation_unresolved") 66 66 67 67 68 68 def _outcome_path(root: Path, facet: str, day: str) -> Path: ··· 563 563 "drop": 1, 564 564 "keep": 1, 565 565 "skipped": 0, 566 + "relation_unresolved": 0, 566 567 } 567 568 assert outcome["error"] is None 568 569 assert _count_sum(outcome) == 4 ··· 613 614 "drop": 0, 614 615 "keep": 0, 615 616 "skipped": 3, 617 + "relation_unresolved": 0, 616 618 } 617 619 assert outcome["error"] is None 618 620 assert _count_sum(outcome) == 3 ··· 633 635 "drop": 0, 634 636 "keep": 0, 635 637 "skipped": 0, 638 + "relation_unresolved": 0, 636 639 } 637 640 assert outcome["error"] is None 638 641 ··· 710 713 "drop": 0, 711 714 "keep": 0, 712 715 "skipped": 8, 716 + "relation_unresolved": 0, 713 717 } 714 718 assert outcome["error"] is None 715 719 assert _count_sum(outcome) == 9 ··· 774 778 "drop": 0, 775 779 "keep": 1, 776 780 "skipped": 1, 781 + "relation_unresolved": 0, 777 782 } 778 783 assert outcome["error"] is None 779 784 assert _count_sum(outcome) == 3 ··· 826 831 "drop": 0, 827 832 "keep": 0, 828 833 "skipped": 2, 834 + "relation_unresolved": 0, 829 835 } 830 836 assert outcome["error"] == "OSError: disk busy" 831 837 assert _count_sum(outcome) == 2 ··· 871 877 "drop": 0, 872 878 "keep": 0, 873 879 "skipped": 1, 880 + "relation_unresolved": 0, 874 881 } 875 882 assert _count_sum(outcome) == 1 883 + 884 + 885 + def test_post_process_persists_resolved_relation_on_observation(tmp_path, monkeypatch): 886 + _set_journal(monkeypatch, str(tmp_path)) 887 + facet = "work" 888 + day = "20260304" 889 + _attach_entity(tmp_path, facet, "alice_johnson", "Alice Johnson") 890 + _attach_entity(tmp_path, facet, "bob_lee", "Bob Lee") 891 + 892 + post_process( 893 + json.dumps( 894 + { 895 + "entities": [ 896 + { 897 + "entity_id": "alice_johnson", 898 + "operations": [ 899 + { 900 + "op": "add", 901 + "content": "Pairs with Bob Lee on the platform team", 902 + "reasoning": "Durable working relationship.", 903 + "relation": { 904 + "kind": "works-with", 905 + "target_name": "Bob Lee", 906 + "note": "", 907 + }, 908 + } 909 + ], 910 + } 911 + ], 912 + "summary": "one relational add", 913 + } 914 + ), 915 + {"facet": facet, "day": day}, 916 + ) 917 + 918 + observations = load_observations(facet, "alice_johnson") 919 + assert len(observations) == 1 920 + assert observations[0]["relation"] == { 921 + "kind": "works-with", 922 + "target_entity_id": "bob_lee", 923 + "target_name": "Bob Lee", 924 + "note": "", 925 + } 926 + outcome = _load_outcome(tmp_path, facet, day) 927 + assert {key: outcome[key] for key in COUNT_KEYS} == { 928 + "update": 0, 929 + "add": 1, 930 + "drop": 0, 931 + "keep": 0, 932 + "skipped": 0, 933 + "relation_unresolved": 0, 934 + } 935 + 936 + 937 + def test_post_process_drops_op_with_unresolvable_relation_target( 938 + tmp_path, monkeypatch, caplog 939 + ): 940 + _set_journal(monkeypatch, str(tmp_path)) 941 + facet = "work" 942 + day = "20260304" 943 + _attach_entity(tmp_path, facet, "alice_johnson", "Alice Johnson") 944 + 945 + post_process( 946 + json.dumps( 947 + { 948 + "entities": [ 949 + { 950 + "entity_id": "alice_johnson", 951 + "operations": [ 952 + { 953 + "op": "add", 954 + "content": "Reports to someone we cannot identify", 955 + "reasoning": "Relational, but the target is unknown.", 956 + "relation": { 957 + "kind": "reports-to", 958 + "target_name": "Nobody Visible", 959 + "note": "", 960 + }, 961 + } 962 + ], 963 + } 964 + ], 965 + "summary": "unresolvable relation target", 966 + } 967 + ), 968 + {"facet": facet, "day": day}, 969 + ) 970 + 971 + assert load_observations(facet, "alice_johnson") == [] 972 + assert "unresolved relation target" in caplog.text 973 + outcome = _load_outcome(tmp_path, facet, day) 974 + assert {key: outcome[key] for key in COUNT_KEYS} == { 975 + "update": 0, 976 + "add": 0, 977 + "drop": 0, 978 + "keep": 0, 979 + "skipped": 0, 980 + "relation_unresolved": 1, 981 + } 982 + 983 + 984 + def test_post_process_drops_op_with_other_relation_kind_and_no_note( 985 + tmp_path, monkeypatch 986 + ): 987 + _set_journal(monkeypatch, str(tmp_path)) 988 + facet = "work" 989 + day = "20260304" 990 + _attach_entity(tmp_path, facet, "alice_johnson", "Alice Johnson") 991 + _attach_entity(tmp_path, facet, "bob_lee", "Bob Lee") 992 + 993 + post_process( 994 + json.dumps( 995 + { 996 + "entities": [ 997 + { 998 + "entity_id": "alice_johnson", 999 + "operations": [ 1000 + { 1001 + "op": "add", 1002 + "content": "Has an unusual tie to Bob Lee", 1003 + "reasoning": "Relational, but the note is blank.", 1004 + "relation": { 1005 + "kind": "other", 1006 + "target_name": "Bob Lee", 1007 + "note": " ", 1008 + }, 1009 + } 1010 + ], 1011 + } 1012 + ], 1013 + "summary": "other kind without a note", 1014 + } 1015 + ), 1016 + {"facet": facet, "day": day}, 1017 + ) 1018 + 1019 + assert load_observations(facet, "alice_johnson") == [] 1020 + outcome = _load_outcome(tmp_path, facet, day) 1021 + assert {key: outcome[key] for key in COUNT_KEYS} == { 1022 + "update": 0, 1023 + "add": 0, 1024 + "drop": 0, 1025 + "keep": 0, 1026 + "skipped": 1, 1027 + "relation_unresolved": 0, 1028 + } 876 1029 877 1030 878 1031 # ============================================================================
+65 -1
tests/test_entity_observer_schema.py
··· 8 8 9 9 from jsonschema import Draft202012Validator 10 10 11 + from solstone.talent.story import ALLOWED_RELATION_KINDS 12 + from solstone.think.schema_bounds import unbounded_nodes 11 13 from solstone.think.talent import get_talent 12 14 13 15 SCHEMA_PATH = ( ··· 51 53 "content": "Prefers concise morning planning meetings", 52 54 "target_quote": "morning meetings", 53 55 "reasoning": "Fresh source narrows the preference.", 56 + "relation": None, 54 57 }, 55 58 { 56 59 "op": "add", ··· 58 61 "content": "Has deep knowledge of distributed systems", 59 62 "target_quote": None, 60 63 "reasoning": "Durable expertise.", 64 + "relation": { 65 + "kind": "works-with", 66 + "target_name": "Bob Lee", 67 + "note": "", 68 + }, 61 69 }, 62 70 { 63 71 "op": "drop", ··· 65 73 "content": None, 66 74 "target_quote": "legacy planning", 67 75 "reasoning": "Stale duplicate.", 76 + "relation": None, 68 77 }, 69 78 { 70 79 "op": "keep", ··· 72 81 "content": None, 73 82 "target_quote": None, 74 83 "reasoning": "Still useful.", 84 + "relation": None, 75 85 }, 76 86 ], 77 87 } ··· 107 117 "entities": [ 108 118 { 109 119 "entity_id": "alice_johnson", 110 - "operations": [{"op": "keep", "reasoning": "audit", "extra": True}], 120 + "operations": [ 121 + { 122 + "op": "keep", 123 + "target_index": 0, 124 + "content": None, 125 + "target_quote": None, 126 + "reasoning": "audit", 127 + "relation": None, 128 + "extra": True, 129 + } 130 + ], 111 131 } 112 132 ], 113 133 "summary": "extra operation", ··· 130 150 "content": None, 131 151 "target_quote": None, 132 152 "reasoning": "unknown op", 153 + "relation": None, 133 154 } 134 155 ], 135 156 } ··· 139 160 ) 140 161 141 162 163 + def test_relation_kind_enum_matches_python(): 164 + schema = _load_schema() 165 + relation = schema["properties"]["entities"]["items"]["properties"]["operations"][ 166 + "items" 167 + ]["properties"]["relation"] 168 + 169 + assert set(relation["properties"]["kind"]["enum"]) == ALLOWED_RELATION_KINDS 170 + 171 + 172 + def test_invalid_unknown_relation_kind(): 173 + validator = Draft202012Validator(_load_schema()) 174 + 175 + assert not validator.is_valid( 176 + { 177 + "entities": [ 178 + { 179 + "entity_id": "alice_johnson", 180 + "operations": [ 181 + { 182 + "op": "add", 183 + "target_index": None, 184 + "content": "Has a durable relation.", 185 + "target_quote": None, 186 + "reasoning": "relation audit", 187 + "relation": { 188 + "kind": "mentors", 189 + "target_name": "Bob Lee", 190 + "note": "Alice mentors Bob.", 191 + }, 192 + } 193 + ], 194 + } 195 + ], 196 + "summary": "bad relation", 197 + } 198 + ) 199 + 200 + 142 201 def test_schema_has_no_conditional_keywords(): 143 202 schema = _schema_text() 144 203 145 204 assert '"if"' not in schema 146 205 assert '"then"' not in schema 147 206 assert '"oneOf"' not in schema 207 + assert '"anyOf"' not in schema 208 + 209 + 210 + def test_schema_has_no_unbounded_nodes(): 211 + assert unbounded_nodes(_load_schema()) == []
+87 -7
tests/test_schema_prep.py
··· 14 14 15 15 from solstone.apps.timeline.rollup import build_rollup_schema 16 16 from solstone.think.models import SchemaValidationError, generate 17 - from solstone.think.schema_prep import prepare_provider_schema 17 + from solstone.think.schema_prep import prepare_provider_schema, unsupported_keyword_hits 18 18 19 19 REPO_ROOT = Path(__file__).resolve().parents[1] 20 20 ··· 40 40 } 41 41 42 42 43 - def _discover_schemas() -> tuple[dict[str, Any], ...]: 44 - discovered: list[dict[str, Any]] = [] 43 + def _discover_schemas() -> tuple[Any, ...]: 44 + discovered: list[Any] = [] 45 45 for path in sorted((REPO_ROOT / "solstone").glob("**/*.schema.json")): 46 46 schema = json.loads(path.read_text(encoding="utf-8")) 47 47 if isinstance(schema.get("x-journal-contract"), dict): 48 48 continue 49 - discovered.append(schema) 50 - discovered.append(build_rollup_schema(3)) 49 + discovered.append( 50 + pytest.param(schema, id=path.relative_to(REPO_ROOT).as_posix()) 51 + ) 52 + discovered.append(pytest.param(build_rollup_schema(3), id="build_rollup_schema(3)")) 51 53 return tuple(discovered) 52 54 53 55 ··· 108 110 109 111 @pytest.mark.parametrize("provider", ["local", "openai", "google", "anthropic"]) 110 112 @pytest.mark.parametrize("schema", _discover_schemas()) 111 - def test_current_shipped_schemas_are_byte_identical_after_prep( 113 + def test_shipped_schemas_prep_to_a_provider_supported_subset( 112 114 schema: dict[str, Any], provider: str 113 115 ) -> None: 114 - assert prepare_provider_schema(schema, provider) == schema 116 + """Prep strips exactly the provider's unsupported keywords, and nothing else. 117 + 118 + Asserting byte-identity here would pin the suite to "no shipped schema is 119 + bounded", which the schema-bounds ratchet is designed to falsify. 120 + """ 121 + original = copy.deepcopy(schema) 122 + prepared = prepare_provider_schema(schema, provider) 123 + 124 + assert schema == original 125 + assert unsupported_keyword_hits(prepared, provider) == [] 126 + if unsupported_keyword_hits(schema, provider): 127 + assert prepared != schema 128 + else: 129 + assert prepared == schema 130 + 131 + 132 + # Schemas carrying generation bounds. The schema-bounds ratchet 133 + # (scripts/check_schema_bounds.py) grows this set; add entries as schemas 134 + # graduate off its allowlist. 135 + BOUNDED_SCHEMAS = ( 136 + "solstone/talent/story.schema.json", 137 + "solstone/apps/entities/talent/entity_observer.schema.json", 138 + ) 139 + 140 + 141 + def _load_shipped_schema(relative_path: str) -> dict[str, Any]: 142 + return json.loads((REPO_ROOT / relative_path).read_text(encoding="utf-8")) 143 + 144 + 145 + def _keyword_paths(node: Any, keyword: str, path: str = "$") -> list[str]: 146 + found: list[str] = [] 147 + if isinstance(node, dict): 148 + for key, value in node.items(): 149 + if key == keyword: 150 + found.append(f"{path}/{key}") 151 + found.extend(_keyword_paths(value, keyword, f"{path}/{key}")) 152 + elif isinstance(node, list): 153 + for index, value in enumerate(node): 154 + found.extend(_keyword_paths(value, keyword, f"{path}[{index}]")) 155 + return found 156 + 157 + 158 + @pytest.mark.parametrize("relative_path", BOUNDED_SCHEMAS) 159 + def test_bounded_schemas_really_carry_bounds(relative_path: str) -> None: 160 + """Guards the three tests below from passing vacuously.""" 161 + schema = _load_shipped_schema(relative_path) 162 + 163 + assert _keyword_paths(schema, "maxLength") 164 + assert _keyword_paths(schema, "maxItems") 165 + 166 + 167 + @pytest.mark.parametrize("relative_path", BOUNDED_SCHEMAS) 168 + def test_bounded_schemas_reach_local_provider_with_bounds_intact( 169 + relative_path: str, 170 + ) -> None: 171 + schema = _load_shipped_schema(relative_path) 172 + 173 + assert prepare_provider_schema(schema, "local") == schema 174 + 175 + 176 + @pytest.mark.parametrize("provider", ["openai", "google"]) 177 + @pytest.mark.parametrize("relative_path", BOUNDED_SCHEMAS) 178 + def test_bounded_schemas_lose_only_maxlength_for_openai_and_google( 179 + relative_path: str, provider: str 180 + ) -> None: 181 + prepared = prepare_provider_schema(_load_shipped_schema(relative_path), provider) 182 + 183 + assert _keyword_paths(prepared, "maxLength") == [] 184 + assert _keyword_paths(prepared, "maxItems") 185 + 186 + 187 + @pytest.mark.parametrize("relative_path", BOUNDED_SCHEMAS) 188 + def test_bounded_schemas_lose_every_size_bound_for_anthropic( 189 + relative_path: str, 190 + ) -> None: 191 + prepared = prepare_provider_schema(_load_shipped_schema(relative_path), "anthropic") 192 + 193 + assert _keyword_paths(prepared, "maxLength") == [] 194 + assert _keyword_paths(prepared, "maxItems") == [] 115 195 116 196 117 197 def _patched_generate(
+129 -2
tests/test_story_hook.py
··· 72 72 { 73 73 "owner": "Team", 74 74 "action": "move the launch review to Tuesday", 75 + "counterparty": None, 75 76 "context": "The group aligned on Tuesday after checking calendars.", 76 77 } 77 78 ], 79 + "relations": [], 78 80 } 79 81 payload.update(overrides) 80 82 return json.dumps(payload) ··· 110 112 assert record["commitments"][0]["owner"] == "Mina" 111 113 assert record["closures"][0]["resolution"] == "sent" 112 114 assert record["decisions"][0]["owner"] == "Team" 115 + assert record["relations"] == [] 113 116 assert record["edits"][-1]["actor"] == "story" 114 117 assert record["edits"][-1]["fields"] == [ 115 118 "story", 116 119 "commitments", 117 120 "closures", 118 121 "decisions", 122 + "relations", 119 123 ] 120 124 121 125 ··· 138 142 assert record["commitments"] == [] 139 143 assert record["closures"] == [] 140 144 assert record["decisions"] == [] 145 + assert record["relations"] == [] 141 146 142 147 143 148 def test_story_hook_bad_resolution_skipped(tmp_path, monkeypatch, caplog): ··· 217 222 { 218 223 "owner": "Team", 219 224 "action": "move the launch review to Tuesday", 225 + "counterparty": None, 220 226 "context": "Valid decision.", 221 227 }, 222 228 { ··· 282 288 { 283 289 "owner": "Mina Lee", 284 290 "action": "move the launch review to Tuesday", 291 + "counterparty": "Ravi", 285 292 "context": "Valid decision.", 286 - } 293 + }, 294 + { 295 + "owner": "Team", 296 + "action": "keep the review owner unchanged", 297 + "counterparty": None, 298 + "context": "Decision without a counterparty.", 299 + }, 300 + ], 301 + relations=[ 302 + { 303 + "from": "Mina", 304 + "to": "Ravi", 305 + "kind": "works-with", 306 + "note": "", 307 + "quote": "Mina and Ravi will handle the deck.", 308 + }, 309 + { 310 + "from": "Mina", 311 + "to": "Nobody Visible", 312 + "kind": "knows", 313 + "note": "Mina mentioned knowing someone outside the tracked entities.", 314 + "quote": None, 315 + }, 287 316 ], 288 317 ), 289 318 _context(tmp_path), ··· 297 326 assert record["closures"][0]["owner_entity_id"] == "ravi_shah" 298 327 assert record["closures"][0]["counterparty_entity_id"] == "mina_lee" 299 328 assert record["decisions"][0]["owner_entity_id"] == "mina_lee" 329 + assert record["decisions"][0]["counterparty"] == "Ravi" 330 + assert record["decisions"][0]["counterparty_entity_id"] == "ravi_shah" 331 + assert record["decisions"][1]["counterparty"] is None 332 + assert record["decisions"][1]["counterparty_entity_id"] is None 333 + assert record["relations"][0]["from_entity_id"] == "mina_lee" 334 + assert record["relations"][0]["to_entity_id"] == "ravi_shah" 335 + assert record["relations"][1]["to"] == "Nobody Visible" 336 + assert record["relations"][1]["to_entity_id"] is None 337 + assert record["relations"][1]["note"] == ( 338 + "Mina mentioned knowing someone outside the tracked entities." 339 + ) 340 + assert record["relations"][1]["quote"] is None 300 341 assert record["commitments"][0]["owner"] == "Mina" 301 342 assert record["closures"][0]["counterparty"] == "Mina" 302 343 303 344 345 + def test_story_hook_skips_invalid_relations(tmp_path, monkeypatch, caplog): 346 + from solstone.talent.story import post_process 347 + from solstone.think.activities import append_activity_record 348 + 349 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 350 + append_activity_record("work", "20260418", _activity_record()) 351 + 352 + post_process( 353 + _valid_result( 354 + relations=[ 355 + { 356 + "from": "Mina", 357 + "to": "Ravi", 358 + "kind": "works-with", 359 + "note": "", 360 + "quote": "They are paired on the deck.", 361 + }, 362 + { 363 + "from": "Mina", 364 + "to": "Ravi", 365 + "kind": "other", 366 + "note": " ", 367 + "quote": "They have a custom relationship.", 368 + }, 369 + { 370 + "from": "Mina", 371 + "to": "Ravi", 372 + "kind": "mentors", 373 + "note": "Mina mentors Ravi.", 374 + "quote": "Mina mentors Ravi.", 375 + }, 376 + ] 377 + ), 378 + _context(tmp_path), 379 + ) 380 + 381 + record = _load_record("work", "20260418") 382 + assert [relation["kind"] for relation in record["relations"]] == ["works-with"] 383 + assert "other kind requires note" in caplog.text 384 + assert "invalid kind 'mentors'" in caplog.text 385 + 386 + 304 387 def test_story_hook_idempotent_rerun(tmp_path, monkeypatch): 305 388 from solstone.talent.story import post_process 306 389 from solstone.think.activities import append_activity_record ··· 308 391 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 309 392 append_activity_record("work", "20260418", _activity_record()) 310 393 311 - post_process(_valid_result(), _context(tmp_path)) 394 + post_process( 395 + _valid_result( 396 + relations=[ 397 + { 398 + "from": "Mina", 399 + "to": "Ravi", 400 + "kind": "works-with", 401 + "note": "", 402 + "quote": "Mina and Ravi owned the first pass.", 403 + } 404 + ] 405 + ), 406 + _context(tmp_path), 407 + ) 312 408 first = _load_record("work", "20260418") 313 409 assert len(first["edits"]) == 1 410 + assert first["relations"][0]["kind"] == "works-with" 314 411 315 412 post_process( 316 413 _valid_result( ··· 322 419 { 323 420 "owner": "Lead", 324 421 "action": "ship the patch on Wednesday", 422 + "counterparty": None, 325 423 "context": "The second pass reached a more specific plan.", 326 424 } 327 425 ], 426 + relations=[ 427 + { 428 + "from": "Lead", 429 + "to": "Patch", 430 + "kind": "created", 431 + "note": "", 432 + "quote": None, 433 + } 434 + ], 328 435 ), 329 436 _context(tmp_path), 330 437 ) ··· 342 449 { 343 450 "owner": "Lead", 344 451 "action": "ship the patch on Wednesday", 452 + "counterparty": None, 345 453 "context": "The second pass reached a more specific plan.", 346 454 "owner_entity_id": None, 455 + "counterparty_entity_id": None, 456 + } 457 + ] 458 + assert second["relations"] == [ 459 + { 460 + "from": "Lead", 461 + "to": "Patch", 462 + "kind": "created", 463 + "note": "", 464 + "quote": None, 465 + "from_entity_id": None, 466 + "to_entity_id": None, 347 467 } 348 468 ] 349 469 assert len(second["edits"]) == 2 470 + assert second["edits"][-1]["fields"] == [ 471 + "story", 472 + "commitments", 473 + "closures", 474 + "decisions", 475 + "relations", 476 + ] 350 477 351 478 352 479 def test_story_hook_normalizes_topics(tmp_path, monkeypatch):
+38 -1
tests/test_story_schema.py
··· 6 6 7 7 from jsonschema import Draft202012Validator 8 8 9 - from solstone.talent.story import ALLOWED_RESOLUTIONS 9 + from solstone.talent.story import ALLOWED_RELATION_KINDS, ALLOWED_RESOLUTIONS 10 + from solstone.think.schema_bounds import unbounded_nodes 10 11 from solstone.think.talent import get_talent 11 12 from tests.test_story_hook import _valid_result 12 13 ··· 41 42 "commitments", 42 43 "closures", 43 44 "decisions", 45 + "relations", 44 46 } 45 47 assert set(properties["commitments"]["items"]["required"]) == { 46 48 "owner", ··· 63 65 assert set(properties["decisions"]["items"]["required"]) == { 64 66 "owner", 65 67 "action", 68 + "counterparty", 66 69 "context", 67 70 } 71 + assert set(properties["relations"]["items"]["required"]) == { 72 + "from", 73 + "to", 74 + "kind", 75 + "note", 76 + "quote", 77 + } 78 + assert ( 79 + set(properties["relations"]["items"]["properties"]["kind"]["enum"]) 80 + == ALLOWED_RELATION_KINDS 81 + ) 68 82 69 83 70 84 def test_story_hook_fixtures_validate_against_schema(): ··· 74 88 errors = list(Draft202012Validator(schema).iter_errors(payload)) 75 89 76 90 assert errors == [] 91 + 92 + 93 + def test_story_schema_rejects_unknown_relation_kind(): 94 + schema = _load_story_schema() 95 + payload = json.loads( 96 + _valid_result( 97 + relations=[ 98 + { 99 + "from": "Mina", 100 + "to": "Ravi", 101 + "kind": "mentors", 102 + "note": "Mina mentors Ravi.", 103 + "quote": "Mina mentors Ravi.", 104 + } 105 + ] 106 + ) 107 + ) 108 + 109 + assert not Draft202012Validator(schema).is_valid(payload) 110 + 111 + 112 + def test_story_schema_has_no_unbounded_nodes(): 113 + assert unbounded_nodes(_load_story_schema()) == []
+1
tests/test_surfaces_ledger.py
··· 145 145 commitments=commitments or [], 146 146 closures=closures or [], 147 147 decisions=decisions or [], 148 + relations=[], 148 149 actor="story", 149 150 ) 150 151
+1
tests/test_surfaces_profile.py
··· 213 213 commitments=commitments or [], 214 214 closures=closures or [], 215 215 decisions=decisions or [], 216 + relations=[], 216 217 actor="story", 217 218 ) 218 219