diff --git a/solstone/apps/entities/talent/entities_review.md b/solstone/apps/entities/talent/entities_review.md index 264eaa5c4..e6cf13135 100644 --- a/solstone/apps/entities/talent/entities_review.md +++ b/solstone/apps/entities/talent/entities_review.md @@ -1,247 +1,42 @@ { - "type": "cogitate", - + "type": "generate", + "tier": 2, "title": "Entity Reviewer", "description": "Reviews detected entities and promotes recurring ones to attached status", "color": "#00796b", "schedule": "daily", "priority": 56, "multi_facet": true, - "group": "Entities" + "group": "Entities", + "output": "json", + "schema": "entities_review.schema.json", + "thinking_budget": 2048, + "hook": {"pre": "entities:entities_review", "post": "entities:entities_review"}, + "load": {"transcripts": false, "percepts": false, "talents": false} } -$facets - -## Core Mission - -Review detected entities from recent days within a specific facet and promote frequently-occurring, unambiguous entities to permanent attached status for that facet. Identify entities that have demonstrated consistent relevance to this facet and synthesize timeless descriptions from multiple day-specific contexts. - -## Input Context - -You receive: -1. **Facet context** - the specific facet (e.g., "personal", "work") you are reviewing entities for -2. **Current date/time** - to compute the review window (last 7 days) -3. **Attached entities for THIS facet** - via `sol call entities list` to avoid re-promoting to this facet -4. **Detection history for THIS facet** - via `sol call entities list -d DAY` for each recent day within this facet - -## Tooling - -SOL_DAY and SOL_FACET are set in your environment. Commands default to the current day and facet — only pass explicit values to override. - -- `sol call entities list` - list entities currently attached to THIS facet (returns entities with entity_id) -- `sol call entities list -d DAY` - list entities detected for THIS facet on a specific day -- `sol call entities attach TYPE ENTITY DESCRIPTION` - promote entity to attached status FOR THIS FACET - - The `entity` parameter becomes the entity name if creating new; if it matches an existing attached entity, returns that instead -- `sol call entities aka ENTITY AKA` - add an alias/abbreviation to an attached entity FOR THIS FACET - - The `entity` parameter can be entity_id, full name, or existing alias -- `sol call entities merge-candidates --json` - list merge candidates already recorded (any facet), so you reflect prior decisions -- `sol call entities record-merge-candidate VARIANT CANONICAL --evidence "..." [--basis name-variant] [--detections N] [--needs N]` - durably record a proposed merge (VARIANT folds into CANONICAL) - -## Review Process - -### Phase 1: Aggregate Recent Detections - -1. Compute the last 7 days in YYYYMMDD format (e.g., if today is 20250115, review 20250108-20250114) -2. Load attached entities for THIS facet: `sol call entities list` - skip entities already attached to this facet -3. Load existing merge candidates: `sol call entities merge-candidates --json`. Note any already recorded (including accepted or dismissed) so your report reflects prior decisions. You still record today's findings below regardless — recording an existing pair only refreshes its evidence and never overturns a prior accept/dismiss. -4. Load detected entities for THIS facet for each of the last 7 days: - - `sol call entities list -d 20250114` - detections for this facet on this day - - `sol call entities list -d 20250113` - detections for this facet on this day - - ... continue for all 7 days - -5. Aggregate detections by entity name (only detections from THIS facet): - - Count how many days each entity appeared in this facet - - Collect all descriptions for each entity from this facet's detections - - Note the entity type from each detection - -**Example aggregation:** -``` -"Sarah Chen": - - Day 1 (20250114): Person, "reviewed PR #1234 and approved migration" - - Day 2 (20250113): Person, "discussed architecture in standup" - - Day 3 (20250112): Person, "pair programmed on auth system" - Count: 3 days, Type: Person (consistent) -``` - -### Phase 2: Apply Promotion Criteria - -Auto-promote entities based on **type-specific thresholds**: - -**Priority-Based Frequency Requirements:** - -1. **High Priority - People and Contacts** (promote readily): - - Require: 2+ detections in last 7 days - - Rationale: People are highest priority, capture all important relationships - - Even 2 appearances indicates ongoing relevance - - Type: Person - -2. **Medium Priority - Companies and Projects** (selective): - - Companies: Require 3+ detections in last 7 days - - Projects: Require 3-4+ detections in last 7 days - - Rationale: Only important business relationships and central projects warrant promotion - - Types: Company, Project, or other appropriate descriptors - -3. **Low Priority - Tools and Resources** (very rare): - - Require: 5+ detections in last 7 days - - Rationale: Resources should only be promoted if extensively discussed - - High bar prevents clutter from incidental mentions - - Type: Tool, or other appropriate resource descriptor - -**Universal Requirements (all types):** - -**Type Consistency**: Same entity type across all detections -- All detections agree on the entity type (e.g., Person, Company, Project, Tool) -- No ambiguity (e.g., "Apple" as both Company and Project) - -**Not Already Attached to THIS Facet**: Entity name not in `sol call entities list` results -- Avoid duplicates within this facet -- Name matching should be exact (case-sensitive) -- Note: An entity may be attached to OTHER facets, but not to this one - that's OK to promote - -**Quality**: Descriptions are meaningful and consistent -- Multiple contexts provide clear picture -- Not contradictory or confusing - -### Phase 3: Description Synthesis - -For each entity selected for promotion, synthesize a timeless description: - -**Remove Day-Specific Details:** -- "reviewed PR #1234 yesterday" → "senior engineer on backend team" -- "sent contract on Monday" → "contract lawyer for vendor agreements" -- "fixed bug in API Gateway" → "microservices architecture project" - -**Combine Multiple Contexts:** -- Detection 1: "discussed API migration" -- Detection 2: "reviewed database schema" -- Detection 3: "led standup meeting" -- Synthesis: "senior backend engineer, leads database and API work" - -**Keep Essential Context:** -- Role/relationship (colleague, client, friend, vendor) -- Facet relevance (what they do, why they matter) -- Key attributes that aid recognition - -**Format:** -- Concise (under 100 characters preferred) -- Professional tone -- Helpful for future context loading - -### Phase 4: Execute Promotions - -For each entity meeting promotion criteria: - -```bash -sol call entities attach Person "Sarah Chen" "senior backend engineer, leads database and API work" -``` - -### Phase 5: Detect and Add Aliases - -After promotions, review detected entities for name variations and add them as structured aliases. - -**Alias detection patterns:** -- Nicknames: "Robert Johnson" detected as "Bob Johnson" or "Bob" → add aka: "Bob" -- Acronyms: "Federal Aviation Administration" detected as "FAA" → add aka: "FAA" -- Abbreviations: "PostgreSQL" detected as "Postgres" or "PG" → add aka: "Postgres", "PG" -- Short forms: "Anthropic PBC" detected as "Anthropic" → add aka: "Anthropic" - -**Execution (use entity_id or name for the entity parameter):** -```bash -sol call entities aka federal_aviation_administration FAA -sol call entities aka PostgreSQL Postgres -sol call entities aka postgresql PG -``` - -**When to add aliases:** -- Different name form appeared 2+ times in detections -- Alias is unambiguous (not shared with other entities) -- Natural nickname, acronym, or common abbreviation - -**Benefits:** Improves audio transcription recognition and search without cluttering descriptions. - -**After all promotions:** -- Summarize promotions and aliases -- Example: "Promoted 3 entities: Sarah Chen [+aka: Bob] (5 detections), API Gateway (5 detections), Federal Aviation Administration [+aka: FAA] (6 detections)" - -### Phase 5b: Record Merge Candidates - -For every clear name-variant pair (a VARIANT form and the CANONICAL form it refers to), durably record a merge candidate in addition to any `aka` you add. - -**Direction rule:** CANONICAL is the target/name to keep; VARIANT is the source folded in. Default to the more-detected name as canonical. On a tie, keep the shorter everyday form and drop corporate suffixes like "Inc", "LLC", or "Corp"; for a person's nickname vs full proper name, keep the FULL proper name as canonical. Example: "Kognova Inc" (variant) folds into "Kognova" (canonical). - -**Execution:** -```bash -sol call entities record-merge-candidate "" "" --evidence "" --basis name-variant --detections [--needs ] -``` -Facet comes from SOL_FACET, and day comes from SOL_DAY. - -Only record a genuine variant→canonical pair. A single name merely near its promotion threshold with NO variant is NOT a merge candidate — leave it in the emit_final report only. Recording is idempotent: re-recording refreshes evidence, never duplicates or overturns a prior accept/dismiss. - -## Smart Duplicate Handling - -**Substring Matches:** -If detected name is substring of entity already attached to THIS facet: -- "Sarah" detected, "Sarah Chen" already attached to this facet → skip "Sarah" -- Prevents fragmentary duplicates within this facet - -**Nickname Variations:** -If multiple variations of same person detected: -- "Robert Johnson" (3x) and "Bob" (2x) both detected (5 total) -- Promote with full name, count all variations toward threshold -- Add nickname in Phase 5 using `sol call entities aka` +## Your Job -**Company Abbreviations:** -If both full name and abbreviation detected: -- "Federal Aviation Administration" (2x) and "FAA" (4x) both detected (6 total) -- Promote with full name, count all variations toward threshold -- Add acronym in Phase 5 using `sol call entities aka` +You are given recurring people and things noticed across recent days in one area of the owner's life. You are also given possible name variants and prior merge decisions. Your job is judgment only: decide what deserves stable saved context and how duplicate-looking names should be handled. -## Quality Guidelines +## What You're Given -### DO: -- Review full 7-day window systematically -- Apply priority-based thresholds (People: 2+, Companies/Projects: 3-4+, Tools: 5+) -- Prioritize person promotions (lowest threshold) -- Be selective with companies and conservative with projects -- Be very strict with tool/resource promotions -- Synthesize descriptions from multiple contexts -- Remove day-specific temporal references -- Check for exact name matches with attached entities +$review_packet -### DON'T: -- Promote people with only 1 detection (but 2+ is ok) -- Promote organizations/projects/tools below their thresholds -- Promote if entity type is inconsistent across detections -- Promote if already attached to THIS facet (check first) -- Use day-specific descriptions in attached entities -- Batch-promote without individual evaluation -- Promote tools that were just used (require 5+ active discussions) +## How To Judge -## Interaction Protocol +Promote a candidate when the evidence points to a clear, recurring person, company, project, tool, or other named thing that will help future context in this area. Decline only when the candidate is genuinely ambiguous, contradictory, too generic, or not useful as a stable saved entity. -When invoked: -1. Announce the SPECIFIC FACET you are reviewing and the review window (last 7 days) -2. Load entities attached to THIS facet for comparison -3. Load detected entities for THIS facet from last 7 days -4. Aggregate by entity name (within this facet), count occurrences -5. Filter by priority-based promotion criteria: - - People: 2+, Companies/Projects: 3-4+, Tools: 5+ - - Type consistent, not already attached to THIS facet -6. Synthesize timeless descriptions for qualifying entities -7. Execute `sol call entities attach` for each promotion to THIS facet -8. Detect name variations and execute `sol call entities aka` for aliases -9. Call `emit_final(content=)` exactly once. Include promoted entities, aliases added, merge candidates recorded, near-threshold entities, and skipped ambiguities. +For each candidate, write one timeless description. Describe who or what it is and why it matters to this area. Strip out day-specific details, fold the repeated contexts together, and keep the description concise. -## Edge Cases +Suggest aliases only when a nickname, acronym, abbreviation, or common alternate form clearly refers to the promoted candidate. Do not add aliases that could point to another entity. -**No Detections**: If no entities meet their priority-based thresholds for THIS facet, call `emit_final(content="No entities qualify for promotion this cycle for $facet.")` +For variant-pair hints, decide whether the two names are truly the same thing. If they are, record a merge direction: `canonical` is the name to keep, and `source` is the form that folds in. For a person's nickname versus full name, keep the fuller proper name. For organizations, prefer the shorter everyday form and drop corporate suffixes like "Inc", "LLC", or "Corp". Otherwise keep the form that recurred more. Reflect prior merge decisions shown in the input; refresh them when they still look right, never overturn them. -**All Already Attached**: If all qualifying entities are already attached to THIS facet, call `emit_final(content="All recurring entities already attached to $facet.")` +## What To Return -**Type Conflicts**: If entity name appears with different types within THIS facet's detections (e.g., "Mercury" as Company and Project), skip and report the ambiguity for manual review +Return exactly one JSON object: -**Below Threshold**: Report entities close to promotion separately: -- "3 entities near promotion for [facet]: Alice (Person, 1 detection - needs 1 more), Acme Corp (Company, 2 detections - needs 1 more)" -Near-threshold entities that are part of a variant pair are recorded as merge candidates in Phase 5b; standalone near-threshold names with no variant stay in this report only. +`{"promotions":[{"name":"...","description":"...","promote":true,"aliases":["..."]}],"merges":[{"source":"...","canonical":"...","evidence":"..."}]}` -Remember: Promotion is a facet-specific one-way operation. Only promote entities with clear evidence of consistent relevance to THIS facet and unambiguous identity. Apply strict priority-based thresholds to maintain quality within this facet. +Use the exact candidate names from the input. Include every promotion decision in `promotions`, with `promote: false` for declined candidates. Use an empty `aliases` array when there are no aliases. Use an empty `merges` array when no variant pair should be recorded. diff --git a/solstone/apps/entities/talent/entities_review.py b/solstone/apps/entities/talent/entities_review.py new file mode 100644 index 000000000..044eed65b --- /dev/null +++ b/solstone/apps/entities/talent/entities_review.py @@ -0,0 +1,488 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timedelta +from itertools import combinations +from typing import Any + +from solstone.think.entities import ( + AkaConflictError, + EntityBlockedError, + EntityExistsError, + EntityNotFoundError, + EntityWriteError, + add_entity_aka, + attach_or_reactivate_entity, + detected_entities_path, + entity_slug, + find_matching_entity, + is_name_variant_match, + load_entities, +) +from solstone.think.entities.review_candidates import ( + load_candidates, + record_merge_candidate, +) +from solstone.think.journal_io import LockTimeout +from solstone.think.utils import now_ms + +logger = logging.getLogger(__name__) + +REVIEW_WINDOW_DAYS = 7 +TYPE_THRESHOLDS = {"Person": 2, "Company": 3, "Project": 3, "Tool": 5} +DEFAULT_THRESHOLD = 5 + + +def _format_name_key(name: str) -> tuple[str, str]: + return (name.casefold(), name) + + +def _review_days(day: str) -> list[str]: + run_day = datetime.strptime(day, "%Y%m%d") + # Offset 1 excludes the run day; out-of-lode VPE can flip this to 0. + start_offset = 1 + return [ + (run_day - timedelta(days=i)).strftime("%Y%m%d") + for i in range(REVIEW_WINDOW_DAYS + start_offset - 1, start_offset - 1, -1) + ] + + +def _find_display_name(contexts: list[dict[str, str]]) -> str: + latest_day = max(context["day"] for context in contexts) + latest_names = [ + context["name"] for context in contexts if context["day"] == latest_day + ] + return sorted(latest_names, key=_format_name_key)[0] + + +def _format_contexts(contexts: list[dict[str, str]]) -> list[dict[str, str]]: + return sorted( + contexts, + key=lambda context: ( + context["day"], + context["name"].casefold(), + context["name"], + context["description"], + ), + ) + + +def _compute_variant_hints(names: set[str]) -> list[tuple[str, str]]: + hints: set[tuple[str, str]] = set() + for name_a, name_b in combinations(sorted(names, key=_format_name_key), 2): + slug_a = entity_slug(name_a) + slug_b = entity_slug(name_b) + if slug_a == slug_b: + continue + if not is_name_variant_match(name_a, name_b): + continue + hints.add(tuple(sorted((name_a, name_b), key=_format_name_key))) + return sorted( + hints, key=lambda pair: (_format_name_key(pair[0]), _format_name_key(pair[1])) + ) + + +def _load_prior_merges(facet: str) -> list[dict[str, str]]: + prior: list[dict[str, str]] = [] + for row in load_candidates(): + if row.get("facet") != facet: + continue + source = row.get("source") + target = row.get("target") + status = row.get("status") + if not isinstance(source, str) or not isinstance(target, str): + continue + prior.append( + { + "source": source, + "canonical": target, + "status": str(status or "open"), + } + ) + return sorted( + prior, + key=lambda row: ( + row["source"].casefold(), + row["canonical"].casefold(), + row["status"], + ), + ) + + +def build_review_inputs( + facet: str, + day: str, +) -> tuple[list[dict[str, Any]], list[tuple[str, str]], list[dict[str, str]]]: + buckets: dict[str, dict[str, Any]] = {} + detected_names: set[str] = set() + + for review_day in _review_days(day): + for entity in load_entities(facet, review_day): + name = str(entity.get("name") or "").strip() + entity_type = str(entity.get("type") or "").strip() + description = str(entity.get("description") or "").strip() + slug = entity_slug(name) + if not name or not slug or not entity_type: + continue + + bucket = buckets.setdefault( + slug, + {"days": set(), "types": set(), "contexts": []}, + ) + bucket["days"].add(review_day) + bucket["types"].add(entity_type) + bucket["contexts"].append( + { + "day": review_day, + "name": name, + "description": description, + } + ) + detected_names.add(name) + + attached = load_entities(facet) + eligible: list[dict[str, Any]] = [] + for slug, bucket in buckets.items(): + types = bucket["types"] + if len(types) != 1: + continue + entity_type = next(iter(types)) + day_count = len(bucket["days"]) + if day_count < TYPE_THRESHOLDS.get(entity_type, DEFAULT_THRESHOLD): + continue + + contexts = _format_contexts(bucket["contexts"]) + name = _find_display_name(contexts) + match = find_matching_entity(name, attached) + if match and match.is_high_confidence: + continue + + eligible.append( + { + "name": name, + "slug": slug, + "type": entity_type, + "day_count": day_count, + "contexts": contexts, + } + ) + + eligible.sort( + key=lambda item: (_format_name_key(str(item["name"])), str(item["type"])) + ) + return eligible, _compute_variant_hints(detected_names), _load_prior_merges(facet) + + +def _format_review_packet( + eligible: list[dict[str, Any]], + variant_hints: list[tuple[str, str]], + prior_merges: list[dict[str, str]], +) -> str: + lines = [ + "These are recurring people and things noticed across recent days in one area of the owner's life.", + "Judge from the facts below. Save stable, useful context; leave ambiguity out.", + "", + "## Recurring candidates", + "", + ] + + if eligible: + for item in eligible: + lines.extend( + [ + f"### {item['name']}", + f"Type: {item['type']}", + f"Distinct days seen: {item['day_count']}", + "What happened:", + ] + ) + for context in item["contexts"]: + description = context["description"] or "No description saved." + lines.append(f"- {context['day']}: {context['name']} — {description}") + lines.append("") + else: + lines.extend(["None.", ""]) + + lines.extend(["## Possible name variants", ""]) + if variant_hints: + for name_a, name_b in variant_hints: + lines.append(f"- {name_a} / {name_b}") + else: + lines.append("None.") + lines.append("") + + lines.extend(["## Prior merge decisions", ""]) + if prior_merges: + for row in prior_merges: + lines.append(f"- {row['source']} -> {row['canonical']} ({row['status']})") + else: + lines.append("None.") + + return "\n".join(lines).strip() + "\n" + + +def pre_process(context: dict) -> dict | None: + day = context.get("day") + if not day: + return {"skip_reason": "no_day"} + facet = context.get("facet") + if not facet: + return {"skip_reason": "no_facet"} + + eligible, variant_hints, prior_merges = build_review_inputs(str(facet), str(day)) + if not eligible and not variant_hints: + return {"skip_reason": "no_candidates"} + + return { + "template_vars": { + "review_packet": _format_review_packet( + eligible, + variant_hints, + prior_merges, + ) + } + } + + +def _write_outcome( + facet: str, + day: str, + counts: dict[str, int], + error: str | None, +) -> None: + payload = {**counts, "error": error, "ts": now_ms()} + out = detected_entities_path(facet, day).parent / f"{day}_review_outcome.json" + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(payload, ensure_ascii=False) + "\n", encoding="utf-8") + + +def _apply_aliases( + *, + facet: str, + entity_id: str, + canonical_name: str, + canonical_slug: str, + aliases: list, + counts: dict[str, int], +) -> str | None: + error: str | None = None + for alias in aliases: + if not isinstance(alias, str) or not alias.strip(): + counts["skipped"] += 1 + continue + clean_alias = alias.strip() + if entity_slug(clean_alias) == canonical_slug: + counts["skipped"] += 1 + continue + try: + add_entity_aka(facet, entity_id, clean_alias, exclude_name=canonical_name) + counts["aliased"] += 1 + except AkaConflictError: + counts["skipped"] += 1 + except EntityNotFoundError as exc: + counts["errored"] += 1 + error = f"{type(exc).__name__}: {exc}" + except LockTimeout as exc: + counts["errored"] += 1 + error = f"{type(exc).__name__}: {exc}" + break + except EntityWriteError as exc: + counts["errored"] += 1 + error = f"{type(exc).__name__}: {exc}" + return error + + +def _apply_promotions( + *, + facet: str, + raw_promotions: list, + eligible_by_slug: dict[str, dict[str, Any]], + counts: dict[str, int], +) -> str | None: + error: str | None = None + for row in raw_promotions: + if not isinstance(row, dict): + counts["skipped"] += 1 + continue + name = row.get("name") + description = row.get("description") + promote = row.get("promote") + aliases = row.get("aliases") + if ( + not isinstance(name, str) + or not name.strip() + or not isinstance(description, str) + or not description.strip() + or not isinstance(promote, bool) + or not isinstance(aliases, list) + ): + counts["skipped"] += 1 + continue + + slug = entity_slug(name.strip()) + eligible = eligible_by_slug.get(slug) + if eligible is None: + counts["skipped"] += 1 + continue + if promote is not True: + counts["skipped"] += 1 + continue + + canonical_name = str(eligible["name"]) + entity_id: str | None = None + try: + relationship, _ = attach_or_reactivate_entity( + facet, + entity_type=str(eligible["type"]), + name=canonical_name, + description=description.strip(), + ) + counts["promoted"] += 1 + entity_id = str(relationship["entity_id"]) + except EntityExistsError: + counts["skipped"] += 1 + entity_id = entity_slug(canonical_name) + except EntityBlockedError: + counts["skipped"] += 1 + continue + except (EntityNotFoundError, LockTimeout, EntityWriteError) as exc: + counts["errored"] += 1 + error = f"{type(exc).__name__}: {exc}" + continue + + alias_error = _apply_aliases( + facet=facet, + entity_id=entity_id, + canonical_name=canonical_name, + canonical_slug=str(eligible["slug"]), + aliases=aliases, + counts=counts, + ) + if alias_error is not None: + error = alias_error + return error + + +def _apply_merges( + *, + facet: str, + day: str, + raw_merges: list, + hint_slug_pairs: set[frozenset[str]], + counts: dict[str, int], +) -> str | None: + error: str | None = None + for row in raw_merges: + if not isinstance(row, dict): + counts["skipped"] += 1 + continue + source = row.get("source") + canonical = row.get("canonical") + evidence = row.get("evidence") + if ( + not isinstance(source, str) + or not source.strip() + or not isinstance(canonical, str) + or not canonical.strip() + or not isinstance(evidence, str) + or not evidence.strip() + ): + counts["skipped"] += 1 + continue + + clean_source = source.strip() + clean_canonical = canonical.strip() + source_slug = entity_slug(clean_source) + canonical_slug = entity_slug(clean_canonical) + if source_slug == canonical_slug: + counts["skipped"] += 1 + continue + if frozenset((source_slug, canonical_slug)) not in hint_slug_pairs: + counts["skipped"] += 1 + continue + + try: + record_merge_candidate( + facet=facet, + day=day, + source=clean_source, + source_slug=source_slug, + target=clean_canonical, + target_slug=canonical_slug, + evidence=evidence.strip(), + basis="name-variant", + ) + counts["merges"] += 1 + except Exception as exc: + counts["errored"] += 1 + error = f"{type(exc).__name__}: {exc}" + return error + + +def post_process(result: str, context: dict) -> None: + counts = {"promoted": 0, "aliased": 0, "merges": 0, "skipped": 0, "errored": 0} + error: str | None = None + facet = context.get("facet") + day = context.get("day") + if not facet or not day: + return None + + facet = str(facet) + day = str(day) + + try: + eligible, variant_hints, _ = build_review_inputs(facet, day) + eligible_by_slug = {str(item["slug"]): item for item in eligible} + hint_slug_pairs = { + frozenset((entity_slug(name_a), entity_slug(name_b))) + for name_a, name_b in variant_hints + } + + try: + data = json.loads(result) + except json.JSONDecodeError: + logger.warning("entities_review post-hook received invalid JSON") + return None + + if not isinstance(data, dict): + logger.warning("entities_review post-hook result is not a JSON object") + return None + raw_promotions = data.get("promotions") + raw_merges = data.get("merges") + if not isinstance(raw_promotions, list) or not isinstance(raw_merges, list): + logger.warning("entities_review post-hook result missing expected arrays") + return None + + promotion_error = _apply_promotions( + facet=facet, + raw_promotions=raw_promotions, + eligible_by_slug=eligible_by_slug, + counts=counts, + ) + if promotion_error is not None: + error = promotion_error + + merge_error = _apply_merges( + facet=facet, + day=day, + raw_merges=raw_merges, + hint_slug_pairs=hint_slug_pairs, + counts=counts, + ) + if merge_error is not None: + error = merge_error + except Exception as exc: + counts["errored"] += 1 + error = f"{type(exc).__name__}: {exc}" + logger.warning("entities_review post-hook failed: %s", exc) + finally: + try: + _write_outcome(facet, day, counts, error) + except Exception as exc: + logger.warning("entities_review outcome write failed: %s", exc) + + return None diff --git a/solstone/apps/entities/talent/entities_review.schema.json b/solstone/apps/entities/talent/entities_review.schema.json new file mode 100644 index 000000000..f653659eb --- /dev/null +++ b/solstone/apps/entities/talent/entities_review.schema.json @@ -0,0 +1,51 @@ +{ + "type": "object", + "additionalProperties": false, + "required": ["promotions", "merges"], + "properties": { + "promotions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "description", "promote", "aliases"], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "promote": { + "type": "boolean" + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "merges": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["source", "canonical", "evidence"], + "properties": { + "source": { + "type": "string" + }, + "canonical": { + "type": "string" + }, + "evidence": { + "type": "string" + } + } + } + } + } +} diff --git a/solstone/apps/entities/tests/test_entities_review.py b/solstone/apps/entities/tests/test_entities_review.py new file mode 100644 index 000000000..b04f78484 --- /dev/null +++ b/solstone/apps/entities/tests/test_entities_review.py @@ -0,0 +1,510 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from jsonschema import Draft202012Validator, ValidationError + +from solstone.apps.entities.talent import entities_review +from solstone.think.entities import ( + attach_or_reactivate_entity, + entity_slug, + load_entities, +) +from solstone.think.entities.journal import load_journal_entity +from solstone.think.entities.review_candidates import ( + load_candidates, + record_merge_candidate, +) +from solstone.think.entities.saving import save_entities + +DAY = "20250115" +FACET = "work" + + +def _set_journal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + import solstone.think.utils as think_utils + + think_utils._journal_path_cache = None + return tmp_path + + +def _write_facet(root: Path, slug: str, description: str = "Professional work") -> None: + facet_dir = root / "facets" / slug + facet_dir.mkdir(parents=True, exist_ok=True) + (facet_dir / "facet.json").write_text( + json.dumps( + { + "title": slug.title(), + "description": description, + "color": "#00796b", + } + ) + + "\n", + encoding="utf-8", + ) + + +def _entity(entity_type: str, name: str, description: str) -> dict: + return {"type": entity_type, "name": name, "description": description} + + +def _save_detected( + facet: str, + day: str, + *rows: dict, +) -> None: + save_entities(facet, list(rows), day=day) + + +def _context() -> dict: + return {"facet": FACET, "day": DAY} + + +def _outcome(root: Path, facet: str = FACET, day: str = DAY) -> dict: + path = root / "facets" / facet / "entities" / f"{day}_review_outcome.json" + return json.loads(path.read_text(encoding="utf-8")) + + +def _valid_result( + *, promotions: list[dict] | None = None, merges: list[dict] | None = None +) -> str: + return json.dumps({"promotions": promotions or [], "merges": merges or []}) + + +def _promotion( + name: str, + description: str = "Stable useful context.", + *, + promote: bool = True, + aliases: list[str] | None = None, +) -> dict: + return { + "name": name, + "description": description, + "promote": promote, + "aliases": aliases or [], + } + + +def _merge( + source: str, canonical: str, evidence: str = "Looks like the same thing." +) -> dict: + return {"source": source, "canonical": canonical, "evidence": evidence} + + +def test_build_review_inputs_aggregates_threshold_boundaries( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + _set_journal(tmp_path, monkeypatch) + _write_facet(tmp_path, FACET) + _save_detected( + FACET, + "20250110", + _entity("Tool", "Vector DB", "Benchmarked retrieval."), + _entity("Methodology", "Review Loop", "Used for planning."), + ) + _save_detected( + FACET, + "20250111", + _entity("Tool", "Vector DB", "Tuned indexing."), + _entity("Methodology", "Review Loop", "Used for follow-up."), + ) + _save_detected( + FACET, + "20250112", + _entity("Company", "Acme Corp", "Discussed pricing."), + _entity("Company", "Beta Inc", "Reviewed contract."), + _entity("Tool", "Vector DB", "Checked query quality."), + _entity("Methodology", "Review Loop", "Used for synthesis."), + ) + _save_detected( + FACET, + "20250113", + _entity("Person", "Sarah Chen", "Reviewed design."), + _entity("Company", "Acme Corp", "Mentioned renewal."), + _entity("Company", "Beta Inc", "Met with legal."), + _entity("Tool", "Vector DB", "Compared latency."), + _entity("Methodology", "Review Loop", "Used for decisions."), + ) + _save_detected( + FACET, + "20250114", + _entity("Person", "Sarah Chen", "Approved launch."), + _entity("Company", "Beta Inc", "Signed terms."), + _entity("Tool", "Vector DB", "Shipped changes."), + _entity("Methodology", "Review Loop", "Closed the review."), + ) + + eligible, _, _ = entities_review.build_review_inputs(FACET, DAY) + + by_name = {row["name"]: row for row in eligible} + assert by_name["Sarah Chen"]["day_count"] == 2 + assert by_name["Sarah Chen"]["type"] == "Person" + assert by_name["Beta Inc"]["day_count"] == 3 + assert by_name["Vector DB"]["day_count"] == 5 + assert by_name["Review Loop"]["day_count"] == 5 + assert "Acme Corp" not in by_name + assert [context["day"] for context in by_name["Sarah Chen"]["contexts"]] == [ + "20250113", + "20250114", + ] + + +def test_build_review_inputs_excludes_run_day_from_window( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + _set_journal(tmp_path, monkeypatch) + _write_facet(tmp_path, FACET) + _save_detected( + FACET, "20250113", _entity("Person", "Sarah Chen", "Reviewed design.") + ) + _save_detected( + FACET, "20250114", _entity("Person", "Sarah Chen", "Approved launch.") + ) + _save_detected(FACET, DAY, _entity("Person", "Sarah Chen", "Met on run day.")) + + eligible, _, _ = entities_review.build_review_inputs(FACET, DAY) + + by_name = {row["name"]: row for row in eligible} + assert by_name["Sarah Chen"]["day_count"] == 2 + assert [context["day"] for context in by_name["Sarah Chen"]["contexts"]] == [ + "20250113", + "20250114", + ] + + +def test_build_review_inputs_drops_type_conflicts( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + _set_journal(tmp_path, monkeypatch) + _write_facet(tmp_path, FACET) + _save_detected(FACET, "20250113", _entity("Person", "Mercury", "Met with Mercury.")) + _save_detected(FACET, "20250114", _entity("Project", "Mercury", "Built Mercury.")) + + eligible, _, _ = entities_review.build_review_inputs(FACET, DAY) + + assert eligible == [] + + +def test_build_review_inputs_excludes_high_confidence_attached_match( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + _set_journal(tmp_path, monkeypatch) + _write_facet(tmp_path, FACET) + _save_detected( + FACET, "20250113", _entity("Person", "Sarah Chen", "Reviewed design.") + ) + _save_detected( + FACET, "20250114", _entity("Person", "Sarah Chen", "Approved launch.") + ) + attach_or_reactivate_entity( + FACET, + entity_type="Person", + name="Sarah Chen", + description="Existing backend lead.", + ) + + eligible, _, _ = entities_review.build_review_inputs(FACET, DAY) + + assert eligible == [] + + +def test_variant_hints_surface_variants_and_skip_same_slug( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + _set_journal(tmp_path, monkeypatch) + _write_facet(tmp_path, FACET) + _save_detected( + FACET, + "20250114", + _entity("Company", "Kognova", "Discussed roadmap."), + _entity("Company", "Kognova Inc", "Reviewed pricing."), + _entity("Company", "Acme Corp", "Mentioned account."), + _entity("Company", "Acme Corp", "Duplicate spacing."), + ) + + _, variant_hints, _ = entities_review.build_review_inputs(FACET, DAY) + + assert ("Kognova", "Kognova Inc") in variant_hints + assert not any( + frozenset((entity_slug(a), entity_slug(b))) == frozenset(("acme_corp",)) + for a, b in variant_hints + ) + + +def test_pre_process_surfaces_prior_merges_in_packet( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + _set_journal(tmp_path, monkeypatch) + _write_facet(tmp_path, FACET) + _save_detected( + FACET, + "20250114", + _entity("Company", "Kognova", "Discussed roadmap."), + _entity("Company", "Kognova Inc", "Reviewed pricing."), + ) + record_merge_candidate( + facet=FACET, + day="20250114", + source="Kognova Inc", + source_slug="kognova_inc", + target="Kognova", + target_slug="kognova", + evidence="Prior review.", + ) + + result = entities_review.pre_process(_context()) + + assert isinstance(result, dict) + packet = result["template_vars"]["review_packet"] + assert "Kognova / Kognova Inc" in packet + assert "Kognova Inc -> Kognova (open)" in packet + assert "sol call" not in packet + + +def test_pre_process_skip_taxonomy_no_sidecar( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + _set_journal(tmp_path, monkeypatch) + _write_facet(tmp_path, FACET) + + assert entities_review.pre_process({"facet": FACET}) == {"skip_reason": "no_day"} + assert entities_review.pre_process({"day": DAY}) == {"skip_reason": "no_facet"} + assert entities_review.pre_process(_context()) == {"skip_reason": "no_candidates"} + assert not ( + tmp_path / "facets" / FACET / "entities" / f"{DAY}_review_outcome.json" + ).exists() + + +def test_entities_review_schema_validates_sample_and_rejects_malformed(): + schema_path = Path(__file__).parents[1] / "talent" / "entities_review.schema.json" + schema = json.loads(schema_path.read_text(encoding="utf-8")) + Draft202012Validator.check_schema(schema) + validator = Draft202012Validator(schema) + + validator.validate( + { + "promotions": [ + { + "name": "Sarah Chen", + "description": "Backend lead for launch planning.", + "promote": True, + "aliases": ["S Chen"], + } + ], + "merges": [ + { + "source": "Kognova Inc", + "canonical": "Kognova", + "evidence": "Same company name.", + } + ], + } + ) + with pytest.raises(ValidationError): + validator.validate( + { + "promotions": [ + { + "name": "Sarah Chen", + "description": "Backend lead.", + "promote": True, + "aliases": [], + "type": "Person", + } + ], + "merges": [], + } + ) + with pytest.raises(ValidationError): + validator.validate({"promotions": []}) + + +def test_post_process_promotes_aliases_records_merge_and_sidecar( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + _set_journal(tmp_path, monkeypatch) + _write_facet(tmp_path, FACET) + _save_detected( + FACET, "20250113", _entity("Person", "Sarah Chen", "Reviewed design.") + ) + _save_detected( + FACET, + "20250114", + _entity("Person", "Sarah Chen", "Approved launch."), + _entity("Company", "Kognova", "Discussed roadmap."), + _entity("Company", "Kognova Inc", "Reviewed pricing."), + ) + + result = entities_review.post_process( + _valid_result( + promotions=[ + _promotion( + "Sarah Chen", + "Backend lead for launch planning.", + aliases=["S Chen"], + ) + ], + merges=[_merge("Kognova Inc", "Kognova", "Same company name.")], + ), + _context(), + ) + + assert result is None + attached = load_entities(FACET) + assert len(attached) == 1 + assert attached[0]["name"] == "Sarah Chen" + assert attached[0]["type"] == "Person" + assert attached[0]["description"] == "Backend lead for launch planning." + assert "S Chen" in load_journal_entity("sarah_chen")["aka"] + candidates = load_candidates() + assert len(candidates) == 1 + assert candidates[0]["source"] == "Kognova Inc" + assert candidates[0]["target"] == "Kognova" + outcome = _outcome(tmp_path) + assert outcome["promoted"] == 1 + assert outcome["aliased"] == 1 + assert outcome["merges"] == 1 + assert outcome["skipped"] == 0 + assert outcome["errored"] == 0 + assert outcome["error"] is None + + +def test_post_process_bad_json_writes_zero_sidecar( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + _set_journal(tmp_path, monkeypatch) + _write_facet(tmp_path, FACET) + _save_detected( + FACET, "20250113", _entity("Person", "Sarah Chen", "Reviewed design.") + ) + _save_detected( + FACET, "20250114", _entity("Person", "Sarah Chen", "Approved launch.") + ) + + assert entities_review.post_process("{bad json", _context()) is None + + outcome = _outcome(tmp_path) + assert outcome["promoted"] == 0 + assert outcome["aliased"] == 0 + assert outcome["merges"] == 0 + assert outcome["skipped"] == 0 + assert outcome["errored"] == 0 + assert outcome["error"] is None + + +def test_post_process_counts_dropped_rows_without_disk_change( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + _set_journal(tmp_path, monkeypatch) + _write_facet(tmp_path, FACET) + _save_detected( + FACET, "20250113", _entity("Person", "Sarah Chen", "Reviewed design.") + ) + _save_detected( + FACET, "20250114", _entity("Person", "Sarah Chen", "Approved launch.") + ) + + entities_review.post_process( + _valid_result( + promotions=[ + _promotion("Invented Person", "Invented context."), + _promotion("Sarah Chen", "Backend lead.", promote=False), + ], + merges=[ + _merge("Sarah Chen", "Sarah Chen", "Self merge."), + _merge("Alpha", "Beta", "Not hinted."), + ], + ), + _context(), + ) + + assert load_entities(FACET) == [] + assert load_candidates() == [] + outcome = _outcome(tmp_path) + assert outcome["skipped"] == 4 + assert outcome["errored"] == 0 + + +def test_post_process_idempotent_for_existing_promotion_and_merge( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + _set_journal(tmp_path, monkeypatch) + _write_facet(tmp_path, FACET) + _save_detected( + FACET, "20250113", _entity("Person", "Sarah Chen", "Reviewed design.") + ) + _save_detected( + FACET, + "20250114", + _entity("Person", "Sarah Chen", "Approved launch."), + _entity("Company", "Kognova", "Discussed roadmap."), + _entity("Company", "Kognova Inc", "Reviewed pricing."), + ) + result = _valid_result( + promotions=[ + _promotion( + "Sarah Chen", + "Backend lead for launch planning.", + aliases=["S Chen"], + ) + ], + merges=[_merge("Kognova Inc", "Kognova", "Same company name.")], + ) + + entities_review.post_process(result, _context()) + entities_review.post_process(result, _context()) + + attached = load_entities(FACET) + assert [entity["name"] for entity in attached] == ["Sarah Chen"] + pair_rows = [ + row + for row in load_candidates() + if row["source_slug"] == "kognova_inc" and row["target_slug"] == "kognova" + ] + assert len(pair_rows) == 1 + + +def test_post_process_records_substrate_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + _set_journal(tmp_path, monkeypatch) + _write_facet(tmp_path, FACET) + _save_detected( + FACET, "20250113", _entity("Person", "Sarah Chen", "Reviewed design.") + ) + _save_detected( + FACET, "20250114", _entity("Person", "Sarah Chen", "Approved launch.") + ) + + def fail_attach(*args: object, **kwargs: object) -> None: + raise RuntimeError("boom") + + monkeypatch.setattr(entities_review, "attach_or_reactivate_entity", fail_attach) + + entities_review.post_process( + _valid_result(promotions=[_promotion("Sarah Chen", "Backend lead.")]), + _context(), + ) + + outcome = _outcome(tmp_path) + assert outcome["errored"] == 1 + assert "RuntimeError: boom" in outcome["error"] diff --git a/tests/baselines/api/sol/talents-day.json b/tests/baselines/api/sol/talents-day.json index c97ce96ad..4ce4afa49 100644 --- a/tests/baselines/api/sol/talents-day.json +++ b/tests/baselines/api/sol/talents-day.json @@ -98,11 +98,11 @@ "color": "#00796b", "description": "Reviews detected entities and promotes recurring ones to attached status", "multi_facet": true, - "output_format": null, + "output_format": "json", "schedule": "daily", "source": "app", "title": "Entity Reviewer", - "type": "cogitate" + "type": "generate" }, "entities:entity_assist": { "app": "entities", diff --git a/tests/baselines/api/stats/stats.json b/tests/baselines/api/stats/stats.json index 75356bcc3..750198fba 100644 --- a/tests/baselines/api/stats/stats.json +++ b/tests/baselines/api/stats/stats.json @@ -119,6 +119,33 @@ "title": "Entity Detection", "type": "generate" }, + "entities:entities_review": { + "app": "entities", + "color": "#00796b", + "description": "Reviews detected entities and promotes recurring ones to attached status", + "group": "Entities", + "hook": { + "post": "entities:entities_review", + "pre": "entities:entities_review" + }, + "load": { + "percepts": false, + "talents": false, + "transcripts": false + }, + "mtime": 0, + "multi_facet": true, + "output": "json", + "path": "/solstone/apps/entities/talent/entities_review.md", + "priority": 56, + "schedule": "daily", + "schema": "entities_review.schema.json", + "source": "app", + "thinking_budget": 2048, + "tier": 2, + "title": "Entity Reviewer", + "type": "generate" + }, "entities:entity_describe": { "app": "entities", "color": "#26a69a", diff --git a/tests/baselines/api/thinking/generators.json b/tests/baselines/api/thinking/generators.json index 3e877ed7a..96c314ed6 100644 --- a/tests/baselines/api/thinking/generators.json +++ b/tests/baselines/api/thinking/generators.json @@ -8,6 +8,14 @@ "source": "app", "title": "Entity Observer" }, + { + "app": "entities", + "description": "Reviews detected entities and promotes recurring ones to attached status", + "disabled": false, + "key": "entities:entities_review", + "source": "app", + "title": "Entity Reviewer" + }, { "app": null, "description": "Analyzes activity patterns to identify optimal times for scheduled maintenance tasks.", diff --git a/tests/baselines/api/thinking/providers.json b/tests/baselines/api/thinking/providers.json index 1c5835efc..a4ddf95f9 100644 --- a/tests/baselines/api/thinking/providers.json +++ b/tests/baselines/api/thinking/providers.json @@ -383,7 +383,7 @@ "label": "Entity Reviewer", "schedule": "daily", "tier": 2, - "type": "cogitate" + "type": "generate" }, "talent.entities.entity_assist": { "disabled": false, diff --git a/tests/test_entity_talents.py b/tests/test_entity_talents.py index 66c08c076..baed0aa07 100644 --- a/tests/test_entity_talents.py +++ b/tests/test_entity_talents.py @@ -27,10 +27,13 @@ def test_entities_review_agent_config(fixture_journal): assert len(config["user_instruction"]) > 0 # Verify JSON metadata fields from entities_review.json + assert config.get("type") == "generate" assert config.get("title") == "Entity Reviewer" assert config.get("schedule") == "daily" assert config.get("priority") == 56 assert config.get("multi_facet") is True + assert config.get("output") == "json" + assert isinstance(config.get("json_schema"), dict) def test_entities_review_agent_instruction_content(fixture_journal): @@ -38,12 +41,15 @@ def test_entities_review_agent_instruction_content(fixture_journal): config = get_talent("entities:entities_review") prompt = config["user_instruction"] - # Check for key sections in the agent prompt - assert "Core Mission" in prompt - assert "sol call entities attach" in prompt - assert "sol call entities list" in prompt - assert "3+" in prompt or "promotion" in prompt.lower() - assert "description" in prompt.lower() + assert prompt + assert "sol call" not in prompt + assert "emit_final" not in prompt + assert "$facets" not in prompt + assert "2+" not in prompt + assert "3+" not in prompt + assert "5+" not in prompt + assert "timeless description" in prompt + assert "canonical" in prompt def test_agent_context_with_facet_focus(fixture_journal): diff --git a/tests/test_openhands_provider.py b/tests/test_openhands_provider.py index f47232804..d350a46e3 100644 --- a/tests/test_openhands_provider.py +++ b/tests/test_openhands_provider.py @@ -1415,8 +1415,8 @@ def test_schedule_gated_cogitate_prompts_use_emit_final(): if config.get("schedule") in {"daily", "weekly", "activity"} and "output" not in config } - # steward and facet_newsletter are generate talents now, not cogitate prompts. - assert len(converted) == 2 + # Daily review talents are generate hooks now; partner remains schedule-gated cogitate. + assert set(converted) == {"partner"} for name, config in converted.items(): body = Path(config["path"]).read_text(encoding="utf-8") diff --git a/tests/test_talent_cli.py b/tests/test_talent_cli.py index 2a17af1bc..a86458416 100644 --- a/tests/test_talent_cli.py +++ b/tests/test_talent_cli.py @@ -291,7 +291,7 @@ def test_show_prompt_context_segment_validation(capsys): assert "segment-scheduled" in output.lower() -def test_show_prompt_context_multi_facet_validation(capsys): +def test_show_prompt_context_entities_review_multi_facet_validation(capsys): """Multi-facet prompts require --facet.""" from solstone.think.talent_cli import show_prompt_context