From 73d940e064b4470b277a4d9996a2aeb28e0e8b5e Mon Sep 17 00:00:00 2001 From: phernandez Date: Sun, 30 Nov 2025 00:12:04 -0600 Subject: [PATCH] fix: observation parsing and permalink limits (#446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Hashtag detection now checks for standalone words starting with # instead of just checking if # appears anywhere in content. This prevents HTML color codes like #4285F4 from being interpreted as hashtags. 2. Observation permalinks now truncate content to 200 chars to stay under PostgreSQL's btree index limit of 2704 bytes. Added tests for both fixes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Signed-off-by: phernandez --- src/basic_memory/markdown/plugins.py | 4 +- src/basic_memory/models/knowledge.py | 7 +- tests/markdown/test_markdown_plugins.py | 38 ++++++++ .../repository/test_observation_repository.py | 92 +++++++++++++++++++ 4 files changed, 139 insertions(+), 2 deletions(-) diff --git a/src/basic_memory/markdown/plugins.py b/src/basic_memory/markdown/plugins.py index d491dbbb..1926c27a 100644 --- a/src/basic_memory/markdown/plugins.py +++ b/src/basic_memory/markdown/plugins.py @@ -30,7 +30,9 @@ def is_observation(token: Token) -> bool: # Check for proper observation format: [category] content match = re.match(r"^\[([^\[\]()]+)\]\s+(.+)", content) - has_tags = "#" in content + # Check for standalone hashtags (words starting with #) + # This excludes # in HTML attributes like color="#4285F4" + has_tags = any(part.startswith('#') for part in content.split()) return bool(match) or has_tags diff --git a/src/basic_memory/models/knowledge.py b/src/basic_memory/models/knowledge.py index 1b7faf0a..a7b6c778 100644 --- a/src/basic_memory/models/knowledge.py +++ b/src/basic_memory/models/knowledge.py @@ -162,9 +162,14 @@ class Observation(Base): We can construct these because observations are always defined in and owned by a single entity. + + Content is truncated to 200 chars to stay under PostgreSQL's + btree index limit of 2704 bytes. """ + # Truncate content to avoid exceeding PostgreSQL's btree index limit + content_for_permalink = self.content[:200] if len(self.content) > 200 else self.content return generate_permalink( - f"{self.entity.permalink}/observations/{self.category}/{self.content}" + f"{self.entity.permalink}/observations/{self.category}/{content_for_permalink}" ) def __repr__(self) -> str: # pragma: no cover diff --git a/tests/markdown/test_markdown_plugins.py b/tests/markdown/test_markdown_plugins.py index 77223b29..ae069f6b 100644 --- a/tests/markdown/test_markdown_plugins.py +++ b/tests/markdown/test_markdown_plugins.py @@ -121,6 +121,44 @@ def test_observation_excludes_markdown_and_wiki_links(): assert not is_observation(token), "No space after category should not be valid observation" +def test_observation_excludes_html_color_codes(): + """Test that HTML color codes are NOT interpreted as hashtags. + + This test validates the fix for issue #446 where: + - HTML color codes like #4285F4 in attributes were incorrectly + causing lines to be parsed as observations. + """ + # HTML color code in font tag should NOT be an observation + token = Token("inline", '**Jane:** Welcome to the show', 0) + assert not is_observation(token), "HTML color codes should not trigger hashtag detection" + + # Color code in style attribute + token = Token("inline", 'Styled text', 0) + assert not is_observation(token), "Color codes in style should not be observations" + + # Multiple color codes + token = Token( + "inline", 'Blue and Red', 0 + ) + assert not is_observation(token), "Multiple color codes should not be observations" + + # Hex color without quotes (edge case) + token = Token("inline", "background-color:#FFFFFF is white", 0) + assert not is_observation(token), "Inline hex colors should not be observations" + + # But standalone hashtags SHOULD still work + token = Token("inline", "This has a #realtag in it", 0) + assert is_observation(token), "Standalone hashtags should still work" + + # Multiple real hashtags + token = Token("inline", "Tags: #design #feature #important", 0) + assert is_observation(token), "Multiple standalone hashtags should work" + + # Mix of color code and real tag - should be observation because of real tag + token = Token("inline", 'Text #actualtag', 0) + assert is_observation(token), "Real hashtag with color code should still be observation" + + def test_relation_plugin(): """Test relation plugin.""" md = MarkdownIt().use(relation_plugin) diff --git a/tests/repository/test_observation_repository.py b/tests/repository/test_observation_repository.py index aed3eaef..57331870 100644 --- a/tests/repository/test_observation_repository.py +++ b/tests/repository/test_observation_repository.py @@ -356,3 +356,95 @@ async def test_find_by_category_case_sensitivity( upper_case = await repo.find_by_category("TECH") assert len(upper_case) == 0 # Currently case-sensitive + + +@pytest.mark.asyncio +async def test_observation_permalink_truncates_long_content( + session_maker: async_sessionmaker, repo, test_project: Project +): + """Test that observation permalinks truncate long content. + + This test validates the fix for issue #446 where: + - Long observation content (like transcript dialogue) created permalinks + exceeding PostgreSQL's btree index limit of 2704 bytes. + - Content is now truncated to 200 chars in the permalink property. + """ + async with db.scoped_session(session_maker) as session: + entity = Entity( + project_id=test_project.id, + title="test_entity", + entity_type="test", + permalink="test/test-entity", + file_path="test/test_entity.md", + content_type="text/markdown", + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + session.add(entity) + await session.flush() + + # Create observation with very long content (5000+ chars to simulate transcript) + long_content = "A" * 5000 # Well over the 200 char limit + obs = Observation( + entity_id=entity.id, + content=long_content, + category="transcript", + ) + session.add(obs) + await session.flush() + + # Access the permalink property + permalink = obs.permalink + + # The full content would create a permalink like: + # test/test-entity/observations/transcript/AAAA...5000 chars + # With truncation, it should be much shorter + + # Content portion should be truncated to 200 chars + # Permalink format: entity_permalink/observations/category/content + assert len(permalink) < 300 # Should be well under 300 chars total + assert len(long_content[:200]) == 200 # Verify truncation length + + # Verify the permalink contains expected parts + assert "test/test-entity" in permalink or "test-entity" in permalink + assert "observations" in permalink + assert "transcript" in permalink + + # Full 5000-char content should NOT be in permalink + assert long_content not in permalink + + +@pytest.mark.asyncio +async def test_observation_permalink_short_content_unchanged( + session_maker: async_sessionmaker, repo, test_project: Project +): + """Test that short observation content is not unnecessarily truncated.""" + async with db.scoped_session(session_maker) as session: + entity = Entity( + project_id=test_project.id, + title="test_entity", + entity_type="test", + permalink="test/test-entity", + file_path="test/test_entity.md", + content_type="text/markdown", + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + session.add(entity) + await session.flush() + + # Create observation with short content + short_content = "Short observation content" + obs = Observation( + entity_id=entity.id, + content=short_content, + category="note", + ) + session.add(obs) + await session.flush() + + permalink = obs.permalink + + # Short content should be fully included (after permalink normalization) + # The generate_permalink function normalizes the content + assert "short-observation-content" in permalink.lower()