Compare commits

...

1 Commits

Author SHA1 Message Date
claude[bot] e1c80d93f7 fix: Improve data validation and error handling for production bugs (#431)
This commit addresses 5 production bugs reported in issue #431:

1. Tags dict handling: parse_tags() now handles dict objects from
   malformed YAML (e.g., tags:{key:value}) by converting dict keys
   to tag list with warning. Prevents AttributeError on .strip().

2. Path normalization: Added normalize_path_separators() function
   and applied it in Entity.file_path property and resource router
   to prevent double slashes causing DB UNIQUE constraint violations.

3. YAML error messages: Enhanced parse_frontmatter() to detect
   common mistakes like missing space after colon (key:value vs
   key: value) and provide actionable suggestions with examples.

4. Edit operation errors: Improved 'Text to replace not found' error
   in entity_service.apply_edit_operation() to include 500-char
   content preview and suggest using read_note() for debugging.

5. Import validation: Added data structure validation in
   memory_json_importer before accessing fields. Checks for dict
   type and 'type' field existence, logs helpful messages, and
   continues processing valid entries.

All fixes include inline comments referencing issue #431 for
future maintainability.

Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
2025-11-13 16:25:32 +00:00
6 changed files with 120 additions and 6 deletions
@@ -16,6 +16,7 @@ from basic_memory.deps import (
FileServiceDep,
EntityRepositoryDep,
)
from basic_memory.file_utils import normalize_path_separators
from basic_memory.repository.search_repository import SearchIndexRow
from basic_memory.schemas.memory import normalize_memory_url
from basic_memory.schemas.search import SearchQuery, SearchItemType
@@ -149,6 +150,9 @@ async def write_resource(
JSON response with file information
"""
try:
# Normalize file_path to prevent double slashes that cause DB conflicts (issue #431)
file_path = normalize_path_separators(file_path)
# Get content from request body
# Defensive type checking: ensure content is a string
+67 -1
View File
@@ -137,7 +137,32 @@ def parse_frontmatter(content: str) -> Dict[str, Any]:
return frontmatter
except yaml.YAMLError as e:
raise ParseError(f"Invalid YAML in frontmatter: {e}")
# Provide helpful error messages for common YAML mistakes (issue #431)
error_msg = str(e)
suggestions = []
# Check for common formatting errors
yaml_content = parts[1]
if "could not find expected ':'" in error_msg:
suggestions.append(
"Missing space after colon - YAML requires 'key: value' not 'key:value'"
)
# Show specific line with the problem if available
for line in yaml_content.split("\n"):
if ":" in line and not ": " in line:
# Found a line with colon but no space after
suggestions.append(f"Problem line: '{line.strip()}'")
fixed_line = line.replace(":", ": ", 1)
suggestions.append(f"Try: '{fixed_line.strip()}'")
break
if suggestions:
suggestion_text = "\n ".join(suggestions)
raise ParseError(
f"Invalid YAML in frontmatter: {error_msg}\n\nSuggestions:\n {suggestion_text}"
)
else:
raise ParseError(f"Invalid YAML in frontmatter: {error_msg}")
except Exception as e: # pragma: no cover
if not isinstance(e, ParseError):
@@ -241,6 +266,47 @@ def sanitize_for_filename(text: str, replacement: str = "-") -> str:
return text.strip(replacement)
def normalize_path_separators(path: str) -> str:
"""
Normalize path separators to prevent double slashes and database conflicts.
This function addresses issue #431 where double slashes in paths like
'L3_design/emotion//biorhythm.md' cause UNIQUE constraint violations
in the database.
Args:
path: File path that may contain double slashes or mixed separators
Returns:
Path with normalized separators (single forward slashes)
Examples:
>>> normalize_path_separators("folder//file.md")
'folder/file.md'
>>> normalize_path_separators("path\\\\to\\\\file.md")
'path/to/file.md'
>>> normalize_path_separators("./folder/./file.md")
'folder/file.md'
"""
if not path:
return ""
# Replace backslashes with forward slashes
normalized = path.replace("\\", "/")
# Remove leading ./ if present
if normalized.startswith("./"):
normalized = normalized[2:]
# Compress multiple slashes into single slashes
normalized = re.sub(r"/+", "/", normalized)
# Remove trailing slashes
normalized = normalized.rstrip("/")
return normalized
def sanitize_for_folder(folder: str) -> str:
"""
Sanitize folder path to be safe for use in file system paths.
@@ -42,6 +42,20 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
# First pass - collect entities and relations
for line in source_data:
data = line
# Validate data structure (issue #431)
if not isinstance(data, dict):
logger.warning(f"Skipping invalid data (not a dict): {data}")
skipped_entities += 1
continue
if "type" not in data:
logger.warning(
f"Skipping data missing 'type' field. Available fields: {list(data.keys())}"
)
skipped_entities += 1
continue
if data["type"] == "entity":
# Handle different possible name keys
entity_name = data.get("name") or data.get("entityName") or data.get("id")
+13 -4
View File
@@ -24,7 +24,11 @@ from dateparser import parse
from pydantic import BaseModel, BeforeValidator, Field, model_validator
from basic_memory.config import ConfigManager
from basic_memory.file_utils import sanitize_for_filename, sanitize_for_folder
from basic_memory.file_utils import (
sanitize_for_filename,
sanitize_for_folder,
normalize_path_separators,
)
from basic_memory.utils import generate_permalink
@@ -238,14 +242,19 @@ class Entity(BaseModel):
@property
def file_path(self):
"""Get the file path for this entity based on its permalink."""
"""Get the file path for this entity based on its permalink.
Returns normalized path to prevent double slashes (issue #431).
"""
safe_title = self.safe_title
if self.content_type == "text/markdown":
return (
path = (
os.path.join(self.folder, f"{safe_title}.md") if self.folder else f"{safe_title}.md"
)
else:
return os.path.join(self.folder, safe_title) if self.folder else safe_title
path = os.path.join(self.folder, safe_title) if self.folder else safe_title
# Normalize to prevent double slashes that cause DB conflicts (issue #431)
return normalize_path_separators(path)
@property
def permalink(self) -> Optional[Permalink]:
+11 -1
View File
@@ -588,7 +588,17 @@ class EntityService(BaseService[EntityModel]):
# Validate count matches expected
if actual_count != expected_replacements:
if actual_count == 0:
raise ValueError(f"Text to replace not found: '{find_text}'")
# Provide helpful context for debugging (issue #431)
# Show a snippet of the content to help diagnose the mismatch
content_preview = current_content[:500]
if len(current_content) > 500:
content_preview += "..."
raise ValueError(
f"Text to replace not found: '{find_text}'\n\n"
f"Content preview (first 500 chars):\n{content_preview}\n\n"
f"Suggestion: Use read_note() to view the full content and verify the exact text to find."
)
else:
raise ValueError(
f"Expected {expected_replacements} occurrences of '{find_text}', "
+11
View File
@@ -272,6 +272,17 @@ def parse_tags(tags: Union[List[str], str, None]) -> List[str]:
if tags is None:
return []
# Handle dict objects (issue #431) - PyYAML can produce dicts for malformed YAML
# e.g., `tags:{key:value}` gets parsed as dict instead of failing
if isinstance(tags, dict):
logger.warning(
f"Tags provided as dict instead of list/string. "
f"Converting dict keys to tag list: {tags}. "
f"Expected format: 'tags: [tag1, tag2]' or 'tags: tag1, tag2'"
)
# Use dict keys as tags, ignore values
return [str(key).strip().lstrip("#") for key in tags.keys() if key]
# Process list of tags
if isinstance(tags, list):
# First strip whitespace, then strip leading '#' characters to prevent accumulation