From c6524d92e4e210f4d44600396d219b1fddb1e6ce Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Thu, 11 Jun 2026 16:17:32 -0600 Subject: [PATCH] refactor(talent): convert briefing and entity descriptions to generate Move morning_briefing and entities:entity_describe onto generate talents with read-only pre-hooks. Gather briefing packets in process, keep entity description dispatch ad hoc without journal writes, and gate entity generation on the configured generate provider readiness. --- solstone/apps/entities/routes.py | 32 +- .../apps/entities/talent/entity_describe.md | 50 +-- .../apps/entities/talent/entity_describe.py | 85 ++++ solstone/talent/morning_briefing.md | 159 +++---- solstone/talent/morning_briefing.py | 419 ++++++++++++++++++ tests/test_cogitate_contract_harness.py | 2 +- tests/test_entity_describe_pre_hook.py | 93 ++++ tests/test_morning_briefing_pre_hook.py | 146 ++++++ ...test_morning_briefing_steward_migration.py | 39 +- 9 files changed, 870 insertions(+), 155 deletions(-) create mode 100644 solstone/apps/entities/talent/entity_describe.py create mode 100644 solstone/talent/morning_briefing.py create mode 100644 tests/test_entity_describe_pre_hook.py create mode 100644 tests/test_morning_briefing_pre_hook.py diff --git a/solstone/apps/entities/routes.py b/solstone/apps/entities/routes.py index a55b966e9..6864556d7 100644 --- a/solstone/apps/entities/routes.py +++ b/solstone/apps/entities/routes.py @@ -7,7 +7,6 @@ from __future__ import annotations import json import logging -import os import re import time import uuid @@ -1071,17 +1070,13 @@ def generate_description(facet_name: str) -> Any: detail="Type and name are required", ) - # Check for Google API key - api_key = os.getenv("GOOGLE_API_KEY") - if not api_key: - return error_response( - PROVIDER_KEY_MISSING, - detail="GOOGLE_API_KEY not set", - ) - try: from solstone.convey.utils import spawn_agent + provider_error = _entity_describe_generate_readiness_error() + if provider_error is not None: + return provider_error + # Build concise prompt - agent has detailed instructions current_desc = current_description or "(none)" prompt = ( @@ -1094,7 +1089,6 @@ def generate_description(facet_name: str) -> Any: use_id = spawn_agent( prompt=prompt, name="entities:entity_describe", - provider="google", ) if use_id is None: return error_response( @@ -1108,6 +1102,24 @@ def generate_description(facet_name: str) -> Any: return error_response(AGENT_UNAVAILABLE, detail=str(e)) +def _entity_describe_generate_readiness_error() -> Any | None: + from solstone.think.models import resolve_provider + from solstone.think.providers.state import readiness_for_provider + from solstone.think.talent import key_to_context + + context = key_to_context("entities:entity_describe") + provider, model = resolve_provider(context, "generate") + readiness = readiness_for_provider(provider, "generate", model) + if readiness.status not in {"blocked", "unhealthy"}: + return None + + detail = readiness.message or ( + f"{provider} generate provider is not ready" + + (f" ({readiness.reason_code})" if readiness.reason_code else "") + ) + return error_response(PROVIDER_KEY_MISSING, detail=detail) + + @entities_bp.route("/api//assist", methods=["POST"]) def assist_add(facet_name: str) -> Any: """Use entity_assist agent to quickly add an entity with AI-generated details.""" diff --git a/solstone/apps/entities/talent/entity_describe.md b/solstone/apps/entities/talent/entity_describe.md index 3d7ace745..c491647d3 100644 --- a/solstone/apps/entities/talent/entity_describe.md +++ b/solstone/apps/entities/talent/entity_describe.md @@ -1,44 +1,34 @@ { - "type": "cogitate", + "type": "generate", "title": "Entity Description", "description": "Research and generate single-sentence descriptions for attached entities", "color": "#26a69a", - "group": "Entities" + "group": "Entities", + "output": "md", + "hook": {"pre": "entities:entity_describe"} } -$facets - -## Core Mission - -Generate a clear, informative single-sentence description for an attached entity based on quick research within the facet context. +Generate a clear, informative single-sentence description for an attached entity. ## Input Context -You receive: -1. **Entity Type** - the type of entity (Person, Company, Project, Tool, etc.) -2. **Entity Name** - the name to describe -3. **Facet** - the facet this entity belongs to (provides context for relevance) -4. **Current Description** - existing description if any (may be empty) - -## Research Tools +- Entity Type: $entity_type +- Entity Name: $entity_name +- Facet: $facet +- Current Description: $current_description -Use these `sol call` commands for quick research (be efficient, 2-3 calls max): -- `sol call journal search QUERY -f FACET -n LIMIT` - find mentions in journal content, scoped to facet -- `sol call journal search QUERY -a audio -n LIMIT` - find mentions in transcripts +## Journal Evidence -## Process - -1. **Quick research** - 1-2 targeted searches for the entity name within the facet -2. **Synthesize** - combine findings into a single descriptive sentence -3. **Output** - return ONLY the description sentence, nothing else +$evidence ## Description Guidelines **Format:** - Single complete sentence, under 100 characters preferred - No quotes around the description -- Present tense for active entities, past tense for historical +- Present tense for active entities, past tense for historical entities +- Return only the description sentence, with no preamble, markdown, or explanation **Content by type:** @@ -58,13 +48,9 @@ Use these `sol call` commands for quick research (be efficient, 2-3 calls max): - "Infrastructure-as-code framework for AWS deployments" - "Time-series database for metrics storage" -**If no research results:** -- Use context from entity type and name -- Generic but accurate: "Colleague from the platform team" -- Never leave empty - always synthesize something - -## Output +**If no journal evidence is found:** +- Use the entity type, entity name, facet, and current description +- Produce a generic but useful sentence +- Never leave the response empty -Return ONLY the description sentence. No preamble, no explanation, no quotes. -Conclude with the built-in finish tool (`FinishTool`) — this talent has no -`emit_final`; the description sentence is your final response. +Return only one plain sentence. diff --git a/solstone/apps/entities/talent/entity_describe.py b/solstone/apps/entities/talent/entity_describe.py new file mode 100644 index 000000000..0d7f4c6bb --- /dev/null +++ b/solstone/apps/entities/talent/entity_describe.py @@ -0,0 +1,85 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Pre-hook for the entity description generate talent.""" + +from __future__ import annotations + +import logging + +from solstone.think.indexer.journal import search_journal + +logger = logging.getLogger(__name__) + +_NO_EVIDENCE = "No journal evidence found for this entity." + + +def pre_process(config: dict) -> dict | None: + """Parse entity context and attach bounded journal evidence.""" + fields = _parse_prompt(str(config.get("prompt") or "")) + entity_name = fields["entity_name"] + if not entity_name: + return {"skip_reason": "missing entity name"} + + evidence = _render_evidence(entity_name, fields["facet"]) + return { + "template_vars": { + "entity_type": fields["entity_type"] or "Entity", + "entity_name": entity_name, + "facet": fields["facet"] or "(none)", + "current_description": fields["current_description"] or "(none)", + "evidence": evidence, + } + } + + +def _parse_prompt(prompt: str) -> dict[str, str]: + fields = { + "entity_type": "", + "entity_name": "", + "facet": "", + "current_description": "", + } + prefixes = { + "Entity Type:": "entity_type", + "Entity Name:": "entity_name", + "Facet:": "facet", + "Current Description:": "current_description", + } + for line in prompt.splitlines(): + for prefix, key in prefixes.items(): + if line.startswith(prefix): + fields[key] = line[len(prefix) :].strip() + break + if fields["current_description"] == "(none)": + fields["current_description"] = "" + return fields + + +def _render_evidence(entity_name: str, facet: str) -> str: + try: + _, results = search_journal( + entity_name, + limit=5, + facet=facet or None, + ) + except Exception as exc: + logger.warning("entity_describe evidence search unavailable: %s", exc) + return f"Journal evidence unavailable: {exc}" + + if not results: + return _NO_EVIDENCE + + lines = [] + for result in results: + metadata = result.get("metadata") or {} + source_id = str(result.get("id") or "") + day = str(metadata.get("day") or "unknown") + result_facet = str(metadata.get("facet") or "unknown") + text = _single_line(str(result.get("text") or "")) + lines.append(f"- {source_id} [{day}, {result_facet}]: {text}") + return "\n".join(lines) + + +def _single_line(value: str) -> str: + return " ".join(value.strip().split()) diff --git a/solstone/talent/morning_briefing.md b/solstone/talent/morning_briefing.md index 02c55221f..ca2357929 100644 --- a/solstone/talent/morning_briefing.md +++ b/solstone/talent/morning_briefing.md @@ -1,5 +1,5 @@ { - "type": "cogitate", + "type": "generate", "title": "Morning Briefing", "description": "Synthesizes all daily agent outputs into a structured five-section morning briefing", @@ -8,139 +8,100 @@ "priority": 50, "output": "md", "degradation_check": true, - "read_scope": ["chronicle/", "facets", "entities", "imports", "health", "identity"] + "hook": {"pre": "morning_briefing"} } -$facets +You are generating the morning briefing for $agent_name: a structured daily briefing that synthesizes agent outputs, calendar, follow-ups, and current context into an actionable start-of-day view. -You are generating the morning briefing for $agent_name — a structured daily digest that synthesizes agent outputs, calendar, follow-ups, and current context into an actionable start-of-day view. +The source packet below is complete. Do not invent data outside the packet. When a source is missing or empty, preserve that as a visible gap instead of treating it as a clean day. -This is not a conversation. Gather data, synthesize, then call `emit_final(content=)`. The system saves the `content` argument automatically. +## Output Contract -## Phase 1: Gather data +Return only the complete briefing markdown in this exact outer shape: -Call all sources upfront. Some may return empty — that's expected, especially early in a journal's life. +``` +--- +type: morning_briefing +date: $day_YYYYMMDD +generated: $generated +model: $model +sources: +$source_counts +gaps: $source_gaps +--- -1. `sol call journal facets` — list active facets -2. For each facet: `sol call journal news FACET --day $day_YYYYMMDD` — facet newsletter -3. `sol call activities list --source anticipated --day $day_YYYYMMDD` — today's scheduled items with participants -4. `read_file` `identity/pulse.md` — current pulse narrative and needs-you items -5. `read_file` `identity/partner.md` — owner behavioral profile (informs tone and emphasis) -6. `sol call journal search "" -d $day_YYYYMMDD -a followups -n 10` — follow-up items from today -7. `sol call activities list --source anticipated --from $day_YYYYMMDD --to <+7>` — forward-looking scheduled items -8. `sol call journal search "" -d $day_YYYYMMDD -a decisions -n 10` — yesterday's consequential decisions -9. For each of the next 7 days after today: `sol call activities list --source anticipated --day YYYYMMDD` — upcoming scheduled items for forward look +$coverage_preamble -Also run: -10. `read_file` `identity/health.md` — sol's federated health surface (synthesized by the steward talent) +## Your Day +[today's prioritized agenda] -## Phase 1.5: Pre-pass audit +## Yesterday +[what happened yesterday] -Before synthesizing, audit what you gathered. This step uses only the data from Phase 1 — make no additional tool calls. +## Needs Attention +[ranked actions and pipeline gaps] -1. **Count sources.** Tally how many results each source returned: - - `segments` — total transcript segments across all journal search calls - - `anticipated_activities` — anticipated activities for today (step 3) - - `facet_newsletters` — facets that returned a newsletter (step 2) - - `followups` — follow-up items returned (step 6) - - `steward_health` — whether the steward health surface returned parseable content and how many Needs your attention bullets it surfaced +## Forward Look +[next seven days] -2. **Identify gaps.** Record a gap for each source that returned zero results or is otherwise missing. A gap is not an error — it means the briefing has a blind spot in that area. Examples: `"no facet newsletters available"`, `"no follow-up items found"`, `"no anticipated activities today"`. +## Reading +[facet newsletter links] +``` -3. **Catalog tool errors.** If any `sol call` in Phase 1 returned an error response, record it as a gap with the error context. +Omit any section that has no content. Keep the YAML frontmatter, `sources`, `gaps`, and coverage preamble exactly as injected above. -4. **Check the steward health surface.** Read the steward's Needs your attention section. If empty, omit the Pipeline gaps subsection entirely. Otherwise surface those bullets as top-ranked operational gaps in Needs Attention, rendering them verbatim. If `identity/health.md` returned empty content, the file is missing, or the surface failed to parse: add `steward health surface unavailable` to the coverage-preamble `gaps:` list AND omit the Pipeline gaps subsection — do not emit a healthy-looking briefing without acknowledging this gap. +## Source Packet -> **CRITICAL: Tool error handling.** When any `sol call` tool returns an error, you MUST: -> 1. Record the error as a gap with the command or source that failed -> 2. Never treat the error message text as data — do not quote, summarize, or reason about the error content as if it were journal data -> 3. Note the gap in the coverage preamble -> 4. Continue the briefing using whatever data succeeded +### Active Facets -## Phase 2: Synthesize +$active_facets -Build five sections from the gathered data. **Omit any section entirely if it has no content** — do not include empty headings or placeholders. +### Facet Newsletters -### Section rules +$facet_newsletters -**Source attribution.** Attribute high-consequence factual claims to their source using inline parenthetical links with `sol://` URIs. Not every claim needs attribution — anticipated activities are self-evident and the Reading section is inherently attributed. +### Anticipated Activities Today -`sol://` URI construction: -- **Search results:** The header includes an `id` (e.g. `20260304/archon/143022_300/talents/followups.md:2`). Strip `:idx`, then strip `/talents/{agent}.md` → `sol://20260304/archon/143022_300`. -- **Facet newsletters:** `sol://facets/{facet}/news/{day_YYYYMMDD}`. +$anticipated_today -**Your Day** — What's ahead today. Lead with anticipated activities in chronological order. For each meeting, include who's attending and source-backed context from the gathered data when available. If no anticipated activities exist, lead with the highest-priority follow-ups or pulse needs. +### Anticipated Activities Next 7 Days -**Yesterday** — What happened. Draw from facet newsletters, pulse, and decisions agent output. 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. -Attribute each highlight to its source: `([facet newsletter](sol://facets/{facet}/news/{day}))`. -Grade highlights by evidence strength. **High** (corroborated by multiple sources — e.g., newsletter + decision + transcript): state assertively — "Shipped the entity pipeline refactor." **Medium** (single source, clear statement): attribute and present directly — "Closed three PRs on the data pipeline ([work newsletter](sol://...))." **Low** (inferred from ambiguous context, single passing mention): hedge — "Possible progress on the auth migration" or "May have discussed budget reallocation." When upstream decision output includes a `Confidence:` score, use it to inform grading: 0.85+ high, 0.50–0.84 medium, below 0.50 low. Never hedge items corroborated by multiple sources; never state single-mention inferences assertively. +$anticipated_forward -**Needs Attention** — Ranked action list. Synthesize from all sources into a single prioritized list: - 0. Pipeline gaps from yesterday's processing - 1. Overdue commitments and missed follow-ups - 2. Pending follow-ups (items flagged by the followups agent) - 3. Important pulse needs without calendar time blocked +### Pulse Surface - Do NOT include pipeline gaps when the steward health surface has no Needs your attention bullets. Zero noise on normal days. -Attribute commitments and follow-ups to the originating segment: `(committed [date](sol://...))`, `(flagged [date](sol://...))`. For inferred items: `(inferred from [source](sol://...))`. -Grade action items by evidence strength. **High** (explicit commitment with date, or overdue follow-up): state assertively — "Follow up on Series A term sheet — committed March 20, now overdue." **Medium** (flagged by followups agent with moderate confidence, or clear single-source item): present with attribution — "Review CI pipeline logs (flagged yesterday)." **Low** (inferred obligation from ambiguous mention, or low-confidence followup): hedge — "Possible commitment to send deck to investors" or "May need to follow up on the API discussion." When upstream followup output includes a `Confidence:` score, use it: 0.85+ high, 0.50–0.84 medium, below 0.50 low. Never hedge explicit commitments with clear dates; never present inferred obligations as definite action items. +$pulse_surface -**Forward Look** — What's coming. Draw from anticipated activity records and upcoming scheduled items (next 7 days). Note preparation needed for upcoming meetings or deadlines. -Attribute schedule-derived items: `(from [schedule](sol://...))`. Data source: `sol call activities list --source anticipated` or the schedule talent output path. -Grade forward items by evidence strength. **High** (confirmed scheduled item or explicit deadline): state assertively — "Board meeting Thursday — slides due Wednesday." **Medium** (schedule-derived activity record with clear basis): attribute and present — "Schedule extraction flagged quarterly review prep based on last quarter's timing." **Low** (speculative schedule inference or pattern-based prediction): hedge — "Possible need to prepare for investor update" or "May want to schedule design review based on sprint cadence." Never hedge confirmed scheduled items or explicit deadlines; never state pattern-based predictions as confirmed plans. +### Partner Surface -**Reading** — Links to full facet newsletters for deep dives. List each active facet that has a newsletter for the analysis day, with a brief one-line description of what it covers. This is the "detailed edition" for owners who want the full picture. Only include if facet newsletters exist. +$partner_surface -## Phase 3: Return the briefing +### Steward Health Surface -After gathering data and synthesizing, call `emit_final(content=)` with the complete briefing in this exact format: +$health_surface -``` ---- -type: morning_briefing -date: $day_YYYYMMDD -generated: [current ISO 8601 datetime] -model: [model identifier you are running as] -sources: - segments: [count] - anticipated_activities: [count] - facet_newsletters: [count] - followups: [count] - steward_health: [present|missing] -gaps: [list of gap descriptions, or empty list [] if none] ---- +### Follow-Ups -> [coverage preamble — 1-2 sentences summarizing source counts and gaps. Example: "Built from 12 transcript segments, 4 anticipated activities, 2 facet newsletters, and 5 follow-ups. No gaps." or with gaps: "Built from 8 segments, 2 activities. Gaps: no facet newsletters today."] +$followups -## Your Day -- **09:00** — Sync with Sarah Chen on Q2 roadmap. Last discussed launch timeline (from your [March standup](sol://20260313/archon/091500_300)). -- **14:00** — Design review with UX team. -[more items...] +### Decisions -## Yesterday -- Shipped the entity pipeline refactor ([work newsletter](sol://facets/work/news/20260326)). -[more items...] +$decisions -## Needs Attention -- Follow up on Series A term sheet — due yesterday (committed [March 20](sol://20260320/archon/101500_600)) -- Possible commitment to update onboarding docs — mentioned once in passing (inferred from [standup](sol://20260325/archon/091500_300)) -[more items...] +## Synthesis Rules -## Forward Look -- Board meeting Thursday — slides need review (confirmed on [calendar](sol://20260327/calendar)) -- May want to prepare quarterly metrics based on last quarter's timing (from [schedule](sol://20260327/talents/schedule)) -[more items...] +**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. -## Reading -[content — no attribution needed] -``` +**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. + +**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. + +**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. -Call `emit_final(content=)`. The `content` argument IS the briefing markdown (with YAML frontmatter and coverage preamble). Do not include any summary, preamble before the YAML frontmatter, explanation, follow-up commentary, or "here is the briefing" phrasing. Omit sections with no content entirely. +**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. -## Guidelines +## Evidence Strength -- Be concise and scannable. This is a morning read, not a report. -- Lead each section with the most important item. -- Use bullets, not paragraphs. -- Don't include greetings, sign-offs, or meta-commentary about being an AI. -- On a quiet day with minimal data, produce only the sections that have content. A briefing with just "Your Day" listing a few scheduled items or follow-ups is perfectly valid. +Grade highlights and action items by evidence strength. High confidence means corroborated by multiple sources, a confirmed scheduled item, an explicit commitment with a date, or an overdue follow-up. Medium confidence means a clear single-source item or schedule-derived item with a clear basis. Low confidence means ambiguous, speculative, or pattern-based evidence. Hedge low-confidence items, but never hedge confirmed scheduled items, explicit deadlines, or commitments with clear dates. diff --git a/solstone/talent/morning_briefing.py b/solstone/talent/morning_briefing.py new file mode 100644 index 000000000..3d749226a --- /dev/null +++ b/solstone/talent/morning_briefing.py @@ -0,0 +1,419 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Pre-hook for the morning briefing generate talent.""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + +from solstone.think.activities import load_activity_records +from solstone.think.facets import get_enabled_facets, get_facet_news +from solstone.think.indexer.journal import search_journal +from solstone.think.utils import get_journal + +logger = logging.getLogger(__name__) + + +def pre_process(config: dict) -> dict | None: + """Gather briefing sources and return template vars for generation.""" + if config.get("dry_run"): + logger.debug("morning briefing pre-hook dry_run: read-only gather") + + day = str(config.get("day") or "").strip() + if not day: + return {"skip_reason": "missing day"} + + try: + analysis_day = datetime.strptime(day, "%Y%m%d") + except ValueError: + return {"skip_reason": f"invalid day: {day}"} + + try: + journal_root = Path(get_journal()) + except Exception as exc: + logger.exception("morning briefing pre-hook could not resolve journal") + return {"skip_reason": f"journal unavailable: {exc}"} + + try: + packet = _build_packet( + day=day, + analysis_day=analysis_day, + journal_root=journal_root, + model=str(config.get("model") or "unknown"), + ) + except Exception as exc: + logger.exception("morning briefing pre-hook failed") + return {"skip_reason": f"morning briefing pre-hook failed: {exc}"} + + return {"template_vars": packet} + + +def _build_packet( + *, + day: str, + analysis_day: datetime, + journal_root: Path, + model: str, +) -> dict[str, str]: + gaps: list[str] = [] + counts: dict[str, int | str] = { + "segments": 0, + "anticipated_activities": 0, + "facet_newsletters": 0, + "followups": 0, + "steward_health": "missing", + } + + facets = _load_facets(gaps) + newsletters = _load_facet_newsletters(facets, day, gaps) + anticipated_today = _load_anticipated_activities( + facets, + [day], + gaps, + empty_gap="no anticipated activities today", + ) + forward_days = [ + (analysis_day + timedelta(days=offset)).strftime("%Y%m%d") + for offset in range(1, 8) + ] + anticipated_forward = _load_anticipated_activities( + facets, + forward_days, + gaps, + empty_gap="no anticipated activities in the next 7 days", + ) + followups_total, followup_results = _search_agent( + day, + "followups", + "follow-up items", + gaps, + ) + decisions_total, decision_results = _search_agent( + day, + "decisions", + "decision items", + gaps, + ) + + pulse = _read_identity_file(journal_root, "pulse.md", "pulse surface", gaps) + partner = _read_identity_file(journal_root, "partner.md", "partner profile", gaps) + health = _read_identity_file( + journal_root, + "health.md", + "steward health surface", + gaps, + ) + + counts["facet_newsletters"] = len(newsletters) + counts["anticipated_activities"] = len(anticipated_today) + counts["followups"] = len(followup_results) + counts["steward_health"] = "present" if health else "missing" + counts["segments"] = len(_distinct_result_paths(followup_results, decision_results)) + + return { + "generated": datetime.now().isoformat(timespec="seconds"), + "model": model, + "active_facets": _render_facets(facets), + "facet_newsletters": _render_newsletters(newsletters), + "anticipated_today": _render_activities(anticipated_today), + "anticipated_forward": _render_activities( + anticipated_forward, group_by_day=True + ), + "pulse_surface": pulse or "(missing)", + "partner_surface": partner or "(missing)", + "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, + ), + } + + +def _load_facets(gaps: list[str]) -> dict[str, dict[str, object]]: + try: + facets = get_enabled_facets() + except Exception as exc: + logger.warning("morning briefing facets unavailable: %s", exc) + gaps.append(f"active facets unavailable: {exc}") + return {} + if not facets: + gaps.append("no active facets available") + return facets + + +def _load_facet_newsletters( + facets: dict[str, dict[str, object]], + day: str, + gaps: list[str], +) -> list[dict[str, str]]: + newsletters: list[dict[str, str]] = [] + for facet in sorted(facets): + try: + payload = get_facet_news(facet, day=day, limit=1) + except Exception as exc: + logger.warning("morning briefing news unavailable for %s: %s", facet, exc) + gaps.append(f"facet newsletter unavailable for {facet}: {exc}") + continue + days = payload.get("days") if isinstance(payload, dict) else None + day_payload = days[0] if isinstance(days, list) and days else None + raw_content = "" + if isinstance(day_payload, dict): + raw_content = str(day_payload.get("raw_content") or "").strip() + if raw_content: + newsletters.append({"facet": facet, "day": day, "content": raw_content}) + else: + gaps.append(f"no facet newsletter available for {facet}") + if facets and not newsletters: + gaps.append("no facet newsletters available") + return newsletters + + +def _load_anticipated_activities( + facets: dict[str, dict[str, object]], + days: list[str], + gaps: list[str], + *, + empty_gap: str, +) -> list[dict[str, Any]]: + activities: list[dict[str, Any]] = [] + for day in days: + for facet in sorted(facets): + try: + records = load_activity_records(facet, day, include_hidden=False) + except Exception as exc: + logger.warning( + "morning briefing activities unavailable for %s/%s: %s", + facet, + day, + exc, + ) + gaps.append( + f"anticipated activities unavailable for {facet} {day}: {exc}" + ) + continue + for record in records: + if record.get("source") != "anticipated": + continue + item = dict(record) + item["facet"] = str(item.get("facet") or facet) + item["day"] = str(item.get("target_date") or day) + activities.append(item) + activities.sort( + key=lambda item: ( + str(item.get("day") or ""), + str(item.get("start") or ""), + str(item.get("facet") or ""), + str(item.get("title") or ""), + ) + ) + if facets and not activities: + gaps.append(empty_gap) + return activities + + +def _search_agent( + day: str, + agent: str, + label: str, + gaps: list[str], +) -> tuple[int, list[dict[str, Any]]]: + try: + total, results = search_journal("", limit=10, day=day, agent=agent) + except Exception as exc: + logger.warning("morning briefing %s search unavailable: %s", agent, exc) + gaps.append(f"{label} search unavailable: {exc}") + return 0, [] + if not results: + gaps.append(f"no {label} found") + return total, results + + +def _read_identity_file( + journal_root: Path, + file_name: str, + label: str, + gaps: list[str], +) -> str: + path = journal_root / "identity" / file_name + if not path.exists(): + gaps.append(f"{label} missing") + return "" + try: + content = path.read_text(encoding="utf-8").strip() + except Exception as exc: + logger.warning( + "morning briefing identity read failed for %s: %s", file_name, exc + ) + gaps.append(f"{label} unavailable: {exc}") + return "" + if not content: + gaps.append(f"{label} empty") + return content + + +def _render_facets(facets: dict[str, dict[str, object]]) -> str: + if not facets: + return "(none)" + lines = [] + for name, meta in sorted(facets.items()): + title = str(meta.get("title") or name) + lines.append(f"- {name}: {title}") + return "\n".join(lines) + + +def _render_newsletters(newsletters: list[dict[str, str]]) -> str: + if not newsletters: + return "(none)" + blocks = [] + for item in newsletters: + blocks.append( + "\n".join( + [ + f"### {item['facet']} newsletter", + f"Source: sol://facets/{item['facet']}/news/{item['day']}", + item["content"], + ] + ) + ) + return "\n\n".join(blocks) + + +def _render_activities( + activities: list[dict[str, Any]], + *, + group_by_day: bool = False, +) -> str: + if not activities: + return "(none)" + lines: list[str] = [] + last_day: str | None = None + for item in activities: + day = str(item.get("day") or "") + if group_by_day and day != last_day: + if lines: + lines.append("") + lines.append(f"### {day}") + last_day = day + time_text = _activity_time(item) + title = str(item.get("title") or item.get("activity") or "Untitled activity") + activity = str(item.get("activity") or "activity") + facet = str(item.get("facet") or "unknown") + participants = _activity_participants(item) + detail = f"- {time_text} {title} [{activity}, {facet}]" + if participants: + detail += f" - participants: {participants}" + lines.append(detail) + return "\n".join(lines) + + +def _activity_time(item: dict[str, Any]) -> str: + start = _short_time(item.get("start")) + end = _short_time(item.get("end")) + if start and end: + return f"{start}-{end}" + if start: + return start + return "unscheduled" + + +def _short_time(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + return text[:5] if len(text) >= 5 else text + + +def _activity_participants(item: dict[str, Any]) -> str: + names: list[str] = [] + participation = item.get("participation") + if isinstance(participation, list): + for entry in participation: + if not isinstance(entry, dict): + continue + name = str(entry.get("name") or entry.get("entity_id") or "").strip() + if name: + names.append(name) + if not names: + active_entities = item.get("active_entities") + if isinstance(active_entities, list): + names = [ + str(value).strip() for value in active_entities if str(value).strip() + ] + return ", ".join(names) + + +def _render_search_results(results: list[dict[str, Any]]) -> str: + if not results: + return "(none)" + blocks = [] + for result in results: + metadata = result.get("metadata") or {} + source_id = str(result.get("id") or "") + facet = str(metadata.get("facet") or "unknown") + day = str(metadata.get("day") or "unknown") + text = str(result.get("text") or "").strip() + blocks.append(f"- {source_id} [{day}, {facet}]\n {text}") + return "\n".join(blocks) + + +def _distinct_result_paths( + *result_groups: list[dict[str, Any]], +) -> set[str]: + paths: set[str] = set() + for results in result_groups: + for result in results: + metadata = result.get("metadata") or {} + path = str(metadata.get("path") or result.get("id") or "").strip() + if path: + paths.add(path) + 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], + *, + decisions_total: int, + forward_count: int, + followups_total: int, +) -> str: + parts = [ + f"{counts['segments']} indexed source paths", + f"{counts['anticipated_activities']} anticipated activities today", + f"{forward_count} forward-looking anticipated activities", + f"{counts['facet_newsletters']} facet newsletters", + f"{counts['followups']} follow-ups", + f"{decisions_total} decision results", + ] + sentence = "Built from " + ", ".join(parts) + "." + if followups_total > counts["followups"]: + sentence += f" Follow-up search returned {followups_total} total matches." + if gaps: + sentence += " Gaps: " + "; ".join(gaps) + "." + else: + sentence += " No gaps." + return sentence diff --git a/tests/test_cogitate_contract_harness.py b/tests/test_cogitate_contract_harness.py index 7eaefbe6b..41c727027 100644 --- a/tests/test_cogitate_contract_harness.py +++ b/tests/test_cogitate_contract_harness.py @@ -31,7 +31,7 @@ EMIT_FINAL_SCHEDULES = {"daily", "weekly", "activity"} ("name", "guard", "expected_finalizer"), [ ( - "morning_briefing", + "weekly_reflection", lambda c: c.get("schedule") in EMIT_FINAL_SCHEDULES, "emit_final", ), diff --git a/tests/test_entity_describe_pre_hook.py b/tests/test_entity_describe_pre_hook.py new file mode 100644 index 000000000..5550255a8 --- /dev/null +++ b/tests/test_entity_describe_pre_hook.py @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +import importlib.util +from pathlib import Path + + +def _load_entity_describe_module(): + path = ( + Path(__file__).resolve().parents[1] + / "solstone" + / "apps" + / "entities" + / "talent" + / "entity_describe.py" + ) + spec = importlib.util.spec_from_file_location("test_entity_describe_hook", path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _prompt(current: str = "Existing description") -> str: + return "\n".join( + [ + "Entity Type: Person", + "Entity Name: Alice Example", + "Facet: work", + f"Current Description: {current}", + ] + ) + + +def test_entity_describe_pre_hook_renders_found_evidence(monkeypatch): + module = _load_entity_describe_module() + + monkeypatch.setattr( + module, + "search_journal", + lambda query, limit, facet: ( + 1, + [ + { + "id": "20260422/work/090000_300/talents/sense.md:0", + "text": "Alice Example led the rollout planning.", + "metadata": { + "day": "20260422", + "facet": "work", + }, + } + ], + ), + ) + + vars_ = module.pre_process({"prompt": _prompt()})["template_vars"] + + assert vars_["entity_type"] == "Person" + assert vars_["entity_name"] == "Alice Example" + assert vars_["facet"] == "work" + assert vars_["current_description"] == "Existing description" + assert "Alice Example led the rollout planning." in vars_["evidence"] + + +def test_entity_describe_pre_hook_empty_evidence_preserves_generic_inputs( + monkeypatch, +): + module = _load_entity_describe_module() + monkeypatch.setattr(module, "search_journal", lambda query, limit, facet: (0, [])) + + vars_ = module.pre_process({"prompt": _prompt("(none)")})["template_vars"] + + assert vars_["entity_type"] == "Person" + assert vars_["entity_name"] == "Alice Example" + assert vars_["facet"] == "work" + assert vars_["current_description"] == "(none)" + assert vars_["evidence"] == "No journal evidence found for this entity." + + +def test_entity_describe_ad_hoc_generate_has_no_output_path(): + from solstone.think.talents import prepare_config + + config = prepare_config( + { + "name": "entities:entity_describe", + "prompt": _prompt(), + } + ) + + assert config["type"] == "generate" + assert config["output"] == "md" + assert "output_path" not in config diff --git a/tests/test_morning_briefing_pre_hook.py b/tests/test_morning_briefing_pre_hook.py new file mode 100644 index 000000000..c72e93b21 --- /dev/null +++ b/tests/test_morning_briefing_pre_hook.py @@ -0,0 +1,146 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +import json + +from solstone.talent import morning_briefing + + +def _result(day: str = "20260422") -> dict: + return { + "id": "20260422/work/090000_300/talents/followups.md:0", + "text": "Follow up with Alice about the launch checklist.", + "metadata": { + "day": day, + "facet": "work", + "agent": "followups", + "stream": "work", + "path": f"{day}/work/090000_300/talents/followups.md", + "idx": 0, + }, + "score": -1.0, + } + + +def test_morning_briefing_pre_hook_builds_source_packet(tmp_path, monkeypatch): + journal = tmp_path / "journal" + identity = journal / "identity" + identity.mkdir(parents=True) + (identity / "pulse.md").write_text("Pulse needs focus time.", encoding="utf-8") + (identity / "partner.md").write_text("Partner profile.", encoding="utf-8") + (identity / "health.md").write_text( + "## Needs your attention\n\nnone", encoding="utf-8" + ) + + monkeypatch.setattr(morning_briefing, "get_journal", lambda: str(journal)) + monkeypatch.setattr( + morning_briefing, + "get_enabled_facets", + lambda: {"work": {"title": "Work"}}, + ) + monkeypatch.setattr( + morning_briefing, + "get_facet_news", + lambda facet, **kwargs: { + "days": [{"date": kwargs["day"], "raw_content": "Work shipped a release."}] + }, + ) + + def fake_load_activity_records(facet, day, *, include_hidden=False): + if day == "20260422": + return [ + { + "source": "anticipated", + "activity": "meeting", + "target_date": "20260422", + "start": "09:00:00", + "end": "10:00:00", + "title": "Planning meeting", + "participation": [{"name": "Alice"}], + } + ] + if day == "20260423": + return [ + { + "source": "anticipated", + "activity": "deadline", + "target_date": "20260423", + "start": "17:00:00", + "title": "Proposal deadline", + "active_entities": ["Bob"], + } + ] + return [] + + monkeypatch.setattr( + morning_briefing, + "load_activity_records", + fake_load_activity_records, + ) + monkeypatch.setattr( + morning_briefing, + "search_journal", + lambda query, limit, day, agent: (1, [_result(day)]), + ) + + packet = morning_briefing.pre_process({"day": "20260422", "model": "test-model"})[ + "template_vars" + ] + + expected = { + "active_facets", + "facet_newsletters", + "anticipated_today", + "anticipated_forward", + "pulse_surface", + "partner_surface", + "health_surface", + "followups", + "decisions", + "source_counts", + "source_gaps", + "coverage_preamble", + } + assert expected <= set(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 " anticipated_activities: 1" in packet["source_counts"] + assert json.loads(packet["source_gaps"]) == [] + + +def test_morning_briefing_pre_hook_missing_sources_are_visible_gaps( + tmp_path, monkeypatch +): + journal = tmp_path / "journal" + journal.mkdir() + + monkeypatch.setattr(morning_briefing, "get_journal", lambda: str(journal)) + monkeypatch.setattr( + morning_briefing, + "get_enabled_facets", + lambda: {"work": {"title": "Work"}}, + ) + monkeypatch.setattr( + morning_briefing, + "get_facet_news", + lambda facet, **kwargs: {"days": []}, + ) + monkeypatch.setattr( + morning_briefing, + "load_activity_records", + lambda facet, day, *, include_hidden=False: [], + ) + monkeypatch.setattr( + morning_briefing, + "search_journal", + lambda query, limit, day, agent: (0, []), + ) + + packet = morning_briefing.pre_process({"day": "20260422"})["template_vars"] + gaps = json.loads(packet["source_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"] diff --git a/tests/test_morning_briefing_steward_migration.py b/tests/test_morning_briefing_steward_migration.py index 459a31241..1ad899503 100644 --- a/tests/test_morning_briefing_steward_migration.py +++ b/tests/test_morning_briefing_steward_migration.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc +import json from pathlib import Path @@ -8,21 +9,33 @@ def _briefing_prompt() -> str: return Path("solstone/talent/morning_briefing.md").read_text(encoding="utf-8") -def test_morning_briefing_reads_steward_health_surface(): - prompt = _briefing_prompt() +def _briefing_metadata() -> dict: + text = _briefing_prompt() + metadata, end = json.JSONDecoder().raw_decode(text) + assert isinstance(metadata, dict) + assert text[end:].startswith("\n\n") + return metadata + - # C3/C4: the steward health surface is now read via the raw-read tool - # (`identity/health.md`) rather than the bare `journal identity health` - # command — the runtime contract routes no-`sol call`-verb evidence through - # the read tools. - assert "`identity/health.md`" in prompt - assert "`journal identity health`" not in prompt - assert "`sol call health pipeline --yesterday`" not in prompt +def test_morning_briefing_is_generate_with_pre_hook(): + metadata = _briefing_metadata() + assert metadata["type"] == "generate" + assert metadata["output"] == "md" + assert metadata["schedule"] == "daily" + assert metadata["hook"]["pre"] == "morning_briefing" + assert "read_scope" not in metadata -def test_morning_briefing_omits_migrated_pipeline_phrasings(): + +def test_morning_briefing_prompt_uses_injected_packet_only(): prompt = _briefing_prompt() - assert "Pipeline gap:" not in prompt - assert "Pipeline issue:" not in prompt - assert "steward health surface unavailable" in prompt + assert "$health_surface" in prompt + assert "gaps: $source_gaps" in prompt + assert "$coverage_preamble" in prompt + assert "Steward Health Surface" in prompt + + assert "sol call" not in prompt + assert "read_file" not in prompt + assert "emit_final" not in prompt + assert "FinishTool" not in prompt -- 2.51.2