From e7e141b9866bfdec6adb448ee55db968974e857c Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Wed, 8 Jul 2026 22:35:24 -0600 Subject: [PATCH] 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 _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) --- scripts/check_schema_bounds.py | 4 - .../apps/entities/talent/entity_observer.md | 19 ++- .../apps/entities/talent/entity_observer.py | 103 ++++++++++-- .../talent/entity_observer.schema.json | 55 ++++++- solstone/talent/conversation.md | 14 +- solstone/talent/event.md | 14 +- solstone/talent/story.py | 72 ++++++++ solstone/talent/story.schema.json | 106 ++++++++++-- solstone/talent/work.md | 14 +- solstone/think/activities.py | 10 +- solstone/think/entities/observations.py | 11 +- tests/baselines/api/stats/stats.json | 3 + tests/test_entity_observer_context.py | 155 +++++++++++++++++- tests/test_entity_observer_schema.py | 66 +++++++- tests/test_schema_prep.py | 94 ++++++++++- tests/test_story_hook.py | 131 ++++++++++++++- tests/test_story_schema.py | 39 ++++- tests/test_surfaces_ledger.py | 1 + tests/test_surfaces_profile.py | 1 + 19 files changed, 836 insertions(+), 76 deletions(-) diff --git a/scripts/check_schema_bounds.py b/scripts/check_schema_bounds.py index 23e12747a..6994e2fee 100644 --- a/scripts/check_schema_bounds.py +++ b/scripts/check_schema_bounds.py @@ -28,9 +28,6 @@ ALLOWLIST: dict[str, str] = { "solstone/apps/entities/talent/entities_review.schema.json": ( "entity_observer follow-on lode" ), - "solstone/apps/entities/talent/entity_observer.schema.json": ( - "entity_observer follow-on lode" - ), "solstone/apps/timeline/talent/segment_summary.schema.json": ( "documents follow-on lode" ), @@ -53,7 +50,6 @@ ALLOWLIST: dict[str, str] = { "unbounded pending KG schema enrichment arc" ), "solstone/talent/steward.schema.json": "morning_briefing follow-on lode", - "solstone/talent/story.schema.json": "story follow-on lode", "solstone/think/detect_created.schema.json": ( "unbounded pending KG schema enrichment arc" ), diff --git a/solstone/apps/entities/talent/entity_observer.md b/solstone/apps/entities/talent/entity_observer.md index e18a5ecac..b14eaff4f 100644 --- a/solstone/apps/entities/talent/entity_observer.md +++ b/solstone/apps/entities/talent/entity_observer.md @@ -76,12 +76,13 @@ Use operations to maintain the numbered current observations shown in context: Rules: - Use the `entity_id` from context. -- Include every field on each operation; set non-applicable fields (`target_index`, `content`, `target_quote`) to `null`. +- Include every field on each operation; set non-applicable fields (`target_index`, `content`, `target_quote`, `relation`) to `null`. - Prefer `update` or `drop` over adding a near-duplicate observation. - For `update` and `drop`, include a short verbatim `target_quote` from the target observation. - At most one operation may target a given observation index for an entity. - Use `add` only for facts that pass the durability litmus. - One fact per observation — no compound sentences. +- `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"`. - The `reasoning` field is for audit only. - Empty operations are valid when no changes are needed for an entity. @@ -100,28 +101,36 @@ Respond with a JSON object in this exact format: "target_index": 0, "content": "The revised durable observation text", "target_quote": "short exact quote from the old observation", - "reasoning": "Why this update is warranted" + "reasoning": "Why this update is warranted", + "relation": null }, { "op": "add", "target_index": null, "content": "A new durable observation text", "target_quote": null, - "reasoning": "Why this qualifies" + "reasoning": "Why this qualifies", + "relation": { + "kind": "works-with", + "target_name": "Bob Lee", + "note": "" + } }, { "op": "drop", "target_index": 2, "content": null, "target_quote": "short exact quote from the old observation", - "reasoning": "Why this should be removed" + "reasoning": "Why this should be removed", + "relation": null }, { "op": "keep", "target_index": 3, "content": null, "target_quote": null, - "reasoning": "Why this should remain unchanged" + "reasoning": "Why this should remain unchanged", + "relation": null } ] } diff --git a/solstone/apps/entities/talent/entity_observer.py b/solstone/apps/entities/talent/entity_observer.py index af8311838..7f5184c27 100644 --- a/solstone/apps/entities/talent/entity_observer.py +++ b/solstone/apps/entities/talent/entity_observer.py @@ -15,8 +15,10 @@ from __future__ import annotations import json import logging +from solstone.talent.story import ALLOWED_RELATION_KINDS from solstone.think.entities.context import assemble_observer_context from solstone.think.entities.loading import detected_entities_path, load_entities +from solstone.think.entities.matching import find_matching_entity from solstone.think.entities.observations import record_observation_ops from solstone.think.journal_io import LockTimeout from solstone.think.utils import now_ms @@ -35,7 +37,14 @@ def pre_process(context: dict) -> dict | None: def _empty_counts() -> dict[str, int]: - return {"update": 0, "add": 0, "drop": 0, "keep": 0, "skipped": 0} + return { + "update": 0, + "add": 0, + "drop": 0, + "keep": 0, + "skipped": 0, + "relation_unresolved": 0, + } def _write_outcome( @@ -65,35 +74,88 @@ def _target_quote(value: object) -> str | None: return value or None -def _clean_operation(item: object, seen_indexes: set[int]) -> tuple[dict | None, bool]: +def _clean_relation( + value: object, + op: str, + entities: list[dict], +) -> tuple[dict | None, str | None]: + if value is None or op in {"drop", "keep"}: + return None, None + if not isinstance(value, dict): + logger.warning("entity_observer: invalid relation payload for %s", op) + return None, "skipped" + + kind = value.get("kind") + target_name = value.get("target_name") + note = value.get("note") + if ( + not isinstance(kind, str) + or not isinstance(target_name, str) + or not isinstance(note, str) + ): + logger.warning("entity_observer: invalid relation fields for %s", op) + return None, "skipped" + if kind not in ALLOWED_RELATION_KINDS: + logger.warning("entity_observer: invalid relation kind %r", kind) + return None, "skipped" + if kind == "other" and not note.strip(): + logger.warning("entity_observer: relation kind 'other' requires note") + return None, "skipped" + + match = find_matching_entity(target_name, entities, fuzzy_threshold=90) + if not match: + logger.warning( + "entity_observer: unresolved relation target %r for %s op", + target_name, + op, + ) + return None, "relation_unresolved" + + return { + "kind": kind, + "target_entity_id": match["id"], + "target_name": target_name, + "note": note, + }, None + + +def _clean_operation( + item: object, seen_indexes: set[int], entities: list[dict] +) -> tuple[dict | None, str | None]: if not isinstance(item, dict): - return None, True + return None, "skipped" op = item.get("op") if op == "add": content = item.get("content") if not isinstance(content, str) or not content.strip(): - return None, True - return {"op": "add", "content": content.strip()}, False + return None, "skipped" + relation, status = _clean_relation(item.get("relation"), op, entities) + if status is not None: + return None, status + cleaned = {"op": "add", "content": content.strip()} + if relation is not None: + cleaned["relation"] = relation + return cleaned, None if op not in {"update", "drop", "keep"}: - return None, True + return None, "skipped" target_index = _target_index(item.get("target_index")) if target_index is None: - return None, True + return None, "skipped" content: str | None = None if op == "update": raw_content = item.get("content") if not isinstance(raw_content, str) or not raw_content.strip(): - return None, True + return None, "skipped" content = raw_content.strip() raw_quote = item.get("target_quote") if raw_quote is not None and not isinstance(raw_quote, str): - return None, True + return None, "skipped" quote = _target_quote(raw_quote) if target_index in seen_indexes: - return None, True + return None, "skipped" seen_indexes.add(target_index) cleaned: dict = {"op": op, "target_index": target_index} @@ -103,7 +165,13 @@ def _clean_operation(item: object, seen_indexes: set[int]) -> tuple[dict | None, if content is not None: cleaned["content"] = content - return cleaned, False + relation, status = _clean_relation(item.get("relation"), op, entities) + if status is not None: + return None, status + if relation is not None: + cleaned["relation"] = relation + + return cleaned, None def _merge_counts(target: dict[str, int], source: dict[str, int]) -> None: @@ -138,8 +206,9 @@ def post_process(result: str, context: dict) -> str | None: logger.warning("entity_observer: entities is not a list") return None + attached_entities = load_entities(facet) valid_entity_ids = { - entity.get("id") for entity in load_entities(facet) if entity.get("id") + entity.get("id") for entity in attached_entities if entity.get("id") } for entry in entities: @@ -155,6 +224,8 @@ def post_process(result: str, context: dict) -> str | None: counts["skipped"] += len(operations) logger.debug("Skipping entity entry with invalid entity_id: %r", entry) continue + # entity_id is enumerated in $observer_context and validated with load_entities(facet). + # Name-first would touch assemble_observer_context, prompt numbering, record_observation_ops. if entity_id not in valid_entity_ids: counts["skipped"] += len(operations) logger.debug("Skipping unrecognized entity_id: %s", entity_id) @@ -163,9 +234,11 @@ def post_process(result: str, context: dict) -> str | None: clean_ops: list[dict] = [] seen_indexes: set[int] = set() for item in operations: - clean_op, skipped = _clean_operation(item, seen_indexes) - if skipped: - counts["skipped"] += 1 + clean_op, status = _clean_operation( + item, seen_indexes, attached_entities + ) + if status is not None: + counts[status] += 1 continue if clean_op is not None: clean_ops.append(clean_op) diff --git a/solstone/apps/entities/talent/entity_observer.schema.json b/solstone/apps/entities/talent/entity_observer.schema.json index 81099becf..b467b7a1e 100644 --- a/solstone/apps/entities/talent/entity_observer.schema.json +++ b/solstone/apps/entities/talent/entity_observer.schema.json @@ -8,6 +8,7 @@ "properties": { "entities": { "type": "array", + "maxItems": 24, "items": { "type": "object", "additionalProperties": false, @@ -17,10 +18,12 @@ ], "properties": { "entity_id": { - "type": "string" + "type": "string", + "maxLength": 160 }, "operations": { "type": "array", + "maxItems": 20, "items": { "type": "object", "additionalProperties": false, @@ -29,7 +32,8 @@ "target_index", "content", "target_quote", - "reasoning" + "reasoning", + "relation" ], "properties": { "op": { @@ -51,16 +55,54 @@ "type": [ "string", "null" - ] + ], + "maxLength": 600 }, "target_quote": { "type": [ "string", "null" - ] + ], + "maxLength": 300 }, "reasoning": { - "type": "string" + "type": "string", + "maxLength": 300 + }, + "relation": { + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "required": [ + "kind", + "target_name", + "note" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "works-with", + "works-at", + "reports-to", + "family-of", + "knows", + "uses", + "created", + "other" + ] + }, + "target_name": { + "type": "string", + "maxLength": 120 + }, + "note": { + "type": "string", + "maxLength": 300 + } + } } } } @@ -69,7 +111,8 @@ } }, "summary": { - "type": "string" + "type": "string", + "maxLength": 500 } } } diff --git a/solstone/talent/conversation.md b/solstone/talent/conversation.md index 19b6bd516..34a601718 100644 --- a/solstone/talent/conversation.md +++ b/solstone/talent/conversation.md @@ -8,6 +8,7 @@ "priority": 20, "tier": 3, "output": "json", + "max_output_tokens": 12288, "schema": "story.schema.json", "hook": {"post": "story"}, "degradation_check": true, @@ -32,7 +33,7 @@ Summarize this conversation as one coherent narrative for the full activity. Participation and entity extraction already happened upstream. Reuse that context; do not re-extract people or entities into new structures. -Return exactly this six-field JSON object: +Return exactly this seven-field JSON object: - `body`: string narrative prose covering what was discussed, what moved, and any commitments. - `topics`: array of short string tags; use `[]` when there are no durable topics worth preserving. - `confidence`: float from 0.0 to 1.0. @@ -40,10 +41,13 @@ Return exactly this six-field JSON object: 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."}` - `closures`: array of objects with required string fields `owner`, `action`, `counterparty`, `resolution`, `context`. `resolution` must be one of `sent`, `done`, `signed`, `dropped`, `deferred`. Example: `{"owner":"Ravi","action":"intro email","counterparty":"Mina","resolution":"sent","context":"Ravi confirmed the intro email already went out during the call."}` -- `decisions`: array of objects with required string fields `owner`, `action`, `context`. - Example: `{"owner":"Team","action":"schedule the launch review for next Tuesday","context":"The group agreed to move the review to Tuesday after checking calendars."}` +- `decisions`: array of objects with required string fields `owner`, `action`, `context`, plus nullable `counterparty`; emit `null` when there is no counterparty. + 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."}` +- `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`. + Example: `{"from":"Mina","to":"Ravi","kind":"works-with","note":"","quote":"Mina and Ravi will co-own the investor follow-up."}` + 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"`. -Return `[]` if you do not observe a clear commitment / closure / decision. Better to omit than invent. +Return `[]` if you do not observe a clear commitment / closure / decision / relation. Better to omit than invent. Body requirements: - Write one tight paragraph in chronological order. @@ -53,4 +57,4 @@ Body requirements: - If the activity mixes channels, unify them into one narrative rather than listing separate threads. -Output a single JSON object with all six required fields: `body`, `topics`, `confidence`, `commitments`, `closures`, and `decisions`. +Output a single JSON object with all seven required fields: `body`, `topics`, `confidence`, `commitments`, `closures`, `decisions`, and `relations`. diff --git a/solstone/talent/event.md b/solstone/talent/event.md index 54cbbc3df..bf2b256b6 100644 --- a/solstone/talent/event.md +++ b/solstone/talent/event.md @@ -7,6 +7,7 @@ "activities": ["appointment", "event", "travel", "errand", "celebration", "deadline", "reminder"], "priority": 20, "output": "json", + "max_output_tokens": 12288, "schema": "story.schema.json", "hook": {"post": "story"}, "degradation_check": true, @@ -32,7 +33,7 @@ deadline-related activity. Participation and entity extraction already happened upstream. Use that context; do not re-extract people or entities into new structures. -Return exactly this six-field JSON object: +Return exactly this seven-field JSON object: - `body`: string narrative prose describing what happened and any outcome. - `topics`: array of short string tags; use `[]` when there are no durable topics worth preserving. - `confidence`: float from 0.0 to 1.0. @@ -40,10 +41,13 @@ Return exactly this six-field JSON object: 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."}` - `closures`: array of objects with required string fields `owner`, `action`, `counterparty`, `resolution`, `context`. `resolution` must be one of `sent`, `done`, `signed`, `dropped`, `deferred`. Example: `{"owner":"Jordan","action":"hotel confirmation","counterparty":"Taylor","resolution":"signed","context":"Jordan completed and signed the hotel check-in form during the event."}` -- `decisions`: array of objects with required string fields `owner`, `action`, `context`. - 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."}` +- `decisions`: array of objects with required string fields `owner`, `action`, `context`, plus nullable `counterparty`; emit `null` when there is no counterparty. + 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."}` +- `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`. + Example: `{"from":"Jordan","to":"Taylor","kind":"family-of","note":"","quote":"Jordan checked in with Taylor before the shuttle left."}` + 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"`. -Return `[]` if you do not observe a clear commitment / closure / decision. Better to omit than invent. +Return `[]` if you do not observe a clear commitment / closure / decision / relation. Better to omit than invent. Body requirements: - Write one tight paragraph in chronological order. @@ -51,4 +55,4 @@ Body requirements: - Prefer what actually occurred over generic labels from the activity type. - If evidence is thin, keep the narrative modest and confidence honest. -Output a single JSON object with all six required fields: `body`, `topics`, `confidence`, `commitments`, `closures`, and `decisions`. +Output a single JSON object with all seven required fields: `body`, `topics`, `confidence`, `commitments`, `closures`, `decisions`, and `relations`. diff --git a/solstone/talent/story.py b/solstone/talent/story.py index 746950873..6f9b3cac8 100644 --- a/solstone/talent/story.py +++ b/solstone/talent/story.py @@ -17,6 +17,18 @@ from solstone.think.entities.matching import find_matching_entity logger = logging.getLogger(__name__) ALLOWED_RESOLUTIONS = frozenset({"sent", "done", "signed", "dropped", "deferred"}) +ALLOWED_RELATION_KINDS = frozenset( + { + "works-with", + "works-at", + "reports-to", + "family-of", + "knows", + "uses", + "created", + "other", + } +) def _normalize_topics(value: Any) -> list[str] | None: @@ -96,6 +108,7 @@ def post_process(result: str, context: dict) -> str: commitments = data.get("commitments") closures = data.get("closures") decisions = data.get("decisions") + relations = data.get("relations") if not isinstance(body, str) or not body.strip(): logger.warning("story hook: missing body") @@ -115,6 +128,9 @@ def post_process(result: str, context: dict) -> str: if not isinstance(decisions, list): logger.warning("story hook: missing decisions list") return "" + if not isinstance(relations, list): + logger.warning("story hook: missing relations list") + return "" activity = context.get("activity") if not isinstance(activity, dict): @@ -201,12 +217,67 @@ def post_process(result: str, context: dict) -> str: index, ) continue + counterparty = entry.get("counterparty") + if counterparty is not None and not isinstance(counterparty, str): + logger.warning( + "story hook: skipping decision[%d]: invalid counterparty field", + index, + ) + continue resolved_decision = dict(normalized) + resolved_decision["counterparty"] = counterparty resolved_decision["owner_entity_id"] = _resolve_entity_id( normalized["owner"], entities ) + resolved_decision["counterparty_entity_id"] = ( + _resolve_entity_id(counterparty, entities) + if isinstance(counterparty, str) and counterparty.strip() + else None + ) resolved_decisions.append(resolved_decision) + resolved_relations: list[dict[str, Any]] = [] + for index, entry in enumerate(relations): + if not isinstance(entry, dict): + logger.warning("story hook: skipping relation[%d]: expected object", index) + continue + normalized = _validate_fields(entry, ("from", "to", "kind", "note")) + if normalized is None: + logger.warning( + "story hook: skipping relation[%d]: missing required string field", + index, + ) + continue + if normalized["kind"] not in ALLOWED_RELATION_KINDS: + logger.warning( + "story hook: skipping relation[%d]: invalid kind '%s'", + index, + normalized["kind"], + ) + continue + if normalized["kind"] == "other" and not normalized["note"].strip(): + logger.warning( + "story hook: skipping relation[%d]: other kind requires note", + index, + ) + continue + quote = entry.get("quote") + if quote is not None and not isinstance(quote, str): + logger.warning( + "story hook: skipping relation[%d]: invalid quote field", + index, + ) + continue + resolved_relation = dict(normalized) + resolved_relation["quote"] = quote + resolved_relation["from_entity_id"] = _resolve_entity_id( + normalized["from"], entities + ) + resolved_relation["to_entity_id"] = _resolve_entity_id( + normalized["to"], entities + ) + resolved_relations.append(resolved_relation) + talent_name = context.get("name") or "" if not talent_name: logger.warning("story hook: missing talent name in context") @@ -226,6 +297,7 @@ def post_process(result: str, context: dict) -> str: commitments=resolved_commitments, closures=resolved_closures, decisions=resolved_decisions, + relations=resolved_relations, actor="story", note=None, ) diff --git a/solstone/talent/story.schema.json b/solstone/talent/story.schema.json index d32b5eb64..563560f85 100644 --- a/solstone/talent/story.schema.json +++ b/solstone/talent/story.schema.json @@ -7,16 +7,20 @@ "confidence", "commitments", "closures", - "decisions" + "decisions", + "relations" ], "properties": { "body": { - "type": "string" + "type": "string", + "maxLength": 4000 }, "topics": { "type": "array", + "maxItems": 10, "items": { - "type": "string" + "type": "string", + "maxLength": 48 } }, "confidence": { @@ -24,6 +28,7 @@ }, "commitments": { "type": "array", + "maxItems": 6, "items": { "type": "object", "additionalProperties": false, @@ -36,25 +41,31 @@ ], "properties": { "owner": { - "type": "string" + "type": "string", + "maxLength": 200 }, "action": { - "type": "string" + "type": "string", + "maxLength": 200 }, "counterparty": { - "type": "string" + "type": "string", + "maxLength": 200 }, "when": { - "type": "string" + "type": "string", + "maxLength": 200 }, "context": { - "type": "string" + "type": "string", + "maxLength": 400 } } } }, "closures": { "type": "array", + "maxItems": 6, "items": { "type": "object", "additionalProperties": false, @@ -67,13 +78,16 @@ ], "properties": { "owner": { - "type": "string" + "type": "string", + "maxLength": 200 }, "action": { - "type": "string" + "type": "string", + "maxLength": 200 }, "counterparty": { - "type": "string" + "type": "string", + "maxLength": 200 }, "resolution": { "type": "string", @@ -86,30 +100,92 @@ ] }, "context": { - "type": "string" + "type": "string", + "maxLength": 400 } } } }, "decisions": { "type": "array", + "maxItems": 6, "items": { "type": "object", "additionalProperties": false, "required": [ "owner", "action", + "counterparty", "context" ], "properties": { "owner": { - "type": "string" + "type": "string", + "maxLength": 200 }, "action": { - "type": "string" + "type": "string", + "maxLength": 200 + }, + "counterparty": { + "type": [ + "string", + "null" + ], + "maxLength": 200 }, "context": { - "type": "string" + "type": "string", + "maxLength": 400 + } + } + } + }, + "relations": { + "type": "array", + "maxItems": 6, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "from", + "to", + "kind", + "note", + "quote" + ], + "properties": { + "from": { + "type": "string", + "maxLength": 120 + }, + "to": { + "type": "string", + "maxLength": 120 + }, + "kind": { + "type": "string", + "enum": [ + "works-with", + "works-at", + "reports-to", + "family-of", + "knows", + "uses", + "created", + "other" + ] + }, + "note": { + "type": "string", + "maxLength": 300 + }, + "quote": { + "type": [ + "string", + "null" + ], + "maxLength": 400 } } } diff --git a/solstone/talent/work.md b/solstone/talent/work.md index e685a8cc2..606f87321 100644 --- a/solstone/talent/work.md +++ b/solstone/talent/work.md @@ -7,6 +7,7 @@ "activities": ["coding", "browsing", "reading"], "priority": 20, "output": "json", + "max_output_tokens": 12288, "schema": "story.schema.json", "hook": {"post": "story"}, "degradation_check": true, @@ -31,7 +32,7 @@ Summarize what this person accomplished, investigated, or worked through during the activity. Participation and entity extraction already happened upstream. Use that context; do not re-extract people or entities into new structures. -Return exactly this six-field JSON object: +Return exactly this seven-field JSON object: - `body`: string narrative prose about the work performed and what changed. - `topics`: array of short string tags; use `[]` when there are no durable topics worth preserving. - `confidence`: float from 0.0 to 1.0. @@ -39,10 +40,13 @@ Return exactly this six-field JSON object: 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."}` - `closures`: array of objects with required string fields `owner`, `action`, `counterparty`, `resolution`, `context`. `resolution` must be one of `sent`, `done`, `signed`, `dropped`, `deferred`. Example: `{"owner":"Avery","action":"follow-up PR","counterparty":"Priya","resolution":"done","context":"Avery noted the cleanup PR was merged during this work block."}` -- `decisions`: array of objects with required string fields `owner`, `action`, `context`. - 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."}` +- `decisions`: array of objects with required string fields `owner`, `action`, `context`, plus nullable `counterparty`; emit `null` when there is no counterparty. + 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."}` +- `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`. + Example: `{"from":"Avery","to":"Queue Worker","kind":"created","note":"","quote":"Avery finished wiring the queue worker into the retry path."}` + 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"`. -Return `[]` if you do not observe a clear commitment / closure / decision. Better to omit than invent. +Return `[]` if you do not observe a clear commitment / closure / decision / relation. Better to omit than invent. Body requirements: - Write one tight paragraph in chronological order. @@ -51,4 +55,4 @@ Body requirements: - If evidence is partial, describe the most defensible story and keep the confidence honest. -Output a single JSON object with all six required fields: `body`, `topics`, `confidence`, `commitments`, `closures`, and `decisions`. +Output a single JSON object with all seven required fields: `body`, `topics`, `confidence`, `commitments`, `closures`, `decisions`, and `relations`. diff --git a/solstone/think/activities.py b/solstone/think/activities.py index 98679f5ca..5dd6aa465 100644 --- a/solstone/think/activities.py +++ b/solstone/think/activities.py @@ -1216,6 +1216,7 @@ def merge_story_fields( commitments: list[dict], closures: list[dict], decisions: list[dict], + relations: list[dict], actor: str, note: str | None = None, ) -> bool: @@ -1233,10 +1234,17 @@ def merge_story_fields( merged["commitments"] = [dict(entry) for entry in commitments] merged["closures"] = [dict(entry) for entry in closures] merged["decisions"] = [dict(entry) for entry in decisions] + merged["relations"] = [dict(entry) for entry in relations] merged = append_edit( merged, actor=actor, - fields=["story", "commitments", "closures", "decisions"], + fields=[ + "story", + "commitments", + "closures", + "decisions", + "relations", + ], note=note, ) new_records.append(merged) diff --git a/solstone/think/entities/observations.py b/solstone/think/entities/observations.py index b1d309495..f2b9e4c41 100644 --- a/solstone/think/entities/observations.py +++ b/solstone/think/entities/observations.py @@ -257,13 +257,17 @@ def _target_quote_matches(observation: dict[str, Any], target_quote: Any) -> boo return target_quote.strip().casefold() in content.casefold() -def _new_observation(content: str, source_day: str | None) -> dict[str, Any]: +def _new_observation( + content: str, source_day: str | None, relation: dict[str, Any] | None = None +) -> dict[str, Any]: observation: dict[str, Any] = { "content": content, "observed_at": now_ms(), } if source_day is not None: observation["source_day"] = source_day + if relation is not None: + observation["relation"] = dict(relation) return observation @@ -284,12 +288,13 @@ def _apply_observation_ops( continue action = op.get("op") + relation = op.get("relation") if action == "add": content = op.get("content") if not isinstance(content, str) or not content.strip(): counts["skipped"] += 1 continue - additions.append(_new_observation(content.strip(), source_day)) + additions.append(_new_observation(content.strip(), source_day, relation)) counts["add"] += 1 changed = True continue @@ -322,7 +327,7 @@ def _apply_observation_ops( if not isinstance(content, str) or not content.strip(): counts["skipped"] += 1 continue - updates[target_index] = _new_observation(content.strip(), source_day) + updates[target_index] = _new_observation(content.strip(), source_day, relation) drops.discard(target_index) counts["update"] += 1 changed = True diff --git a/tests/baselines/api/stats/stats.json b/tests/baselines/api/stats/stats.json index 02119fbc2..081f2d79d 100644 --- a/tests/baselines/api/stats/stats.json +++ b/tests/baselines/api/stats/stats.json @@ -35,6 +35,7 @@ "talents": false, "transcripts": true }, + "max_output_tokens": 12288, "mtime": 0, "output": "json", "path": "/solstone/talent/conversation.md", @@ -209,6 +210,7 @@ "talents": false, "transcripts": true }, + "max_output_tokens": 12288, "mtime": 0, "output": "json", "path": "/solstone/talent/event.md", @@ -462,6 +464,7 @@ "talents": false, "transcripts": true }, + "max_output_tokens": 12288, "mtime": 0, "output": "json", "path": "/solstone/talent/work.md", diff --git a/tests/test_entity_observer_context.py b/tests/test_entity_observer_context.py index 454e28d8d..69a68ddae 100644 --- a/tests/test_entity_observer_context.py +++ b/tests/test_entity_observer_context.py @@ -62,7 +62,7 @@ def _obs_path(facet: str, entity_id: str) -> Path: return Path("facets") / facet / "entities" / entity_id / "observations.jsonl" -COUNT_KEYS = ("update", "add", "drop", "keep", "skipped") +COUNT_KEYS = ("update", "add", "drop", "keep", "skipped", "relation_unresolved") def _outcome_path(root: Path, facet: str, day: str) -> Path: @@ -563,6 +563,7 @@ def test_post_process_applies_operations_and_writes_outcome(tmp_path, monkeypatc "drop": 1, "keep": 1, "skipped": 0, + "relation_unresolved": 0, } assert outcome["error"] is None assert _count_sum(outcome) == 4 @@ -613,6 +614,7 @@ def test_post_process_unknown_entity_counts_all_operation_rows_skipped( "drop": 0, "keep": 0, "skipped": 3, + "relation_unresolved": 0, } assert outcome["error"] is None assert _count_sum(outcome) == 3 @@ -633,6 +635,7 @@ def test_post_process_handles_malformed_json_with_zero_outcome(tmp_path, monkeyp "drop": 0, "keep": 0, "skipped": 0, + "relation_unresolved": 0, } assert outcome["error"] is None @@ -710,6 +713,7 @@ def test_post_process_rejects_malformed_ops_and_counts_skipped(tmp_path, monkeyp "drop": 0, "keep": 0, "skipped": 8, + "relation_unresolved": 0, } assert outcome["error"] is None assert _count_sum(outcome) == 9 @@ -774,6 +778,7 @@ def test_post_process_duplicate_target_index_first_clean_op_wins(tmp_path, monke "drop": 0, "keep": 1, "skipped": 1, + "relation_unresolved": 0, } assert outcome["error"] is None assert _count_sum(outcome) == 3 @@ -826,6 +831,7 @@ def test_post_process_storage_failure_sets_error_and_writes_outcome( "drop": 0, "keep": 0, "skipped": 2, + "relation_unresolved": 0, } assert outcome["error"] == "OSError: disk busy" assert _count_sum(outcome) == 2 @@ -871,10 +877,157 @@ def test_post_process_invalid_entity_id_counts_operation_rows_skipped( "drop": 0, "keep": 0, "skipped": 1, + "relation_unresolved": 0, } assert _count_sum(outcome) == 1 +def test_post_process_persists_resolved_relation_on_observation(tmp_path, monkeypatch): + _set_journal(monkeypatch, str(tmp_path)) + facet = "work" + day = "20260304" + _attach_entity(tmp_path, facet, "alice_johnson", "Alice Johnson") + _attach_entity(tmp_path, facet, "bob_lee", "Bob Lee") + + post_process( + json.dumps( + { + "entities": [ + { + "entity_id": "alice_johnson", + "operations": [ + { + "op": "add", + "content": "Pairs with Bob Lee on the platform team", + "reasoning": "Durable working relationship.", + "relation": { + "kind": "works-with", + "target_name": "Bob Lee", + "note": "", + }, + } + ], + } + ], + "summary": "one relational add", + } + ), + {"facet": facet, "day": day}, + ) + + observations = load_observations(facet, "alice_johnson") + assert len(observations) == 1 + assert observations[0]["relation"] == { + "kind": "works-with", + "target_entity_id": "bob_lee", + "target_name": "Bob Lee", + "note": "", + } + outcome = _load_outcome(tmp_path, facet, day) + assert {key: outcome[key] for key in COUNT_KEYS} == { + "update": 0, + "add": 1, + "drop": 0, + "keep": 0, + "skipped": 0, + "relation_unresolved": 0, + } + + +def test_post_process_drops_op_with_unresolvable_relation_target( + tmp_path, monkeypatch, caplog +): + _set_journal(monkeypatch, str(tmp_path)) + facet = "work" + day = "20260304" + _attach_entity(tmp_path, facet, "alice_johnson", "Alice Johnson") + + post_process( + json.dumps( + { + "entities": [ + { + "entity_id": "alice_johnson", + "operations": [ + { + "op": "add", + "content": "Reports to someone we cannot identify", + "reasoning": "Relational, but the target is unknown.", + "relation": { + "kind": "reports-to", + "target_name": "Nobody Visible", + "note": "", + }, + } + ], + } + ], + "summary": "unresolvable relation target", + } + ), + {"facet": facet, "day": day}, + ) + + assert load_observations(facet, "alice_johnson") == [] + assert "unresolved relation target" in caplog.text + outcome = _load_outcome(tmp_path, facet, day) + assert {key: outcome[key] for key in COUNT_KEYS} == { + "update": 0, + "add": 0, + "drop": 0, + "keep": 0, + "skipped": 0, + "relation_unresolved": 1, + } + + +def test_post_process_drops_op_with_other_relation_kind_and_no_note( + tmp_path, monkeypatch +): + _set_journal(monkeypatch, str(tmp_path)) + facet = "work" + day = "20260304" + _attach_entity(tmp_path, facet, "alice_johnson", "Alice Johnson") + _attach_entity(tmp_path, facet, "bob_lee", "Bob Lee") + + post_process( + json.dumps( + { + "entities": [ + { + "entity_id": "alice_johnson", + "operations": [ + { + "op": "add", + "content": "Has an unusual tie to Bob Lee", + "reasoning": "Relational, but the note is blank.", + "relation": { + "kind": "other", + "target_name": "Bob Lee", + "note": " ", + }, + } + ], + } + ], + "summary": "other kind without a note", + } + ), + {"facet": facet, "day": day}, + ) + + assert load_observations(facet, "alice_johnson") == [] + outcome = _load_outcome(tmp_path, facet, day) + assert {key: outcome[key] for key in COUNT_KEYS} == { + "update": 0, + "add": 0, + "drop": 0, + "keep": 0, + "skipped": 1, + "relation_unresolved": 0, + } + + # ============================================================================ # Agent config test # ============================================================================ diff --git a/tests/test_entity_observer_schema.py b/tests/test_entity_observer_schema.py index 8f4be143e..9a5ab5efa 100644 --- a/tests/test_entity_observer_schema.py +++ b/tests/test_entity_observer_schema.py @@ -8,6 +8,8 @@ from pathlib import Path from jsonschema import Draft202012Validator +from solstone.talent.story import ALLOWED_RELATION_KINDS +from solstone.think.schema_bounds import unbounded_nodes from solstone.think.talent import get_talent SCHEMA_PATH = ( @@ -51,6 +53,7 @@ def test_valid_operations_payload(): "content": "Prefers concise morning planning meetings", "target_quote": "morning meetings", "reasoning": "Fresh source narrows the preference.", + "relation": None, }, { "op": "add", @@ -58,6 +61,11 @@ def test_valid_operations_payload(): "content": "Has deep knowledge of distributed systems", "target_quote": None, "reasoning": "Durable expertise.", + "relation": { + "kind": "works-with", + "target_name": "Bob Lee", + "note": "", + }, }, { "op": "drop", @@ -65,6 +73,7 @@ def test_valid_operations_payload(): "content": None, "target_quote": "legacy planning", "reasoning": "Stale duplicate.", + "relation": None, }, { "op": "keep", @@ -72,6 +81,7 @@ def test_valid_operations_payload(): "content": None, "target_quote": None, "reasoning": "Still useful.", + "relation": None, }, ], } @@ -107,7 +117,17 @@ def test_invalid_extra_property_at_each_level(): "entities": [ { "entity_id": "alice_johnson", - "operations": [{"op": "keep", "reasoning": "audit", "extra": True}], + "operations": [ + { + "op": "keep", + "target_index": 0, + "content": None, + "target_quote": None, + "reasoning": "audit", + "relation": None, + "extra": True, + } + ], } ], "summary": "extra operation", @@ -130,6 +150,7 @@ def test_invalid_unknown_op_enum(): "content": None, "target_quote": None, "reasoning": "unknown op", + "relation": None, } ], } @@ -139,9 +160,52 @@ def test_invalid_unknown_op_enum(): ) +def test_relation_kind_enum_matches_python(): + schema = _load_schema() + relation = schema["properties"]["entities"]["items"]["properties"]["operations"][ + "items" + ]["properties"]["relation"] + + assert set(relation["properties"]["kind"]["enum"]) == ALLOWED_RELATION_KINDS + + +def test_invalid_unknown_relation_kind(): + validator = Draft202012Validator(_load_schema()) + + assert not validator.is_valid( + { + "entities": [ + { + "entity_id": "alice_johnson", + "operations": [ + { + "op": "add", + "target_index": None, + "content": "Has a durable relation.", + "target_quote": None, + "reasoning": "relation audit", + "relation": { + "kind": "mentors", + "target_name": "Bob Lee", + "note": "Alice mentors Bob.", + }, + } + ], + } + ], + "summary": "bad relation", + } + ) + + def test_schema_has_no_conditional_keywords(): schema = _schema_text() assert '"if"' not in schema assert '"then"' not in schema assert '"oneOf"' not in schema + assert '"anyOf"' not in schema + + +def test_schema_has_no_unbounded_nodes(): + assert unbounded_nodes(_load_schema()) == [] diff --git a/tests/test_schema_prep.py b/tests/test_schema_prep.py index 01a230e42..8b2d94a7d 100644 --- a/tests/test_schema_prep.py +++ b/tests/test_schema_prep.py @@ -14,7 +14,7 @@ import pytest from solstone.apps.timeline.rollup import build_rollup_schema from solstone.think.models import SchemaValidationError, generate -from solstone.think.schema_prep import prepare_provider_schema +from solstone.think.schema_prep import prepare_provider_schema, unsupported_keyword_hits REPO_ROOT = Path(__file__).resolve().parents[1] @@ -40,14 +40,16 @@ def bounded_schema() -> dict[str, Any]: } -def _discover_schemas() -> tuple[dict[str, Any], ...]: - discovered: list[dict[str, Any]] = [] +def _discover_schemas() -> tuple[Any, ...]: + discovered: list[Any] = [] for path in sorted((REPO_ROOT / "solstone").glob("**/*.schema.json")): schema = json.loads(path.read_text(encoding="utf-8")) if isinstance(schema.get("x-journal-contract"), dict): continue - discovered.append(schema) - discovered.append(build_rollup_schema(3)) + discovered.append( + pytest.param(schema, id=path.relative_to(REPO_ROOT).as_posix()) + ) + discovered.append(pytest.param(build_rollup_schema(3), id="build_rollup_schema(3)")) return tuple(discovered) @@ -108,10 +110,88 @@ def test_none_and_unknown_provider_passthrough( @pytest.mark.parametrize("provider", ["local", "openai", "google", "anthropic"]) @pytest.mark.parametrize("schema", _discover_schemas()) -def test_current_shipped_schemas_are_byte_identical_after_prep( +def test_shipped_schemas_prep_to_a_provider_supported_subset( schema: dict[str, Any], provider: str ) -> None: - assert prepare_provider_schema(schema, provider) == schema + """Prep strips exactly the provider's unsupported keywords, and nothing else. + + Asserting byte-identity here would pin the suite to "no shipped schema is + bounded", which the schema-bounds ratchet is designed to falsify. + """ + original = copy.deepcopy(schema) + prepared = prepare_provider_schema(schema, provider) + + assert schema == original + assert unsupported_keyword_hits(prepared, provider) == [] + if unsupported_keyword_hits(schema, provider): + assert prepared != schema + else: + assert prepared == schema + + +# Schemas carrying generation bounds. The schema-bounds ratchet +# (scripts/check_schema_bounds.py) grows this set; add entries as schemas +# graduate off its allowlist. +BOUNDED_SCHEMAS = ( + "solstone/talent/story.schema.json", + "solstone/apps/entities/talent/entity_observer.schema.json", +) + + +def _load_shipped_schema(relative_path: str) -> dict[str, Any]: + return json.loads((REPO_ROOT / relative_path).read_text(encoding="utf-8")) + + +def _keyword_paths(node: Any, keyword: str, path: str = "$") -> list[str]: + found: list[str] = [] + if isinstance(node, dict): + for key, value in node.items(): + if key == keyword: + found.append(f"{path}/{key}") + found.extend(_keyword_paths(value, keyword, f"{path}/{key}")) + elif isinstance(node, list): + for index, value in enumerate(node): + found.extend(_keyword_paths(value, keyword, f"{path}[{index}]")) + return found + + +@pytest.mark.parametrize("relative_path", BOUNDED_SCHEMAS) +def test_bounded_schemas_really_carry_bounds(relative_path: str) -> None: + """Guards the three tests below from passing vacuously.""" + schema = _load_shipped_schema(relative_path) + + assert _keyword_paths(schema, "maxLength") + assert _keyword_paths(schema, "maxItems") + + +@pytest.mark.parametrize("relative_path", BOUNDED_SCHEMAS) +def test_bounded_schemas_reach_local_provider_with_bounds_intact( + relative_path: str, +) -> None: + schema = _load_shipped_schema(relative_path) + + assert prepare_provider_schema(schema, "local") == schema + + +@pytest.mark.parametrize("provider", ["openai", "google"]) +@pytest.mark.parametrize("relative_path", BOUNDED_SCHEMAS) +def test_bounded_schemas_lose_only_maxlength_for_openai_and_google( + relative_path: str, provider: str +) -> None: + prepared = prepare_provider_schema(_load_shipped_schema(relative_path), provider) + + assert _keyword_paths(prepared, "maxLength") == [] + assert _keyword_paths(prepared, "maxItems") + + +@pytest.mark.parametrize("relative_path", BOUNDED_SCHEMAS) +def test_bounded_schemas_lose_every_size_bound_for_anthropic( + relative_path: str, +) -> None: + prepared = prepare_provider_schema(_load_shipped_schema(relative_path), "anthropic") + + assert _keyword_paths(prepared, "maxLength") == [] + assert _keyword_paths(prepared, "maxItems") == [] def _patched_generate( diff --git a/tests/test_story_hook.py b/tests/test_story_hook.py index c1cfba463..505a359e2 100644 --- a/tests/test_story_hook.py +++ b/tests/test_story_hook.py @@ -72,9 +72,11 @@ def _valid_result(**overrides) -> str: { "owner": "Team", "action": "move the launch review to Tuesday", + "counterparty": None, "context": "The group aligned on Tuesday after checking calendars.", } ], + "relations": [], } payload.update(overrides) return json.dumps(payload) @@ -110,12 +112,14 @@ def test_story_hook_parses_and_writes(tmp_path, monkeypatch): assert record["commitments"][0]["owner"] == "Mina" assert record["closures"][0]["resolution"] == "sent" assert record["decisions"][0]["owner"] == "Team" + assert record["relations"] == [] assert record["edits"][-1]["actor"] == "story" assert record["edits"][-1]["fields"] == [ "story", "commitments", "closures", "decisions", + "relations", ] @@ -138,6 +142,7 @@ def test_story_hook_empty_arrays(tmp_path, monkeypatch): assert record["commitments"] == [] assert record["closures"] == [] assert record["decisions"] == [] + assert record["relations"] == [] def test_story_hook_bad_resolution_skipped(tmp_path, monkeypatch, caplog): @@ -217,6 +222,7 @@ def test_story_hook_missing_required_field_skipped(tmp_path, monkeypatch, caplog { "owner": "Team", "action": "move the launch review to Tuesday", + "counterparty": None, "context": "Valid decision.", }, { @@ -282,8 +288,31 @@ def test_story_hook_resolves_entities(tmp_path, monkeypatch): { "owner": "Mina Lee", "action": "move the launch review to Tuesday", + "counterparty": "Ravi", "context": "Valid decision.", - } + }, + { + "owner": "Team", + "action": "keep the review owner unchanged", + "counterparty": None, + "context": "Decision without a counterparty.", + }, + ], + relations=[ + { + "from": "Mina", + "to": "Ravi", + "kind": "works-with", + "note": "", + "quote": "Mina and Ravi will handle the deck.", + }, + { + "from": "Mina", + "to": "Nobody Visible", + "kind": "knows", + "note": "Mina mentioned knowing someone outside the tracked entities.", + "quote": None, + }, ], ), _context(tmp_path), @@ -297,10 +326,64 @@ def test_story_hook_resolves_entities(tmp_path, monkeypatch): assert record["closures"][0]["owner_entity_id"] == "ravi_shah" assert record["closures"][0]["counterparty_entity_id"] == "mina_lee" assert record["decisions"][0]["owner_entity_id"] == "mina_lee" + assert record["decisions"][0]["counterparty"] == "Ravi" + assert record["decisions"][0]["counterparty_entity_id"] == "ravi_shah" + assert record["decisions"][1]["counterparty"] is None + assert record["decisions"][1]["counterparty_entity_id"] is None + assert record["relations"][0]["from_entity_id"] == "mina_lee" + assert record["relations"][0]["to_entity_id"] == "ravi_shah" + assert record["relations"][1]["to"] == "Nobody Visible" + assert record["relations"][1]["to_entity_id"] is None + assert record["relations"][1]["note"] == ( + "Mina mentioned knowing someone outside the tracked entities." + ) + assert record["relations"][1]["quote"] is None assert record["commitments"][0]["owner"] == "Mina" assert record["closures"][0]["counterparty"] == "Mina" +def test_story_hook_skips_invalid_relations(tmp_path, monkeypatch, caplog): + from solstone.talent.story import post_process + from solstone.think.activities import append_activity_record + + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + append_activity_record("work", "20260418", _activity_record()) + + post_process( + _valid_result( + relations=[ + { + "from": "Mina", + "to": "Ravi", + "kind": "works-with", + "note": "", + "quote": "They are paired on the deck.", + }, + { + "from": "Mina", + "to": "Ravi", + "kind": "other", + "note": " ", + "quote": "They have a custom relationship.", + }, + { + "from": "Mina", + "to": "Ravi", + "kind": "mentors", + "note": "Mina mentors Ravi.", + "quote": "Mina mentors Ravi.", + }, + ] + ), + _context(tmp_path), + ) + + record = _load_record("work", "20260418") + assert [relation["kind"] for relation in record["relations"]] == ["works-with"] + assert "other kind requires note" in caplog.text + assert "invalid kind 'mentors'" in caplog.text + + def test_story_hook_idempotent_rerun(tmp_path, monkeypatch): from solstone.talent.story import post_process from solstone.think.activities import append_activity_record @@ -308,9 +391,23 @@ def test_story_hook_idempotent_rerun(tmp_path, monkeypatch): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) append_activity_record("work", "20260418", _activity_record()) - post_process(_valid_result(), _context(tmp_path)) + post_process( + _valid_result( + relations=[ + { + "from": "Mina", + "to": "Ravi", + "kind": "works-with", + "note": "", + "quote": "Mina and Ravi owned the first pass.", + } + ] + ), + _context(tmp_path), + ) first = _load_record("work", "20260418") assert len(first["edits"]) == 1 + assert first["relations"][0]["kind"] == "works-with" post_process( _valid_result( @@ -322,9 +419,19 @@ def test_story_hook_idempotent_rerun(tmp_path, monkeypatch): { "owner": "Lead", "action": "ship the patch on Wednesday", + "counterparty": None, "context": "The second pass reached a more specific plan.", } ], + relations=[ + { + "from": "Lead", + "to": "Patch", + "kind": "created", + "note": "", + "quote": None, + } + ], ), _context(tmp_path), ) @@ -342,11 +449,31 @@ def test_story_hook_idempotent_rerun(tmp_path, monkeypatch): { "owner": "Lead", "action": "ship the patch on Wednesday", + "counterparty": None, "context": "The second pass reached a more specific plan.", "owner_entity_id": None, + "counterparty_entity_id": None, + } + ] + assert second["relations"] == [ + { + "from": "Lead", + "to": "Patch", + "kind": "created", + "note": "", + "quote": None, + "from_entity_id": None, + "to_entity_id": None, } ] assert len(second["edits"]) == 2 + assert second["edits"][-1]["fields"] == [ + "story", + "commitments", + "closures", + "decisions", + "relations", + ] def test_story_hook_normalizes_topics(tmp_path, monkeypatch): diff --git a/tests/test_story_schema.py b/tests/test_story_schema.py index 1c912774b..9903a4084 100644 --- a/tests/test_story_schema.py +++ b/tests/test_story_schema.py @@ -6,7 +6,8 @@ from pathlib import Path from jsonschema import Draft202012Validator -from solstone.talent.story import ALLOWED_RESOLUTIONS +from solstone.talent.story import ALLOWED_RELATION_KINDS, ALLOWED_RESOLUTIONS +from solstone.think.schema_bounds import unbounded_nodes from solstone.think.talent import get_talent from tests.test_story_hook import _valid_result @@ -41,6 +42,7 @@ def test_story_schema_mirrors_hook_requirements(): "commitments", "closures", "decisions", + "relations", } assert set(properties["commitments"]["items"]["required"]) == { "owner", @@ -63,8 +65,20 @@ def test_story_schema_mirrors_hook_requirements(): assert set(properties["decisions"]["items"]["required"]) == { "owner", "action", + "counterparty", "context", } + assert set(properties["relations"]["items"]["required"]) == { + "from", + "to", + "kind", + "note", + "quote", + } + assert ( + set(properties["relations"]["items"]["properties"]["kind"]["enum"]) + == ALLOWED_RELATION_KINDS + ) def test_story_hook_fixtures_validate_against_schema(): @@ -74,3 +88,26 @@ def test_story_hook_fixtures_validate_against_schema(): errors = list(Draft202012Validator(schema).iter_errors(payload)) assert errors == [] + + +def test_story_schema_rejects_unknown_relation_kind(): + schema = _load_story_schema() + payload = json.loads( + _valid_result( + relations=[ + { + "from": "Mina", + "to": "Ravi", + "kind": "mentors", + "note": "Mina mentors Ravi.", + "quote": "Mina mentors Ravi.", + } + ] + ) + ) + + assert not Draft202012Validator(schema).is_valid(payload) + + +def test_story_schema_has_no_unbounded_nodes(): + assert unbounded_nodes(_load_story_schema()) == [] diff --git a/tests/test_surfaces_ledger.py b/tests/test_surfaces_ledger.py index 4a6abda32..2b5c2ced7 100644 --- a/tests/test_surfaces_ledger.py +++ b/tests/test_surfaces_ledger.py @@ -145,6 +145,7 @@ def _write_story_activity( commitments=commitments or [], closures=closures or [], decisions=decisions or [], + relations=[], actor="story", ) diff --git a/tests/test_surfaces_profile.py b/tests/test_surfaces_profile.py index ba45f7d80..ed2244036 100644 --- a/tests/test_surfaces_profile.py +++ b/tests/test_surfaces_profile.py @@ -213,6 +213,7 @@ def _write_story_activity( commitments=commitments or [], closures=closures or [], decisions=decisions or [], + relations=[], actor="story", ) -- 2.51.2