mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
add link_resolver.py
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user