add link_resolver.py

This commit is contained in:
phernandez
2025-01-12 17:44:26 -06:00
parent c5d21276de
commit bdedf53566
7 changed files with 348 additions and 2 deletions
+1
View File
@@ -22,6 +22,7 @@ dependencies = [
"python-frontmatter>=1.1.0",
"fastmcp>=0.4.1",
"rich>=13.9.4",
"unidecode>=1.3.8",
]
[project.optional-dependencies]
+60 -2
View File
@@ -1,7 +1,10 @@
"""Knowledge graph models."""
import re
import os
from datetime import datetime
from typing import Optional
from unidecode import unidecode
from sqlalchemy import (
Integer,
@@ -14,12 +17,49 @@ from sqlalchemy import (
Index,
JSON,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from basic_memory.models.base import Base
from enum import Enum
def generate_permalink(file_path: str) -> str:
"""Generate a stable permalink from a file path.
Args:
file_path: Original file path
Returns:
Normalized permalink that matches validation rules
Examples:
>>> generate_permalink("docs/My Feature.md")
'docs/my-feature'
>>> generate_permalink("specs/API (v2).md")
'specs/api-v2'
"""
# Remove extension
base = os.path.splitext(file_path)[0]
# Transliterate unicode to ascii
ascii_text = unidecode(base)
# Convert to lowercase
lower_text = ascii_text.lower()
# Replace spaces and invalid chars with hyphens
clean_text = re.sub(r'[^a-z0-9/\-_]', '-', lower_text)
# Collapse multiple hyphens
clean_text = re.sub(r'-+', '-', clean_text)
# Clean each path segment
segments = clean_text.split('/')
clean_segments = [s.strip('-') for s in segments]
return '/'.join(clean_segments)
class Entity(Base):
"""
Core entity in the knowledge graph.
@@ -35,6 +75,7 @@ class Entity(Base):
__table_args__ = (
UniqueConstraint("permalink", name="uix_entity_permalink"), # Make permalink unique
Index("ix_entity_type", "entity_type"),
Index("ix_entity_title", "title"),
Index("ix_entity_created_at", "created_at"), # For timeline queries
Index("ix_entity_updated_at", "updated_at"), # For timeline queries
)
@@ -83,6 +124,23 @@ class Entity(Base):
def relations(self):
return self.incoming_relations + self.outgoing_relations
@validates('permalink')
def validate_permalink(self, key, value):
"""Validate permalink format.
Requirements:
1. Must be valid URI path component
2. Only lowercase letters, numbers, hyphens, and underscores
3. Path segments separated by forward slashes
4. No leading/trailing hyphens in segments
"""
if not re.match(r'^[a-z0-9][a-z0-9\-_/]*[a-z0-9]$', value):
raise ValueError(
f"Invalid permalink format: {value}. "
"Use only lowercase letters, numbers, hyphens, and underscores."
)
return value
def __repr__(self) -> str:
return f"Entity(id={self.id}, name='{self.title}', type='{self.entity_type}', summary='{self.summary}')"
@@ -169,4 +227,4 @@ class Relation(Base):
to_entity = relationship("Entity", foreign_keys=[to_id], back_populates="incoming_relations")
def __repr__(self) -> str:
return f"Relation(id={self.id}, from_id={self.from_id}, to_id={self.to_id}, type='{self.relation_type}')"
return f"Relation(id={self.id}, from_id={self.from_id}, to_id={self.to_id}, type='{self.relation_type}')"
@@ -23,6 +23,11 @@ class EntityRepository(Repository[Entity]):
query = self.select().where(Entity.permalink == permalink).options(*self.get_load_options())
return await self.find_one(query)
async def get_by_title(self, title: str) -> Optional[Entity]:
"""Get entity by title."""
query = self.select().where(Entity.title == title).options(*self.get_load_options())
return await self.find_one(query)
async def list_entities(
self,
entity_type: Optional[str] = None,
@@ -0,0 +1,84 @@
"""Service for resolving markdown links to permalinks."""
from typing import Optional, List
from loguru import logger
from basic_memory.services.service import BaseService
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.models import Entity
from basic_memory.services.exceptions import EntityNotFoundError
class LinkResolver(BaseService[Entity]):
"""Service for resolving markdown links to permalinks.
Handles both exact and fuzzy link resolution using a combination of
direct permalink lookup and search-based matching.
"""
def __init__(self, entity_repository: EntityRepository):
"""Initialize with repositories."""
super().__init__(entity_repository)
async def resolve_link(
self,
link_text: str,
source_permalink: Optional[str] = None
) -> str:
"""Resolve a markdown link to a permalink.
Args:
link_text: The text content of the link (without brackets)
source_permalink: Optional permalink of the source document for context
Returns:
Resolved permalink, or original link text if no match found
"""
logger.debug(f"Resolving link: {link_text} from source: {source_permalink}")
# Clean link text
clean_text = self._normalize_link_text(link_text)
try:
# Try exact permalink match first
entity = await self.repository.get_by_permalink(clean_text)
if entity:
logger.debug(f"Found exact permalink match: {entity.permalink}")
return entity.permalink
# Fall back to title match if needed
entity = await self.repository.get_by_title(clean_text)
if entity:
logger.debug(f"Found title match: {entity.permalink}")
return entity.permalink
# No match found - will be created
logger.debug(f"No match found for link: {link_text}")
return clean_text
except Exception as e:
logger.error(f"Error resolving link {link_text}: {e}")
return clean_text
def _normalize_link_text(self, link_text: str) -> str:
"""Normalize link text for matching.
Args:
link_text: Raw link text from markdown
Returns:
Normalized form for matching
"""
# Strip whitespace
text = link_text.strip()
# Remove enclosing brackets if present
if text.startswith('[[') and text.endswith(']]'):
text = text[2:-2]
# Handle Obsidian-style aliases
if '|' in text:
text = text.split('|')[0]
return text
@@ -8,6 +8,7 @@ from sqlalchemy import select
from basic_memory import db
from basic_memory.models import Entity, Observation, Relation
from basic_memory.models.knowledge import generate_permalink
from basic_memory.repository.entity_repository import EntityRepository
@@ -447,3 +448,81 @@ async def test_list_entities_with_related(entity_repository: EntityRepository, s
core_service = next(e for e in services_and_related if e.title == "core_service")
assert len(core_service.outgoing_relations) > 0 # Has incoming relation from config
assert len(core_service.incoming_relations) > 0 # Has outgoing relation to db
@pytest.mark.asyncio
async def test_create_entity_with_invalid_permalink(entity_repository: EntityRepository):
"""Test that creating an entity with invalid permalink raises error."""
with pytest.raises(ValueError, match="Invalid permalink format"):
await entity_repository.create({
"title": "Test",
"entity_type": "test",
"permalink": "Test/Invalid!!", # Invalid permalink
"file_path": "test/test.md",
"content_type": "text/markdown",
})
@pytest.mark.asyncio
async def test_generate_permalink_from_file_path():
"""Test permalink generation from different file paths."""
test_cases = [
("docs/My Feature.md", "docs/my-feature"),
("specs/API (v2).md", "specs/api-v2"),
("notes/2024/Q1 Planning!!!.md", "notes/2024/q1-planning"),
("test/Über File.md", "test/uber-file"),
("docs/my_feature_name.md", "docs/my_feature_name"),
("specs/multiple--dashes.md", "specs/multiple-dashes"),
("notes/trailing/space/ file.md", "notes/trailing/space/file"),
]
for input_path, expected in test_cases:
result = generate_permalink(input_path)
assert result == expected, f"Failed for {input_path}"
# Verify the result passes validation
Entity(
title="test",
entity_type="test",
permalink=result,
file_path=input_path,
content_type="text/markdown"
) # This will raise ValueError if invalid
@pytest.mark.asyncio
async def test_get_by_title(entity_repository: EntityRepository, session_maker):
"""Test getting an entity by title."""
# Create test entities
async with db.scoped_session(session_maker) as session:
entities = [
Entity(
title="Unique Title",
entity_type="test",
permalink="test/unique-title",
file_path="test/unique-title.md",
content_type="text/markdown",
),
Entity(
title="Another Title",
entity_type="test",
permalink="test/another-title",
file_path="test/another-title.md",
content_type="text/markdown",
),
]
session.add_all(entities)
await session.flush()
# Test getting by exact title
found = await entity_repository.get_by_title("Unique Title")
assert found is not None
assert found.title == "Unique Title"
# Test case sensitivity
found = await entity_repository.get_by_title("unique title")
assert found is None # Should be case-sensitive
# Test non-existent title
found = await entity_repository.get_by_title("Non Existent")
assert found is None
+107
View File
@@ -0,0 +1,107 @@
"""Tests for link resolution service."""
import pytest
import pytest_asyncio
from basic_memory.models.knowledge import Entity
from basic_memory.services.link_resolver import LinkResolver
@pytest_asyncio.fixture
async def link_resolver(entity_repository):
"""Create LinkResolver instance."""
return LinkResolver(entity_repository)
@pytest.mark.asyncio
async def test_exact_permalink_match(
link_resolver, entity_repository
):
"""Test resolving a link that exactly matches a permalink."""
# Create test entity
entity = Entity(
title="Test Entity",
entity_type="test",
summary="A test entity",
permalink="specs/test-entity",
file_path="specs/test-entity.md",
content_type="text/markdown"
)
await entity_repository.add(entity)
# Test exact permalink match
result = await link_resolver.resolve_link("specs/test-entity")
assert result == "specs/test-entity"
@pytest.mark.asyncio
async def test_normalize_link_text(link_resolver):
"""Test link text normalization."""
assert link_resolver._normalize_link_text("[[Test Entity]]") == "Test Entity"
assert link_resolver._normalize_link_text("Test Entity|Alias") == "Test Entity"
assert link_resolver._normalize_link_text(" Test Entity ") == "Test Entity"
assert link_resolver._normalize_link_text("specs/test-entity") == "specs/test-entity"
@pytest.mark.asyncio
async def test_title_match(
link_resolver, entity_repository
):
"""Test resolving a link that matches an entity title."""
# Create test entity
entity = Entity(
title="Test Entity",
entity_type="test",
summary="A test entity",
permalink="specs/test-entity",
file_path="specs/test-entity.md",
content_type="text/markdown"
)
await entity_repository.add(entity)
# Test title match
result = await link_resolver.resolve_link("Test Entity")
assert result == "specs/test-entity"
@pytest.mark.asyncio
async def test_no_match_returns_original(link_resolver):
"""Test that unmatched links return original text."""
result = await link_resolver.resolve_link("Non Existent Entity")
assert result == "Non Existent Entity"
@pytest.mark.asyncio
async def test_obsidian_style_links(
link_resolver, entity_repository
):
"""Test handling Obsidian-style links with aliases."""
# Create test entity
entity = Entity(
title="Original Title",
entity_type="test",
summary="A test entity",
permalink="test/original-title",
file_path="test/original-title.md",
content_type="text/markdown"
)
await entity_repository.add(entity)
# Test with Obsidian link formats
result = await link_resolver.resolve_link("[[Original Title|Display Text]]")
assert result == "test/original-title"
@pytest.mark.asyncio
async def test_error_handling(
link_resolver, entity_repository, monkeypatch
):
"""Test error handling during link resolution."""
# Mock repository to raise an exception
async def mock_get_by_permalink(*args, **kwargs):
raise Exception("Test error")
monkeypatch.setattr(entity_repository, "get_by_permalink", mock_get_by_permalink)
# Should return original text on error
result = await link_resolver.resolve_link("Test Entity")
assert result == "Test Entity"
Generated
+12
View File
@@ -197,6 +197,7 @@ dependencies = [
{ name = "rich" },
{ name = "sqlalchemy" },
{ name = "typer" },
{ name = "unidecode" },
]
[package.optional-dependencies]
@@ -236,6 +237,7 @@ requires-dist = [
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.6" },
{ name = "sqlalchemy", specifier = ">=2.0.0" },
{ name = "typer", specifier = ">=0.9.0" },
{ name = "unidecode", specifier = ">=1.3.8" },
]
[package.metadata.requires-dev]
@@ -1130,6 +1132,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/62/51/2007ea29e605957a1
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/16/4623fad6076448df21c1a870c93a9774ad8a7b4dd1660223b59082dd8fec/psycopg2-2.9.10-cp312-cp312-win32.whl", hash = "sha256:65a63d7ab0e067e2cdb3cf266de39663203d38d6a8ed97f5ca0cb315c73fe067", size = 1025113 },
{ url = "https://files.pythonhosted.org/packages/66/de/baed128ae0fc07460d9399d82e631ea31a1f171c0c4ae18f9808ac6759e3/psycopg2-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:4a579d6243da40a7b3182e0430493dbd55950c493d8c68f4eec0b302f6bbf20e", size = 1163951 },
{ url = "https://files.pythonhosted.org/packages/ae/49/a6cfc94a9c483b1fa401fbcb23aca7892f60c7269c5ffa2ac408364f80dc/psycopg2-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:91fd603a2155da8d0cfcdbf8ab24a2d54bca72795b90d2a3ed2b6da8d979dee2", size = 2569060 },
]
[[package]]
@@ -1590,6 +1593,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/ab/7e5f53c3b9d14972843a647d8d7a853969a58aecc7559cb3267302c94774/tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd", size = 346586 },
]
[[package]]
name = "unidecode"
version = "1.3.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f7/89/19151076a006b9ac0dd37b1354e031f5297891ee507eb624755e58e10d3e/Unidecode-1.3.8.tar.gz", hash = "sha256:cfdb349d46ed3873ece4586b96aa75258726e2fa8ec21d6f00a591d98806c2f4", size = 192701 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/b7/6ec57841fb67c98f52fc8e4a2d96df60059637cba077edc569a302a8ffc7/Unidecode-1.3.8-py3-none-any.whl", hash = "sha256:d130a61ce6696f8148a3bd8fe779c99adeb4b870584eeb9526584e9aa091fd39", size = 235494 },
]
[[package]]
name = "urllib3"
version = "2.2.3"