feat(tagging-system): Add comprehensive tagging system implementation

- Add tagging system Python package with modular architecture (config, core, impl)
- Create core interfaces for file discovery, content analysis, and tag generation
- Implement content analyzer with language detection and topic extraction
- Implement file discovery engine with directory scanning and filtering
- Implement tag generator with hierarchical tag creation and consolidation
- Add configuration management with example config and validation
- Create comprehensive design and requirements documentation in .kiro/specs
- Add pytest test suite with unit tests for models, interfaces, and config
- Add setup.py, requirements.txt, and pytest.ini for package management
- Add README.md with project overview and usage instructions
- Update workspace.json with new project structure
- Add Excalidraw diagram for system architecture visualization
- Establish foundation for automated vault tagging and metadata management
This commit is contained in:
windyboy
2025-12-31 08:37:16 +08:00
parent 6c7a6d0e3a
commit f99056a099
44 changed files with 3209 additions and 14 deletions
+1
View File
@@ -0,0 +1 @@
"""Test package for the tagging system."""
Binary file not shown.
+124
View File
@@ -0,0 +1,124 @@
"""Pytest configuration and fixtures."""
import pytest
from pathlib import Path
from datetime import datetime
from typing import List
from hypothesis import settings, Verbosity
from tagging_system.core.models import (
FileInfo,
ContentAnalysis,
TagStructure,
FrontmatterData,
ContentType,
LanguageInfo
)
from tagging_system.config import TaggingConfig
# Configure hypothesis for property-based testing
settings.register_profile("default", max_examples=100, verbosity=Verbosity.normal)
settings.register_profile("ci", max_examples=1000, verbosity=Verbosity.verbose)
settings.load_profile("default")
@pytest.fixture
def sample_file_info() -> FileInfo:
"""Create a sample FileInfo for testing."""
return FileInfo(
path="100-project/AI/test.md",
name="test.md",
directory="100-project/AI",
extension=".md",
size=1024,
created=datetime(2024, 1, 1, 12, 0, 0),
modified=datetime(2024, 1, 2, 12, 0, 0),
content="# Test File\n\nThis is a test file about AI and machine learning."
)
@pytest.fixture
def sample_content_analysis() -> ContentAnalysis:
"""Create a sample ContentAnalysis for testing."""
return ContentAnalysis(
language=LanguageInfo.ENGLISH,
content_type=ContentType.NOTE,
topics=["ai", "machine-learning", "technology"],
mentions={
'tools': ['python', 'tensorflow'],
'technologies': ['ai', 'ml'],
'people': [],
'organizations': ['openai']
},
sentiment="neutral",
complexity="intermediate"
)
@pytest.fixture
def sample_tag_structure() -> TagStructure:
"""Create a sample TagStructure for testing."""
return TagStructure(
primary=["project"],
hierarchical=["tech/ai", "tech/ml"],
content=["python", "tensorflow"],
meta=["lang/en", "type/note"],
custom=["custom-tag"]
)
@pytest.fixture
def sample_frontmatter_data() -> FrontmatterData:
"""Create a sample FrontmatterData for testing."""
return FrontmatterData(
title="Test File",
tags=["project", "tech/ai", "python"],
created="2024-01-01",
updated="2024-01-02",
type="note",
lang="en",
aliases=["test"],
description="A test file for AI projects"
)
@pytest.fixture
def default_config() -> TaggingConfig:
"""Create a default TaggingConfig for testing."""
return TaggingConfig()
@pytest.fixture
def temp_vault_structure(tmp_path: Path) -> Path:
"""Create a temporary vault structure for testing."""
vault_root = tmp_path / "test_vault"
# Create directory structure
directories = [
"100-project/AI",
"100-project/Infrastructure",
"200-area/Productivity",
"200-area/Health",
"300-resources/Development",
"400-archive",
"Clippings",
"ReadItLater Inbox"
]
for directory in directories:
(vault_root / directory).mkdir(parents=True, exist_ok=True)
# Create sample files
sample_files = [
("100-project/AI/llm-notes.md", "# LLM Notes\n\nNotes about large language models."),
("200-area/Productivity/gtd.md", "# Getting Things Done\n\nProductivity methodology."),
("300-resources/Development/python.md", "# Python Resources\n\nPython development resources."),
("Clippings/article.md", "# Interesting Article\n\nClipped from web.")
]
for file_path, content in sample_files:
file_full_path = vault_root / file_path
file_full_path.write_text(content, encoding='utf-8')
return vault_root
+178
View File
@@ -0,0 +1,178 @@
"""Unit tests for configuration system."""
import pytest
import json
from pathlib import Path
from tagging_system.config import (
TaggingConfig,
DirectoryMapping,
TagHierarchy,
SensitivePatterns,
load_config,
save_config
)
class TestDirectoryMapping:
"""Test DirectoryMapping model."""
def test_directory_mapping_creation(self):
"""Test DirectoryMapping creation."""
mapping = DirectoryMapping(
pattern="100-project",
primary_tag="project",
hierarchical_tags=["tech"],
exclude_patterns=["*.tmp"]
)
assert mapping.pattern == "100-project"
assert mapping.primary_tag == "project"
assert mapping.hierarchical_tags == ["tech"]
assert mapping.exclude_patterns == ["*.tmp"]
class TestTagHierarchy:
"""Test TagHierarchy model."""
def test_tag_hierarchy_creation(self):
"""Test TagHierarchy creation."""
hierarchy = TagHierarchy(
root="tech",
children={"ai": TagHierarchy("ai")},
aliases=["technology"]
)
assert hierarchy.root == "tech"
assert "ai" in hierarchy.children
assert hierarchy.aliases == ["technology"]
def test_get_full_path(self):
"""Test get_full_path method."""
hierarchy = TagHierarchy("tech")
assert hierarchy.get_full_path() == "tech"
assert hierarchy.get_full_path("ai") == "tech/ai"
assert hierarchy.get_full_path("ai/llm") == "tech/ai/llm"
class TestSensitivePatterns:
"""Test SensitivePatterns model."""
def test_sensitive_patterns_defaults(self):
"""Test SensitivePatterns default values."""
patterns = SensitivePatterns()
assert len(patterns.credential_patterns) > 0
assert len(patterns.personal_patterns) > 0
assert len(patterns.financial_patterns) > 0
assert any("api" in pattern for pattern in patterns.credential_patterns)
class TestTaggingConfig:
"""Test TaggingConfig model."""
def test_tagging_config_defaults(self):
"""Test TaggingConfig default values."""
config = TaggingConfig()
assert len(config.directory_mappings) > 0
assert len(config.tag_hierarchies) > 0
assert len(config.excluded_directories) > 0
assert config.tag_format_rules["case"] == "kebab"
def test_get_directory_mapping(self):
"""Test get_directory_mapping method."""
config = TaggingConfig()
mapping = config.get_directory_mapping("100-project/AI/test.md")
assert mapping is not None
assert mapping.primary_tag == "project"
no_mapping = config.get_directory_mapping("unknown/path")
assert no_mapping is None
def test_get_tag_hierarchy(self):
"""Test get_tag_hierarchy method."""
config = TaggingConfig()
tech_hierarchy = config.get_tag_hierarchy("tech")
assert tech_hierarchy is not None
assert tech_hierarchy.root == "tech"
unknown_hierarchy = config.get_tag_hierarchy("unknown")
assert unknown_hierarchy is None
def test_is_excluded_directory(self):
"""Test is_excluded_directory method."""
config = TaggingConfig()
assert config.is_excluded_directory(".obsidian/plugins") is True
assert config.is_excluded_directory("100-project/AI") is False
def test_is_excluded_file(self):
"""Test is_excluded_file method."""
config = TaggingConfig()
assert config.is_excluded_file("test.pyc") is True
assert config.is_excluded_file(".DS_Store") is True
assert config.is_excluded_file("test.md") is False
class TestConfigLoading:
"""Test configuration loading and saving."""
def test_load_default_config(self):
"""Test loading default config when no file exists."""
config = load_config()
assert isinstance(config, TaggingConfig)
assert len(config.directory_mappings) > 0
def test_load_config_creates_default_file(self, tmp_path: Path):
"""Test that load_config creates default file when path doesn't exist."""
config_path = tmp_path / "config.yaml"
config = load_config(config_path)
assert isinstance(config, TaggingConfig)
assert config_path.exists()
def test_save_and_load_config(self, tmp_path: Path):
"""Test saving and loading configuration."""
config_path = tmp_path / "test_config.yaml"
original_config = TaggingConfig()
original_config.excluded_directories.append("test_exclude")
save_config(original_config, config_path)
loaded_config = load_config(config_path)
assert config_path.exists()
assert "test_exclude" in loaded_config.excluded_directories
def test_load_json_config(self, tmp_path: Path):
"""Test loading JSON configuration."""
config_path = tmp_path / "config.json"
config_data = {
"excluded_directories": [".test", ".custom"],
"tag_format_rules": {"case": "snake"}
}
with open(config_path, 'w') as f:
json.dump(config_data, f)
config = load_config(config_path)
assert ".test" in config.excluded_directories
assert ".custom" in config.excluded_directories
assert config.tag_format_rules["case"] == "snake"
def test_load_invalid_config_returns_default(self, tmp_path: Path):
"""Test that invalid config file returns default config."""
config_path = tmp_path / "invalid.yaml"
config_path.write_text("invalid: yaml: content: [")
config = load_config(config_path)
# Should return default config on error
assert isinstance(config, TaggingConfig)
assert len(config.directory_mappings) > 0
+232
View File
@@ -0,0 +1,232 @@
"""Unit tests for core interfaces and protocols."""
import pytest
from typing import List
from tagging_system.core.interfaces import (
FileDiscovery,
ContentAnalyzer,
TagGenerator,
FrontmatterManager,
BaseFileDiscovery,
BaseContentAnalyzer,
BaseTagGenerator,
BaseFrontmatterManager
)
from tagging_system.core.models import (
FileInfo,
ContentAnalysis,
TagStructure,
FrontmatterData,
ValidationResult,
ContentType,
LanguageInfo
)
class MockFileDiscovery(BaseFileDiscovery):
"""Mock implementation of FileDiscovery for testing."""
def scan_directory(self, path: str) -> List[FileInfo]:
"""Mock directory scanning."""
from datetime import datetime
return [
FileInfo(
path=f"{path}/test.md",
name="test.md",
directory=path,
extension=".md",
size=100,
created=datetime.now(),
modified=datetime.now()
)
]
def filter_by_type(self, files: List[FileInfo], types: List[str]) -> List[FileInfo]:
"""Mock file filtering."""
return [f for f in files if f.extension in types]
def exclude_sensitive(self, files: List[FileInfo]) -> List[FileInfo]:
"""Mock sensitive file exclusion."""
return [f for f in files if "sensitive" not in f.path]
class MockContentAnalyzer(BaseContentAnalyzer):
"""Mock implementation of ContentAnalyzer for testing."""
def analyze_content(self, content: str) -> ContentAnalysis:
"""Mock content analysis."""
return ContentAnalysis(
language=LanguageInfo.ENGLISH,
content_type=ContentType.NOTE,
topics=["test"],
mentions={'tools': [], 'technologies': [], 'people': [], 'organizations': []}
)
def detect_language(self, content: str) -> str:
"""Mock language detection."""
return "en"
def extract_topics(self, content: str) -> List[str]:
"""Mock topic extraction."""
return ["test", "mock"]
def classify_content_type(self, content: str, filename: str) -> str:
"""Mock content type classification."""
return "note"
class MockTagGenerator(BaseTagGenerator):
"""Mock implementation of TagGenerator for testing."""
def generate_directory_tags(self, filepath: str) -> List[str]:
"""Mock directory tag generation."""
if "100-project" in filepath:
return ["project"]
return ["unknown"]
def generate_content_tags(self, analysis: ContentAnalysis) -> List[str]:
"""Mock content tag generation."""
return analysis.topics
def generate_hierarchical_tags(self, topics: List[str]) -> List[str]:
"""Mock hierarchical tag generation."""
return [f"topic/{topic}" for topic in topics]
def consolidate_tags(self, tags: List[str]) -> List[str]:
"""Mock tag consolidation."""
return list(set(tags)) # Remove duplicates
class MockFrontmatterManager(BaseFrontmatterManager):
"""Mock implementation of FrontmatterManager for testing."""
def parse_frontmatter(self, content: str) -> FrontmatterData:
"""Mock frontmatter parsing."""
return FrontmatterData(
title="Test",
tags=["test"],
created="2024-01-01"
)
def update_frontmatter(self, content: str, updates: FrontmatterData) -> str:
"""Mock frontmatter updating."""
return f"---\ntitle: {updates.title}\ntags: {updates.tags}\n---\n{content}"
def validate_frontmatter(self, data: FrontmatterData) -> ValidationResult:
"""Mock frontmatter validation."""
result = ValidationResult(is_valid=True)
if not data.title:
result.add_error("Title is required")
return result
class TestProtocolCompliance:
"""Test that mock implementations comply with protocols."""
def test_file_discovery_protocol_compliance(self):
"""Test that MockFileDiscovery implements FileDiscovery protocol."""
mock = MockFileDiscovery()
assert isinstance(mock, FileDiscovery)
# Test method calls
files = mock.scan_directory("test")
assert len(files) == 1
assert files[0].name == "test.md"
filtered = mock.filter_by_type(files, [".md"])
assert len(filtered) == 1
non_sensitive = mock.exclude_sensitive(files)
assert len(non_sensitive) == 1
def test_content_analyzer_protocol_compliance(self):
"""Test that MockContentAnalyzer implements ContentAnalyzer protocol."""
mock = MockContentAnalyzer()
assert isinstance(mock, ContentAnalyzer)
# Test method calls
analysis = mock.analyze_content("test content")
assert analysis.language == LanguageInfo.ENGLISH
assert analysis.content_type == ContentType.NOTE
language = mock.detect_language("test content")
assert language == "en"
topics = mock.extract_topics("test content")
assert "test" in topics
content_type = mock.classify_content_type("test content", "test.md")
assert content_type == "note"
def test_tag_generator_protocol_compliance(self):
"""Test that MockTagGenerator implements TagGenerator protocol."""
mock = MockTagGenerator()
assert isinstance(mock, TagGenerator)
# Test method calls
dir_tags = mock.generate_directory_tags("100-project/test.md")
assert "project" in dir_tags
analysis = ContentAnalysis(
language=LanguageInfo.ENGLISH,
content_type=ContentType.NOTE,
topics=["ai", "ml"]
)
content_tags = mock.generate_content_tags(analysis)
assert "ai" in content_tags
hierarchical = mock.generate_hierarchical_tags(["ai", "ml"])
assert "topic/ai" in hierarchical
consolidated = mock.consolidate_tags(["tag1", "tag1", "tag2"])
assert len(consolidated) == 2
def test_frontmatter_manager_protocol_compliance(self):
"""Test that MockFrontmatterManager implements FrontmatterManager protocol."""
mock = MockFrontmatterManager()
assert isinstance(mock, FrontmatterManager)
# Test method calls
frontmatter = mock.parse_frontmatter("---\ntitle: Test\n---\nContent")
assert frontmatter.title == "Test"
updated = mock.update_frontmatter("Content", frontmatter)
assert "title: Test" in updated
validation = mock.validate_frontmatter(frontmatter)
assert validation.is_valid is True
class TestAbstractBaseClasses:
"""Test abstract base class behavior."""
def test_base_classes_cannot_be_instantiated(self):
"""Test that abstract base classes cannot be instantiated directly."""
with pytest.raises(TypeError):
BaseFileDiscovery()
with pytest.raises(TypeError):
BaseContentAnalyzer()
with pytest.raises(TypeError):
BaseTagGenerator()
with pytest.raises(TypeError):
BaseFrontmatterManager()
def test_concrete_implementations_work(self):
"""Test that concrete implementations of base classes work."""
file_discovery = MockFileDiscovery()
content_analyzer = MockContentAnalyzer()
tag_generator = MockTagGenerator()
frontmatter_manager = MockFrontmatterManager()
# All should be instances of their respective base classes
assert isinstance(file_discovery, BaseFileDiscovery)
assert isinstance(content_analyzer, BaseContentAnalyzer)
assert isinstance(tag_generator, BaseTagGenerator)
assert isinstance(frontmatter_manager, BaseFrontmatterManager)
+193
View File
@@ -0,0 +1,193 @@
"""Unit tests for core data models."""
import pytest
from datetime import datetime
from tagging_system.core.models import (
FileInfo,
ContentAnalysis,
TagStructure,
FrontmatterData,
ValidationResult,
ContentType,
LanguageInfo
)
class TestFileInfo:
"""Test FileInfo model."""
def test_file_info_creation(self):
"""Test FileInfo creation with required fields."""
file_info = FileInfo(
path="test/file.md",
name="file.md",
directory="test",
extension=".md",
size=100,
created=datetime.now(),
modified=datetime.now()
)
assert file_info.path == "test/file.md"
assert file_info.name == "file.md"
assert file_info.is_markdown is True
assert file_info.relative_path == "test/file.md"
def test_is_markdown_detection(self):
"""Test markdown file detection."""
md_file = FileInfo("test.md", "test.md", ".", ".md", 100, datetime.now(), datetime.now())
txt_file = FileInfo("test.txt", "test.txt", ".", ".txt", 100, datetime.now(), datetime.now())
assert md_file.is_markdown is True
assert txt_file.is_markdown is False
class TestContentAnalysis:
"""Test ContentAnalysis model."""
def test_content_analysis_creation(self):
"""Test ContentAnalysis creation."""
analysis = ContentAnalysis(
language=LanguageInfo.ENGLISH,
content_type=ContentType.NOTE,
topics=["ai", "ml"]
)
assert analysis.language == LanguageInfo.ENGLISH
assert analysis.content_type == ContentType.NOTE
assert analysis.topics == ["ai", "ml"]
assert analysis.complexity == "basic" # Default value
def test_complexity_validation(self):
"""Test complexity validation in post_init."""
analysis = ContentAnalysis(
language=LanguageInfo.ENGLISH,
content_type=ContentType.NOTE,
topics=[],
complexity="invalid"
)
assert analysis.complexity == "basic" # Should default to basic
class TestTagStructure:
"""Test TagStructure model."""
def test_tag_structure_creation(self):
"""Test TagStructure creation."""
tags = TagStructure(
primary=["project"],
hierarchical=["tech/ai"],
content=["python"],
meta=["lang/en"],
custom=["custom"]
)
assert tags.primary == ["project"]
assert tags.hierarchical == ["tech/ai"]
assert tags.content == ["python"]
assert tags.meta == ["lang/en"]
assert tags.custom == ["custom"]
def test_all_tags_method(self):
"""Test all_tags method returns unique tags."""
tags = TagStructure(
primary=["project", "duplicate"],
hierarchical=["tech/ai"],
content=["python", "duplicate"], # Duplicate tag
meta=["lang/en"],
custom=["custom"]
)
all_tags = tags.all_tags()
# Expected unique tags: project, duplicate, tech/ai, python, lang/en, custom = 6 tags
assert len(all_tags) == 6
assert "duplicate" in all_tags
assert "project" in all_tags
assert "tech/ai" in all_tags
assert "python" in all_tags
assert "lang/en" in all_tags
assert "custom" in all_tags
# Test that duplicates are actually removed by checking set behavior
unique_tags = set(all_tags)
assert len(unique_tags) == len(all_tags) # No duplicates should exist
# Test with actual duplicates to verify deduplication works
tags_with_more_duplicates = TagStructure(
primary=["tag1", "tag2"],
hierarchical=["tag1"], # Duplicate of primary
content=["tag2", "tag3"], # Duplicate of primary
meta=["tag3"], # Duplicate of content
custom=["tag4"]
)
deduplicated = tags_with_more_duplicates.all_tags()
assert len(deduplicated) == 4 # tag1, tag2, tag3, tag4
assert len(set(deduplicated)) == len(deduplicated)
class TestFrontmatterData:
"""Test FrontmatterData model."""
def test_frontmatter_data_creation(self):
"""Test FrontmatterData creation."""
frontmatter = FrontmatterData(
title="Test",
tags=["tag1", "tag2"],
created="2024-01-01",
type="note"
)
assert frontmatter.title == "Test"
assert frontmatter.tags == ["tag1", "tag2"]
assert frontmatter.created == "2024-01-01"
assert frontmatter.type == "note"
def test_to_dict_method(self):
"""Test to_dict method excludes None values."""
frontmatter = FrontmatterData(
title="Test",
tags=["tag1"],
created="2024-01-01",
updated=None, # Should be excluded
custom_fields={"custom": "value"}
)
result = frontmatter.to_dict()
assert result["title"] == "Test"
assert result["tags"] == ["tag1"]
assert result["created"] == "2024-01-01"
assert "updated" not in result # None values excluded
assert result["custom"] == "value" # Custom fields included
class TestValidationResult:
"""Test ValidationResult model."""
def test_validation_result_creation(self):
"""Test ValidationResult creation."""
result = ValidationResult(is_valid=True)
assert result.is_valid is True
assert result.errors == []
assert result.warnings == []
assert result.suggestions == []
def test_add_error_sets_invalid(self):
"""Test that adding error sets is_valid to False."""
result = ValidationResult(is_valid=True)
result.add_error("Test error")
assert result.is_valid is False
assert "Test error" in result.errors
def test_add_warning_and_suggestion(self):
"""Test adding warnings and suggestions."""
result = ValidationResult(is_valid=True)
result.add_warning("Test warning")
result.add_suggestion("Test suggestion")
assert result.is_valid is True # Warnings don't affect validity
assert "Test warning" in result.warnings
assert "Test suggestion" in result.suggestions
+97
View File
@@ -0,0 +1,97 @@
"""Test overall package structure and imports."""
import pytest
class TestPackageStructure:
"""Test that the package structure is correct."""
def test_main_package_imports(self):
"""Test that main package imports work correctly."""
from tagging_system import (
FileInfo,
ContentAnalysis,
TagStructure,
FrontmatterData,
ContentType,
LanguageInfo,
ValidationResult,
FileDiscovery,
ContentAnalyzer,
TagGenerator,
FrontmatterManager
)
# Test that all imports are available
assert FileInfo is not None
assert ContentAnalysis is not None
assert TagStructure is not None
assert FrontmatterData is not None
assert ContentType is not None
assert LanguageInfo is not None
assert ValidationResult is not None
assert FileDiscovery is not None
assert ContentAnalyzer is not None
assert TagGenerator is not None
assert FrontmatterManager is not None
def test_core_module_imports(self):
"""Test that core module imports work correctly."""
from tagging_system.core import (
FileInfo,
ContentAnalysis,
TagStructure,
FrontmatterData,
ContentType,
LanguageInfo,
ValidationResult,
FileDiscovery,
ContentAnalyzer,
TagGenerator,
FrontmatterManager
)
# All imports should be available
assert all([
FileInfo, ContentAnalysis, TagStructure, FrontmatterData,
ContentType, LanguageInfo, ValidationResult,
FileDiscovery, ContentAnalyzer, TagGenerator, FrontmatterManager
])
def test_config_module_imports(self):
"""Test that config module imports work correctly."""
from tagging_system.config import (
TaggingConfig,
DirectoryMapping,
TagHierarchy,
SensitivePatterns,
load_config,
save_config
)
# All imports should be available
assert all([
TaggingConfig, DirectoryMapping, TagHierarchy,
SensitivePatterns, load_config, save_config
])
def test_cli_module_import(self):
"""Test that CLI module can be imported."""
from tagging_system import cli
assert hasattr(cli, 'main')
assert callable(cli.main)
def test_package_version(self):
"""Test that package version is available."""
import tagging_system
assert hasattr(tagging_system, '__version__')
assert tagging_system.__version__ == "0.1.0"
def test_package_metadata(self):
"""Test that package metadata is available."""
import tagging_system
assert hasattr(tagging_system, '__author__')
assert tagging_system.__author__ == "Tagging System"