diff --git a/tests/test_importer_obsidian_sync.py b/tests/test_importer_obsidian_sync.py index d32ea4c7b..71e323a5d 100644 --- a/tests/test_importer_obsidian_sync.py +++ b/tests/test_importer_obsidian_sync.py @@ -263,6 +263,187 @@ def test_obsidian_sync_incremental(tmp_path, monkeypatch): assert second["imported"] >= 1 +def test_infer_entity_type_from_path(): + """Folder path entity type inference.""" + from think.importers.obsidian import infer_entity_type_from_path + + # Direct folder match + assert infer_entity_type_from_path("People/Jane Smith.md") == "Person" + assert infer_entity_type_from_path("Contacts/John.md") == "Person" + assert infer_entity_type_from_path("Projects/Alpha.md") == "Project" + assert infer_entity_type_from_path("Companies/Acme.md") == "Organization" + assert infer_entity_type_from_path("Organizations/UN.md") == "Organization" + assert infer_entity_type_from_path("Places/Paris.md") == "Place" + assert infer_entity_type_from_path("Locations/HQ.md") == "Place" + + # Case insensitive + assert infer_entity_type_from_path("people/jane.md") == "Person" + assert infer_entity_type_from_path("PEOPLE/Jane.md") == "Person" + + # Any depth in path + assert infer_entity_type_from_path("00 knowledge/People/Jane Smith.md") == "Person" + assert infer_entity_type_from_path("Atlas/References/People/Jane.md") == "Person" + + # Numeric prefix stripping + assert infer_entity_type_from_path("00 People/Jane.md") == "Person" + assert infer_entity_type_from_path("01 Projects/Alpha.md") == "Project" + + # No match → None + assert infer_entity_type_from_path("Notes/random.md") is None + assert infer_entity_type_from_path("Daily/2026-03-14.md") is None + assert infer_entity_type_from_path("random.md") is None + + +def test_clean_at_prefix(): + """@ prefix stripping from entity names.""" + from think.importers.obsidian import _clean_at_prefix + + assert _clean_at_prefix("@JaneSmith") == ("JaneSmith", True) + assert _clean_at_prefix("@ Jane Smith") == ("Jane Smith", True) + assert _clean_at_prefix("@") == ("", True) + assert _clean_at_prefix("Jane Smith") == ("Jane Smith", False) + assert _clean_at_prefix("") == ("", False) + + +def test_build_entity_dicts_precedence(): + """Entity type inference precedence: @ > folder-path > Topic.""" + from think.importers.obsidian import _build_entity_dicts + + # Basic Topic fallback + result = _build_entity_dicts({"Design Doc"}, {}) + assert result == [{"name": "Design Doc", "type": "Topic"}] + + # Folder-path type + result = _build_entity_dicts({"Jane Smith"}, {"Jane Smith": "Person"}) + assert result == [{"name": "Jane Smith", "type": "Person"}] + + # @ prefix → Person + result = _build_entity_dicts({"@Jane Smith"}, {}) + assert result == [{"name": "Jane Smith", "type": "Person"}] + + # @ wins over folder-path + result = _build_entity_dicts( + {"@Jane Smith"}, + {"Jane Smith": "Organization"}, + ) + assert result == [{"name": "Jane Smith", "type": "Person"}] + + # @ filename added even without wikilink + result = _build_entity_dicts(set(), {}, at_filenames={"Jane Smith"}) + assert result == [{"name": "Jane Smith", "type": "Person"}] + + # Dedup: both @Jane and Jane as wikilinks — @ wins + result = _build_entity_dicts({"@Jane Smith", "Jane Smith"}, {}) + assert result == [{"name": "Jane Smith", "type": "Person"}] + + +def test_obsidian_sync_folder_path_entity_typing(tmp_path, monkeypatch): + """Notes in typed folders produce typed entities.""" + from think.importers.obsidian import ObsidianSyncBackend + + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) + vault = tmp_path / "vault" + + _write_note( + vault, "People/Jane Smith.md", "# Jane Smith\nA person.", mtime=1_700_000_000 + ) + _write_note( + vault, + "Notes/meeting.md", + "# Meeting\nMet with [[Jane Smith]] about [[Design Doc]].", + mtime=1_700_000_100, + ) + + captured: list[tuple[str, str, list[dict[str, str]]]] = [] + + def _fake_seed(facet, day, entities): + captured.append((facet, day, entities)) + return entities + + with patch( + "think.importers.obsidian.seed_entities", side_effect=_fake_seed + ): + ObsidianSyncBackend().sync(tmp_path, source_path=vault, dry_run=False) + + all_entities = {} + for _, _, entities in captured: + for e in entities: + all_entities[e["name"]] = e["type"] + + assert all_entities["Jane Smith"] == "Person" + assert all_entities["Design Doc"] == "Topic" + + +def test_obsidian_sync_at_prefix_entity_typing(tmp_path, monkeypatch): + """Wikilinks with @ prefix produce Person entities.""" + from think.importers.obsidian import ObsidianSyncBackend + + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) + vault = tmp_path / "vault" + + _write_note( + vault, + "Notes/meeting.md", + "# Meeting\nMet with [[@Bob Jones]] and [[Design Doc]].", + mtime=1_700_000_000, + ) + + captured: list[tuple[str, str, list[dict[str, str]]]] = [] + + def _fake_seed(facet, day, entities): + captured.append((facet, day, entities)) + return entities + + with patch( + "think.importers.obsidian.seed_entities", side_effect=_fake_seed + ): + ObsidianSyncBackend().sync(tmp_path, source_path=vault, dry_run=False) + + all_entities = {} + for _, _, entities in captured: + for e in entities: + all_entities[e["name"]] = e["type"] + + assert all_entities["Bob Jones"] == "Person" + assert all_entities["Design Doc"] == "Topic" + + +def test_obsidian_sync_numeric_prefix_folder(tmp_path, monkeypatch): + """Numeric-prefixed folder names are matched after stripping.""" + from think.importers.obsidian import ObsidianSyncBackend + + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) + vault = tmp_path / "vault" + + _write_note( + vault, "00 People/Jane.md", "# Jane\nA person.", mtime=1_700_000_000 + ) + _write_note( + vault, + "Notes/ref.md", + "# Ref\nSee [[Jane]].", + mtime=1_700_000_100, + ) + + captured: list[tuple[str, str, list[dict[str, str]]]] = [] + + def _fake_seed(facet, day, entities): + captured.append((facet, day, entities)) + return entities + + with patch( + "think.importers.obsidian.seed_entities", side_effect=_fake_seed + ): + ObsidianSyncBackend().sync(tmp_path, source_path=vault, dry_run=False) + + all_entities = {} + for _, _, entities in captured: + for e in entities: + all_entities[e["name"]] = e["type"] + + assert all_entities["Jane"] == "Person" + + def test_obsidian_backends_cli_flag(capsys, monkeypatch): """sol import --backends lists obsidian.""" import sys diff --git a/think/importers/obsidian.py b/think/importers/obsidian.py index c52a6720a..99a244dd6 100644 --- a/think/importers/obsidian.py +++ b/think/importers/obsidian.py @@ -72,6 +72,79 @@ SKIP_EXTENSIONS = { ".eot", } +# Folder name to entity type mapping (case-insensitive, after stripping numeric prefixes) +FOLDER_TYPE_MAP: dict[str, str] = { + "people": "Person", + "contacts": "Person", + "projects": "Project", + "companies": "Organization", + "organizations": "Organization", + "places": "Place", + "locations": "Place", +} + +# Numeric prefix pattern (e.g., "00 knowledge" → "knowledge") +NUMERIC_PREFIX_RE = re.compile(r"^\d+\s+") + + +def infer_entity_type_from_path(rel_path: str) -> str | None: + """Infer entity type from a note's relative folder path. + + Checks each folder component (after stripping numeric prefixes) against + known entity-typed folder names. Returns the entity type or None. + """ + parts = Path(rel_path).parent.parts + for part in parts: + cleaned = NUMERIC_PREFIX_RE.sub("", part).lower() + entity_type = FOLDER_TYPE_MAP.get(cleaned) + if entity_type: + return entity_type + return None + + +def _clean_at_prefix(name: str) -> tuple[str, bool]: + """Strip @ prefix from an entity name. + + Returns (cleaned_name, had_at_prefix). Handles both '@Name' and '@ Name'. + """ + if name.startswith("@"): + return name[1:].lstrip(), True + return name, False + + +def _build_entity_dicts( + wikilinks: set[str], + title_type_map: dict[str, str], + at_filenames: set[str] | None = None, +) -> list[dict[str, str]]: + """Build entity dicts from wikilinks with type inference. + + Precedence: @ prefix > folder-path type > "Topic" default. + Also includes @-prefixed filenames as Person entities. + """ + entities: dict[str, dict[str, str]] = {} + + for link in wikilinks: + name, is_at = _clean_at_prefix(link) + if not name: + continue + if is_at: + entity_type = "Person" + elif name in title_type_map: + entity_type = title_type_map[name] + else: + entity_type = "Topic" + # @ prefix wins if we've already seen this name without @ + if name not in entities or (is_at and entities[name]["type"] != "Person"): + entities[name] = {"name": name, "type": entity_type} + + if at_filenames: + for name in at_filenames: + if name not in entities: + entities[name] = {"name": name, "type": "Person"} + + return [entities[k] for k in sorted(entities)] + def _parse_daily_note_date(filename: str) -> dt.date | None: """Try to parse a daily note date from filename. Returns None if not a daily note.""" @@ -396,13 +469,24 @@ class ObsidianImporter: manifest_entry["segments"] = [{"day": day, "key": key}] write_content_manifest(import_id, note_manifest) - # Seed entities from wikilinks + # Build title → entity type mapping from folder paths and @ filenames + title_type_map: dict[str, str] = {} + at_filenames: set[str] = set() + for note in notes: + folder_type = infer_entity_type_from_path(note["source_path"]) + if folder_type: + title_type_map[note["title"]] = folder_type + name, is_at = _clean_at_prefix(note["title"]) + if is_at and name: + at_filenames.add(name) + + # Seed entities from wikilinks with type inference entities_seeded = 0 - if all_wikilinks and facet: + if (all_wikilinks or at_filenames) and facet: day = segments[0][0] if segments else dt.datetime.now().strftime("%Y%m%d") - entity_dicts = [ - {"name": link, "type": "Topic"} for link in sorted(all_wikilinks) - ] + entity_dicts = _build_entity_dicts( + all_wikilinks, title_type_map, at_filenames + ) resolved = seed_entities(facet, day, entity_dicts) entities_seeded = len(resolved) @@ -478,11 +562,17 @@ class ObsidianSyncBackend: known_files: dict[str, dict[str, Any]] = state.get("files", {}) to_import: list[dict[str, Any]] = [] current_paths: set[str] = set() + title_type_map: dict[str, str] = {} for md_path in _walk_md_files(vault_path): rel_path = str(md_path.relative_to(vault_path)) current_paths.add(rel_path) + # Track folder-path types for entity type inference + folder_type = infer_entity_type_from_path(rel_path) + if folder_type: + title_type_map[md_path.stem] = folder_type + content = _read_file_safe(md_path) if content is None or not content.strip(): continue @@ -569,20 +659,30 @@ class ObsidianSyncBackend: filename="note_transcript.md", ) - if note["wikilinks"] and segs: + if segs: day = segs[0][0] - entity_dicts = [ - {"name": link, "type": "Topic"} - for link in sorted(set(note["wikilinks"])) - ] - try: - seed_entities("import.obsidian", day, entity_dicts) - except Exception as exc: - logger.warning( - "Entity seeding failed for %s: %s", - note["rel_path"], - exc, + note_at_filenames: set[str] = set() + name, is_at = _clean_at_prefix(note["title"]) + if is_at and name: + note_at_filenames.add(name) + wikilink_set = ( + set(note["wikilinks"]) if note["wikilinks"] else set() + ) + if wikilink_set or note_at_filenames: + entity_dicts = _build_entity_dicts( + wikilink_set, + title_type_map, + note_at_filenames or None, ) + if entity_dicts: + try: + seed_entities("import.obsidian", day, entity_dicts) + except Exception as exc: + logger.warning( + "Entity seeding failed for %s: %s", + note["rel_path"], + exc, + ) known_files[note["rel_path"]].update( {