diff --git a/scripts/check_layer_hygiene.py b/scripts/check_layer_hygiene.py index 3a46a2de4..a8abc084f 100644 --- a/scripts/check_layer_hygiene.py +++ b/scripts/check_layer_hygiene.py @@ -109,11 +109,6 @@ READ_VERBS: frozenset[str] = frozenset( # # Audit ref: vpe/workspace/solstone-layer-violations-audit.md (extro repo). ALLOWLIST: dict[str, str] = { - # TODO(V2): seed_entities() creates entities from importer shared code. - # Indirect writes go through save_journal_entity(), so the direct-write - # grep does not flag the file today. Keep the entry so the file is - # named alongside V1 as a known audit target; remove after Bundle A. - "think/importers/shared.py": "V2", # TODO(import-resolve-facet): apps/import/call.py's `resolve-facet` # command uses a read-verb name ("resolve_*" per L3) but writes to # journal/facets and unlinks staged files. Not in the audit's V1-V14, diff --git a/tests/test_importer_granola.py b/tests/test_importer_granola.py index 8c924338b..4e68ba9c7 100644 --- a/tests/test_importer_granola.py +++ b/tests/test_importer_granola.py @@ -720,7 +720,7 @@ def test_observations_source_day(tmp_path, monkeypatch): def test_seed_entities_without_observations(tmp_path, monkeypatch): """seed_entities() works unchanged when no observations are provided.""" - from think.importers.shared import seed_entities + from think.entities.seeding import seed_entities monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) @@ -735,7 +735,7 @@ def test_seed_entities_without_observations(tmp_path, monkeypatch): def test_seed_entities_observation_formatting(tmp_path, monkeypatch): """seed_entities() creates observations with correct formatting for all field combos.""" from think.entities.observations import load_observations - from think.importers.shared import seed_entities + from think.entities.seeding import seed_entities monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) @@ -793,7 +793,7 @@ def test_seed_entities_observation_formatting(tmp_path, monkeypatch): def test_seed_entities_observation_dedup(tmp_path, monkeypatch): """seed_entities() does not duplicate observations on re-call.""" from think.entities.observations import load_observations - from think.importers.shared import seed_entities + from think.entities.seeding import seed_entities monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) diff --git a/think/entities/seeding.py b/think/entities/seeding.py new file mode 100644 index 000000000..c304aaf27 --- /dev/null +++ b/think/entities/seeding.py @@ -0,0 +1,106 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Entity seeding functions. + +This module handles seeding entities from structured imports: +- seed_entities: Match or create entities and add optional observations +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from think.entities.core import EntityDict + + +def seed_entities( + facet: str, + day: str, + entities: list[dict], +) -> list[EntityDict]: + """Seed entities from structured imports. + + Each dict should have: name (required), type (default "Person"), + email (optional), context (optional), observations (optional list of strings). + + Matches by email first, then name. Creates new entities for non-matches. + If observations are provided, adds them via add_observation() with dedup. + + Args: + facet: Facet name for entity context + day: Day string YYYYMMDD for activity tracking + entities: List of entity dicts to seed + + Returns: + List of resolved/created entity dicts + """ + from think.entities.core import entity_slug + from think.entities.journal import ( + create_journal_entity, + load_all_journal_entities, + load_journal_entity, + save_journal_entity, + ) + from think.entities.matching import find_entity_by_email, find_matching_entity + from think.entities.observations import add_observation, load_observations + + # Load all journal entities for matching + all_entities = load_all_journal_entities() + entity_list = list(all_entities.values()) + + resolved: list[EntityDict] = [] + + for ent in entities: + name = ent.get("name", "").strip() + if not name: + continue + + entity_type = ent.get("type", "Person") + email = ent.get("email", "") + + matched = None + + # Try email match first + if email: + matched = find_entity_by_email(email, entity_list) + + # Fall back to name match + if not matched: + matched = find_matching_entity(name, entity_list) + + if matched: + # Merge email into existing entity if new + if email: + existing_emails = set(e.lower() for e in matched.get("emails", [])) + if email.lower() not in existing_emails: + matched["emails"] = sorted(existing_emails | {email.lower()}) + save_journal_entity(matched) + resolved.append(matched) + resolved_name = matched.get("name", name) + else: + # Create new entity + eid = entity_slug(name) + emails = [email.lower()] if email else None + new_entity = load_journal_entity(eid) or create_journal_entity( + entity_id=eid, + name=name, + entity_type=entity_type, + emails=emails, + ) + entity_list.append(new_entity) # Add to list for future matches + resolved.append(new_entity) + resolved_name = new_entity.get("name", name) + + # Add observations if provided, with dedup + observations = ent.get("observations", []) + if observations: + existing_obs = load_observations(facet, resolved_name) + existing_contents = {o["content"] for o in existing_obs} + for obs_content in observations: + if obs_content not in existing_contents: + add_observation(facet, resolved_name, obs_content, source_day=day) + existing_contents.add(obs_content) + + return resolved diff --git a/think/importers/documents.py b/think/importers/documents.py index ce370301b..0cfdb9a20 100644 --- a/think/importers/documents.py +++ b/think/importers/documents.py @@ -24,8 +24,9 @@ try: except ImportError: # pragma: no cover - optional dependency pytesseract = None +from think.entities.seeding import seed_entities from think.importers.file_importer import ImportPreview, ImportResult -from think.importers.shared import seed_entities, write_content_manifest +from think.importers.shared import write_content_manifest from think.models import generate from think.utils import day_path diff --git a/think/importers/granola.py b/think/importers/granola.py index 28abd6b9d..2d6b4fabe 100644 --- a/think/importers/granola.py +++ b/think/importers/granola.py @@ -20,9 +20,9 @@ from typing import Any import frontmatter +from think.entities.seeding import seed_entities from think.importers.shared import ( _window_messages, - seed_entities, write_segment, ) from think.importers.sync import load_sync_state, save_sync_state diff --git a/think/importers/ics.py b/think/importers/ics.py index 7ecae379a..c4c0feba4 100644 --- a/think/importers/ics.py +++ b/think/importers/ics.py @@ -9,10 +9,10 @@ import zipfile from pathlib import Path from typing import Any, Callable +from think.entities.seeding import seed_entities from think.importers.file_importer import ImportPreview, ImportResult from think.importers.shared import ( map_items_to_segments, - seed_entities, window_items, write_content_manifest, write_markdown_segments, diff --git a/think/importers/kindle.py b/think/importers/kindle.py index d0094645b..051e38440 100644 --- a/think/importers/kindle.py +++ b/think/importers/kindle.py @@ -9,10 +9,10 @@ import re from pathlib import Path from typing import Callable +from think.entities.seeding import seed_entities from think.importers.file_importer import ImportPreview, ImportResult from think.importers.shared import ( map_items_to_segments, - seed_entities, window_items, write_content_manifest, write_markdown_segments, diff --git a/think/importers/obsidian.py b/think/importers/obsidian.py index 4f73d85a4..c6915d6e2 100644 --- a/think/importers/obsidian.py +++ b/think/importers/obsidian.py @@ -11,10 +11,10 @@ import re from pathlib import Path from typing import Any, Callable +from think.entities.seeding import seed_entities from think.importers.file_importer import ImportPreview, ImportResult from think.importers.shared import ( map_items_to_segments, - seed_entities, window_items, write_content_manifest, write_markdown_segments, diff --git a/think/importers/shared.py b/think/importers/shared.py index ad68d1383..1906ae49e 100644 --- a/think/importers/shared.py +++ b/think/importers/shared.py @@ -10,15 +10,12 @@ import logging import os import shutil from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable +from typing import Any, Callable from media import MIME_TYPES from think.importers.utils import save_import_file, write_import_metadata from think.utils import day_path, get_journal, now_ms -if TYPE_CHECKING: - from think.entities.core import EntityDict - logger = logging.getLogger(__name__) @@ -642,94 +639,3 @@ def map_items_to_segments( result.append((window_day, seg_key)) return result - - -def seed_entities( - facet: str, - day: str, - entities: list[dict], -) -> list[EntityDict]: - """Seed entities from structured imports. - - Each dict should have: name (required), type (default "Person"), - email (optional), context (optional), observations (optional list of strings). - - Matches by email first, then name. Creates new entities for non-matches. - If observations are provided, adds them via add_observation() with dedup. - - Args: - facet: Facet name for entity context - day: Day string YYYYMMDD for activity tracking - entities: List of entity dicts to seed - - Returns: - List of resolved/created entity dicts - """ - from think.entities.core import entity_slug - from think.entities.journal import ( - create_journal_entity, - load_all_journal_entities, - load_journal_entity, - save_journal_entity, - ) - from think.entities.matching import find_entity_by_email, find_matching_entity - from think.entities.observations import add_observation, load_observations - - # Load all journal entities for matching - all_entities = load_all_journal_entities() - entity_list = list(all_entities.values()) - - resolved: list[EntityDict] = [] - - for ent in entities: - name = ent.get("name", "").strip() - if not name: - continue - - entity_type = ent.get("type", "Person") - email = ent.get("email", "") - - matched = None - - # Try email match first - if email: - matched = find_entity_by_email(email, entity_list) - - # Fall back to name match - if not matched: - matched = find_matching_entity(name, entity_list) - - if matched: - # Merge email into existing entity if new - if email: - existing_emails = set(e.lower() for e in matched.get("emails", [])) - if email.lower() not in existing_emails: - matched["emails"] = sorted(existing_emails | {email.lower()}) - save_journal_entity(matched) - resolved.append(matched) - resolved_name = matched.get("name", name) - else: - # Create new entity - eid = entity_slug(name) - emails = [email.lower()] if email else None - new_entity = load_journal_entity(eid) or create_journal_entity( - entity_id=eid, - name=name, - entity_type=entity_type, - emails=emails, - ) - entity_list.append(new_entity) # Add to list for future matches - resolved.append(new_entity) - resolved_name = new_entity.get("name", name) - - # Add observations if provided, with dedup - observations = ent.get("observations", []) - if observations: - existing_obs = load_observations(facet, resolved_name) - existing_contents = {o["content"] for o in existing_obs} - for obs_content in observations: - if obs_content not in existing_contents: - add_observation(facet, resolved_name, obs_content, source_day=day) - existing_contents.add(obs_content) - - return resolved