From 7586e768ca66735cbb229d335d9ac1b005d7f86b Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Thu, 9 Jul 2026 06:04:55 -0600 Subject: [PATCH] feat(talent): schema-bounded JSON for morning briefing morning_briefing now emits schema-bounded JSON via the new morning_briefing.schema.json file. The schema is fully bounded and does not require any check_schema_bounds.py ALLOWLIST entry. A day-level formatter registry entry renders the JSON output back to markdown for the search index and the identity CLI, preserving the five ## headings and the morning_briefing indexer agent string that entities/context.py's NOISE_AGENTS depends on. morning_briefing_path() previously hard-coded output_format="md", the single hard-coded lie in the path authority for this talent. It now derives the extension from the talent's declared output format through a cached _briefing_output_format() helper, so briefing_cmd's day_dirs() loop does not re-read the talent config once per day. The consumer migration is a clean break, not a compatibility shim. _load_briefing_md and _BRIEFING_SECTIONS are deleted, and Home, voice, and the identity CLI all read through the new shared solstone/think/briefing.py loader and renderers. This incidentally removes the solstone.think.voice.tools -> solstone.apps.home.routes import, a think-to-apps dependency inversion logged as a follow-up during recon. A post_process hook was considered and declined. The empirical finding was that _run_post_hooks runs before _write_output on both talents.py:1375 and talents.py:1673, while _schema_validation_clean at talents.py:1087 rejects on the provider's gen_result["schema_validation"] before re-validating the post-hooked string, so a post-hook cannot repair a required key the model omitted. Because every schema root is additionalProperties: false, a post-hook cannot inject an undeclared key either. OpenAI strict mode forbids optional properties, so "declared but not required" is unavailable; the model must emit the key regardless, leaving the hook's only residual value as overwriting values the model was already forced to produce. talents.py:1822 also pops template_vars off modifications and never stores it on config, so a post-hook cannot even see the pre-hook packet without inventing a new pre-hook return-key convention that exists nowhere else in the repo. Instead, the pre-hook collapses generated, model, source_counts, source_gaps, and coverage_preamble into one $briefing_metadata template var built with json.dumps(..., indent=2), retiring the two-space YAML fragment _render_source_counts used to emit and removing the JSON-escaping hazard for the preamble prose. date is dropped entirely because the path encodes the day. Only lowercase $briefing_metadata is safe in the prompt. _apply_template_vars at talents.py:852-861 registers a .capitalize() alias that would lowercase the rest of the JSON string, so the prompt uses the lowercase token only and a test pins that constraint. tests/eval_schemas.py now runs schema_path cases through hydrate_runtime_enums so the eval validates the runtime schema the provider actually receives. Without this, the reading[].facet __RUNTIME_FACETS__ sentinel would force the eval model to emit that literal string and the new golden case could never pass. reading[].facet carries both the sentinel enum and a maxLength because hydrate_runtime_enums drops the enum key on a zero-facet journal and the string would otherwise ship to the provider unbounded. _strip_outer_markdown_fence runs only for output_format == "md" at talents.py:1658-1668, so JSON output is not fence-stripped. The prompt's explicit no-fence instruction is the only mitigation, matching every other output: json talent. This is not a new condition, but it is recorded here so the next reader does not assume the runner strips JSON fences. max_output_tokens: 8192 now matches documents; the talent previously inherited the 8192 * 6 default silently. degradation_check: true is retained for the same reason 9654c0d0 retained it on documents: four other json-output talents set it, so briefing is not special. A near-empty briefing sits under the MIN_OUTPUT_TOKENS = 300 floor and could be falsely flagged degraded on a sparse day; this remains a follow-up risk. The two API baselines flip legitimately. tests/baselines/api/sol/talents-day.json moves output_format from md to json. tests/baselines/api/stats/stats.json moves output from md to json and additionally gains schema and max_output_tokens because that API exposes raw prompt frontmatter and every other talent with those fields already shows them there. Nothing else in either file moved. Behavior is otherwise held constant across the web contract. briefing_sections stays a dict of markdown strings and briefing_needs_deduped stays an array of strings, so home.js and workspace.html are untouched. Needs items are now objects carrying text plus a sol:// source_id, which needs_dedup_key resolves by identity; inline [label](sol://...) links stay inside text so the existing parse_sol_sources fallback keeps working. _briefing_summary now counts meetings from structured your_day items with a non-empty time rather than regexing - **HH:MM**, and produces a byte-identical string for equivalent content. One deliberate behavior change remains: reading links now resolve to facet=, for example facet=work, where the model previously wrote **Work** and produced facet=Work. No test pinned the old casing, and the slug is what /app/search expects. The local Qwen provider is not installed on this machine, so the eval was not executed and no pass is claimed. The command below was run with this output. ``` $ make eval-schemas; echo "exit=$?" .venv/bin/python tests/eval_schemas.py Local schema eval requires the bundled local provider. Run `journal install-provider local`, then start it with `journal start` (or `journal service start` for an installed service). make: *** [Makefile:234: eval-schemas] Error 2 exit=2 ``` Follow-ups deliberately not done: pulse.schema.json still has 5 unbounded nodes and steward.schema.json still has 2, both allowlisted under their "morning_briefing follow-on lode" reason, following the convention that the string names the lode a following lode will bound. solstone/apps/speakers/status.py's meetings_files counter still has the same wrong-path bug 9654c0d0 fixed for screen_files. docs/design/yesterdays-processing-card.md and docs/design/voice-server.md still describe frontmatter-based briefing reads in narrative prose beyond the one-line path references corrected here. No confidence field was added to briefing items; evidence strength stays prose hedging because a field would change rendering. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/design/voice-server.md | 10 +- docs/design/yesterdays-processing-card.md | 6 +- solstone/apps/home/routes.py | 124 +++---------- solstone/talent/morning_briefing.md | 60 +++--- solstone/talent/morning_briefing.py | 36 ++-- solstone/talent/morning_briefing.schema.json | 171 ++++++++++++++++++ solstone/talent/patterns/provenance.md | 2 +- solstone/think/briefing.py | 153 ++++++++++++++++ solstone/think/formatters.py | 5 + solstone/think/talent.py | 20 +- solstone/think/talent_outputs.py | 16 ++ solstone/think/tools/sol.py | 16 +- solstone/think/voice/brain.py | 2 +- solstone/think/voice/tools.py | 12 +- tests/baselines/api/sol/talents-day.json | 2 +- tests/baselines/api/stats/stats.json | 4 +- tests/eval_schemas.py | 5 +- .../20260327/talents/morning_briefing.json | 67 +++++++ .../20260327/talents/morning_briefing.md | 45 ----- tests/fixtures/schema_eval/cases.jsonl | 1 + tests/test_formatters.py | 79 ++++++++ tests/test_home_routes.py | 81 +++++++-- tests/test_home_yesterdays_processing.py | 93 ++++++---- tests/test_morning_briefing_pre_hook.py | 17 +- ...test_morning_briefing_steward_migration.py | 12 +- tests/test_schema_prep.py | 23 +++ tests/test_sol_call_identity_briefing.py | 33 +++- tests/test_talent_output_schemas.py | 26 +++ tests/test_voice_tools.py | 3 + 29 files changed, 827 insertions(+), 297 deletions(-) create mode 100644 solstone/talent/morning_briefing.schema.json create mode 100644 solstone/think/briefing.py create mode 100644 tests/fixtures/journal/chronicle/20260327/talents/morning_briefing.json delete mode 100644 tests/fixtures/journal/chronicle/20260327/talents/morning_briefing.md diff --git a/docs/design/voice-server.md b/docs/design/voice-server.md index 54d694a41..f74abbbbd 100644 --- a/docs/design/voice-server.md +++ b/docs/design/voice-server.md @@ -229,7 +229,7 @@ Rules that apply to every tool: | `commitments.list` | `{"state": "open"|"closed"|"dropped"|null, "facet": "|null", "limit": 20|null}` | `{"commitments": [{"id": "", "owner": "", "action": "", "counterparty": "", "state": "", "context": "", "day_opened": "YYYY-MM-DD", "day_closed": "YYYY-MM-DD"?, "resolution": ""?}]}` | No nav hint | `think.surfaces.ledger.list(state=..., facets=[facet] if facet else None, top=limit or 20)`. Convert each `LedgerItem` dataclass to a dict, drop `sources`, and derive `day_*` strings from the millisecond timestamps. `resolution` is best-effort only: set it to `"dropped"` when `item.state == "dropped"`, otherwise omit because the ledger surface does not expose the close-note resolution (`solstone/think/surfaces/ledger.py:441-487`, `solstone/think/surfaces/types.py:16-32`) | `{"error": "invalid state"}` | | `commitments.complete` | `{"commitment_id": "lg_...", "resolution": "done"|"sent"|"signed"|"dropped"|"deferred"}` | `{"ok": true, "commitment": {"id": "...", "owner": "...", "action": "...", "counterparty": "...", "state": "...", "context": "...", "day_opened": "YYYY-MM-DD", "day_closed": "YYYY-MM-DD"?, "resolution": ""}}` | No nav hint | Validate `resolution`. Map `dropped -> as_state="dropped", note="resolution: dropped"`. Map `done|sent|signed|deferred -> as_state="closed", note="resolution: "`. Call `think.surfaces.ledger.close(...)`, catch `KeyError`, and shape the returned `LedgerItem` as above (`solstone/think/surfaces/ledger.py:497-529`, `solstone/think/activities.py:1156-1207`) | `{"error": "invalid resolution"}` or `{"error": "not found"}` | | `calendar.today` | `{}` | `{"date": "YYYY-MM-DD", "events": [{"time": "HH:MM", "title": "", "attendees": ["<name>"], "location": "<string>", "prep_notes": "<string>"}], "_nav_target": "today"}` | Always emit `_nav_target` | `think.activities.load_activity_records(facet, day)` across all enabled facets, filtered to `source == "anticipated"` using the same participation parsing pattern Home uses today (`solstone/apps/home/routes.py:305-337`, `solstone/think/activities.py:877-890`) | `{"error": "today unavailable"}` only on unexpected failures; normal empty day is `{"date": "...", "events": [], "_nav_target": "today"}` | -| `briefing.get` | `{}` | `{"date": "YYYY-MM-DD", "facet": "identity", "text": "<spoken-English body>", "highlights": ["...", "..."], "_nav_target": "today"}` or `{"error": "no briefing today yet"}` | Emit `_nav_target` only when a fresh briefing exists | Reuse `solstone/apps/home/routes.py::_load_briefing_md(today)` exactly. If `metadata.date != today`, return the error object. `text` is a plain-text join of the loaded sections; `highlights` comes from `needs_attention` bullets first, then falls back to the first three bullets across the other sections (`solstone/apps/home/routes.py:149-198`) | `{"error": "no briefing today yet"}` | +| `briefing.get` | `{}` | `{"date": "YYYY-MM-DD", "facet": "identity", "text": "<spoken-English body>", "highlights": ["...", "..."], "_nav_target": "today"}` or `{"error": "no briefing today yet"}` | Emit `_nav_target` only when a fresh briefing exists | Reuse `solstone.think.briefing.load_briefing(today)` and `render_briefing_sections(...)` exactly. `None` returns the error object. `text` is a plain-text join of the loaded sections; `highlights` comes from `needs_attention` items first, then falls back to the first three bullets across the other sections | `{"error": "no briefing today yet"}` | | `observer.start_listening` | `{"mode": "meeting"|"voice_memo"}` | `{"status": "ack", "mode": "<mode>", "note": "wave-4 observer not yet wired"}` | No nav hint | No data dependency in Wave 2. Log the requested mode at INFO and return the stub acknowledgement. | `{"error": "invalid mode"}` | Implementation notes by tool: @@ -242,7 +242,7 @@ Implementation notes by tool: - `entities.recent_with` sorts interactions descending by activity timestamp and truncates to a small spoken-friendly limit, default 10. - `commitments.list` and `commitments.complete` must strip `sources` before returning anything model-facing. - `calendar.today.location` and `calendar.today.prep_notes` default to `""` because current anticipated activity rows do not guarantee either field. -- `briefing.get.facet` is the literal string `"identity"` as a fixed spoken-context label, not a facet-scoped talent output; the briefing itself is read from `chronicle/<day>/talents/morning_briefing.md`. +- `briefing.get.facet` is the literal string `"identity"` as a fixed spoken-context label, not a facet-scoped talent output; the briefing itself is read from `chronicle/<day>/talents/morning_briefing.json`. ## 6. Brain init prompt (full text) @@ -275,7 +275,7 @@ Before you write the instruction, ingest the current context: - Read the active entities that matter right now. - Read the open commitments. - Read today's calendar and anticipated activities. -- Read today's morning briefing at chronicle/<today>/talents/morning_briefing.md if it exists. +- Read today's morning briefing at chronicle/<today>/talents/morning_briefing.json if it exists. Then write one system instruction that does all of the following: - Establish who {agent_name} is and how the voice should speak. @@ -417,7 +417,7 @@ Uniform fixture-date strategy: - Add one narrow helper in `think.voice.tools`, for example `_today() -> datetime.date` plus a formatter helper in the same module. - All date-sensitive voice tools (`journal.get_day`, `journal.search` day-window math, `calendar.today`, `briefing.get`) use that helper. - Tests monkeypatch the helper to the fixture briefing date or another explicit date instead of rewriting shared fixture files. -- This keeps the shared fixture journal stable and avoids clock-driven flakes from `tests/fixtures/journal/chronicle/20260327/talents/morning_briefing.md` being dated `20260327`. +- This keeps the shared fixture journal stable and avoids clock-driven flakes from `tests/fixtures/journal/chronicle/20260327/talents/morning_briefing.json` being dated `20260327`. Per-file plan: @@ -456,7 +456,7 @@ Journal-data rule: - Brain-not-ready behavior: this design treats the bridge contract and acceptance list as canonical and returns HTTP 503 from `/api/voice/session` after a 10-second wait, instead of using the older static fallback instruction path from the scope prose. - Routing location: this design uses a root-level `solstone/convey/voice.py` blueprint, not `solstone/apps/voice/`, because the feature is a root API and the app shell assumes `/app/<name>` plus `workspace.html`. -- Briefing source path: `_load_briefing_md(...)` reads the canonical `chronicle/<day>/talents/morning_briefing.md` talent output. (Updated 2026-07-02: an earlier revision read the phantom identity-dir briefing file; retired in the H1 lode.) +- Briefing source path: `solstone.think.briefing.load_briefing(...)` reads the canonical `chronicle/<day>/talents/morning_briefing.json` talent output. (Updated 2026-07-02: an earlier revision read the phantom identity-dir briefing file; retired in the H1 lode.) - Commitments resolution mapping: this design maps `done|sent|signed|deferred -> as_state="closed"` and `dropped -> as_state="dropped"` because `think.surfaces.ledger.close(...)` only accepts `closed|dropped`. - OpenAI key sourcing: this design uses `config.voice.openai_api_key` in `journal/config/journal.json` first, then `OPENAI_API_KEY`, and does not add `journal/config/openai.json`. - `ask_sol` clause: this design removes it from the brain init prompt and does not add a 10th tool to the manifest. diff --git a/docs/design/yesterdays-processing-card.md b/docs/design/yesterdays-processing-card.md index d73ec1aa9..ec45d3f62 100644 --- a/docs/design/yesterdays-processing-card.md +++ b/docs/design/yesterdays-processing-card.md @@ -31,7 +31,7 @@ Internal helpers called only by `_summarize_yesterday_processing`: Reads `stats_data["heatmap_data"]["hours"]`, keeps the top 3 non-zero hours, sorts by minutes desc then hour asc. - `_briefing_freshness(today: str) -> dict` - Reads `chronicle/<day>/talents/morning_briefing.md` with local `frontmatter.load`. Valid only when frontmatter has `type: morning_briefing` and `date` (which may be a YAML int) equal to `today`. `generated` is used only for the display label. + Reads `chronicle/<day>/talents/morning_briefing.json`. Valid only when the JSON root has the required morning-briefing keys. `metadata.generated` is used only for the display label. - `_newsletter_attempts_from_think_logs(yesterday: str) -> tuple[int, int]` Option A helper from section 3. Counts successful facet newsletters from files plus failed facet newsletter attempts from think logs. @@ -339,8 +339,8 @@ Fixture plan: Supporting non-chronicle fixture: -- `tests/fixtures/journal/chronicle/20260327/talents/morning_briefing.md` - Valid morning-briefing frontmatter fixture for healthy cases. +- `tests/fixtures/journal/chronicle/20260327/talents/morning_briefing.json` + Valid morning-briefing JSON fixture for healthy cases. Tests that need missing/invalid frontmatter can overwrite or delete it in `tmp_path`. Fixture minimization rule: diff --git a/solstone/apps/home/routes.py b/solstone/apps/home/routes.py index b7598f442..2cef7e210 100644 --- a/solstone/apps/home/routes.py +++ b/solstone/apps/home/routes.py @@ -14,7 +14,6 @@ from urllib.parse import quote logger = logging.getLogger(__name__) -import frontmatter from flask import Blueprint, current_app, jsonify from solstone.apps.home.health_glance import build_health_glance @@ -24,6 +23,12 @@ from solstone.convey.bridge import get_cached_state from solstone.convey.shell_data import _resolve_attention from solstone.convey.utils import DATE_RE, format_date, relative_time from solstone.think.awareness import get_current +from solstone.think.briefing import ( + briefing_meeting_count, + briefing_needs_items, + load_briefing, + render_briefing_sections, +) from solstone.think.capture_health import get_capture_health from solstone.think.day_accumulator import read_latest from solstone.think.facets import get_enabled_facets, get_facets @@ -37,15 +42,6 @@ BRIEFING_MORNING_END_HOUR = 10 BRIEFING_LATENESS_THRESHOLD_HOURS = 2 BRIEFING_EOD_HOUR = 20 -# Section heading -> key mapping -_BRIEFING_SECTIONS = { - "your day": "your_day", - "yesterday": "yesterday", - "needs attention": "needs_attention", - "forward look": "forward_look", - "reading": "reading", -} - home_bp = Blueprint( "app:home", __name__, @@ -148,60 +144,6 @@ def _load_pulse_narrative(today: str) -> tuple[str | None, str | None, list[str] return None, None, [] -def _load_briefing_md( - today: str | None = None, -) -> tuple[dict[str, str], dict | None, list[str]]: - """Load today's briefing.md sections and needs_attention bullets.""" - try: - today = today or _today() - briefing_path = morning_briefing_path(today) - if not briefing_path.exists(): - return {}, None, [] - - post = frontmatter.load(str(briefing_path)) - metadata = post.metadata - if metadata.get("type") != "morning_briefing": - return {}, None, [] - if str(metadata.get("date")) != today: - return {}, None, [] - - sections = {} - current_key = None - current_lines: list[str] = [] - - def flush_section() -> None: - nonlocal current_key, current_lines - if not current_key: - current_lines = [] - return - body = "\n".join(current_lines).strip() - if body: - sections[current_key] = body - current_lines = [] - - for line in post.content.splitlines(): - if line.startswith("## "): - flush_section() - heading = line[3:].strip().lower() - current_key = _BRIEFING_SECTIONS.get(heading) - continue - if current_key: - current_lines.append(line) - flush_section() - - needs_attention_items = [] - needs_body = sections.get("needs_attention", "") - for line in needs_body.splitlines(): - stripped = line.strip() - if stripped.startswith("- "): - needs_attention_items.append(stripped[2:].strip()) - - return sections, metadata, needs_attention_items - except Exception: - logger.warning("home: failed to load briefing.md", exc_info=True) - return {}, None, [] - - def _compute_briefing_phase( segment_count: int, hour: int, briefing_exists: bool ) -> str: @@ -233,18 +175,11 @@ def _briefing_lateness_state(now: datetime, phase: str) -> dict[str, Any]: return {"late": is_late, "late_hours": late_hours if is_late else 0} -def _briefing_summary(sections: dict[str, str], needs_count: int) -> str: +def _briefing_summary( + briefing: dict | None, sections: dict[str, str], needs_count: int +) -> str: """Generate a short collapsed summary for the briefing card.""" - meeting_count = 0 - your_day = sections.get("your_day", "") - for line in your_day.splitlines(): - stripped = line.strip() - if stripped.startswith("- ") and "**" in stripped: - after_bullet = stripped[2:] - if after_bullet.startswith("**") and after_bullet.count("**") >= 2: - time_part = after_bullet.split("**", 2)[1] - if len(time_part) == 5 and time_part[2] == ":": - meeting_count += 1 + meeting_count = briefing_meeting_count(briefing or {}) if meeting_count or needs_count: meeting_label = "meeting" if meeting_count == 1 else "meetings" @@ -433,31 +368,23 @@ def _briefing_freshness(today: str) -> dict[str, Any]: if not briefing_path.exists(): return {"exists": False, "valid": False, "generated_label": None} - try: - metadata = frontmatter.load(str(briefing_path)).metadata - except Exception: - logger.warning("home: failed to load briefing freshness", exc_info=True) + briefing = load_briefing(today) + if briefing is None: return {"exists": True, "valid": False, "generated_label": None} - valid = ( - metadata.get("type") == "morning_briefing" - and str(metadata.get("date")) == today - ) - generated_label = None + metadata = ( + briefing.get("metadata") if isinstance(briefing.get("metadata"), dict) else {} + ) generated = metadata.get("generated") if generated is not None: try: - generated_dt = ( - datetime.fromisoformat(generated) - if isinstance(generated, str) - else generated - ) + generated_dt = datetime.fromisoformat(str(generated)) generated_label = generated_dt.astimezone().strftime("%-I:%M%p").lower() except Exception: generated_label = None - return {"exists": True, "valid": valid, "generated_label": generated_label} + return {"exists": True, "valid": True, "generated_label": generated_label} def _newsletter_attempts_from_think_logs(yesterday: str) -> tuple[int, int]: @@ -894,7 +821,10 @@ def _build_pulse_context() -> dict[str, Any]: ) # Briefing card - briefing_sections, briefing_meta, briefing_needs = _load_briefing_md(today) + briefing = load_briefing(today) + briefing_sections = render_briefing_sections(briefing) if briefing else {} + briefing_meta = briefing.get("metadata") if briefing else None + briefing_needs = briefing_needs_items(briefing) if briefing else [] briefing_exists = bool(briefing_sections) briefing_phase = _compute_briefing_phase(segment_count, now.hour, briefing_exists) briefing_lateness = _briefing_lateness_state(now, briefing_phase) @@ -971,8 +901,9 @@ def _build_pulse_context() -> dict[str, Any]: briefing_summary = None if briefing_phase == "active": briefing_summary = _briefing_summary( - briefing_sections, len(briefing_needs_deduped) + briefing, briefing_sections, len(briefing_needs_deduped) ) + briefing_needs_deduped_text = [item["text"] for item in briefing_needs_deduped] pipeline_status = read_steward_health() if pipeline_status is not None: @@ -1016,7 +947,7 @@ def _build_pulse_context() -> dict[str, Any]: "briefing_lateness": briefing_lateness, "briefing_exists": briefing_exists, "briefing_summary": briefing_summary, - "briefing_needs_deduped": briefing_needs_deduped, + "briefing_needs_deduped": briefing_needs_deduped_text, "briefing_needs_shared_count": briefing_needs_shared_count, "briefing_needs_badge": briefing_needs_badge, "latest_weekly_reflection": latest_weekly_reflection, @@ -1058,13 +989,6 @@ def api_briefing(): """Briefing-specific JSON for WebSocket-triggered refresh.""" ctx = _build_pulse_context() meta = ctx.get("briefing_meta") - if meta: - generated = meta.get("generated") - if hasattr(generated, "isoformat"): - meta = dict(meta) - meta["generated"] = generated.isoformat() - if "date" in meta: - meta["date"] = str(meta["date"]) return jsonify( { "exists": ctx["briefing_exists"], diff --git a/solstone/talent/morning_briefing.md b/solstone/talent/morning_briefing.md index ca2357929..778cbd3bb 100644 --- a/solstone/talent/morning_briefing.md +++ b/solstone/talent/morning_briefing.md @@ -6,7 +6,9 @@ "color": "#1565c0", "schedule": "daily", "priority": 50, - "output": "md", + "output": "json", + "schema": "morning_briefing.schema.json", + "max_output_tokens": 8192, "degradation_check": true, "hook": {"pre": "morning_briefing"} } @@ -17,38 +19,32 @@ The source packet below is complete. Do not invent data outside the packet. When ## Output Contract -Return only the complete briefing markdown in this exact outer shape: +Return only the JSON object. Do not wrap it in a markdown fence. Do not include prose before or after the object. JSON output is not fence-stripped by the runner; a fence is a hard failure. ``` ---- -type: morning_briefing -date: $day_YYYYMMDD -generated: $generated -model: $model -sources: -$source_counts -gaps: $source_gaps ---- - -$coverage_preamble - -## Your Day -[today's prioritized agenda] - -## Yesterday -[what happened yesterday] - -## Needs Attention -[ranked actions and pipeline gaps] - -## Forward Look -[next seven days] - -## Reading -[facet newsletter links] +{ + "metadata": $briefing_metadata, + "your_day": [ + {"time": "HH:MM or empty string", "text": "today's prioritized agenda item"} + ], + "yesterday": [ + "what happened yesterday" + ], + "needs_attention": [ + {"text": "ranked action or pipeline gap", "source_id": "sol://... or empty string"} + ], + "forward_look": [ + "next seven days" + ], + "reading": [ + {"facet": "facet slug", "summary": "one-line newsletter summary"} + ] +} ``` -Omit any section that has no content. Keep the YAML frontmatter, `sources`, `gaps`, and coverage preamble exactly as injected above. +Copy `metadata` exactly as injected above. Use the lowercase `$briefing_metadata` placeholder only in this prompt; never use the capitalized form because the runner's capitalization alias would corrupt the JSON string. + +Every root key shown above is required. Use empty arrays when a section has no content. ## Source Packet @@ -92,15 +88,15 @@ $decisions **Source attribution.** Attribute high-consequence factual claims to their source using inline parenthetical links with `sol://` URIs when a source URI is present in the packet. Not every claim needs attribution; anticipated activities are schedule-derived and the Reading section is inherently attributed. -**Your Day** - What's ahead today. Lead with anticipated activities in chronological order. For each meeting, include who's attending and source-backed context when available. If no anticipated activities exist, lead with the highest-priority follow-ups or pulse needs. +**Your Day** - What's ahead today. Lead with anticipated activities in chronological order. Put a zero-padded `HH:MM` in `time` when the item has a specific start time; otherwise use `""`. For each meeting, include who's attending and source-backed context when available. If no anticipated activities exist, lead with the highest-priority follow-ups or pulse needs. **Yesterday** - What happened. Draw from facet newsletters, pulse, and decisions. Highlight accomplishments, consequential decisions, and notable interactions. Keep to 3-5 bullets max. Only include if facet newsletters or decisions have content for the analysis day. -**Needs Attention** - Ranked action list. Start with steward health pipeline gaps when the health surface contains needs-attention items. Then include overdue commitments, missed follow-ups, pending follow-ups, and important pulse needs without calendar time blocked. Do not include pipeline gaps when the steward health surface has no needs-attention bullets. +**Needs Attention** - Ranked action list. Start with steward health pipeline gaps when the health surface contains needs-attention items. Then include overdue commitments, missed follow-ups, pending follow-ups, and important pulse needs without calendar time blocked. Do not include pipeline gaps when the steward health surface has no needs-attention bullets. Set `source_id` to the primary source's `sol://` URI when one exists, else `""`. Keep inline `[label](sol://...)` links inside `text`. **Forward Look** - What's coming. Draw from anticipated activity records and upcoming scheduled items in the next seven days. Note preparation needed for upcoming meetings or deadlines. -**Reading** - Links to full facet newsletters for deeper context. List each active facet that has a newsletter for the analysis day, with a brief one-line description of what it covers. +**Reading** - Links to full facet newsletters for deeper context. List each active facet slug that has a newsletter for the analysis day, with a brief one-line description of what it covers. ## Evidence Strength diff --git a/solstone/talent/morning_briefing.py b/solstone/talent/morning_briefing.py index 57e4a6d37..68a37eaac 100644 --- a/solstone/talent/morning_briefing.py +++ b/solstone/talent/morning_briefing.py @@ -115,9 +115,22 @@ def _build_packet( counts["steward_health"] = "present" if health else "missing" counts["segments"] = len(_distinct_result_paths(followup_results, decision_results)) - return { + metadata = { "generated": datetime.now().isoformat(timespec="seconds"), "model": model, + "sources": counts, + "gaps": gaps, + "coverage_preamble": _render_coverage_preamble( + counts, + gaps, + decisions_total=decisions_total, + forward_count=len(anticipated_forward), + followups_total=followups_total, + ), + } + + return { + "briefing_metadata": json.dumps(metadata, indent=2), "active_facets": _render_facets(facets), "facet_newsletters": _render_newsletters(newsletters), "anticipated_today": _render_activities(anticipated_today), @@ -129,15 +142,6 @@ def _build_packet( "health_surface": health or "(missing)", "followups": _render_search_results(followup_results), "decisions": _render_search_results(decision_results), - "source_counts": _render_source_counts(counts), - "source_gaps": json.dumps(gaps), - "coverage_preamble": _render_coverage_preamble( - counts, - gaps, - decisions_total=decisions_total, - forward_count=len(anticipated_forward), - followups_total=followups_total, - ), } @@ -408,18 +412,6 @@ def _distinct_result_paths( return paths -def _render_source_counts(counts: dict[str, int | str]) -> str: - return "\n".join( - [ - f" segments: {counts['segments']}", - f" anticipated_activities: {counts['anticipated_activities']}", - f" facet_newsletters: {counts['facet_newsletters']}", - f" followups: {counts['followups']}", - f" steward_health: {counts['steward_health']}", - ] - ) - - def _render_coverage_preamble( counts: dict[str, int | str], gaps: list[str], diff --git a/solstone/talent/morning_briefing.schema.json b/solstone/talent/morning_briefing.schema.json new file mode 100644 index 000000000..9a6c14e22 --- /dev/null +++ b/solstone/talent/morning_briefing.schema.json @@ -0,0 +1,171 @@ +{ + "type": "object", + "additionalProperties": false, + "required": [ + "metadata", + "your_day", + "yesterday", + "needs_attention", + "forward_look", + "reading" + ], + "properties": { + "metadata": { + "type": "object", + "additionalProperties": false, + "required": [ + "generated", + "model", + "sources", + "gaps", + "coverage_preamble" + ], + "properties": { + "generated": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(Z|[+-]\\d{2}:?\\d{2})?$", + "maxLength": 32 + }, + "model": { + "type": "string", + "maxLength": 120 + }, + "sources": { + "type": "object", + "additionalProperties": false, + "required": [ + "segments", + "anticipated_activities", + "facet_newsletters", + "followups", + "steward_health" + ], + "properties": { + "segments": { + "type": "integer", + "minimum": 0 + }, + "anticipated_activities": { + "type": "integer", + "minimum": 0 + }, + "facet_newsletters": { + "type": "integer", + "minimum": 0 + }, + "followups": { + "type": "integer", + "minimum": 0 + }, + "steward_health": { + "type": "string", + "enum": [ + "present", + "missing" + ] + } + } + }, + "gaps": { + "type": "array", + "maxItems": 20, + "items": { + "type": "string", + "maxLength": 240 + } + }, + "coverage_preamble": { + "type": "string", + "maxLength": 1200 + } + } + }, + "your_day": { + "type": "array", + "maxItems": 16, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "time", + "text" + ], + "properties": { + "time": { + "type": "string", + "pattern": "^$|^([01]\\d|2[0-3]):[0-5]\\d$", + "maxLength": 5 + }, + "text": { + "type": "string", + "maxLength": 700 + } + } + } + }, + "yesterday": { + "type": "array", + "maxItems": 10, + "items": { + "type": "string", + "maxLength": 700 + } + }, + "needs_attention": { + "type": "array", + "maxItems": 12, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "text", + "source_id" + ], + "properties": { + "text": { + "type": "string", + "maxLength": 700 + }, + "source_id": { + "type": "string", + "pattern": "^$|^sol://[^\\s)]+$", + "maxLength": 240 + } + } + } + }, + "forward_look": { + "type": "array", + "maxItems": 16, + "items": { + "type": "string", + "maxLength": 700 + } + }, + "reading": { + "type": "array", + "maxItems": 12, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "facet", + "summary" + ], + "properties": { + "facet": { + "type": "string", + "enum": [ + "__RUNTIME_FACETS__" + ], + "maxLength": 80 + }, + "summary": { + "type": "string", + "maxLength": 700 + } + } + } + } + } +} diff --git a/solstone/talent/patterns/provenance.md b/solstone/talent/patterns/provenance.md index 3ce17d347..56a87d351 100644 --- a/solstone/talent/patterns/provenance.md +++ b/solstone/talent/patterns/provenance.md @@ -2,7 +2,7 @@ How cogitate agents communicate the basis and reliability of their claims. This pattern ensures briefings and reports distinguish between well-sourced facts and inferences. -Canonical implementation: `solstone/talent/morning_briefing.md`. +Canonical implementation: `solstone/talent/morning_briefing.md` emitting `chronicle/<day>/talents/morning_briefing.json`. ## Four Mechanisms diff --git a/solstone/think/briefing.py b/solstone/think/briefing.py new file mode 100644 index 000000000..8d4833eb1 --- /dev/null +++ b/solstone/think/briefing.py @@ -0,0 +1,153 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Shared morning briefing loaders and renderers.""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from solstone.think.talent import morning_briefing_path + +logger = logging.getLogger(__name__) + +ABSENT_TEXT = "Not specified in this document" +SECTION_KEYS = ( + "your_day", + "yesterday", + "needs_attention", + "forward_look", + "reading", +) +REQUIRED_ROOT_KEYS = ("metadata", *SECTION_KEYS) +SECTION_HEADINGS = { + "your_day": "Your Day", + "yesterday": "Yesterday", + "needs_attention": "Needs Attention", + "forward_look": "Forward Look", + "reading": "Reading", +} + + +def load_briefing(day: str) -> dict | None: + """Load a day's JSON morning briefing if it has the required root shape.""" + path = morning_briefing_path(day) + if not path.exists(): + return None + + try: + with path.open(encoding="utf-8") as handle: + briefing = json.load(handle) + except Exception: + logger.warning("failed to load morning briefing JSON %s", path, exc_info=True) + return None + + if not isinstance(briefing, dict): + return None + if any(key not in briefing for key in REQUIRED_ROOT_KEYS): + return None + return briefing + + +def render_briefing_sections(briefing: dict) -> dict[str, str]: + """Render non-empty briefing sections as markdown bullet bodies.""" + sections: dict[str, str] = {} + + your_day_lines = [] + for item in _dict_items(briefing.get("your_day")): + text = _clean(item.get("text")) + if not text: + continue + time = _clean(item.get("time")) + if time: + your_day_lines.append(f"- **{time}** \u2014 {text}") + else: + your_day_lines.append(f"- {text}") + if your_day_lines: + sections["your_day"] = "\n".join(your_day_lines) + + yesterday_lines = [f"- {text}" for text in _string_items(briefing.get("yesterday"))] + if yesterday_lines: + sections["yesterday"] = "\n".join(yesterday_lines) + + needs_lines = [] + for item in briefing_needs_items(briefing): + text = _clean(item.get("text")) + if text: + needs_lines.append(f"- {text}") + if needs_lines: + sections["needs_attention"] = "\n".join(needs_lines) + + forward_lines = [ + f"- {text}" for text in _string_items(briefing.get("forward_look")) + ] + if forward_lines: + sections["forward_look"] = "\n".join(forward_lines) + + reading_lines = [] + for item in _dict_items(briefing.get("reading")): + facet = _clean(item.get("facet")) + summary = _clean(item.get("summary")) + if facet and summary: + reading_lines.append(f"- **{facet}** \u2014 {summary}") + elif facet: + reading_lines.append(f"- **{facet}**") + elif summary: + reading_lines.append(f"- {summary}") + if reading_lines: + sections["reading"] = "\n".join(reading_lines) + + return sections + + +def render_briefing_markdown(briefing: dict) -> str: + """Render a full markdown projection of a morning briefing.""" + sections = render_briefing_sections(briefing) + metadata = ( + briefing.get("metadata") if isinstance(briefing.get("metadata"), dict) else {} + ) + preamble = _clean(metadata.get("coverage_preamble")) + + lines: list[str] = [] + if preamble: + lines.extend(f"> {line}" if line else ">" for line in preamble.splitlines()) + else: + lines.append(f"> {ABSENT_TEXT}") + + for key in SECTION_KEYS: + lines.append("") + lines.append(f"## {SECTION_HEADINGS[key]}") + lines.append("") + lines.append(sections.get(key) or ABSENT_TEXT) + + return "\n".join(lines).strip() + + +def briefing_needs_items(briefing: dict) -> list[dict]: + """Return raw needs_attention item objects from a briefing.""" + return _dict_items(briefing.get("needs_attention")) + + +def briefing_meeting_count(briefing: dict) -> int: + """Count Your Day items with a non-empty time.""" + return sum( + 1 for item in _dict_items(briefing.get("your_day")) if _clean(item.get("time")) + ) + + +def _clean(value: object) -> str: + return str(value or "").strip() + + +def _dict_items(value: Any) -> list[dict]: + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, dict)] + + +def _string_items(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [text for text in (_clean(item) for item in value) if text] diff --git a/solstone/think/formatters.py b/solstone/think/formatters.py index 5baea1606..836b4093f 100644 --- a/solstone/think/formatters.py +++ b/solstone/think/formatters.py @@ -233,6 +233,11 @@ FORMATTERS: dict[str, tuple[str, str, bool]] = { "format_screen_record", True, ), + "*/talents/morning_briefing.json": ( + "solstone.think.talent_outputs", + "format_morning_briefing", + True, + ), "*/talents/*.jsonl": ( "solstone.think.day_accumulator", "format_day_accumulator", diff --git a/solstone/think/talent.py b/solstone/think/talent.py index c7a8be126..97a0d3ccb 100644 --- a/solstone/think/talent.py +++ b/solstone/think/talent.py @@ -23,6 +23,7 @@ import json import logging import os import re +from functools import lru_cache from pathlib import Path from typing import Any, Callable @@ -218,18 +219,29 @@ def get_output_path( return day / "talents" / filename +@lru_cache(maxsize=1) +def _briefing_output_format() -> str: + metadata = _load_prompt_metadata(TALENT_DIR / "morning_briefing.md") + output = metadata.get("output") + if not isinstance(output, str) or output not in {"md", "json"}: + raise ValueError("morning_briefing talent must declare output md or json") + return output + + def morning_briefing_path(day: str) -> Path: """Canonical filesystem path to a day's morning-briefing artifact. Delegates to the shared path authority so the - ``chronicle/<day>/talents/morning_briefing.md`` layout is derived in - exactly one place. Callers pass a ``YYYYMMDD`` day string; no directory - is created (read-verb safe). + ``chronicle/<day>/talents/morning_briefing.json`` layout is derived from + the talent's declared output format in exactly one place. Callers pass a + ``YYYYMMDD`` day string; no directory is created (read-verb safe). """ from solstone.think.utils import day_path return get_output_path( - day_path(day, create=False), "morning_briefing", output_format="md" + day_path(day, create=False), + "morning_briefing", + output_format=_briefing_output_format(), ) diff --git a/solstone/think/talent_outputs.py b/solstone/think/talent_outputs.py index 5d46fe118..b9d6291ba 100644 --- a/solstone/think/talent_outputs.py +++ b/solstone/think/talent_outputs.py @@ -11,6 +11,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Any +from solstone.think.briefing import render_briefing_markdown from solstone.think.formatters import format_file, get_formatter from solstone.think.utils import get_journal, journal_relative_path @@ -236,6 +237,21 @@ def format_screen_record( return [{"markdown": markdown, "timestamp": 0, "source": record}], meta +def format_morning_briefing( + entries: list[dict[str, Any]], + context: dict[str, Any] | None = None, +) -> tuple[list[dict[str, Any]], dict[str, Any]]: + """Render the structured Morning Briefing talent output.""" + _ = context + meta = {"indexer": {"agent": "morning_briefing"}} + briefing = _first_object(entries) + if briefing is None: + return [], meta + + markdown = render_briefing_markdown(briefing) + return [{"markdown": markdown, "timestamp": 0, "source": briefing}], meta + + def _render_json_projection(path: Path) -> str | None: try: chunks, _meta = format_file(path) diff --git a/solstone/think/tools/sol.py b/solstone/think/tools/sol.py index fb01bf269..445cca759 100644 --- a/solstone/think/tools/sol.py +++ b/solstone/think/tools/sol.py @@ -5,7 +5,7 @@ Provides read and write access to ``{journal}/identity/partner.md`` and read access to sol's health surface. Also provides read access to the morning briefing at -``{journal}/YYYYMMDD/talents/morning_briefing.md``. +``{journal}/YYYYMMDD/talents/morning_briefing.json``. Top-level ``journal identity`` command. """ @@ -17,6 +17,7 @@ from pathlib import Path import typer +from solstone.think.briefing import load_briefing, render_briefing_markdown from solstone.think.cortex_client import ( CortexNotClaimed, CortexSpawnUnavailable, @@ -271,20 +272,23 @@ def health_cmd( def briefing_cmd( day: str | None = typer.Option(None, "--day", "-d", help="Specific day YYYYMMDD."), ) -> None: - """Read the morning briefing from YYYYMMDD/talents/morning_briefing.md.""" + """Read the morning briefing from YYYYMMDD/talents/morning_briefing.json.""" if day: - path = morning_briefing_path(day) - if not path.exists(): + briefing = load_briefing(day) + if briefing is None: typer.echo("No briefing found.", err=True) raise typer.Exit(1) - typer.echo(path.read_text(encoding="utf-8")) + typer.echo(render_briefing_markdown(briefing)) return # No day specified — find most recent for day in sorted(day_dirs().keys(), reverse=True): briefing = morning_briefing_path(day) if briefing.exists() and briefing.stat().st_size > 0: - typer.echo(briefing.read_text(encoding="utf-8")) + data = load_briefing(day) + if data is None: + continue + typer.echo(render_briefing_markdown(data)) return typer.echo("No briefing found.", err=True) diff --git a/solstone/think/voice/brain.py b/solstone/think/voice/brain.py index 251d3222b..af329632d 100644 --- a/solstone/think/voice/brain.py +++ b/solstone/think/voice/brain.py @@ -46,7 +46,7 @@ Before you write the instruction, ingest the current context: - Read the active entities that matter right now. - Read the open commitments. - Read today's calendar and anticipated activities. -- Read today's morning briefing at chronicle/<today>/talents/morning_briefing.md if it exists. +- Read today's morning briefing at chronicle/<today>/talents/morning_briefing.json if it exists. Then write one system instruction that does all of the following: - Establish who {agent_name} is and how the voice should speak. diff --git a/solstone/think/voice/tools.py b/solstone/think/voice/tools.py index 11f5a574f..95f151475 100644 --- a/solstone/think/voice/tools.py +++ b/solstone/think/voice/tools.py @@ -15,8 +15,12 @@ from typing import Any, Callable from urllib.parse import quote_plus from solstone.apps.entities.routes import _build_facet_relationships -from solstone.apps.home.routes import _load_briefing_md from solstone.think.activities import load_activity_records +from solstone.think.briefing import ( + briefing_needs_items, + load_briefing, + render_briefing_sections, +) from solstone.think.cluster import cluster_segments, scan_day from solstone.think.entities.journal import load_journal_entity from solstone.think.facets import get_facets @@ -682,9 +686,11 @@ def _briefing_highlights( def handle_briefing_get(payload: dict[str, Any], app: Any) -> dict[str, Any]: del payload, app internal_day = _today_internal() - sections, metadata, needs_attention_items = _load_briefing_md(internal_day) - if not metadata or str(metadata.get("date")) != internal_day: + briefing = load_briefing(internal_day) + if not briefing: return {"error": "no briefing today yet"} + sections = render_briefing_sections(briefing) + needs_attention_items = [item["text"] for item in briefing_needs_items(briefing)] return { "date": _format_day_external(internal_day), "facet": "identity", diff --git a/tests/baselines/api/sol/talents-day.json b/tests/baselines/api/sol/talents-day.json index cf8c3794e..ef2a579ee 100644 --- a/tests/baselines/api/sol/talents-day.json +++ b/tests/baselines/api/sol/talents-day.json @@ -175,7 +175,7 @@ "color": "#1565c0", "description": "Synthesizes all daily agent outputs into a structured five-section morning briefing", "multi_facet": false, - "output_format": "md", + "output_format": "json", "schedule": "daily", "source": "system", "title": "Morning Briefing", diff --git a/tests/baselines/api/stats/stats.json b/tests/baselines/api/stats/stats.json index 3da34b364..302e3afd5 100644 --- a/tests/baselines/api/stats/stats.json +++ b/tests/baselines/api/stats/stats.json @@ -247,11 +247,13 @@ "hook": { "pre": "morning_briefing" }, + "max_output_tokens": 8192, "mtime": 0, - "output": "md", + "output": "json", "path": "<PROJECT>/solstone/talent/morning_briefing.md", "priority": 50, "schedule": "daily", + "schema": "morning_briefing.schema.json", "source": "system", "title": "Morning Briefing", "type": "generate" diff --git a/tests/eval_schemas.py b/tests/eval_schemas.py index 0b7dd220b..16a534cce 100644 --- a/tests/eval_schemas.py +++ b/tests/eval_schemas.py @@ -23,6 +23,7 @@ from solstone.think.schema_eval import ( # noqa: E402 content_preservation, schema_validity, ) +from solstone.think.talent import hydrate_runtime_enums # noqa: E402 DEFAULT_CASES = ROOT / "tests" / "fixtures" / "schema_eval" / "cases.jsonl" DEFAULT_OUT = ROOT / "tmp" / "schema-eval" @@ -49,7 +50,9 @@ def load_cases(path: Path) -> list[dict[str, Any]]: case = json.loads(stripped) if "schema_path" in case: schema_path = _resolve_path(Path(case["schema_path"])) - case["schema"] = json.loads(schema_path.read_text(encoding="utf-8")) + schema = json.loads(schema_path.read_text(encoding="utf-8")) + # Match runtime provider schemas; empty facet journals drop the enum. + case["schema"] = hydrate_runtime_enums(schema) cases.append(case) return cases diff --git a/tests/fixtures/journal/chronicle/20260327/talents/morning_briefing.json b/tests/fixtures/journal/chronicle/20260327/talents/morning_briefing.json new file mode 100644 index 000000000..12f4a5541 --- /dev/null +++ b/tests/fixtures/journal/chronicle/20260327/talents/morning_briefing.json @@ -0,0 +1,67 @@ +{ + "metadata": { + "generated": "2026-03-27T06:45:00", + "model": "claude-sonnet-4-20250514", + "sources": { + "segments": 14, + "anticipated_activities": 3, + "facet_newsletters": 2, + "followups": 5, + "steward_health": "present" + }, + "gaps": [], + "coverage_preamble": "Built from 14 indexed source paths, 3 anticipated activities today, 2 facet newsletters, 5 follow-ups, and steward health present. No gaps." + }, + "your_day": [ + { + "time": "09:00", + "text": "Sync with Sarah Chen on the Q2 product roadmap. Last met 2 weeks ago; discussed launch timeline (from your [March standup](sol://20260313/archon/091500_300))." + }, + { + "time": "11:30", + "text": "1:1 with Marcus about the infrastructure migration. He's been blocked on the DNS cutover (from your [Thursday 1:1](sol://20260325/archon/113000_1800))." + }, + { + "time": "14:00", + "text": "Design review for the new onboarding flow with the UX team." + }, + { + "time": "", + "text": "Review and respond to the open comments on the auth middleware PR." + } + ], + "yesterday": [ + "Shipped the entity intelligence pipeline refactor \u2014 3x faster lookups on large journals ([work newsletter](sol://facets/work/news/20260326)).", + "Had a productive brainstorm with Anika on the notification system. She proposed a priority-based queue ([work newsletter](sol://facets/work/news/20260326)).", + "Decided to delay the mobile app beta by one week to fix the sync regression ([work newsletter](sol://facets/work/news/20260326))." + ], + "needs_attention": [ + { + "text": "Follow up with investors on the Series A term sheet \u2014 response was due yesterday (committed [March 20](sol://20260320/archon/101500_600))", + "source_id": "sol://20260320/archon/101500_600" + }, + { + "text": "The CI pipeline has been failing intermittently on the integration test suite (flagged [yesterday](sol://20260326/default/143000_300))", + "source_id": "sol://20260326/default/143000_300" + }, + { + "text": "Review the draft partnership agreement from Acme Corp (last interaction March 15)", + "source_id": "" + } + ], + "forward_look": [ + "**Monday** \u2014 All-hands presentation on Q1 results. Slides need final review by Friday (from [schedule](sol://20260327/talents/schedule)).", + "**Wednesday** \u2014 Deadline for the compliance audit documentation.", + "Sarah mentioned wanting to discuss the API rate limiting strategy next week (from [schedule](sol://20260327/talents/schedule))." + ], + "reading": [ + { + "facet": "work", + "summary": "Product analytics show a 15% increase in daily active users this week" + }, + { + "facet": "personal", + "summary": "New developments in on-device AI processing could impact the capture pipeline" + } + ] +} diff --git a/tests/fixtures/journal/chronicle/20260327/talents/morning_briefing.md b/tests/fixtures/journal/chronicle/20260327/talents/morning_briefing.md deleted file mode 100644 index 0d8003d14..000000000 --- a/tests/fixtures/journal/chronicle/20260327/talents/morning_briefing.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -type: morning_briefing -date: "20260327" -generated: "2026-03-27T06:45:00" -model: "claude-sonnet-4-20250514" -sources: - segments: 14 - anticipated_activities: 3 - entities_consulted: 3 - facet_newsletters: 2 - followups: 5 -gaps: [] ---- - -> Built from 14 transcript segments, 3 anticipated activities, 3 entity profiles, 2 facet newsletters, and 5 follow-ups. No gaps. - -## Your Day - -- **09:00** — Sync with Sarah Chen on the Q2 product roadmap. Last met 2 weeks ago; discussed launch timeline (from your [March standup](sol://20260313/archon/091500_300)). -- **11:30** — 1:1 with Marcus about the infrastructure migration. He's been blocked on the DNS cutover (from your [Thursday 1:1](sol://20260325/archon/113000_1800)). -- **14:00** — Design review for the new onboarding flow with the UX team. -- Review and respond to the open comments on the auth middleware PR. - -## Yesterday - -- Shipped the entity intelligence pipeline refactor — 3x faster lookups on large journals ([work newsletter](sol://facets/work/news/20260326)). -- Had a productive brainstorm with Anika on the notification system. She proposed a priority-based queue ([work newsletter](sol://facets/work/news/20260326)). -- Decided to delay the mobile app beta by one week to fix the sync regression ([work newsletter](sol://facets/work/news/20260326)). - -## Needs Attention - -- Follow up with investors on the Series A term sheet — response was due yesterday (committed [March 20](sol://20260320/archon/101500_600)) -- The CI pipeline has been failing intermittently on the integration test suite (flagged [yesterday](sol://20260326/default/143000_300)) -- Review the draft partnership agreement from Acme Corp (last interaction March 15) - -## Forward Look - -- **Monday** — All-hands presentation on Q1 results. Slides need final review by Friday (from [schedule](sol://20260327/talents/schedule)). -- **Wednesday** — Deadline for the compliance audit documentation. -- Sarah mentioned wanting to discuss the API rate limiting strategy next week (from [schedule](sol://20260327/talents/schedule)). - -## Reading - -- **Work** — Product analytics show a 15% increase in daily active users this week -- **Industry** — New developments in on-device AI processing could impact the capture pipeline diff --git a/tests/fixtures/schema_eval/cases.jsonl b/tests/fixtures/schema_eval/cases.jsonl index 06fdf2826..f3e2ab0a0 100644 --- a/tests/fixtures/schema_eval/cases.jsonl +++ b/tests/fixtures/schema_eval/cases.jsonl @@ -3,3 +3,4 @@ {"name":"decision_flags","schema":{"type":"object","properties":{"decision":{"type":"string","maxLength":120},"approved":{"type":"boolean"},"tags":{"type":"array","maxItems":3,"items":{"type":"string","enum":["billing","security","launch","support"]}}},"required":["decision","approved","tags"],"additionalProperties":false},"system_instruction":"Return only JSON matching the schema. Capture the decision and select applicable tags.","input":"The team approved delaying the launch until the security review finishes. Billing and support are not involved.","expect_contains":["delaying","security","launch"]} {"name":"document_analysis_trust_excerpt","schema_path":"solstone/talent/documents.schema.json","system_instruction":"Return only JSON matching the schema. Extract the document facts and do not add unsupported details.","input":"Document: Miller Family Trust Amendment. Executed March 4, 2026. Jordan Miller is the Settlor. Priya Shah is appointed primary Trustee. Evan Lee is named successor Trustee if Priya cannot serve. The Trustee may distribute the brokerage account for health and education expenses. The third anniversary of the Settlor's death triggers a mandatory review.","expect_contains":["Miller Family Trust Amendment","Jordan Miller","Priya Shah","Evan Lee","brokerage account","third anniversary"]} {"name":"screen_record_editor_meeting","schema_path":"solstone/talent/screen.schema.json","system_instruction":"Return only JSON matching the schema. Preserve the visible screen details and entities.","input":"09:00:05 monitor main category meeting visual Zoom call with Alice Smith and Bob Chen visible in participant tiles. Shared screen shows the solstone repository in VS Code. 09:01:10 terminal command `pytest tests/test_cluster.py` fails with AssertionError in solstone/think/cluster.py. 09:02:00 browser opens https://example.test/spec for the formatter contract.","expect_contains":["Alice Smith","Bob Chen","solstone","pytest tests/test_cluster.py","solstone/think/cluster.py","https://example.test/spec"]} +{"name":"morning_briefing_20260708_trimmed","schema_path":"solstone/talent/morning_briefing.schema.json","system_instruction":"Return only JSON matching the schema. Copy metadata exactly. Preserve HH:MM times and sol:// source ids from the input.","input":"metadata: {\"generated\":\"2026-07-09T04:47:41\",\"model\":\"scratch-model\",\"sources\":{\"segments\":0,\"anticipated_activities\":9,\"facet_newsletters\":6,\"followups\":0,\"steward_health\":\"present\"},\"gaps\":[\"no facet newsletter available for vconic\",\"no follow-up items found\",\"no decision items found\"],\"coverage_preamble\":\"Built from 0 indexed source paths, 9 anticipated activities today, 17 forward-looking anticipated activities, 6 facet newsletters, 0 follow-ups, 0 decision results. Gaps: no facet newsletter available for vconic; no follow-up items found; no decision items found.\"}\nToday: 11:00-12:00 Michael Bauer <> Jer Miller Meeting [meeting, solpbc]. 13:00-17:00 Travel Prep [reminder, personal].\nSteward: Pipeline issue: 82 agents failed during yesterday's processing.\nReading: awareness newsletter Source: sol://facets/awareness/news/20260708. Michael Morgan confirmed guardrails; reply required.","expect_contains":["Michael Bauer <> Jer Miller Meeting","11:00","13:00","82 agents failed","sol://facets/awareness/news/20260708","no follow-up items found"]} diff --git a/tests/test_formatters.py b/tests/test_formatters.py index a358a85e2..4ce0017b9 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -115,6 +115,17 @@ class TestRegistry: assert screen is not None assert screen.__name__ == "format_screen_record" + def test_get_formatter_day_level_morning_briefing_json(self): + """Morning briefing JSON uses a day-level formatter only by registered path.""" + from solstone.think.formatters import FORMATTERS, get_formatter + + formatter = get_formatter("20240101/talents/morning_briefing.json") + + assert formatter is not None + assert formatter.__name__ == "format_morning_briefing" + assert "*/talents/*.json" not in FORMATTERS + assert get_formatter("20240101/talents/other.json") is None + def test_no_day_level_document_or_screen_json_formatter(self): """Document and screen JSON formatters are segment-level only.""" from solstone.think.formatters import get_formatter @@ -412,6 +423,74 @@ def test_format_document_analysis_renders_all_seven_sections(): assert "Quick reference summary" in section("Summary") +def test_format_morning_briefing_renders_all_five_sections(): + from solstone.think.talent_outputs import format_morning_briefing + + briefing = { + "metadata": { + "generated": "2026-03-27T06:45:00", + "model": "test-model", + "sources": { + "segments": 1, + "anticipated_activities": 1, + "facet_newsletters": 1, + "followups": 1, + "steward_health": "present", + }, + "gaps": [], + "coverage_preamble": "Built from test sources. No gaps.", + }, + "your_day": [{"time": "09:00", "text": "Meet Sarah."}], + "yesterday": ["Shipped the formatter."], + "needs_attention": [ + { + "text": "Review the report.", + "source_id": "sol://20260327/default/090000_300", + } + ], + "forward_look": ["Prepare for Monday."], + "reading": [{"facet": "work", "summary": "Newsletter summary."}], + } + + chunks, meta = format_morning_briefing([briefing]) + + rendered = chunks[0]["markdown"] + headings = [ + "Your Day", + "Yesterday", + "Needs Attention", + "Forward Look", + "Reading", + ] + assert meta["indexer"]["agent"] == "morning_briefing" + assert chunks == [{"markdown": rendered, "timestamp": 0, "source": briefing}] + assert [rendered.index(f"## {heading}") for heading in headings] == sorted( + rendered.index(f"## {heading}") for heading in headings + ) + assert rendered.count("## ") == 5 + assert "> Built from test sources. No gaps." in rendered + assert "- **09:00** — Meet Sarah." in rendered + assert "- **work** — Newsletter summary." in rendered + + +def test_find_formattable_includes_day_level_morning_briefing_only(tmp_path: Path): + from solstone.think.formatters import find_formattable_files + + day_level = tmp_path / "chronicle" / "20240101" / "talents" + segment_level = ( + tmp_path / "chronicle" / "20240101" / "default" / "120000_300" / "talents" + ) + day_level.mkdir(parents=True) + segment_level.mkdir(parents=True) + (day_level / "morning_briefing.json").write_text("{}", encoding="utf-8") + (segment_level / "morning_briefing.json").write_text("{}", encoding="utf-8") + + files = find_formattable_files(str(tmp_path)) + + assert "20240101/talents/morning_briefing.json" in files + assert "20240101/default/120000_300/talents/morning_briefing.json" not in files + + def test_format_screen_record_renders_narrative_entities_and_agent_meta(): from solstone.think.talent_outputs import format_screen_record diff --git a/tests/test_home_routes.py b/tests/test_home_routes.py index ba2c5c5e4..e921b206c 100644 --- a/tests/test_home_routes.py +++ b/tests/test_home_routes.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import datetime +from pathlib import Path from typing import Any import pytest @@ -16,16 +17,25 @@ def _patch_minimal_pulse_context( monkeypatch, *, pulse_needs: list[Any], - briefing_needs: list[str], + briefing_needs: list[Any], attention: Any = None, ): import solstone.apps.home.routes as home_routes - briefing_sections = ( - {"needs_attention": "\n".join(f"- {item}" for item in briefing_needs)} - if briefing_needs - else {} - ) + briefing = None + if briefing_needs: + needs_items = [ + item if isinstance(item, dict) else {"text": item, "source_id": ""} + for item in briefing_needs + ] + briefing = { + "metadata": {"generated": "2026-04-16T09:00:00"}, + "your_day": [], + "yesterday": [], + "needs_attention": needs_items, + "forward_look": [], + "reading": [], + } monkeypatch.setattr( home_routes, "get_capture_health", @@ -46,12 +56,8 @@ def _patch_minimal_pulse_context( ) monkeypatch.setattr( home_routes, - "_load_briefing_md", - lambda today: ( - briefing_sections, - {"generated": "2026-04-16T09:00:00"}, - briefing_needs, - ), + "load_briefing", + lambda today: briefing, ) monkeypatch.setattr( home_routes, "_collect_anticipated_activities", lambda today: [] @@ -219,7 +225,7 @@ def test_pulse_and_briefing_needs_dedup_by_shared_source(monkeypatch): "source_id": source, } ], - briefing_needs=[f"Look at the Q3 numbers {source}"], + briefing_needs=[{"text": "Look at the Q3 numbers", "source_id": source}], ) ctx = home_routes._build_pulse_context() @@ -235,15 +241,15 @@ def test_briefing_repeated_source_identity_renders_once(monkeypatch): monkeypatch, pulse_needs=[], briefing_needs=[ - f"Review the Q3 report {source}", - f"Look at the Q3 numbers {source}", + {"text": "Review the Q3 report", "source_id": source}, + {"text": "Look at the Q3 numbers", "source_id": source}, ], ) ctx = home_routes._build_pulse_context() assert ctx["briefing_needs_shared_count"] == 0 - assert ctx["briefing_needs_deduped"] == [f"Review the Q3 report {source}"] + assert ctx["briefing_needs_deduped"] == ["Review the Q3 report"] def test_briefing_different_source_identities_stay_distinct(monkeypatch): @@ -253,8 +259,8 @@ def test_briefing_different_source_identities_stay_distinct(monkeypatch): monkeypatch, pulse_needs=[], briefing_needs=[ - f"Review the report {source_a}", - f"Review the report {source_b}", + {"text": "Review the report", "source_id": source_a}, + {"text": "Review the report", "source_id": source_b}, ], ) @@ -262,8 +268,8 @@ def test_briefing_different_source_identities_stay_distinct(monkeypatch): assert ctx["briefing_needs_shared_count"] == 0 assert ctx["briefing_needs_deduped"] == [ - f"Review the report {source_a}", - f"Review the report {source_b}", + "Review the report", + "Review the report", ] @@ -279,3 +285,38 @@ def test_legacy_plain_string_needs_still_dedup_by_normalized_text(monkeypatch): assert len(ctx["needs_you_items"]) == 1 assert ctx["briefing_needs_shared_count"] == 1 assert ctx["briefing_needs_deduped"] == [] + + +def test_markdown_briefing_loader_removed(): + import solstone.apps.home.routes as home_routes + + source = Path(home_routes.__file__).read_text(encoding="utf-8") + + assert not hasattr(home_routes, "_load_briefing_md") + assert not hasattr(home_routes, "_BRIEFING_SECTIONS") + assert 'startswith("## ")' not in source + + +def test_briefing_needs_dedup_by_inline_sol_link(monkeypatch): + source = "sol://20260313/archon/091500_300" + home_routes = _patch_minimal_pulse_context( + monkeypatch, + pulse_needs=[], + briefing_needs=[ + { + "text": f"Review the Q3 report ([standup]({source}))", + "source_id": "", + }, + { + "text": f"Look at the Q3 numbers ([standup]({source}))", + "source_id": "", + }, + ], + ) + + ctx = home_routes._build_pulse_context() + + assert ctx["briefing_needs_shared_count"] == 0 + assert ctx["briefing_needs_deduped"] == [ + f"Review the Q3 report ([standup]({source}))" + ] diff --git a/tests/test_home_yesterdays_processing.py b/tests/test_home_yesterdays_processing.py index 37ea651b4..40ca09fb8 100644 --- a/tests/test_home_yesterdays_processing.py +++ b/tests/test_home_yesterdays_processing.py @@ -17,6 +17,7 @@ from solstone.apps.home.routes import ( BRIEFING_MORNING_END_HOUR, _briefing_freshness, _briefing_lateness_state, + _briefing_summary, _build_pulse_context, _collect_activities, _collect_anticipated_activities, @@ -118,25 +119,35 @@ def _write_briefing( day: str, generated: str, *, - metadata_type: str = "morning_briefing", - date: str | None = None, + payload: object | None = None, ) -> None: from solstone.think.talent import morning_briefing_path path = morning_briefing_path(day) path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - ( - f"---\n" - f"type: {metadata_type}\n" - f"date: {date if date is not None else day}\n" - f'generated: "{generated}"\n' - f"---\n\n" - "## Your Day\n\n" - "- One thing.\n" - ), - encoding="utf-8", - ) + data = payload + if data is None: + data = { + "metadata": { + "generated": generated, + "model": "test-model", + "sources": { + "segments": 1, + "anticipated_activities": 1, + "facet_newsletters": 1, + "followups": 0, + "steward_health": "present", + }, + "gaps": [], + "coverage_preamble": "Built from test sources. No gaps.", + }, + "your_day": [{"time": "", "text": "One thing."}], + "yesterday": [], + "needs_attention": [], + "forward_look": [], + "reading": [], + } + path.write_text(json.dumps(data), encoding="utf-8") def _append_think_log( @@ -185,9 +196,7 @@ def _patch_minimal_pulse_context(monkeypatch, pipeline_status): "solstone.apps.home.routes._load_pulse_narrative", lambda today: (None, None, []), ) - monkeypatch.setattr( - "solstone.apps.home.routes._load_briefing_md", lambda today: ({}, None, []) - ) + monkeypatch.setattr("solstone.apps.home.routes.load_briefing", lambda today: None) monkeypatch.setattr( "solstone.apps.home.routes._collect_anticipated_activities", lambda today: [] ) @@ -456,7 +465,7 @@ def test_activity_bullet_title_duration_facet(tmp_path, monkeypatch): ) -def test_briefing_frontmatter_missing_counts_as_gap(tmp_path, monkeypatch): +def test_briefing_missing_counts_as_gap(tmp_path, monkeypatch): _seed_journal(tmp_path, monkeypatch) monkeypatch.setattr("solstone.apps.home.routes._today", lambda: "20260416") @@ -485,18 +494,19 @@ def test_briefing_freshness_valid_with_prior_evening_generated(tmp_path, monkeyp assert result == {"exists": True, "valid": True, "generated_label": "9:30pm"} -def test_briefing_freshness_accepts_unquoted_int_date(tmp_path, monkeypatch): - import frontmatter +def test_briefing_summary_matches_fixture_projection(monkeypatch): + from solstone.think.briefing import load_briefing, render_briefing_sections - from solstone.think.talent import morning_briefing_path - - _seed_journal(tmp_path, monkeypatch) - _write_briefing("20260702", "2026-07-01T20:00:00") + monkeypatch.setenv("SOLSTONE_JOURNAL", str(FIXTURES)) - meta = frontmatter.load(str(morning_briefing_path("20260702"))).metadata + briefing = load_briefing("20260327") + assert briefing is not None + sections = render_briefing_sections(briefing) - assert isinstance(meta["date"], int) - assert _briefing_freshness("20260702")["valid"] is True + assert ( + _briefing_summary(briefing, sections, len(briefing["needs_attention"])) + == "Morning briefing — 3 meetings, 3 items need attention" + ) def test_briefing_freshness_invalid_when_missing(tmp_path, monkeypatch): @@ -509,12 +519,12 @@ def test_briefing_freshness_invalid_when_missing(tmp_path, monkeypatch): } -def test_briefing_freshness_invalid_when_wrong_type(tmp_path, monkeypatch): +def test_briefing_freshness_invalid_when_root_is_list(tmp_path, monkeypatch): _seed_journal(tmp_path, monkeypatch) _write_briefing( "20260416", "2026-04-15T21:00:00", - metadata_type="daily_summary", + payload=[], ) result = _briefing_freshness("20260416") @@ -523,23 +533,30 @@ def test_briefing_freshness_invalid_when_wrong_type(tmp_path, monkeypatch): assert result["valid"] is False -def test_briefing_freshness_invalid_when_date_mismatch(tmp_path, monkeypatch): +def test_briefing_freshness_invalid_when_metadata_missing(tmp_path, monkeypatch): _seed_journal(tmp_path, monkeypatch) - _write_briefing("20260416", "2026-04-15T21:00:00", date="20260415") + _write_briefing( + "20260416", + "2026-04-15T21:00:00", + payload={ + "your_day": [], + "yesterday": [], + "needs_attention": [], + "forward_look": [], + "reading": [], + }, + ) assert _briefing_freshness("20260416")["valid"] is False -def test_briefing_freshness_invalid_when_frontmatter_unparseable(tmp_path, monkeypatch): +def test_briefing_freshness_invalid_when_json_malformed(tmp_path, monkeypatch): from solstone.think.talent import morning_briefing_path _seed_journal(tmp_path, monkeypatch) path = morning_briefing_path("20260416") path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - "---\ntype: morning_briefing\ndate: [unclosed\n---\n\nbody\n", - encoding="utf-8", - ) + path.write_text("{not valid json", encoding="utf-8") assert _briefing_freshness("20260416") == { "exists": True, @@ -791,9 +808,7 @@ def test_build_pulse_context_includes_yesterday_processing(monkeypatch): "solstone.apps.home.routes._load_pulse_narrative", lambda today: (None, None, []), ) - monkeypatch.setattr( - "solstone.apps.home.routes._load_briefing_md", lambda today: ({}, None, []) - ) + monkeypatch.setattr("solstone.apps.home.routes.load_briefing", lambda today: None) monkeypatch.setattr( "solstone.apps.home.routes._collect_anticipated_activities", lambda today: [] ) diff --git a/tests/test_morning_briefing_pre_hook.py b/tests/test_morning_briefing_pre_hook.py index 144a2957b..da0073ae0 100644 --- a/tests/test_morning_briefing_pre_hook.py +++ b/tests/test_morning_briefing_pre_hook.py @@ -98,6 +98,7 @@ def test_morning_briefing_pre_hook_builds_source_packet(tmp_path, monkeypatch): ] expected = { + "briefing_metadata", "active_facets", "facet_newsletters", "anticipated_today", @@ -107,18 +108,19 @@ def test_morning_briefing_pre_hook_builds_source_packet(tmp_path, monkeypatch): "health_surface", "followups", "decisions", - "source_counts", - "source_gaps", - "coverage_preamble", } assert expected <= set(packet) + assert "source_counts" not in packet + assert "source_gaps" not in packet + assert "coverage_preamble" not in packet assert "Planning meeting" in packet["anticipated_today"] assert "Proposal deadline" in packet["anticipated_forward"] assert "Work shipped a release." in packet["facet_newsletters"] assert "Pulse needs focus time." in packet["pulse_surface"] assert "- Review the launch checklist." in packet["pulse_surface"] - assert " anticipated_activities: 1" in packet["source_counts"] - assert json.loads(packet["source_gaps"]) == [] + metadata = json.loads(packet["briefing_metadata"]) + assert metadata["sources"]["anticipated_activities"] == 1 + assert metadata["gaps"] == [] def test_morning_briefing_pre_hook_missing_sources_are_visible_gaps( @@ -151,9 +153,10 @@ def test_morning_briefing_pre_hook_missing_sources_are_visible_gaps( ) packet = morning_briefing.pre_process({"day": "20260422"})["template_vars"] - gaps = json.loads(packet["source_gaps"]) + metadata = json.loads(packet["briefing_metadata"]) + gaps = metadata["gaps"] assert any("no facet newsletter available" in gap for gap in gaps) assert any("no anticipated activities today" in gap for gap in gaps) assert any("steward health surface missing" in gap for gap in gaps) - assert "Gaps:" in packet["coverage_preamble"] + assert "Gaps:" in metadata["coverage_preamble"] diff --git a/tests/test_morning_briefing_steward_migration.py b/tests/test_morning_briefing_steward_migration.py index 1ad899503..634046cb1 100644 --- a/tests/test_morning_briefing_steward_migration.py +++ b/tests/test_morning_briefing_steward_migration.py @@ -21,7 +21,9 @@ def test_morning_briefing_is_generate_with_pre_hook(): metadata = _briefing_metadata() assert metadata["type"] == "generate" - assert metadata["output"] == "md" + assert metadata["output"] == "json" + assert metadata["schema"] == "morning_briefing.schema.json" + assert metadata["max_output_tokens"] == 8192 assert metadata["schedule"] == "daily" assert metadata["hook"]["pre"] == "morning_briefing" assert "read_scope" not in metadata @@ -31,9 +33,13 @@ def test_morning_briefing_prompt_uses_injected_packet_only(): prompt = _briefing_prompt() assert "$health_surface" in prompt - assert "gaps: $source_gaps" in prompt - assert "$coverage_preamble" in prompt + assert '"metadata": $briefing_metadata' in prompt + assert "$Briefing_metadata" not in prompt + assert "$source_gaps" not in prompt + assert "$source_counts" not in prompt + assert "$coverage_preamble" not in prompt assert "Steward Health Surface" in prompt + assert "Do not wrap it in a markdown fence" in prompt assert "sol call" not in prompt assert "read_file" not in prompt diff --git a/tests/test_schema_prep.py b/tests/test_schema_prep.py index 9dcc50a57..c46dcda3a 100644 --- a/tests/test_schema_prep.py +++ b/tests/test_schema_prep.py @@ -87,6 +87,29 @@ def test_anthropic_strips_array_and_length_bounds( assert item["enum"] == ["alpha", "beta"] +@pytest.mark.parametrize("provider", ["openai", "google", "anthropic"]) +def test_morning_briefing_schema_is_provider_portable(provider: str) -> None: + schema = json.loads( + (REPO_ROOT / "solstone/talent/morning_briefing.schema.json").read_text( + encoding="utf-8" + ) + ) + + prepared = prepare_provider_schema(schema, provider) + + assert unsupported_keyword_hits(prepared, provider) == [] + assert prepared["properties"]["reading"]["items"]["properties"]["facet"][ + "enum" + ] == ["__RUNTIME_FACETS__"] + assert ( + "pattern" in prepared["properties"]["your_day"]["items"]["properties"]["time"] + ) + assert ( + "pattern" + in prepared["properties"]["needs_attention"]["items"]["properties"]["source_id"] + ) + + @pytest.mark.parametrize("provider", ["local", "openai", "google", "anthropic", "fake"]) def test_prepare_provider_schema_is_pure_and_idempotent( bounded_schema: dict[str, Any], provider: str diff --git a/tests/test_sol_call_identity_briefing.py b/tests/test_sol_call_identity_briefing.py index aad70583a..5fec14731 100644 --- a/tests/test_sol_call_identity_briefing.py +++ b/tests/test_sol_call_identity_briefing.py @@ -3,6 +3,8 @@ """Tests for ``journal identity briefing``.""" +import json + from typer.testing import CliRunner from solstone.think.tools.sol import app @@ -10,18 +12,42 @@ from solstone.think.tools.sol import app runner = CliRunner() -def _seed_briefing(day: str, body: str) -> None: +def _seed_briefing(day: str, marker: str) -> None: from solstone.think.talent import morning_briefing_path path = morning_briefing_path(day) path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(body, encoding="utf-8") + path.write_text( + json.dumps( + { + "metadata": { + "generated": "2026-01-01T09:00:00", + "model": "test-model", + "sources": { + "segments": 1, + "anticipated_activities": 0, + "facet_newsletters": 0, + "followups": 0, + "steward_health": "present", + }, + "gaps": [], + "coverage_preamble": "Built from test sources. No gaps.", + }, + "your_day": [], + "yesterday": [marker], + "needs_attention": [], + "forward_look": [], + "reading": [], + } + ), + encoding="utf-8", + ) def test_briefing_no_day_returns_most_recent_available(tmp_path, monkeypatch): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - _seed_briefing("20260101", "# Morning Briefing\n\nolder-marker\n") + _seed_briefing("20260101", "older-marker") from solstone.think.talent import morning_briefing_path morning_briefing_path("20260102").parent.mkdir(parents=True, exist_ok=True) @@ -29,6 +55,7 @@ def test_briefing_no_day_returns_most_recent_available(tmp_path, monkeypatch): result = runner.invoke(app, ["briefing"]) assert result.exit_code == 0 + assert "## Yesterday" in result.stdout assert "older-marker" in result.stdout diff --git a/tests/test_talent_output_schemas.py b/tests/test_talent_output_schemas.py index 0f1e2f90f..6c1957427 100644 --- a/tests/test_talent_output_schemas.py +++ b/tests/test_talent_output_schemas.py @@ -8,6 +8,7 @@ from pathlib import Path from jsonschema import Draft202012Validator +from solstone.think.schema_bounds import unbounded_nodes from solstone.think.talent import get_talent TALENT_DIR = Path(__file__).resolve().parents[1] / "solstone" / "talent" @@ -62,3 +63,28 @@ def test_screen_talent_uses_bounded_json_schema(): _assert_declares_max_output_tokens("screen") assert talent["json_schema"] == schema assert sorted(schema["properties"]) == ["entities", "narrative"] + + +def test_morning_briefing_talent_uses_bounded_json_schema(): + from scripts.check_schema_bounds import ALLOWLIST + + schema = _load_schema("morning_briefing") + Draft202012Validator.check_schema(schema) + + talent = get_talent("morning_briefing") + + assert talent["output"] == "json" + assert talent["max_output_tokens"] == 8192 + _assert_declares_max_output_tokens("morning_briefing") + assert talent["json_schema"] == schema + assert talent["degradation_check"] is True + assert unbounded_nodes(schema) == [] + assert "solstone/talent/morning_briefing.schema.json" not in ALLOWLIST + assert sorted(schema["properties"]) == [ + "forward_look", + "metadata", + "needs_attention", + "reading", + "yesterday", + "your_day", + ] diff --git a/tests/test_voice_tools.py b/tests/test_voice_tools.py index 382c73dda..145afe174 100644 --- a/tests/test_voice_tools.py +++ b/tests/test_voice_tools.py @@ -238,10 +238,13 @@ def test_briefing_get_happy(monkeypatch): result = tools.handle_briefing_get({}, object()) + assert set(result) == {"date", "facet", "text", "highlights", "_nav_target"} assert result["date"] == "2026-03-27" assert result["facet"] == "identity" assert result["_nav_target"] == "today" assert result["highlights"] + assert len(result["highlights"]) <= 3 + assert "Series A term sheet" in result["highlights"][0] def test_briefing_get_failure(monkeypatch): -- 2.51.2