Initial project setup: Obsidian intelligent journal organizer

- Add core agent architecture with Command + Skill pattern
- Implement Claude API integration for content analysis
- Add Obsidian REST API integration for vault operations
- Create conversational interface (v2.0) with natural language processing
- Add comprehensive configuration management and validation
- Include project documentation and developer guides
- Set up testing framework with unit, integration, and property tests
- Add Kiro specs for Claude API configuration and code quality improvements
- Configure project steering files for development guidelines
This commit is contained in:
windyboy
2025-12-31 17:55:10 +08:00
parent 3200ad3dd5
commit f7e54692a9
67 changed files with 23088 additions and 0 deletions
@@ -0,0 +1,387 @@
# Design Document
## Overview
This design enhances the Claude API configuration system in the Obsidian journal organizer to support custom API URLs and comprehensive model selection. The enhancement maintains backward compatibility while providing flexibility for users to connect to different Claude API endpoints, including proxy servers, regional endpoints, and custom deployments. The design follows the existing configuration patterns and integrates seamlessly with the current Command + Skill architecture.
## Architecture
The configuration enhancement will extend the existing YAML-based configuration system with:
1. **Flexible API URL Configuration**: Support for custom Claude API endpoints
2. **Enhanced Model Validation**: Comprehensive model name validation and suggestions
3. **Environment Variable Integration**: Full support for environment-based configuration
4. **Backward Compatibility Layer**: Seamless migration from existing configurations
5. **Configuration Validation Framework**: Robust validation with helpful error messages
## Components and Interfaces
### Enhanced Claude Configuration Schema
```python
from typing import Optional, Dict, Any, List
from pydantic import BaseModel, validator, Field
import re
from urllib.parse import urlparse
class ClaudeAPIConfig(BaseModel):
"""Enhanced Claude API configuration with URL and model validation"""
api_key: str = Field(..., min_length=1, description="Claude API key")
api_url: Optional[str] = Field(
default="https://api.anthropic.com",
description="Claude API base URL"
)
model: str = Field(
default="claude-3-5-sonnet-20241022",
description="Claude model name"
)
max_tokens: int = Field(default=4096, ge=1, le=200000)
temperature: float = Field(default=0.7, ge=0.0, le=1.0)
@validator('api_url')
def validate_api_url(cls, v: Optional[str]) -> str:
"""Validate API URL format and accessibility"""
if v is None:
return "https://api.anthropic.com"
# Parse URL to validate format
parsed = urlparse(v)
if not parsed.scheme or not parsed.netloc:
raise ValueError(f"Invalid URL format: {v}")
if parsed.scheme not in ['http', 'https']:
raise ValueError(f"URL must use http or https protocol: {v}")
# Remove trailing slash for consistency
return v.rstrip('/')
@validator('model')
def validate_model_name(cls, v: str) -> str:
"""Validate Claude model name and provide suggestions"""
valid_models = [
# Claude 3 series
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307",
# Claude 3.5 series
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
# Latest aliases
"claude-3-opus-latest",
"claude-3-sonnet-latest",
"claude-3-haiku-latest",
"claude-3-5-sonnet-latest",
"claude-3-5-haiku-latest"
]
if v not in valid_models:
# Check if it follows Claude naming pattern
claude_pattern = r'^claude-\d+(\.\d+)?-(opus|sonnet|haiku)(-\d{8}|-latest)?$'
if not re.match(claude_pattern, v, re.IGNORECASE):
suggestions = ", ".join(valid_models[:5])
raise ValueError(
f"Invalid model name: {v}. "
f"Valid models include: {suggestions}. "
f"Model names should follow pattern: claude-X-Y-YYYYMMDD or claude-X-Y-latest"
)
return v
```
### Configuration Loading and Environment Variable Support
```python
import os
import re
from typing import Any, Dict
import yaml
class ConfigurationLoader:
"""Enhanced configuration loader with environment variable expansion"""
@staticmethod
def expand_environment_variables(config_dict: Dict[str, Any]) -> Dict[str, Any]:
"""Recursively expand environment variables in configuration"""
def expand_value(value: Any) -> Any:
if isinstance(value, str):
# Handle ${VAR} and ${VAR:-default} patterns
pattern = r'\$\{([^}]+)\}'
def replace_env_var(match):
var_expr = match.group(1)
if ':-' in var_expr:
var_name, default_value = var_expr.split(':-', 1)
return os.getenv(var_name.strip(), default_value)
else:
var_name = var_expr.strip()
env_value = os.getenv(var_name)
if env_value is None:
raise ValueError(
f"Environment variable '{var_name}' is not set. "
f"Please set it or provide a default value using ${{{var_name}:-default}}"
)
return env_value
return re.sub(pattern, replace_env_var, value)
elif isinstance(value, dict):
return {k: expand_value(v) for k, v in value.items()}
elif isinstance(value, list):
return [expand_value(item) for item in value]
else:
return value
return expand_value(config_dict)
@classmethod
def load_config(cls, config_path: str) -> Dict[str, Any]:
"""Load and validate configuration with environment variable expansion"""
try:
with open(config_path, 'r', encoding='utf-8') as f:
config_dict = yaml.safe_load(f)
# Expand environment variables
config_dict = cls.expand_environment_variables(config_dict)
return config_dict
except FileNotFoundError:
raise ConfigurationError(f"Configuration file not found: {config_path}")
except yaml.YAMLError as e:
raise ConfigurationError(f"Invalid YAML in configuration file: {e}")
except ValueError as e:
raise ConfigurationError(f"Environment variable error: {e}")
```
### Enhanced Claude API Client
```python
import aiohttp
import ssl
from typing import Optional, Dict, Any
from contextlib import asynccontextmanager
class ClaudeAPIClient:
"""Enhanced Claude API client with configurable endpoint support"""
def __init__(self, config: ClaudeAPIConfig):
self.config = config
self.base_url = config.api_url
self.api_key = config.api_key
self.model = config.model
@asynccontextmanager
async def create_session(self):
"""Create HTTP session with proper configuration"""
headers = {
'Authorization': f'Bearer {self.api_key}',
'Content-Type': 'application/json',
'User-Agent': 'journal-organizer/1.0'
}
# Configure SSL context for custom endpoints
ssl_context = ssl.create_default_context()
if self.base_url.startswith('https://localhost') or 'localhost' in self.base_url:
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
connector = aiohttp.TCPConnector(ssl=ssl_context)
async with aiohttp.ClientSession(
connector=connector,
headers=headers,
timeout=aiohttp.ClientTimeout(total=60)
) as session:
yield session
async def validate_connection(self) -> bool:
"""Validate API connection and credentials"""
try:
async with self.create_session() as session:
# Try a simple API call to validate connection
url = f"{self.base_url}/v1/messages"
payload = {
"model": self.model,
"max_tokens": 10,
"messages": [{"role": "user", "content": "test"}]
}
async with session.post(url, json=payload) as response:
if response.status == 401:
raise APIError("Invalid API key or unauthorized access")
elif response.status == 404:
raise APIError(f"API endpoint not found. Check if {self.base_url} is correct")
elif response.status >= 400:
error_text = await response.text()
raise APIError(f"API error ({response.status}): {error_text}")
return True
except aiohttp.ClientError as e:
raise APIError(f"Connection error to {self.base_url}: {str(e)}")
```
## Data Models
### Configuration Migration Support
```python
class ConfigurationMigrator:
"""Handle migration from legacy configuration formats"""
@staticmethod
def migrate_claude_config(config_dict: Dict[str, Any]) -> Dict[str, Any]:
"""Migrate legacy Claude configuration to new format"""
claude_config = config_dict.get('claude', {})
# Add default api_url if not present
if 'api_url' not in claude_config:
claude_config['api_url'] = "https://api.anthropic.com"
# Ensure model has a default value
if 'model' not in claude_config:
claude_config['model'] = "claude-3-5-sonnet-20241022"
# Migrate old model names to new format if needed
model_migrations = {
"claude-3-sonnet": "claude-3-sonnet-20240229",
"claude-3-opus": "claude-3-opus-20240229",
"claude-3-haiku": "claude-3-haiku-20240307"
}
if claude_config['model'] in model_migrations:
old_model = claude_config['model']
new_model = model_migrations[old_model]
claude_config['model'] = new_model
print(f"INFO: Migrated model '{old_model}' to '{new_model}'")
config_dict['claude'] = claude_config
return config_dict
```
## Error Handling
### Comprehensive Error Management
```python
class ClaudeConfigurationError(ConfigurationError):
"""Specific error for Claude API configuration issues"""
pass
class ModelValidationError(ClaudeConfigurationError):
"""Error for invalid model names with suggestions"""
def __init__(self, message: str, suggestions: List[str] = None):
super().__init__(message)
self.suggestions = suggestions or []
class APIConnectionError(ClaudeConfigurationError):
"""Error for API connection issues"""
pass
def validate_claude_configuration(config: Dict[str, Any]) -> ClaudeAPIConfig:
"""Validate Claude configuration with helpful error messages"""
try:
claude_config = config.get('claude', {})
return ClaudeAPIConfig(**claude_config)
except ValidationError as e:
# Transform pydantic errors into user-friendly messages
error_messages = []
for error in e.errors():
field = error['loc'][0] if error['loc'] else 'configuration'
message = error['msg']
if field == 'api_url':
error_messages.append(
f"Invalid API URL: {message}. "
f"Please provide a valid HTTP/HTTPS URL like 'https://api.anthropic.com'"
)
elif field == 'model':
error_messages.append(
f"Invalid model name: {message}"
)
else:
error_messages.append(f"Invalid {field}: {message}")
raise ClaudeConfigurationError(
f"Claude configuration errors:\n" + "\n".join(f"- {msg}" for msg in error_messages)
)
```
## Testing Strategy
### Dual Testing Approach
The testing strategy combines unit tests for specific functionality with property-based tests for comprehensive validation:
**Unit Tests:**
- Test configuration loading with various URL formats
- Test model name validation with valid and invalid inputs
- Test environment variable expansion with different patterns
- Test backward compatibility with legacy configurations
- Mock API calls to test connection validation
**Property-Based Tests:**
- Validate that all valid URL formats are accepted
- Test that model name validation works across all valid model patterns
- Verify environment variable expansion handles all supported formats
- Ensure configuration migration preserves all existing functionality
**Testing Framework:**
- Use `pytest` for unit testing framework
- Use `hypothesis` for property-based testing
- Configure tests to run minimum 100 iterations per property test
- Tag each property test with: **Feature: claude-api-configuration, Property {number}: {property_text}**
Now I need to use the prework tool to analyze the acceptance criteria before writing the correctness properties:
## Correctness Properties
*A property is a characteristic or behavior that should hold true across all valid executions of a system-essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
### Property 1: Configuration Field Support
*For any* configuration dictionary containing claude section fields (api_url, model), the system should successfully parse and store these values in the configuration object
**Validates: Requirements 1.1, 2.1**
### Property 2: URL Validation and Error Handling
*For any* URL string provided as api_url, the system should validate proper URL format, reject malformed URLs with specific error messages, and accept valid HTTP/HTTPS URLs
**Validates: Requirements 1.4, 1.5, 4.1**
### Property 3: Model Name Validation with Suggestions
*For any* model name string, the system should validate Claude naming conventions, accept all supported model variants, and provide helpful suggestions when invalid model names are provided
**Validates: Requirements 2.2, 2.3, 2.4, 4.2**
### Property 4: Default Value Behavior
*For any* configuration missing api_url or model fields, the system should use appropriate default values (default Anthropic API URL and sensible default model) without errors
**Validates: Requirements 1.2, 2.5**
### Property 5: Custom Configuration Usage
*For any* valid custom api_url or model specified in configuration, the system should use these values in API client initialization and requests
**Validates: Requirements 1.3**
### Property 6: Environment Variable Expansion
*For any* configuration value containing environment variable references, the system should correctly expand variables for both api_url and model fields, and validate expanded values the same as direct configuration
**Validates: Requirements 5.1, 5.2, 5.4, 5.5**
### Property 7: Environment Variable Error Handling
*For any* undefined environment variable referenced in configuration, the system should provide clear error messages indicating which variables are missing
**Validates: Requirements 5.3**
### Property 8: Backward Compatibility
*For any* legacy configuration format, the system should load successfully, maintain existing functionality, and provide informational messages about new options without breaking changes
**Validates: Requirements 3.1, 3.2, 3.3, 3.4**
### Property 9: API Connection Validation
*For any* API configuration (URL and credentials), the system should validate connectivity when possible and provide clear authentication error messages for invalid credentials
**Validates: Requirements 4.3, 4.4**
### Property 10: Comprehensive Error Messaging
*For any* configuration validation failure, the system should provide helpful error messages with corrective suggestions, appropriate logging, and references to documentation where applicable
**Validates: Requirements 4.5, 6.3, 6.4**
### Property-Based Testing Configuration
Each property test will be tagged with comments referencing the design document property and run with sufficient iterations to catch edge cases through randomization. Tests will use the `hypothesis` library for property-based testing with minimum 100 iterations per test.
@@ -0,0 +1,87 @@
# Requirements Document
## Introduction
This specification addresses the need to enhance Claude API configuration flexibility in the Obsidian journal organizer project. Currently, the system has limited Claude API configuration options, lacking support for custom API URLs and comprehensive model selection. This enhancement will provide users with greater flexibility to use different Claude API endpoints and models based on their specific needs and deployment scenarios.
## Glossary
- **System**: The Obsidian journal organizer application
- **Claude_API**: Anthropic's Claude AI service API
- **API_URL**: The base URL endpoint for Claude API requests
- **Model_Name**: The specific Claude model identifier (e.g., claude-3-5-sonnet-20241022)
- **Configuration**: YAML-based settings that control system behavior
- **API_Client**: The component responsible for making requests to Claude API
## Requirements
### Requirement 1: Add Claude API URL Configuration
**User Story:** As a user, I want to configure a custom Claude API URL, so that I can use different Claude API endpoints including proxy servers, regional endpoints, or custom deployments.
#### Acceptance Criteria
1. THE Configuration SHALL support a configurable api_url field in the claude section
2. WHEN api_url is not specified, THE System SHALL use the default Anthropic API URL
3. WHEN api_url is specified, THE System SHALL use the custom URL for all Claude API requests
4. THE System SHALL validate that the api_url follows proper URL format
5. WHEN the api_url is invalid, THE System SHALL provide clear error messages during configuration validation
### Requirement 2: Enhance Model Name Configuration
**User Story:** As a user, I want flexible model name configuration with validation, so that I can easily switch between different Claude models and get clear feedback when using unsupported models.
#### Acceptance Criteria
1. THE Configuration SHALL support model name specification in the claude section
2. THE System SHALL validate that the specified model name follows Claude model naming conventions
3. WHEN an invalid model name is provided, THE System SHALL provide helpful suggestions for valid model names
4. THE System SHALL support all current Claude model variants (claude-3-5-sonnet, claude-3-haiku, claude-3-opus)
5. WHEN model configuration is missing, THE System SHALL use a sensible default model
### Requirement 3: Maintain Backward Compatibility
**User Story:** As an existing user, I want my current configuration to continue working, so that I don't need to modify my setup when upgrading.
#### Acceptance Criteria
1. WHEN existing configuration files lack api_url, THE System SHALL use default values without errors
2. WHEN existing configuration files use the current model format, THE System SHALL continue to work unchanged
3. THE System SHALL not break existing functionality when new configuration options are added
4. WHEN loading legacy configuration, THE System SHALL provide informational messages about new available options
### Requirement 4: Configuration Validation and Error Handling
**User Story:** As a user, I want clear validation and error messages for Claude API configuration, so that I can quickly identify and fix configuration issues.
#### Acceptance Criteria
1. WHEN the api_url is malformed, THE System SHALL provide specific error messages indicating the URL format issue
2. WHEN the model name is invalid, THE System SHALL suggest valid alternatives
3. THE System SHALL validate API connectivity during startup when possible
4. WHEN API credentials are invalid for the specified endpoint, THE System SHALL provide clear authentication error messages
5. THE System SHALL log configuration validation results at appropriate levels
### Requirement 5: Environment Variable Support
**User Story:** As a developer, I want to use environment variables for Claude API configuration, so that I can manage different environments and keep sensitive configuration out of files.
#### Acceptance Criteria
1. THE System SHALL support environment variable expansion for api_url configuration
2. THE System SHALL support environment variable expansion for model name configuration
3. WHEN environment variables are undefined, THE System SHALL provide clear error messages
4. THE System SHALL support mixed configuration (some values from files, some from environment)
5. WHEN using environment variables, THE System SHALL validate expanded values the same as direct configuration
### Requirement 6: Documentation and Examples
**User Story:** As a user, I want clear documentation and examples for Claude API configuration, so that I can understand how to use the new configuration options.
#### Acceptance Criteria
1. THE Configuration example file SHALL include api_url configuration with comments
2. THE Configuration example file SHALL include model name options with descriptions
3. THE System SHALL provide helpful error messages that reference documentation
4. WHEN configuration validation fails, THE System SHALL suggest corrective actions
5. THE Documentation SHALL include examples for common use cases (proxy servers, different regions)
@@ -0,0 +1,169 @@
# Implementation Plan: Claude API Configuration
## Overview
This implementation plan enhances the Claude API configuration system to support custom API URLs and comprehensive model selection. The approach maintains backward compatibility while adding flexible configuration options through systematic enhancement of the existing configuration framework.
## Tasks
- [-] 1. Enhance Configuration Schema and Validation
- [x] 1.1 Create enhanced ClaudeAPIConfig model with pydantic
- Add api_url field with URL validation
- Add comprehensive model name validation with suggestions
- Implement field validators for URL format and model naming conventions
- _Requirements: 1.1, 1.4, 2.1, 2.2_
- [ ]* 1.2 Write property test for configuration field support
- **Property 1: Configuration Field Support**
- **Validates: Requirements 1.1, 2.1**
- [ ]* 1.3 Write property test for URL validation
- **Property 2: URL Validation and Error Handling**
- **Validates: Requirements 1.4, 1.5, 4.1**
- [ ]* 1.4 Write property test for model validation
- **Property 3: Model Name Validation with Suggestions**
- **Validates: Requirements 2.2, 2.3, 2.4, 4.2**
- [x] 2. Implement Environment Variable Support
- [x] 2.1 Create ConfigurationLoader with environment variable expansion
- Implement ${VAR} and ${VAR:-default} pattern support
- Add recursive expansion for nested configuration values
- Handle missing environment variables with clear error messages
- _Requirements: 5.1, 5.2, 5.3_
- [x] 2.2 Integrate environment variable expansion into config loading
- Update existing configuration loading to use new expansion system
- Ensure validation works on expanded values
- _Requirements: 5.4, 5.5_
- [ ]* 2.3 Write property test for environment variable expansion
- **Property 6: Environment Variable Expansion**
- **Validates: Requirements 5.1, 5.2, 5.4, 5.5**
- [ ]* 2.4 Write property test for environment variable error handling
- **Property 7: Environment Variable Error Handling**
- **Validates: Requirements 5.3**
- [-] 3. Implement Default Value Handling
- [x] 3.1 Add default value logic to configuration loading
- Set default api_url to "https://api.anthropic.com"
- Set default model to "claude-3-5-sonnet-20241022"
- Ensure defaults are applied when fields are missing
- _Requirements: 1.2, 2.5_
- [ ]* 3.2 Write property test for default value behavior
- **Property 4: Default Value Behavior**
- **Validates: Requirements 1.2, 2.5**
- [x] 4. Enhance Claude API Client
- [x] 4.1 Update ClaudeAPIClient to use configurable URL
- Modify client initialization to accept custom api_url
- Update all API request methods to use configured base URL
- Add SSL context handling for localhost and custom endpoints
- _Requirements: 1.3_
- [x] 4.2 Add API connection validation
- Implement validate_connection method for testing API connectivity
- Add authentication error detection and clear error messages
- Handle different types of API errors (404, 401, etc.)
- _Requirements: 4.3, 4.4_
- [ ]* 4.3 Write property test for custom configuration usage
- **Property 5: Custom Configuration Usage**
- **Validates: Requirements 1.3**
- [ ]* 4.4 Write property test for API connection validation
- **Property 9: API Connection Validation**
- **Validates: Requirements 4.3, 4.4**
- [x] 5. Implement Backward Compatibility
- [x] 5.1 Create ConfigurationMigrator for legacy support
- Add migration logic for configurations missing new fields
- Implement model name migration for old format names
- Add informational logging for migration actions
- _Requirements: 3.1, 3.2, 3.4_
- [x] 5.2 Integrate migration into configuration loading process
- Apply migration before validation
- Ensure existing functionality remains unchanged
- _Requirements: 3.3_
- [ ]* 5.3 Write property test for backward compatibility
- **Property 8: Backward Compatibility**
- **Validates: Requirements 3.1, 3.2, 3.3, 3.4**
- [x] 6. Enhance Error Handling and Messaging
- [x] 6.1 Implement comprehensive error handling framework
- Create ClaudeConfigurationError and related exception classes
- Add user-friendly error message transformation
- Include corrective suggestions in error messages
- _Requirements: 4.1, 4.2, 6.3, 6.4_
- [x] 6.2 Add configuration validation logging
- Implement appropriate logging levels for validation results
- Add debug logging for configuration loading steps
- _Requirements: 4.5_
- [ ]* 6.3 Write property test for error messaging
- **Property 10: Comprehensive Error Messaging**
- **Validates: Requirements 4.5, 6.3, 6.4**
- [x] 7. Update Configuration Files and Documentation
- [x] 7.1 Update config.example.yaml with new options
- Add api_url configuration with comments and examples
- Add model name options with descriptions
- Include examples for common use cases (proxy servers, regions)
- _Requirements: 6.1, 6.2, 6.5_
- [x] 7.2 Update existing Skills to use enhanced configuration
- Modify ClaudeAnalyzeSkill and ClaudeTransformSkill to use new config
- Ensure all Claude API interactions use the enhanced client
- _Requirements: 1.3_
- [x] 8. Integration and Testing
- [x] 8.1 Create comprehensive unit tests
- Test configuration loading with various scenarios
- Test error handling for invalid configurations
- Test migration from legacy configurations
- _Requirements: All_
- [x] 8.2 Create integration tests
- Test Skills with different Claude API configurations
- Test environment variable scenarios
- Test backward compatibility with existing setups
- _Requirements: 3.1, 3.2, 5.4_
- [x] 9. Checkpoint - Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise.
- [x] 10. Final Integration and Validation
- [x] 10.1 Test with real API endpoints
- Validate with default Anthropic API
- Test with proxy server configurations
- Verify different model selections work correctly
- _Requirements: 1.3, 2.4_
- [x] 10.2 Validate backward compatibility
- Test existing configuration files continue to work
- Verify no breaking changes to existing functionality
- Test migration messages are appropriate
- _Requirements: 3.1, 3.2, 3.3, 3.4_
- [x] 10.3 Update documentation and troubleshooting guides
- Add configuration examples for common scenarios
- Update troubleshooting guide with new error messages
- Document environment variable usage patterns
- _Requirements: 6.3, 6.4, 6.5_
- [ ] 11. Final checkpoint - Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise.
## Notes
- Tasks marked with `*` are optional and can be skipped for faster MVP
- Each task references specific requirements for traceability
- Checkpoints ensure incremental validation
- Property tests validate universal correctness properties
- Unit tests validate specific examples and edge cases
- Focus on maintaining backward compatibility throughout implementation
@@ -0,0 +1,253 @@
# Design Document
## Overview
This design addresses critical code quality issues in the Obsidian journal organizer project. The improvements focus on fixing syntax errors, adding comprehensive type hints, modernizing Python patterns, and establishing consistent error handling throughout the codebase. The design maintains backward compatibility while significantly improving maintainability and developer experience.
## Architecture
The existing Command + Skill architecture will be preserved, but enhanced with:
1. **Type Safety Layer**: Comprehensive type hints using Python's typing module
2. **Error Handling Framework**: Consistent error patterns across all components
3. **Configuration Validation**: Robust validation and sanitization of user inputs
4. **Modern Python Patterns**: Updated code to follow current best practices
## Components and Interfaces
### Enhanced Agent Core
The `agent_core.py` module will be updated with:
```python
from typing import Dict, Any, List, Optional, Union, Protocol, TypeVar
from dataclasses import dataclass, field
from pathlib import Path
from abc import ABC, abstractmethod
T = TypeVar('T')
class SkillProtocol(Protocol):
async def execute(self, context: 'CommandContext', **kwargs: Any) -> 'SkillResult':
...
@dataclass
class SkillResult:
success: bool
data: Optional[Dict[str, Any]] = None
error: Optional[str] = None
message: str = ""
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
```
### Type-Safe Configuration Management
```python
from typing import TypedDict, Optional
from pathlib import Path
class ObsidianConfig(TypedDict):
vault_path: str
rest_api: Dict[str, Union[str, bool]]
class ClaudeConfig(TypedDict):
api_key: str
model: str
max_tokens: int
class SystemConfig(TypedDict):
obsidian: ObsidianConfig
claude: ClaudeConfig
output: Dict[str, str]
```
### Enhanced Error Handling
```python
class JournalOrganizerError(Exception):
"""Base exception for journal organizer errors"""
pass
class ConfigurationError(JournalOrganizerError):
"""Raised when configuration is invalid or missing"""
pass
class APIError(JournalOrganizerError):
"""Raised when external API calls fail"""
pass
class ValidationError(JournalOrganizerError):
"""Raised when input validation fails"""
pass
```
## Data Models
### Enhanced SkillResult with Validation
```python
@dataclass
class SkillResult:
success: bool
data: Optional[Dict[str, Any]] = None
error: Optional[str] = None
message: str = ""
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
def __post_init__(self) -> None:
if not self.success and not self.error:
raise ValueError("Failed results must include error message")
if self.success and self.error:
raise ValueError("Successful results should not include error message")
```
### Configuration Validation Models
```python
from pydantic import BaseModel, validator, Field
from typing import Optional
class ObsidianRestAPIConfig(BaseModel):
url: str = Field(..., regex=r'^https?://')
api_key: str = Field(..., min_length=1)
verify_ssl: bool = False
class ObsidianConfig(BaseModel):
vault_path: str = Field(..., min_length=1)
rest_api: ObsidianRestAPIConfig
@validator('vault_path')
def validate_vault_path(cls, v: str) -> str:
path = Path(v)
if not path.exists():
raise ValueError(f"Vault path does not exist: {v}")
return str(path.resolve())
```
## Error Handling
### Centralized Error Management
```python
class ErrorHandler:
"""Centralized error handling and logging"""
def __init__(self, logger: logging.Logger):
self.logger = logger
def handle_api_error(self, error: Exception, context: str) -> SkillResult:
"""Handle API-related errors consistently"""
self.logger.error(f"API error in {context}: {str(error)}")
return SkillResult(
success=False,
error=f"API call failed: {str(error)}",
message=f"Failed to complete {context}"
)
def handle_validation_error(self, error: Exception, field: str) -> SkillResult:
"""Handle validation errors with user-friendly messages"""
self.logger.warning(f"Validation error for {field}: {str(error)}")
return SkillResult(
success=False,
error=f"Invalid {field}: {str(error)}",
message="Please check your input and try again"
)
```
### Async Context Managers for Resources
```python
from contextlib import asynccontextmanager
from typing import AsyncGenerator
@asynccontextmanager
async def obsidian_client(config: ObsidianConfig) -> AsyncGenerator[aiohttp.ClientSession, None]:
"""Async context manager for Obsidian API client"""
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
connector = aiohttp.TCPConnector(ssl=ssl_context)
headers = {
'Authorization': f'Bearer {config.rest_api.api_key}',
'Content-Type': 'application/json'
}
async with aiohttp.ClientSession(connector=connector, headers=headers) as session:
try:
yield session
except Exception as e:
logger.error(f"Error in Obsidian client: {str(e)}")
raise APIError(f"Obsidian API error: {str(e)}") from e
```
## Testing Strategy
### Dual Testing Approach
The testing strategy combines unit tests for specific functionality with property-based tests for comprehensive validation:
**Unit Tests:**
- Test specific error conditions and edge cases
- Validate configuration parsing and validation
- Test individual Skill and Command functionality
- Mock external API calls for isolated testing
**Property-Based Tests:**
- Validate that all SkillResult objects maintain consistency
- Test configuration validation across various input combinations
- Verify error handling patterns work for all error types
- Ensure type hints are correctly applied
**Testing Framework:**
- Use `pytest` for unit testing framework
- Use `hypothesis` for property-based testing
- Configure tests to run minimum 100 iterations per property test
- Tag each property test with: **Feature: code-quality-improvements, Property {number}: {property_text}**
## Correctness Properties
*A property is a characteristic or behavior that should hold true across all valid executions of a system-essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
### Property 1: Syntax Validity Across All Modules
*For any* Python module in the project, parsing the module with Python's AST parser should succeed without raising SyntaxError exceptions
**Validates: Requirements 1.1, 1.2, 1.3**
### Property 2: Comprehensive Type Hint Coverage
*For any* public method or function in Agent_Core, Skills, Commands, or Chat_Interface, the method should have complete type annotations for parameters and return values
**Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5**
### Property 3: Consistent Error Handling Structure
*For any* error condition in Skills or Commands, the returned SkillResult should have a consistent structure with appropriate error messages and no sensitive information exposure
**Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5**
### Property 4: Modern Python Code Patterns
*For any* source file in the project, the code should use f-strings for formatting, pathlib.Path for file operations, proper dataclass definitions, and follow PEP 8 style guidelines
**Validates: Requirements 4.1, 4.2, 4.3, 4.4, 4.5**
### Property 5: Configuration Validation Completeness
*For any* configuration input (valid or invalid), the system should validate all required fields, provide specific error messages for invalid values, and handle missing API keys with clear guidance
**Validates: Requirements 5.1, 5.2, 5.4, 7.4**
### Property 6: Import Organization and Style
*For any* Python module in the project, imports should be organized according to PEP 8 standards, use absolute imports consistently, and handle missing dependencies gracefully
**Validates: Requirements 6.1, 6.2, 6.3, 6.4, 6.5**
### Property 7: Input Validation and Sanitization
*For any* user input (file paths, dates, configuration values), the system should validate format and constraints, sanitize potentially dangerous inputs, and reject malformed data
**Validates: Requirements 7.1, 7.2, 7.3, 7.5**
### Property 8: Environment Variable Expansion
*For any* configuration value containing environment variable references, the system should correctly expand the variables or provide clear error messages when variables are undefined
**Validates: Requirements 5.3**
### Property 9: String Formatting Consistency
*For any* string formatting operation in the codebase, the system should use consistent and appropriate formatting patterns (f-strings, proper escaping, etc.)
**Validates: Requirements 1.4**
### Property-Based Testing Configuration
Each property test will be tagged with comments referencing the design document property and run with sufficient iterations to catch edge cases through randomization.
@@ -0,0 +1,99 @@
# Requirements Document
## Introduction
This specification addresses critical code quality and syntax issues identified in the Obsidian journal organizer project. The system currently has syntax errors, inconsistent error handling, missing type hints, and several areas where Python best practices are not followed. These improvements will enhance maintainability, reliability, and developer experience.
## Glossary
- **System**: The Obsidian journal organizer application
- **Agent_Core**: The core framework defining Agent, Command, and Skill interfaces
- **Chat_Interface**: The conversational interface for v2.0 functionality
- **Skills**: Atomic functional units for specific tasks (READ, WRITE, ANALYZE, TRANSFORM)
- **Commands**: High-level operations that orchestrate multiple Skills
- **Type_Hints**: Python type annotations for better code documentation and IDE support
## Requirements
### Requirement 1: Fix Critical Syntax Errors
**User Story:** As a developer, I want the codebase to be syntactically correct, so that I can run and develop the application without encountering basic syntax errors.
#### Acceptance Criteria
1. WHEN the chat_main.py file is parsed, THE System SHALL not produce syntax errors
2. WHEN any Python file is imported, THE System SHALL not raise SyntaxError exceptions
3. WHEN the application starts, THE System SHALL initialize without syntax-related failures
4. THE System SHALL use proper string literal formatting throughout all modules
### Requirement 2: Add Comprehensive Type Hints
**User Story:** As a developer, I want comprehensive type hints throughout the codebase, so that I can understand function signatures and catch type-related errors early.
#### Acceptance Criteria
1. THE Agent_Core SHALL include type hints for all public methods and properties
2. THE Skills SHALL include type hints for all execute methods and parameters
3. THE Commands SHALL include type hints for all public interfaces
4. THE Chat_Interface SHALL include type hints for all async methods
5. WHEN using modern Python features, THE System SHALL import from typing module appropriately
### Requirement 3: Improve Error Handling Consistency
**User Story:** As a developer, I want consistent error handling patterns, so that I can predict how errors are managed and debug issues effectively.
#### Acceptance Criteria
1. WHEN any Skill encounters an error, THE System SHALL return a SkillResult with consistent error structure
2. WHEN API calls fail, THE System SHALL provide meaningful error messages with context
3. WHEN configuration is missing, THE System SHALL fail gracefully with clear guidance
4. THE System SHALL log errors at appropriate levels with sufficient detail
5. WHEN exceptions occur, THE System SHALL not expose sensitive information in error messages
### Requirement 4: Modernize Python Code Patterns
**User Story:** As a developer, I want the codebase to follow modern Python best practices, so that it's maintainable and follows current standards.
#### Acceptance Criteria
1. THE System SHALL use f-strings instead of string concatenation where appropriate
2. THE System SHALL use pathlib.Path for file system operations
3. THE System SHALL use dataclasses with proper field definitions
4. THE System SHALL follow PEP 8 style guidelines consistently
5. WHEN handling async operations, THE System SHALL use proper async/await patterns
### Requirement 5: Enhance Configuration Validation
**User Story:** As a user, I want clear validation of configuration files, so that I can quickly identify and fix configuration issues.
#### Acceptance Criteria
1. WHEN loading configuration, THE System SHALL validate required fields are present
2. WHEN configuration values are invalid, THE System SHALL provide specific error messages
3. THE System SHALL support environment variable expansion in configuration
4. WHEN API keys are missing, THE System SHALL provide clear setup instructions
5. THE System SHALL validate file paths and folder structures exist
### Requirement 6: Improve Import and Dependency Management
**User Story:** As a developer, I want clean import statements and proper dependency handling, so that the codebase is organized and dependencies are clear.
#### Acceptance Criteria
1. THE System SHALL use absolute imports consistently
2. WHEN optional dependencies are missing, THE System SHALL provide helpful installation messages
3. THE System SHALL organize imports according to PEP 8 standards
4. THE System SHALL handle missing dependencies gracefully without crashing
5. WHEN importing from local modules, THE System SHALL use relative imports appropriately
### Requirement 7: Add Input Validation and Sanitization
**User Story:** As a developer, I want robust input validation, so that the system handles edge cases and invalid inputs gracefully.
#### Acceptance Criteria
1. WHEN processing user input, THE System SHALL validate input format and constraints
2. WHEN handling file paths, THE System SHALL sanitize and validate path safety
3. WHEN processing dates, THE System SHALL validate date format and ranges
4. THE System SHALL reject empty or malformed configuration values
5. WHEN handling API responses, THE System SHALL validate response structure before processing
@@ -0,0 +1,221 @@
# Implementation Plan: Code Quality Improvements
## Overview
This implementation plan addresses critical code quality issues in the Obsidian journal organizer project through systematic fixes of syntax errors, addition of comprehensive type hints, modernization of Python patterns, and establishment of consistent error handling.
## Tasks
- [x] 1. Fix Critical Syntax Errors
- Fix the malformed docstring in chat_main.py that causes SyntaxError
- Validate all Python files can be parsed without syntax errors
- Test application startup to ensure no syntax-related failures
- _Requirements: 1.1, 1.2, 1.3, 1.4_
- [ ]* 1.1 Write property test for syntax validation
- **Property 1: Syntax Validity Across All Modules**
- **Validates: Requirements 1.1, 1.2, 1.3**
- [x] 2. Add Comprehensive Type Hints
- [x] 2.1 Add type hints to agent_core.py
- Add type hints to Agent, Command, Skill, and SkillResult classes
- Import necessary types from typing module
- _Requirements: 2.1, 2.5_
- [x] 2.2 Add type hints to all Skills
- Update ObsidianReadSkill, ObsidianWriteSkill, ObsidianAppendSkill with type hints
- Update ClaudeAnalyzeSkill and ClaudeTransformSkill with type hints
- _Requirements: 2.2_
- [x] 2.3 Add type hints to Commands
- Update OrganizeCommand with comprehensive type hints
- _Requirements: 2.3_
- [x] 2.4 Add type hints to Chat Interface
- Update chat_main.py and conversation modules with type hints
- Focus on async method signatures
- _Requirements: 2.4_
- [ ]* 2.5 Write property test for type hint coverage
- **Property 2: Comprehensive Type Hint Coverage**
- **Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5**
- [x] 3. Implement Consistent Error Handling
- [x] 3.1 Create centralized error handling framework
- Define custom exception classes (JournalOrganizerError, ConfigurationError, APIError, ValidationError)
- Create ErrorHandler class for consistent error management
- _Requirements: 3.1, 3.2, 3.3_
- [x] 3.2 Update Skills with consistent error handling
- Modify all Skills to use new error handling patterns
- Ensure SkillResult objects have consistent error structure
- _Requirements: 3.1, 3.4_
- [x] 3.3 Add security-conscious error messages
- Review error messages to ensure no sensitive information exposure
- Implement sanitized error reporting
- _Requirements: 3.5_
- [ ]* 3.4 Write property test for error handling consistency
- **Property 3: Consistent Error Handling Structure**
- **Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5**
- [x] 4. Modernize Python Code Patterns
- [x] 4.1 Replace string concatenation with f-strings
- Scan codebase for string concatenation patterns
- Replace with f-string formatting where appropriate
- _Requirements: 4.1_
- [x] 4.2 Update file system operations to use pathlib
- Replace os.path usage with pathlib.Path
- Update file handling in configuration and Skills
- _Requirements: 4.2_
- [x] 4.3 Enhance dataclass definitions
- Review and improve existing dataclass field definitions
- Add proper validation and default values
- _Requirements: 4.3_
- [x] 4.4 Apply PEP 8 style guidelines
- Run code formatter (black) on entire codebase
- Fix any remaining style issues
- _Requirements: 4.4_
- [x] 4.5 Improve async/await patterns
- Review async code for proper patterns
- Add async context managers where appropriate
- _Requirements: 4.5_
- [ ]* 4.6 Write property test for modern Python patterns
- **Property 4: Modern Python Code Patterns**
- **Validates: Requirements 4.1, 4.2, 4.3, 4.4, 4.5**
- [x] 5. Enhance Configuration Validation
- [x] 5.1 Implement configuration validation framework
- Add pydantic models for configuration validation
- Create validation functions for all config sections
- _Requirements: 5.1, 5.2_
- [x] 5.2 Add environment variable expansion support
- Implement environment variable substitution in config
- Handle missing environment variables gracefully
- _Requirements: 5.3_
- [x] 5.3 Improve API key validation and guidance
- Add specific validation for API key formats
- Provide clear setup instructions when keys are missing
- _Requirements: 5.4_
- [x] 5.4 Add file path validation
- Validate that configured paths exist and are accessible
- Provide helpful error messages for path issues
- _Requirements: 5.5_
- [ ]* 5.5 Write property test for configuration validation
- **Property 5: Configuration Validation Completeness**
- **Validates: Requirements 5.1, 5.2, 5.4, 7.4**
- [ ]* 5.6 Write property test for environment variable expansion
- **Property 8: Environment Variable Expansion**
- **Validates: Requirements 5.3**
- [x] 6. Improve Import and Dependency Management
- [x] 6.1 Standardize import organization
- Organize imports according to PEP 8 (standard, third-party, local)
- Use absolute imports consistently
- _Requirements: 6.1, 6.3_
- [x] 6.2 Enhance dependency error handling
- Improve error messages for missing optional dependencies
- Add graceful degradation when dependencies are unavailable
- _Requirements: 6.2, 6.4_
- [x] 6.3 Review and fix relative imports
- Ensure relative imports are used appropriately for local modules
- _Requirements: 6.5_
- [ ]* 6.4 Write property test for import organization
- **Property 6: Import Organization and Style**
- **Validates: Requirements 6.1, 6.2, 6.3, 6.4, 6.5**
- [x] 7. Add Input Validation and Sanitization
- [x] 7.1 Implement comprehensive input validation
- Add validation for user input formats and constraints
- Create validation utilities for common input types
- _Requirements: 7.1_
- [x] 7.2 Add path sanitization and security
- Implement path traversal protection
- Validate file paths for security issues
- _Requirements: 7.2_
- [x] 7.3 Enhance date validation
- Add robust date format validation
- Validate date ranges and constraints
- _Requirements: 7.3_
- [x] 7.4 Add API response validation
- Validate API response structure before processing
- Handle malformed responses gracefully
- _Requirements: 7.5_
- [ ]* 7.5 Write property test for input validation
- **Property 7: Input Validation and Sanitization**
- **Validates: Requirements 7.1, 7.2, 7.3, 7.5**
- [ ]* 7.6 Write property test for string formatting consistency
- **Property 9: String Formatting Consistency**
- **Validates: Requirements 1.4**
- [x] 8. Create comprehensive test suite
- [x] 8.1 Set up testing framework
- Install pytest and hypothesis for property-based testing
- Create test directory structure
- Configure test runner and coverage reporting
- _Requirements: All_
- [x] 8.2 Write unit tests for core functionality
- Test agent_core classes (Agent, Command, Skill, SkillResult)
- Test configuration loading and validation
- Test error handling framework
- _Requirements: 1.3, 2.1, 3.1, 5.1_
- [x] 8.3 Write integration tests
- Test Skills with mocked API responses
- Test Commands with full skill chains
- Test conversational agent flow
- _Requirements: 2.2, 2.3, 2.4_
- [x] 9. Checkpoint - Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise.
- [x] 10. Final Integration and Validation
- [x] 10.1 Validate application startup and basic functionality
- Test both v1.0 CLI and v2.0 conversational interfaces
- Ensure configuration loading works correctly
- Test with sample configuration files
- _Requirements: 1.3, 5.1_
- [x] 10.2 Performance and reliability testing
- Test with various input sizes and edge cases
- Verify memory usage and error recovery
- Test dependency graceful degradation
- _Requirements: 6.2, 7.1_
- [x] 10.3 Update documentation for new patterns
- Update developer guide with new error handling patterns
- Document new configuration validation features
- Add troubleshooting guide for common issues
- _Requirements: 5.4_
- [x] 11. Final checkpoint - Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise.
## Notes
- Tasks marked with `*` are optional and can be skipped for faster MVP
- Each task references specific requirements for traceability
- Checkpoints ensure incremental validation
- Property tests validate universal correctness properties
- Unit tests validate specific examples and edge cases
- Most core implementation tasks are complete - focus is now on testing and validation
+30
View File
@@ -0,0 +1,30 @@
# Product Overview
## Obsidian 智能日记整理 Agent
An intelligent journal organization system designed for Obsidian users that automatically analyzes daily journal entries, extracts key information (experiences, tasks, problems, etc.), and intelligently organizes them into designated locations within your knowledge base.
### Core Features
- **Intelligent Analysis**: Integrates Claude 3.5 Sonnet model for deep understanding of journal content and structured information extraction
- **Automated Organization**: Automatically creates new notes from extracted content and places them in preset folders
- **Bidirectional Linking**: Automatically adds links to original journal entries in generated notes for easy traceability
- **Conversational Interface**: v2.0 introduces natural language conversation capabilities for intuitive interaction
- **Command-Line Driven**: Standard CLI interface for easy integration and automation
- **Highly Configurable**: All key parameters manageable through configuration files
### Architecture
The system uses a **Command + Skill** architecture with two main versions:
1. **v1.0**: Direct command execution with CLI interface
2. **v2.0**: Conversational agent with natural language understanding
Both versions share the same execution layer but v2.0 adds a conversational layer for intent understanding and response generation.
### Target Users
- Obsidian users who maintain daily journals
- Knowledge workers seeking automated content organization
- Users who want to extract actionable insights from their daily notes
- Teams looking for structured knowledge management workflows
+141
View File
@@ -0,0 +1,141 @@
# Project Structure
## Directory Organization
```
journal_organizer/
├── __init__.py # Package initialization
├── __main__.py # Entry point for python -m execution
├── main.py # v1.0 CLI entry and Agent initialization
├── chat_main.py # v2.0 conversational interface entry
├── agent_core.py # Core Agent framework (Command + Skill)
├── config.py # Configuration management utilities
├── server.py # Optional server interface
├── config.example.yaml # Configuration template
├── requirements.txt # Python dependencies
├── README.md # v1.0 documentation
├── README_V2.md # v2.0 documentation
├── DEVELOPER_GUIDE.md # Development guidelines
├── OBSIDIAN_INTEGRATION.md # Obsidian integration guide
├── commands/ # Command implementations
│ ├── __init__.py
│ └── organize_command.py # Main journal organization command
├── skills/ # Skill implementations
│ ├── __init__.py
│ ├── obsidian_skill.py # Obsidian API integration skills
│ └── claude_skill.py # Claude AI integration skills
└── conversation/ # v2.0 conversational layer
├── __init__.py
├── conversational_agent.py # Main conversational agent
├── conversation_state.py # State management
├── intent_understanding.py # Natural language understanding
└── response_generator.py # Response generation
```
## Core Components
### Agent Core (`agent_core.py`)
- **Agent**: Main orchestrator class
- **Command**: Abstract base for high-level operations
- **Skill**: Abstract base for atomic operations
- **SkillChain**: Sequential skill execution
- **SkillResult**: Standardized result format
- **CommandContext**: Execution context container
### Entry Points
- **`__main__.py`**: Package entry point (`python -m journal_organizer`)
- **`main.py`**: v1.0 CLI with argparse-based command handling
- **`chat_main.py`**: v2.0 conversational interface with natural language processing
### Command Layer (`commands/`)
Commands orchestrate multiple Skills to accomplish complex tasks:
- **OrganizeCommand**: Main journal analysis and organization workflow
- Commands inherit from `Command` base class
- Commands register and coordinate Skills
- Commands handle parameter validation and error recovery
### Skill Layer (`skills/`)
Skills perform atomic operations:
- **ObsidianReadSkill**: Read notes from Obsidian vault
- **ObsidianWriteSkill**: Create/update notes in Obsidian
- **ObsidianAppendSkill**: Append content to existing notes
- **ObsidianListFilesSkill**: List files in vault directories
- **ClaudeAnalyzeSkill**: Analyze journal content with Claude
- **ClaudeTransformSkill**: Transform content formats with Claude
### Conversational Layer (`conversation/`)
v2.0 natural language interface:
- **ConversationalAgent**: Main conversation coordinator
- **IntentUnderstanding**: Maps natural language to commands/parameters
- **ConversationState**: Manages chat history and context
- **ResponseGenerator**: Generates natural language responses
## Naming Conventions
### Files and Modules
- Snake_case for Python files: `organize_command.py`
- Package names match directory structure
- Skills end with `_skill.py`
- Commands end with `_command.py`
### Classes
- PascalCase for class names: `OrganizeCommand`, `ClaudeAnalyzeSkill`
- Skills inherit from `Skill` base class
- Commands inherit from `Command` base class
- Result objects use `Result` suffix: `SkillResult`
### Methods and Variables
- Snake_case for methods and variables: `execute_command`, `api_key`
- Async methods use `async def` prefix
- Private methods start with underscore: `_parse_response`
### Constants and Enums
- UPPER_CASE for constants: `MAX_RETRIES`
- PascalCase for Enums: `SkillType`, `TaskStatus`
## Configuration Structure
### YAML Configuration (`config.yaml`)
```yaml
obsidian:
vault_path: "/path/to/vault"
rest_api:
url: "https://localhost:27123"
api_key: "your-key"
verify_ssl: false
claude:
api_key: "${ANTHROPIC_API_KEY}"
model: "claude-3-5-sonnet-20241022"
max_tokens: 4096
journal:
daily_notes_folder: "Daily"
date_format: "YYYY-MM-DD"
output:
experiences_folder: "Knowledge/Experiences"
lessons_folder: "Knowledge/Lessons"
# ... other output folders
```
## Extension Patterns
### Adding New Skills
1. Create new file in `skills/` directory
2. Inherit from `Skill` base class
3. Implement `execute()` method returning `SkillResult`
4. Register skill in relevant commands
### Adding New Commands
1. Create new file in `commands/` directory
2. Inherit from `Command` base class
3. Register required skills in `__init__()`
4. Implement `execute()` method with skill orchestration
5. Register command in `main.py` or `chat_main.py`
### Extending Conversational Capabilities
1. Add new intent patterns in `intent_understanding.py`
2. Update command keyword mappings
3. Extend parameter extraction patterns
4. Add response templates in `response_generator.py`
+102
View File
@@ -0,0 +1,102 @@
# Technology Stack
## Core Technologies
- **Python 3.8+**: Primary programming language
- **asyncio**: Asynchronous programming for concurrent operations
- **aiohttp**: HTTP client for API interactions
- **PyYAML**: Configuration file management
- **Anthropic Claude API**: AI-powered content analysis and understanding
- **Obsidian Local REST API**: Integration with Obsidian vault
## Key Dependencies
```
anthropic>=0.25.0 # Claude API client
aiohttp>=3.9.0 # Async HTTP client
pyyaml>=6.0 # YAML configuration parsing
```
## Architecture Patterns
### Command + Skill Pattern
- **Commands**: High-level operations that orchestrate multiple Skills
- **Skills**: Atomic functional units for specific tasks (READ, WRITE, ANALYZE, TRANSFORM, INTEGRATE)
- **Agent Core**: Base framework defining interfaces and interaction logic
### Async/Await Pattern
- All Skills and Commands use async/await for non-blocking operations
- HTTP API calls are asynchronous using aiohttp
- Supports concurrent execution of multiple operations
### Configuration-Driven Design
- YAML-based configuration files for all settings
- Environment variable support for sensitive data
- Separation of code and configuration
## Common Commands
### Development Setup
```bash
# Install dependencies
pip install -r requirements.txt
# Copy and configure settings
cp config.example.yaml config.yaml
# Edit config.yaml with your API keys and paths
```
### Running the Application
#### v1.0 - Direct Commands
```bash
# Organize today's journal
python -m journal_organizer organize
# Organize specific date
python -m journal_organizer organize --date 2025-12-31
# List available commands
python -m journal_organizer list
# Get help for specific command
python -m journal_organizer help organize
```
#### v2.0 - Conversational Interface
```bash
# Start interactive chat
python -m journal_organizer.chat_main
# Single query mode
python -m journal_organizer.chat_main --query "整理今天的日记"
```
### Configuration
```bash
# Use custom config file
python -m journal_organizer --config /path/to/config.yaml organize
# Set log level
python -m journal_organizer --log-level DEBUG organize
```
## API Integration Requirements
### Obsidian Setup
1. Install "Local REST API" plugin in Obsidian
2. Generate API key in plugin settings
3. Configure API URL (default: https://localhost:27123)
4. Disable SSL verification for local development
### Claude API Setup
1. Obtain Anthropic API key
2. Set environment variable: `ANTHROPIC_API_KEY`
3. Configure model in config.yaml (default: claude-3-5-sonnet-20241022)
## Error Handling Patterns
- All Skills return `SkillResult` objects with success/failure status
- Comprehensive logging at DEBUG, INFO, WARNING, ERROR levels
- Graceful degradation when external APIs are unavailable
- SSL context configuration for local HTTPS endpoints
+581
View File
@@ -0,0 +1,581 @@
# Claude API 配置指南
本指南详细介绍如何配置和使用 Claude API 的增强功能,包括自定义 API 端点、模型选择和环境变量管理。
## 概述
Claude API 配置系统支持以下增强功能:
- **自定义 API 端点**:支持代理服务器、区域端点和自定义部署
- **灵活的模型选择**:支持所有当前 Claude 模型和自动迁移
- **环境变量集成**:完整的环境变量支持和默认值处理
- **向后兼容性**:自动迁移旧配置格式
- **全面的错误处理**:详细的错误消息和修复建议
## 基本配置
### 最小配置
```yaml
claude:
api_key: "${ANTHROPIC_API_KEY}"
```
系统将自动使用以下默认值:
- `api_url`: `https://api.anthropic.com`
- `model`: `claude-3-5-sonnet-20241022`
- `max_tokens`: `4096`
- `temperature`: `0.7`
### 完整配置示例
```yaml
claude:
api_key: "${ANTHROPIC_API_KEY}"
api_url: "https://api.anthropic.com"
model: "claude-3-5-sonnet-20241022"
max_tokens: 4096
temperature: 0.7
```
## API 端点配置
### 官方 API 端点
```yaml
claude:
api_key: "${ANTHROPIC_API_KEY}"
api_url: "https://api.anthropic.com" # 默认官方端点
```
### 代理服务器配置
#### 企业代理服务器
```yaml
claude:
api_key: "${ANTHROPIC_API_KEY}"
api_url: "https://claude-proxy.company.com"
```
#### 带端口的代理服务器
```yaml
claude:
api_key: "${ANTHROPIC_API_KEY}"
api_url: "https://proxy.example.com:8080"
```
#### 内部 API 网关
```yaml
claude:
api_key: "${COMPANY_CLAUDE_KEY}"
api_url: "https://api-gateway.internal:8443/claude"
```
### 本地开发环境
#### HTTP 本地端点
```yaml
claude:
api_key: "local-dev-key"
api_url: "http://localhost:3128"
```
#### HTTPS 本地端点
```yaml
claude:
api_key: "local-dev-key"
api_url: "https://localhost:8080"
```
**注意**:本地 HTTPS 端点会自动禁用 SSL 证书验证。
### 区域端点(如果可用)
```yaml
claude:
api_key: "${ANTHROPIC_API_KEY}"
api_url: "https://api-eu.anthropic.com" # 欧洲端点
```
## 模型配置
### 支持的模型
#### 当前推荐模型
```yaml
claude:
model: "claude-3-5-sonnet-20241022" # 最新最强,推荐用于生产
```
#### 所有支持的模型
```yaml
# Claude 3.5 系列(最新)
model: "claude-3-5-sonnet-20241022" # 最强性能
model: "claude-3-5-haiku-20241022" # 快速响应
# Claude 3 系列
model: "claude-3-opus-20240229" # 最强推理能力
model: "claude-3-sonnet-20240229" # 平衡性能和成本
model: "claude-3-haiku-20240307" # 最快最经济
# 最新别名(自动使用最新版本)
model: "claude-3-5-sonnet-latest"
model: "claude-3-5-haiku-latest"
model: "claude-3-opus-latest"
model: "claude-3-sonnet-latest"
model: "claude-3-haiku-latest"
```
### 模型选择建议
#### 生产环境
```yaml
claude:
model: "claude-3-5-sonnet-20241022" # 最佳性能
max_tokens: 4096
temperature: 0.7
```
#### 开发和测试
```yaml
claude:
model: "claude-3-haiku-20240307" # 快速且经济
max_tokens: 2048
temperature: 0.5
```
#### 复杂分析任务
```yaml
claude:
model: "claude-3-opus-20240229" # 最强推理能力
max_tokens: 8192
temperature: 0.3
```
### 自动模型迁移
系统会自动迁移旧的模型名称:
```yaml
# 旧配置(自动迁移)
model: "claude-3-sonnet" # → claude-3-sonnet-20240229
model: "claude-3-opus" # → claude-3-opus-20240229
model: "claude-3-haiku" # → claude-3-haiku-20240307
model: "sonnet" # → claude-3-5-sonnet-20241022
model: "opus" # → claude-3-opus-20240229
model: "haiku" # → claude-3-haiku-20240307
```
## 环境变量配置
### 基本环境变量
#### 必需的环境变量
```bash
# Claude API 密钥(必需)
export ANTHROPIC_API_KEY="sk-ant-your-api-key-here"
```
#### 可选的环境变量
```bash
# 自定义 API 端点
export CLAUDE_API_URL="https://api.anthropic.com"
# 自定义模型
export CLAUDE_MODEL="claude-3-5-sonnet-20241022"
# 自定义参数
export CLAUDE_MAX_TOKENS="4096"
export CLAUDE_TEMPERATURE="0.7"
```
### 环境变量语法
#### 基本语法
```yaml
claude:
api_key: "${ANTHROPIC_API_KEY}" # 必需变量
api_url: "${CLAUDE_API_URL}" # 可选变量
```
#### 带默认值的语法
```yaml
claude:
api_url: "${CLAUDE_API_URL:-https://api.anthropic.com}"
model: "${CLAUDE_MODEL:-claude-3-5-sonnet-20241022}"
max_tokens: "${CLAUDE_MAX_TOKENS:-4096}"
temperature: "${CLAUDE_TEMPERATURE:-0.7}"
```
#### 带错误消息的语法
```yaml
claude:
api_key: "${ANTHROPIC_API_KEY:?请设置 ANTHROPIC_API_KEY 环境变量}"
```
### 环境变量管理
#### 使用 .env 文件
```bash
# 创建 .env 文件
cat > .env << EOF
ANTHROPIC_API_KEY=sk-ant-your-api-key-here
CLAUDE_API_URL=https://api.anthropic.com
CLAUDE_MODEL=claude-3-5-sonnet-20241022
CLAUDE_MAX_TOKENS=4096
CLAUDE_TEMPERATURE=0.7
EOF
# 加载环境变量
set -a; source .env; set +a
```
#### 永久设置环境变量
```bash
# 添加到 shell 配置文件
echo 'export ANTHROPIC_API_KEY="sk-ant-your-api-key-here"' >> ~/.bashrc
echo 'export CLAUDE_API_URL="https://api.anthropic.com"' >> ~/.bashrc
# 重新加载配置
source ~/.bashrc
```
## 高级配置
### 参数调优
#### 最大 Token 数配置
```yaml
claude:
max_tokens: 1024 # 短回复,快速响应
max_tokens: 4096 # 标准回复(推荐)
max_tokens: 8192 # 长回复,详细分析
max_tokens: 16384 # 超长回复,复杂任务
```
#### 温度参数配置
```yaml
claude:
temperature: 0.0 # 最确定的输出
temperature: 0.3 # 较确定,适合分析任务
temperature: 0.7 # 平衡创造性和一致性(推荐)
temperature: 1.0 # 最有创造性的输出
```
### 多环境配置
#### 开发环境
```yaml
claude:
api_key: "${DEV_CLAUDE_KEY}"
api_url: "${DEV_CLAUDE_URL:-https://dev-api.example.com}"
model: "claude-3-haiku-20240307" # 快速且经济
max_tokens: 2048
temperature: 0.0 # 确定性输出用于测试
```
#### 生产环境
```yaml
claude:
api_key: "${PROD_CLAUDE_KEY}"
api_url: "https://api.anthropic.com"
model: "claude-3-5-sonnet-20241022" # 最佳性能
max_tokens: 4096
temperature: 0.7
```
#### 测试环境
```yaml
claude:
api_key: "${TEST_CLAUDE_KEY}"
api_url: "${TEST_CLAUDE_URL:-https://test-api.example.com}"
model: "claude-3-haiku-20240307"
max_tokens: 1024
temperature: 0.0
```
## 配置验证
### 验证配置文件
```bash
# 检查配置文件语法
python -c "
import yaml
with open('config.yaml', 'r') as f:
config = yaml.safe_load(f)
print('配置文件语法正确')
"
```
### 验证环境变量
```bash
# 检查必需的环境变量
echo "ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-(未设置)}"
echo "CLAUDE_API_URL: ${CLAUDE_API_URL:-(使用默认值)}"
echo "CLAUDE_MODEL: ${CLAUDE_MODEL:-(使用默认值)}"
```
### 测试配置加载
```python
# 测试完整配置加载
from config import Config
try:
config = Config('config.yaml')
print('✅ 配置加载成功')
print(f'API URL: {config.claude.api_url}')
print(f'Model: {config.claude.model}')
print(f'Max Tokens: {config.claude.max_tokens}')
print(f'Temperature: {config.claude.temperature}')
except Exception as e:
print(f'❌ 配置错误: {e}')
```
## 故障排除
### 常见错误和解决方案
#### API 密钥格式错误
```
ClaudeAPIKeyError: Claude API key should start with 'sk-ant-'
```
**解决方案**
- 确保 API 密钥以 `sk-ant-` 开头
- 检查密钥长度(应该超过 50 个字符)
- 从 https://console.anthropic.com/ 获取正确的密钥
#### API URL 格式错误
```
ClaudeAPIURLError: Invalid URL format: not-a-url
```
**解决方案**
- 使用完整的 URL,包括协议(http:// 或 https://
- 检查 URL 格式是否正确
- 确保端点可访问
#### 模型名称无效
```
ClaudeModelValidationError: Invalid model name: invalid-model
```
**解决方案**
- 使用支持的模型名称
- 检查模型名称拼写
- 参考本文档的模型列表
#### 环境变量未设置
```
EnvironmentVariableError: Environment variable 'ANTHROPIC_API_KEY' is not set
```
**解决方案**
- 设置必需的环境变量
- 检查环境变量名称拼写
- 使用带默认值的语法
### 调试技巧
#### 启用详细日志
```bash
python -m journal_organizer --log-level DEBUG organize
```
#### 检查配置值
```python
from config import Config
config = Config('config.yaml')
print(f'实际配置值:')
print(f' API Key: {"已设置" if config.claude.api_key else "未设置"}')
print(f' API URL: {config.claude.api_url}')
print(f' Model: {config.claude.model}')
```
## 最佳实践
### 安全最佳实践
1. **使用环境变量存储敏感信息**
```yaml
# ✅ 推荐
api_key: "${ANTHROPIC_API_KEY}"
# ❌ 不推荐
api_key: "sk-ant-actual-key-here"
```
2. **设置适当的文件权限**
```bash
chmod 600 config.yaml
```
3. **使用 .gitignore 保护配置文件**
```bash
echo "config.yaml" >> .gitignore
echo ".env" >> .gitignore
```
### 性能最佳实践
1. **选择合适的模型**
- 生产环境:`claude-3-5-sonnet-20241022`
- 开发测试:`claude-3-haiku-20240307`
- 复杂任务:`claude-3-opus-20240229`
2. **优化参数设置**
- 根据任务调整 `max_tokens`
- 根据需求设置 `temperature`
3. **使用连接池和重试机制**
- 系统自动处理连接管理
- 内置错误重试机制
### 维护最佳实践
1. **定期更新配置**
- 检查新的模型版本
- 更新 API 端点配置
2. **监控配置变化**
- 记录配置迁移
- 验证配置更新
3. **备份重要配置**
```bash
cp config.yaml config.yaml.backup
```
## 示例配置
### 企业环境完整配置
```yaml
# 企业环境 Claude API 配置
claude:
# 使用企业 API 密钥
api_key: "${COMPANY_CLAUDE_KEY:?请设置企业 Claude API 密钥}"
# 使用企业代理服务器
api_url: "${CLAUDE_PROXY_URL:-https://claude-proxy.company.com}"
# 使用最新最强模型
model: "${CLAUDE_MODEL:-claude-3-5-sonnet-20241022}"
# 企业级参数设置
max_tokens: "${CLAUDE_MAX_TOKENS:-8192}"
temperature: "${CLAUDE_TEMPERATURE:-0.5}"
# 企业环境变量设置
# export COMPANY_CLAUDE_KEY="sk-ant-company-key-here"
# export CLAUDE_PROXY_URL="https://claude-proxy.company.com"
# export CLAUDE_MODEL="claude-3-5-sonnet-20241022"
# export CLAUDE_MAX_TOKENS="8192"
# export CLAUDE_TEMPERATURE="0.5"
```
### 开发环境完整配置
```yaml
# 开发环境 Claude API 配置
claude:
# 使用个人 API 密钥
api_key: "${ANTHROPIC_API_KEY:?请设置 ANTHROPIC_API_KEY 环境变量}"
# 可选择使用本地代理或官方 API
api_url: "${CLAUDE_API_URL:-https://api.anthropic.com}"
# 开发环境使用快速模型
model: "${CLAUDE_MODEL:-claude-3-haiku-20240307}"
# 开发环境参数设置
max_tokens: "${CLAUDE_MAX_TOKENS:-2048}"
temperature: "${CLAUDE_TEMPERATURE:-0.0}"
# 开发环境变量设置
# export ANTHROPIC_API_KEY="sk-ant-your-personal-key"
# export CLAUDE_API_URL="https://api.anthropic.com"
# export CLAUDE_MODEL="claude-3-haiku-20240307"
# export CLAUDE_MAX_TOKENS="2048"
# export CLAUDE_TEMPERATURE="0.0"
```
## 更新和迁移
### 从旧版本迁移
系统会自动检测和迁移旧配置:
1. **检查是否需要迁移**
```python
from configuration_migrator import ConfigurationMigrator
migrator = ConfigurationMigrator()
with open('config.yaml', 'r') as f:
config = yaml.safe_load(f)
if migrator.check_migration_needed(config):
print('配置需要迁移')
preview = migrator.get_migration_preview(config)
for change in preview:
print(f' - {change}')
```
2. **执行迁移**
```python
migrated_config = migrator.migrate_configuration(config)
with open('config.yaml', 'w') as f:
yaml.dump(migrated_config, f, default_flow_style=False)
```
### 配置更新检查清单
- [ ] API 密钥是否有效
- [ ] API 端点是否可访问
- [ ] 模型名称是否支持
- [ ] 环境变量是否正确设置
- [ ] 配置文件权限是否安全
- [ ] 备份是否已创建
## 支持和帮助
如果遇到配置问题:
1. 查看 [TROUBLESHOOTING.md](TROUBLESHOOTING.md) 获取详细的故障排除指南
2. 检查日志文件中的错误信息
3. 验证环境变量和配置文件格式
4. 测试网络连接和 API 访问
更多信息请参考项目文档和示例配置文件。
+963
View File
@@ -0,0 +1,963 @@
# 开发者指南
本指南说明如何扩展和自定义日记整理 Agent,包括新的错误处理模式、配置验证功能和故障排除指南。
## 架构概览
Agent 系统基于以下核心概念:
- **Agent**:主控制器,负责管理 Commands 和 Skills
- **Command**:用户可执行的命令,编排多个 Skills
- **Skill**:原子化的功能单元,执行具体任务
- **SkillChain**:多个 Skills 的有序执行链
- **ErrorHandler**:集中式错误处理和日志记录
- **ConfigurationValidator**:配置验证和环境变量扩展
## 核心类
### Agent
```python
from journal_organizer.agent_core import Agent
# 创建 Agent
agent = Agent("MyAgent", config={})
# 注册命令
agent.register_command(my_command)
# 执行命令
result = await agent.execute_command("command_name", args={})
```
### Skill
所有 Skill 都继承自 `Skill` 基类:
```python
from journal_organizer.agent_core import Skill, SkillType, SkillResult, CommandContext
class MySkill(Skill):
def __init__(self):
super().__init__(
name="my_skill",
skill_type=SkillType.ANALYZE,
description="我的自定义 Skill"
)
async def execute(self, context: CommandContext, **kwargs) -> SkillResult:
# 实现您的逻辑
try:
result = do_something(**kwargs)
return SkillResult(
success=True,
data=result,
message="执行成功"
)
except Exception as e:
return SkillResult(
success=False,
error=str(e),
message="执行失败"
)
```
### Command
所有 Command 都继承自 `Command` 基类:
```python
from journal_organizer.agent_core import Command, SkillResult, CommandContext
class MyCommand(Command):
def __init__(self):
super().__init__(
name="my_command",
description="我的自定义命令",
aliases=["mc"]
)
# 注册 Skills
self.register_skill(MySkill())
async def execute(self, context: CommandContext) -> SkillResult:
# 获取参数
param1 = context.args.get('param1')
# 执行 Skill
skill = self.skills['my_skill']
result = await skill.execute(context, param1=param1)
return result
```
## 添加新的 Skill
### 步骤 1: 创建 Skill 类
`skills/` 目录下创建一个新文件,例如 `my_skill.py`
```python
from ..agent_core import Skill, SkillType, SkillResult, CommandContext
class MyCustomSkill(Skill):
def __init__(self):
super().__init__(
name="my_custom_skill",
skill_type=SkillType.TRANSFORM,
description="执行自定义转换"
)
async def execute(self, context: CommandContext, **kwargs) -> SkillResult:
try:
input_data = kwargs.get('input_data')
# 您的自定义逻辑
output_data = self._process(input_data)
return SkillResult(
success=True,
data=output_data,
message="处理完成"
)
except Exception as e:
return SkillResult(
success=False,
error=str(e),
message="处理失败"
)
def _process(self, data):
# 实现处理逻辑
return data
```
### 步骤 2: 在 Command 中使用 Skill
```python
from ..skills.my_skill import MyCustomSkill
class MyCommand(Command):
def __init__(self):
super().__init__(name="my_command")
self.register_skill(MyCustomSkill())
async def execute(self, context: CommandContext) -> SkillResult:
skill = self.skills['my_custom_skill']
return await skill.execute(context, input_data="test")
```
## 添加新的 Command
### 步骤 1: 创建 Command 类
`commands/` 目录下创建一个新文件,例如 `my_command.py`
```python
from ..agent_core import Command, SkillResult, CommandContext
from ..skills.my_skill import MyCustomSkill
class MyCommand(Command):
def __init__(self):
super().__init__(
name="my_command",
description="我的自定义命令",
aliases=["mc", "my-cmd"]
)
self.register_skill(MyCustomSkill())
async def execute(self, context: CommandContext) -> SkillResult:
# 获取参数
param1 = context.args.get('param1')
param2 = context.args.get('param2', 'default')
# 执行 Skill
skill = self.skills['my_custom_skill']
result = await skill.execute(context, input_data=param1)
return result
```
### 步骤 2: 在 Agent 中注册 Command
编辑 `main.py``_register_commands` 方法:
```python
def _register_commands(self) -> None:
"""注册所有命令"""
self.agent.register_command(OrganizeCommand())
self.agent.register_command(MyCommand()) # 添加新命令
```
### 步骤 3: 测试新命令
```bash
python -m journal_organizer my_command --param1 "value1"
```
## 使用 SkillChain
SkillChain 允许您按顺序执行多个 Skills:
```python
from journal_organizer.agent_core import SkillChain
class MyCommand(Command):
def __init__(self):
super().__init__(name="my_command")
# 创建 Skill 链
chain = SkillChain("my_chain", "执行一系列操作")
chain.add_skill(Skill1(), {"param1": "value1"})
chain.add_skill(Skill2(), {"param2": "value2"})
self.register_skill_chain(chain)
async def execute(self, context: CommandContext) -> SkillResult:
chain = self.skill_chains['my_chain']
return await chain.execute(context)
```
## 异步编程
所有 Skills 和 Commands 都使用异步编程(async/await)。这允许并发执行多个操作。
### 基本示例
```python
async def execute(self, context: CommandContext, **kwargs) -> SkillResult:
# 异步调用外部 API
result = await self.call_external_api()
return SkillResult(success=True, data=result)
async def call_external_api(self):
# 使用 aiohttp 进行异步 HTTP 请求
async with aiohttp.ClientSession() as session:
async with session.get('https://api.example.com/data') as resp:
return await resp.json()
```
## 错误处理
### 新的错误处理框架
系统现在使用集中式错误处理框架,提供一致的错误管理和日志记录:
```python
from journal_organizer.error_handling import (
ErrorHandler,
JournalOrganizerError,
ConfigurationError,
APIError,
ValidationError
)
# 创建错误处理器
import logging
logger = logging.getLogger("MySkill")
error_handler = ErrorHandler(logger)
# 处理 API 错误
try:
result = await api_call()
except Exception as e:
error_result = error_handler.handle_api_error(e, "claude", "analyze_text")
return SkillResult(
success=False,
error=error_result["error"],
message=error_result["message"]
)
```
### 自定义异常类型
使用专门的异常类型来处理不同类型的错误:
```python
from journal_organizer.error_handling import (
JournalOrganizerError,
ConfigurationError,
APIError,
ValidationError
)
# 配置错误
if not api_key:
raise ConfigurationError("API key is required", context={"service": "claude"})
# API 错误
if response.status_code != 200:
raise APIError(
"API request failed",
api_name="obsidian",
status_code=response.status_code
)
# 验证错误
if not validate_input(data):
raise ValidationError("Invalid input format", field="date")
```
### Skill 中的错误处理模式
在 Skill 中实现标准化的错误处理:
```python
from journal_organizer.agent_core import Skill, SkillResult, CommandContext
from journal_organizer.error_handling import ErrorHandler, APIError, ValidationError
class MySkill(Skill):
def __init__(self):
super().__init__(name="my_skill")
self.error_handler = ErrorHandler(self.logger)
async def execute(self, context: CommandContext, **kwargs) -> SkillResult:
try:
# 输入验证
self._validate_inputs(**kwargs)
# 执行主要逻辑
result = await self._perform_operation(**kwargs)
return SkillResult(success=True, data=result, message="操作成功")
except ValidationError as e:
error_result = self.error_handler.handle_validation_error(e, "input_data")
return SkillResult(
success=False,
error=error_result["error"],
message=error_result["message"]
)
except APIError as e:
error_result = self.error_handler.handle_api_error(e, "external_service", "operation")
return SkillResult(
success=False,
error=error_result["error"],
message=error_result["message"]
)
except Exception as e:
# 处理未预期的错误
self.logger.error(f"Unexpected error in {self.name}: {str(e)}", exc_info=True)
return SkillResult(
success=False,
error="Internal error occurred",
message="操作失败,请检查日志"
)
def _validate_inputs(self, **kwargs):
"""验证输入参数"""
required_params = ['param1', 'param2']
for param in required_params:
if param not in kwargs:
raise ValidationError(f"Missing required parameter: {param}", field=param)
async def _perform_operation(self, **kwargs):
"""执行主要操作"""
# 实现您的逻辑
pass
```
## 日志记录
使用内置的 logger 记录信息:
```python
class MySkill(Skill):
async def execute(self, context: CommandContext, **kwargs) -> SkillResult:
self.logger.debug("开始执行")
self.logger.info("处理数据")
self.logger.warning("可能的问题")
self.logger.error("发生错误")
return SkillResult(success=True)
```
## 配置管理
### 新的配置验证系统
系统现在包含强大的配置验证功能,支持类型检查、环境变量扩展和路径验证:
```python
from journal_organizer.config_validation import (
SystemConfig,
ObsidianConfig,
ClaudeConfig,
validate_system_config
)
# 验证配置
try:
config = validate_system_config(raw_config)
print("配置验证成功")
except ValidationError as e:
print(f"配置验证失败: {e}")
```
### 环境变量扩展
配置文件支持环境变量扩展:
```yaml
# config.yaml
claude:
api_key: "${ANTHROPIC_API_KEY}" # 从环境变量读取
model: "${CLAUDE_MODEL:-claude-3-5-sonnet-20241022}" # 带默认值
obsidian:
vault_path: "${OBSIDIAN_VAULT_PATH}"
rest_api:
api_key: "${OBSIDIAN_API_KEY}"
```
### 配置验证示例
```python
from journal_organizer.config_validation import validate_obsidian_config, validate_claude_config
# 验证 Obsidian 配置
obsidian_config = {
"vault_path": "/path/to/vault",
"rest_api": {
"url": "https://localhost:27123",
"api_key": "your-key",
"verify_ssl": False
}
}
try:
validated_config = validate_obsidian_config(obsidian_config)
print("Obsidian 配置有效")
except ValidationError as e:
print(f"Obsidian 配置错误: {e}")
# 验证 Claude 配置
claude_config = {
"api_key": "sk-ant-...",
"model": "claude-3-5-sonnet-20241022",
"max_tokens": 4096
}
try:
validated_config = validate_claude_config(claude_config)
print("Claude 配置有效")
except ValidationError as e:
print(f"Claude 配置错误: {e}")
```
### 在 Skill 中访问配置
```python
async def execute(self, context: CommandContext, **kwargs) -> SkillResult:
config = context.config or {}
# 安全地访问配置
claude_config = config.get('claude', {})
api_key = claude_config.get('api_key')
if not api_key:
raise ConfigurationError("Claude API key not configured")
# 使用配置
return SkillResult(success=True)
```
## 测试
### 单元测试示例
```python
import pytest
from journal_organizer.agent_core import CommandContext
@pytest.mark.asyncio
async def test_my_skill():
skill = MySkill()
context = CommandContext(command_name="test")
result = await skill.execute(context, input_data="test")
assert result.success == True
assert result.data is not None
```
### 运行测试
```bash
pytest tests/
```
## 性能优化
### 并发执行
```python
import asyncio
async def execute(self, context: CommandContext, **kwargs) -> SkillResult:
# 并发执行多个操作
results = await asyncio.gather(
self.operation1(),
self.operation2(),
self.operation3()
)
return SkillResult(success=True, data=results)
```
### 缓存
```python
from functools import lru_cache
class MySkill(Skill):
@lru_cache(maxsize=128)
def expensive_operation(self, key):
# 缓存昂贵的操作
return process(key)
```
## 最佳实践
### 现代 Python 模式
系统现在遵循现代 Python 最佳实践:
#### 1. 使用 f-strings 进行字符串格式化
```python
# ✅ 推荐:使用 f-strings
name = "用户"
message = f"欢迎 {name},当前时间是 {datetime.now()}"
# ❌ 避免:字符串连接
message = "欢迎 " + name + ",当前时间是 " + str(datetime.now())
```
#### 2. 使用 pathlib 进行文件路径操作
```python
from pathlib import Path
# ✅ 推荐:使用 pathlib
vault_path = Path(config['obsidian']['vault_path'])
daily_folder = vault_path / "Daily"
note_file = daily_folder / f"{date}.md"
# 检查文件是否存在
if note_file.exists():
content = note_file.read_text(encoding='utf-8')
# ❌ 避免:使用 os.path
import os
note_file = os.path.join(vault_path, "Daily", f"{date}.md")
```
#### 3. 使用 dataclasses 定义数据结构
```python
from dataclasses import dataclass, field
from typing import Optional, List
from datetime import datetime
@dataclass
class SkillResult:
success: bool
data: Optional[Dict[str, Any]] = None
error: Optional[str] = None
message: str = ""
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
```
#### 4. 使用类型提示
```python
from typing import Dict, Any, Optional, List, Union
async def execute_skill(
skill_name: str,
context: CommandContext,
**kwargs: Any
) -> SkillResult:
"""
执行指定的 Skill
Args:
skill_name: Skill 名称
context: 命令执行上下文
**kwargs: Skill 参数
Returns:
SkillResult: 执行结果
"""
pass
```
#### 5. 使用异步上下文管理器
```python
from contextlib import asynccontextmanager
import aiohttp
@asynccontextmanager
async def http_client(config: Dict[str, Any]):
"""HTTP 客户端上下文管理器"""
connector = aiohttp.TCPConnector(
ssl=False if not config.get('verify_ssl', True) else None
)
async with aiohttp.ClientSession(connector=connector) as session:
try:
yield session
finally:
await session.close()
# 使用示例
async def call_api():
async with http_client(api_config) as client:
async with client.get(url) as response:
return await response.json()
```
### 通用最佳实践
1. **单一职责**:每个 Skill 只负责一个任务
2. **错误处理**:使用新的错误处理框架
3. **日志记录**:使用 logger 记录重要信息
4. **配置驱动**:使用配置验证系统
5. **异步编程**:充分利用异步特性提高性能
6. **类型安全**:使用类型提示和验证
7. **文档**:为您的 Skills 和 Commands 编写清晰的文档
8. **测试**:编写单元测试和属性测试确保代码质量
9. **版本控制**:使用 git 管理代码版本
10. **代码格式化**:使用 black 和 isort 保持代码风格一致
## 示例:完整的自定义 Skill
```python
"""
自定义 Skill 示例:文本统计
"""
from ..agent_core import Skill, SkillType, SkillResult, CommandContext
class TextStatisticsSkill(Skill):
"""计算文本统计信息的 Skill"""
def __init__(self):
super().__init__(
name="text_statistics",
skill_type=SkillType.ANALYZE,
description="计算文本的字数、词数、句数等统计信息"
)
async def execute(self, context: CommandContext, **kwargs) -> SkillResult:
"""
执行文本统计
Args:
context: 命令执行上下文
**kwargs: 包含 text 参数
Returns:
SkillResult: 包含统计结果的结果
"""
try:
text = kwargs.get('text', '')
if not text:
return SkillResult(
success=False,
error="缺少文本参数",
message="未提供要统计的文本"
)
# 计算统计信息
stats = {
'char_count': len(text),
'word_count': len(text.split()),
'sentence_count': len(text.split('')),
'line_count': len(text.split('\n')),
'avg_word_length': len(text) / len(text.split()) if text.split() else 0
}
self.logger.info(f"文本统计完成: {stats}")
return SkillResult(
success=True,
data=stats,
message="文本统计完成"
)
except Exception as e:
self.logger.error(f"文本统计失败: {str(e)}")
return SkillResult(
success=False,
error=str(e),
message="文本统计异常"
)
```
## 资源
- [Python 异步编程](https://docs.python.org/3/library/asyncio.html)
- [Anthropic API 文档](https://docs.anthropic.com/)
- [Obsidian API 文档](https://docs.obsidian.md/Obsidian+API)
---
# 故障排除指南
本节提供常见问题的解决方案和调试技巧。
## 常见问题
### 1. 导入错误 (ImportError)
**问题**: `ImportError: attempted relative import with no known parent package`
**解决方案**:
```bash
# 确保以模块方式运行
python -m journal_organizer --help
# 而不是直接运行
python main.py # ❌ 错误方式
```
**原因**: 项目使用相对导入,需要作为包运行。
### 2. 配置文件问题
**问题**: `ConfigurationError: Missing required configuration`
**解决方案**:
1. 检查配置文件是否存在:
```bash
ls -la config.yaml
```
2. 验证配置格式:
```bash
python -c "
import yaml
with open('config.yaml', 'r') as f:
config = yaml.safe_load(f)
print('配置文件格式正确')
"
```
3. 检查环境变量:
```bash
echo $ANTHROPIC_API_KEY
echo $OBSIDIAN_API_KEY
```
### 3. API 连接问题
**问题**: `APIError: Failed to connect to Claude/Obsidian API`
**解决方案**:
**Claude API**:
```bash
# 测试 API 密钥
curl -H "Authorization: Bearer $ANTHROPIC_API_KEY" \
-H "Content-Type: application/json" \
https://api.anthropic.com/v1/messages
```
**Obsidian API**:
```bash
# 检查 Obsidian Local REST API 插件状态
curl -k -H "Authorization: Bearer $OBSIDIAN_API_KEY" \
https://localhost:27123/
```
### 4. 依赖项问题
**问题**: `ModuleNotFoundError: No module named 'xxx'`
**解决方案**:
```bash
# 检查依赖项状态
python -m journal_organizer check-deps
# 安装缺失的依赖项
pip install -r requirements.txt
# 安装可选依赖项
pip install pyyaml aiohttp anthropic
```
### 5. 权限问题
**问题**: `PermissionError: [Errno 13] Permission denied`
**解决方案**:
```bash
# 检查文件权限
ls -la config.yaml
ls -la /path/to/obsidian/vault
# 修复权限
chmod 644 config.yaml
chmod -R 755 /path/to/obsidian/vault
```
### 6. SSL 证书问题
**问题**: `SSL: CERTIFICATE_VERIFY_FAILED`
**解决方案**:
在配置文件中禁用 SSL 验证(仅用于本地开发):
```yaml
obsidian:
rest_api:
verify_ssl: false
```
## 调试技巧
### 1. 启用详细日志
```bash
# 设置调试级别日志
python -m journal_organizer --log-level DEBUG organize
```
### 2. 使用 Python 调试器
```python
# 在 Skill 中添加断点
import pdb; pdb.set_trace()
# 或使用 ipdb(更友好的界面)
import ipdb; ipdb.set_trace()
```
### 3. 检查配置加载
```python
# 测试配置加载
from journal_organizer.main import JournalOrganizerAgent
agent = JournalOrganizerAgent("config.yaml")
print(f"配置: {agent.config}")
```
### 4. 测试单个 Skill
```python
# 单独测试 Skill
import asyncio
from journal_organizer.skills.claude_skill import ClaudeAnalyzeSkill
from journal_organizer.agent_core import CommandContext
async def test_skill():
skill = ClaudeAnalyzeSkill()
context = CommandContext(command_name="test")
result = await skill.execute(context, text="测试文本")
print(f"结果: {result}")
asyncio.run(test_skill())
```
## 性能问题
### 1. 内存使用过高
**诊断**:
```python
import psutil
import os
process = psutil.Process(os.getpid())
print(f"内存使用: {process.memory_info().rss / 1024 / 1024:.2f} MB")
```
**解决方案**:
- 检查是否有内存泄漏
- 使用 `gc.collect()` 强制垃圾回收
- 限制并发操作数量
### 2. API 调用缓慢
**诊断**:
```python
import time
start_time = time.time()
result = await api_call()
duration = time.time() - start_time
print(f"API 调用耗时: {duration:.2f}")
```
**解决方案**:
- 检查网络连接
- 增加超时设置
- 使用连接池
- 实现重试机制
## 错误代码参考
| 错误代码 | 描述 | 解决方案 |
|---------|------|----------|
| CONFIG_001 | 配置文件不存在 | 创建 config.yaml 文件 |
| CONFIG_002 | 配置格式错误 | 检查 YAML/JSON 语法 |
| CONFIG_003 | 缺少必需配置项 | 添加缺失的配置项 |
| API_001 | API 密钥无效 | 检查并更新 API 密钥 |
| API_002 | API 连接超时 | 检查网络连接和服务状态 |
| API_003 | API 限流 | 减少请求频率或升级 API 计划 |
| SKILL_001 | Skill 执行失败 | 检查 Skill 输入参数和依赖项 |
| SKILL_002 | Skill 超时 | 增加超时设置或优化 Skill 逻辑 |
## 获取帮助
如果问题仍然存在:
1. **检查日志文件**: `logs/journal_organizer.log`
2. **运行诊断命令**: `python -m journal_organizer check-deps`
3. **查看详细错误**: 使用 `--log-level DEBUG`
4. **测试基本功能**: 运行简单的命令如 `list``help`
## 开发环境设置
### 推荐的开发工具
```bash
# 安装开发依赖
pip install pytest pytest-asyncio black isort mypy
# 代码格式化
black .
isort .
# 类型检查
mypy journal_organizer/
# 运行测试
pytest tests/
```
### 调试配置 (VS Code)
创建 `.vscode/launch.json`:
```json
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Journal Organizer",
"type": "python",
"request": "launch",
"module": "journal_organizer",
"args": ["--log-level", "DEBUG", "organize"],
"console": "integratedTerminal",
"cwd": "${workspaceFolder}"
}
]
}
```
+266
View File
@@ -0,0 +1,266 @@
# Obsidian 集成指南
本指南说明如何在 Obsidian 中集成和使用日记整理 Agent。
## 前置条件
1. 已安装 Obsidian
2. 已安装 `Local REST API` 插件并配置 API 密钥
3. 已安装并配置日记整理 Agent
4. 已安装 Obsidian 的 `Templater``QuickAdd` 插件(用于触发脚本)
## 方案 1: 使用 Templater 插件
### 步骤 1: 安装 Templater 插件
1. 在 Obsidian 中,进入 `设置` > `第三方插件`
2. 关闭 `安全模式`
3. 点击 `浏览社区插件`,搜索 `Templater` 并安装
4. 启用 Templater 插件
### 步骤 2: 创建模板
1. 在 Obsidian 中创建一个新的笔记,例如 `Templates/OrganizeJournal.md`
2. 在笔记中添加以下内容:
```javascript
<%*
// 获取当前笔记的标题(假设为日期格式)
const noteTitle = tp.file.title;
const agentPath = "/path/to/journal_organizer";
// 构建命令
const command = `cd ${agentPath} && /path/to/journal_venv/bin/python -m journal_organizer organize --date ${noteTitle}`;
// 执行命令
try {
const result = await tp.system.exec(command);
const output = JSON.parse(result);
if (output.success) {
tp.obsidian.Notice.show(`✓ 成功整理日记 ${noteTitle}\n已生成 ${output.data.successful_writes} 个文件`);
} else {
tp.obsidian.Notice.show(`✗ 整理失败: ${output.message}`);
}
} catch (error) {
tp.obsidian.Notice.show(`✗ 执行错误: ${error.message}`);
}
%>
```
3. 根据您的环境修改 `agentPath` 和虚拟环境路径
### 步骤 3: 创建快捷键
1. 进入 `设置` > `快捷键`
2. 搜索 `Templater: Open Insert Template modal`
3. 为其分配一个快捷键,例如 `Ctrl+Alt+O`
### 步骤 4: 使用
1. 打开或创建一个日记笔记(文件名应为日期格式,如 `2025-12-31.md`
2. 按下快捷键打开模板选择器
3. 选择 `OrganizeJournal` 模板
4. Agent 将自动执行并整理日记
## 方案 2: 使用 QuickAdd 插件
### 步骤 1: 安装 QuickAdd 插件
1. 在 Obsidian 中进入 `设置` > `第三方插件`
2. 点击 `浏览社区插件`,搜索 `QuickAdd` 并安装
3. 启用 QuickAdd 插件
### 步骤 2: 创建宏
1. 打开 QuickAdd 插件设置
2. 点击 `Manage Macros`
3. 创建一个新的宏,例如 `OrganizeJournal`
4. 在宏中添加以下步骤:
- 类型:`User Script`
- 脚本内容:
```javascript
module.exports = async (params) => {
const { app } = params;
const noteTitle = app.workspace.getActiveFile()?.basename;
if (!noteTitle) {
new Notice("请先打开一个笔记");
return;
}
const agentPath = "/path/to/journal_organizer";
const command = `cd ${agentPath} && /path/to/journal_venv/bin/python -m journal_organizer organize --date ${noteTitle}`;
try {
const result = await require('child_process').execSync(command, { encoding: 'utf-8' });
const output = JSON.parse(result);
if (output.success) {
new Notice(`✓ 成功整理日记 ${noteTitle}`);
} else {
new Notice(`✗ 整理失败: ${output.message}`);
}
} catch (error) {
new Notice(`✗ 执行错误: ${error.message}`);
}
};
```
### 步骤 3: 创建快捷键
1. 在 QuickAdd 设置中,为宏分配一个快捷键
### 步骤 4: 使用
1. 打开一个日记笔记
2. 按下快捷键执行宏
3. Agent 将自动整理日记
## 方案 3: 使用 Shell 命令(高级)
如果您熟悉 Shell 脚本,可以创建一个更复杂的集成方案:
### 创建 Shell 脚本
`/home/ubuntu/organize_journal.sh` 中创建以下脚本:
```bash
#!/bin/bash
# 日记整理 Agent 调用脚本
AGENT_PATH="/path/to/journal_organizer"
VENV_PATH="/path/to/journal_venv"
DATE="${1:-$(date +%Y-%m-%d)}"
# 激活虚拟环境并运行 Agent
source "${VENV_PATH}/bin/activate"
cd "${AGENT_PATH}"
# 执行 Agent
python -m journal_organizer organize --date "${DATE}"
# 返回状态
exit $?
```
### 在 Obsidian 中调用
在 Templater 或 QuickAdd 中使用以下命令:
```javascript
const result = await tp.system.exec("/home/ubuntu/organize_journal.sh 2025-12-31");
```
## 故障排除
### 问题 1: "找不到 python 模块"
**解决方案**:确保虚拟环境路径正确,并且依赖已安装。
```bash
/path/to/journal_venv/bin/pip list | grep anthropic
```
### 问题 2: "API 密钥错误"
**解决方案**:检查配置文件中的 API 密钥是否正确。
```bash
cat ~/.journal_organizer/config.yaml | grep api_key
```
### 问题 3: "找不到日记文件"
**解决方案**:确保日记文件名格式正确(YYYY-MM-DD.md),并且在配置的文件夹中。
### 问题 4: "权限被拒绝"
**解决方案**:确保脚本有执行权限。
```bash
chmod +x /home/ubuntu/organize_journal.sh
```
## 高级配置
### 定时自动整理
您可以使用系统的 cron 任务来定时运行 Agent
```bash
# 编辑 crontab
crontab -e
# 添加以下行,每天晚上 10 点运行
0 22 * * * /home/ubuntu/organize_journal.sh $(date +\%Y-\%m-\%d)
```
### 监听文件变化
使用 `watchdog` 库来监听日记文件的变化,并自动触发整理:
```python
import time
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
import subprocess
class JournalHandler(FileSystemEventHandler):
def on_modified(self, event):
if event.src_path.endswith('.md'):
# 延迟 5 秒后执行,避免频繁触发
time.sleep(5)
subprocess.run(['/home/ubuntu/organize_journal.sh'])
observer = Observer()
observer.schedule(JournalHandler(), path='/path/to/Daily', recursive=False)
observer.start()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
observer.stop()
observer.join()
```
## 最佳实践
1. **定期备份**:在整理前备份您的 Obsidian vault,以防万一。
2. **测试配置**:在正式使用前,用一个测试日记进行测试。
3. **监控日志**:定期检查 Agent 的日志文件,了解执行情况。
4. **逐步扩展**:先用基础功能,然后根据需要添加更多的 Skills 和 Commands。
5. **保护密钥**:不要在公开的地方暴露 API 密钥,使用环境变量或安全的配置管理工具。
## 常见场景
### 场景 1: 每天晚上自动整理
使用 cron 任务在每天晚上 10 点自动运行 Agent:
```bash
0 22 * * * /home/ubuntu/organize_journal.sh
```
### 场景 2: 手动触发整理
在 Obsidian 中创建一个快捷键,按需整理日记。
### 场景 3: 批量整理历史日记
使用以下命令整理指定日期范围内的所有日记:
```bash
for date in {1..31}; do
/home/ubuntu/organize_journal.sh "2025-12-$(printf "%02d" $date)"
done
```
## 下一步
- 查看 [README.md](README.md) 了解更多关于 Agent 的信息
- 查看 [config.example.yaml](config.example.yaml) 了解配置选项
- 根据需要扩展 Agent 功能,添加新的 Skills 和 Commands
+206
View File
@@ -0,0 +1,206 @@
# Obsidian 智能日记整理 Agent
**版本**: 0.1.0
这是一个基于 Agent 架构的智能日记整理系统,专为 Obsidian 用户设计。它能够自动分析您的每日日记,提取关键信息(如经验、待办事项、问题等),并将其智能地整理到您的知识库中的指定位置。
该系统采用 Command + Skill 架构,具有高度的可扩展性和灵活性,并支持通过命令行触发,方便与 Obsidian 或其他工具集成。
## 系统特性
- **智能分析**:集成 Claude 3.5 Sonnet 模型,深度理解日记内容并提取结构化信息。
- **自动化整理**:自动将提取的内容创建为新的笔记,并放置在预设的文件夹中。
- **双向链接**:在生成的笔记中自动添加指向原始日记的链接,方便溯源。
- **Agent 架构**:采用 Command + Skill 模式,逻辑清晰,易于扩展新功能。
- **命令行驱动**:通过标准命令行接口(CLI)操作,易于集成和自动化。
- **高度可配置**:所有关键参数(如 API 密钥、文件夹路径、分析规则)均可通过配置文件管理。
- **通用性设计**:核心框架与具体实现分离,可兼容 Claude Code、OpenCode 等多种 Agent 环境。
## 系统架构
系统由以下核心组件构成:
1. **Agent Core**:定义了 `Agent``Command``Skill` 的基础接口和交互逻辑。
2. **Skills**:原子化的功能单元,负责执行具体任务,如 `ObsidianReadSkill`(读取笔记)和 `ClaudeAnalyzeSkill`(分析内容)。
3. **Commands**:用户可执行的命令,负责编排一个或多个 Skill 来完成复杂任务,如 `OrganizeCommand`
4. **CLI 入口**:提供一个命令行界面,用于接收用户指令并驱动 Agent 执行相应命令。
5. **配置文件**:使用 YAML 文件管理所有配置,实现代码与配置分离。
```mermaid
graph TD
subgraph User Interface
CLI[命令行接口]
Obsidian[Obsidian (via shell command)]
end
subgraph Agent System
AgentCore[Agent Core]
CLI --> AgentCore
Obsidian --> AgentCore
AgentCore -- dispatches --> Commands
subgraph Commands
OrganizeCmd[Organize Command]
end
Commands -- orchestrates --> Skills
subgraph Skills
ReadNote[Obsidian Read Skill]
AnalyzeNote[Claude Analyze Skill]
WriteNote[Obsidian Write Skill]
end
end
subgraph External Services
ObsidianAPI[Obsidian Local REST API]
ClaudeAPI[Claude API]
end
ReadNote -- HTTP --> ObsidianAPI
WriteNote -- HTTP --> ObsidianAPI
AnalyzeNote -- HTTP --> ClaudeAPI
```
## 安装指南
### 1. 先决条件
- **Python 3.8+**
- **Obsidian**
- **Obsidian 插件**: `Local REST API`
### 2. 安装 Local REST API 插件
1. 在 Obsidian 中,进入 `设置` > `第三方插件`
2. 关闭 `安全模式`
3. 点击 `浏览社区插件`,搜索 `Local REST API` 并安装。
4. 启用插件,并在插件设置页面生成一个 API 密钥。请妥善保管此密钥。
### 3. 安装 Agent
1. 克隆或下载本项目到您的本地计算机。
```bash
git clone <repository_url> journal_organizer
cd journal_organizer
```
2. 安装 Python 依赖。
```bash
pip install -r requirements.txt
```
## 配置指南
1. **复制配置文件**
将 `config.example.yaml` 复制为 `config.yaml`。
```bash
cp config.example.yaml config.yaml
```
2. **编辑配置文件**
打开 `config.yaml` 并根据您的环境填写以下关键信息:
- `obsidian.vault_path`: 您的 Obsidian vault 在计算机上的绝对路径。
- `obsidian.rest_api.api_key`: 您在 `Local REST API` 插件中生成的 API 密钥。
- `claude.api_key`: 您的 Anthropic API 密钥。建议使用环境变量 `ANTHROPIC_API_KEY` 来设置。
- `journal` 和 `output` 部分的文件夹路径,确保它们在您的 vault 中存在。
**安全提示**: 请勿将包含敏感密钥的 `config.yaml` 文件提交到公共代码仓库。
## 使用方法
您可以通过命令行在项目根目录下运行 Agent。
### 整理今天的日记
```bash
python -m journal_organizer organize
```
### 整理指定日期的日记
```bash
python -m journal_organizer organize --date 2025-12-31
```
### 在 Obsidian 中调用
您可以使用 `Templater` 或 `QuickAdd` 等插件,通过执行 Shell 命令来调用 Agent。
例如,在 `Templater` 中可以这样设置:
```javascript
<%*
const command = `cd /path/to/journal_organizer && python -m journal_organizer organize --date ` + tp.file.title;
const result = await tp.user.exec(command);
tp.obsidian.Notice.now(result, 10000);
%>
```
### 查看帮助
```bash
# 查看所有命令
python -m journal_organizer --help
# 查看特定命令的帮助
python -m journal_organizer organize --help
```
## 项目结构
```
journal_organizer/
├── commands/ # Command 模块
│ ├── __init__.py
│ └── organize_command.py # 日记整理命令
├── skills/ # Skill 模块
│ ├── __init__.py
│ ├── claude_skill.py # Claude AI 相关 Skill
│ └── obsidian_skill.py # Obsidian API 相关 Skill
├── __init__.py
├── agent_core.py # Agent 核心框架
├── main.py # 命令行入口和 Agent 初始化
├── config.example.yaml # 配置文件示例
├── requirements.txt # Python 依赖
└── README.md # 本文档
```
## 如何扩展
本系统基于 Command + Skill 架构,您可以轻松地添加新的功能。
### 添加一个新的 Skill
1. 在 `skills/` 目录下创建一个新的 Python 文件,例如 `my_new_skill.py`。
2. 在该文件中,创建一个继承自 `Skill` 的类。
3. 实现 `execute` 方法,该方法是 Skill 的核心逻辑。
```python
from ..agent_core import Skill, SkillType, SkillResult, CommandContext
class MyNewSkill(Skill):
def __init__(self):
super().__init__(name="my_new_skill", skill_type=SkillType.TRANSFORM, description="我的新技能")
async def execute(self, context: CommandContext, **kwargs) -> SkillResult:
# 在这里实现您的逻辑
return SkillResult(success=True, data="新技能执行成功")
```
### 添加一个新的 Command
1. 在 `commands/` 目录下创建一个新的 Python 文件。
2. 创建一个继承自 `Command` 的类。
3. 在 `__init__` 方法中注册所需的 Skills。
4. 实现 `execute` 方法,编排 Skills 来完成任务。
5. 在 `main.py` 的 `_register_commands` 方法中注册您的新命令。
## 许可证
本项目采用 MIT 许可证。
+185
View File
@@ -0,0 +1,185 @@
# Obsidian 智能日记整理 Agent v2.0 - 对话式
**版本**: 2.0.0
这是一个基于 **对话式 Agent 架构** 的智能日记整理系统,专为 Obsidian 用户设计。它能够通过自然语言对话,理解您的需求,自动分析每日日记,提取关键信息,并将其智能地整理到您的知识库中。
## v2.0 新特性:对话式交互
- **自然语言理解**:您可以直接用自然语言与 Agent 对话,例如“帮我整理一下昨天的日记”。
- **多轮对话**:支持上下文感知,可以进行多轮对话来澄清和确认您的需求。
- **智能建议**:根据您的使用习惯,主动推荐可能的操作。
- **交互式界面**:提供一个命令行聊天界面,支持实时交互和反馈。
## 系统架构
系统采用分层架构,将对话逻辑与业务执行分离:
1. **对话层** (`conversation/`)
- **意图理解** (`intent_understanding.py`):使用 Claude API 理解用户意图。
- **对话状态管理** (`conversation_state.py`):管理对话历史和上下文。
- **响应生成** (`response_generator.py`):生成自然语言响应。
- **对话式 Agent** (`conversational_agent.py`):协调对话层的所有模块。
2. **执行层** (Command + Skill)
- 沿用 v1.0 的 Command + Skill 架构,负责执行具体的业务逻辑。
```mermaid
graph TD
subgraph User Interface
ChatCLI[命令行聊天界面]
ObsidianPlugin[Obsidian 插件]
end
subgraph Conversational Layer
ConvAgent[对话式 Agent]
ChatCLI --> ConvAgent
ObsidianPlugin --> ConvAgent
ConvAgent -- uses --> IntentUnderstanding[意图理解]
ConvAgent -- uses --> ConversationState[对话状态管理]
ConvAgent -- uses --> ResponseGenerator[响应生成]
end
subgraph Execution Layer
CommandAgent[Command Agent]
ConvAgent -- dispatches to --> CommandAgent
CommandAgent -- orchestrates --> Skills[Skills]
end
subgraph External Services
ClaudeAPI[Claude API]
ObsidianAPI[Obsidian Local REST API]
end
IntentUnderstanding -- HTTP --> ClaudeAPI
ResponseGenerator -- HTTP --> ClaudeAPI
Skills -- HTTP --> ObsidianAPI
```
## 安装与配置
安装和配置过程与 v1.0 相同。请参考 [README.md](README.md)。
## 使用方法
### 启动交互式对话
```bash
python -m journal_organizer.chat_main
```
启动后,您将进入一个交互式的命令行聊天界面:
```
============================================================
Obsidian 智能日记整理 Agent - 对话模式
============================================================
🤖 助手: 👋 欢迎使用 Obsidian 日记整理助手!我可以帮您整理日记、分析内容、导出总结等。请告诉我您想要做什么?
👤 您:
```
### 示例对话
```
👤 您: 帮我整理一下昨天的日记
⏳ 处理中...
🤖 助手: ✓ 已成功整理您昨天的日记。提取了 5 条经验、3 条待办事项和 2 个问题。
💡 您可以尝试:
1. 分析本周的主题
2. 导出月度总结
👤 您: 分析一下这周的主题
⏳ 处理中...
🤖 助手: ✓ 分析完成!本周的主题主要集中在项目管理和技术学习两个方面。
```
### 系统命令
在对话界面中,您可以使用以下系统命令:
- `help` / `帮助`:显示帮助信息
- `history` / `历史`:显示对话历史
- `status` / `状态`:显示当前状态
- `clear` / `清除`:清除对话历史
- `exit` / `quit` / `退出`:退出程序
### 处理单个查询
```bash
python -m journal_organizer.chat_main --query "整理今天的日记"
```
此命令将直接输出 JSON 格式的结果,方便脚本调用。
## 项目结构
```
journal_organizer/
├── conversation/ # 对话层模块
│ ├── __init__.py
│ ├── conversational_agent.py
│ ├── conversation_state.py
│ ├── intent_understanding.py
│ └── response_generator.py
├── commands/
├── skills/
├── agent_core.py
├── main.py
├── chat_main.py # 对话式 Agent 入口
├── ... (其他文件)
```
## 开发者指南
### 扩展对话能力
1. **添加新的意图**:在 `intent_understanding.py``command_keywords` 中添加新的命令和关键词。
2. **添加新的参数提取**:在 `parameter_patterns` 中添加新的正则表达式。
3. **自定义响应**:修改 `response_generator.py` 中的提示来改变响应风格。
### 集成到 Obsidian 插件
您可以使用 Obsidian 插件的 `Modal` 来创建一个对话窗口,并通过 `child_process` 调用 `chat_main.py` 来与 Agent 交互。
```typescript
// Obsidian 插件示例
import { App, Modal, Plugin } from 'obsidian';
import { spawn } from 'child_process';
class ChatModal extends Modal {
constructor(app: App) {
super(app);
}
onOpen() {
// 创建对话界面
// ...
}
async sendMessage(message: string) {
const agentProcess = spawn('python', ['-m', 'journal_organizer.chat_main', '--query', message]);
agentProcess.stdout.on('data', (data) => {
const response = JSON.parse(data.toString());
// 显示响应
});
agentProcess.stderr.on('data', (data) => {
// 处理错误
});
}
}
```
## 许可证
本项目采用 MIT 许可证。
+873
View File
@@ -0,0 +1,873 @@
# 故障排除指南
本指南提供 Obsidian 智能日记整理 Agent 常见问题的解决方案和调试技巧。
## 快速诊断
运行以下命令进行快速系统检查:
```bash
# 检查依赖项状态
python -m journal_organizer check-deps
# 测试基本功能
python -m journal_organizer --help
# 验证配置文件
python -c "
import json
with open('config.yaml', 'r') as f:
print('配置文件存在且可读')
"
```
## 常见问题分类
### 🚀 启动问题
#### 问题 1: 模块导入错误
```
ImportError: attempted relative import with no known parent package
```
**解决方案**:
```bash
# ✅ 正确方式:作为模块运行
python -m journal_organizer --help
# ❌ 错误方式:直接运行脚本
python main.py
```
**原因**: 项目使用相对导入,必须作为 Python 包运行。
#### 问题 2: 找不到模块规范
```
ValueError: __main__.__spec__ is None
```
**解决方案**:
确保在项目根目录运行命令,并且 `__init__.py` 文件存在:
```bash
ls -la __init__.py
pwd # 确认在正确目录
```
### ⚙️ 配置问题
#### 问题 3: 配置文件不存在
```
ConfigurationError: Configuration file not found
```
**解决方案**:
1. 复制示例配置文件:
```bash
cp config.example.yaml config.yaml
```
2. 编辑配置文件,填入正确的值:
```yaml
obsidian:
vault_path: "/path/to/your/vault"
rest_api:
api_key: "your-obsidian-api-key"
claude:
api_key: "${ANTHROPIC_API_KEY}"
```
#### 问题 4: 环境变量未设置
```
EnvironmentVariableError: Environment variable 'ANTHROPIC_API_KEY' is not set
```
**解决方案**:
1. 设置必需的环境变量:
```bash
# 设置 Claude API 密钥(必需)
export ANTHROPIC_API_KEY="sk-ant-your-api-key-here"
# 设置 Obsidian API 密钥(必需)
export OBSIDIAN_API_KEY="your-obsidian-api-key"
# 设置可选的环境变量
export OBSIDIAN_VAULT_PATH="/path/to/your/vault"
export CLAUDE_API_URL="https://api.anthropic.com"
export CLAUDE_MODEL="claude-3-5-sonnet-20241022"
# 验证设置
echo $ANTHROPIC_API_KEY
echo $OBSIDIAN_API_KEY
```
2. 永久设置环境变量:
```bash
# 添加到 ~/.bashrc 或 ~/.zshrc
echo 'export ANTHROPIC_API_KEY="sk-ant-your-api-key-here"' >> ~/.bashrc
echo 'export OBSIDIAN_API_KEY="your-obsidian-api-key"' >> ~/.bashrc
# 重新加载配置
source ~/.bashrc
```
3. 使用 .env 文件(可选):
```bash
# 创建 .env 文件
cat > .env << EOF
ANTHROPIC_API_KEY=sk-ant-your-api-key-here
OBSIDIAN_API_KEY=your-obsidian-api-key
OBSIDIAN_VAULT_PATH=/path/to/your/vault
CLAUDE_API_URL=https://api.anthropic.com
CLAUDE_MODEL=claude-3-5-sonnet-20241022
EOF
# 加载 .env 文件
set -a; source .env; set +a
```
#### 问题 4a: 环境变量格式错误
```
EnvironmentVariableError: Environment variable expansion failed
```
**解决方案**:
检查配置文件中的环境变量语法:
```yaml
# ✅ 正确的环境变量语法
claude:
api_key: "${ANTHROPIC_API_KEY}" # 必需变量
api_url: "${CLAUDE_API_URL:-https://api.anthropic.com}" # 带默认值
model: "${CLAUDE_MODEL:?请设置 CLAUDE_MODEL 环境变量}" # 带错误消息
# ❌ 错误的语法
claude:
api_key: "$ANTHROPIC_API_KEY" # 缺少大括号
api_url: "${CLAUDE_API_URL-default}" # 错误的默认值语法
model: "${CLAUDE_MODEL?error}" # 错误的错误消息语法
```
#### 问题 5: 配置格式错误
```
yaml.scanner.ScannerError: mapping values are not allowed here
```
**解决方案**:
1. 检查 YAML 语法:
```bash
python -c "
import yaml
with open('config.yaml', 'r') as f:
yaml.safe_load(f)
print('YAML 格式正确')
"
```
2. 常见 YAML 错误:
```yaml
# ❌ 错误:缩进不一致
obsidian:
vault_path: "/path"
rest_api: # 缩进错误
api_key: "key"
# ✅ 正确:一致的缩进
obsidian:
vault_path: "/path"
rest_api:
api_key: "key"
```
### 🌐 API 连接问题
#### 问题 6: Claude API 连接失败
```
APIError: Failed to connect to Claude API
```
**诊断步骤**:
1. 验证 API 密钥格式:
```bash
echo $ANTHROPIC_API_KEY | grep -E "^sk-ant-"
```
2. 检查 API URL 配置:
```bash
# 检查配置文件中的 API URL
grep -A 5 "claude:" config.yaml
```
3. 测试网络连接:
```bash
# 测试默认 API 端点
curl -I https://api.anthropic.com
# 测试自定义端点(如果使用)
curl -I https://your-custom-endpoint.com
```
#### 问题 6a: Claude API URL 配置错误
```
ClaudeAPIURLError: Invalid URL format: not-a-url
```
**解决方案**:
1. 检查 API URL 格式:
```yaml
claude:
# ✅ 正确格式
api_url: "https://api.anthropic.com"
api_url: "https://proxy.example.com:8080"
api_url: "http://localhost:3128"
# ❌ 错误格式
api_url: "not-a-url"
api_url: "ftp://api.anthropic.com"
api_url: "api.anthropic.com" # 缺少协议
```
2. 常见 API URL 配置:
```yaml
# 官方 API
api_url: "https://api.anthropic.com"
# 代理服务器
api_url: "https://your-proxy.example.com"
api_url: "https://claude-proxy.internal:8080"
# 本地开发
api_url: "http://localhost:3128"
api_url: "https://localhost:8080"
```
#### 问题 6b: Claude 模型名称无效
```
ClaudeModelValidationError: Invalid model name: invalid-model
```
**解决方案**:
1. 使用支持的模型名称:
```yaml
claude:
# ✅ 当前支持的模型
model: "claude-3-5-sonnet-20241022" # 推荐
model: "claude-3-5-haiku-20241022" # 快速
model: "claude-3-opus-20240229" # 最强
model: "claude-3-sonnet-20240229" # 平衡
model: "claude-3-haiku-20240307" # 经济
# ✅ 最新别名
model: "claude-3-5-sonnet-latest"
model: "claude-3-5-haiku-latest"
# ❌ 无效模型名称
model: "gpt-4"
model: "claude-4"
model: "invalid-model"
```
2. 检查模型可用性:
```bash
# 查看配置中的模型
grep "model:" config.yaml
```
#### 问题 6c: Claude API 密钥格式错误
```
ClaudeAPIKeyError: Claude API key should start with 'sk-ant-'
```
**解决方案**:
1. 验证 API 密钥格式:
```bash
# 检查密钥格式
echo $ANTHROPIC_API_KEY | head -c 20
# 应该显示: sk-ant-api03-...
# 检查密钥长度
echo $ANTHROPIC_API_KEY | wc -c
# 应该大于 50 个字符
```
2. 获取正确的 API 密钥:
- 访问 https://console.anthropic.com/
- 创建新的 API 密钥
- 确保密钥以 `sk-ant-` 开头
#### 问题 6d: Claude API 连接超时或网络错误
```
ClaudeConnectionError: Connection error to https://api.anthropic.com
```
**解决方案**:
1. 检查网络连接:
```bash
# 测试基本连接
ping api.anthropic.com
# 测试 HTTPS 连接
curl -I https://api.anthropic.com
# 检查防火墙设置
telnet api.anthropic.com 443
```
2. 代理服务器配置:
```bash
# 如果使用代理,设置环境变量
export https_proxy=http://proxy.company.com:8080
export http_proxy=http://proxy.company.com:8080
```
3. 自定义端点配置:
```yaml
claude:
# 对于自定义端点,确保服务正在运行
api_url: "https://your-proxy.example.com"
# 对于本地端点,可能需要禁用 SSL 验证
api_url: "http://localhost:3128"
```
#### 问题 7: Obsidian API 连接失败
```
APIError: Failed to connect to Obsidian Local REST API
```
**解决方案**:
1. 确认 Obsidian Local REST API 插件已安装并启用
2. 检查 API 服务状态:
```bash
curl -k -H "Authorization: Bearer $OBSIDIAN_API_KEY" \
https://localhost:27123/
```
3. 验证配置:
```yaml
obsidian:
rest_api:
url: "https://localhost:27123" # 确认端口正确
verify_ssl: false # 本地开发时禁用 SSL 验证
```
### 📦 依赖项问题
#### 问题 8: 缺少依赖项
```
ModuleNotFoundError: No module named 'aiohttp'
```
**解决方案**:
```bash
# 安装所有依赖项
pip install -r requirements.txt
# 或单独安装缺失的包
pip install aiohttp pyyaml anthropic
# 检查安装状态
python -m journal_organizer check-deps
```
#### 问题 9: 版本冲突
```
ImportError: cannot import name 'xxx' from 'yyy'
```
**解决方案**:
```bash
# 升级到兼容版本
pip install --upgrade aiohttp anthropic
# 或使用虚拟环境
python -m venv venv
source venv/bin/activate # Linux/Mac
# 或 venv\Scripts\activate # Windows
pip install -r requirements.txt
```
### 🤖 Claude API 配置问题
#### 问题 12: Claude API 配置迁移
```
INFO: Migrated model 'claude-3-sonnet' to 'claude-3-sonnet-20240229'
```
**说明**: 这是正常的迁移信息,不是错误。系统自动将旧的模型名称迁移到新格式。
**常见迁移**:
- `claude-3-sonnet``claude-3-sonnet-20240229`
- `claude-3-opus``claude-3-opus-20240229`
- `claude-3-haiku``claude-3-haiku-20240307`
- `sonnet``claude-3-5-sonnet-20241022`
- `opus``claude-3-opus-20240229`
- `haiku``claude-3-haiku-20240307`
#### 问题 13: 自定义 API 端点配置
```
WARNING: Using custom API endpoint: https://proxy.example.com
```
**解决方案**:
1. 验证自定义端点:
```bash
# 测试端点可用性
curl -I https://proxy.example.com
# 测试 API 兼容性
curl -X POST https://proxy.example.com/v1/messages \
-H "Authorization: Bearer $ANTHROPIC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"claude-3-5-sonnet-20241022","max_tokens":10,"messages":[{"role":"user","content":"test"}]}'
```
2. 常见自定义端点配置:
```yaml
# 企业代理服务器
claude:
api_url: "https://claude-proxy.company.com"
api_key: "${ANTHROPIC_API_KEY}"
# 本地开发环境
claude:
api_url: "http://localhost:8080"
api_key: "local-dev-key"
# 区域端点(如果可用)
claude:
api_url: "https://api-eu.anthropic.com"
api_key: "${ANTHROPIC_API_KEY}"
```
#### 问题 14: 配置验证失败
```
ConfigurationError: Claude configuration validation failed
```
**诊断步骤**:
1. 检查配置完整性:
```bash
# 验证配置文件语法
python -c "
import yaml
with open('config.yaml', 'r') as f:
config = yaml.safe_load(f)
claude_config = config.get('claude', {})
print('API Key:', 'present' if claude_config.get('api_key') else 'missing')
print('API URL:', claude_config.get('api_url', 'default'))
print('Model:', claude_config.get('model', 'default'))
"
```
2. 测试配置加载:
```python
# 测试配置验证
from config import Config
try:
config = Config('config.yaml')
print('配置加载成功')
print(f'Claude API URL: {config.claude.api_url}')
print(f'Claude Model: {config.claude.model}')
except Exception as e:
print(f'配置错误: {e}')
```
#### 问题 15: 向后兼容性问题
```
WARNING: Legacy configuration detected, migration applied
```
**说明**: 系统检测到旧版本的配置格式,自动进行了迁移。这是正常行为。
**迁移内容**:
- 添加缺失的 `api_url` 字段(默认为 `https://api.anthropic.com`
- 更新旧的模型名称格式
- 添加缺失的配置节(如 `journal``output` 等)
**验证迁移结果**:
```bash
# 查看迁移后的配置
python -c "
from config import Config
config = Config('config.yaml')
print('迁移后的配置:')
print(f' API URL: {config.claude.api_url}')
print(f' Model: {config.claude.model}')
print(f' Max Tokens: {config.claude.max_tokens}')
"
```
#### 问题 10: 文件权限错误
```
PermissionError: [Errno 13] Permission denied: 'config.yaml'
```
**解决方案**:
```bash
# 检查文件权限
ls -la config.yaml
# 修复权限
chmod 644 config.yaml
chmod 755 . # 目录权限
```
#### 问题 11: Vault 访问权限
```
PermissionError: Cannot access Obsidian vault
```
**解决方案**:
```bash
# 检查 vault 目录权限
ls -la /path/to/obsidian/vault
# 修复权限(谨慎操作)
chmod -R 755 /path/to/obsidian/vault
```
## 调试技巧
### 1. 启用详细日志
```bash
# 设置调试级别
python -m journal_organizer --log-level DEBUG organize
# 查看日志文件
tail -f logs/journal_organizer.log
```
### 2. 分步调试
```python
# 在代码中添加调试点
import logging
logger = logging.getLogger(__name__)
logger.debug(f"配置内容: {config}")
logger.debug(f"API 响应: {response}")
```
### 3. 测试单个组件
```python
# 测试配置加载
from journal_organizer.main import JournalOrganizerAgent
agent = JournalOrganizerAgent("config.yaml")
print(f"配置加载成功: {bool(agent.config)}")
# 测试 API 连接
import asyncio
from journal_organizer.skills.claude_skill import ClaudeAnalyzeSkill
async def test_claude():
skill = ClaudeAnalyzeSkill()
# 测试逻辑
asyncio.run(test_claude())
```
### 4. 网络诊断
```bash
# 检查网络连接
ping api.anthropic.com
ping localhost
# 检查端口占用
netstat -an | grep 27123
# 测试 SSL 连接
openssl s_client -connect api.anthropic.com:443
```
## 性能问题
### 内存使用过高
**诊断**:
```python
import psutil
import os
process = psutil.Process(os.getpid())
memory_mb = process.memory_info().rss / 1024 / 1024
print(f"内存使用: {memory_mb:.2f} MB")
```
**解决方案**:
- 检查是否有内存泄漏
- 限制并发操作数量
- 使用 `gc.collect()` 强制垃圾回收
### API 调用缓慢
**诊断**:
```python
import time
import asyncio
async def time_api_call():
start = time.time()
result = await api_call()
duration = time.time() - start
print(f"API 调用耗时: {duration:.2f}")
return result
```
**解决方案**:
- 检查网络延迟
- 增加超时设置
- 实现重试机制
- 使用连接池
## 错误代码参考
| 错误代码 | 描述 | 常见原因 | 解决方案 |
|---------|------|----------|----------|
| CONFIG_001 | 配置文件不存在 | 未创建配置文件 | 复制 config.example.yaml |
| CONFIG_002 | 配置格式错误 | YAML 语法错误 | 检查缩进和语法 |
| CONFIG_003 | 缺少必需配置项 | 配置不完整 | 添加缺失的配置项 |
| CONFIG_004 | 环境变量未设置 | 环境变量缺失 | 设置相应的环境变量 |
| CONFIG_005 | 环境变量格式错误 | 语法错误 | 检查 ${VAR} 语法 |
| API_001 | API 密钥无效 | 密钥错误或过期 | 检查并更新 API 密钥 |
| API_002 | API 连接超时 | 网络问题 | 检查网络连接 |
| API_003 | API 限流 | 请求过于频繁 | 减少请求频率 |
| API_004 | SSL 证书错误 | 证书验证失败 | 禁用 SSL 验证(仅本地) |
| API_005 | API URL 格式错误 | URL 格式无效 | 使用正确的 URL 格式 |
| API_006 | 模型名称无效 | 不支持的模型 | 使用支持的模型名称 |
| API_007 | API 密钥格式错误 | 密钥格式不正确 | 使用 sk-ant- 开头的密钥 |
| CLAUDE_001 | Claude 配置错误 | Claude 特定配置问题 | 检查 Claude 配置节 |
| CLAUDE_002 | Claude 连接错误 | Claude API 连接失败 | 检查网络和端点 |
| CLAUDE_003 | Claude 模型错误 | 模型不可用 | 更换可用的模型 |
| CLAUDE_004 | Claude 迁移警告 | 配置需要迁移 | 允许自动迁移 |
| SKILL_001 | Skill 执行失败 | 输入参数错误 | 检查参数格式 |
| SKILL_002 | Skill 超时 | 操作耗时过长 | 增加超时设置 |
| IMPORT_001 | 模块导入错误 | 相对导入问题 | 使用模块方式运行 |
| IMPORT_002 | 依赖项缺失 | 包未安装 | 安装缺失的依赖项 |
| ENV_001 | 环境变量缺失 | 必需变量未设置 | 设置环境变量 |
| ENV_002 | 环境变量展开失败 | 语法或值错误 | 检查变量语法和值 |
## 日志分析
### 常见日志模式
```bash
# 查找错误
grep -i error logs/journal_organizer.log
# 查找 API 调用
grep -i "api" logs/journal_organizer.log
# 查找配置问题
grep -i "config" logs/journal_organizer.log
# 实时监控
tail -f logs/journal_organizer.log | grep -i error
```
### 日志级别说明
- **DEBUG**: 详细的调试信息
- **INFO**: 一般信息,正常操作
- **WARNING**: 警告信息,可能的问题
- **ERROR**: 错误信息,操作失败
- **CRITICAL**: 严重错误,系统无法继续
## 常见配置场景
### 企业环境配置
#### 使用代理服务器
```yaml
claude:
api_key: "${ANTHROPIC_API_KEY}"
api_url: "https://claude-proxy.company.com"
model: "claude-3-5-sonnet-20241022"
# 可能需要设置代理环境变量
# export https_proxy=http://proxy.company.com:8080
# export http_proxy=http://proxy.company.com:8080
```
#### 使用内部 API 网关
```yaml
claude:
api_key: "${COMPANY_CLAUDE_KEY}"
api_url: "https://api-gateway.internal:8443/claude"
model: "claude-3-5-sonnet-20241022"
```
### 开发环境配置
#### 本地开发设置
```yaml
claude:
api_key: "${ANTHROPIC_API_KEY}"
api_url: "${CLAUDE_API_URL:-https://api.anthropic.com}"
model: "${CLAUDE_MODEL:-claude-3-5-sonnet-20241022}"
max_tokens: 4096
temperature: 0.7
obsidian:
vault_path: "${OBSIDIAN_VAULT_PATH:-./test_vault}"
rest_api:
url: "${OBSIDIAN_API_URL:-https://localhost:27123}"
api_key: "${OBSIDIAN_API_KEY}"
verify_ssl: false
```
#### 测试环境配置
```yaml
claude:
api_key: "${TEST_CLAUDE_KEY}"
api_url: "https://test-api.example.com"
model: "claude-3-haiku-20240307" # 使用更便宜的模型进行测试
max_tokens: 1024
temperature: 0.0 # 确定性输出用于测试
```
### 多环境配置管理
#### 使用环境特定的配置文件
```bash
# 开发环境
cp config.example.yaml config.dev.yaml
# 编辑 config.dev.yaml
# 生产环境
cp config.example.yaml config.prod.yaml
# 编辑 config.prod.yaml
# 运行时指定配置文件
python -m journal_organizer --config config.dev.yaml organize
```
#### 使用环境变量切换配置
```bash
# 设置环境特定的变量
export ENV=development
export CLAUDE_API_URL="https://dev-api.example.com"
export CLAUDE_MODEL="claude-3-haiku-20240307"
# 或者生产环境
export ENV=production
export CLAUDE_API_URL="https://api.anthropic.com"
export CLAUDE_MODEL="claude-3-5-sonnet-20241022"
```
### 安全配置最佳实践
#### 1. 使用环境变量存储敏感信息
```yaml
# ✅ 推荐:使用环境变量
claude:
api_key: "${ANTHROPIC_API_KEY}"
obsidian:
rest_api:
api_key: "${OBSIDIAN_API_KEY}"
# ❌ 不推荐:直接在配置文件中存储密钥
claude:
api_key: "sk-ant-actual-key-here"
```
#### 2. 设置适当的文件权限
```bash
# 限制配置文件访问权限
chmod 600 config.yaml
# 确保日志目录权限正确
chmod 755 logs/
chmod 644 logs/*.log
```
#### 3. 使用 .gitignore 保护敏感文件
```bash
# 添加到 .gitignore
echo "config.yaml" >> .gitignore
echo ".env" >> .gitignore
echo "logs/*.log" >> .gitignore
```
### 性能优化配置
#### 高性能配置
```yaml
claude:
api_key: "${ANTHROPIC_API_KEY}"
api_url: "https://api.anthropic.com"
model: "claude-3-5-sonnet-20241022" # 最新最强模型
max_tokens: 8192 # 更大的输出空间
temperature: 0.7
# 启用详细日志以监控性能
logging:
level: "DEBUG"
file: "logs/performance.log"
```
#### 成本优化配置
```yaml
claude:
api_key: "${ANTHROPIC_API_KEY}"
api_url: "https://api.anthropic.com"
model: "claude-3-haiku-20240307" # 更经济的模型
max_tokens: 2048 # 限制输出长度
temperature: 0.5
# 减少日志输出
logging:
level: "WARNING"
file: "logs/journal_organizer.log"
```
### 自助诊断清单
在寻求帮助前,请完成以下检查:
- [ ] 运行 `python -m journal_organizer check-deps`
- [ ] 检查配置文件格式和内容
- [ ] 验证环境变量设置
- [ ] 查看日志文件中的错误信息
- [ ] 测试网络连接和 API 访问
- [ ] 确认文件和目录权限
### 报告问题时请提供
1. **错误信息**: 完整的错误堆栈跟踪
2. **配置文件**: 脱敏后的配置内容
3. **环境信息**: Python 版本、操作系统
4. **日志文件**: 相关的日志片段
5. **重现步骤**: 导致问题的具体操作
### 联系方式
- 查看项目文档
- 检查 GitHub Issues
- 运行内置诊断工具
## 预防措施
### 定期维护
```bash
# 定期更新依赖项
pip list --outdated
pip install --upgrade package_name
# 清理日志文件
find logs/ -name "*.log" -mtime +30 -delete
# 备份配置文件
cp config.yaml config.yaml.backup
```
### 监控建议
- 设置日志轮转
- 监控内存和 CPU 使用
- 定期测试 API 连接
- 备份重要配置和数据
+20
View File
@@ -0,0 +1,20 @@
"""
Obsidian 智能日记整理 Agent
"""
__version__ = "0.1.0"
__author__ = "Journal Organizer Team"
from .agent_core import Agent, Command, Skill, SkillChain, SkillResult, CommandContext
from .main import JournalOrganizerAgent, run_command_sync
__all__ = [
"Agent",
"Command",
"Skill",
"SkillChain",
"SkillResult",
"CommandContext",
"JournalOrganizerAgent",
"run_command_sync",
]
+18
View File
@@ -0,0 +1,18 @@
"""
包的主入口点
允许通过 python -m journal_organizer 运行
"""
import asyncio
import sys
# Handle imports with both relative and absolute paths
try:
from .main import main
except ImportError:
# Fallback to absolute imports when running as script
from main import main
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code or 0)
+354
View File
@@ -0,0 +1,354 @@
"""
Agent 核心框架
定义 Command 和 Skill 的基础类和接口
支持 Claude Code 和 OpenCode
"""
import json
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field, asdict
from datetime import datetime
from enum import Enum
from typing import Dict, Any, List, Optional, Tuple
class SkillType(Enum):
"""Skill 类型枚举"""
READ = "read" # 读取操作
WRITE = "write" # 写入操作
ANALYZE = "analyze" # 分析操作
TRANSFORM = "transform" # 转换操作
INTEGRATE = "integrate" # 集成操作
@dataclass
class SkillResult:
"""Skill 执行结果"""
success: bool
data: Optional[Any] = None
error: Optional[str] = None
message: str = ""
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
def __post_init__(self) -> None:
"""Validate SkillResult consistency after initialization"""
if not self.success and not self.error:
raise ValueError("Failed results must include error message")
if self.success and self.error:
raise ValueError("Successful results should not include error message")
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return asdict(self)
def to_json(self) -> str:
"""转换为 JSON 字符串"""
return json.dumps(self.to_dict(), ensure_ascii=False, indent=2)
@dataclass
class CommandContext:
"""命令执行上下文"""
command_name: str
args: Dict[str, Any] = field(default_factory=dict)
options: Dict[str, Any] = field(default_factory=dict)
config: Optional[Dict[str, Any]] = None
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return asdict(self)
class Skill(ABC):
"""Skill 基础类"""
def __init__(self, name: str, skill_type: SkillType, description: str = "") -> None:
"""
初始化 Skill
Args:
name: Skill 名称
skill_type: Skill 类型
description: Skill 描述
"""
self.name = name
self.skill_type = skill_type
self.description = description
self.logger = logging.getLogger(f"Skill.{name}")
@abstractmethod
async def execute(self, context: CommandContext, **kwargs: Any) -> SkillResult:
"""
执行 Skill
Args:
context: 命令执行上下文
**kwargs: 额外参数
Returns:
SkillResult: 执行结果
"""
pass
def get_info(self) -> Dict[str, Any]:
"""获取 Skill 信息"""
return {
"name": self.name,
"type": self.skill_type.value,
"description": self.description,
}
class SkillChain:
"""Skill 链 - 用于按顺序执行多个 Skill"""
def __init__(self, name: str, description: str = "") -> None:
"""
初始化 Skill 链
Args:
name: 链名称
description: 链描述
"""
self.name = name
self.description = description
self.skills: List[Tuple[Skill, Dict[str, Any]]] = []
self.logger = logging.getLogger(f"SkillChain.{name}")
def add_skill(
self, skill: Skill, params: Optional[Dict[str, Any]] = None
) -> "SkillChain":
"""
添加 Skill 到链中
Args:
skill: 要添加的 Skill
params: Skill 参数
Returns:
self 用于链式调用
"""
self.skills.append((skill, params or {}))
return self
async def execute(self, context: CommandContext) -> SkillResult:
"""
执行 Skill 链
Args:
context: 命令执行上下文
Returns:
SkillResult: 最后一个 Skill 的结果
"""
result: Optional[SkillResult] = None
for skill, params in self.skills:
try:
self.logger.info(f"执行 Skill: {skill.name}")
result = await skill.execute(context, **params)
if not result.success:
self.logger.error(f"Skill {skill.name} 执行失败: {result.error}")
return result
# 将结果传递给下一个 Skill
if result.data:
context.metadata[f"{skill.name}_result"] = result.data
except Exception as e:
self.logger.error(f"执行 Skill {skill.name} 时出错: {str(e)}")
return SkillResult(
success=False, error=str(e), message=f"Skill {skill.name} 执行异常"
)
return result or SkillResult(success=True, message="Skill 链执行完成")
def get_info(self) -> Dict[str, Any]:
"""获取 Skill 链信息"""
return {
"name": self.name,
"description": self.description,
"skills": [skill.get_info() for skill, _ in self.skills],
}
class Command(ABC):
"""Command 基础类"""
def __init__(
self, name: str, description: str = "", aliases: Optional[List[str]] = None
) -> None:
"""
初始化 Command
Args:
name: 命令名称
description: 命令描述
aliases: 命令别名
"""
self.name = name
self.description = description
self.aliases = aliases or []
self.skills: Dict[str, Skill] = {}
self.skill_chains: Dict[str, SkillChain] = {}
self.logger = logging.getLogger(f"Command.{name}")
def register_skill(self, skill: Skill) -> "Command":
"""
注册 Skill
Args:
skill: 要注册的 Skill
Returns:
self 用于链式调用
"""
self.skills[skill.name] = skill
return self
def register_skill_chain(self, chain: SkillChain) -> "Command":
"""
注册 Skill 链
Args:
chain: 要注册的 Skill 链
Returns:
self 用于链式调用
"""
self.skill_chains[chain.name] = chain
return self
@abstractmethod
async def execute(self, context: CommandContext) -> SkillResult:
"""
执行命令
Args:
context: 命令执行上下文
Returns:
SkillResult: 执行结果
"""
pass
def get_info(self) -> Dict[str, Any]:
"""获取命令信息"""
return {
"name": self.name,
"description": self.description,
"aliases": self.aliases,
"skills": {name: skill.get_info() for name, skill in self.skills.items()},
"skill_chains": {
name: chain.get_info() for name, chain in self.skill_chains.items()
},
}
class Agent:
"""Agent 核心类"""
def __init__(self, name: str, config: Optional[Dict[str, Any]] = None) -> None:
"""
初始化 Agent
Args:
name: Agent 名称
config: 配置字典
"""
self.name = name
self.config = config or {}
self.commands: Dict[str, Command] = {}
self.command_aliases: Dict[str, str] = {}
self.logger = logging.getLogger(f"Agent.{name}")
self._setup_logging()
def _setup_logging(self) -> None:
"""设置日志"""
log_level = self.config.get("log_level", "INFO")
logging.basicConfig(
level=getattr(logging, log_level),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
def register_command(self, command: Command) -> "Agent":
"""
注册命令
Args:
command: 要注册的命令
Returns:
self 用于链式调用
"""
self.commands[command.name] = command
# 注册别名
for alias in command.aliases:
self.command_aliases[alias] = command.name
self.logger.info(f"注册命令: {command.name}")
return self
async def execute_command(
self,
command_name: str,
args: Optional[Dict[str, Any]] = None,
options: Optional[Dict[str, Any]] = None,
) -> SkillResult:
"""
执行命令
Args:
command_name: 命令名称或别名
args: 命令参数
options: 命令选项
Returns:
SkillResult: 执行结果
"""
# 解析命令名称(处理别名)
actual_command_name = self.command_aliases.get(command_name, command_name)
if actual_command_name not in self.commands:
return SkillResult(
success=False,
error=f"未知命令: {command_name}",
message=f"命令 '{command_name}' 不存在",
)
command = self.commands[actual_command_name]
context = CommandContext(
command_name=actual_command_name,
args=args or {},
options=options or {},
config=self.config,
)
self.logger.info(f"执行命令: {actual_command_name}, 参数: {args}, 选项: {options}")
try:
result = await command.execute(context)
self.logger.info(f"命令 {actual_command_name} 执行完成: {result.success}")
return result
except Exception as e:
self.logger.error(f"执行命令 {actual_command_name} 时出错: {str(e)}")
return SkillResult(success=False, error=str(e), message=f"命令执行异常: {str(e)}")
def get_commands_info(self) -> Dict[str, Any]:
"""获取所有命令信息"""
return {
"agent_name": self.name,
"commands": {name: cmd.get_info() for name, cmd in self.commands.items()},
"aliases": self.command_aliases,
}
def list_commands(self) -> List[str]:
"""列出所有可用命令"""
return list(self.commands.keys())
+657
View File
@@ -0,0 +1,657 @@
"""
API response validation and sanitization utilities
Provides comprehensive validation for API responses to handle malformed data gracefully
"""
import json
import logging
import re
from typing import Any, Dict, List, Optional, Union, Tuple, Callable
try:
from .error_handling import ValidationError, APIError
except ImportError:
from error_handling import ValidationError, APIError
class APIResponseValidator:
"""Comprehensive API response validation and sanitization"""
def __init__(self):
self.logger = logging.getLogger(__name__)
# Response size limits
self.max_response_size = 10 * 1024 * 1024 # 10MB
self.max_json_depth = 32
self.max_array_length = 10000
self.max_string_length = 1000000 # 1MB for individual strings
# Content type patterns
self.json_content_types = [
'application/json',
'application/vnd.api+json',
'text/json'
]
self.text_content_types = [
'text/plain',
'text/html',
'text/markdown',
'text/xml'
]
def validate_http_response(
self,
response: Any,
expected_status_codes: Optional[List[int]] = None,
expected_content_type: Optional[str] = None,
max_size: Optional[int] = None
) -> Dict[str, Any]:
"""
Validate HTTP response object (aiohttp.ClientResponse or similar)
Args:
response: HTTP response object
expected_status_codes: List of acceptable status codes
expected_content_type: Expected content type
max_size: Maximum response size in bytes
Returns:
Dictionary with validation results
Raises:
APIError: If response validation fails
"""
validation_result = {
'valid': True,
'status_code': None,
'content_type': None,
'content_length': None,
'warnings': []
}
try:
# Check if response object has expected attributes
if not hasattr(response, 'status'):
raise APIError(
message="Response object missing 'status' attribute",
api_name="unknown"
)
validation_result['status_code'] = response.status
# Validate status code
if expected_status_codes and response.status not in expected_status_codes:
raise APIError(
message=f"Unexpected status code: {response.status} (expected: {expected_status_codes})",
api_name="unknown",
status_code=response.status
)
# Check content type if available
if hasattr(response, 'headers') and 'content-type' in response.headers:
content_type = response.headers['content-type'].split(';')[0].strip().lower()
validation_result['content_type'] = content_type
if expected_content_type and not content_type.startswith(expected_content_type.lower()):
validation_result['warnings'].append(
f"Unexpected content type: {content_type} (expected: {expected_content_type})"
)
# Check content length if available
if hasattr(response, 'headers') and 'content-length' in response.headers:
try:
content_length = int(response.headers['content-length'])
validation_result['content_length'] = content_length
max_allowed = max_size or self.max_response_size
if content_length > max_allowed:
raise APIError(
message=f"Response too large: {content_length} bytes (max: {max_allowed})",
api_name="unknown"
)
except ValueError:
validation_result['warnings'].append("Invalid content-length header")
return validation_result
except Exception as e:
if isinstance(e, APIError):
raise
else:
raise APIError(
message=f"Response validation error: {str(e)}",
api_name="unknown",
cause=e
)
def validate_json_response(
self,
json_data: Any,
schema: Optional[Dict[str, Any]] = None,
api_name: str = "unknown"
) -> Dict[str, Any]:
"""
Validate JSON response data with optional schema validation
Args:
json_data: Parsed JSON data to validate
schema: Optional schema definition for validation
api_name: Name of the API for error context
Returns:
Validated and sanitized JSON data
Raises:
APIError: If validation fails
"""
try:
# Basic structure validation
self._validate_json_structure(json_data, api_name)
# Schema validation if provided
if schema:
self._validate_json_schema(json_data, schema, api_name)
# Sanitize the data
sanitized_data = self._sanitize_json_data(json_data)
return sanitized_data
except Exception as e:
if isinstance(e, (APIError, ValidationError)):
raise
else:
raise APIError(
message=f"JSON validation error: {str(e)}",
api_name=api_name,
cause=e
)
def validate_text_response(
self,
text_data: str,
max_length: Optional[int] = None,
allowed_patterns: Optional[List[str]] = None,
forbidden_patterns: Optional[List[str]] = None,
api_name: str = "unknown"
) -> str:
"""
Validate text response with content checks
Args:
text_data: Text response to validate
max_length: Maximum allowed text length
allowed_patterns: List of regex patterns that must be present
forbidden_patterns: List of regex patterns that must not be present
api_name: Name of the API for error context
Returns:
Validated and sanitized text
Raises:
APIError: If validation fails
"""
if not isinstance(text_data, str):
raise APIError(
message="Response data must be a string",
api_name=api_name
)
# Length validation
max_len = max_length or self.max_string_length
if len(text_data) > max_len:
raise APIError(
message=f"Response text too long: {len(text_data)} characters (max: {max_len})",
api_name=api_name
)
# Pattern validation
if allowed_patterns:
for pattern in allowed_patterns:
if not re.search(pattern, text_data, re.IGNORECASE | re.DOTALL):
raise APIError(
message=f"Response missing required pattern: {pattern}",
api_name=api_name
)
if forbidden_patterns:
for pattern in forbidden_patterns:
if re.search(pattern, text_data, re.IGNORECASE | re.DOTALL):
raise APIError(
message=f"Response contains forbidden pattern: {pattern}",
api_name=api_name
)
# Sanitize the text
sanitized_text = self._sanitize_text_data(text_data)
return sanitized_text
def parse_and_validate_json(
self,
response_text: str,
schema: Optional[Dict[str, Any]] = None,
api_name: str = "unknown"
) -> Dict[str, Any]:
"""
Parse JSON response text and validate the result
Args:
response_text: Raw response text to parse
schema: Optional schema for validation
api_name: Name of the API for error context
Returns:
Parsed and validated JSON data
Raises:
APIError: If parsing or validation fails
"""
# Basic text validation first
if not isinstance(response_text, str):
raise APIError(
message="Response must be a string",
api_name=api_name
)
if len(response_text) > self.max_response_size:
raise APIError(
message=f"Response too large: {len(response_text)} bytes",
api_name=api_name
)
# Try to parse JSON
try:
json_data = json.loads(response_text)
except json.JSONDecodeError as e:
# Try to extract JSON from response if it's embedded
json_data = self._extract_json_from_text(response_text, api_name)
if json_data is None:
raise APIError(
message=f"Invalid JSON response: {str(e)}",
api_name=api_name,
response_data=response_text[:500], # First 500 chars for debugging
cause=e
)
# Validate the parsed JSON
return self.validate_json_response(json_data, schema, api_name)
def validate_obsidian_api_response(
self,
response_data: Any,
operation: str = "unknown"
) -> Dict[str, Any]:
"""
Validate Obsidian API response with operation-specific checks
Args:
response_data: Response data to validate
operation: Type of operation (read, write, list, etc.)
Returns:
Validated response data
Raises:
APIError: If validation fails
"""
api_name = "obsidian"
if operation == "read":
# For read operations, expect text content
if not isinstance(response_data, str):
raise APIError(
message="Obsidian read response must be text",
api_name=api_name
)
# Validate as text with reasonable limits
return {
'content': self.validate_text_response(
response_data,
max_length=10 * 1024 * 1024, # 10MB for note content
api_name=api_name
),
'length': len(response_data)
}
elif operation == "write":
# Write operations might return status info
if isinstance(response_data, str):
# Simple text response
return {'message': response_data}
elif isinstance(response_data, dict):
# Structured response
return self.validate_json_response(response_data, api_name=api_name)
else:
# Assume success if no specific response
return {'success': True}
elif operation == "list":
# List operations should return array or object with files
if isinstance(response_data, list):
# Validate as array of file info
validated_files = []
for item in response_data:
if isinstance(item, str):
# Simple filename
validated_files.append(self._sanitize_filename(item))
elif isinstance(item, dict):
# File info object
validated_files.append(self._validate_file_info(item, api_name))
else:
self.logger.warning(f"Unexpected file list item type: {type(item)}")
return {'files': validated_files, 'count': len(validated_files)}
elif isinstance(response_data, dict):
# Object with file list
return self.validate_json_response(response_data, api_name=api_name)
else:
raise APIError(
message="Obsidian list response must be array or object",
api_name=api_name
)
else:
# Generic validation for unknown operations
if isinstance(response_data, str):
return {'content': self.validate_text_response(response_data, api_name=api_name)}
elif isinstance(response_data, (dict, list)):
return self.validate_json_response(response_data, api_name=api_name)
else:
return {'data': str(response_data)}
def validate_claude_api_response(
self,
response_data: Any,
operation: str = "unknown"
) -> Dict[str, Any]:
"""
Validate Claude API response with operation-specific checks
Args:
response_data: Response data to validate
operation: Type of operation (analyze, transform, etc.)
Returns:
Validated response data
Raises:
APIError: If validation fails
"""
api_name = "claude"
# Claude API typically returns structured objects
if not isinstance(response_data, dict):
raise APIError(
message="Claude API response must be an object",
api_name=api_name
)
# Validate basic structure
validated_response = self.validate_json_response(response_data, api_name=api_name)
# Check for required fields based on operation
if operation in ["analyze", "transform"]:
# Expect content field
if 'content' not in validated_response:
raise APIError(
message="Claude response missing 'content' field",
api_name=api_name
)
# Validate content structure
content = validated_response['content']
if isinstance(content, list) and len(content) > 0:
# Check first content item
first_item = content[0]
if isinstance(first_item, dict) and 'text' in first_item:
# Validate the text content
text_content = first_item['text']
if isinstance(text_content, str):
validated_response['content'][0]['text'] = self.validate_text_response(
text_content,
max_length=1000000, # 1MB for AI responses
api_name=api_name
)
return validated_response
def _validate_json_structure(self, data: Any, api_name: str, depth: int = 0) -> None:
"""Recursively validate JSON structure"""
if depth > self.max_json_depth:
raise APIError(
message=f"JSON structure too deep (max depth: {self.max_json_depth})",
api_name=api_name
)
if isinstance(data, dict):
if len(data) > 1000: # Reasonable limit for object keys
raise APIError(
message=f"JSON object has too many keys: {len(data)}",
api_name=api_name
)
for key, value in data.items():
if not isinstance(key, str):
raise APIError(
message=f"JSON object key must be string, got {type(key)}",
api_name=api_name
)
if len(key) > 1000: # Reasonable key length limit
raise APIError(
message=f"JSON object key too long: {len(key)} characters",
api_name=api_name
)
self._validate_json_structure(value, api_name, depth + 1)
elif isinstance(data, list):
if len(data) > self.max_array_length:
raise APIError(
message=f"JSON array too long: {len(data)} items (max: {self.max_array_length})",
api_name=api_name
)
for item in data:
self._validate_json_structure(item, api_name, depth + 1)
elif isinstance(data, str):
if len(data) > self.max_string_length:
raise APIError(
message=f"JSON string too long: {len(data)} characters (max: {self.max_string_length})",
api_name=api_name
)
def _validate_json_schema(self, data: Any, schema: Dict[str, Any], api_name: str) -> None:
"""Basic JSON schema validation"""
# This is a simplified schema validator
# For production use, consider using jsonschema library
if 'type' in schema:
expected_type = schema['type']
type_mapping = {
'object': dict,
'array': list,
'string': str,
'number': (int, float),
'integer': int,
'boolean': bool,
'null': type(None)
}
if expected_type in type_mapping:
expected_python_type = type_mapping[expected_type]
if not isinstance(data, expected_python_type):
raise APIError(
message=f"Expected {expected_type}, got {type(data).__name__}",
api_name=api_name
)
if isinstance(data, dict) and 'properties' in schema:
# Validate object properties
for prop_name, prop_schema in schema['properties'].items():
if prop_name in data:
self._validate_json_schema(data[prop_name], prop_schema, api_name)
# Check required properties
if 'required' in schema:
for required_prop in schema['required']:
if required_prop not in data:
raise APIError(
message=f"Missing required property: {required_prop}",
api_name=api_name
)
elif isinstance(data, list) and 'items' in schema:
# Validate array items
item_schema = schema['items']
for item in data:
self._validate_json_schema(item, item_schema, api_name)
def _sanitize_json_data(self, data: Any) -> Any:
"""Sanitize JSON data by removing/replacing problematic content"""
if isinstance(data, dict):
sanitized = {}
for key, value in data.items():
# Sanitize key
clean_key = self._sanitize_string(str(key))
# Recursively sanitize value
sanitized[clean_key] = self._sanitize_json_data(value)
return sanitized
elif isinstance(data, list):
return [self._sanitize_json_data(item) for item in data]
elif isinstance(data, str):
return self._sanitize_string(data)
else:
# Numbers, booleans, null - return as-is
return data
def _sanitize_text_data(self, text: str) -> str:
"""Sanitize text data"""
return self._sanitize_string(text)
def _sanitize_string(self, text: str) -> str:
"""Sanitize string content"""
if not isinstance(text, str):
return str(text)
# Remove null bytes
sanitized = text.replace('\x00', '')
# Remove other control characters except common whitespace
sanitized = ''.join(char for char in sanitized if ord(char) >= 32 or char in '\t\n\r')
# Limit length
if len(sanitized) > self.max_string_length:
sanitized = sanitized[:self.max_string_length] + '...[truncated]'
return sanitized
def _sanitize_filename(self, filename: str) -> str:
"""Sanitize filename from API response"""
if not isinstance(filename, str):
filename = str(filename)
# Remove dangerous characters
sanitized = re.sub(r'[<>:"|?*\x00-\x1f]', '', filename)
# Remove path separators
sanitized = sanitized.replace('/', '').replace('\\', '')
# Limit length
if len(sanitized) > 255:
sanitized = sanitized[:255]
return sanitized
def _validate_file_info(self, file_info: Dict[str, Any], api_name: str) -> Dict[str, Any]:
"""Validate file information object"""
validated = {}
# Common file info fields
if 'name' in file_info:
validated['name'] = self._sanitize_filename(str(file_info['name']))
if 'path' in file_info:
validated['path'] = self._sanitize_string(str(file_info['path']))
if 'size' in file_info:
try:
validated['size'] = int(file_info['size'])
except (ValueError, TypeError):
self.logger.warning(f"Invalid file size: {file_info['size']}")
if 'modified' in file_info:
validated['modified'] = self._sanitize_string(str(file_info['modified']))
if 'type' in file_info:
validated['type'] = self._sanitize_string(str(file_info['type']))
return validated
def _extract_json_from_text(self, text: str, api_name: str) -> Optional[Dict[str, Any]]:
"""Try to extract JSON from text response (e.g., if wrapped in markdown)"""
# Look for JSON blocks in markdown
json_patterns = [
r'```json\s*\n(.*?)\n```', # Markdown JSON block
r'```\s*\n(\{.*?\})\n```', # Generic code block with JSON
r'(\{.*\})', # Any JSON-like structure
]
for pattern in json_patterns:
matches = re.findall(pattern, text, re.DOTALL | re.IGNORECASE)
for match in matches:
try:
return json.loads(match.strip())
except json.JSONDecodeError:
continue
return None
# Global validator instance
api_response_validator = APIResponseValidator()
def validate_api_response(
response_data: Any,
api_name: str,
operation: str = "unknown",
schema: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Convenience function for validating API responses
Args:
response_data: Response data to validate
api_name: Name of the API
operation: Type of operation
schema: Optional schema for validation
Returns:
Validated response data
Raises:
APIError: If validation fails
"""
if api_name.lower() == "obsidian":
return api_response_validator.validate_obsidian_api_response(response_data, operation)
elif api_name.lower() == "claude":
return api_response_validator.validate_claude_api_response(response_data, operation)
else:
# Generic validation
if isinstance(response_data, str):
return {'content': api_response_validator.validate_text_response(response_data, api_name=api_name)}
elif isinstance(response_data, (dict, list)):
return api_response_validator.validate_json_response(response_data, schema, api_name)
else:
return {'data': str(response_data)}
+254
View File
@@ -0,0 +1,254 @@
"""
对话式 Agent 的命令行入口
支持交互式对话
"""
import argparse
import asyncio
import logging
import sys
from typing import Dict, Any, Optional, List
from typing import Dict, Any, Optional, List
# Handle imports with both relative and absolute paths
try:
from .main import JournalOrganizerAgent
from .conversation import ConversationalAgent
except ImportError:
# Fallback to absolute imports when running as script
from main import JournalOrganizerAgent
from conversation import ConversationalAgent
class ChatInterface:
"""对话式 Agent 的命令行界面"""
def __init__(self, config_file: Optional[str] = None) -> None:
"""
初始化聊天界面
Args:
config_file: 配置文件路径
"""
self.logger: logging.Logger = logging.getLogger("ChatInterface")
# 初始化 Agent
self.journal_agent: JournalOrganizerAgent = JournalOrganizerAgent(config_file)
self.conversational_agent: ConversationalAgent = ConversationalAgent(
self.journal_agent.agent, self.journal_agent.config
)
async def run_interactive(self) -> None:
"""
运行交互式对话
"""
print(f"\n{'='*60}")
print("Obsidian 智能日记整理 Agent - 对话模式")
print(f"{'='*60}\n")
# 显示欢迎消息
welcome_msg = await self.conversational_agent.initialize()
print(f"\n🤖 助手: {welcome_msg}\n")
# 交互循环
while True:
try:
# 获取用户输入
user_input: str = input("👤 您: ").strip()
if not user_input:
continue
# 处理特殊命令
if user_input.lower() in ["exit", "quit", "退出"]:
print("\n👋 再见!\n")
break
if user_input.lower() in ["help", "帮助"]:
self._show_help()
continue
if user_input.lower() in ["history", "历史"]:
self._show_history()
continue
if user_input.lower() in ["status", "状态"]:
self._show_status()
continue
if user_input.lower() in ["clear", "清除"]:
self.conversational_agent.clear_history()
print("\n✓ 对话历史已清除\n")
continue
# 处理用户消息
print("\n⏳ 处理中...\n")
response = await self.conversational_agent.chat(user_input)
# 显示响应
print(f"🤖 助手: {response.message}")
# 显示建议
if response.suggestions:
print("\n💡 您可以尝试:")
for i, suggestion in enumerate(response.suggestions, 1):
print(f" {i}. {suggestion}")
print()
except KeyboardInterrupt:
print("\n\n👋 再见!\n")
break
except Exception as e:
self.logger.error(f"错误: {str(e)}", exc_info=True)
print(f"\n❌ 发生错误: {str(e)}\n")
async def run_single_query(self, query: str) -> None:
"""
运行单个查询
Args:
query: 用户查询
"""
# 初始化
await self.conversational_agent.initialize()
# 处理查询
response = await self.conversational_agent.chat(query)
# 输出结果
output_data: Dict[str, Any] = {
"message": response.message,
"status": response.status,
"suggestions": response.suggestions,
"metadata": response.metadata,
}
print(json.dumps(output_data, ensure_ascii=False, indent=2))
def _show_help(self) -> None:
"""
显示帮助信息
"""
help_text = """
🆘 可用命令:
对话命令:
- 直接输入您的需求,例如: "整理今天的日记"
- "分析本周的主题"
- "导出月度总结"
系统命令:
- help / 帮助 显示此帮助信息
- history / 历史 显示对话历史
- status / 状态 显示当前状态
- clear / 清除 清除对话历史
- exit / quit / 退出 退出程序
示例对话:
👤 您: 帮我整理一下昨天的日记
🤖 助手: 好的,我来帮您整理昨天的日记...
👤 您: 分析一下这周的主题
🤖 助手: 这周的主题主要集中在...
"""
print(help_text)
def _show_history(self) -> None:
"""
显示对话历史
"""
history: List[
Dict[str, str]
] = self.conversational_agent.get_conversation_history()
if not history:
print("\n📭 对话历史为空\n")
return
print("\n📜 对话历史:\n")
for msg in history:
role: str = "👤 您" if msg["role"] == "user" else "🤖 助手"
content: str = msg["content"]
if len(content) > 100:
content = f"{content[:100]}..."
print(f"{role}: {content}")
print()
def _show_status(self) -> None:
"""
显示当前状态
"""
summary: Dict[str, Any] = self.conversational_agent.get_state_summary()
print("\n📊 当前状态:\n")
print(f"总消息数: {summary['total_messages']}")
print(f"总任务数: {summary['stats']['total_tasks']}")
print(f"成功任务: {summary['stats']['successful_tasks']}")
print(f"失败任务: {summary['stats']['failed_tasks']}")
if summary["current_task"]:
print(f"\n当前任务: {summary['current_task']['command']}")
print(f"状态: {summary['current_task']['status']}")
print()
async def main() -> Optional[int]:
"""
主函数
"""
parser: argparse.ArgumentParser = argparse.ArgumentParser(
description="Obsidian 智能日记整理 Agent - 对话模式",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
# 启动交互式对话
python -m journal_organizer.chat_main
# 处理单个查询
python -m journal_organizer.chat_main --query "整理今天的日记"
# 使用指定配置文件
python -m journal_organizer.chat_main --config /path/to/config.yaml
""",
)
parser.add_argument("--config", type=str, help="配置文件路径")
parser.add_argument("--query", type=str, help="单个查询(不进入交互模式)")
parser.add_argument(
"--log-level",
type=str,
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
help="日志级别",
)
args: argparse.Namespace = parser.parse_args()
# 设置日志
logging.basicConfig(
level=getattr(logging, args.log_level),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
# 初始化聊天界面
chat: ChatInterface = ChatInterface(args.config)
# 运行
if args.query:
# 单个查询模式
await chat.run_single_query(args.query)
else:
# 交互模式
await chat.run_interactive()
return None
if __name__ == "__main__":
exit_code: Optional[int] = asyncio.run(main())
sys.exit(exit_code or 0)
+473
View File
@@ -0,0 +1,473 @@
"""
Enhanced Claude API Client with configurable URL support
Provides a centralized client for all Claude API interactions with custom endpoint support
"""
import ssl
import logging
from datetime import datetime
from typing import Optional, Dict, Any, AsyncContextManager, TYPE_CHECKING
from contextlib import asynccontextmanager
if TYPE_CHECKING:
import aiohttp
try:
from .dependency_manager import get_dependency_manager
from .config_validation import ClaudeAPIConfig
from .error_handling import APIError, ConfigurationError
# Try to import anthropic with graceful degradation
dependency_manager = get_dependency_manager()
AsyncAnthropic = dependency_manager.get_class_from_module('anthropic', 'AsyncAnthropic')
aiohttp = dependency_manager.get_module('aiohttp')
except ImportError:
# Fallback for backward compatibility
try:
from anthropic import AsyncAnthropic
import aiohttp
from config_validation import ClaudeAPIConfig
from error_handling import APIError, ConfigurationError
except ImportError:
AsyncAnthropic = None
aiohttp = None
ClaudeAPIConfig = None
APIError = Exception
ConfigurationError = Exception
class ClaudeAPIClient:
"""Enhanced Claude API client with configurable endpoint support"""
def __init__(self, config: ClaudeAPIConfig):
"""
Initialize Claude API client with enhanced configuration
Args:
config: ClaudeAPIConfig instance with api_url, api_key, model, etc.
"""
self.config = config
self.base_url = config.api_url
self.api_key = config.api_key
self.model = config.model
self.max_tokens = config.max_tokens
self.temperature = config.temperature
self.logger = logging.getLogger(__name__)
# Validate dependencies
if not AsyncAnthropic:
raise ConfigurationError(
message="Anthropic library is not installed. Please install it with: pip install anthropic",
config_key="anthropic_dependency",
)
if not aiohttp:
raise ConfigurationError(
message="aiohttp library is not installed. Please install it with: pip install aiohttp",
config_key="aiohttp_dependency",
)
# Initialize the Anthropic client with custom base URL
self._anthropic_client = self._create_anthropic_client()
def _create_anthropic_client(self) -> AsyncAnthropic:
"""Create Anthropic client with custom configuration"""
client_kwargs = {
'api_key': self.api_key,
}
# Set custom base URL if different from default
if self.base_url != "https://api.anthropic.com":
client_kwargs['base_url'] = self.base_url
self.logger.info(f"Using custom Claude API URL: {self.base_url}")
return AsyncAnthropic(**client_kwargs)
@asynccontextmanager
async def create_http_session(self) -> AsyncContextManager[Any]:
"""
Create HTTP session with proper SSL configuration for custom endpoints
This is used for direct HTTP calls when needed (e.g., connection validation)
"""
if not aiohttp:
raise ConfigurationError(
message="aiohttp library is not installed. Please install it with: pip install aiohttp",
config_key="aiohttp_dependency",
)
headers = {
'Authorization': f'Bearer {self.api_key}',
'Content-Type': 'application/json',
'User-Agent': 'journal-organizer/1.0',
'anthropic-version': '2023-06-01'
}
# Configure SSL context for custom endpoints
ssl_context = ssl.create_default_context()
# Handle localhost and custom endpoints with SSL considerations
if ('localhost' in self.base_url or
self.base_url.startswith('https://127.0.0.1') or
self.base_url.startswith('http://localhost') or
self.base_url.startswith('http://127.0.0.1')):
# For localhost, disable SSL verification
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
self.logger.warning(f"SSL verification disabled for localhost endpoint: {self.base_url}")
connector = aiohttp.TCPConnector(ssl=ssl_context)
timeout = aiohttp.ClientTimeout(total=60)
async with aiohttp.ClientSession(
connector=connector,
headers=headers,
timeout=timeout
) as session:
yield session
async def validate_connection(self) -> bool:
"""
Validate API connection and credentials
Returns:
True if connection is valid
Raises:
APIError: If connection validation fails
"""
try:
self.logger.info(f"Validating connection to Claude API at {self.base_url}")
# Try a minimal API call to validate connection
message = await self._anthropic_client.messages.create(
model=self.model,
max_tokens=10,
messages=[{"role": "user", "content": "test"}]
)
if message and hasattr(message, 'content') and message.content:
self.logger.info("Claude API connection validated successfully")
return True
else:
raise APIError(
message="Invalid response from Claude API",
api_name="claude"
)
except Exception as e:
error_msg = str(e)
# Provide specific error messages based on common issues
if "401" in error_msg or "unauthorized" in error_msg.lower():
raise APIError(
message=f"Invalid API key or unauthorized access to {self.base_url}. Please check your Claude API key.",
api_name="claude",
cause=e
)
elif "404" in error_msg or "not found" in error_msg.lower():
raise APIError(
message=f"API endpoint not found. Please verify that {self.base_url} is the correct Claude API URL.",
api_name="claude",
cause=e
)
elif "connection" in error_msg.lower() or "timeout" in error_msg.lower():
raise APIError(
message=f"Connection error to {self.base_url}. Please check your network connection and API URL.",
api_name="claude",
cause=e
)
elif "ssl" in error_msg.lower() or "certificate" in error_msg.lower():
raise APIError(
message=f"SSL/Certificate error connecting to {self.base_url}. For localhost endpoints, this is expected.",
api_name="claude",
cause=e
)
else:
raise APIError(
message=f"Claude API validation failed: {error_msg}",
api_name="claude",
cause=e
)
async def validate_model_availability(self) -> bool:
"""
Validate that the configured model is available
Returns:
True if model is available
Raises:
APIError: If model validation fails
"""
try:
self.logger.info(f"Validating model availability: {self.model}")
# Try a minimal API call with the specific model
message = await self._anthropic_client.messages.create(
model=self.model,
max_tokens=5,
messages=[{"role": "user", "content": "hi"}]
)
if message and hasattr(message, 'content') and message.content:
self.logger.info(f"Model {self.model} is available and working")
return True
else:
raise APIError(
message=f"Invalid response when testing model {self.model}",
api_name="claude"
)
except Exception as e:
error_msg = str(e)
if "model" in error_msg.lower() and ("not found" in error_msg.lower() or "invalid" in error_msg.lower()):
raise APIError(
message=f"Model '{self.model}' is not available or invalid. Please check your model configuration.",
api_name="claude",
cause=e
)
else:
# Re-raise as general API error
raise APIError(
message=f"Model validation failed: {error_msg}",
api_name="claude",
cause=e
)
async def test_api_connectivity(self) -> Dict[str, Any]:
"""
Comprehensive API connectivity test with detailed results
Returns:
Dictionary with test results and diagnostics
"""
results = {
'connection_test': {'status': 'unknown', 'message': '', 'error': None},
'model_test': {'status': 'unknown', 'message': '', 'error': None},
'authentication_test': {'status': 'unknown', 'message': '', 'error': None},
'overall_status': 'unknown',
'api_url': self.base_url,
'model': self.model,
'timestamp': datetime.now().isoformat()
}
# Test 1: Basic connection
try:
await self.validate_connection()
results['connection_test'] = {
'status': 'success',
'message': 'API connection successful',
'error': None
}
results['authentication_test'] = {
'status': 'success',
'message': 'API key authentication successful',
'error': None
}
except APIError as e:
results['connection_test'] = {
'status': 'failed',
'message': str(e),
'error': type(e).__name__
}
# Determine if it's an auth issue
if "401" in str(e) or "unauthorized" in str(e).lower():
results['authentication_test'] = {
'status': 'failed',
'message': 'API key authentication failed',
'error': 'AuthenticationError'
}
else:
results['authentication_test'] = {
'status': 'unknown',
'message': 'Could not test authentication due to connection failure',
'error': None
}
# Test 2: Model availability (only if connection succeeded)
if results['connection_test']['status'] == 'success':
try:
await self.validate_model_availability()
results['model_test'] = {
'status': 'success',
'message': f'Model {self.model} is available',
'error': None
}
except APIError as e:
results['model_test'] = {
'status': 'failed',
'message': str(e),
'error': type(e).__name__
}
else:
results['model_test'] = {
'status': 'skipped',
'message': 'Model test skipped due to connection failure',
'error': None
}
# Determine overall status
if (results['connection_test']['status'] == 'success' and
results['model_test']['status'] == 'success' and
results['authentication_test']['status'] == 'success'):
results['overall_status'] = 'success'
elif results['connection_test']['status'] == 'failed':
results['overall_status'] = 'connection_failed'
elif results['authentication_test']['status'] == 'failed':
results['overall_status'] = 'authentication_failed'
elif results['model_test']['status'] == 'failed':
results['overall_status'] = 'model_unavailable'
else:
results['overall_status'] = 'partial_failure'
return results
async def create_message(self, messages: list, **kwargs) -> Any:
"""
Create a message using the Claude API
Args:
messages: List of message dictionaries
**kwargs: Additional parameters (max_tokens, temperature, etc.)
Returns:
Claude API response
Raises:
APIError: If API call fails
"""
try:
# Use configured defaults, allow override via kwargs
api_params = {
'model': kwargs.get('model', self.model),
'max_tokens': kwargs.get('max_tokens', self.max_tokens),
'messages': messages
}
# Add temperature if specified
temperature = kwargs.get('temperature', self.temperature)
if temperature is not None:
api_params['temperature'] = temperature
# Add any other parameters passed in kwargs
for key, value in kwargs.items():
if key not in ['model', 'max_tokens', 'temperature'] and value is not None:
api_params[key] = value
self.logger.debug(f"Making Claude API call with model: {api_params['model']}")
response = await self._anthropic_client.messages.create(**api_params)
self.logger.debug("Claude API call completed successfully")
return response
except Exception as e:
error_msg = str(e)
# Provide specific error handling
if "rate_limit" in error_msg.lower():
raise APIError(
message="Claude API rate limit exceeded. Please wait before making more requests.",
api_name="claude",
cause=e
)
elif "invalid_request" in error_msg.lower():
raise APIError(
message=f"Invalid request to Claude API. Please check your parameters: {error_msg}",
api_name="claude",
cause=e
)
elif "model" in error_msg.lower() and "not found" in error_msg.lower():
raise APIError(
message=f"Model '{api_params.get('model', self.model)}' not found. Please check your model configuration.",
api_name="claude",
cause=e
)
else:
raise APIError(
message=f"Claude API call failed: {error_msg}",
api_name="claude",
cause=e
)
def get_client_info(self) -> Dict[str, Any]:
"""
Get information about the client configuration
Returns:
Dictionary with client configuration details
"""
return {
'api_url': self.base_url,
'model': self.model,
'max_tokens': self.max_tokens,
'temperature': self.temperature,
'is_custom_endpoint': self.base_url != "https://api.anthropic.com"
}
@classmethod
async def validate_configuration(cls, config: ClaudeAPIConfig, quick_test: bool = True) -> Dict[str, Any]:
"""
Class method to validate Claude API configuration without creating a persistent client
Args:
config: ClaudeAPIConfig to validate
quick_test: If True, perform only basic validation. If False, run comprehensive tests.
Returns:
Dictionary with validation results
"""
validation_results = {
'config_valid': False,
'connection_valid': False,
'model_valid': False,
'errors': [],
'warnings': [],
'config_info': {
'api_url': config.api_url,
'model': config.model,
'is_custom_endpoint': config.api_url != "https://api.anthropic.com"
}
}
try:
# Create temporary client for validation
client = cls(config)
validation_results['config_valid'] = True
if quick_test:
# Quick validation - just test connection
try:
await client.validate_connection()
validation_results['connection_valid'] = True
validation_results['model_valid'] = True # Assume model is valid if connection works
except APIError as e:
validation_results['errors'].append(str(e))
else:
# Comprehensive validation
test_results = await client.test_api_connectivity()
validation_results['connection_valid'] = test_results['connection_test']['status'] == 'success'
validation_results['model_valid'] = test_results['model_test']['status'] == 'success'
# Collect errors from detailed tests
for test_name, test_result in test_results.items():
if isinstance(test_result, dict) and test_result.get('status') == 'failed':
validation_results['errors'].append(f"{test_name}: {test_result['message']}")
# Add test results to validation results
validation_results['detailed_tests'] = test_results
# Add warnings for custom endpoints
if config.api_url != "https://api.anthropic.com":
validation_results['warnings'].append(
f"Using custom API endpoint: {config.api_url}. "
"Ensure this is a valid Claude API-compatible endpoint."
)
except Exception as e:
validation_results['errors'].append(f"Configuration validation failed: {str(e)}")
return validation_results
+14
View File
@@ -0,0 +1,14 @@
"""
Commands 模块
"""
# Handle imports with both relative and absolute paths
try:
from .organize_command import OrganizeCommand
except ImportError:
# Fallback to absolute imports when running as script
from commands.organize_command import OrganizeCommand
__all__ = [
"OrganizeCommand",
]
+422
View File
@@ -0,0 +1,422 @@
"""
日记整理命令
负责协调各个 Skill 完成日记的分析和整理
"""
from datetime import datetime
from pathlib import Path
from typing import Dict, Any, Optional, List
# Handle imports with both relative and absolute paths
try:
from ..agent_core import Command, SkillResult, CommandContext
from ..date_validation import validate_date_input
from ..input_validation import command_input_validator
from ..config_validation import ClaudeAPIConfig
from ..skills.claude_skill import ClaudeAnalyzeSkill, ClaudeTransformSkill
from ..skills.obsidian_skill import (
ObsidianReadSkill,
ObsidianWriteSkill,
ObsidianAppendSkill,
ObsidianListFilesSkill,
)
except ImportError:
# Fallback to absolute imports when running as script
from agent_core import Command, SkillResult, CommandContext
from date_validation import validate_date_input
from input_validation import command_input_validator
from config_validation import ClaudeAPIConfig
from skills.claude_skill import ClaudeAnalyzeSkill, ClaudeTransformSkill
from skills.obsidian_skill import (
ObsidianReadSkill,
ObsidianWriteSkill,
ObsidianAppendSkill,
ObsidianListFilesSkill,
)
class OrganizeCommand(Command):
"""日记整理命令"""
def __init__(self) -> None:
super().__init__(
name="organize",
description="分析和整理日记内容,提取经验和要点",
aliases=["org", "organize-journal"],
)
# 注册 Skills
self.register_skill(ObsidianReadSkill())
self.register_skill(ObsidianWriteSkill())
self.register_skill(ObsidianAppendSkill())
self.register_skill(ClaudeAnalyzeSkill())
self.register_skill(ClaudeTransformSkill())
async def execute(self, context: CommandContext) -> SkillResult:
"""
执行日记整理命令
Args:
context: 命令执行上下文
- args:
- date: 日期(格式: YYYY-MM-DD,默认今天)
- vault_path: Obsidian vault 路径
- daily_folder: 日记文件夹(默认: Daily
- config: 系统配置
Returns:
SkillResult: 执行结果
"""
try:
# Validate and sanitize input arguments
validated_args = command_input_validator.validate_organize_command_input(
context.args
)
# 获取参数
date_str: Optional[str] = validated_args.get("date")
vault_path: Optional[str] = validated_args.get("vault_path")
daily_folder: str = validated_args.get("daily_folder", "Daily")
# 从配置中获取信息
config: Dict[str, Any] = context.config or {}
obsidian_config: Dict[str, Any] = config.get("obsidian", {})
claude_config: Dict[str, Any] = config.get("claude", {})
output_config: Dict[str, Any] = config.get("output", {})
analysis_config: Dict[str, Any] = config.get("analysis", {})
# 如果没有提供日期,使用今天
if not date_str:
date_str = datetime.now().strftime("%Y-%m-%d")
else:
# Validate the provided date
validated_date = validate_date_input(
date_str,
field_name="date",
required=True,
format_hint="iso_date"
)
date_str = validated_date.strftime("%Y-%m-%d")
# 获取必要的配置
api_url: str = obsidian_config.get("rest_api", {}).get(
"url", "https://localhost:27123"
)
api_key: Optional[str] = obsidian_config.get("rest_api", {}).get("api_key")
# Create enhanced Claude API configuration
try:
claude_api_config = ClaudeAPIConfig(**claude_config)
except Exception as e:
return SkillResult(
success=False,
error=f"Claude API 配置无效: {str(e)}",
message="请检查 Claude API 配置",
)
if not api_key or not claude_api_config.api_key:
return SkillResult(
success=False,
error="缺少必要的配置",
message="请配置 Obsidian API 密钥和 Claude API 密钥",
)
self.logger.info(f"开始整理日期 {date_str} 的日记")
# 步骤 1: 读取日记
daily_note_path = str(Path(daily_folder) / f"{date_str}.md")
self.logger.info(f"步骤 1: 读取日记 {daily_note_path}")
read_skill = self.skills["obsidian_read"]
read_result: SkillResult = await read_skill.execute(
context,
file_path=daily_note_path,
vault_path=vault_path,
api_url=api_url,
api_key=api_key,
)
if not read_result.success:
return read_result
journal_content: str = read_result.data["content"]
# 步骤 2: 使用 Claude 分析日记
self.logger.info("步骤 2: 使用 Claude 分析日记内容")
analyze_skill = self.skills["claude_analyze"]
categories: List[str] = analysis_config.get("categories", [])
analyze_result: SkillResult = await analyze_skill.execute(
context,
journal_content=journal_content,
claude_config=claude_api_config,
categories=categories,
)
if not analyze_result.success:
return analyze_result
analysis_data: Dict[str, Any] = analyze_result.data["analysis"]
# 步骤 3: 整理分析结果到各个位置
self.logger.info("步骤 3: 整理分析结果")
write_results: Dict[str, SkillResult] = {}
write_skill = self.skills["obsidian_write"]
# 处理经验
if "experiences" in analysis_data and analysis_data["experiences"]:
experiences_content: str = self._format_experiences(
analysis_data["experiences"], date_str
)
experiences_path = str(
Path(
output_config.get("experiences_folder", "Knowledge/Experiences")
)
/ f"{date_str}.md"
)
exp_result: SkillResult = await write_skill.execute(
context,
file_path=experiences_path,
content=experiences_content,
api_url=api_url,
api_key=api_key,
overwrite=True,
)
write_results["experiences"] = exp_result
# 处理经验教训
if "lessons_learned" in analysis_data and analysis_data["lessons_learned"]:
lessons_content: str = self._format_lessons(
analysis_data["lessons_learned"], date_str
)
lessons_path = str(
Path(output_config.get("lessons_folder", "Knowledge/Lessons"))
/ f"{date_str}.md"
)
lessons_result: SkillResult = await write_skill.execute(
context,
file_path=lessons_path,
content=lessons_content,
api_url=api_url,
api_key=api_key,
overwrite=True,
)
write_results["lessons"] = lessons_result
# 处理待办事项
if "action_items" in analysis_data and analysis_data["action_items"]:
tasks_content: str = self._format_tasks(
analysis_data["action_items"], date_str
)
tasks_path = str(
Path(output_config.get("tasks_folder", "Tasks/Daily"))
/ f"{date_str}.md"
)
tasks_result: SkillResult = await write_skill.execute(
context,
file_path=tasks_path,
content=tasks_content,
api_url=api_url,
api_key=api_key,
overwrite=True,
)
write_results["tasks"] = tasks_result
# 处理问题
if "problems" in analysis_data and analysis_data["problems"]:
problems_content: str = self._format_problems(
analysis_data["problems"], date_str
)
problems_path = str(
Path(output_config.get("problems_folder", "Knowledge/Problems"))
/ f"{date_str}.md"
)
problems_result: SkillResult = await write_skill.execute(
context,
file_path=problems_path,
content=problems_content,
api_url=api_url,
api_key=api_key,
overwrite=True,
)
write_results["problems"] = problems_result
# 处理成就
if "achievements" in analysis_data and analysis_data["achievements"]:
achievements_content: str = self._format_achievements(
analysis_data["achievements"], date_str
)
achievements_path = str(
Path(
output_config.get(
"achievements_folder", "Knowledge/Achievements"
)
)
/ f"{date_str}.md"
)
achievements_result: SkillResult = await write_skill.execute(
context,
file_path=achievements_path,
content=achievements_content,
api_url=api_url,
api_key=api_key,
overwrite=True,
)
write_results["achievements"] = achievements_result
# 处理改进建议
if "improvements" in analysis_data and analysis_data["improvements"]:
improvements_content: str = self._format_improvements(
analysis_data["improvements"], date_str
)
improvements_path = str(
Path(
output_config.get(
"improvements_folder", "Knowledge/Improvements"
)
)
/ f"{date_str}.md"
)
improvements_result: SkillResult = await write_skill.execute(
context,
file_path=improvements_path,
content=improvements_content,
api_url=api_url,
api_key=api_key,
overwrite=True,
)
write_results["improvements"] = improvements_result
# 统计结果
successful_writes: int = sum(1 for r in write_results.values() if r.success)
return SkillResult(
success=True,
data={
"date": date_str,
"journal_file": daily_note_path,
"analysis": analysis_data,
"write_results": {k: v.success for k, v in write_results.items()},
"successful_writes": successful_writes,
"total_writes": len(write_results),
"organized_at": datetime.now().isoformat(),
},
message=f"成功整理日记 {date_str},已生成 {successful_writes} 个文件",
)
except Exception as e:
self.logger.error(f"执行日记整理命令时出错: {str(e)}")
return SkillResult(success=False, error=str(e), message="日记整理异常")
def _format_experiences(
self, experiences: List[Dict[str, Any]], date_str: str
) -> str:
"""格式化经验内容"""
content: str = f"# 经验总结 - {date_str}\n\n"
content += f"*生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*\n\n"
for i, exp in enumerate(experiences, 1):
content += f"## {i}. {exp.get('title', '经验')}\n\n"
content += f"**分类**: {exp.get('category', '未分类')}\n"
content += f"**优先级**: {exp.get('priority', 'medium')}\n\n"
content += f"{exp.get('content', '')}\n\n"
content += f"\n---\n*来源: [[{date_str}]]*\n"
return content
def _format_lessons(self, lessons: List[Dict[str, Any]], date_str: str) -> str:
"""格式化经验教训内容"""
content: str = f"# 经验教训 - {date_str}\n\n"
content += f"*生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*\n\n"
for i, lesson in enumerate(lessons, 1):
content += f"## {i}. {lesson.get('lesson', '教训')}\n\n"
content += f"**背景**: {lesson.get('context', '')}\n\n"
content += f"**应用**: {lesson.get('application', '')}\n\n"
content += f"\n---\n*来源: [[{date_str}]]*\n"
return content
def _format_tasks(self, tasks: List[Dict[str, Any]], date_str: str) -> str:
"""格式化待办事项内容"""
content: str = f"# 待办事项 - {date_str}\n\n"
content += f"*生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*\n\n"
# 按优先级分组
by_priority: Dict[str, List[Dict[str, Any]]] = {
"high": [],
"medium": [],
"low": [],
}
for task in tasks:
priority: str = task.get("priority", "medium")
by_priority[priority].append(task)
for priority in ["high", "medium", "low"]:
if by_priority[priority]:
priority_text: Dict[str, str] = {
"high": "🔴 高",
"medium": "",
r"low": "🟢 低",
}
content += f"## {priority_text[priority]} 优先级\n\n"
for task in by_priority[priority]:
content += f"- [ ] {task.get('task', '任务')}\n"
if task.get("deadline"):
content += f" - 截止: {task.get('deadline')}\n"
content += "\n"
content += f"\n---\n*来源: [[{date_str}]]*\n"
return content
def _format_problems(self, problems: List[Dict[str, Any]], date_str: str) -> str:
"""格式化问题内容"""
content: str = f"# 问题记录 - {date_str}\n\n"
content += f"*生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*\n\n"
for i, problem in enumerate(problems, 1):
content += f"## {i}. {problem.get('problem', '问题')}\n\n"
content += f"**影响**: {problem.get('impact', '')}\n\n"
content += f"**建议方案**: {problem.get('proposed_solution', '')}\n\n"
content += f"\n---\n*来源: [[{date_str}]]*\n"
return content
def _format_achievements(
self, achievements: List[Dict[str, Any]], date_str: str
) -> str:
"""格式化成就内容"""
content: str = f"# 成就记录 - {date_str}\n\n"
content += f"*生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*\n\n"
for i, achievement in enumerate(achievements, 1):
content += f"## {i}. {achievement.get('achievement', '成就')}\n\n"
content += f"**重要性**: {achievement.get('significance', '')}\n\n"
content += f"**证据**: {achievement.get('evidence', '')}\n\n"
content += f"\n---\n*来源: [[{date_str}]]*\n"
return content
def _format_improvements(
self, improvements: List[Dict[str, Any]], date_str: str
) -> str:
"""格式化改进建议内容"""
content: str = f"# 改进建议 - {date_str}\n\n"
content += f"*生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*\n\n"
for i, improvement in enumerate(improvements, 1):
content += f"## {i}. {improvement.get('area', '改进领域')}\n\n"
content += f"**当前状态**: {improvement.get('current_state', '')}\n\n"
content += f"**建议改进**: {improvement.get('suggested_change', '')}\n\n"
content += f"**预期收益**: {improvement.get('expected_benefit', '')}\n\n"
content += f"\n---\n*来源: [[{date_str}]]*\n"
return content
+136
View File
@@ -0,0 +1,136 @@
# Obsidian 智能日记整理 Agent 配置文件示例
# 将此文件复制为 config.yaml 并根据您的环境进行配置
# 环境变量使用说明:
# 本配置文件支持环境变量替换,格式如下:
# - ${VAR}: 必需的环境变量,如果未设置会报错
# - ${VAR:-default}: 可选的环境变量,未设置时使用默认值
# - ${VAR:?error_message}: 必需的环境变量,未设置时显示自定义错误信息
#
# 推荐的环境变量设置:
# export ANTHROPIC_API_KEY="sk-ant-your-api-key-here"
# export OBSIDIAN_API_KEY="your-obsidian-api-key"
# export OBSIDIAN_VAULT_PATH="/path/to/your/vault"
# export CLAUDE_API_URL="https://api.anthropic.com" # 可选
# export CLAUDE_MODEL="claude-3-5-sonnet-20241022" # 可选
# Obsidian 配置
obsidian:
# Obsidian vault 的路径
# 可以使用环境变量:
vault_path: "${OBSIDIAN_VAULT_PATH:-/path/to/your/obsidian/vault}"
# Local REST API 插件配置
rest_api:
# API 服务器地址
url: "${OBSIDIAN_API_URL:-https://localhost:27123}"
# API 密钥(从 Obsidian 插件设置中获取)
# 强烈推荐使用环境变量:
api_key: "${OBSIDIAN_API_KEY}"
# 是否验证 SSL 证书(本地开发通常设为 false)
verify_ssl: false
# Claude API 配置
claude:
# Claude API 密钥(从环境变量或直接配置)
# 推荐使用环境变量以保护敏感信息
api_key: "${ANTHROPIC_API_KEY:?请设置 ANTHROPIC_API_KEY 环境变量}"
# Claude API 基础 URL(可选,默认为官方 API)
# 支持自定义 API 端点,包括代理服务器、区域端点或自定义部署
api_url: "${CLAUDE_API_URL:-https://api.anthropic.com}"
# 常见配置示例:
# 官方 API(默认):
# api_url: "https://api.anthropic.com"
# 代理服务器配置:
# api_url: "https://your-proxy.example.com"
# api_url: "https://claude-proxy.internal:8080"
# 本地开发环境:
# api_url: "http://localhost:3128"
# api_url: "https://localhost:8080"
# 区域端点(如果可用):
# api_url: "https://api-eu.anthropic.com"
# api_url: "https://api-asia.anthropic.com"
# 使用的模型
# 支持环境变量配置:
model: "${CLAUDE_MODEL:-claude-3-5-sonnet-20241022}"
# 最大 token 数(1-200000
# 建议值:
# - 4096: 适合大多数日记分析任务
# - 8192: 适合长文档分析
# - 16384: 适合复杂分析任务
max_tokens: 4096
# 温度参数(0.0-1.0,控制输出随机性)
# - 0.0: 最确定的输出
# - 0.7: 平衡创造性和一致性(推荐)
# - 1.0: 最有创造性的输出
temperature: 0.7
# 日记配置
journal:
# 日记文件夹名称
daily_notes_folder: "Daily"
# 日期格式
date_format: "YYYY-MM-DD"
# 文件扩展名
file_extension: ".md"
# 输出配置(整理后的内容放置位置)
output:
# 经验总结文件夹
experiences_folder: "Knowledge/Experiences"
# 经验教训文件夹
lessons_folder: "Knowledge/Lessons"
# 待办事项文件夹
tasks_folder: "Tasks/Daily"
# 问题记录文件夹
problems_folder: "Knowledge/Problems"
# 成就记录文件夹
achievements_folder: "Knowledge/Achievements"
# 改进建议文件夹
improvements_folder: "Knowledge/Improvements"
# 分析配置
analysis:
# 分类类别
categories:
- "技术学习"
- "项目管理"
- "个人成长"
- "团队协作"
- "问题解决"
# 提取规则(定义哪些内容需要提取)
extraction_rules:
experiences:
enabled: true
description: "提取日记中的重要经验和见解"
lessons:
enabled: true
description: "提取学到的知识点和最佳实践"
action_items:
enabled: true
description: "提取需要采取行动的任务"
problems:
enabled: true
description: "提取遇到的问题和挑战"
achievements:
enabled: true
description: "提取完成的成就和进展"
improvements:
enabled: true
description: "提取改进建议和优化方向"
# 日志配置
logging:
# 日志级别 (DEBUG, INFO, WARNING, ERROR, CRITICAL)
level: "INFO"
# 日志文件路径
file: "logs/journal_organizer.log"
+333
View File
@@ -0,0 +1,333 @@
"""
配置管理模块
负责加载和管理系统的所有配置参数
Enhanced with Pydantic validation and comprehensive error handling
"""
import json
import logging
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, Any, Optional, List
from .dependency_manager import get_dependency_manager
# Import the new validation framework
from .config_validation import (
SystemConfig, ConfigurationValidator,
ObsidianConfig as PydanticObsidianConfig,
ClaudeAPIConfig as PydanticClaudeAPIConfig,
JournalConfig as PydanticJournalConfig,
OutputConfig as PydanticOutputConfig,
AnalysisConfig as PydanticAnalysisConfig,
LoggingConfig as PydanticLoggingConfig
)
# Try to import yaml with graceful degradation
dependency_manager = get_dependency_manager()
yaml = dependency_manager.get_module('yaml')
@dataclass
class ObsidianConfig:
"""Obsidian 配置"""
vault_path: str
rest_api_url: str
rest_api_key: str
verify_ssl: bool = False
def __post_init__(self) -> None:
"""Validate configuration after initialization"""
if not self.vault_path or not self.vault_path.strip():
raise ValueError("vault_path cannot be empty")
if not self.rest_api_key or not self.rest_api_key.strip():
raise ValueError("rest_api_key cannot be empty")
if not self.rest_api_url or not self.rest_api_url.strip():
raise ValueError("rest_api_url cannot be empty")
if not self.rest_api_url.startswith(("http://", "https://")):
raise ValueError("rest_api_url must start with http:// or https://")
@dataclass
class ClaudeConfig:
"""Claude API 配置"""
api_key: str
model: str = "claude-3-5-sonnet-20241022"
api_url: str = "https://api.anthropic.com"
max_tokens: int = 4096
temperature: float = 0.7
def __post_init__(self) -> None:
"""Validate configuration after initialization"""
if not self.api_key or not self.api_key.strip():
raise ValueError("api_key cannot be empty")
if self.max_tokens <= 0:
raise ValueError("max_tokens must be positive")
if not 0.0 <= self.temperature <= 2.0:
raise ValueError("temperature must be between 0.0 and 2.0")
if not self.api_url or not self.api_url.strip():
raise ValueError("api_url cannot be empty")
if not self.api_url.startswith(("http://", "https://")):
raise ValueError("api_url must start with http:// or https://")
@dataclass
class JournalConfig:
"""日记配置"""
daily_notes_folder: str
date_format: str = "YYYY-MM-DD"
file_extension: str = ".md"
def __post_init__(self) -> None:
"""Validate configuration after initialization"""
if not self.daily_notes_folder or not self.daily_notes_folder.strip():
raise ValueError("daily_notes_folder cannot be empty")
if not self.file_extension.startswith("."):
raise ValueError("file_extension must start with a dot")
@dataclass
class OutputConfig:
"""输出配置"""
experiences_folder: str
lessons_folder: str
tasks_folder: str
problems_folder: str
achievements_folder: str
improvements_folder: str
def __post_init__(self) -> None:
"""Validate configuration after initialization"""
folders = [
self.experiences_folder,
self.lessons_folder,
self.tasks_folder,
self.problems_folder,
self.achievements_folder,
self.improvements_folder,
]
for folder in folders:
if not folder or not folder.strip():
raise ValueError("All folder paths must be non-empty")
@dataclass
class AnalysisConfig:
"""分析配置"""
categories: List[str] = field(default_factory=list)
extraction_rules: Dict[str, Dict[str, Any]] = field(default_factory=dict)
def __post_init__(self) -> None:
"""Validate configuration after initialization"""
if not isinstance(self.categories, list):
raise ValueError("categories must be a list")
if not isinstance(self.extraction_rules, dict):
raise ValueError("extraction_rules must be a dictionary")
@dataclass
class LoggingConfig:
"""日志配置"""
level: str = "INFO"
file: str = "logs/journal_organizer.log"
def __post_init__(self) -> None:
"""Validate configuration after initialization"""
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
if self.level not in valid_levels:
raise ValueError(f"level must be one of {valid_levels}")
if not self.file or not self.file.strip():
raise ValueError("file path cannot be empty")
class Config:
"""系统配置管理器 - Enhanced with Pydantic validation"""
def __init__(self, config_file: Optional[str] = None):
"""
初始化配置
Args:
config_file: 配置文件路径,如果为 None 则使用默认位置
"""
self.config_file = config_file or self._get_default_config_path()
self.validator = ConfigurationValidator()
self.logger = logging.getLogger(__name__)
# Load and validate configuration using Pydantic
try:
self.system_config = self.validator.load_and_validate_config(self.config_file)
except (FileNotFoundError, ValueError) as e:
self.logger.error(f"Configuration error: {e}")
raise
# Create legacy-compatible attributes
self._create_legacy_attributes()
# Perform additional validations
self._perform_additional_validations()
def _create_legacy_attributes(self) -> None:
"""Create legacy-compatible attributes from Pydantic models"""
# Convert Pydantic models to legacy dataclass format for backward compatibility
self.obsidian = ObsidianConfig(
vault_path=self.system_config.obsidian.vault_path,
rest_api_url=self.system_config.obsidian.rest_api.url,
rest_api_key=self.system_config.obsidian.rest_api.api_key,
verify_ssl=self.system_config.obsidian.rest_api.verify_ssl
)
self.claude = ClaudeConfig(
api_key=self.system_config.claude.api_key,
model=self.system_config.claude.model,
api_url=self.system_config.claude.api_url,
max_tokens=self.system_config.claude.max_tokens,
temperature=self.system_config.claude.temperature
)
self.journal = JournalConfig(
daily_notes_folder=self.system_config.journal.daily_notes_folder,
date_format=self.system_config.journal.date_format,
file_extension=self.system_config.journal.file_extension
)
self.output = OutputConfig(
experiences_folder=self.system_config.output.experiences_folder,
lessons_folder=self.system_config.output.lessons_folder,
tasks_folder=self.system_config.output.tasks_folder,
problems_folder=self.system_config.output.problems_folder,
achievements_folder=self.system_config.output.achievements_folder,
improvements_folder=self.system_config.output.improvements_folder
)
self.analysis = AnalysisConfig(
categories=self.system_config.analysis.categories,
extraction_rules=self.system_config.analysis.extraction_rules
)
self.logging = LoggingConfig(
level=self.system_config.logging.level,
file=self.system_config.logging.file
)
def _perform_additional_validations(self) -> None:
"""Perform additional validations and provide helpful guidance"""
# Validate API keys and provide setup instructions
api_key_issues = self.validator.validate_api_keys(self.system_config)
if api_key_issues:
error_msg = "API Key Configuration Issues:\n" + "\n\n".join(api_key_issues)
self.logger.error(error_msg)
raise ValueError(error_msg)
# Validate file paths
path_issues = self.validator.validate_file_paths(self.system_config)
if path_issues:
error_msg = "File Path Issues:\n" + "\n".join(path_issues)
self.logger.warning(error_msg)
# Don't raise error for path issues, just warn
def get_environment_variable_help(self) -> str:
"""Get help for setting up environment variables"""
return self.validator.get_environment_variable_help()
def get_path_setup_help(self) -> str:
"""Get help for setting up file paths"""
return self.validator.get_path_setup_help()
def _get_default_config_path(self) -> str:
"""获取默认配置文件路径"""
# First check current directory
current_dir_config = Path("config.yaml")
if current_dir_config.exists():
return str(current_dir_config)
# Then check user home directory
config_dir = Path.home() / ".journal_organizer"
config_dir.mkdir(exist_ok=True)
return str(config_dir / "config.yaml")
def get_validation_errors(self) -> List[str]:
"""Get any validation errors or warnings"""
errors = []
try:
# Re-validate to get current status
api_key_issues = self.validator.validate_api_keys(self.system_config)
errors.extend(api_key_issues)
path_issues = self.validator.validate_file_paths(self.system_config)
errors.extend(path_issues)
except Exception as e:
errors.append(f"Validation error: {e}")
return errors
def reload_config(self) -> None:
"""Reload configuration from file"""
try:
self.system_config = self.validator.load_and_validate_config(self.config_file)
self._create_legacy_attributes()
self._perform_additional_validations()
self.logger.info("Configuration reloaded successfully")
except Exception as e:
self.logger.error(f"Failed to reload configuration: {e}")
raise
def get_daily_note_path(self, date_str: str) -> str:
"""
获取指定日期的日记文件路径
Args:
date_str: 日期字符串,格式应与配置中的 date_format 一致
Returns:
日记文件的相对路径
"""
daily_path = (
Path(self.journal.daily_notes_folder)
/ f"{date_str}{self.journal.file_extension}"
)
return str(daily_path)
def to_dict(self) -> Dict[str, Any]:
"""转换为字典(不包含敏感信息)"""
return {
"obsidian": {
"vault_path": self.obsidian.vault_path,
"rest_api_url": self.obsidian.rest_api_url,
},
"claude": {
"model": self.claude.model,
"api_url": self.claude.api_url,
"max_tokens": self.claude.max_tokens,
"temperature": self.claude.temperature,
},
"journal": {
"daily_notes_folder": self.journal.daily_notes_folder,
"date_format": self.journal.date_format,
"file_extension": self.journal.file_extension,
},
"output": {
"experiences_folder": self.output.experiences_folder,
"lessons_folder": self.output.lessons_folder,
"tasks_folder": self.output.tasks_folder,
"problems_folder": self.output.problems_folder,
"achievements_folder": self.output.achievements_folder,
"improvements_folder": self.output.improvements_folder,
},
"analysis": {
"categories": self.analysis.categories,
},
"logging": {
"level": self.logging.level,
"file": self.logging.file,
},
}
+1316
View File
File diff suppressed because it is too large Load Diff
+268
View File
@@ -0,0 +1,268 @@
"""
Enhanced configuration loader with comprehensive environment variable expansion
Implements ${VAR} and ${VAR:-default} pattern support with recursive expansion
"""
import os
import re
from typing import Any, Dict, List, Union
from pathlib import Path
try:
from .dependency_manager import get_dependency_manager
except ImportError:
from dependency_manager import get_dependency_manager
# Try to import dependencies with graceful degradation
dependency_manager = get_dependency_manager()
yaml = dependency_manager.get_module('yaml')
class ConfigurationError(Exception):
"""Base exception for configuration-related errors"""
pass
class EnvironmentVariableError(ConfigurationError):
"""Exception for environment variable expansion errors"""
pass
class ConfigurationLoader:
"""Enhanced configuration loader with environment variable expansion"""
def __init__(self):
# Pattern to match ${VAR} and ${VAR:-default} syntax
self.env_var_pattern = re.compile(r'\$\{([^}]+)\}')
def expand_environment_variables(self, config_dict: Dict[str, Any]) -> Dict[str, Any]:
"""
Recursively expand environment variables in configuration
Supports patterns:
- ${VAR}: Required variable, raises error if not set
- ${VAR:-default}: Variable with default value
- ${VAR:?error_message}: Required variable with custom error message
Args:
config_dict: Configuration dictionary to expand
Returns:
Configuration dictionary with expanded environment variables
Raises:
EnvironmentVariableError: If required environment variable is missing
"""
return self._expand_value(config_dict)
def _expand_value(self, value: Any) -> Any:
"""Recursively expand environment variables in any value type"""
if isinstance(value, str):
return self._expand_string_env_vars(value)
elif isinstance(value, dict):
return {k: self._expand_value(v) for k, v in value.items()}
elif isinstance(value, list):
return [self._expand_value(item) for item in value]
else:
return value
def _expand_string_env_vars(self, text: str) -> str:
"""
Expand environment variables in a string with enhanced support
Supports multiple expansion patterns:
- ${VAR}: Required variable
- ${VAR:-default}: Variable with default value
- ${VAR:?error_message}: Required variable with custom error message
Args:
text: String potentially containing environment variable references
Returns:
String with environment variables expanded
Raises:
EnvironmentVariableError: If required environment variable is missing
"""
def replace_env_var(match):
var_expr = match.group(1)
# Handle ${VAR:-default} pattern
if ':-' in var_expr:
var_name, default_value = var_expr.split(':-', 1)
var_name = var_name.strip()
env_value = os.getenv(var_name)
return env_value if env_value is not None else default_value
# Handle ${VAR:?error_message} pattern
elif ':?' in var_expr:
var_name, error_msg = var_expr.split(':?', 1)
var_name = var_name.strip()
env_value = os.getenv(var_name)
if env_value is None:
raise EnvironmentVariableError(
f"Environment variable '{var_name}' is required: {error_msg}"
)
return env_value
# Handle simple ${VAR} pattern
else:
var_name = var_expr.strip()
env_value = os.getenv(var_name)
if env_value is None:
raise EnvironmentVariableError(
f"Environment variable '{var_name}' is not set. "
f"Please set it or provide a default value using ${{{var_name}:-default}}"
)
return env_value
return self.env_var_pattern.sub(replace_env_var, text)
@classmethod
def load_config(cls, config_path: Union[str, Path]) -> Dict[str, Any]:
"""
Load and validate configuration with environment variable expansion
Args:
config_path: Path to configuration file (YAML or JSON)
Returns:
Configuration dictionary with expanded environment variables
Raises:
ConfigurationError: If configuration file cannot be loaded or parsed
EnvironmentVariableError: If required environment variables are missing
"""
loader = cls()
config_path = Path(config_path)
if not config_path.exists():
raise ConfigurationError(f"Configuration file not found: {config_path}")
# Load raw configuration
try:
with config_path.open('r', encoding='utf-8') as f:
if config_path.suffix.lower() in ['.yaml', '.yml']:
if yaml is None:
raise ConfigurationError(
'YAML configuration files require PyYAML library.\n'
'Please install it with: pip install pyyaml>=6.0'
)
config_dict = yaml.safe_load(f) or {}
elif config_path.suffix.lower() == '.json':
import json
config_dict = json.load(f)
else:
raise ConfigurationError(
'Configuration file must be YAML (.yaml/.yml) or JSON (.json)'
)
except Exception as e:
if isinstance(e, ConfigurationError):
raise
elif yaml is None and 'yaml' in str(e).lower():
raise ConfigurationError(
'YAML parsing failed - PyYAML library not available.\n'
'Please install it with: pip install pyyaml>=6.0\n'
f'Original error: {e}'
)
else:
raise ConfigurationError(f"Failed to parse configuration file: {e}")
# Expand environment variables
try:
expanded_config = loader.expand_environment_variables(config_dict)
return expanded_config
except EnvironmentVariableError as e:
raise EnvironmentVariableError(f"Environment variable expansion failed: {e}")
def validate_environment_variables(self, config_dict: Dict[str, Any]) -> List[str]:
"""
Validate that all required environment variables are available
Args:
config_dict: Configuration dictionary to validate
Returns:
List of missing environment variable error messages
"""
missing_vars = []
def check_env_vars_in_value(value: Any, path: str = "") -> None:
if isinstance(value, str):
# Find all environment variable references
matches = self.env_var_pattern.findall(value)
for match in matches:
var_expr = match
# Skip variables with default values
if ':-' in var_expr:
continue
# Handle custom error message pattern
if ':?' in var_expr:
var_name = var_expr.split(':?')[0].strip()
else:
var_name = var_expr.strip()
if not os.getenv(var_name):
location = f" at {path}" if path else ""
missing_vars.append(f"Environment variable '{var_name}'{location} is not set")
elif isinstance(value, dict):
for key, val in value.items():
new_path = f"{path}.{key}" if path else key
check_env_vars_in_value(val, new_path)
elif isinstance(value, list):
for i, item in enumerate(value):
new_path = f"{path}[{i}]" if path else f"[{i}]"
check_env_vars_in_value(item, new_path)
check_env_vars_in_value(config_dict)
return missing_vars
def get_environment_variable_references(self, config_dict: Dict[str, Any]) -> Dict[str, List[str]]:
"""
Get all environment variable references in the configuration
Args:
config_dict: Configuration dictionary to analyze
Returns:
Dictionary mapping variable names to list of locations where they're used
"""
env_vars = {}
def collect_env_vars_in_value(value: Any, path: str = "") -> None:
if isinstance(value, str):
matches = self.env_var_pattern.findall(value)
for match in matches:
var_expr = match
# Extract variable name (handle default value and error message patterns)
if ':-' in var_expr:
var_name = var_expr.split(':-')[0].strip()
elif ':?' in var_expr:
var_name = var_expr.split(':?')[0].strip()
else:
var_name = var_expr.strip()
if var_name not in env_vars:
env_vars[var_name] = []
location = path if path else "root"
if location not in env_vars[var_name]:
env_vars[var_name].append(location)
elif isinstance(value, dict):
for key, val in value.items():
new_path = f"{path}.{key}" if path else key
collect_env_vars_in_value(val, new_path)
elif isinstance(value, list):
for i, item in enumerate(value):
new_path = f"{path}[{i}]" if path else f"[{i}]"
collect_env_vars_in_value(item, new_path)
collect_env_vars_in_value(config_dict)
return env_vars
+349
View File
@@ -0,0 +1,349 @@
"""
Configuration migration utility for backward compatibility
Handles migration from legacy configuration formats to new enhanced format
"""
import logging
from typing import Dict, Any, List, Optional
from pathlib import Path
class ConfigurationMigrator:
"""Handle migration from legacy configuration formats"""
def __init__(self):
self.logger = logging.getLogger(__name__)
# Model name migrations mapping old names to new names
self.model_migrations = {
# Legacy model names to current format
"claude-3-sonnet": "claude-3-sonnet-20240229",
"claude-3-opus": "claude-3-opus-20240229",
"claude-3-haiku": "claude-3-haiku-20240307",
"claude-3.5-sonnet": "claude-3-5-sonnet-20241022",
"claude-3.5-haiku": "claude-3-5-haiku-20241022",
# Handle common variations
"claude-sonnet": "claude-3-sonnet-20240229",
"claude-opus": "claude-3-opus-20240229",
"claude-haiku": "claude-3-haiku-20240307",
"sonnet": "claude-3-5-sonnet-20241022",
"opus": "claude-3-opus-20240229",
"haiku": "claude-3-haiku-20240307",
}
# Default values for new fields
self.default_values = {
'claude': {
'api_url': "https://api.anthropic.com",
'model': "claude-3-5-sonnet-20241022",
'max_tokens': 4096,
'temperature': 0.7
},
'journal': {
'daily_notes_folder': "Daily",
'date_format': "YYYY-MM-DD",
'file_extension': ".md"
},
'output': {
'experiences_folder': "Knowledge/Experiences",
'lessons_folder': "Knowledge/Lessons",
'tasks_folder': "Tasks/Daily",
'problems_folder': "Knowledge/Problems",
'achievements_folder': "Knowledge/Achievements",
'improvements_folder': "Knowledge/Improvements"
},
'analysis': {
'categories': [],
'extraction_rules': {}
},
'logging': {
'level': "INFO",
'file': "logs/journal_organizer.log"
}
}
def migrate_claude_config(self, config_dict: Dict[str, Any]) -> Dict[str, Any]:
"""
Migrate legacy Claude configuration to new format
Args:
config_dict: Configuration dictionary to migrate
Returns:
Migrated configuration dictionary
"""
# Create a copy to avoid modifying the original
migrated_config = config_dict.copy()
# Ensure claude section exists
if 'claude' not in migrated_config:
migrated_config['claude'] = {}
self.logger.info("Created missing 'claude' configuration section")
claude_config = migrated_config['claude']
migration_actions = []
# Add default api_url if not present
if 'api_url' not in claude_config:
claude_config['api_url'] = self.default_values['claude']['api_url']
migration_actions.append(f"Added default api_url: {claude_config['api_url']}")
# Ensure model has a default value
if 'model' not in claude_config:
claude_config['model'] = self.default_values['claude']['model']
migration_actions.append(f"Added default model: {claude_config['model']}")
else:
# Migrate old model names to new format if needed
old_model = claude_config['model']
if old_model in self.model_migrations:
new_model = self.model_migrations[old_model]
claude_config['model'] = new_model
migration_actions.append(f"Migrated model '{old_model}' to '{new_model}'")
# Add other default values if missing
if 'max_tokens' not in claude_config:
claude_config['max_tokens'] = self.default_values['claude']['max_tokens']
migration_actions.append(f"Added default max_tokens: {claude_config['max_tokens']}")
if 'temperature' not in claude_config:
claude_config['temperature'] = self.default_values['claude']['temperature']
migration_actions.append(f"Added default temperature: {claude_config['temperature']}")
# Log migration actions
if migration_actions:
self.logger.info("Claude configuration migration completed:")
for action in migration_actions:
self.logger.info(f" - {action}")
return migrated_config
def migrate_configuration(self, config_dict: Dict[str, Any]) -> Dict[str, Any]:
"""
Migrate complete configuration from legacy format to new format
Args:
config_dict: Configuration dictionary to migrate
Returns:
Migrated configuration dictionary
"""
migrated_config = config_dict.copy()
all_migration_actions = []
# Migrate Claude configuration
migrated_config = self.migrate_claude_config(migrated_config)
# Migrate other sections if needed
migrated_config, journal_actions = self._migrate_journal_config(migrated_config)
all_migration_actions.extend(journal_actions)
migrated_config, output_actions = self._migrate_output_config(migrated_config)
all_migration_actions.extend(output_actions)
migrated_config, analysis_actions = self._migrate_analysis_config(migrated_config)
all_migration_actions.extend(analysis_actions)
migrated_config, logging_actions = self._migrate_logging_config(migrated_config)
all_migration_actions.extend(logging_actions)
# Log overall migration summary
if all_migration_actions:
self.logger.info(f"Configuration migration completed with {len(all_migration_actions)} changes")
self._log_migration_summary(all_migration_actions)
else:
self.logger.debug("No configuration migration needed - all fields are up to date")
return migrated_config
def _migrate_journal_config(self, config_dict: Dict[str, Any]) -> tuple[Dict[str, Any], List[str]]:
"""Migrate journal configuration section"""
migrated_config = config_dict.copy()
actions = []
if 'journal' not in migrated_config:
migrated_config['journal'] = self.default_values['journal'].copy()
actions.append("Created missing 'journal' configuration section with defaults")
else:
journal_config = migrated_config['journal']
# Add missing fields with defaults
for field, default_value in self.default_values['journal'].items():
if field not in journal_config:
journal_config[field] = default_value
actions.append(f"Added default journal.{field}: {default_value}")
return migrated_config, actions
def _migrate_output_config(self, config_dict: Dict[str, Any]) -> tuple[Dict[str, Any], List[str]]:
"""Migrate output configuration section"""
migrated_config = config_dict.copy()
actions = []
if 'output' not in migrated_config:
migrated_config['output'] = self.default_values['output'].copy()
actions.append("Created missing 'output' configuration section with defaults")
else:
output_config = migrated_config['output']
# Add missing fields with defaults
for field, default_value in self.default_values['output'].items():
if field not in output_config:
output_config[field] = default_value
actions.append(f"Added default output.{field}: {default_value}")
return migrated_config, actions
def _migrate_analysis_config(self, config_dict: Dict[str, Any]) -> tuple[Dict[str, Any], List[str]]:
"""Migrate analysis configuration section"""
migrated_config = config_dict.copy()
actions = []
if 'analysis' not in migrated_config:
migrated_config['analysis'] = self.default_values['analysis'].copy()
actions.append("Created missing 'analysis' configuration section with defaults")
else:
analysis_config = migrated_config['analysis']
# Add missing fields with defaults
for field, default_value in self.default_values['analysis'].items():
if field not in analysis_config:
analysis_config[field] = default_value
actions.append(f"Added default analysis.{field}: {default_value}")
return migrated_config, actions
def _migrate_logging_config(self, config_dict: Dict[str, Any]) -> tuple[Dict[str, Any], List[str]]:
"""Migrate logging configuration section"""
migrated_config = config_dict.copy()
actions = []
if 'logging' not in migrated_config:
migrated_config['logging'] = self.default_values['logging'].copy()
actions.append("Created missing 'logging' configuration section with defaults")
else:
logging_config = migrated_config['logging']
# Add missing fields with defaults
for field, default_value in self.default_values['logging'].items():
if field not in logging_config:
logging_config[field] = default_value
actions.append(f"Added default logging.{field}: {default_value}")
return migrated_config, actions
def _log_migration_summary(self, actions: List[str]) -> None:
"""Log a summary of migration actions taken"""
self.logger.info("Migration summary:")
for action in actions:
self.logger.info(f" - {action}")
# Provide helpful information about new features
self.logger.info("")
self.logger.info("New configuration options are now available:")
self.logger.info(" - claude.api_url: Configure custom Claude API endpoints")
self.logger.info(" - claude.model: Enhanced model validation with suggestions")
self.logger.info(" - Environment variable support: Use ${VAR} and ${VAR:-default} patterns")
self.logger.info(" - See config.example.yaml for complete configuration examples")
def check_migration_needed(self, config_dict: Dict[str, Any]) -> bool:
"""
Check if configuration needs migration
Args:
config_dict: Configuration dictionary to check
Returns:
True if migration is needed, False otherwise
"""
# Check if Claude section needs migration
claude_config = config_dict.get('claude', {})
# Check for missing new fields
if 'api_url' not in claude_config:
return True
# Check for old model names that need migration
model = claude_config.get('model', '')
if model in self.model_migrations:
return True
# Check for missing other sections
required_sections = ['journal', 'output', 'analysis', 'logging']
for section in required_sections:
if section not in config_dict:
return True
# Check for missing fields in existing sections
section_config = config_dict[section]
default_fields = self.default_values.get(section, {})
for field in default_fields:
if field not in section_config:
return True
return False
def get_migration_preview(self, config_dict: Dict[str, Any]) -> List[str]:
"""
Get a preview of what migration actions would be taken
Args:
config_dict: Configuration dictionary to analyze
Returns:
List of migration actions that would be taken
"""
preview_actions = []
# Check Claude configuration
claude_config = config_dict.get('claude', {})
if 'api_url' not in claude_config:
preview_actions.append(f"Would add default api_url: {self.default_values['claude']['api_url']}")
if 'model' not in claude_config:
preview_actions.append(f"Would add default model: {self.default_values['claude']['model']}")
elif claude_config['model'] in self.model_migrations:
old_model = claude_config['model']
new_model = self.model_migrations[old_model]
preview_actions.append(f"Would migrate model '{old_model}' to '{new_model}'")
# Check other sections
for section_name, section_defaults in self.default_values.items():
if section_name == 'claude':
continue # Already handled above
if section_name not in config_dict:
preview_actions.append(f"Would create missing '{section_name}' section with defaults")
else:
section_config = config_dict[section_name]
for field, default_value in section_defaults.items():
if field not in section_config:
preview_actions.append(f"Would add default {section_name}.{field}: {default_value}")
return preview_actions
def get_supported_model_names(self) -> List[str]:
"""
Get list of all supported model names (both old and new)
Returns:
List of supported model names
"""
# Current valid models
current_models = [
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307",
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
"claude-3-opus-latest",
"claude-3-sonnet-latest",
"claude-3-haiku-latest",
"claude-3-5-sonnet-latest",
"claude-3-5-haiku-latest"
]
# Legacy models that will be migrated
legacy_models = list(self.model_migrations.keys())
return current_models + legacy_models
+21
View File
@@ -0,0 +1,21 @@
"""
对话模块
提供对话式 Agent 的核心功能
"""
from .intent_understanding import IntentUnderstanding, Intent
from .conversation_state import ConversationState, Message, Task, TaskStatus
from .response_generator import ResponseGenerator
from .conversational_agent import ConversationalAgent, ChatResponse
__all__ = [
"IntentUnderstanding",
"Intent",
"ConversationState",
"Message",
"Task",
"TaskStatus",
"ResponseGenerator",
"ConversationalAgent",
"ChatResponse",
]
+295
View File
@@ -0,0 +1,295 @@
"""
对话状态管理模块
管理对话历史、上下文和当前任务状态
"""
import logging
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Dict, Any, List, Optional, Union
class TaskStatus(Enum):
"""任务状态"""
IDLE = "idle" # 空闲
PROCESSING = "processing" # 处理中
COMPLETED = "completed" # 已完成
FAILED = "failed" # 失败
WAITING_INPUT = "waiting_input" # 等待用户输入
@dataclass
class Message:
"""对话消息"""
role: str # "user" 或 "assistant"
content: str
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
metadata: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
"""Validate message after initialization"""
if self.role not in ["user", "assistant"]:
raise ValueError("role must be 'user' or 'assistant'")
if not self.content or not self.content.strip():
raise ValueError("content cannot be empty")
@dataclass
class Task:
"""当前任务"""
command: str
parameters: Dict[str, Any]
status: TaskStatus = TaskStatus.IDLE
result: Optional[Any] = None
error: Optional[str] = None
started_at: Optional[str] = None
completed_at: Optional[str] = None
def __post_init__(self) -> None:
"""Validate task after initialization"""
if not self.command or not self.command.strip():
raise ValueError("command cannot be empty")
if not isinstance(self.parameters, dict):
raise ValueError("parameters must be a dictionary")
class ConversationState:
"""对话状态管理器"""
def __init__(self, max_history: int = 20) -> None:
"""
初始化对话状态
Args:
max_history: 保留的最大历史消息数
"""
self.logger: logging.Logger = logging.getLogger("ConversationState")
self.max_history: int = max_history
# 对话历史
self.messages: List[Message] = []
# 当前任务
self.current_task: Optional[Task] = None
# 用户偏好和上下文
self.user_preferences: Dict[str, Any] = {}
self.context: Dict[str, Any] = {}
# 统计信息
self.stats: Dict[str, Union[int, str]] = {
"total_messages": 0,
"total_tasks": 0,
"successful_tasks": 0,
"failed_tasks": 0,
"session_start": datetime.now().isoformat(),
}
def add_message(
self, role: str, content: str, metadata: Optional[Dict[str, Any]] = None
) -> Message:
"""
添加消息到历史
Args:
role: 消息角色("user""assistant"
content: 消息内容
metadata: 消息元数据
Returns:
Message: 添加的消息
"""
message: Message = Message(role=role, content=content, metadata=metadata or {})
self.messages.append(message)
self.stats["total_messages"] += 1
# 保持历史长度在限制内
if len(self.messages) > self.max_history:
self.messages.pop(0)
self.logger.debug(f"添加消息: {role} - {content[:50]}...")
return message
def get_recent_messages(self, count: int = 5) -> List[Message]:
"""
获取最近的 N 条消息
Args:
count: 消息数量
Returns:
最近的消息列表
"""
return self.messages[-count:]
def get_conversation_history(self) -> List[Dict[str, str]]:
"""
获取对话历史(用于 Claude API)
Returns:
对话历史列表
"""
return [{"role": msg.role, "content": msg.content} for msg in self.messages]
def start_task(self, command: str, parameters: Dict[str, Any]) -> Task:
"""
开始一个新任务
Args:
command: 命令名称
parameters: 命令参数
Returns:
Task: 创建的任务
"""
self.current_task = Task(
command=command,
parameters=parameters,
status=TaskStatus.PROCESSING,
started_at=datetime.now().isoformat(),
)
self.stats["total_tasks"] += 1
self.logger.info(f"开始任务: {command} - {parameters}")
return self.current_task
def complete_task(self, result: Any) -> Optional[Task]:
"""
完成当前任务
Args:
result: 任务结果
Returns:
Task: 完成的任务
"""
if not self.current_task:
self.logger.warning("没有正在进行的任务")
return None
self.current_task.status = TaskStatus.COMPLETED
self.current_task.result = result
self.current_task.completed_at = datetime.now().isoformat()
self.stats["successful_tasks"] += 1
self.logger.info(f"任务完成: {self.current_task.command}")
return self.current_task
def fail_task(self, error: str) -> Optional[Task]:
"""
标记任务失败
Args:
error: 错误信息
Returns:
Task: 失败的任务
"""
if not self.current_task:
self.logger.warning("没有正在进行的任务")
return None
self.current_task.status = TaskStatus.FAILED
self.current_task.error = error
self.current_task.completed_at = datetime.now().isoformat()
self.stats["failed_tasks"] += 1
self.logger.error(f"任务失败: {self.current_task.command} - {error}")
return self.current_task
def set_context(self, key: str, value: Any) -> None:
"""
设置上下文信息
Args:
key: 上下文键
value: 上下文值
"""
self.context[key] = value
self.logger.debug(f"设置上下文: {key} = {value}")
def get_context(self, key: str, default: Any = None) -> Any:
"""
获取上下文信息
Args:
key: 上下文键
default: 默认值
Returns:
上下文值
"""
return self.context.get(key, default)
def set_preference(self, key: str, value: Any) -> None:
"""
设置用户偏好
Args:
key: 偏好键
value: 偏好值
"""
self.user_preferences[key] = value
self.logger.debug(f"设置偏好: {key} = {value}")
def get_preference(self, key: str, default: Any = None) -> Any:
"""
获取用户偏好
Args:
key: 偏好键
default: 默认值
Returns:
偏好值
"""
return self.user_preferences.get(key, default)
def get_summary(self) -> Dict[str, Any]:
"""
获取对话状态摘要
Returns:
状态摘要字典
"""
return {
"total_messages": len(self.messages),
"recent_messages": [
{
"role": msg.role,
"content": msg.content[:100],
"timestamp": msg.timestamp,
}
for msg in self.get_recent_messages(3)
],
"current_task": {
"command": self.current_task.command,
"status": self.current_task.status.value,
"started_at": self.current_task.started_at,
}
if self.current_task
else None,
"stats": self.stats,
"preferences": self.user_preferences,
}
def clear_history(self) -> None:
"""清除对话历史"""
self.messages.clear()
self.logger.info("对话历史已清除")
def reset(self) -> None:
"""重置对话状态"""
self.messages.clear()
self.current_task = None
self.context.clear()
self.logger.info("对话状态已重置")
+218
View File
@@ -0,0 +1,218 @@
"""
对话式 Agent 核心模块
融合对话能力的智能 Agent
"""
import logging
from dataclasses import dataclass
from typing import Dict, Any, Optional, List
from .conversation_state import ConversationState
from .intent_understanding import IntentUnderstanding, Intent
from .response_generator import ResponseGenerator
from ..agent_core import Agent, SkillResult
@dataclass
class ChatResponse:
"""对话响应"""
message: str # 响应消息
suggestions: Optional[List[str]] = None # 建议的后续操作
status: str = "success" # 状态:success, error, waiting_input
metadata: Optional[Dict[str, Any]] = None # 元数据
def __post_init__(self) -> None:
"""Validate response after initialization"""
if not self.message or not self.message.strip():
raise ValueError("message cannot be empty")
valid_statuses = ["success", "error", "waiting_input"]
if self.status not in valid_statuses:
raise ValueError(f"status must be one of {valid_statuses}")
if self.suggestions is not None and not isinstance(self.suggestions, list):
raise ValueError("suggestions must be a list or None")
class ConversationalAgent:
"""对话式 Agent,融合对话能力的智能 Agent"""
def __init__(
self, command_agent: Agent, config: Optional[Dict[str, Any]] = None
) -> None:
"""
初始化对话式 Agent
Args:
command_agent: 底层的 Command Agent
config: 配置字典
"""
self.command_agent: Agent = command_agent
self.config: Dict[str, Any] = config or {}
self.logger: logging.Logger = logging.getLogger("ConversationalAgent")
# 初始化各个模块
self.intent_understanding: IntentUnderstanding = IntentUnderstanding(config)
self.conversation_state: ConversationState = ConversationState()
self.response_generator: ResponseGenerator = ResponseGenerator(config)
async def initialize(self) -> str:
"""
初始化 Agent 并返回欢迎消息
Returns:
欢迎消息
"""
welcome_msg = await self.response_generator.generate_welcome_message()
self.conversation_state.add_message("assistant", welcome_msg)
self.logger.info("对话式 Agent 已初始化")
return welcome_msg
async def chat(self, user_message: str) -> ChatResponse:
"""
处理用户消息并返回响应
Args:
user_message: 用户的输入消息
Returns:
ChatResponse: 对话响应
"""
self.logger.info(f"处理用户消息: {user_message}")
try:
# 1. 记录用户消息
self.conversation_state.add_message("user", user_message)
# 2. 理解用户意图
context: Dict[str, Any] = {
"conversation_history": self.conversation_state.get_conversation_history()
}
intent: Intent = await self.intent_understanding.understand(
user_message, context
)
self.logger.debug(f"识别的意图: {intent.command}")
# 3. 如果需要澄清,返回澄清问题
if intent.clarification_needed:
response: ChatResponse = ChatResponse(
message=intent.clarification_question or "抱歉,我没有理解您的意思。能否请您重新表述?",
status="waiting_input",
)
self.conversation_state.add_message("assistant", response.message)
return response
# 4. 映射意图到命令并执行
command_result: SkillResult = await self._execute_command(intent)
# 5. 生成响应
if command_result.success:
response_msg: str = (
await self.response_generator.generate_success_response(
intent.command, command_result.data, user_message
)
)
else:
response_msg = await self.response_generator.generate_error_response(
intent.command, command_result.error or "未知错误", user_message
)
# 6. 生成建议
suggestions: List[str] = await self.response_generator.generate_suggestions(
self.conversation_state.context
)
# 7. 构建响应
response = ChatResponse(
message=response_msg,
suggestions=suggestions,
status="success" if command_result.success else "error",
metadata={
"command": intent.command,
"confidence": intent.confidence,
"reasoning": intent.raw_understanding,
},
)
# 8. 记录助手响应
self.conversation_state.add_message("assistant", response_msg)
self.logger.info(f"响应生成完成: {response_msg[:50]}...")
return response
except Exception as e:
self.logger.error(f"处理消息失败: {str(e)}", exc_info=True)
error_response: ChatResponse = ChatResponse(
message="抱歉,处理您的请求时出现了问题。请稍后重试。", status="error"
)
self.conversation_state.add_message("assistant", error_response.message)
return error_response
async def _execute_command(self, intent: Intent) -> SkillResult:
"""
执行命令
Args:
intent: 识别的意图
Returns:
SkillResult: 命令执行结果
"""
try:
# 开始任务
task = self.conversation_state.start_task(intent.command, intent.parameters)
# 执行命令
result: SkillResult = await self.command_agent.execute_command(
intent.command, intent.parameters
)
# 更新任务状态
if result.success:
self.conversation_state.complete_task(result.data)
else:
self.conversation_state.fail_task(result.error or "未知错误")
return result
except Exception as e:
self.logger.error(f"命令执行失败: {str(e)}")
self.conversation_state.fail_task(str(e))
return SkillResult(success=False, error=str(e), message="命令执行失败")
def get_conversation_history(self) -> List[Dict[str, str]]:
"""
获取对话历史
Returns:
对话历史列表
"""
return self.conversation_state.get_conversation_history()
def get_state_summary(self) -> Dict[str, Any]:
"""
获取对话状态摘要
Returns:
状态摘要
"""
return self.conversation_state.get_summary()
def clear_history(self) -> None:
"""
清除对话历史
"""
self.conversation_state.clear_history()
self.logger.info("对话历史已清除")
def reset(self) -> None:
"""
重置对话状态
"""
self.conversation_state.reset()
self.logger.info("对话状态已重置")
+402
View File
@@ -0,0 +1,402 @@
"""
意图理解模块
使用 Claude 理解用户的自然语言输入,提取意图和参数
"""
import json
import logging
import re
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Dict, Any, Optional, List
from ..dependency_manager import get_dependency_manager, graceful_import
# Try to import anthropic with graceful degradation
dependency_manager = get_dependency_manager()
Anthropic = dependency_manager.get_class_from_module('anthropic', 'Anthropic')
@dataclass
class Intent:
"""用户意图"""
command: str # 对应的命令名称
parameters: Dict[str, Any] = field(default_factory=dict)
confidence: float = 1.0
clarification_needed: bool = False
clarification_question: Optional[str] = None
raw_understanding: str = "" # Claude 的原始理解
def __post_init__(self) -> None:
"""Validate intent after initialization"""
if not self.command or not self.command.strip():
raise ValueError("command cannot be empty")
if not 0.0 <= self.confidence <= 1.0:
raise ValueError("confidence must be between 0.0 and 1.0")
if not isinstance(self.parameters, dict):
raise ValueError("parameters must be a dictionary")
class IntentUnderstanding:
"""意图理解器,使用 Claude 理解用户意图"""
def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
"""
初始化意图理解器
Args:
config: 配置字典
"""
self.config: Dict[str, Any] = config or {}
self.logger: logging.Logger = logging.getLogger("IntentUnderstanding")
# Initialize Anthropic client with dependency checking
if Anthropic is not None:
try:
self.client: Optional[Anthropic] = Anthropic()
except Exception as e:
self.logger.error(f"Failed to initialize Anthropic client: {e}")
self.client = None
else:
self.client = None
self.logger.warning("Anthropic library not available - Claude-based understanding disabled")
# 定义支持的命令和它们的关键词
self.command_keywords: Dict[str, List[str]] = {
"organize": [
"整理",
"组织",
"分类",
"归纳",
"整理日记",
"organize",
"arrange",
"categorize",
],
"analyze": [
"分析",
"总结",
"统计",
"分类",
"分析日记",
"analyze",
"summarize",
"statistics",
],
"export": [
"导出",
"保存",
"生成",
"输出",
"导出为",
"export",
"save",
"generate",
"output",
],
"review": [
"回顾",
"查看",
"查询",
"搜索",
"浏览",
"review",
"view",
"search",
"browse",
],
}
# 定义参数提取规则
self.parameter_patterns: Dict[str, List[str]] = {
"date": [
r"(\d{4}[-/]\d{1,2}[-/]\d{1,2})", # YYYY-MM-DD 或 YYYY/M/D
r"(今天|明天|昨天|前天)", # 相对日期
r"(这周|本周|上周|下周)", # 周
r"(这个月|本月|上个月|下个月)", # 月
],
"category": [
r"(经验|教训|待办|问题|成就|改进)",
r"(experience|lesson|task|problem|achievement|improvement)",
],
"format": [r"(PDF|Excel|Word|Markdown|JSON)", r"(pdf|xlsx|docx|md|json)"],
}
async def understand(
self, user_message: str, context: Optional[Dict[str, Any]] = None
) -> Intent:
"""
理解用户的自然语言输入
Args:
user_message: 用户的输入消息
context: 上下文信息(如对话历史)
Returns:
Intent: 提取的意图
"""
self.logger.debug(f"理解用户消息: {user_message}")
try:
# 首先尝试本地模式匹配(快速路径)
intent = self._match_intent_locally(user_message)
if intent and intent.confidence > 0.8:
self.logger.debug(f"本地匹配成功: {intent.command}")
return intent
# 使用 Claude 进行更深入的理解
intent = await self._understand_with_claude(user_message, context)
return intent
except Exception as e:
self.logger.error(f"意图理解失败: {str(e)}")
return Intent(
command="unknown",
clarification_needed=True,
clarification_question="抱歉,我没有理解您的意思。能否请您重新表述?",
)
def _match_intent_locally(self, user_message: str) -> Optional[Intent]:
"""
本地模式匹配,快速识别常见意图
Args:
user_message: 用户消息
Returns:
Intent 或 None
"""
message_lower: str = user_message.lower()
# 逐个检查命令关键词
for command, keywords in self.command_keywords.items():
for keyword in keywords:
if keyword in message_lower:
# 提取参数
parameters: Dict[str, Any] = self._extract_parameters_locally(
user_message
)
return Intent(
command=command, parameters=parameters, confidence=0.9
)
return None
def _extract_parameters_locally(self, user_message: str) -> Dict[str, Any]:
"""
本地提取参数
Args:
user_message: 用户消息
Returns:
提取的参数字典
"""
parameters: Dict[str, Any] = {}
# 提取日期
for pattern in self.parameter_patterns["date"]:
match: Optional[re.Match[str]] = re.search(pattern, user_message)
if match:
date_str: str = match.group(1)
parameters["date"] = self._normalize_date(date_str)
break
# 提取分类
for pattern in self.parameter_patterns["category"]:
match = re.search(pattern, user_message)
if match:
parameters["category"] = match.group(1)
break
# 提取格式
for pattern in self.parameter_patterns["format"]:
match = re.search(pattern, user_message)
if match:
parameters["format"] = match.group(1).lower()
break
return parameters
def _normalize_date(self, date_str: str) -> str:
"""
规范化日期字符串为 YYYY-MM-DD 格式
Args:
date_str: 日期字符串
Returns:
规范化的日期字符串
"""
today: datetime = datetime.now()
# 处理相对日期
if date_str == "今天":
return today.strftime("%Y-%m-%d")
elif date_str == "明天":
return (today + timedelta(days=1)).strftime("%Y-%m-%d")
elif date_str == "昨天":
return (today - timedelta(days=1)).strftime("%Y-%m-%d")
elif date_str == "前天":
return (today - timedelta(days=2)).strftime("%Y-%m-%d")
# 处理标准日期格式
try:
# 尝试 YYYY-MM-DD 或 YYYY/M/D 格式
for fmt in ["%Y-%m-%d", "%Y/%m/%d", "%Y-%m-%d"]:
try:
parsed: datetime = datetime.strptime(
date_str.replace("/", "-"), fmt
)
return parsed.strftime("%Y-%m-%d")
except ValueError:
continue
except:
pass
return date_str
async def _understand_with_claude(
self, user_message: str, context: Optional[Dict[str, Any]] = None
) -> Intent:
"""
使用 Claude 理解用户意图
Args:
user_message: 用户消息
context: 上下文信息
Returns:
Intent: 提取的意图
"""
# Check if Claude is available
if self.client is None:
self.logger.warning("Claude client not available, falling back to local matching")
return Intent(
command="unknown",
clarification_needed=True,
clarification_question="抱歉,AI 理解功能暂时不可用。请使用更具体的命令,如 '整理今天的日记''分析本周内容'",
)
# Construct prompt
prompt = self._build_understanding_prompt(user_message, context)
try:
# Call Claude
response = self.client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=500,
messages=[{"role": "user", "content": prompt}],
)
# Parse response
response_text = response.content[0].text
self.logger.debug(f"Claude 响应: {response_text}")
return self._parse_claude_response(response_text, user_message)
except Exception as e:
self.logger.error(f"Claude API call failed: {str(e)}")
# Fall back to local matching
local_intent = self._match_intent_locally(user_message)
if local_intent:
return local_intent
return Intent(
command="unknown",
clarification_needed=True,
clarification_question="抱歉,我在理解您的意图时遇到了问题。请尝试使用更具体的命令。",
)
def _build_understanding_prompt(
self, user_message: str, context: Optional[Dict[str, Any]] = None
) -> str:
"""
构建用于 Claude 的提示
Args:
user_message: 用户消息
context: 上下文信息
Returns:
提示文本
"""
available_commands: str = ", ".join(self.command_keywords.keys())
prompt: str = f"""你是一个 Obsidian 日记整理助手的意图识别器。
用户消息: "{user_message}"
可用的命令有: {available_commands}
请分析用户的意图,并返回一个 JSON 对象,包含以下字段:
{{
"command": "识别出的命令名称(必须是可用命令之一)",
"parameters": {{
"date": "如果用户指定了日期,转换为 YYYY-MM-DD 格式;否则为 null",
"category": "如果用户指定了分类,提取分类名称;否则为 null",
"format": "如果用户指定了导出格式,提取格式;否则为 null",
"other_params": "其他相关参数"
}},
"confidence": 0.0 到 1.0 之间的置信度,
"clarification_needed": 是否需要澄清(布尔值),
"clarification_question": "如果需要澄清,提出的问题;否则为 null",
"reasoning": "简短的推理说明"
}}
请确保返回有效的 JSON 格式。"""
if context and "conversation_history" in context:
prompt += f"\n\n对话历史(最近的消息):\n"
for msg in context["conversation_history"][-3:]:
prompt += f"- {msg['role']}: {msg['content']}\n"
return prompt
def _parse_claude_response(self, response_text: str, user_message: str) -> Intent:
"""
解析 Claude 的响应
Args:
response_text: Claude 的响应文本
user_message: 原始用户消息
Returns:
Intent: 提取的意图
"""
try:
# 尝试从响应中提取 JSON
json_match: Optional[re.Match[str]] = re.search(
r"\{.*\}", response_text, re.DOTALL
)
if not json_match:
raise ValueError("未找到 JSON 响应")
json_str: str = json_match.group(0)
data: Dict[str, Any] = json.loads(json_str)
# 构建 Intent 对象
intent: Intent = Intent(
command=data.get("command", "unknown"),
parameters={
k: v for k, v in data.get("parameters", {}).items() if v is not None
},
confidence=data.get("confidence", 0.7),
clarification_needed=data.get("clarification_needed", False),
clarification_question=data.get("clarification_question"),
raw_understanding=data.get("reasoning", ""),
)
return intent
except Exception as e:
self.logger.error(f"解析 Claude 响应失败: {str(e)}")
return Intent(
command="unknown",
clarification_needed=True,
clarification_question="抱歉,我在处理您的请求时遇到了问题。能否请您重新表述?",
)
+234
View File
@@ -0,0 +1,234 @@
"""
响应生成模块
使用 Claude 生成自然语言响应
"""
import logging
from typing import Dict, Any, Optional, List
from ..dependency_manager import get_dependency_manager
# Try to import anthropic with graceful degradation
dependency_manager = get_dependency_manager()
Anthropic = dependency_manager.get_class_from_module('anthropic', 'Anthropic')
class ResponseGenerator:
"""响应生成器,使用 Claude 生成自然语言响应"""
def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
"""
初始化响应生成器
Args:
config: 配置字典
"""
self.config: Dict[str, Any] = config or {}
self.logger: logging.Logger = logging.getLogger("ResponseGenerator")
# Initialize Anthropic client with dependency checking
if Anthropic is not None:
try:
self.client: Optional[Anthropic] = Anthropic()
except Exception as e:
self.logger.error(f"Failed to initialize Anthropic client: {e}")
self.client = None
else:
self.client = None
self.logger.warning("Anthropic library not available - AI response generation disabled")
async def generate_success_response(
self, command: str, result: Any, user_message: str
) -> str:
"""
生成成功响应
Args:
command: 执行的命令
result: 命令执行结果
user_message: 原始用户消息
Returns:
生成的响应文本
"""
prompt: str = self._build_success_prompt(command, result, user_message)
return await self._generate_response(prompt)
async def generate_error_response(
self, command: str, error: str, user_message: str
) -> str:
"""
生成错误响应
Args:
command: 执行的命令
error: 错误信息
user_message: 原始用户消息
Returns:
生成的响应文本
"""
prompt: str = self._build_error_prompt(command, error, user_message)
return await self._generate_response(prompt)
async def generate_clarification_response(self, question: str) -> str:
"""
生成澄清问题的响应
Args:
question: 澄清问题
Returns:
生成的响应文本
"""
return question
async def generate_welcome_message(self) -> str:
"""
生成欢迎消息
Returns:
欢迎消息
"""
return "👋 欢迎使用 Obsidian 日记整理助手!我可以帮您整理日记、分析内容、导出总结等。请告诉我您想要做什么?"
async def generate_suggestions(
self, context: Optional[Dict[str, Any]] = None
) -> List[str]:
"""
生成智能建议
Args:
context: 上下文信息
Returns:
建议列表
"""
suggestions: List[str] = ["整理今天的日记", "分析本周的主题", "导出月度总结", "查看最近的经验"]
# 可以根据上下文生成更个性化的建议
if context:
# 例如,如果是周五,建议生成周总结
from datetime import datetime
if datetime.now().weekday() == 4: # 周五
suggestions.insert(0, "生成本周总结")
return suggestions
def _build_success_prompt(
self, command: str, result: Any, user_message: str
) -> str:
"""
构建成功响应的提示
Args:
command: 执行的命令
result: 命令执行结果
user_message: 原始用户消息
Returns:
提示文本
"""
result_str: str = self._format_result(result)
prompt: str = f"""你是一个友好的 Obsidian 日记整理助手。
用户问: "{user_message}"
你已经成功执行了 "{command}" 命令。
执行结果:
{result_str}
请用友好、自然的语言总结结果。保持回复简洁(1-3 句话)。
如果有重要的数据或统计信息,请突出显示。
示例回复:
- "✓ 已成功整理您今天的日记。提取了 5 条经验、3 条待办事项和 2 个问题。"
- "✓ 分析完成!本周的主题主要集中在项目管理和技术学习两个方面。"
"""
return prompt
def _build_error_prompt(self, command: str, error: str, user_message: str) -> str:
"""
构建错误响应的提示
Args:
command: 执行的命令
error: 错误信息
user_message: 原始用户消息
Returns:
提示文本
"""
prompt: str = f"""你是一个友好的 Obsidian 日记整理助手。
用户问: "{user_message}"
执行 "{command}" 命令时出现了错误:
{error}
请用友好、有帮助的语言解释错误,并建议可能的解决方案。保持回复简洁(1-2 句话)。
示例回复:
- "✗ 抱歉,找不到该日期的日记。请检查日期格式是否正确(YYYY-MM-DD)。"
- "✗ 执行过程中出现了问题。请稍后重试,或检查您的配置设置。"
"""
return prompt
def _format_result(self, result: Any) -> str:
"""
格式化结果
Args:
result: 结果对象
Returns:
格式化的结果字符串
"""
if isinstance(result, dict):
lines: List[str] = []
for key, value in result.items():
if isinstance(value, (list, dict)):
lines.append(f"- {key}: {len(value)}")
else:
lines.append(f"- {key}: {value}")
return "\n".join(lines)
elif isinstance(result, list):
return "\n".join([f"- {item}" for item in result])
else:
return str(result)
async def _generate_response(self, prompt: str) -> str:
"""
使用 Claude 生成响应
Args:
prompt: 提示文本
Returns:
生成的响应
"""
# Check if Claude is available
if self.client is None:
self.logger.warning("Claude client not available, using fallback response")
return "✓ 操作已完成。(注意:AI 响应生成功能暂时不可用)"
try:
response = self.client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=300,
messages=[{"role": "user", "content": prompt}],
)
response_text: str = response.content[0].text.strip()
self.logger.debug(f"生成响应: {response_text[:100]}...")
return response_text
except Exception as e:
self.logger.error(f"生成响应失败: {str(e)}")
return "✓ 操作已完成。(注意:AI 响应生成遇到问题,请检查网络连接和 API 配置)"
+675
View File
@@ -0,0 +1,675 @@
"""
Comprehensive date validation and parsing utilities
Provides robust date format validation and constraint checking
"""
import logging
import re
from datetime import datetime, date, timedelta
from typing import Optional, List, Tuple, Union, Dict, Any
try:
from .error_handling import ValidationError
except ImportError:
from error_handling import ValidationError
class DateValidator:
"""Comprehensive date validation with multiple format support"""
def __init__(self):
self.logger = logging.getLogger(__name__)
# Supported date formats with their regex patterns and strptime formats
self.date_formats = {
'iso_date': {
'pattern': r'^\d{4}-\d{2}-\d{2}$',
'strptime': '%Y-%m-%d',
'description': 'ISO date format (YYYY-MM-DD)'
},
'iso_datetime': {
'pattern': r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$',
'strptime': '%Y-%m-%dT%H:%M:%S',
'description': 'ISO datetime format (YYYY-MM-DDTHH:MM:SS)'
},
'iso_datetime_ms': {
'pattern': r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}$',
'strptime': '%Y-%m-%dT%H:%M:%S.%f',
'description': 'ISO datetime with milliseconds'
},
'iso_datetime_tz': {
'pattern': r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$',
'strptime': '%Y-%m-%dT%H:%M:%S%z',
'description': 'ISO datetime with timezone'
},
'us_date': {
'pattern': r'^\d{1,2}/\d{1,2}/\d{4}$',
'strptime': '%m/%d/%Y',
'description': 'US date format (MM/DD/YYYY)'
},
'eu_date': {
'pattern': r'^\d{1,2}/\d{1,2}/\d{4}$',
'strptime': '%d/%m/%Y',
'description': 'European date format (DD/MM/YYYY)'
},
'dot_date': {
'pattern': r'^\d{1,2}\.\d{1,2}\.\d{4}$',
'strptime': '%d.%m.%Y',
'description': 'Dot-separated date (DD.MM.YYYY)'
},
'compact_date': {
'pattern': r'^\d{8}$',
'strptime': '%Y%m%d',
'description': 'Compact date format (YYYYMMDD)'
}
}
# Default constraints
self.default_constraints = {
'min_year': 1900,
'max_year': 2100,
'allow_future': True,
'max_future_days': 365 * 10, # 10 years
'allow_past': True,
'max_past_days': 365 * 50, # 50 years
}
def validate_date_string(
self,
date_string: str,
field_name: str = "date",
allowed_formats: Optional[List[str]] = None,
constraints: Optional[Dict[str, Any]] = None,
auto_detect_format: bool = True
) -> Tuple[datetime, str]:
"""
Validate a date string with comprehensive format and constraint checking
Args:
date_string: Date string to validate
field_name: Name of the field for error messages
allowed_formats: List of allowed format names (None = all formats)
constraints: Date constraints (None = use defaults)
auto_detect_format: Whether to auto-detect format
Returns:
Tuple of (parsed_datetime, detected_format)
Raises:
ValidationError: If validation fails
"""
if not date_string or not isinstance(date_string, str):
raise ValidationError(
message=f"{field_name} must be a non-empty string",
field_name=field_name,
validation_rule="non_empty_string"
)
date_string = date_string.strip()
if not date_string:
raise ValidationError(
message=f"{field_name} cannot be empty",
field_name=field_name,
validation_rule="non_empty"
)
# Determine which formats to try
formats_to_try = allowed_formats or list(self.date_formats.keys())
parsed_date = None
detected_format = None
parsing_errors = []
# Try each format
for format_name in formats_to_try:
if format_name not in self.date_formats:
self.logger.warning(f"Unknown date format: {format_name}")
continue
format_info = self.date_formats[format_name]
# Check regex pattern first (faster than strptime)
if not re.match(format_info['pattern'], date_string):
continue
# Try to parse with strptime
try:
parsed_date = datetime.strptime(date_string, format_info['strptime'])
detected_format = format_name
break
except ValueError as e:
parsing_errors.append(f"{format_name}: {str(e)}")
continue
# If no format worked, provide helpful error
if parsed_date is None:
if auto_detect_format:
error_msg = f"{field_name} format not recognized. Tried formats: {', '.join(formats_to_try)}"
if parsing_errors:
error_msg += f". Errors: {'; '.join(parsing_errors[:3])}" # Limit error details
else:
format_descriptions = [f'{fmt} ({self.date_formats[fmt]["description"]})' for fmt in formats_to_try if fmt in self.date_formats]
error_msg = f"{field_name} must match one of these formats: {', '.join(format_descriptions)}"
raise ValidationError(
message=error_msg,
field_name=field_name,
validation_rule="date_format"
)
# Apply constraints
self._validate_date_constraints(parsed_date, field_name, constraints or self.default_constraints)
return parsed_date, detected_format
def validate_date_range(
self,
start_date: Union[str, datetime, date],
end_date: Union[str, datetime, date],
field_name_start: str = "start_date",
field_name_end: str = "end_date",
allow_same_date: bool = True,
max_range_days: Optional[int] = None
) -> Tuple[datetime, datetime]:
"""
Validate a date range with logical constraints
Args:
start_date: Start date (string or datetime)
end_date: End date (string or datetime)
field_name_start: Name of start date field
field_name_end: Name of end date field
allow_same_date: Whether start and end can be the same
max_range_days: Maximum allowed range in days
Returns:
Tuple of (start_datetime, end_datetime)
Raises:
ValidationError: If validation fails
"""
# Parse start date
if isinstance(start_date, str):
start_dt, _ = self.validate_date_string(start_date, field_name_start)
elif isinstance(start_date, datetime):
start_dt = start_date
elif isinstance(start_date, date):
start_dt = datetime.combine(start_date, datetime.min.time())
else:
raise ValidationError(
message=f"{field_name_start} must be a string, date, or datetime",
field_name=field_name_start,
validation_rule="date_type"
)
# Parse end date
if isinstance(end_date, str):
end_dt, _ = self.validate_date_string(end_date, field_name_end)
elif isinstance(end_date, datetime):
end_dt = end_date
elif isinstance(end_date, date):
end_dt = datetime.combine(end_date, datetime.min.time())
else:
raise ValidationError(
message=f"{field_name_end} must be a string, date, or datetime",
field_name=field_name_end,
validation_rule="date_type"
)
# Validate range logic
if start_dt > end_dt:
raise ValidationError(
message=f"{field_name_start} cannot be after {field_name_end}",
field_name=field_name_start,
validation_rule="date_range_order"
)
if not allow_same_date and start_dt.date() == end_dt.date():
raise ValidationError(
message=f"{field_name_start} and {field_name_end} cannot be the same date",
field_name=field_name_start,
validation_rule="date_range_same"
)
# Check maximum range
if max_range_days is not None:
range_days = (end_dt - start_dt).days
if range_days > max_range_days:
raise ValidationError(
message=f"Date range cannot exceed {max_range_days} days (current: {range_days} days)",
field_name=field_name_start,
validation_rule="date_range_too_large"
)
return start_dt, end_dt
def validate_journal_date(
self,
date_string: str,
field_name: str = "journal_date"
) -> datetime:
"""
Validate a date for journal entries with specific constraints
Args:
date_string: Date string to validate
field_name: Name of the field for error messages
Returns:
Parsed datetime
Raises:
ValidationError: If validation fails
"""
# Journal dates should typically be in ISO format and not too far in the future
journal_constraints = {
'min_year': 2000, # Journals unlikely before 2000
'max_year': datetime.now().year + 1, # Allow up to next year
'allow_future': True,
'max_future_days': 30, # Allow up to 30 days in future
'allow_past': True,
'max_past_days': 365 * 20, # Allow up to 20 years in past
}
# Prefer ISO date format for journals
preferred_formats = ['iso_date', 'compact_date', 'us_date', 'eu_date']
parsed_date, detected_format = self.validate_date_string(
date_string,
field_name,
allowed_formats=preferred_formats,
constraints=journal_constraints
)
# Additional journal-specific validations
today = datetime.now().date()
date_only = parsed_date.date()
# Warn about future dates (but don't reject)
if date_only > today:
days_future = (date_only - today).days
if days_future > 7: # More than a week in future
self.logger.warning(f"Journal date is {days_future} days in the future: {date_string}")
# Check for weekend dates (informational)
if date_only.weekday() >= 5: # Saturday = 5, Sunday = 6
self.logger.debug(f"Journal date is on weekend: {date_string}")
return parsed_date
def validate_obsidian_date_format(
self,
date_string: str,
obsidian_format: str = "YYYY-MM-DD",
field_name: str = "date"
) -> datetime:
"""
Validate date against Obsidian's date format configuration
Args:
date_string: Date string to validate
obsidian_format: Obsidian date format (e.g., "YYYY-MM-DD", "DD-MM-YYYY")
field_name: Name of the field for error messages
Returns:
Parsed datetime
Raises:
ValidationError: If validation fails
"""
# Convert Obsidian format to Python strptime format
python_format = self._obsidian_to_python_format(obsidian_format)
if not date_string or not isinstance(date_string, str):
raise ValidationError(
message=f"{field_name} must be a non-empty string",
field_name=field_name,
validation_rule="non_empty_string"
)
date_string = date_string.strip()
try:
parsed_date = datetime.strptime(date_string, python_format)
except ValueError as e:
raise ValidationError(
message=f"{field_name} must match Obsidian format '{obsidian_format}': {str(e)}",
field_name=field_name,
validation_rule="obsidian_date_format"
)
# Apply basic constraints
self._validate_date_constraints(parsed_date, field_name, self.default_constraints)
return parsed_date
def get_supported_formats(self) -> Dict[str, str]:
"""
Get list of supported date formats
Returns:
Dictionary of format_name -> description
"""
return {name: info['description'] for name, info in self.date_formats.items()}
def suggest_date_format(self, date_string: str) -> List[Tuple[str, str]]:
"""
Suggest possible date formats for a given string
Args:
date_string: Date string to analyze
Returns:
List of (format_name, description) tuples for matching patterns
"""
suggestions = []
for format_name, format_info in self.date_formats.items():
if re.match(format_info['pattern'], date_string.strip()):
suggestions.append((format_name, format_info['description']))
return suggestions
def _validate_date_constraints(
self,
date_obj: datetime,
field_name: str,
constraints: Dict[str, Any]
) -> None:
"""Validate date against constraints"""
# Year constraints
if date_obj.year < constraints.get('min_year', 1900):
raise ValidationError(
message=f"{field_name} year cannot be before {constraints['min_year']}",
field_name=field_name,
validation_rule="min_year"
)
if date_obj.year > constraints.get('max_year', 2100):
raise ValidationError(
message=f"{field_name} year cannot be after {constraints['max_year']}",
field_name=field_name,
validation_rule="max_year"
)
# Future date constraints
today = datetime.now()
if date_obj > today:
if not constraints.get('allow_future', True):
raise ValidationError(
message=f"{field_name} cannot be in the future",
field_name=field_name,
validation_rule="no_future_dates"
)
days_future = (date_obj - today).days
max_future = constraints.get('max_future_days', 365 * 10)
if days_future > max_future:
raise ValidationError(
message=f"{field_name} cannot be more than {max_future} days in the future",
field_name=field_name,
validation_rule="max_future_days"
)
# Past date constraints
if date_obj < today:
if not constraints.get('allow_past', True):
raise ValidationError(
message=f"{field_name} cannot be in the past",
field_name=field_name,
validation_rule="no_past_dates"
)
days_past = (today - date_obj).days
max_past = constraints.get('max_past_days', 365 * 50)
if days_past > max_past:
raise ValidationError(
message=f"{field_name} cannot be more than {max_past} days in the past",
field_name=field_name,
validation_rule="max_past_days"
)
def _obsidian_to_python_format(self, obsidian_format: str) -> str:
"""Convert Obsidian date format to Python strptime format"""
# Common Obsidian format mappings
mappings = {
'YYYY': '%Y', # 4-digit year
'YY': '%y', # 2-digit year
'MM': '%m', # Month with zero padding
'M': '%m', # Month without zero padding (Python doesn't distinguish)
'DD': '%d', # Day with zero padding
'D': '%d', # Day without zero padding (Python doesn't distinguish)
'HH': '%H', # Hour (24-hour)
'hh': '%I', # Hour (12-hour)
'mm': '%M', # Minute
'ss': '%S', # Second
'A': '%p', # AM/PM
}
python_format = obsidian_format
# Replace in order of length (longest first to avoid partial replacements)
for obsidian_token in sorted(mappings.keys(), key=len, reverse=True):
python_format = python_format.replace(obsidian_token, mappings[obsidian_token])
return python_format
class DateRangeValidator:
"""Specialized validator for date ranges and periods"""
def __init__(self):
self.logger = logging.getLogger(__name__)
self.date_validator = DateValidator()
def validate_journal_date_range(
self,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
period: Optional[str] = None
) -> Tuple[datetime, datetime]:
"""
Validate a date range for journal operations
Args:
start_date: Start date string (optional)
end_date: End date string (optional)
period: Period specification (e.g., "week", "month", "year")
Returns:
Tuple of (start_datetime, end_datetime)
Raises:
ValidationError: If validation fails
"""
today = datetime.now()
# Handle period-based ranges
if period:
return self._get_period_range(period, today)
# Handle explicit date ranges
if start_date and end_date:
return self.date_validator.validate_date_range(
start_date, end_date,
max_range_days=365 * 2 # Max 2 years for journal ranges
)
# Handle single date (default to that day)
if start_date:
start_dt = self.date_validator.validate_journal_date(start_date)
end_dt = start_dt.replace(hour=23, minute=59, second=59)
return start_dt, end_dt
if end_date:
end_dt = self.date_validator.validate_journal_date(end_date)
start_dt = end_dt.replace(hour=0, minute=0, second=0)
return start_dt, end_dt
# Default to today
start_dt = today.replace(hour=0, minute=0, second=0, microsecond=0)
end_dt = today.replace(hour=23, minute=59, second=59, microsecond=999999)
return start_dt, end_dt
def _get_period_range(self, period: str, reference_date: datetime) -> Tuple[datetime, datetime]:
"""Get date range for a period specification"""
period = period.lower().strip()
if period in ['today', 'day']:
start = reference_date.replace(hour=0, minute=0, second=0, microsecond=0)
end = reference_date.replace(hour=23, minute=59, second=59, microsecond=999999)
elif period in ['yesterday']:
yesterday = reference_date - timedelta(days=1)
start = yesterday.replace(hour=0, minute=0, second=0, microsecond=0)
end = yesterday.replace(hour=23, minute=59, second=59, microsecond=999999)
elif period in ['week', 'this_week']:
# Start of week (Monday)
days_since_monday = reference_date.weekday()
start = (reference_date - timedelta(days=days_since_monday)).replace(
hour=0, minute=0, second=0, microsecond=0
)
end = (start + timedelta(days=6)).replace(
hour=23, minute=59, second=59, microsecond=999999
)
elif period in ['last_week']:
# Previous week
days_since_monday = reference_date.weekday()
this_week_start = reference_date - timedelta(days=days_since_monday)
start = (this_week_start - timedelta(days=7)).replace(
hour=0, minute=0, second=0, microsecond=0
)
end = (start + timedelta(days=6)).replace(
hour=23, minute=59, second=59, microsecond=999999
)
elif period in ['month', 'this_month']:
# Start of month
start = reference_date.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
# End of month
if reference_date.month == 12:
next_month = reference_date.replace(year=reference_date.year + 1, month=1, day=1)
else:
next_month = reference_date.replace(month=reference_date.month + 1, day=1)
end = (next_month - timedelta(days=1)).replace(
hour=23, minute=59, second=59, microsecond=999999
)
elif period in ['last_month']:
# Previous month
if reference_date.month == 1:
last_month = reference_date.replace(year=reference_date.year - 1, month=12, day=1)
else:
last_month = reference_date.replace(month=reference_date.month - 1, day=1)
start = last_month.replace(hour=0, minute=0, second=0, microsecond=0)
# End of last month
this_month_start = reference_date.replace(day=1)
end = (this_month_start - timedelta(days=1)).replace(
hour=23, minute=59, second=59, microsecond=999999
)
elif period in ['year', 'this_year']:
start = reference_date.replace(
month=1, day=1, hour=0, minute=0, second=0, microsecond=0
)
end = reference_date.replace(
month=12, day=31, hour=23, minute=59, second=59, microsecond=999999
)
elif period in ['last_year']:
last_year = reference_date.year - 1
start = reference_date.replace(
year=last_year, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
)
end = reference_date.replace(
year=last_year, month=12, day=31, hour=23, minute=59, second=59, microsecond=999999
)
else:
# Try to parse as number of days
try:
if period.endswith('d') or period.endswith('days'):
days = int(period.rstrip('days').rstrip('d'))
start = (reference_date - timedelta(days=days)).replace(
hour=0, minute=0, second=0, microsecond=0
)
end = reference_date.replace(hour=23, minute=59, second=59, microsecond=999999)
else:
raise ValueError("Invalid period format")
except ValueError:
raise ValidationError(
message=f"Unknown period specification: {period}. "
f"Supported: today, yesterday, week, month, year, last_week, last_month, last_year, or Nd (N days)",
field_name="period",
validation_rule="unknown_period"
)
return start, end
# Global validator instances
date_validator = DateValidator()
date_range_validator = DateRangeValidator()
def validate_date_input(
date_input: Union[str, datetime, date, None],
field_name: str = "date",
required: bool = True,
format_hint: Optional[str] = None
) -> Optional[datetime]:
"""
Convenience function for validating date inputs
Args:
date_input: Date input to validate
field_name: Name of the field for error messages
required: Whether the date is required
format_hint: Hint about expected format
Returns:
Validated datetime or None if not required and empty
Raises:
ValidationError: If validation fails
"""
if date_input is None or (isinstance(date_input, str) and not date_input.strip()):
if required:
raise ValidationError(
message=f"{field_name} is required",
field_name=field_name,
validation_rule="required"
)
return None
if isinstance(date_input, datetime):
return date_input
elif isinstance(date_input, date):
return datetime.combine(date_input, datetime.min.time())
elif isinstance(date_input, str):
# Use format hint if provided
allowed_formats = None
if format_hint:
if format_hint in date_validator.date_formats:
allowed_formats = [format_hint]
else:
# Try to match format hint to known formats
for fmt_name, fmt_info in date_validator.date_formats.items():
if format_hint.lower() in fmt_info['description'].lower():
allowed_formats = [fmt_name]
break
parsed_date, _ = date_validator.validate_date_string(
date_input, field_name, allowed_formats=allowed_formats
)
return parsed_date
else:
raise ValidationError(
message=f"{field_name} must be a string, date, or datetime",
field_name=field_name,
validation_rule="date_type"
)
+341
View File
@@ -0,0 +1,341 @@
"""
Dependency Management Module
Handles optional dependencies with graceful degradation and helpful error messages
"""
import logging
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple, Callable
@dataclass
class DependencyInfo:
"""Information about a dependency"""
name: str
import_name: str
install_command: str
description: str
required_for: List[str]
minimum_version: Optional[str] = None
alternative_packages: Optional[List[str]] = None
setup_instructions: Optional[str] = None
class DependencyManager:
"""Manages optional dependencies with graceful degradation"""
def __init__(self):
self.logger = logging.getLogger("DependencyManager")
self._dependency_cache: Dict[str, Any] = {}
self._availability_cache: Dict[str, bool] = {}
# Define known dependencies
self.dependencies = {
'anthropic': DependencyInfo(
name='anthropic',
import_name='anthropic',
install_command='pip install anthropic>=0.25.0',
description='Anthropic Claude API client for AI-powered content analysis',
required_for=['Claude AI analysis', 'Natural language understanding', 'Content transformation'],
minimum_version='0.25.0',
setup_instructions="""
Claude API Setup:
1. Install: pip install anthropic>=0.25.0
2. Get API key from: https://console.anthropic.com/
3. Set environment variable: export ANTHROPIC_API_KEY="sk-ant-your-key-here"
4. Or add to config.yaml: claude.api_key: "${ANTHROPIC_API_KEY}"
"""
),
'aiohttp': DependencyInfo(
name='aiohttp',
import_name='aiohttp',
install_command='pip install aiohttp>=3.9.0',
description='Async HTTP client for Obsidian REST API integration',
required_for=['Obsidian API communication', 'Reading/writing notes', 'File operations'],
minimum_version='3.9.0',
setup_instructions="""
HTTP Client Setup:
1. Install: pip install aiohttp>=3.9.0
2. Used for communicating with Obsidian Local REST API
3. No additional configuration required
"""
),
'yaml': DependencyInfo(
name='PyYAML',
import_name='yaml',
install_command='pip install pyyaml>=6.0',
description='YAML parser for configuration files',
required_for=['Configuration file parsing', 'Settings management'],
minimum_version='6.0',
alternative_packages=['ruamel.yaml'],
setup_instructions="""
YAML Parser Setup:
1. Install: pip install pyyaml>=6.0
2. Alternative: pip install ruamel.yaml
3. Used for parsing config.yaml files
"""
),
'pydantic': DependencyInfo(
name='pydantic',
import_name='pydantic',
install_command='pip install pydantic>=2.0.0',
description='Data validation and settings management',
required_for=['Configuration validation', 'Data model validation', 'Type checking'],
minimum_version='2.0.0',
setup_instructions="""
Data Validation Setup:
1. Install: pip install pydantic>=2.0.0
2. Used for validating configuration files and data models
3. Provides enhanced error messages and type checking
"""
)
}
def is_available(self, dependency_name: str) -> bool:
"""Check if a dependency is available"""
if dependency_name in self._availability_cache:
return self._availability_cache[dependency_name]
if dependency_name not in self.dependencies:
self.logger.warning(f"Unknown dependency: {dependency_name}")
return False
dep_info = self.dependencies[dependency_name]
try:
# Try to import the module
__import__(dep_info.import_name)
self._availability_cache[dependency_name] = True
return True
except ImportError:
self._availability_cache[dependency_name] = False
return False
def get_module(self, dependency_name: str, raise_on_missing: bool = False) -> Optional[Any]:
"""Get a module if available, with optional error raising"""
if dependency_name in self._dependency_cache:
return self._dependency_cache[dependency_name]
if not self.is_available(dependency_name):
if raise_on_missing:
raise ImportError(self._get_missing_dependency_message(dependency_name))
return None
dep_info = self.dependencies[dependency_name]
try:
module = __import__(dep_info.import_name)
self._dependency_cache[dependency_name] = module
return module
except ImportError as e:
if raise_on_missing:
raise ImportError(self._get_missing_dependency_message(dependency_name)) from e
return None
def get_class_from_module(self, dependency_name: str, class_name: str, raise_on_missing: bool = False) -> Optional[Any]:
"""Get a specific class from a module"""
module = self.get_module(dependency_name, raise_on_missing=False)
if module is None:
if raise_on_missing:
raise ImportError(self._get_missing_dependency_message(dependency_name))
return None
try:
return getattr(module, class_name)
except AttributeError as e:
if raise_on_missing:
raise ImportError(f"Class {class_name} not found in {dependency_name}") from e
return None
def require_dependency(self, dependency_name: str) -> Any:
"""Require a dependency, raising detailed error if not available"""
module = self.get_module(dependency_name, raise_on_missing=True)
return module
def check_all_dependencies(self) -> Dict[str, Dict[str, Any]]:
"""Check status of all known dependencies"""
results = {}
for dep_name, dep_info in self.dependencies.items():
is_avail = self.is_available(dep_name)
results[dep_name] = {
'available': is_avail,
'name': dep_info.name,
'description': dep_info.description,
'required_for': dep_info.required_for,
'install_command': dep_info.install_command,
'setup_instructions': dep_info.setup_instructions
}
if is_avail:
# Try to get version info
try:
module = self.get_module(dep_name)
if hasattr(module, '__version__'):
results[dep_name]['version'] = module.__version__
elif hasattr(module, 'version'):
results[dep_name]['version'] = module.version
except:
pass
return results
def get_missing_dependencies(self) -> List[str]:
"""Get list of missing dependencies"""
missing = []
for dep_name in self.dependencies:
if not self.is_available(dep_name):
missing.append(dep_name)
return missing
def get_installation_instructions(self, missing_only: bool = True) -> str:
"""Get installation instructions for dependencies"""
deps_to_show = self.get_missing_dependencies() if missing_only else list(self.dependencies.keys())
if not deps_to_show:
return "✓ All dependencies are available!"
instructions = []
instructions.append("Missing Dependencies Installation Guide:")
instructions.append("=" * 50)
for dep_name in deps_to_show:
dep_info = self.dependencies[dep_name]
instructions.append(f"\n📦 {dep_info.name}")
instructions.append(f" Description: {dep_info.description}")
instructions.append(f" Required for: {', '.join(dep_info.required_for)}")
instructions.append(f" Install: {dep_info.install_command}")
if dep_info.alternative_packages:
instructions.append(f" Alternatives: {', '.join(dep_info.alternative_packages)}")
if dep_info.setup_instructions:
instructions.append(f" Setup:{dep_info.setup_instructions}")
instructions.append("\n" + "=" * 50)
instructions.append("Quick install all missing dependencies:")
install_commands = [self.dependencies[dep].install_command for dep in deps_to_show]
instructions.append(" && ".join(install_commands))
return "\n".join(instructions)
def _get_missing_dependency_message(self, dependency_name: str) -> str:
"""Get detailed error message for missing dependency"""
if dependency_name not in self.dependencies:
return f"Unknown dependency: {dependency_name}"
dep_info = self.dependencies[dependency_name]
message_parts = [
f"Missing required dependency: {dep_info.name}",
f"Description: {dep_info.description}",
f"Required for: {', '.join(dep_info.required_for)}",
"",
f"To install: {dep_info.install_command}",
]
if dep_info.alternative_packages:
message_parts.append(f"Alternatives: {', '.join(dep_info.alternative_packages)}")
if dep_info.setup_instructions:
message_parts.append("")
message_parts.append("Setup Instructions:")
message_parts.append(dep_info.setup_instructions)
return "\n".join(message_parts)
def create_graceful_import_wrapper(self, dependency_name: str, fallback_message: Optional[str] = None):
"""Create a wrapper that provides graceful degradation for missing dependencies"""
def wrapper(func: Callable) -> Callable:
def inner(*args, **kwargs):
if not self.is_available(dependency_name):
error_msg = fallback_message or f"Feature unavailable: {dependency_name} is not installed"
detailed_msg = self._get_missing_dependency_message(dependency_name)
# Log the detailed message
self.logger.error(f"Dependency missing: {detailed_msg}")
# Return a user-friendly error
try:
from agent_core import SkillResult
except ImportError:
# Fallback if agent_core is not available
class SkillResult:
def __init__(self, success, error=None, message=""):
self.success = success
self.error = error
self.message = message
return SkillResult(
success=False,
error=error_msg,
message=f"Please install {dependency_name} to use this feature"
)
return func(*args, **kwargs)
return inner
return wrapper
def get_dependency_status_report(self) -> str:
"""Get a comprehensive dependency status report"""
all_deps = self.check_all_dependencies()
available = [name for name, info in all_deps.items() if info['available']]
missing = [name for name, info in all_deps.items() if not info['available']]
report = []
report.append("Dependency Status Report")
report.append("=" * 30)
if available:
report.append(f"\n✓ Available ({len(available)}):")
for dep_name in available:
info = all_deps[dep_name]
version = info.get('version', 'unknown version')
report.append(f"{info['name']} ({version})")
if missing:
report.append(f"\n✗ Missing ({len(missing)}):")
for dep_name in missing:
info = all_deps[dep_name]
report.append(f"{info['name']} - {info['description']}")
report.append(f" Install: {info['install_command']}")
if missing:
report.append(f"\nTo install all missing dependencies:")
install_commands = [all_deps[dep]['install_command'] for dep in missing]
report.append(" && ".join(install_commands))
else:
report.append(f"\n🎉 All dependencies are available!")
return "\n".join(report)
# Global dependency manager instance
dependency_manager = DependencyManager()
def get_dependency_manager() -> DependencyManager:
"""Get the global dependency manager instance"""
return dependency_manager
def check_dependency(dependency_name: str) -> bool:
"""Quick check if a dependency is available"""
return dependency_manager.is_available(dependency_name)
def require_dependency(dependency_name: str) -> Any:
"""Require a dependency, raising detailed error if not available"""
return dependency_manager.require_dependency(dependency_name)
def get_optional_module(dependency_name: str) -> Optional[Any]:
"""Get a module if available, None otherwise"""
return dependency_manager.get_module(dependency_name, raise_on_missing=False)
def graceful_import(dependency_name: str, fallback_message: Optional[str] = None):
"""Decorator for graceful dependency handling"""
return dependency_manager.create_graceful_import_wrapper(dependency_name, fallback_message)
+1118
View File
File diff suppressed because it is too large Load Diff
+1297
View File
File diff suppressed because it is too large Load Diff
+284
View File
@@ -0,0 +1,284 @@
"""
日记整理 Agent 主入口
支持命令行调用和外部集成
"""
import argparse
import asyncio
import json
import logging
import sys
from pathlib import Path
from typing import Dict, Any, Optional
import sys
from pathlib import Path
from typing import Dict, Any, Optional
# Handle imports with both relative and absolute paths
try:
from .dependency_manager import get_dependency_manager
from .agent_core import Agent, SkillResult
from .commands.organize_command import OrganizeCommand
except ImportError:
# Fallback to absolute imports when running as script
from dependency_manager import get_dependency_manager
from agent_core import Agent, SkillResult
from commands.organize_command import OrganizeCommand
# Try to import yaml with graceful degradation
dependency_manager = get_dependency_manager()
yaml = dependency_manager.get_module('yaml')
class JournalOrganizerAgent:
"""日记整理 Agent"""
def __init__(self, config_file: Optional[str] = None):
"""
初始化 Agent
Args:
config_file: 配置文件路径
"""
self.logger = logging.getLogger("JournalOrganizerAgent")
self.config = self._load_config(config_file)
self.agent = Agent("JournalOrganizer", self.config)
self._register_commands()
def _load_config(self, config_file: Optional[str] = None) -> Dict[str, Any]:
"""
加载配置文件
Args:
config_file: 配置文件路径
Returns:
配置字典
"""
if config_file and Path(config_file).exists():
config_path = Path(config_file)
with config_path.open("r", encoding="utf-8") as f:
if config_path.suffix in [".yaml", ".yml"]:
return yaml.safe_load(f) or {}
elif config_path.suffix == ".json":
return json.load(f)
# 尝试从默认位置加载
default_paths = [
Path.home() / ".journal_organizer" / "config.yaml",
Path.home() / ".journal_organizer" / "config.json",
Path.cwd() / "config.yaml",
Path.cwd() / "config.json",
]
for path in default_paths:
if path.exists():
self.logger.info(f"{path} 加载配置")
with path.open("r", encoding="utf-8") as f:
if path.suffix in [".yaml", ".yml"]:
return yaml.safe_load(f) or {}
else:
return json.load(f)
self.logger.warning("未找到配置文件,使用默认配置")
return {}
def _register_commands(self) -> None:
"""注册所有命令"""
self.agent.register_command(OrganizeCommand())
async def run_command(
self,
command: str,
args: Optional[Dict[str, Any]] = None,
options: Optional[Dict[str, Any]] = None,
) -> SkillResult:
"""
运行命令
Args:
command: 命令名称
args: 命令参数
options: 命令选项
Returns:
SkillResult: 执行结果
"""
return await self.agent.execute_command(command, args, options)
def list_commands(self) -> list:
"""列出所有可用命令"""
return self.agent.list_commands()
def get_command_info(self, command: str) -> Dict[str, Any]:
"""获取命令信息"""
if command in self.agent.commands:
return self.agent.commands[command].get_info()
return {}
def get_all_commands_info(self) -> Dict[str, Any]:
"""获取所有命令信息"""
return self.agent.get_commands_info()
async def main():
"""命令行主函数"""
parser = argparse.ArgumentParser(
description="Obsidian 智能日记整理 Agent",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
# 整理今天的日记
python -m journal_organizer organize
# 整理指定日期的日记
python -m journal_organizer organize --date 2025-12-31
# 列出所有可用命令
python -m journal_organizer list
# 显示命令帮助
python -m journal_organizer help organize
# 使用指定配置文件
python -m journal_organizer --config /path/to/config.yaml organize
""",
)
parser.add_argument("--config", type=str, help="配置文件路径")
parser.add_argument(
"--log-level",
type=str,
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
help="日志级别",
)
subparsers = parser.add_subparsers(dest="command", help="命令")
# organize 命令
organize_parser = subparsers.add_parser("organize", help="整理日记")
organize_parser.add_argument("--date", type=str, help="日期 (YYYY-MM-DD)")
organize_parser.add_argument("--vault-path", type=str, help="Obsidian vault 路径")
organize_parser.add_argument(
"--daily-folder", type=str, default="Daily", help="日记文件夹"
)
# list 命令
subparsers.add_parser("list", help="列出所有可用命令")
# help 命令
help_parser = subparsers.add_parser("help", help="显示命令帮助")
help_parser.add_argument("help_command", nargs="?", help="要查看帮助的命令")
# info 命令
subparsers.add_parser("info", help="显示 Agent 信息")
# check-deps 命令
subparsers.add_parser("check-deps", help="检查依赖项状态")
args = parser.parse_args()
# 设置日志
logging.basicConfig(
level=getattr(logging, args.log_level),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
# 初始化 Agent
agent = JournalOrganizerAgent(args.config)
# 处理命令
if not args.command:
parser.print_help()
return
if args.command == "organize":
# 构建参数
organize_args = {}
if args.date:
organize_args["date"] = args.date
if args.vault_path:
organize_args["vault_path"] = args.vault_path
if args.daily_folder:
organize_args["daily_folder"] = args.daily_folder
result = await agent.run_command("organize", organize_args)
print(f"\n{'='*50}")
print("执行结果")
print("=" * 50)
print(result.to_json())
return 0 if result.success else 1
elif args.command == "list":
commands = agent.list_commands()
print("\n可用命令:")
for cmd in commands:
print(f" - {cmd}")
return 0
elif args.command == "help":
if args.help_command:
info = agent.get_command_info(args.help_command)
if info:
print(f"\n命令: {info['name']}")
print(f"描述: {info['description']}")
if info.get("aliases"):
print(f"别名: {', '.join(info['aliases'])}")
print("\nSkills:")
for skill_name, skill_info in info.get("skills", {}).items():
print(f" - {skill_name}: {skill_info['description']}")
else:
print(f"未找到命令: {args.help_command}")
return 1
else:
parser.print_help()
return 0
elif args.command == "info":
info = agent.get_all_commands_info()
print(f"\n{json.dumps(info, ensure_ascii=False, indent=2)}")
return 0
elif args.command == "check-deps":
print("\n🔍 检查依赖项状态...")
print(dependency_manager.get_dependency_status_report())
missing_deps = dependency_manager.get_missing_dependencies()
if missing_deps:
print(f"\n📋 安装说明:")
print(dependency_manager.get_installation_instructions(missing_only=True))
return 1
else:
print(f"\n✅ 所有依赖项都已正确安装!")
return 0
def run_command_sync(
command: str,
args: Optional[Dict[str, Any]] = None,
config_file: Optional[str] = None,
) -> Dict[str, Any]:
"""
同步运行命令用于外部调用
Args:
command: 命令名称
args: 命令参数
config_file: 配置文件路径
Returns:
执行结果字典
"""
agent = JournalOrganizerAgent(config_file)
result = asyncio.run(agent.run_command(command, args))
return result.to_dict()
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code or 0)
+583
View File
@@ -0,0 +1,583 @@
"""
Path sanitization and security utilities
Provides comprehensive path validation and sanitization to prevent security issues
"""
import logging
import os
import re
from pathlib import Path, PurePath
from typing import Optional, List, Tuple, Union
try:
from .error_handling import ValidationError, SecurityError
except ImportError:
from error_handling import ValidationError, SecurityError
class PathSanitizer:
"""Comprehensive path sanitization and security validation"""
def __init__(self):
self.logger = logging.getLogger(__name__)
# Dangerous path patterns
self.dangerous_patterns = [
r'\.\./', # Directory traversal
r'\.\.\.', # Multiple dots
r'~/', # Home directory reference
r'\$\{.*\}', # Environment variable expansion
r'%[A-Za-z0-9_]+%', # Windows environment variables
]
# Dangerous characters in file paths
self.dangerous_chars = {
'<': 'less_than',
'>': 'greater_than',
':': 'colon',
'"': 'quote',
'|': 'pipe',
'?': 'question',
'*': 'asterisk',
'\x00': 'null_byte',
'\n': 'newline',
'\r': 'carriage_return',
'\t': 'tab'
}
# Reserved names (Windows)
self.reserved_names = {
'CON', 'PRN', 'AUX', 'NUL',
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'
}
# Maximum path lengths
self.max_path_length = 4096 # Unix/Linux limit
self.max_filename_length = 255 # Most filesystems
self.max_path_depth = 32 # Reasonable depth limit
def sanitize_vault_relative_path(
self,
path: Union[str, Path],
vault_root: Optional[Union[str, Path]] = None,
allow_creation: bool = True
) -> str:
"""
Sanitize a path that should be relative to an Obsidian vault
Args:
path: Path to sanitize (should be relative to vault)
vault_root: Optional vault root path for additional validation
allow_creation: Whether to allow paths that don't exist yet
Returns:
Sanitized relative path
Raises:
ValidationError: If path is invalid or unsafe
SecurityError: If path poses security risks
"""
if not path:
raise ValidationError(
message="Path cannot be empty",
field_name="path",
validation_rule="non_empty"
)
# Convert to string and normalize
path_str = str(path).strip()
# Basic security checks
self._check_dangerous_patterns(path_str)
self._check_dangerous_characters(path_str)
# Normalize path separators
normalized_path = path_str.replace('\\', '/')
# Remove leading/trailing slashes for relative paths
normalized_path = normalized_path.strip('/')
# Check for absolute path attempts
if os.path.isabs(path_str) or path_str.startswith('/'):
raise SecurityError(
message="Absolute paths are not allowed - path must be relative to vault",
security_issue="absolute_path_attempt",
attempted_path=path_str
)
# Check for directory traversal
if '..' in normalized_path:
raise SecurityError(
message="Directory traversal is not allowed",
security_issue="directory_traversal",
attempted_path=path_str
)
# Validate path components
path_parts = normalized_path.split('/')
self._validate_path_components(path_parts)
# Check path length and depth
self._validate_path_constraints(normalized_path, path_parts)
# Additional validation if vault root is provided
if vault_root:
self._validate_against_vault_root(normalized_path, vault_root, allow_creation)
return normalized_path
def sanitize_absolute_path(
self,
path: Union[str, Path],
allowed_roots: Optional[List[Union[str, Path]]] = None,
must_exist: bool = True
) -> str:
"""
Sanitize an absolute path with security checks
Args:
path: Absolute path to sanitize
allowed_roots: List of allowed root directories
must_exist: Whether the path must exist
Returns:
Sanitized absolute path
Raises:
ValidationError: If path is invalid
SecurityError: If path poses security risks
"""
if not path:
raise ValidationError(
message="Path cannot be empty",
field_name="path",
validation_rule="non_empty"
)
# Convert to Path object for better handling
try:
path_obj = Path(path).resolve()
except (OSError, ValueError) as e:
raise ValidationError(
message=f"Invalid path format: {e}",
field_name="path",
validation_rule="path_format"
)
path_str = str(path_obj)
# Basic security checks
self._check_dangerous_patterns(path_str)
self._check_dangerous_characters(path_str)
# Check if path exists (if required)
if must_exist and not path_obj.exists():
raise ValidationError(
message=f"Path does not exist: {path_str}",
field_name="path",
validation_rule="path_exists"
)
# Check against allowed roots
if allowed_roots:
self._validate_against_allowed_roots(path_obj, allowed_roots)
# Check for symbolic link attacks
self._check_symbolic_links(path_obj)
# Validate path components
path_parts = path_obj.parts
self._validate_path_components(path_parts)
return path_str
def sanitize_filename(
self,
filename: str,
allow_extensions: Optional[List[str]] = None,
max_length: Optional[int] = None
) -> str:
"""
Sanitize a filename with security checks
Args:
filename: Filename to sanitize
allow_extensions: List of allowed file extensions
max_length: Maximum filename length
Returns:
Sanitized filename
Raises:
ValidationError: If filename is invalid
SecurityError: If filename poses security risks
"""
if not filename:
raise ValidationError(
message="Filename cannot be empty",
field_name="filename",
validation_rule="non_empty"
)
# Remove path separators (filename only)
clean_filename = filename.replace('/', '').replace('\\', '')
# Basic security checks
self._check_dangerous_characters(clean_filename)
# Check for reserved names
name_without_ext = clean_filename.split('.')[0].upper()
if name_without_ext in self.reserved_names:
raise SecurityError(
message=f"Filename uses reserved name: {name_without_ext}",
security_issue="reserved_filename",
attempted_path=filename
)
# Check filename length
max_len = max_length or self.max_filename_length
if len(clean_filename) > max_len:
raise ValidationError(
message=f"Filename too long (max {max_len} characters)",
field_name="filename",
validation_rule="max_length"
)
# Check file extension if restrictions apply
if allow_extensions:
file_ext = Path(clean_filename).suffix.lower()
if file_ext not in allow_extensions:
raise ValidationError(
message=f"File extension not allowed. Allowed: {', '.join(allow_extensions)}",
field_name="filename",
validation_rule="extension_not_allowed"
)
# Check for hidden files (starting with dot)
if clean_filename.startswith('.') and clean_filename != '.obsidian':
self.logger.warning(f"Hidden file detected: {clean_filename}")
return clean_filename
def validate_vault_structure(self, vault_path: Union[str, Path]) -> Tuple[bool, List[str]]:
"""
Validate that a directory is a proper Obsidian vault
Args:
vault_path: Path to validate as vault
Returns:
Tuple of (is_valid, list_of_issues)
"""
issues = []
vault_path_obj = Path(vault_path)
# Check if path exists and is directory
if not vault_path_obj.exists():
issues.append(f"Vault path does not exist: {vault_path}")
return False, issues
if not vault_path_obj.is_dir():
issues.append(f"Vault path is not a directory: {vault_path}")
return False, issues
# Check for .obsidian directory
obsidian_dir = vault_path_obj / '.obsidian'
if not obsidian_dir.exists():
issues.append("No .obsidian directory found - this may not be an Obsidian vault")
# Check permissions
if not os.access(vault_path_obj, os.R_OK):
issues.append(f"Vault directory is not readable: {vault_path}")
if not os.access(vault_path_obj, os.W_OK):
issues.append(f"Vault directory is not writable: {vault_path}")
# Check for suspicious files/directories
try:
for item in vault_path_obj.iterdir():
if item.name.startswith('..'):
issues.append(f"Suspicious directory name found: {item.name}")
# Check for executable files in vault (potential security risk)
if item.is_file() and item.suffix.lower() in ['.exe', '.bat', '.sh', '.cmd']:
issues.append(f"Executable file found in vault: {item.name}")
except PermissionError:
issues.append("Cannot read vault directory contents - permission denied")
return len(issues) == 0, issues
def create_safe_path(
self,
base_path: Union[str, Path],
relative_path: str,
create_dirs: bool = False
) -> Path:
"""
Safely create a path by joining base and relative paths
Args:
base_path: Base directory path
relative_path: Relative path to join
create_dirs: Whether to create intermediate directories
Returns:
Safe combined path
Raises:
SecurityError: If the resulting path would be unsafe
"""
# Sanitize the relative path first
safe_relative = self.sanitize_vault_relative_path(relative_path, allow_creation=True)
# Create the combined path
base_path_obj = Path(base_path).resolve()
combined_path = base_path_obj / safe_relative
# Ensure the result is still within the base path
try:
combined_path.resolve().relative_to(base_path_obj.resolve())
except ValueError:
raise SecurityError(
message="Resulting path would be outside base directory",
security_issue="path_escape",
attempted_path=str(combined_path)
)
# Create directories if requested
if create_dirs and not combined_path.parent.exists():
try:
combined_path.parent.mkdir(parents=True, exist_ok=True)
self.logger.info(f"Created directory: {combined_path.parent}")
except (OSError, PermissionError) as e:
raise ValidationError(
message=f"Cannot create directory: {e}",
field_name="path",
validation_rule="directory_creation"
)
return combined_path
def _check_dangerous_patterns(self, path: str) -> None:
"""Check for dangerous patterns in path"""
for pattern in self.dangerous_patterns:
if re.search(pattern, path, re.IGNORECASE):
raise SecurityError(
message=f"Dangerous pattern detected in path: {pattern}",
security_issue="dangerous_pattern",
attempted_path=path
)
def _check_dangerous_characters(self, path: str) -> None:
"""Check for dangerous characters in path"""
for char, char_name in self.dangerous_chars.items():
if char in path:
raise SecurityError(
message=f"Dangerous character '{char}' ({char_name}) found in path",
security_issue="dangerous_character",
attempted_path=path
)
def _validate_path_components(self, path_parts: Union[List[str], Tuple[str, ...]]) -> None:
"""Validate individual path components"""
for part in path_parts:
if not part: # Empty component
continue
# Check for reserved names
if part.upper() in self.reserved_names:
raise SecurityError(
message=f"Path component uses reserved name: {part}",
security_issue="reserved_name",
attempted_path=str(path_parts)
)
# Check component length
if len(part) > self.max_filename_length:
raise ValidationError(
message=f"Path component too long: {part} (max {self.max_filename_length})",
field_name="path_component",
validation_rule="max_length"
)
# Check for control characters
if any(ord(c) < 32 for c in part):
raise SecurityError(
message=f"Path component contains control characters: {part}",
security_issue="control_characters",
attempted_path=part
)
def _validate_path_constraints(self, path: str, path_parts: List[str]) -> None:
"""Validate path length and depth constraints"""
# Check total path length
if len(path) > self.max_path_length:
raise ValidationError(
message=f"Path too long (max {self.max_path_length} characters)",
field_name="path",
validation_rule="max_path_length"
)
# Check path depth
if len(path_parts) > self.max_path_depth:
raise ValidationError(
message=f"Path too deep (max {self.max_path_depth} levels)",
field_name="path",
validation_rule="max_path_depth"
)
def _validate_against_vault_root(
self,
relative_path: str,
vault_root: Union[str, Path],
allow_creation: bool
) -> None:
"""Validate path against vault root"""
vault_root_obj = Path(vault_root)
full_path = vault_root_obj / relative_path
# Ensure path stays within vault
try:
full_path.resolve().relative_to(vault_root_obj.resolve())
except ValueError:
raise SecurityError(
message="Path would escape vault directory",
security_issue="vault_escape",
attempted_path=relative_path
)
# Check if parent directory exists (for file creation)
if not allow_creation and not full_path.parent.exists():
raise ValidationError(
message=f"Parent directory does not exist: {full_path.parent}",
field_name="path",
validation_rule="parent_directory_exists"
)
def _validate_against_allowed_roots(
self,
path_obj: Path,
allowed_roots: List[Union[str, Path]]
) -> None:
"""Validate path is within allowed root directories"""
path_resolved = path_obj.resolve()
for allowed_root in allowed_roots:
try:
allowed_root_obj = Path(allowed_root).resolve()
path_resolved.relative_to(allowed_root_obj)
return # Path is within this allowed root
except ValueError:
continue # Try next allowed root
# Path is not within any allowed root
raise SecurityError(
message=f"Path is not within any allowed root directory",
security_issue="unauthorized_path",
attempted_path=str(path_obj)
)
def _check_symbolic_links(self, path_obj: Path) -> None:
"""Check for symbolic link attacks"""
# Check if any part of the path is a symbolic link
current_path = path_obj
while current_path != current_path.parent:
if current_path.is_symlink():
self.logger.warning(f"Symbolic link detected in path: {current_path}")
# Don't reject, but log for security monitoring
break
current_path = current_path.parent
class PathSecurityManager:
"""High-level path security management"""
def __init__(self, vault_root: Optional[Union[str, Path]] = None):
self.vault_root = Path(vault_root) if vault_root else None
self.sanitizer = PathSanitizer()
self.logger = logging.getLogger(__name__)
def set_vault_root(self, vault_root: Union[str, Path]) -> None:
"""Set the vault root directory"""
self.vault_root = Path(vault_root)
# Validate vault structure
is_valid, issues = self.sanitizer.validate_vault_structure(self.vault_root)
if not is_valid:
self.logger.warning(f"Vault validation issues: {'; '.join(issues)}")
def get_safe_vault_path(self, relative_path: str, create_dirs: bool = False) -> Path:
"""
Get a safe path within the vault
Args:
relative_path: Relative path within vault
create_dirs: Whether to create intermediate directories
Returns:
Safe absolute path within vault
Raises:
ValidationError: If vault root not set or path invalid
"""
if not self.vault_root:
raise ValidationError(
message="Vault root not configured",
field_name="vault_root",
validation_rule="not_configured"
)
return self.sanitizer.create_safe_path(
self.vault_root,
relative_path,
create_dirs=create_dirs
)
def validate_file_operation(
self,
file_path: str,
operation: str = "read",
content_length: Optional[int] = None
) -> Tuple[bool, Optional[str]]:
"""
Validate a file operation for security
Args:
file_path: Path to file
operation: Type of operation (read, write, append, delete)
content_length: Length of content for write operations
Returns:
Tuple of (is_allowed, reason_if_not_allowed)
"""
try:
# Sanitize the path
safe_path = self.sanitizer.sanitize_vault_relative_path(
file_path,
self.vault_root,
allow_creation=(operation in ['write', 'append'])
)
# Additional checks based on operation
if operation == 'write' and content_length:
# Check for reasonable content size limits
max_content_size = 10 * 1024 * 1024 # 10MB
if content_length > max_content_size:
return False, f"Content too large ({content_length} bytes, max {max_content_size})"
# Check file extension for security
file_ext = Path(safe_path).suffix.lower()
dangerous_extensions = ['.exe', '.bat', '.sh', '.cmd', '.scr', '.vbs', '.js']
if file_ext in dangerous_extensions:
return False, f"Dangerous file extension: {file_ext}"
return True, None
except (ValidationError, SecurityError) as e:
return False, str(e)
# Global instances
path_sanitizer = PathSanitizer()
path_security_manager = PathSecurityManager()
+20
View File
@@ -0,0 +1,20 @@
[tool:pytest]
testpaths = tests
python_files = test_*.py *_test.py
python_classes = Test*
python_functions = test_*
addopts =
--verbose
--tb=short
--cov=.
--cov-report=term-missing
--cov-report=html:htmlcov
--cov-exclude=tests/*
--cov-exclude=__pycache__/*
--cov-exclude=.kiro/*
asyncio_mode = auto
markers =
unit: Unit tests
integration: Integration tests
property: Property-based tests
slow: Slow running tests
+14
View File
@@ -0,0 +1,14 @@
# Obsidian 智能日记整理 Agent 依赖
# 核心依赖
anthropic>=0.25.0
aiohttp>=3.9.0
pyyaml>=6.0
pydantic>=2.0.0
# 测试依赖
pytest>=7.0.0
pytest-asyncio>=0.21.0
pytest-cov>=4.0.0
hypothesis>=6.0.0
aioresponses>=0.7.0
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""
Test runner script for the journal organizer project.
Provides easy access to run different types of tests.
"""
import sys
import subprocess
from pathlib import Path
def run_command(cmd, description):
"""Run a command and handle the result"""
print(f"\n{'='*60}")
print(f"Running: {description}")
print(f"Command: {' '.join(cmd)}")
print('='*60)
try:
result = subprocess.run(cmd, check=True, capture_output=False)
print(f"\n{description} - PASSED")
return True
except subprocess.CalledProcessError as e:
print(f"\n{description} - FAILED (exit code: {e.returncode})")
return False
def main():
"""Main test runner"""
if len(sys.argv) < 2:
print("Usage: python run_tests.py [unit|integration|all|coverage]")
print("\nOptions:")
print(" unit - Run unit tests only")
print(" integration - Run integration tests only (may have import issues)")
print(" all - Run all tests")
print(" coverage - Run tests with coverage report")
print(" help - Show this help message")
return
test_type = sys.argv[1].lower()
if test_type == "help":
main()
return
# Base pytest command
base_cmd = ["python", "-m", "pytest", "-v"]
success = True
if test_type == "unit":
cmd = base_cmd + ["tests/unit/"]
success = run_command(cmd, "Unit Tests")
elif test_type == "integration":
cmd = base_cmd + ["tests/integration/"]
success = run_command(cmd, "Integration Tests")
elif test_type == "all":
# Run unit tests first
cmd = base_cmd + ["tests/unit/"]
success = run_command(cmd, "Unit Tests")
if success:
# Run integration tests
cmd = base_cmd + ["tests/integration/"]
success = run_command(cmd, "Integration Tests") and success
elif test_type == "coverage":
cmd = base_cmd + ["--cov=.", "--cov-report=term-missing", "--cov-report=html:htmlcov", "tests/unit/"]
success = run_command(cmd, "Unit Tests with Coverage")
if success:
print(f"\n📊 Coverage report generated in htmlcov/index.html")
else:
print(f"Unknown test type: {test_type}")
print("Use 'python run_tests.py help' for usage information")
return
# Summary
print(f"\n{'='*60}")
if success:
print("🎉 All tests completed successfully!")
else:
print("💥 Some tests failed. Check the output above for details.")
print('='*60)
if __name__ == "__main__":
main()
+260
View File
@@ -0,0 +1,260 @@
"""
Journal Organizer Agent HTTP 服务器
提供 REST API 接口 Obsidian 插件调用
"""
import json
import logging
from datetime import datetime
from typing import Dict, List, Any
from flask import Flask, request, jsonify
from flask_cors import CORS
from .conversation.conversational_agent import ConversationalAgent
from .config import Config
# 配置日志
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
# 创建 Flask 应用
app = Flask(__name__)
CORS(app) # 启用 CORS 支持
# 初始化 Agent
config = Config()
agent = ConversationalAgent(config)
# 存储对话历史
conversations: Dict[str, List[Dict[str, Any]]] = {}
@app.route("/health", methods=["GET"])
def health_check():
"""
健康检查端点
Returns:
JSON: { "status": "ok", "timestamp": "ISO 8601 时间戳" }
"""
return jsonify(
{"status": "ok", "timestamp": datetime.now().isoformat(), "agent": "ready"}
)
@app.route("/api/chat", methods=["POST"])
def chat():
"""
对话端点
Request JSON:
{
"message": "用户消息",
"conversation_id": "对话 ID",
"timestamp": "ISO 8601 时间戳"
}
Response JSON:
{
"message": "Agent 响应",
"suggestions": ["建议1", "建议2"],
"status": "success|error|waiting_input",
"conversation_id": "对话 ID",
"timestamp": "ISO 8601 时间戳"
}
"""
try:
data = request.get_json()
if not data or "message" not in data:
return jsonify({"message": "错误:缺少 'message' 字段", "status": "error"}), 400
user_message = data["message"]
conversation_id = data.get("conversation_id", "default")
logger.info(f"[{conversation_id}] 用户消息: {user_message}")
# 初始化对话历史
if conversation_id not in conversations:
conversations[conversation_id] = []
# 添加用户消息到历史
conversations[conversation_id].append(
{
"role": "user",
"content": user_message,
"timestamp": datetime.now().isoformat(),
}
)
# 调用 Agent 处理消息
response = agent.process_message(
user_message, conversation_id, conversations[conversation_id]
)
# 添加 Agent 响应到历史
conversations[conversation_id].append(
{
"role": "assistant",
"content": response.get("message", ""),
"timestamp": datetime.now().isoformat(),
}
)
logger.info(f"[{conversation_id}] Agent 响应: {response['message'][:100]}...")
return jsonify(
{
"message": response.get("message", ""),
"suggestions": response.get("suggestions", []),
"status": response.get("status", "success"),
"conversation_id": conversation_id,
"timestamp": datetime.now().isoformat(),
}
)
except Exception as e:
logger.error(f"Chat 端点错误: {e}", exc_info=True)
return jsonify({"message": f"服务器错误: {str(e)}", "status": "error"}), 500
@app.route("/api/history/<conversation_id>", methods=["GET"])
def get_history(conversation_id: str):
"""
获取对话历史
Args:
conversation_id: 对话 ID
Response JSON:
{
"messages": [
{ "role": "user", "content": "...", "timestamp": "..." },
{ "role": "assistant", "content": "...", "timestamp": "..." }
],
"conversation_id": "对话 ID"
}
"""
try:
messages = conversations.get(conversation_id, [])
return jsonify(
{
"messages": messages,
"conversation_id": conversation_id,
"count": len(messages),
}
)
except Exception as e:
logger.error(f"获取历史错误: {e}")
return jsonify({"message": f"错误: {str(e)}", "status": "error"}), 500
@app.route("/api/history/<conversation_id>", methods=["DELETE"])
def clear_history(conversation_id: str):
"""
清除对话历史
Args:
conversation_id: 对话 ID
Response JSON:
{ "status": "success", "conversation_id": "对话 ID" }
"""
try:
if conversation_id in conversations:
del conversations[conversation_id]
logger.info(f"已清除对话历史: {conversation_id}")
return jsonify({"status": "success", "conversation_id": conversation_id})
except Exception as e:
logger.error(f"清除历史错误: {e}")
return jsonify({"message": f"错误: {str(e)}", "status": "error"}), 500
@app.route("/api/suggestions/<conversation_id>", methods=["GET"])
def get_suggestions(conversation_id: str):
"""
获取建议
Args:
conversation_id: 对话 ID
Response JSON:
{
"suggestions": ["建议1", "建议2"],
"conversation_id": "对话 ID"
}
"""
try:
history = conversations.get(conversation_id, [])
# 调用 Agent 生成建议
suggestions = agent.generate_suggestions(history)
return jsonify({"suggestions": suggestions, "conversation_id": conversation_id})
except Exception as e:
logger.error(f"获取建议错误: {e}")
return jsonify({"suggestions": [], "status": "error"}), 500
@app.route("/api/status", methods=["GET"])
def get_status():
"""
获取 Agent 状态
Response JSON:
{
"status": "ready",
"conversations": 对话数量,
"uptime": "运行时间",
"version": "版本号"
}
"""
return jsonify(
{
"status": "ready",
"conversations": len(conversations),
"timestamp": datetime.now().isoformat(),
"version": "1.0.0",
}
)
@app.errorhandler(404)
def not_found(error):
"""处理 404 错误"""
return jsonify({"message": "端点不存在", "status": "error"}), 404
@app.errorhandler(500)
def internal_error(error):
"""处理 500 错误"""
logger.error(f"内部服务器错误: {error}")
return jsonify({"message": "内部服务器错误", "status": "error"}), 500
def run_server(host: str = "0.0.0.0", port: int = 5000, debug: bool = False):
"""
启动 HTTP 服务器
Args:
host: 绑定的主机地址
port: 绑定的端口
debug: 是否启用调试模式
"""
logger.info(f"启动 Journal Organizer Agent 服务器...")
logger.info(f"监听地址: {host}:{port}")
logger.info(f"调试模式: {debug}")
app.run(host=host, port=port, debug=debug, threaded=True)
if __name__ == "__main__":
run_server(debug=True)
+23
View File
@@ -0,0 +1,23 @@
"""
Skills 模块
"""
from .obsidian_skill import (
ObsidianReadSkill,
ObsidianWriteSkill,
ObsidianAppendSkill,
ObsidianListFilesSkill,
)
from .claude_skill import (
ClaudeAnalyzeSkill,
ClaudeTransformSkill,
)
__all__ = [
"ObsidianReadSkill",
"ObsidianWriteSkill",
"ObsidianAppendSkill",
"ObsidianListFilesSkill",
"ClaudeAnalyzeSkill",
"ClaudeTransformSkill",
]
+467
View File
@@ -0,0 +1,467 @@
"""
Claude AI 集成 Skill
负责与 Claude API 的交互和内容分析
Enhanced with configurable API URL support
"""
import json
import logging
from datetime import datetime
from typing import Dict, Any, Optional, List, Union
try:
from ..dependency_manager import get_dependency_manager
from ..claude_api_client import ClaudeAPIClient
from ..config_validation import ClaudeAPIConfig
# Try to import anthropic with graceful degradation
dependency_manager = get_dependency_manager()
Anthropic = dependency_manager.get_class_from_module('anthropic', 'Anthropic')
AsyncAnthropic = dependency_manager.get_class_from_module('anthropic', 'AsyncAnthropic')
except ImportError:
# Fallback for backward compatibility
try:
from anthropic import Anthropic, AsyncAnthropic
from claude_api_client import ClaudeAPIClient
from config_validation import ClaudeAPIConfig
except ImportError:
AsyncAnthropic = None
Anthropic = None
ClaudeAPIClient = None
ClaudeAPIConfig = None
from ..agent_core import Skill, SkillType, SkillResult, CommandContext
from ..api_response_validation import validate_api_response
from ..error_handling import (
ErrorHandler,
APIError,
ConfigurationError,
ValidationError,
ErrorContext,
get_error_handler,
)
from ..input_validation import command_input_validator
class ClaudeAnalyzeSkill(Skill):
"""Claude 日记分析 Skill"""
def __init__(self) -> None:
super().__init__(
name="claude_analyze",
skill_type=SkillType.ANALYZE,
description="使用 Claude 分析日记内容并提取关键信息",
)
self.client: Optional[ClaudeAPIClient] = None
self.error_handler = get_error_handler()
def _get_analysis_prompt(self, categories: List[str]) -> str:
"""
获取分析 prompt
Args:
categories: 分类列表
Returns:
prompt 字符串
"""
categories_str: str = "".join(categories) if categories else "技术学习、项目管理、个人成长"
return f"""你是一个专业的日记分析助手。请分析以下日记内容,并按照指定的格式提取关键信息。
分析要求
1. 提取经验和见解Experiences日记中提到的重要经验发现或见解
2. 提取学到的知识Lessons Learned具体学到的知识点最佳实践或原则
3. 提取待办事项Action Items需要采取行动的任务或改进项
4. 提取问题和挑战Problems遇到的问题挑战或障碍
5. 提取成就和进展Achievements完成的工作达成的目标或进展
6. 提取改进建议Improvements可以改进的方向或优化建议
分类类别{categories_str}
请以 JSON 格式返回结果结构如下
{{
"experiences": [
{{
"title": "标题",
"content": "详细内容",
"category": "分类",
"priority": "high/medium/low"
}}
],
"lessons_learned": [
{{
"lesson": "学到的内容",
"context": "背景信息",
"application": "如何应用"
}}
],
"action_items": [
{{
"task": "任务描述",
"priority": "high/medium/low",
"deadline": "建议截止日期",
"status": "new"
}}
],
"problems": [
{{
"problem": "问题描述",
"impact": "影响程度",
"proposed_solution": "建议方案"
}}
],
"achievements": [
{{
"achievement": "成就描述",
"significance": "重要性",
"evidence": "证据或细节"
}}
],
"improvements": [
{{
"area": "改进领域",
"current_state": "当前状态",
"suggested_change": "建议改进",
"expected_benefit": "预期收益"
}}
],
"summary": "日记的总体总结"
}}
日记内容
"""
async def execute(self, context: CommandContext, **kwargs: Any) -> SkillResult:
"""
分析日记内容
Args:
context: 命令执行上下文
**kwargs: 包含以下参数
- journal_content: 日记内容
- api_key: Claude API 密钥
- model: 模型名称默认 claude-3-5-sonnet-20241022
- categories: 分类列表可选
Returns:
SkillResult: 包含分析结果的结果
"""
try:
return await self._execute_analyze(context, **kwargs)
except (ValidationError, ConfigurationError, APIError) as e:
error_context = ErrorContext(
component="claude_analyze",
operation="analyze_journal",
user_message="Failed to analyze journal content with Claude AI",
technical_details=kwargs,
)
error_response = self.error_handler.handle_error(e, error_context)
return SkillResult(
success=error_response["success"],
error=error_response["error"],
message=error_response["message"],
)
except Exception as e:
api_error = APIError(
message=f"Unexpected error analyzing journal: {str(e)}",
api_name="claude",
cause=e,
)
error_context = ErrorContext(
component="claude_analyze",
operation="analyze_journal",
user_message="An unexpected error occurred while analyzing the journal",
)
error_response = self.error_handler.handle_error(api_error, error_context)
return SkillResult(
success=error_response["success"],
error=error_response["error"],
message=error_response["message"],
)
async def _execute_analyze(
self, context: CommandContext, **kwargs: Any
) -> SkillResult:
"""Internal method that performs the actual analysis"""
# Validate input parameters
validated_kwargs = command_input_validator.validate_skill_input(
'claude_analyze', kwargs
)
journal_content: str = validated_kwargs["journal_content"]
# Handle both legacy and new configuration formats
if 'claude_config' in validated_kwargs:
# New format: ClaudeAPIConfig object
claude_config = validated_kwargs['claude_config']
if not isinstance(claude_config, ClaudeAPIConfig):
raise ConfigurationError(
message="claude_config must be a ClaudeAPIConfig instance",
config_key="claude_config"
)
else:
# Legacy format: individual parameters
api_key: str = validated_kwargs["api_key"]
model: str = validated_kwargs.get("model", "claude-3-5-sonnet-20241022")
api_url: str = validated_kwargs.get("api_url", "https://api.anthropic.com")
max_tokens: int = validated_kwargs.get("max_tokens", 4096)
temperature: float = validated_kwargs.get("temperature", 0.7)
# Create ClaudeAPIConfig from legacy parameters
claude_config = ClaudeAPIConfig(
api_key=api_key,
model=model,
api_url=api_url,
max_tokens=max_tokens,
temperature=temperature
)
categories: List[str] = validated_kwargs.get("categories", [])
if not ClaudeAPIClient:
raise ConfigurationError(
message="ClaudeAPIClient is not available. Please check your installation.",
config_key="claude_api_client",
)
# Initialize enhanced client
try:
client = ClaudeAPIClient(claude_config)
except Exception as e:
raise ConfigurationError(
message=f"Failed to initialize Claude API client: {str(e)}",
config_key="claude_client_init",
cause=e
)
# Construct prompt
system_prompt: str = self._get_analysis_prompt(categories)
user_message: str = journal_content
self.logger.info(f"开始分析日记,模型: {claude_config.model}, API URL: {claude_config.api_url}")
try:
# Call Claude API using enhanced client
message = await client.create_message(
messages=[
{"role": "user", "content": f"{system_prompt}{user_message}"}
]
)
except APIError:
# Re-raise APIError as-is (already properly formatted)
raise
except Exception as e:
raise APIError(
message=f"Claude API call failed: {str(e)}",
api_name="claude",
cause=e
)
# Validate API response
validated_response = validate_api_response(
message.model_dump() if hasattr(message, 'model_dump') else message.__dict__,
"claude",
"analyze"
)
# Parse response
response_text: str = message.content[0].text
# Try to extract JSON
try:
# Find JSON block
json_start: int = response_text.find("{")
json_end: int = response_text.rfind("}") + 1
if json_start >= 0 and json_end > json_start:
json_str: str = response_text[json_start:json_end]
analysis_result: Dict[str, Any] = json.loads(json_str)
else:
# If no JSON found, return raw text
analysis_result = {
"raw_response": response_text,
"parse_error": "无法解析 JSON 格式",
}
except json.JSONDecodeError as e:
self.logger.warning(f"JSON 解析失败: {str(e)}")
analysis_result = {"raw_response": response_text, "parse_error": str(e)}
return SkillResult(
success=True,
data={
"analysis": analysis_result,
"model": claude_config.model,
"api_url": claude_config.api_url,
"analyzed_at": datetime.now().isoformat(),
"journal_length": len(journal_content),
"api_response": validated_response,
"client_info": client.get_client_info(),
},
message="成功分析日记内容",
)
class ClaudeTransformSkill(Skill):
"""Claude 内容转换 Skill"""
def __init__(self) -> None:
super().__init__(
name="claude_transform",
skill_type=SkillType.TRANSFORM,
description="使用 Claude 转换和格式化内容",
)
self.error_handler = get_error_handler()
async def execute(self, context: CommandContext, **kwargs: Any) -> SkillResult:
"""
转换内容格式
Args:
context: 命令执行上下文
**kwargs: 包含以下参数
- content: 要转换的内容
- transform_type: 转换类型markdown, html, summary
- api_key: Claude API 密钥
- model: 模型名称可选
Returns:
SkillResult: 包含转换结果的结果
"""
try:
return await self._execute_transform(context, **kwargs)
except (ValidationError, ConfigurationError, APIError) as e:
error_context = ErrorContext(
component="claude_transform",
operation="transform_content",
user_message="Failed to transform content with Claude AI",
technical_details=kwargs,
)
error_response = self.error_handler.handle_error(e, error_context)
return SkillResult(
success=error_response["success"],
error=error_response["error"],
message=error_response["message"],
)
except Exception as e:
api_error = APIError(
message=f"Unexpected error transforming content: {str(e)}",
api_name="claude",
cause=e,
)
error_context = ErrorContext(
component="claude_transform",
operation="transform_content",
user_message="An unexpected error occurred while transforming content",
)
error_response = self.error_handler.handle_error(api_error, error_context)
return SkillResult(
success=error_response["success"],
error=error_response["error"],
message=error_response["message"],
)
async def _execute_transform(
self, context: CommandContext, **kwargs: Any
) -> SkillResult:
"""Internal method that performs the actual transformation"""
content: Optional[str] = kwargs.get("content")
transform_type: str = kwargs.get("transform_type", "markdown")
if not content:
raise ValidationError(
message="Content is required for transformation",
field_name="content",
validation_rule="non_empty",
)
# Handle both legacy and new configuration formats
if 'claude_config' in kwargs:
# New format: ClaudeAPIConfig object
claude_config = kwargs['claude_config']
if not isinstance(claude_config, ClaudeAPIConfig):
raise ConfigurationError(
message="claude_config must be a ClaudeAPIConfig instance",
config_key="claude_config"
)
else:
# Legacy format: individual parameters
api_key: Optional[str] = kwargs.get("api_key")
model: str = kwargs.get("model", "claude-3-5-sonnet-20241022")
api_url: str = kwargs.get("api_url", "https://api.anthropic.com")
max_tokens: int = kwargs.get("max_tokens", 4096)
temperature: float = kwargs.get("temperature", 0.7)
if not api_key:
raise ConfigurationError(
message="Claude API key is required", config_key="api_key"
)
# Create ClaudeAPIConfig from legacy parameters
claude_config = ClaudeAPIConfig(
api_key=api_key,
model=model,
api_url=api_url,
max_tokens=max_tokens,
temperature=temperature
)
if not ClaudeAPIClient:
raise ConfigurationError(
message="ClaudeAPIClient is not available. Please check your installation.",
config_key="claude_api_client",
)
# Initialize enhanced client
try:
client = ClaudeAPIClient(claude_config)
except Exception as e:
raise ConfigurationError(
message=f"Failed to initialize Claude API client: {str(e)}",
config_key="claude_client_init",
cause=e
)
# Build prompt based on transformation type
prompts: Dict[str, str] = {
"markdown": "请将以下内容转换为格式良好的 Markdown 格式:",
"html": "请将以下内容转换为 HTML 格式:",
"summary": "请为以下内容生成一个简洁的总结:",
"outline": "请为以下内容生成一个结构化的大纲:",
"checklist": "请将以下内容转换为检查清单格式:",
}
system_prompt: str = prompts.get(transform_type, "请转换以下内容:")
self.logger.info(f"转换内容,类型: {transform_type}, 模型: {claude_config.model}")
try:
message = await client.create_message(
messages=[{"role": "user", "content": f"{system_prompt}\n\n{content}"}]
)
except APIError:
# Re-raise APIError as-is (already properly formatted)
raise
except Exception as e:
raise APIError(
message=f"Claude API call failed: {str(e)}",
api_name="claude",
cause=e
)
transformed_content: str = message.content[0].text
return SkillResult(
success=True,
data={
"original_length": len(content),
"transformed_length": len(transformed_content),
"transform_type": transform_type,
"transformed_content": transformed_content,
"transformed_at": datetime.now().isoformat(),
"model": claude_config.model,
"api_url": claude_config.api_url,
"client_info": client.get_client_info(),
},
message=f"成功转换内容为 {transform_type} 格式",
)
+517
View File
@@ -0,0 +1,517 @@
"""
Obsidian 集成 Skill
负责与 Obsidian Local REST API 的交互
"""
import ssl
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Dict, Any, Optional, List, Union, AsyncGenerator
from ..agent_core import Skill, SkillType, SkillResult, CommandContext
from ..api_response_validation import validate_api_response
from ..dependency_manager import get_dependency_manager
from ..error_handling import (
APIError,
ConfigurationError,
ValidationError,
ErrorContext,
get_error_handler,
)
from ..input_validation import command_input_validator
# Try to import aiohttp with graceful degradation
dependency_manager = get_dependency_manager()
aiohttp = dependency_manager.get_module('aiohttp')
@asynccontextmanager
async def obsidian_api_client(
api_url: str, api_key: str
) -> AsyncGenerator[Any, None]:
"""
Async context manager for Obsidian API client
Args:
api_url: Obsidian API URL
api_key: API key for authentication
Yields:
Configured aiohttp ClientSession
Raises:
ConfigurationError: If aiohttp is not available
"""
if aiohttp is None:
raise ConfigurationError(
message="aiohttp library is not installed. Please install it with: pip install aiohttp>=3.9.0",
config_key="aiohttp_dependency",
)
# Create SSL context (skip certificate verification for local development)
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
# Configure headers
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
# Create session with proper configuration
connector = aiohttp.TCPConnector(ssl=ssl_context)
async with aiohttp.ClientSession(connector=connector, headers=headers) as session:
try:
yield session
except Exception as e:
# Log error but let it propagate
import logging
logger = logging.getLogger("obsidian_api_client")
logger.error(f"Error in Obsidian API client: {str(e)}")
raise
class ObsidianReadSkill(Skill):
"""读取 Obsidian 笔记 Skill"""
def __init__(self) -> None:
super().__init__(
name="obsidian_read",
skill_type=SkillType.READ,
description="从 Obsidian 读取笔记内容",
)
self.error_handler = get_error_handler()
async def execute(self, context: CommandContext, **kwargs: Any) -> SkillResult:
"""
读取 Obsidian 笔记
Args:
context: 命令执行上下文
**kwargs: 包含以下参数
- file_path: 笔记文件路径相对于 vault
- vault_path: vault 路径
- api_url: API URL
- api_key: API 密钥
Returns:
SkillResult: 包含笔记内容的结果
"""
try:
return await self._execute_read(context, **kwargs)
except (ValidationError, ConfigurationError, APIError) as e:
error_context = ErrorContext(
component="obsidian_read",
operation="read_note",
user_message="Failed to read note from Obsidian",
technical_details=kwargs,
)
error_response = self.error_handler.handle_error(e, error_context)
return SkillResult(
success=error_response["success"],
error=error_response["error"],
message=error_response["message"],
)
except Exception as e:
# Handle any unexpected errors
api_error = APIError(
message=f"Unexpected error reading note: {str(e)}",
api_name="obsidian",
cause=e,
)
error_context = ErrorContext(
component="obsidian_read",
operation="read_note",
user_message="An unexpected error occurred while reading the note",
)
error_response = self.error_handler.handle_error(api_error, error_context)
return SkillResult(
success=error_response["success"],
error=error_response["error"],
message=error_response["message"],
)
async def _execute_read(
self, context: CommandContext, **kwargs: Any
) -> SkillResult:
"""Internal method that performs the actual read operation"""
# Validate input parameters
validated_kwargs = command_input_validator.validate_skill_input(
'obsidian_read', kwargs
)
file_path: str = validated_kwargs["file_path"]
api_url: str = validated_kwargs.get("api_url", "https://localhost:27123")
api_key: str = validated_kwargs["api_key"]
# Use async context manager for API client
async with obsidian_api_client(api_url, api_key) as session:
# Build API URL
api_endpoint: str = f"{api_url}/vault/{file_path}"
self.logger.info(f"读取笔记: {file_path}")
async with session.get(api_endpoint) as response:
if response.status == 200:
content: str = await response.text()
# Validate API response
validated_response = validate_api_response(
content, "obsidian", "read"
)
return SkillResult(
success=True,
data={
"file_path": file_path,
"content": validated_response["content"],
"size": validated_response["length"],
"read_at": datetime.now().isoformat(),
},
message=f"成功读取笔记: {file_path}",
)
elif response.status == 404:
raise APIError(
message=f"Note file not found: {file_path}",
api_name="obsidian",
status_code=response.status,
)
else:
error_text: str = await response.text()
raise APIError(
message=f"Failed to read note: {error_text}",
api_name="obsidian",
status_code=response.status,
response_data=error_text,
)
class ObsidianWriteSkill(Skill):
"""写入 Obsidian 笔记 Skill"""
def __init__(self) -> None:
super().__init__(
name="obsidian_write",
skill_type=SkillType.WRITE,
description="向 Obsidian 写入或更新笔记",
)
self.error_handler = get_error_handler()
async def execute(self, context: CommandContext, **kwargs: Any) -> SkillResult:
"""
写入或创建 Obsidian 笔记
Args:
context: 命令执行上下文
**kwargs: 包含以下参数
- file_path: 笔记文件路径相对于 vault
- content: 要写入的内容
- overwrite: 是否覆盖现有内容默认 False
- api_url: API URL
- api_key: API 密钥
Returns:
SkillResult: 执行结果
"""
try:
return await self._execute_write(context, **kwargs)
except (ValidationError, ConfigurationError, APIError) as e:
error_context = ErrorContext(
component="obsidian_write",
operation="write_note",
user_message="Failed to write note to Obsidian",
technical_details=kwargs,
)
error_response = self.error_handler.handle_error(e, error_context)
return SkillResult(
success=error_response["success"],
error=error_response["error"],
message=error_response["message"],
)
except Exception as e:
api_error = APIError(
message=f"Unexpected error writing note: {str(e)}",
api_name="obsidian",
cause=e,
)
error_context = ErrorContext(
component="obsidian_write",
operation="write_note",
user_message="An unexpected error occurred while writing the note",
)
error_response = self.error_handler.handle_error(api_error, error_context)
return SkillResult(
success=error_response["success"],
error=error_response["error"],
message=error_response["message"],
)
async def _execute_write(
self, context: CommandContext, **kwargs: Any
) -> SkillResult:
"""Internal method that performs the actual write operation"""
# Validate input parameters
validated_kwargs = command_input_validator.validate_skill_input(
'obsidian_write', kwargs
)
file_path: str = validated_kwargs["file_path"]
content: str = validated_kwargs["content"]
overwrite: bool = validated_kwargs.get("overwrite", False)
api_url: str = validated_kwargs.get("api_url", "https://localhost:27123")
api_key: str = validated_kwargs["api_key"]
async with obsidian_api_client(api_url, api_key) as session:
api_endpoint: str = f"{api_url}/vault/{file_path}"
payload: Dict[str, Union[str, bool]] = {
"content": content,
"overwrite": overwrite,
}
self.logger.info(f"写入笔记: {file_path}")
async with session.post(api_endpoint, json=payload) as response:
if response.status in [200, 201]:
# Validate API response
response_text = await response.text()
validated_response = validate_api_response(
response_text, "obsidian", "write"
)
return SkillResult(
success=True,
data={
"file_path": file_path,
"size": len(content) if content else 0,
"written_at": datetime.now().isoformat(),
"response": validated_response,
},
message=f"成功写入笔记: {file_path}",
)
else:
error_text: str = await response.text()
raise APIError(
message=f"Failed to write note: {error_text}",
api_name="obsidian",
status_code=response.status,
response_data=error_text,
)
class ObsidianAppendSkill(Skill):
"""追加内容到 Obsidian 笔记 Skill"""
def __init__(self) -> None:
super().__init__(
name="obsidian_append",
skill_type=SkillType.WRITE,
description="向 Obsidian 笔记追加内容",
)
self.error_handler = get_error_handler()
async def execute(self, context: CommandContext, **kwargs: Any) -> SkillResult:
"""
向笔记追加内容
Args:
context: 命令执行上下文
**kwargs: 包含以下参数
- file_path: 笔记文件路径
- content: 要追加的内容
- api_url: API URL
- api_key: API 密钥
Returns:
SkillResult: 执行结果
"""
try:
return await self._execute_append(context, **kwargs)
except (ValidationError, ConfigurationError, APIError) as e:
error_context = ErrorContext(
component="obsidian_append",
operation="append_note",
user_message="Failed to append content to Obsidian note",
technical_details=kwargs,
)
error_response = self.error_handler.handle_error(e, error_context)
return SkillResult(
success=error_response["success"],
error=error_response["error"],
message=error_response["message"],
)
except Exception as e:
api_error = APIError(
message=f"Unexpected error appending to note: {str(e)}",
api_name="obsidian",
cause=e,
)
error_context = ErrorContext(
component="obsidian_append",
operation="append_note",
user_message="An unexpected error occurred while appending to the note",
)
error_response = self.error_handler.handle_error(api_error, error_context)
return SkillResult(
success=error_response["success"],
error=error_response["error"],
message=error_response["message"],
)
async def _execute_append(
self, context: CommandContext, **kwargs: Any
) -> SkillResult:
"""Internal method that performs the actual append operation"""
file_path: Optional[str] = kwargs.get("file_path")
content: Optional[str] = kwargs.get("content")
api_url: str = kwargs.get("api_url", "https://localhost:27123")
api_key: Optional[str] = kwargs.get("api_key")
if not file_path:
raise ValidationError(
message="File path is required for appending to notes",
field_name="file_path",
validation_rule="non_empty",
)
if content is None:
raise ValidationError(
message="Content is required for appending to notes",
field_name="content",
validation_rule="not_none",
)
if not api_key:
raise ConfigurationError(
message="Obsidian API key is required", config_key="api_key"
)
async with obsidian_api_client(api_url, api_key) as session:
api_endpoint: str = f"{api_url}/vault/{file_path}"
payload: Dict[str, Union[str, bool]] = {"content": content, "append": True}
self.logger.info(f"追加内容到笔记: {file_path}")
async with session.post(api_endpoint, json=payload) as response:
if response.status in [200, 201]:
return SkillResult(
success=True,
data={
"file_path": file_path,
"appended_size": len(content) if content else 0,
"appended_at": datetime.now().isoformat(),
},
message=f"成功追加内容到笔记: {file_path}",
)
else:
error_text: str = await response.text()
raise APIError(
message=f"Failed to append to note: {error_text}",
api_name="obsidian",
status_code=response.status,
response_data=error_text,
)
class ObsidianListFilesSkill(Skill):
"""列出 Obsidian 文件 Skill"""
def __init__(self) -> None:
super().__init__(
name="obsidian_list_files",
skill_type=SkillType.READ,
description="列出 Obsidian vault 中的文件",
)
self.error_handler = get_error_handler()
async def execute(self, context: CommandContext, **kwargs: Any) -> SkillResult:
"""
列出指定文件夹中的文件
Args:
context: 命令执行上下文
**kwargs: 包含以下参数
- folder_path: 文件夹路径可选
- api_url: API URL
- api_key: API 密钥
Returns:
SkillResult: 包含文件列表的结果
"""
try:
return await self._execute_list(context, **kwargs)
except (ValidationError, ConfigurationError, APIError) as e:
error_context = ErrorContext(
component="obsidian_list_files",
operation="list_files",
user_message="Failed to list files from Obsidian vault",
technical_details=kwargs,
)
error_response = self.error_handler.handle_error(e, error_context)
return SkillResult(
success=error_response["success"],
error=error_response["error"],
message=error_response["message"],
)
except Exception as e:
api_error = APIError(
message=f"Unexpected error listing files: {str(e)}",
api_name="obsidian",
cause=e,
)
error_context = ErrorContext(
component="obsidian_list_files",
operation="list_files",
user_message="An unexpected error occurred while listing files",
)
error_response = self.error_handler.handle_error(api_error, error_context)
return SkillResult(
success=error_response["success"],
error=error_response["error"],
message=error_response["message"],
)
async def _execute_list(
self, context: CommandContext, **kwargs: Any
) -> SkillResult:
"""Internal method that performs the actual list operation"""
folder_path: str = kwargs.get("folder_path", "")
api_url: str = kwargs.get("api_url", "https://localhost:27123")
api_key: Optional[str] = kwargs.get("api_key")
if not api_key:
raise ConfigurationError(
message="Obsidian API key is required", config_key="api_key"
)
async with obsidian_api_client(api_url, api_key) as session:
api_endpoint: str = f"{api_url}/vault/list"
params: Dict[str, str] = {}
if folder_path:
params["path"] = folder_path
self.logger.info(f"列出文件: {folder_path or 'root'}")
async with session.get(api_endpoint, params=params) as response:
if response.status == 200:
files: Union[List[Any], Dict[str, Any]] = await response.json()
return SkillResult(
success=True,
data={
"folder_path": folder_path,
"files": files,
"count": len(files) if isinstance(files, list) else 0,
},
message=f"成功列出文件",
)
else:
error_text: str = await response.text()
raise APIError(
message=f"Failed to list files: {error_text}",
api_name="obsidian",
status_code=response.status,
response_data=error_text,
)
+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""
Simple application startup validation test.
Tests the application by running it as a module to validate basic functionality.
"""
import asyncio
import json
import logging
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Dict, Any, List, Tuple
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger("ApplicationStartup")
class ApplicationStartupTest:
"""Test application startup and basic functionality"""
def __init__(self):
self.test_results: List[Tuple[str, bool, str]] = []
self.temp_config_path: Path = None
def create_test_config(self) -> Path:
"""Create a temporary test configuration file"""
config_content = {
'obsidian': {
'vault_path': '/tmp/test_vault',
'rest_api': {
'url': 'https://localhost:27123',
'api_key': 'test-api-key',
'verify_ssl': False
}
},
'claude': {
'api_key': 'test-api-key-placeholder',
'model': 'claude-3-5-sonnet-20241022',
'max_tokens': 4096,
'temperature': 0.7
},
'journal': {
'daily_notes_folder': 'Daily',
'date_format': 'YYYY-MM-DD',
'file_extension': '.md'
},
'output': {
'experiences_folder': 'Knowledge/Experiences',
'lessons_folder': 'Knowledge/Lessons',
'tasks_folder': 'Tasks/Daily',
'problems_folder': 'Knowledge/Problems',
'achievements_folder': 'Knowledge/Achievements',
'improvements_folder': 'Knowledge/Improvements'
},
'logging': {
'level': 'INFO',
'file': 'logs/journal_organizer.log'
}
}
# Create temporary config file
temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False)
json.dump(config_content, temp_file, indent=2)
temp_file.close()
self.temp_config_path = Path(temp_file.name)
return self.temp_config_path
def cleanup(self):
"""Clean up temporary files"""
if self.temp_config_path and self.temp_config_path.exists():
self.temp_config_path.unlink()
def run_command(self, cmd: List[str], timeout: int = 30) -> Tuple[bool, str, str]:
"""Run a command and return success, stdout, stderr"""
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
cwd=Path.cwd()
)
return result.returncode == 0, result.stdout, result.stderr
except subprocess.TimeoutExpired:
return False, "", "Command timed out"
except Exception as e:
return False, "", str(e)
def test_module_import(self) -> Tuple[str, bool, str]:
"""Test if the module can be imported"""
test_name = "Module Import Test"
cmd = [sys.executable, "-c", "import sys; sys.path.insert(0, '.'); import main; print('SUCCESS: Module imported')"]
success, stdout, stderr = self.run_command(cmd, timeout=10)
if success and "SUCCESS" in stdout:
return test_name, True, "Module imported successfully"
else:
return test_name, False, f"Import failed: {stderr}"
def test_help_command(self) -> Tuple[str, bool, str]:
"""Test the help command"""
test_name = "Help Command Test"
cmd = [sys.executable, "-m", "__main__", "--help"]
success, stdout, stderr = self.run_command(cmd, timeout=10)
if success and ("usage:" in stdout.lower() or "help" in stdout.lower()):
return test_name, True, "Help command works"
else:
return test_name, False, f"Help command failed: {stderr}"
def test_list_command(self) -> Tuple[str, bool, str]:
"""Test the list command"""
test_name = "List Command Test"
config_path = self.create_test_config()
cmd = [sys.executable, "-m", "__main__", "--config", str(config_path), "list"]
success, stdout, stderr = self.run_command(cmd, timeout=15)
if success and ("organize" in stdout.lower() or "可用命令" in stdout):
return test_name, True, "List command works"
else:
return test_name, False, f"List command failed: {stderr}"
def test_check_deps_command(self) -> Tuple[str, bool, str]:
"""Test the check-deps command"""
test_name = "Check Dependencies Command Test"
cmd = [sys.executable, "-m", "__main__", "check-deps"]
success, stdout, stderr = self.run_command(cmd, timeout=15)
# This command should run regardless of missing dependencies
if "检查依赖项状态" in stdout or "dependency" in stdout.lower() or success:
return test_name, True, "Check-deps command works"
else:
return test_name, False, f"Check-deps command failed: {stderr}"
def test_info_command(self) -> Tuple[str, bool, str]:
"""Test the info command"""
test_name = "Info Command Test"
config_path = self.create_test_config()
cmd = [sys.executable, "-m", "__main__", "--config", str(config_path), "info"]
success, stdout, stderr = self.run_command(cmd, timeout=15)
if success and ("{" in stdout or "info" in stdout.lower()):
return test_name, True, "Info command works"
else:
return test_name, False, f"Info command failed: {stderr}"
def test_chat_help(self) -> Tuple[str, bool, str]:
"""Test the chat interface help"""
test_name = "Chat Interface Help Test"
cmd = [sys.executable, "-c", "import sys; sys.path.insert(0, '.'); import chat_main; print('SUCCESS: Chat module imported')"]
success, stdout, stderr = self.run_command(cmd, timeout=10)
if success and "SUCCESS" in stdout:
return test_name, True, "Chat module can be imported"
else:
return test_name, False, f"Chat module import failed: {stderr}"
def test_configuration_validation(self) -> Tuple[str, bool, str]:
"""Test configuration validation"""
test_name = "Configuration Validation Test"
# Test with valid config
config_path = self.create_test_config()
cmd = [sys.executable, "-c", f"""
import sys
sys.path.insert(0, '.')
import json
from pathlib import Path
# Test config loading
config_path = Path('{config_path}')
with config_path.open('r') as f:
config = json.load(f)
print(f'SUCCESS: Config loaded with {{len(config)}} sections')
"""]
success, stdout, stderr = self.run_command(cmd, timeout=10)
if success and "SUCCESS" in stdout:
return test_name, True, "Configuration validation works"
else:
return test_name, False, f"Configuration validation failed: {stderr}"
def run_all_tests(self) -> List[Tuple[str, bool, str]]:
"""Run all startup tests"""
logger.info("🚀 Starting application startup tests...")
test_methods = [
self.test_module_import,
self.test_help_command,
self.test_list_command,
self.test_check_deps_command,
self.test_info_command,
self.test_chat_help,
self.test_configuration_validation
]
for test_method in test_methods:
try:
logger.info(f"Running {test_method.__name__}...")
result = test_method()
self.test_results.append(result)
status = "✅ PASS" if result[1] else "❌ FAIL"
logger.info(f"{status} {result[0]}: {result[2]}")
except Exception as e:
error_result = (test_method.__name__, False, f"Test execution failed: {str(e)}")
self.test_results.append(error_result)
logger.error(f"❌ FAIL {error_result[0]}: {error_result[2]}")
return self.test_results
def generate_report(self) -> str:
"""Generate test report"""
total_tests = len(self.test_results)
passed_tests = sum(1 for _, success, _ in self.test_results if success)
failed_tests = total_tests - passed_tests
report = f"""
{'='*60}
APPLICATION STARTUP TEST REPORT
{'='*60}
Summary:
Total Tests: {total_tests}
Passed: {passed_tests}
Failed: {failed_tests}
Success Rate: {(passed_tests/total_tests*100):.1f}%
Test Results:
"""
for test_name, success, message in self.test_results:
status = "✅ PASS" if success else "❌ FAIL"
report += f"\n{status} {test_name}: {message}"
report += f"\n\n{'='*60}\n"
return report
def main():
"""Main test function"""
tester = ApplicationStartupTest()
try:
results = tester.run_all_tests()
report = tester.generate_report()
print(report)
# Return appropriate exit code
failed_count = sum(1 for _, success, _ in results if not success)
return 0 if failed_count == 0 else 1
except Exception as e:
logger.error(f"Test execution failed: {str(e)}")
return 1
finally:
tester.cleanup()
if __name__ == "__main__":
exit_code = main()
sys.exit(exit_code)
+292
View File
@@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""
Basic functionality validation test.
Tests core components that can be imported and validated.
"""
import asyncio
import json
import logging
import os
import sys
import tempfile
import traceback
from pathlib import Path
from typing import Dict, Any, List, Tuple
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger("BasicFunctionality")
class BasicFunctionalityTest:
"""Test basic functionality of core components"""
def __init__(self):
self.test_results: List[Tuple[str, bool, str]] = []
def test_dependency_manager(self) -> Tuple[str, bool, str]:
"""Test dependency manager functionality"""
test_name = "Dependency Manager Test"
try:
import dependency_manager
# Test getting dependency manager
dep_manager = dependency_manager.get_dependency_manager()
assert dep_manager is not None, "Dependency manager should not be None"
# Test status report
status_report = dep_manager.get_dependency_status_report()
assert isinstance(status_report, str), "Status report should be a string"
assert len(status_report) > 0, "Status report should not be empty"
# Test missing dependencies
missing_deps = dep_manager.get_missing_dependencies()
assert isinstance(missing_deps, list), "Missing deps should be a list"
return test_name, True, f"Dependency manager works (missing: {len(missing_deps)} deps)"
except Exception as e:
return test_name, False, f"Dependency manager failed: {str(e)}"
def test_error_handling(self) -> Tuple[str, bool, str]:
"""Test error handling framework"""
test_name = "Error Handling Framework Test"
try:
import error_handling
# Test custom exceptions
exc1 = error_handling.JournalOrganizerError("test")
exc2 = error_handling.ConfigurationError("config error")
exc3 = error_handling.APIError("api error")
exc4 = error_handling.ValidationError("validation error")
assert all(isinstance(exc, Exception) for exc in [exc1, exc2, exc3, exc4])
# Test ErrorHandler
import logging
logger = logging.getLogger("test")
error_handler = error_handling.ErrorHandler(logger)
assert hasattr(error_handler, 'handle_api_error')
assert hasattr(error_handler, 'handle_validation_error')
return test_name, True, "Error handling framework works correctly"
except Exception as e:
return test_name, False, f"Error handling test failed: {str(e)}"
def test_configuration_validation(self) -> Tuple[str, bool, str]:
"""Test configuration validation"""
test_name = "Configuration Validation Test"
try:
import config_validation
# Test configuration models exist
assert hasattr(config_validation, 'ObsidianConfig')
assert hasattr(config_validation, 'ClaudeConfig')
assert hasattr(config_validation, 'SystemConfig')
# Test validation functions
assert hasattr(config_validation, 'validate_obsidian_config')
assert hasattr(config_validation, 'validate_claude_config')
return test_name, True, "Configuration validation works"
except Exception as e:
return test_name, False, f"Configuration validation failed: {str(e)}"
def test_input_validation(self) -> Tuple[str, bool, str]:
"""Test input validation"""
test_name = "Input Validation Test"
try:
import input_validation
# Test validation functions exist
assert hasattr(input_validation, 'command_input_validator')
assert hasattr(input_validation, 'validate_user_input')
# Test basic validation
validator = input_validation.command_input_validator
assert hasattr(validator, 'validate_organize_command_input'), "Validator should have organize command validation method"
return test_name, True, "Input validation works"
except Exception as e:
return test_name, False, f"Input validation failed: {str(e)}"
def test_date_validation(self) -> Tuple[str, bool, str]:
"""Test date validation"""
test_name = "Date Validation Test"
try:
import date_validation
# Test validation function exists
assert hasattr(date_validation, 'validate_date_input')
# Test basic date validation
result = date_validation.validate_date_input("2025-12-31")
assert result is not None, "Valid date should return result"
return test_name, True, "Date validation works"
except Exception as e:
return test_name, False, f"Date validation failed: {str(e)}"
def test_path_security(self) -> Tuple[str, bool, str]:
"""Test path security"""
test_name = "Path Security Test"
try:
import path_security
# Test security functions exist
assert hasattr(path_security, 'validate_path_safety')
assert hasattr(path_security, 'sanitize_path')
# Test basic path validation
safe_path = "/tmp/test.txt"
result = path_security.validate_path_safety(safe_path)
assert isinstance(result, bool), "Path validation should return boolean"
return test_name, True, "Path security works"
except Exception as e:
return test_name, False, f"Path security failed: {str(e)}"
def test_api_response_validation(self) -> Tuple[str, bool, str]:
"""Test API response validation"""
test_name = "API Response Validation Test"
try:
import api_response_validation
# Test validation functions exist
assert hasattr(api_response_validation, 'validate_claude_response')
assert hasattr(api_response_validation, 'validate_obsidian_response')
# Test basic response validation
test_response = {"status": "success", "data": {}}
result = api_response_validation.validate_obsidian_response(test_response)
assert isinstance(result, bool), "Response validation should return boolean"
return test_name, True, "API response validation works"
except Exception as e:
return test_name, False, f"API response validation failed: {str(e)}"
def test_agent_core_classes(self) -> Tuple[str, bool, str]:
"""Test agent core classes can be imported"""
test_name = "Agent Core Classes Test"
try:
import agent_core
# Test core classes exist
assert hasattr(agent_core, 'Agent')
assert hasattr(agent_core, 'Command')
assert hasattr(agent_core, 'Skill')
assert hasattr(agent_core, 'SkillResult')
assert hasattr(agent_core, 'CommandContext')
# Test SkillResult can be instantiated
result = agent_core.SkillResult(success=True, message="test")
assert result.success is True
assert result.message == "test"
return test_name, True, "Agent core classes work"
except Exception as e:
return test_name, False, f"Agent core classes failed: {str(e)}"
def run_all_tests(self) -> List[Tuple[str, bool, str]]:
"""Run all basic functionality tests"""
logger.info("🚀 Starting basic functionality tests...")
test_methods = [
self.test_dependency_manager,
self.test_error_handling,
self.test_configuration_validation,
self.test_input_validation,
self.test_date_validation,
self.test_path_security,
self.test_api_response_validation,
self.test_agent_core_classes
]
for test_method in test_methods:
try:
logger.info(f"Running {test_method.__name__}...")
result = test_method()
self.test_results.append(result)
status = "✅ PASS" if result[1] else "❌ FAIL"
logger.info(f"{status} {result[0]}: {result[2]}")
except Exception as e:
error_result = (test_method.__name__, False, f"Test execution failed: {str(e)}")
self.test_results.append(error_result)
logger.error(f"❌ FAIL {error_result[0]}: {error_result[2]}")
return self.test_results
def generate_report(self) -> str:
"""Generate test report"""
total_tests = len(self.test_results)
passed_tests = sum(1 for _, success, _ in self.test_results if success)
failed_tests = total_tests - passed_tests
report = f"""
{'='*60}
BASIC FUNCTIONALITY TEST REPORT
{'='*60}
Summary:
Total Tests: {total_tests}
Passed: {passed_tests}
Failed: {failed_tests}
Success Rate: {(passed_tests/total_tests*100):.1f}%
Test Results:
"""
for test_name, success, message in self.test_results:
status = "✅ PASS" if success else "❌ FAIL"
report += f"\n{status} {test_name}: {message}"
report += f"\n\n{'='*60}\n"
return report
def main():
"""Main test function"""
tester = BasicFunctionalityTest()
try:
results = tester.run_all_tests()
report = tester.generate_report()
print(report)
# Return appropriate exit code
failed_count = sum(1 for _, success, _ in results if not success)
return 0 if failed_count == 0 else 1
except Exception as e:
logger.error(f"Test execution failed: {str(e)}")
traceback.print_exc()
return 1
if __name__ == "__main__":
exit_code = main()
sys.exit(exit_code)
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""
Simple test to verify ConfigurationLoader integration works correctly
"""
import os
import tempfile
from pathlib import Path
from configuration_loader import ConfigurationLoader, ConfigurationError, EnvironmentVariableError
def test_basic_environment_variable_expansion():
"""Test basic environment variable expansion functionality"""
# Set up test environment variables
os.environ['TEST_API_KEY'] = 'test-key-123'
os.environ['TEST_URL'] = 'https://test.example.com'
# Create test configuration
test_config = {
'api': {
'key': '${TEST_API_KEY}',
'url': '${TEST_URL}',
'timeout': '${TEST_TIMEOUT:-30}', # With default value
'retries': 3 # No environment variable
},
'nested': {
'values': ['${TEST_API_KEY}', 'static-value', '${TEST_URL}']
}
}
# Test expansion
loader = ConfigurationLoader()
expanded = loader.expand_environment_variables(test_config)
# Verify results
assert expanded['api']['key'] == 'test-key-123'
assert expanded['api']['url'] == 'https://test.example.com'
assert expanded['api']['timeout'] == '30' # Default value used
assert expanded['api']['retries'] == 3 # Unchanged
assert expanded['nested']['values'][0] == 'test-key-123'
assert expanded['nested']['values'][1] == 'static-value'
assert expanded['nested']['values'][2] == 'https://test.example.com'
print("✓ Basic environment variable expansion test passed")
def test_missing_required_variable():
"""Test error handling for missing required environment variables"""
# Ensure variable is not set
if 'MISSING_VAR' in os.environ:
del os.environ['MISSING_VAR']
test_config = {
'api': {
'key': '${MISSING_VAR}'
}
}
loader = ConfigurationLoader()
try:
loader.expand_environment_variables(test_config)
assert False, "Should have raised EnvironmentVariableError"
except EnvironmentVariableError as e:
assert 'MISSING_VAR' in str(e)
print("✓ Missing required variable error test passed")
def test_config_file_loading():
"""Test loading configuration from YAML file"""
# Set up test environment variable
os.environ['TEST_CLAUDE_KEY'] = 'sk-ant-test-key'
# Create temporary YAML config file
yaml_content = """
obsidian:
vault_path: "/tmp/test-vault"
rest_api:
url: "https://localhost:27123"
api_key: "test-obsidian-key"
verify_ssl: false
claude:
api_key: "${TEST_CLAUDE_KEY}"
api_url: "${CLAUDE_URL:-https://api.anthropic.com}"
model: "claude-3-5-sonnet-20241022"
max_tokens: 4096
temperature: 0.7
"""
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
f.write(yaml_content)
temp_file = f.name
try:
# Load and expand configuration
expanded_config = ConfigurationLoader.load_config(temp_file)
# Verify expansion worked
assert expanded_config['claude']['api_key'] == 'sk-ant-test-key'
assert expanded_config['claude']['api_url'] == 'https://api.anthropic.com' # Default value
assert expanded_config['obsidian']['rest_api']['api_key'] == 'test-obsidian-key'
print("✓ Config file loading test passed")
finally:
# Clean up
Path(temp_file).unlink()
def test_validation_methods():
"""Test validation helper methods"""
# Set up test environment
os.environ['PRESENT_VAR'] = 'present'
if 'MISSING_VAR' in os.environ:
del os.environ['MISSING_VAR']
test_config = {
'present': '${PRESENT_VAR}',
'missing': '${MISSING_VAR}',
'with_default': '${MISSING_VAR:-default_value}'
}
loader = ConfigurationLoader()
# Test validation
missing_vars = loader.validate_environment_variables(test_config)
assert len(missing_vars) == 1
assert 'MISSING_VAR' in missing_vars[0]
# Test environment variable references
env_refs = loader.get_environment_variable_references(test_config)
assert 'PRESENT_VAR' in env_refs
assert 'MISSING_VAR' in env_refs
assert len(env_refs['MISSING_VAR']) == 2 # Used in 'missing' and 'with_default'
print("✓ Validation methods test passed")
if __name__ == '__main__':
print("Testing ConfigurationLoader integration...")
try:
test_basic_environment_variable_expansion()
test_missing_required_variable()
test_config_file_loading()
test_validation_methods()
print("\n✅ All tests passed! ConfigurationLoader integration is working correctly.")
except Exception as e:
print(f"\n❌ Test failed: {e}")
import traceback
traceback.print_exc()
exit(1)
finally:
# Clean up test environment variables
for var in ['TEST_API_KEY', 'TEST_URL', 'TEST_CLAUDE_KEY', 'PRESENT_VAR']:
if var in os.environ:
del os.environ[var]
+496
View File
@@ -0,0 +1,496 @@
#!/usr/bin/env python3
"""
Performance and reliability testing script.
Tests various input sizes, edge cases, memory usage, and error recovery.
"""
import asyncio
import gc
import json
import logging
import os
import sys
import tempfile
import time
import traceback
from pathlib import Path
from typing import Dict, Any, List, Tuple, Optional
# Try to import psutil, fallback to basic memory tracking
try:
import psutil
HAS_PSUTIL = True
except ImportError:
HAS_PSUTIL = False
psutil = None
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger("PerformanceReliability")
class PerformanceReliabilityTest:
"""Test performance and reliability of core components"""
def __init__(self):
self.test_results: List[Tuple[str, bool, str, Dict[str, Any]]] = []
if HAS_PSUTIL:
self.process = psutil.Process()
else:
self.process = None
def get_memory_usage(self) -> float:
"""Get current memory usage in MB"""
if HAS_PSUTIL and self.process:
return self.process.memory_info().rss / 1024 / 1024
else:
# Fallback to basic memory tracking
return 0.0 # Can't measure without psutil
def test_dependency_manager_performance(self) -> Tuple[str, bool, str, Dict[str, Any]]:
"""Test dependency manager performance with multiple calls"""
test_name = "Dependency Manager Performance Test"
metrics = {}
try:
import dependency_manager
# Measure initial memory
initial_memory = self.get_memory_usage()
# Test multiple instantiations
start_time = time.time()
managers = []
for i in range(100):
manager = dependency_manager.get_dependency_manager()
managers.append(manager)
creation_time = time.time() - start_time
# Test status report generation performance
start_time = time.time()
for manager in managers[:10]: # Test first 10
status = manager.get_dependency_status_report()
report_time = time.time() - start_time
# Measure final memory
final_memory = self.get_memory_usage()
memory_increase = final_memory - initial_memory if HAS_PSUTIL else 0.0
# Cleanup
del managers
gc.collect()
metrics = {
"creation_time_100_instances": f"{creation_time:.3f}s",
"report_generation_time_10_calls": f"{report_time:.3f}s",
"memory_increase": f"{memory_increase:.2f}MB" if HAS_PSUTIL else "N/A (psutil not available)",
"avg_creation_time": f"{creation_time/100*1000:.2f}ms"
}
# Performance thresholds
if creation_time > 5.0: # 5 seconds for 100 instances
return test_name, False, "Dependency manager creation too slow", metrics
if HAS_PSUTIL and memory_increase > 50: # 50MB increase
return test_name, False, "Excessive memory usage", metrics
return test_name, True, "Dependency manager performance acceptable", metrics
except Exception as e:
return test_name, False, f"Performance test failed: {str(e)}", metrics
def test_error_handling_reliability(self) -> Tuple[str, bool, str, Dict[str, Any]]:
"""Test error handling reliability with various error conditions"""
test_name = "Error Handling Reliability Test"
metrics = {}
try:
import error_handling
import logging
# Create error handler
logger = logging.getLogger("test_reliability")
error_handler = error_handling.ErrorHandler(logger)
# Test various error scenarios
test_errors = [
Exception("Generic error"),
ValueError("Value error"),
TypeError("Type error"),
RuntimeError("Runtime error"),
ConnectionError("Connection error")
]
successful_handles = 0
start_time = time.time()
for i, error in enumerate(test_errors * 20): # 100 total errors
try:
result = error_handler.handle_api_error(error, "test_api", f"test_operation_{i}")
# ErrorHandler returns a dict with success, message, etc.
if isinstance(result, dict) and 'success' in result:
successful_handles += 1
elif result is not None: # Any non-None result counts as handled
successful_handles += 1
except Exception:
pass # Error in error handling - not good but continue
handling_time = time.time() - start_time
metrics = {
"total_errors_processed": len(test_errors) * 20,
"successful_handles": successful_handles,
"handling_time": f"{handling_time:.3f}s",
"avg_handling_time": f"{handling_time/(len(test_errors)*20)*1000:.2f}ms",
"success_rate": f"{successful_handles/(len(test_errors)*20)*100:.1f}%"
}
# Reliability thresholds
success_rate = successful_handles / (len(test_errors) * 20)
if success_rate < 0.95: # 95% success rate
return test_name, False, "Error handling success rate too low", metrics
if handling_time > 2.0: # 2 seconds for 100 errors
return test_name, False, "Error handling too slow", metrics
return test_name, True, "Error handling reliability acceptable", metrics
except Exception as e:
return test_name, False, f"Reliability test failed: {str(e)}", metrics
def test_configuration_edge_cases(self) -> Tuple[str, bool, str, Dict[str, Any]]:
"""Test configuration handling with edge cases"""
test_name = "Configuration Edge Cases Test"
metrics = {}
try:
# Test various configuration scenarios
test_configs = [
{}, # Empty config
{"invalid": "structure"}, # Invalid structure
{"obsidian": {}}, # Partial config
{"obsidian": {"vault_path": ""}}, # Empty values
{"obsidian": {"vault_path": "/nonexistent/path"}}, # Invalid paths
{"claude": {"api_key": ""}}, # Empty API key
{"claude": {"api_key": "x" * 1000}}, # Very long API key
]
successful_loads = 0
start_time = time.time()
for i, config in enumerate(test_configs):
try:
# Create temporary config file
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
json.dump(config, f)
temp_path = f.name
# Try to load config (this would normally be done by main.py)
with open(temp_path, 'r') as f:
loaded_config = json.load(f)
# Basic validation that it's a dict
if isinstance(loaded_config, dict):
successful_loads += 1
# Cleanup
os.unlink(temp_path)
except Exception:
# Expected for some edge cases
pass
processing_time = time.time() - start_time
metrics = {
"total_configs_tested": len(test_configs),
"successful_loads": successful_loads,
"processing_time": f"{processing_time:.3f}s",
"avg_processing_time": f"{processing_time/len(test_configs)*1000:.2f}ms"
}
# Should handle at least basic configs
if successful_loads < 3:
return test_name, False, "Too many configuration failures", metrics
return test_name, True, "Configuration edge cases handled", metrics
except Exception as e:
return test_name, False, f"Edge case test failed: {str(e)}", metrics
def test_memory_usage_patterns(self) -> Tuple[str, bool, str, Dict[str, Any]]:
"""Test memory usage patterns and potential leaks"""
test_name = "Memory Usage Patterns Test"
metrics = {}
try:
# Measure baseline memory
gc.collect()
baseline_memory = self.get_memory_usage()
# Test repeated operations that might cause memory leaks
operations_memory = []
for iteration in range(10):
# Simulate typical operations
temp_data = []
# Create temporary objects
for i in range(1000):
temp_data.append({
"id": i,
"data": "x" * 100, # 100 chars
"nested": {"value": i * 2}
})
# Process data
processed = [item for item in temp_data if item["id"] % 2 == 0]
# Measure memory after each iteration
current_memory = self.get_memory_usage()
operations_memory.append(current_memory)
# Cleanup
del temp_data, processed
gc.collect()
# Final memory measurement
final_memory = self.get_memory_usage()
memory_increase = final_memory - baseline_memory
# Calculate memory growth trend
if len(operations_memory) > 1:
memory_growth = operations_memory[-1] - operations_memory[0]
else:
memory_growth = 0
metrics = {
"baseline_memory": f"{baseline_memory:.2f}MB" if HAS_PSUTIL else "N/A",
"final_memory": f"{final_memory:.2f}MB" if HAS_PSUTIL else "N/A",
"total_memory_increase": f"{memory_increase:.2f}MB" if HAS_PSUTIL else "N/A",
"memory_growth_trend": f"{memory_growth:.2f}MB" if HAS_PSUTIL else "N/A",
"max_memory_during_test": f"{max(operations_memory):.2f}MB" if HAS_PSUTIL and operations_memory else "N/A",
"iterations_tested": 10
}
# Memory usage thresholds (only check if psutil available)
if HAS_PSUTIL:
if memory_increase > 20: # 20MB increase after cleanup
return test_name, False, "Potential memory leak detected", metrics
if memory_growth > 15: # 15MB growth trend
return test_name, False, "Memory growth trend concerning", metrics
return test_name, True, "Memory usage patterns acceptable", metrics
except Exception as e:
return test_name, False, f"Memory test failed: {str(e)}", metrics
def test_graceful_degradation(self) -> Tuple[str, bool, str, Dict[str, Any]]:
"""Test graceful degradation when dependencies are missing"""
test_name = "Graceful Degradation Test"
metrics = {}
try:
import dependency_manager
# Test dependency manager with missing modules
dep_manager = dependency_manager.get_dependency_manager()
# Test getting non-existent modules
test_modules = [
'nonexistent_module',
'fake_dependency',
'missing_package',
'invalid.module.name'
]
successful_degradations = 0
start_time = time.time()
for module_name in test_modules:
try:
result = dep_manager.get_module(module_name)
# Should return None for missing modules, not crash
if result is None:
successful_degradations += 1
except Exception:
# Should not raise exceptions for missing modules
pass
degradation_time = time.time() - start_time
# Test status report with missing dependencies
try:
status_report = dep_manager.get_dependency_status_report()
status_report_works = isinstance(status_report, str) and len(status_report) > 0
except Exception:
status_report_works = False
metrics = {
"modules_tested": len(test_modules),
"successful_degradations": successful_degradations,
"degradation_time": f"{degradation_time:.3f}s",
"status_report_works": status_report_works,
"degradation_rate": f"{successful_degradations/len(test_modules)*100:.1f}%"
}
# Should gracefully handle all missing modules
if successful_degradations < len(test_modules):
return test_name, False, "Not all missing modules handled gracefully", metrics
if not status_report_works:
return test_name, False, "Status report fails with missing dependencies", metrics
return test_name, True, "Graceful degradation working correctly", metrics
except Exception as e:
return test_name, False, f"Degradation test failed: {str(e)}", metrics
def test_concurrent_operations(self) -> Tuple[str, bool, str, Dict[str, Any]]:
"""Test concurrent operations and thread safety"""
test_name = "Concurrent Operations Test"
metrics = {}
try:
import dependency_manager
import asyncio
import concurrent.futures
async def async_operation(operation_id: int) -> bool:
"""Simulate async operation"""
try:
dep_manager = dependency_manager.get_dependency_manager()
status = dep_manager.get_dependency_status_report()
missing = dep_manager.get_missing_dependencies()
# Simulate some processing time
await asyncio.sleep(0.01)
return len(status) > 0 and isinstance(missing, list)
except Exception:
return False
# Test concurrent async operations
start_time = time.time()
async def run_concurrent_test():
tasks = [async_operation(i) for i in range(20)]
results = await asyncio.gather(*tasks, return_exceptions=True)
return results
results = asyncio.run(run_concurrent_test())
concurrent_time = time.time() - start_time
successful_operations = sum(1 for r in results if r is True)
metrics = {
"concurrent_operations": 20,
"successful_operations": successful_operations,
"concurrent_time": f"{concurrent_time:.3f}s",
"avg_operation_time": f"{concurrent_time/20*1000:.2f}ms",
"success_rate": f"{successful_operations/20*100:.1f}%"
}
# Concurrency thresholds
if successful_operations < 18: # 90% success rate
return test_name, False, "Concurrent operations success rate too low", metrics
if concurrent_time > 5.0: # 5 seconds for 20 operations
return test_name, False, "Concurrent operations too slow", metrics
return test_name, True, "Concurrent operations working correctly", metrics
except Exception as e:
return test_name, False, f"Concurrency test failed: {str(e)}", metrics
def run_all_tests(self) -> List[Tuple[str, bool, str, Dict[str, Any]]]:
"""Run all performance and reliability tests"""
logger.info("🚀 Starting performance and reliability tests...")
test_methods = [
self.test_dependency_manager_performance,
self.test_error_handling_reliability,
self.test_configuration_edge_cases,
self.test_memory_usage_patterns,
self.test_graceful_degradation,
self.test_concurrent_operations
]
for test_method in test_methods:
try:
logger.info(f"Running {test_method.__name__}...")
result = test_method()
self.test_results.append(result)
status = "✅ PASS" if result[1] else "❌ FAIL"
logger.info(f"{status} {result[0]}: {result[2]}")
except Exception as e:
error_result = (test_method.__name__, False, f"Test execution failed: {str(e)}", {})
self.test_results.append(error_result)
logger.error(f"❌ FAIL {error_result[0]}: {error_result[2]}")
return self.test_results
def generate_report(self) -> str:
"""Generate performance and reliability test report"""
total_tests = len(self.test_results)
passed_tests = sum(1 for _, success, _, _ in self.test_results if success)
failed_tests = total_tests - passed_tests
report = f"""
{'='*60}
PERFORMANCE AND RELIABILITY TEST REPORT
{'='*60}
Summary:
Total Tests: {total_tests}
Passed: {passed_tests}
Failed: {failed_tests}
Success Rate: {(passed_tests/total_tests*100):.1f}%
Test Results:
"""
for test_name, success, message, metrics in self.test_results:
status = "✅ PASS" if success else "❌ FAIL"
report += f"\n{status} {test_name}: {message}"
if metrics:
report += "\n Metrics:"
for key, value in metrics.items():
report += f"\n - {key}: {value}"
report += f"\n\n{'='*60}\n"
return report
def main():
"""Main test function"""
tester = PerformanceReliabilityTest()
try:
results = tester.run_all_tests()
report = tester.generate_report()
print(report)
# Return appropriate exit code
failed_count = sum(1 for _, success, _, _ in results if not success)
return 0 if failed_count == 0 else 1
except Exception as e:
logger.error(f"Test execution failed: {str(e)}")
traceback.print_exc()
return 1
if __name__ == "__main__":
exit_code = main()
sys.exit(exit_code)
+484
View File
@@ -0,0 +1,484 @@
#!/usr/bin/env python3
"""
Application startup and basic functionality validation script.
Tests both v1.0 CLI and v2.0 conversational interfaces.
"""
import asyncio
import json
import logging
import os
import sys
import tempfile
import traceback
from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple
# Add the current directory to Python path for imports
sys.path.insert(0, str(Path(__file__).parent))
# Configure logging for validation
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger("StartupValidation")
class ValidationResult:
"""Container for validation test results"""
def __init__(self, test_name: str):
self.test_name = test_name
self.success = False
self.message = ""
self.details: Dict[str, Any] = {}
self.error: Optional[Exception] = None
def set_success(self, message: str, details: Optional[Dict[str, Any]] = None):
self.success = True
self.message = message
self.details = details or {}
def set_failure(self, message: str, error: Optional[Exception] = None, details: Optional[Dict[str, Any]] = None):
self.success = False
self.message = message
self.error = error
self.details = details or {}
def __str__(self) -> str:
status = "✅ PASS" if self.success else "❌ FAIL"
return f"{status} {self.test_name}: {self.message}"
class StartupValidator:
"""Validates application startup and basic functionality"""
def __init__(self):
self.results: List[ValidationResult] = []
self.temp_config_path: Optional[Path] = None
def create_test_config(self) -> Path:
"""Create a temporary test configuration file"""
config_content = {
'obsidian': {
'vault_path': '/tmp/test_vault',
'rest_api': {
'url': 'https://localhost:27123',
'api_key': 'test-api-key',
'verify_ssl': False
}
},
'claude': {
'api_key': '${ANTHROPIC_API_KEY}',
'model': 'claude-3-5-sonnet-20241022',
'max_tokens': 4096,
'temperature': 0.7
},
'journal': {
'daily_notes_folder': 'Daily',
'date_format': 'YYYY-MM-DD',
'file_extension': '.md'
},
'output': {
'experiences_folder': 'Knowledge/Experiences',
'lessons_folder': 'Knowledge/Lessons',
'tasks_folder': 'Tasks/Daily',
'problems_folder': 'Knowledge/Problems',
'achievements_folder': 'Knowledge/Achievements',
'improvements_folder': 'Knowledge/Improvements'
},
'logging': {
'level': 'INFO',
'file': 'logs/journal_organizer.log'
}
}
# Create temporary config file
temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False)
json.dump(config_content, temp_file, indent=2)
temp_file.close()
self.temp_config_path = Path(temp_file.name)
return self.temp_config_path
def cleanup_test_config(self):
"""Clean up temporary test configuration"""
if self.temp_config_path and self.temp_config_path.exists():
self.temp_config_path.unlink()
async def test_import_main_modules(self) -> ValidationResult:
"""Test importing main application modules"""
result = ValidationResult("Import Main Modules")
try:
# Test importing core modules
modules_tested = []
# Test core modules
try:
import main
modules_tested.append("main")
except ImportError as e:
logger.warning(f"Could not import main: {e}")
try:
import chat_main
modules_tested.append("chat_main")
except ImportError as e:
logger.warning(f"Could not import chat_main: {e}")
try:
import agent_core
modules_tested.append("agent_core")
except ImportError as e:
logger.warning(f"Could not import agent_core: {e}")
try:
import config
modules_tested.append("config")
except ImportError as e:
logger.warning(f"Could not import config: {e}")
# Test command modules
try:
from commands import organize_command
modules_tested.append("commands.organize_command")
except ImportError as e:
logger.warning(f"Could not import commands.organize_command: {e}")
# Test skill modules
try:
from skills import obsidian_skill
modules_tested.append("skills.obsidian_skill")
except ImportError as e:
logger.warning(f"Could not import skills.obsidian_skill: {e}")
try:
from skills import claude_skill
modules_tested.append("skills.claude_skill")
except ImportError as e:
logger.warning(f"Could not import skills.claude_skill: {e}")
# Test conversation modules
try:
from conversation import conversational_agent
modules_tested.append("conversation.conversational_agent")
except ImportError as e:
logger.warning(f"Could not import conversation.conversational_agent: {e}")
try:
from conversation import conversation_state
modules_tested.append("conversation.conversation_state")
except ImportError as e:
logger.warning(f"Could not import conversation.conversation_state: {e}")
if len(modules_tested) >= 4: # At least core modules should work
result.set_success(f"Successfully imported {len(modules_tested)} modules", {
"modules_tested": modules_tested,
"total_attempted": 9
})
else:
result.set_failure(f"Only imported {len(modules_tested)} out of 9 modules", details={
"modules_tested": modules_tested
})
except Exception as e:
result.set_failure(f"Failed to import modules: {str(e)}", e)
return result
async def test_agent_initialization(self) -> ValidationResult:
"""Test basic agent initialization"""
result = ValidationResult("Agent Initialization")
try:
# Try to import and test agent initialization
try:
from main import JournalOrganizerAgent
except ImportError:
# If relative import fails, skip this test
result.set_failure("Could not import JournalOrganizerAgent - likely due to package structure")
return result
# Create test config
config_path = self.create_test_config()
# Initialize agent
agent = JournalOrganizerAgent(str(config_path))
# Test basic properties
assert hasattr(agent, 'config'), "Agent should have config attribute"
assert hasattr(agent, 'agent'), "Agent should have agent attribute"
assert hasattr(agent, 'logger'), "Agent should have logger attribute"
# Test command listing
commands = agent.list_commands()
assert isinstance(commands, list), "Commands should be a list"
assert len(commands) > 0, "Should have at least one command"
result.set_success("Agent initialized successfully", {
"available_commands": commands,
"config_loaded": bool(agent.config)
})
except Exception as e:
result.set_failure(f"Agent initialization failed: {str(e)}", e)
return result
async def test_configuration_loading(self) -> ValidationResult:
"""Test configuration loading with various scenarios"""
result = ValidationResult("Configuration Loading")
try:
from main import JournalOrganizerAgent
test_results = {}
# Test 1: Load from specific config file
config_path = self.create_test_config()
agent1 = JournalOrganizerAgent(str(config_path))
test_results["specific_config"] = bool(agent1.config)
# Test 2: Load with no config file (should use defaults)
agent2 = JournalOrganizerAgent(None)
test_results["default_config"] = isinstance(agent2.config, dict)
# Test 3: Load with non-existent config file (should use defaults)
agent3 = JournalOrganizerAgent("/non/existent/config.yaml")
test_results["fallback_config"] = isinstance(agent3.config, dict)
result.set_success("Configuration loading works correctly", test_results)
except Exception as e:
result.set_failure(f"Configuration loading failed: {str(e)}", e)
return result
async def test_command_registration(self) -> ValidationResult:
"""Test command registration and basic info retrieval"""
result = ValidationResult("Command Registration")
try:
from main import JournalOrganizerAgent
config_path = self.create_test_config()
agent = JournalOrganizerAgent(str(config_path))
# Test command listing
commands = agent.list_commands()
assert "organize" in commands, "Should have 'organize' command"
# Test command info retrieval
organize_info = agent.get_command_info("organize")
assert isinstance(organize_info, dict), "Command info should be a dict"
assert "name" in organize_info, "Command info should have name"
# Test all commands info
all_info = agent.get_all_commands_info()
assert isinstance(all_info, dict), "All commands info should be a dict"
result.set_success("Command registration working correctly", {
"registered_commands": commands,
"organize_command_info": bool(organize_info),
"all_commands_info": bool(all_info)
})
except Exception as e:
result.set_failure(f"Command registration failed: {str(e)}", e)
return result
async def test_conversational_agent_init(self) -> ValidationResult:
"""Test conversational agent initialization"""
result = ValidationResult("Conversational Agent Initialization")
try:
from main import JournalOrganizerAgent
from conversation import ConversationalAgent
config_path = self.create_test_config()
journal_agent = JournalOrganizerAgent(str(config_path))
# Initialize conversational agent
conv_agent = ConversationalAgent(journal_agent.agent, journal_agent.config)
# Test basic properties
assert hasattr(conv_agent, 'agent'), "Should have agent attribute"
assert hasattr(conv_agent, 'config'), "Should have config attribute"
# Test initialization
welcome_msg = await conv_agent.initialize()
assert isinstance(welcome_msg, str), "Welcome message should be a string"
assert len(welcome_msg) > 0, "Welcome message should not be empty"
result.set_success("Conversational agent initialized successfully", {
"welcome_message_length": len(welcome_msg),
"has_required_attributes": True
})
except Exception as e:
result.set_failure(f"Conversational agent initialization failed: {str(e)}", e)
return result
async def test_dependency_management(self) -> ValidationResult:
"""Test dependency management and graceful degradation"""
result = ValidationResult("Dependency Management")
try:
from dependency_manager import get_dependency_manager
dep_manager = get_dependency_manager()
# Test dependency status
status_report = dep_manager.get_dependency_status_report()
assert isinstance(status_report, str), "Status report should be a string"
# Test missing dependencies check
missing_deps = dep_manager.get_missing_dependencies()
assert isinstance(missing_deps, list), "Missing deps should be a list"
# Test module retrieval (should work even if module is missing)
yaml_module = dep_manager.get_module('yaml')
# yaml_module could be None if not installed, which is fine
result.set_success("Dependency management working correctly", {
"status_report_generated": bool(status_report),
"missing_dependencies_count": len(missing_deps),
"yaml_module_available": yaml_module is not None
})
except Exception as e:
result.set_failure(f"Dependency management failed: {str(e)}", e)
return result
async def test_error_handling_framework(self) -> ValidationResult:
"""Test error handling framework"""
result = ValidationResult("Error Handling Framework")
try:
from error_handling import (
JournalOrganizerError, ConfigurationError,
APIError, ValidationError, ErrorHandler
)
# Test custom exceptions
test_exceptions = [
JournalOrganizerError("test"),
ConfigurationError("test config error"),
APIError("test api error"),
ValidationError("test validation error")
]
for exc in test_exceptions:
assert isinstance(exc, Exception), f"{type(exc).__name__} should be an Exception"
assert str(exc), f"{type(exc).__name__} should have string representation"
# Test ErrorHandler
import logging
logger = logging.getLogger("test")
error_handler = ErrorHandler(logger)
assert hasattr(error_handler, 'handle_api_error'), "Should have handle_api_error method"
assert hasattr(error_handler, 'handle_validation_error'), "Should have handle_validation_error method"
result.set_success("Error handling framework working correctly", {
"custom_exceptions_count": len(test_exceptions),
"error_handler_methods": ["handle_api_error", "handle_validation_error"]
})
except Exception as e:
result.set_failure(f"Error handling framework test failed: {str(e)}", e)
return result
async def run_all_tests(self) -> List[ValidationResult]:
"""Run all validation tests"""
logger.info("🚀 Starting application startup validation...")
test_methods = [
self.test_import_main_modules,
self.test_agent_initialization,
self.test_configuration_loading,
self.test_command_registration,
self.test_conversational_agent_init,
self.test_dependency_management,
self.test_error_handling_framework
]
for test_method in test_methods:
try:
logger.info(f"Running {test_method.__name__}...")
result = await test_method()
self.results.append(result)
logger.info(str(result))
except Exception as e:
error_result = ValidationResult(test_method.__name__)
error_result.set_failure(f"Test execution failed: {str(e)}", e)
self.results.append(error_result)
logger.error(str(error_result))
# Cleanup
self.cleanup_test_config()
return self.results
def generate_report(self) -> str:
"""Generate a comprehensive validation report"""
total_tests = len(self.results)
passed_tests = sum(1 for r in self.results if r.success)
failed_tests = total_tests - passed_tests
report = f"""
{'='*60}
APPLICATION STARTUP VALIDATION REPORT
{'='*60}
Summary:
Total Tests: {total_tests}
Passed: {passed_tests}
Failed: {failed_tests}
Success Rate: {(passed_tests/total_tests*100):.1f}%
Test Results:
"""
for result in self.results:
report += f"\n{str(result)}"
if result.details:
for key, value in result.details.items():
report += f"\n - {key}: {value}"
if not result.success and result.error:
report += f"\n - Error: {str(result.error)}"
report += f"\n\n{'='*60}\n"
return report
async def main():
"""Main validation function"""
validator = StartupValidator()
try:
results = await validator.run_all_tests()
report = validator.generate_report()
print(report)
# Return appropriate exit code
failed_count = sum(1 for r in results if not r.success)
return 0 if failed_count == 0 else 1
except Exception as e:
logger.error(f"Validation failed with unexpected error: {str(e)}")
traceback.print_exc()
return 1
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)
+247
View File
@@ -0,0 +1,247 @@
# Testing Framework
This directory contains the comprehensive test suite for the Obsidian journal organizer project.
## Overview
The test suite is organized into three main categories:
- **Unit Tests** (`tests/unit/`) - Test individual components in isolation
- **Integration Tests** (`tests/integration/`) - Test component interactions and workflows
- **Property Tests** (`tests/property/`) - Property-based tests for comprehensive validation
## Test Structure
```
tests/
├── README.md # This file
├── conftest.py # Shared pytest fixtures and configuration
├── unit/ # Unit tests
│ ├── test_agent_core.py # Tests for Agent, Command, Skill classes
│ └── test_error_handling.py # Tests for error handling framework
├── integration/ # Integration tests
│ ├── test_obsidian_skills.py # Obsidian API integration tests
│ ├── test_claude_skills.py # Claude AI integration tests
│ ├── test_organize_command.py # Full command workflow tests
│ └── test_conversational_agent.py # Conversational interface tests
└── property/ # Property-based tests (placeholder)
```
## Running Tests
### Using the Test Runner
The easiest way to run tests is using the provided test runner:
```bash
# Run unit tests only
python run_tests.py unit
# Run integration tests only (may have import issues)
python run_tests.py integration
# Run all tests
python run_tests.py all
# Run tests with coverage report
python run_tests.py coverage
# Show help
python run_tests.py help
```
### Using pytest Directly
You can also run tests directly with pytest:
```bash
# Run all unit tests
python -m pytest tests/unit/ -v
# Run specific test file
python -m pytest tests/unit/test_agent_core.py -v
# Run with coverage
python -m pytest tests/unit/ --cov=. --cov-report=html
# Run tests matching a pattern
python -m pytest -k "test_skill" -v
```
## Test Configuration
The test suite is configured through:
- `pytest.ini` - Main pytest configuration
- `tests/conftest.py` - Shared fixtures and test utilities
### Key Configuration Options
- **Async Support**: Tests use `pytest-asyncio` for async/await testing
- **Coverage**: Configured to exclude test files and generate HTML reports
- **Markers**: Custom markers for different test types (unit, integration, property)
- **Fixtures**: Shared fixtures for common test data and mocks
## Test Categories
### Unit Tests
Unit tests focus on testing individual components in isolation:
- **Agent Core Tests** (`test_agent_core.py`)
- SkillResult validation and serialization
- CommandContext creation and validation
- Skill base class functionality
- SkillChain execution logic
- Command registration and execution
- Agent command orchestration
- **Error Handling Tests** (`test_error_handling.py`)
- Custom exception classes
- Error context management
- Error message sanitization
- Security audit functionality
- Global error handler patterns
### Integration Tests
Integration tests verify component interactions and end-to-end workflows:
- **Obsidian Skills** (`test_obsidian_skills.py`)
- API client integration with mocked responses
- File read/write/append operations
- Error handling for API failures
- Multi-skill workflows
- **Claude Skills** (`test_claude_skills.py`)
- AI analysis integration with mocked responses
- Content transformation workflows
- Batch processing scenarios
- Error recovery patterns
- **Organize Command** (`test_organize_command.py`)
- Full journal organization workflow
- Skill chain coordination
- Partial success handling
- Agent integration
- **Conversational Agent** (`test_conversational_agent.py`)
- Natural language intent understanding
- Multi-turn conversation flows
- Response generation
- Error recovery in conversations
## Test Data and Fixtures
### Shared Fixtures (conftest.py)
- `temp_dir` - Temporary directory for file operations
- `sample_config` - Complete system configuration for testing
- `config_file` - Temporary configuration file
- `mock_skill_result` - Standard SkillResult for mocking
- `sample_journal_content` - Realistic journal content for testing
- `sample_obsidian_response` - Mock Obsidian API responses
### Mock Strategies
The test suite uses several mocking strategies:
1. **API Mocking**: Using `aioresponses` for HTTP API calls
2. **Service Mocking**: Using `unittest.mock` for external services
3. **Dependency Injection**: Providing test doubles through fixtures
4. **Response Simulation**: Creating realistic API responses for testing
## Writing New Tests
### Unit Test Guidelines
1. **Isolation**: Test one component at a time
2. **Mocking**: Mock all external dependencies
3. **Coverage**: Test both success and failure paths
4. **Validation**: Verify inputs, outputs, and side effects
5. **Naming**: Use descriptive test names that explain the scenario
Example unit test:
```python
def test_skill_result_validation_success(self):
"""Test SkillResult validation with valid data"""
result = SkillResult(success=True, data={"key": "value"})
assert result.success is True
assert result.data == {"key": "value"}
assert result.error is None
```
### Integration Test Guidelines
1. **Realistic Scenarios**: Test real-world usage patterns
2. **Mock External APIs**: Use aioresponses for HTTP calls
3. **End-to-End Flows**: Test complete workflows
4. **Error Scenarios**: Test failure modes and recovery
5. **Data Validation**: Verify data flows between components
Example integration test:
```python
@pytest.mark.asyncio
async def test_organize_workflow_success(self, command, context):
"""Test complete organize command workflow"""
with aioresponses() as m:
# Mock API responses
m.get("https://localhost:27123/vault/Daily/2024-01-15.md",
payload={"content": "journal content"})
result = await command.execute(context)
assert result.success is True
assert "created_notes" in result.data
```
## Test Maintenance
### Regular Tasks
1. **Update Fixtures**: Keep test data current with schema changes
2. **Review Coverage**: Ensure new code has adequate test coverage
3. **Mock Updates**: Update mocks when external APIs change
4. **Performance**: Monitor test execution time and optimize slow tests
### Debugging Tests
1. **Verbose Output**: Use `-v` flag for detailed test output
2. **Specific Tests**: Run individual tests with `-k` pattern matching
3. **Debug Mode**: Use `--pdb` to drop into debugger on failures
4. **Logging**: Enable debug logging in tests when needed
## Dependencies
The test suite requires these additional packages:
- `pytest>=7.0.0` - Test framework
- `pytest-asyncio>=0.21.0` - Async test support
- `pytest-cov>=4.0.0` - Coverage reporting
- `hypothesis>=6.0.0` - Property-based testing
- `aioresponses>=0.7.0` - HTTP mocking for aiohttp
Install with:
```bash
pip install -r requirements.txt
```
## Continuous Integration
The test suite is designed to run in CI environments:
- All tests should pass on clean installations
- No external network dependencies (all APIs mocked)
- Deterministic results (no random failures)
- Fast execution (unit tests < 2 minutes)
## Contributing
When adding new features:
1. Write unit tests for new components
2. Add integration tests for new workflows
3. Update fixtures if data models change
4. Maintain test coverage above 80%
5. Follow existing test patterns and naming conventions
+1
View File
@@ -0,0 +1 @@
# Test package initialization
+138
View File
@@ -0,0 +1,138 @@
"""
Pytest configuration and shared fixtures for the journal organizer test suite.
"""
import pytest
import asyncio
import tempfile
import shutil
import sys
from pathlib import Path
from typing import Dict, Any, AsyncGenerator
from unittest.mock import Mock, AsyncMock
import yaml
# Add the project root to Python path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from agent_core import Agent, SkillResult
from error_handling import ErrorHandler
@pytest.fixture(scope="session")
def event_loop():
"""Create an instance of the default event loop for the test session."""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest.fixture
def temp_dir():
"""Create a temporary directory for test files."""
temp_dir = tempfile.mkdtemp()
yield Path(temp_dir)
shutil.rmtree(temp_dir)
@pytest.fixture
def sample_config(temp_dir: Path) -> Dict[str, Any]:
"""Create a sample configuration for testing."""
vault_path = temp_dir / "test_vault"
vault_path.mkdir()
config = {
"obsidian": {
"vault_path": str(vault_path),
"rest_api": {
"url": "https://localhost:27123",
"api_key": "test-api-key",
"verify_ssl": False
}
},
"claude": {
"api_key": "test-claude-key",
"model": "claude-3-5-sonnet-20241022",
"max_tokens": 4096
},
"journal": {
"daily_notes_folder": "Daily",
"date_format": "YYYY-MM-DD"
},
"output": {
"experiences_folder": "Knowledge/Experiences",
"lessons_folder": "Knowledge/Lessons"
}
}
return config
@pytest.fixture
def config_file(temp_dir: Path, sample_config: Dict[str, Any]) -> Path:
"""Create a temporary config file for testing."""
config_path = temp_dir / "test_config.yaml"
with open(config_path, 'w') as f:
yaml.dump(sample_config, f)
return config_path
@pytest.fixture
def mock_skill_result():
"""Create a mock SkillResult for testing."""
return SkillResult(
success=True,
data={"test": "data"},
message="Test operation completed"
)
@pytest.fixture
def mock_error_handler():
"""Create a mock ErrorHandler for testing."""
return Mock(spec=ErrorHandler)
@pytest.fixture
async def mock_aiohttp_session():
"""Create a mock aiohttp session for testing."""
session = AsyncMock()
session.get = AsyncMock()
session.post = AsyncMock()
session.put = AsyncMock()
session.delete = AsyncMock()
return session
@pytest.fixture
def sample_journal_content():
"""Sample journal content for testing."""
return """# 2024-01-15 Daily Journal
## 今天的经历
- 完成了项目的重要里程碑
- 与团队进行了有效的沟通
## 学到的东西
- 学会了新的Python异步编程技巧
- 理解了更好的错误处理模式
## 遇到的问题
- API调用偶尔超时
- 配置文件格式需要改进
## 明天的计划
- 优化API调用的重试机制
- 更新文档
"""
@pytest.fixture
def sample_obsidian_response():
"""Sample Obsidian API response for testing."""
return {
"content": "# Test Note\n\nThis is test content.",
"stat": {
"ctime": 1642204800000,
"mtime": 1642204800000,
"size": 35
}
}
+1
View File
@@ -0,0 +1 @@
# Integration tests package
@@ -0,0 +1,659 @@
"""
Backward compatibility validation tests
Tests that existing configuration files continue to work, no breaking changes to existing functionality,
and migration messages are appropriate.
Requirements: 3.1, 3.2, 3.3, 3.4
"""
import os
import pytest
import tempfile
import yaml
from pathlib import Path
from typing import Dict, Any
from unittest.mock import patch, Mock
# Import the modules we're testing
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from config import Config
from config_validation import SystemConfig, ConfigurationValidator
from configuration_loader import ConfigurationLoader
from configuration_migrator import ConfigurationMigrator
class TestLegacyConfigurationFiles:
"""Test that existing configuration files continue to work"""
def setup_method(self):
"""Set up test fixtures"""
self.temp_dir = Path(tempfile.mkdtemp())
self.test_vault = self.temp_dir / 'test_vault'
self.test_vault.mkdir()
(self.test_vault / '.obsidian').mkdir()
# Store original environment variables
self.original_env = {}
if 'ANTHROPIC_API_KEY' in os.environ:
self.original_env['ANTHROPIC_API_KEY'] = os.environ['ANTHROPIC_API_KEY']
# Set a test API key for validation
os.environ['ANTHROPIC_API_KEY'] = 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
def teardown_method(self):
"""Clean up test fixtures"""
import shutil
shutil.rmtree(self.temp_dir)
# Restore original environment variables
if 'ANTHROPIC_API_KEY' in self.original_env:
os.environ['ANTHROPIC_API_KEY'] = self.original_env['ANTHROPIC_API_KEY']
elif 'ANTHROPIC_API_KEY' in os.environ:
del os.environ['ANTHROPIC_API_KEY']
def create_legacy_config_file(self, config_data: Dict[str, Any]) -> Path:
"""Create a legacy configuration file"""
config_file = self.temp_dir / 'legacy_config.yaml'
with config_file.open('w') as f:
yaml.dump(config_data, f)
return config_file
def test_minimal_legacy_config_loads(self):
"""Test that minimal legacy configuration loads successfully"""
# This represents a very basic legacy config that users might have
legacy_config = {
'obsidian': {
'vault_path': str(self.test_vault),
'rest_api_url': 'https://localhost:27123',
'rest_api_key': 'test-key'
},
'claude': {
'api_key': '${ANTHROPIC_API_KEY}',
'model': 'claude-3-sonnet' # Legacy model name
}
}
config_file = self.create_legacy_config_file(legacy_config)
# Should load without errors
config = Config(str(config_file))
# Verify legacy values are preserved and migrated appropriately
assert config.claude.api_key == 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
assert config.claude.model == 'claude-3-sonnet-20240229' # Should be migrated
assert config.claude.api_url == 'https://api.anthropic.com' # Should get default
assert Path(config.obsidian.vault_path).resolve() == self.test_vault.resolve()
def test_legacy_config_without_claude_section(self):
"""Test legacy config that doesn't have Claude section at all"""
legacy_config = {
'obsidian': {
'vault_path': str(self.test_vault),
'rest_api_url': 'https://localhost:27123',
'rest_api_key': 'test-key'
}
}
config_file = self.create_legacy_config_file(legacy_config)
# Should load and add Claude section with defaults
config = Config(str(config_file))
# Should have Claude config with defaults
assert config.claude.api_key == 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
assert config.claude.model == 'claude-3-5-sonnet-20241022'
assert config.claude.api_url == 'https://api.anthropic.com'
def test_legacy_config_with_old_structure(self):
"""Test legacy config with old obsidian structure"""
legacy_config = {
'obsidian': {
'vault_path': str(self.test_vault),
'rest_api_url': 'https://localhost:27123',
'rest_api_key': 'test-key',
'verify_ssl': False
},
'claude': {
'api_key': '${ANTHROPIC_API_KEY}',
'model': 'claude-3-opus', # Legacy model name
'max_tokens': 2048,
'temperature': 0.5
},
'journal': {
'daily_notes_folder': 'Journal/Daily',
'date_format': 'YYYY-MM-DD'
}
}
config_file = self.create_legacy_config_file(legacy_config)
# Should load and migrate appropriately
config = Config(str(config_file))
# Verify all legacy values are preserved
assert config.claude.model == 'claude-3-opus-20240229' # Migrated
assert config.claude.max_tokens == 2048 # Preserved
assert config.claude.temperature == 0.5 # Preserved
assert config.journal.daily_notes_folder == 'Journal/Daily' # Preserved
assert config.obsidian.verify_ssl is False # Preserved
def test_legacy_config_with_missing_sections(self):
"""Test legacy config with missing sections gets defaults"""
legacy_config = {
'obsidian': {
'vault_path': str(self.test_vault),
'rest_api_url': 'https://localhost:27123',
'rest_api_key': 'test-key'
},
'claude': {
'api_key': '${ANTHROPIC_API_KEY}'
}
# Missing journal, output, analysis, logging sections
}
config_file = self.create_legacy_config_file(legacy_config)
# Should load and add missing sections with defaults
config = Config(str(config_file))
# Should have all sections with defaults
assert config.journal.daily_notes_folder == 'Daily'
assert config.journal.date_format == 'YYYY-MM-DD'
assert config.output.experiences_folder == 'Knowledge/Experiences'
assert config.analysis.categories == []
assert config.logging.level == 'INFO'
def test_legacy_model_name_variations(self):
"""Test various legacy model name formats are migrated correctly"""
legacy_models = {
'claude-3-sonnet': 'claude-3-sonnet-20240229',
'claude-3-opus': 'claude-3-opus-20240229',
'claude-3-haiku': 'claude-3-haiku-20240307',
'sonnet': 'claude-3-5-sonnet-20241022',
'opus': 'claude-3-opus-20240229',
'haiku': 'claude-3-haiku-20240307'
}
for legacy_model, expected_model in legacy_models.items():
legacy_config = {
'obsidian': {
'vault_path': str(self.test_vault),
'rest_api_url': 'https://localhost:27123',
'rest_api_key': 'test-key'
},
'claude': {
'api_key': '${ANTHROPIC_API_KEY}',
'model': legacy_model
}
}
config_file = self.create_legacy_config_file(legacy_config)
config = Config(str(config_file))
assert config.claude.model == expected_model, f"Legacy model '{legacy_model}' should migrate to '{expected_model}'"
class TestNoBreakingChanges:
"""Test that no breaking changes exist in existing functionality"""
def setup_method(self):
"""Set up test fixtures"""
self.temp_dir = Path(tempfile.mkdtemp())
self.test_vault = self.temp_dir / 'test_vault'
self.test_vault.mkdir()
(self.test_vault / '.obsidian').mkdir()
# Set test API key
os.environ['ANTHROPIC_API_KEY'] = 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
def teardown_method(self):
"""Clean up test fixtures"""
import shutil
shutil.rmtree(self.temp_dir)
if 'ANTHROPIC_API_KEY' in os.environ:
del os.environ['ANTHROPIC_API_KEY']
def create_config_file(self, config_data: Dict[str, Any]) -> Path:
"""Create a configuration file"""
config_file = self.temp_dir / 'config.yaml'
with config_file.open('w') as f:
yaml.dump(config_data, f)
return config_file
def test_config_class_interface_unchanged(self):
"""Test that Config class interface remains unchanged"""
config_data = {
'obsidian': {
'vault_path': str(self.test_vault),
'rest_api_url': 'https://localhost:27123',
'rest_api_key': 'test-key'
},
'claude': {
'api_key': '${ANTHROPIC_API_KEY}',
'model': 'claude-3-5-sonnet-20241022'
}
}
config_file = self.create_config_file(config_data)
config = Config(str(config_file))
# Test that all expected attributes exist and work as before
assert hasattr(config, 'obsidian')
assert hasattr(config, 'claude')
assert hasattr(config, 'journal')
assert hasattr(config, 'output')
assert hasattr(config, 'analysis')
assert hasattr(config, 'logging')
# Test that methods still work
assert callable(config.get_daily_note_path)
assert callable(config.to_dict)
# Test method functionality
daily_path = config.get_daily_note_path('2024-01-01')
assert '2024-01-01' in daily_path
config_dict = config.to_dict()
assert isinstance(config_dict, dict)
assert 'obsidian' in config_dict
assert 'claude' in config_dict
def test_config_attribute_access_unchanged(self):
"""Test that config attribute access patterns remain unchanged"""
config_data = {
'obsidian': {
'vault_path': str(self.test_vault),
'rest_api_url': 'https://localhost:27123',
'rest_api_key': 'test-key'
},
'claude': {
'api_key': '${ANTHROPIC_API_KEY}',
'model': 'claude-3-5-sonnet-20241022',
'max_tokens': 4096,
'temperature': 0.7
}
}
config_file = self.create_config_file(config_data)
config = Config(str(config_file))
# Test that all legacy attribute access patterns still work
assert config.obsidian.vault_path == str(self.test_vault)
assert config.obsidian.rest_api_url == 'https://localhost:27123'
assert config.obsidian.rest_api_key == 'test-key'
assert config.claude.api_key == 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
assert config.claude.model == 'claude-3-5-sonnet-20241022'
assert config.claude.max_tokens == 4096
assert config.claude.temperature == 0.7
# Test new attributes are accessible
assert hasattr(config.claude, 'api_url')
assert config.claude.api_url == 'https://api.anthropic.com'
def test_config_validation_behavior_unchanged(self):
"""Test that config validation behavior remains the same for valid configs"""
valid_config = {
'obsidian': {
'vault_path': str(self.test_vault),
'rest_api_url': 'https://localhost:27123',
'rest_api_key': 'test-key'
},
'claude': {
'api_key': '${ANTHROPIC_API_KEY}',
'model': 'claude-3-5-sonnet-20241022'
}
}
config_file = self.create_config_file(valid_config)
# Should load without errors (same as before)
config = Config(str(config_file))
assert config is not None
# Should still validate the same way
errors = config.get_validation_errors()
assert isinstance(errors, list)
def test_config_error_handling_unchanged(self):
"""Test that config error handling behavior remains unchanged"""
# Test with invalid vault path (should still raise appropriate error)
invalid_config = {
'obsidian': {
'vault_path': '/nonexistent/path',
'rest_api_url': 'https://localhost:27123',
'rest_api_key': 'test-key'
},
'claude': {
'api_key': '${ANTHROPIC_API_KEY}',
'model': 'claude-3-5-sonnet-20241022'
}
}
config_file = self.create_config_file(invalid_config)
# Should still handle errors the same way (warnings, not exceptions for path issues)
config = Config(str(config_file))
errors = config.get_validation_errors()
assert len(errors) > 0 # Should have path validation warnings
class TestMigrationMessages:
"""Test that migration messages are appropriate and helpful"""
def setup_method(self):
"""Set up test fixtures"""
self.migrator = ConfigurationMigrator()
self.temp_dir = Path(tempfile.mkdtemp())
# Set test API key
os.environ['ANTHROPIC_API_KEY'] = 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
def teardown_method(self):
"""Clean up test fixtures"""
import shutil
shutil.rmtree(self.temp_dir)
if 'ANTHROPIC_API_KEY' in os.environ:
del os.environ['ANTHROPIC_API_KEY']
def test_migration_needed_detection_accurate(self):
"""Test that migration detection is accurate"""
# Config that needs migration
config_needing_migration = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'model': 'claude-3-sonnet' # Legacy model name
}
}
assert self.migrator.check_migration_needed(config_needing_migration) is True
# Config that doesn't need migration
config_up_to_date = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'api_url': 'https://api.anthropic.com',
'model': 'claude-3-5-sonnet-20241022',
'max_tokens': 4096,
'temperature': 0.7
},
'journal': {
'daily_notes_folder': 'Daily',
'date_format': 'YYYY-MM-DD',
'file_extension': '.md'
},
'output': {
'experiences_folder': 'Knowledge/Experiences',
'lessons_folder': 'Knowledge/Lessons',
'tasks_folder': 'Tasks/Daily',
'problems_folder': 'Knowledge/Problems',
'achievements_folder': 'Knowledge/Achievements',
'improvements_folder': 'Knowledge/Improvements'
},
'analysis': {
'categories': [],
'extraction_rules': {}
},
'logging': {
'level': 'INFO',
'file': 'logs/journal_organizer.log'
}
}
assert self.migrator.check_migration_needed(config_up_to_date) is False
def test_migration_preview_messages_helpful(self):
"""Test that migration preview messages are helpful and informative"""
config_dict = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'model': 'claude-3-sonnet' # Legacy model name
}
}
preview = self.migrator.get_migration_preview(config_dict)
assert len(preview) > 0
# Should have informative messages about what will be changed
preview_text = '\n'.join(preview)
assert 'api_url' in preview_text.lower()
assert 'claude-3-sonnet' in preview_text
assert 'claude-3-sonnet-20240229' in preview_text
# Messages should be user-friendly
assert any('will be added' in msg or 'will be migrated' in msg or 'will be updated' in msg for msg in preview)
def test_migration_messages_include_rationale(self):
"""Test that migration messages include rationale for changes"""
config_dict = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
# Missing api_url and using default model
}
}
preview = self.migrator.get_migration_preview(config_dict)
# Should explain why changes are being made
preview_text = '\n'.join(preview).lower()
assert 'default' in preview_text or 'missing' in preview_text
def test_migration_preserves_user_values(self):
"""Test that migration preserves user-specified values"""
config_dict = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'model': 'claude-3-sonnet', # Legacy model name
'max_tokens': 8192, # User-specified value
'temperature': 0.3 # User-specified value
}
}
migrated = self.migrator.migrate_claude_config(config_dict)
# Should preserve user values
assert migrated['claude']['max_tokens'] == 8192
assert migrated['claude']['temperature'] == 0.3
# Should migrate legacy values
assert migrated['claude']['model'] == 'claude-3-sonnet-20240229'
# Should add missing defaults
assert migrated['claude']['api_url'] == 'https://api.anthropic.com'
def test_migration_handles_partial_configs(self):
"""Test that migration handles partial configurations gracefully"""
partial_configs = [
# Only obsidian config
{
'obsidian': {
'vault_path': '/test/vault',
'rest_api_url': 'https://localhost:27123',
'rest_api_key': 'test-key'
}
},
# Only claude config
{
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
}
},
# Empty config
{}
]
for partial_config in partial_configs:
# Should not raise errors
migrated = self.migrator.migrate_configuration(partial_config)
# Should have all required sections
assert 'claude' in migrated
assert 'journal' in migrated
assert 'output' in migrated
assert 'analysis' in migrated
assert 'logging' in migrated
# Claude section should have all required fields
assert 'api_key' in migrated['claude']
assert 'api_url' in migrated['claude']
assert 'model' in migrated['claude']
def test_supported_model_names_comprehensive(self):
"""Test that supported model names list is comprehensive"""
supported_models = self.migrator.get_supported_model_names()
# Should include current models
current_models = [
'claude-3-5-sonnet-20241022',
'claude-3-5-haiku-20241022',
'claude-3-opus-20240229',
'claude-3-sonnet-20240229',
'claude-3-haiku-20240307'
]
for model in current_models:
assert model in supported_models, f"Current model '{model}' should be in supported list"
# Should include legacy models
legacy_models = [
'claude-3-sonnet',
'claude-3-opus',
'claude-3-haiku',
'sonnet',
'opus',
'haiku'
]
for model in legacy_models:
assert model in supported_models, f"Legacy model '{model}' should be in supported list"
@patch('builtins.print')
def test_migration_logging_appropriate(self, mock_print):
"""Test that migration produces appropriate logging messages"""
config_dict = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'model': 'claude-3-sonnet' # Legacy model name
}
}
# Perform migration
migrated = self.migrator.migrate_claude_config(config_dict)
# Should have logged the migration
assert mock_print.called
# Check that the log message is informative
log_calls = [call[0][0] for call in mock_print.call_args_list]
log_text = ' '.join(log_calls)
assert 'claude-3-sonnet' in log_text
assert 'claude-3-sonnet-20240229' in log_text
assert 'migrated' in log_text.lower() or 'updated' in log_text.lower()
class TestConfigurationIntegration:
"""Test full configuration integration with backward compatibility"""
def setup_method(self):
"""Set up test fixtures"""
self.temp_dir = Path(tempfile.mkdtemp())
self.test_vault = self.temp_dir / 'test_vault'
self.test_vault.mkdir()
(self.test_vault / '.obsidian').mkdir()
# Set test API key
os.environ['ANTHROPIC_API_KEY'] = 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
def teardown_method(self):
"""Clean up test fixtures"""
import shutil
shutil.rmtree(self.temp_dir)
if 'ANTHROPIC_API_KEY' in os.environ:
del os.environ['ANTHROPIC_API_KEY']
def create_config_file(self, config_data: Dict[str, Any]) -> Path:
"""Create a configuration file"""
config_file = self.temp_dir / 'config.yaml'
with config_file.open('w') as f:
yaml.dump(config_data, f)
return config_file
def test_end_to_end_legacy_config_loading(self):
"""Test end-to-end loading of legacy configuration"""
# Simulate a real legacy config file that a user might have
legacy_config = {
'obsidian': {
'vault_path': str(self.test_vault),
'rest_api_url': 'https://localhost:27123',
'rest_api_key': 'test-key',
'verify_ssl': False
},
'claude': {
'api_key': '${ANTHROPIC_API_KEY}',
'model': 'sonnet', # Very legacy model name
'max_tokens': 2048
},
'journal': {
'daily_notes_folder': 'Daily Notes',
'date_format': 'YYYY-MM-DD'
}
}
config_file = self.create_config_file(legacy_config)
# Load through the full Config class (end-to-end test)
config = Config(str(config_file))
# Verify everything works as expected
assert config.claude.model == 'claude-3-5-sonnet-20241022' # Migrated from 'sonnet'
assert config.claude.api_url == 'https://api.anthropic.com' # Added default
assert config.claude.max_tokens == 2048 # Preserved user value
assert config.journal.daily_notes_folder == 'Daily Notes' # Preserved
# Test that methods still work
daily_path = config.get_daily_note_path('2024-01-01')
assert 'Daily Notes' in daily_path
assert '2024-01-01' in daily_path
# Test serialization still works
config_dict = config.to_dict()
assert config_dict['claude']['model'] == 'claude-3-5-sonnet-20241022'
assert 'api_key' not in config_dict['claude'] # Should be sanitized
def test_config_reload_preserves_migration(self):
"""Test that config reload preserves migration results"""
legacy_config = {
'obsidian': {
'vault_path': str(self.test_vault),
'rest_api_url': 'https://localhost:27123',
'rest_api_key': 'test-key'
},
'claude': {
'api_key': '${ANTHROPIC_API_KEY}',
'model': 'claude-3-opus' # Legacy model name
}
}
config_file = self.create_config_file(legacy_config)
config = Config(str(config_file))
# Verify initial migration
assert config.claude.model == 'claude-3-opus-20240229'
# Reload config
config.reload_config()
# Should still have migrated values
assert config.claude.model == 'claude-3-opus-20240229'
assert config.claude.api_url == 'https://api.anthropic.com'
if __name__ == "__main__":
# Run tests with pytest
pytest.main([__file__, "-v"])
@@ -0,0 +1,191 @@
"""
Integration tests for Claude API configuration system
Tests Skills with different Claude API configurations, environment variables, and backward compatibility
"""
import os
import pytest
import tempfile
import yaml
from pathlib import Path
from unittest.mock import Mock, patch, AsyncMock
from typing import Dict, Any
# Import the modules we're testing
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
from config_validation import ClaudeAPIConfig
from configuration_loader import ConfigurationLoader
from configuration_migrator import ConfigurationMigrator
class TestEnvironmentVariableIntegration:
"""Test environment variable scenarios in configuration"""
def setup_method(self):
"""Set up test fixtures"""
self.temp_dir = Path(tempfile.mkdtemp())
self.loader = ConfigurationLoader()
self.migrator = ConfigurationMigrator()
def teardown_method(self):
"""Clean up test fixtures"""
import shutil
shutil.rmtree(self.temp_dir)
# Clean up any test environment variables
test_vars = ['TEST_CLAUDE_API_KEY', 'TEST_CLAUDE_API_URL', 'TEST_CLAUDE_MODEL']
for var in test_vars:
if var in os.environ:
del os.environ[var]
def create_test_config_file(self, config_data: Dict[str, Any]) -> Path:
"""Create a test configuration file"""
config_file = self.temp_dir / 'test_config.yaml'
with config_file.open('w') as f:
yaml.dump(config_data, f)
return config_file
def test_environment_variable_expansion(self):
"""Test environment variable expansion"""
# Set up environment variables
os.environ['TEST_CLAUDE_API_KEY'] = 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
os.environ['TEST_CLAUDE_API_URL'] = 'https://custom-api.example.com'
config_data = {
'claude': {
'api_key': '${TEST_CLAUDE_API_KEY}',
'api_url': '${TEST_CLAUDE_API_URL}',
'model': 'claude-3-5-sonnet-20241022'
}
}
expanded = self.loader.expand_environment_variables(config_data)
assert expanded['claude']['api_key'] == 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
assert expanded['claude']['api_url'] == 'https://custom-api.example.com'
assert expanded['claude']['model'] == 'claude-3-5-sonnet-20241022'
def test_environment_variable_defaults(self):
"""Test environment variable expansion with defaults"""
config_data = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'api_url': '${CLAUDE_API_URL:-https://api.anthropic.com}',
'model': '${CLAUDE_MODEL:-claude-3-5-sonnet-20241022}'
}
}
expanded = self.loader.expand_environment_variables(config_data)
# Should use defaults since env vars are not set
assert expanded['claude']['api_url'] == 'https://api.anthropic.com'
assert expanded['claude']['model'] == 'claude-3-5-sonnet-20241022'
class TestBackwardCompatibilityIntegration:
"""Test backward compatibility with existing setups"""
def setup_method(self):
"""Set up test fixtures"""
self.migrator = ConfigurationMigrator()
def test_legacy_model_migration(self):
"""Test migration of legacy model names"""
legacy_config = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'model': 'claude-3-sonnet' # Legacy model name
}
}
migrated = self.migrator.migrate_claude_config(legacy_config)
# Should migrate to new model name
assert migrated['claude']['model'] == 'claude-3-sonnet-20240229'
# Should add default API URL
assert migrated['claude']['api_url'] == 'https://api.anthropic.com'
def test_migration_needed_detection(self):
"""Test detection of configurations that need migration"""
# Config that needs migration
legacy_config = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'model': 'claude-3-opus' # Legacy model name
}
}
assert self.migrator.check_migration_needed(legacy_config) is True
# Config that doesn't need migration
modern_config = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'api_url': 'https://api.anthropic.com',
'model': 'claude-3-5-sonnet-20241022',
'max_tokens': 4096,
'temperature': 0.7
},
'journal': {
'daily_notes_folder': 'Daily',
'date_format': 'YYYY-MM-DD',
'file_extension': '.md'
},
'output': {
'experiences_folder': 'Knowledge/Experiences',
'lessons_folder': 'Knowledge/Lessons',
'tasks_folder': 'Tasks/Daily',
'problems_folder': 'Knowledge/Problems',
'achievements_folder': 'Knowledge/Achievements',
'improvements_folder': 'Knowledge/Improvements'
},
'analysis': {
'categories': [],
'extraction_rules': {}
},
'logging': {
'level': 'INFO',
'file': 'logs/journal_organizer.log'
}
}
assert self.migrator.check_migration_needed(modern_config) is False
class TestConfigurationValidation:
"""Test configuration validation scenarios"""
def test_claude_api_config_validation(self):
"""Test ClaudeAPIConfig validation"""
# Valid configuration
config = ClaudeAPIConfig(
api_key='sk-ant-test-key-12345678901234567890123456789012345678901234567890',
api_url='https://custom-api.example.com',
model='claude-3-5-sonnet-20241022'
)
assert config.api_key.startswith('sk-ant-')
assert config.api_url == 'https://custom-api.example.com'
assert config.model == 'claude-3-5-sonnet-20241022'
def test_invalid_configuration_handling(self):
"""Test handling of invalid configurations"""
# Invalid API key format
with pytest.raises(ValueError, match="Claude API key should start with"):
ClaudeAPIConfig(api_key='invalid-key')
# Invalid URL format
with pytest.raises(ValueError, match="Invalid URL format"):
ClaudeAPIConfig(
api_key='sk-ant-test-key-12345678901234567890123456789012345678901234567890',
api_url='not-a-url'
)
# Invalid model name
with pytest.raises(ValueError, match="Invalid model name"):
ClaudeAPIConfig(
api_key='sk-ant-test-key-12345678901234567890123456789012345678901234567890',
model='invalid-model'
)
+516
View File
@@ -0,0 +1,516 @@
"""
Integration tests for Claude Skills.
Tests Skills with mocked API responses to verify AI integration functionality.
"""
import pytest
import json
from unittest.mock import AsyncMock, patch, Mock
from agent_core import CommandContext, SkillResult
from skills.claude_skill import ClaudeAnalyzeSkill, ClaudeTransformSkill
class TestClaudeAnalyzeSkill:
"""Integration tests for ClaudeAnalyzeSkill"""
@pytest.fixture
def skill(self):
"""Create ClaudeAnalyzeSkill instance"""
return ClaudeAnalyzeSkill()
@pytest.fixture
def context(self, sample_config):
"""Create command context with analysis parameters"""
return CommandContext(
command_name="analyze",
args={
"content": """# 2024-01-15 Daily Journal
## 今天的经历
- 完成了项目的重要里程碑
- 与团队进行了有效的沟通
## 学到的东西
- 学会了新的Python异步编程技巧
- 理解了更好的错误处理模式
## 遇到的问题
- API调用偶尔超时
- 配置文件格式需要改进
## 明天的计划
- 优化API调用的重试机制
- 更新文档
""",
"analysis_type": "extract_experiences"
},
config=sample_config
)
@pytest.mark.asyncio
async def test_analyze_journal_success(self, skill, context):
"""Test successful journal analysis"""
mock_response = {
"experiences": [
{
"title": "项目里程碑完成",
"description": "成功完成了项目的重要里程碑,展现了良好的项目管理能力",
"category": "项目管理",
"importance": "high"
},
{
"title": "团队沟通改进",
"description": "与团队进行了有效的沟通,提升了协作效率",
"category": "团队协作",
"importance": "medium"
}
],
"lessons": [
{
"title": "Python异步编程",
"description": "学会了新的Python异步编程技巧,提升了代码效率",
"category": "技术学习",
"application": "可以应用到当前项目的API调用优化中"
}
],
"problems": [
{
"title": "API调用超时",
"description": "API调用偶尔出现超时问题",
"severity": "medium",
"suggested_solution": "实现重试机制和超时处理"
}
]
}
# Mock the Claude API client
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
# Mock the messages.create method
mock_message = Mock()
mock_message.content = [Mock(text=json.dumps(mock_response, ensure_ascii=False))]
mock_client.messages.create.return_value = mock_message
result = await skill.execute(context)
assert result.success is True
assert "experiences" in result.data
assert "lessons" in result.data
assert "problems" in result.data
assert len(result.data["experiences"]) == 2
assert len(result.data["lessons"]) == 1
assert len(result.data["problems"]) == 1
# Verify API was called with correct parameters
mock_client.messages.create.assert_called_once()
call_args = mock_client.messages.create.call_args
assert call_args[1]["model"] == "claude-3-5-sonnet-20241022"
assert call_args[1]["max_tokens"] == 4096
@pytest.mark.asyncio
async def test_analyze_empty_content(self, skill, sample_config):
"""Test analysis with empty content"""
context = CommandContext(
command_name="analyze",
args={"content": "", "analysis_type": "extract_experiences"},
config=sample_config
)
result = await skill.execute(context)
assert result.success is False
assert "content" in result.error.lower()
@pytest.mark.asyncio
async def test_analyze_api_error(self, skill, context):
"""Test handling of Claude API errors"""
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
# Mock API error
mock_client.messages.create.side_effect = Exception("API rate limit exceeded")
result = await skill.execute(context)
assert result.success is False
assert "api" in result.error.lower() or "rate limit" in result.error.lower()
@pytest.mark.asyncio
async def test_analyze_invalid_json_response(self, skill, context):
"""Test handling of invalid JSON response from Claude"""
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
# Mock invalid JSON response
mock_message = Mock()
mock_message.content = [Mock(text="Invalid JSON response")]
mock_client.messages.create.return_value = mock_message
result = await skill.execute(context)
assert result.success is False
assert "json" in result.error.lower() or "parse" in result.error.lower()
@pytest.mark.asyncio
async def test_analyze_different_types(self, skill, sample_config):
"""Test different analysis types"""
analysis_types = ["extract_experiences", "extract_lessons", "extract_problems", "summarize"]
for analysis_type in analysis_types:
context = CommandContext(
command_name="analyze",
args={
"content": "Sample journal content for testing",
"analysis_type": analysis_type
},
config=sample_config
)
mock_response = {"result": f"Analysis result for {analysis_type}"}
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
mock_message = Mock()
mock_message.content = [Mock(text=json.dumps(mock_response))]
mock_client.messages.create.return_value = mock_message
result = await skill.execute(context)
assert result.success is True
assert result.data["analysis_type"] == analysis_type
class TestClaudeTransformSkill:
"""Integration tests for ClaudeTransformSkill"""
@pytest.fixture
def skill(self):
"""Create ClaudeTransformSkill instance"""
return ClaudeTransformSkill()
@pytest.fixture
def context(self, sample_config):
"""Create command context with transformation parameters"""
return CommandContext(
command_name="transform",
args={
"content": {
"title": "项目里程碑完成",
"description": "成功完成了项目的重要里程碑",
"category": "项目管理"
},
"transform_type": "create_experience_note",
"target_format": "markdown"
},
config=sample_config
)
@pytest.mark.asyncio
async def test_transform_to_markdown_success(self, skill, context):
"""Test successful content transformation to markdown"""
mock_response = """# 项目里程碑完成
## 经验描述
成功完成了项目的重要里程碑这次经历展现了良好的项目管理能力和团队协作精神
## 关键要点
- 项目管理技能得到提升
- 团队协作效率显著改善
- 里程碑按时完成
## 应用场景
这个经验可以应用到未来的项目管理中特别是在设定和跟踪项目里程碑方面
## 相关标签
#项目管理 #里程碑 #团队协作
---
*创建时间: 2024-01-15*
*来源: 日记整理*
"""
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
mock_message = Mock()
mock_message.content = [Mock(text=mock_response)]
mock_client.messages.create.return_value = mock_message
result = await skill.execute(context)
assert result.success is True
assert result.data["transformed_content"] == mock_response
assert result.data["transform_type"] == "create_experience_note"
assert result.data["target_format"] == "markdown"
# Verify the content contains expected markdown elements
assert "# 项目里程碑完成" in result.data["transformed_content"]
assert "## 经验描述" in result.data["transformed_content"]
assert "#项目管理" in result.data["transformed_content"]
@pytest.mark.asyncio
async def test_transform_different_types(self, skill, sample_config):
"""Test different transformation types"""
transform_types = [
"create_experience_note",
"create_lesson_note",
"create_problem_note",
"create_summary"
]
for transform_type in transform_types:
context = CommandContext(
command_name="transform",
args={
"content": {"title": "Test", "description": "Test content"},
"transform_type": transform_type,
"target_format": "markdown"
},
config=sample_config
)
mock_response = f"# Transformed Content\n\nContent for {transform_type}"
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
mock_message = Mock()
mock_message.content = [Mock(text=mock_response)]
mock_client.messages.create.return_value = mock_message
result = await skill.execute(context)
assert result.success is True
assert result.data["transform_type"] == transform_type
@pytest.mark.asyncio
async def test_transform_missing_content(self, skill, sample_config):
"""Test transformation with missing content"""
context = CommandContext(
command_name="transform",
args={
"transform_type": "create_experience_note",
"target_format": "markdown"
# Missing content
},
config=sample_config
)
result = await skill.execute(context)
assert result.success is False
assert "content" in result.error.lower()
@pytest.mark.asyncio
async def test_transform_api_error(self, skill, context):
"""Test handling of Claude API errors during transformation"""
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
# Mock API error
mock_client.messages.create.side_effect = Exception("API authentication failed")
result = await skill.execute(context)
assert result.success is False
assert "api" in result.error.lower() or "authentication" in result.error.lower()
class TestClaudeSkillsIntegration:
"""Integration tests combining Claude skills"""
@pytest.mark.asyncio
async def test_analyze_then_transform_workflow(self, sample_config):
"""Test complete analyze-then-transform workflow"""
analyze_skill = ClaudeAnalyzeSkill()
transform_skill = ClaudeTransformSkill()
# Step 1: Analyze journal content
analyze_context = CommandContext(
command_name="analyze",
args={
"content": sample_journal_content,
"analysis_type": "extract_experiences"
},
config=sample_config
)
# Step 2: Transform extracted experience to note
experience_data = {
"title": "项目里程碑完成",
"description": "成功完成了项目的重要里程碑",
"category": "项目管理",
"importance": "high"
}
transform_context = CommandContext(
command_name="transform",
args={
"content": experience_data,
"transform_type": "create_experience_note",
"target_format": "markdown"
},
config=sample_config
)
# Mock responses
analyze_response = {
"experiences": [experience_data],
"lessons": [],
"problems": []
}
transform_response = """# 项目里程碑完成
## 经验描述
成功完成了项目的重要里程碑
## 分类
项目管理
## 重要程度
#项目管理 #里程碑
"""
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
# Mock analyze response
mock_analyze_message = Mock()
mock_analyze_message.content = [Mock(text=json.dumps(analyze_response, ensure_ascii=False))]
# Mock transform response
mock_transform_message = Mock()
mock_transform_message.content = [Mock(text=transform_response)]
# Set up side_effect to return different responses for different calls
mock_client.messages.create.side_effect = [mock_analyze_message, mock_transform_message]
# Execute analyze
analyze_result = await analyze_skill.execute(analyze_context)
assert analyze_result.success is True
assert len(analyze_result.data["experiences"]) == 1
# Execute transform using analyze result
transform_result = await transform_skill.execute(transform_context)
assert transform_result.success is True
assert "项目里程碑完成" in transform_result.data["transformed_content"]
# Verify both API calls were made
assert mock_client.messages.create.call_count == 2
@pytest.mark.asyncio
async def test_batch_analysis_and_transformation(self, sample_config):
"""Test batch processing of multiple content pieces"""
analyze_skill = ClaudeAnalyzeSkill()
transform_skill = ClaudeTransformSkill()
# Multiple journal entries to process
journal_entries = [
"今天学会了新的编程技巧",
"解决了一个复杂的技术问题",
"与客户进行了重要的项目讨论"
]
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
# Mock responses for each entry
mock_responses = []
for i, entry in enumerate(journal_entries):
analyze_response = {
"experiences": [{
"title": f"Experience {i+1}",
"description": entry,
"category": "学习"
}]
}
transform_response = f"# Experience {i+1}\n\n{entry}\n\n#学习"
mock_analyze_message = Mock()
mock_analyze_message.content = [Mock(text=json.dumps(analyze_response, ensure_ascii=False))]
mock_transform_message = Mock()
mock_transform_message.content = [Mock(text=transform_response)]
mock_responses.extend([mock_analyze_message, mock_transform_message])
mock_client.messages.create.side_effect = mock_responses
# Process each entry
results = []
for entry in journal_entries:
# Analyze
analyze_context = CommandContext(
command_name="analyze",
args={"content": entry, "analysis_type": "extract_experiences"},
config=sample_config
)
analyze_result = await analyze_skill.execute(analyze_context)
# Transform
if analyze_result.success and analyze_result.data["experiences"]:
experience = analyze_result.data["experiences"][0]
transform_context = CommandContext(
command_name="transform",
args={
"content": experience,
"transform_type": "create_experience_note",
"target_format": "markdown"
},
config=sample_config
)
transform_result = await transform_skill.execute(transform_context)
results.append((analyze_result, transform_result))
# Verify all entries were processed successfully
assert len(results) == len(journal_entries)
for analyze_result, transform_result in results:
assert analyze_result.success is True
assert transform_result.success is True
# Verify correct number of API calls (2 per entry: analyze + transform)
assert mock_client.messages.create.call_count == len(journal_entries) * 2
# Sample journal content for testing
sample_journal_content = """# 2024-01-15 Daily Journal
## 今天的经历
- 完成了项目的重要里程碑
- 与团队进行了有效的沟通
- 参加了技术分享会议
## 学到的东西
- 学会了新的Python异步编程技巧
- 理解了更好的错误处理模式
- 掌握了新的项目管理方法
## 遇到的问题
- API调用偶尔超时
- 配置文件格式需要改进
- 团队沟通中存在信息不对称
## 明天的计划
- 优化API调用的重试机制
- 更新文档
- 组织团队同步会议
"""
@@ -0,0 +1,431 @@
"""
Integration tests for conversational agent flow.
Tests the v2.0 conversational interface with natural language processing.
"""
import pytest
import json
from unittest.mock import AsyncMock, patch, Mock
from aioresponses import aioresponses
from agent_core import CommandContext, SkillResult
from conversation.conversational_agent import ConversationalAgent
from conversation.conversation_state import ConversationState
from conversation.intent_understanding import IntentUnderstanding
from conversation.response_generator import ResponseGenerator
class TestConversationalAgent:
"""Integration tests for ConversationalAgent"""
@pytest.fixture
def agent(self, sample_config):
"""Create ConversationalAgent instance"""
return ConversationalAgent(sample_config)
@pytest.fixture
def sample_user_inputs(self):
"""Sample user inputs for testing"""
return [
"整理今天的日记",
"organize today's journal",
"分析2024年1月15日的日记",
"help me organize my notes from yesterday",
"今天学到了什么?",
"what did I learn today?",
"整理昨天的经验和教训"
]
@pytest.mark.asyncio
async def test_conversational_agent_basic_flow(self, agent):
"""Test basic conversational flow"""
user_input = "整理今天的日记"
# Mock the underlying organize command execution
with patch.object(agent.agent, 'execute_command') as mock_execute:
mock_result = SkillResult(
success=True,
data={
"summary": "Successfully organized journal",
"created_notes": [
{"type": "experience", "title": "Test Experience", "path": "Knowledge/Experiences/test.md"}
]
},
message="Journal organized successfully"
)
mock_execute.return_value = mock_result
response = await agent.process_message(user_input)
assert response is not None
assert "成功" in response or "successfully" in response.lower()
mock_execute.assert_called_once()
# Verify the command was called with correct parameters
call_args = mock_execute.call_args
assert call_args[0][0] == "organize" # Command name
@pytest.mark.asyncio
async def test_conversational_agent_with_date_extraction(self, agent):
"""Test conversational agent with date parameter extraction"""
user_input = "分析2024年1月15日的日记"
with patch.object(agent.agent, 'execute_command') as mock_execute:
mock_result = SkillResult(success=True, data={}, message="Analysis completed")
mock_execute.return_value = mock_result
response = await agent.process_message(user_input)
assert response is not None
mock_execute.assert_called_once()
# Verify date was extracted and passed
call_args = mock_execute.call_args
assert "date" in call_args[1]["args"] # Should have extracted date
@pytest.mark.asyncio
async def test_conversational_agent_error_handling(self, agent):
"""Test conversational agent error handling"""
user_input = "整理今天的日记"
with patch.object(agent.agent, 'execute_command') as mock_execute:
mock_result = SkillResult(
success=False,
error="Journal file not found",
message="Failed to organize journal"
)
mock_execute.return_value = mock_result
response = await agent.process_message(user_input)
assert response is not None
assert "错误" in response or "error" in response.lower() or "failed" in response.lower()
@pytest.mark.asyncio
async def test_conversational_agent_unknown_intent(self, agent):
"""Test conversational agent with unknown intent"""
user_input = "今天天气怎么样?" # Weather question, not related to journal organization
response = await agent.process_message(user_input)
assert response is not None
assert "不理解" in response or "不明白" in response or "help" in response.lower()
@pytest.mark.asyncio
async def test_conversational_agent_help_request(self, agent):
"""Test conversational agent help functionality"""
help_inputs = ["help", "帮助", "你能做什么?", "what can you do?"]
for user_input in help_inputs:
response = await agent.process_message(user_input)
assert response is not None
assert "整理" in response or "organize" in response.lower()
assert "日记" in response or "journal" in response.lower()
@pytest.mark.asyncio
async def test_conversational_agent_multiple_turns(self, agent):
"""Test multi-turn conversation"""
conversation_turns = [
("你好", "greeting"),
("整理今天的日记", "organize"),
("谢谢", "thanks")
]
for user_input, expected_intent in conversation_turns:
if expected_intent == "organize":
with patch.object(agent.agent, 'execute_command') as mock_execute:
mock_result = SkillResult(success=True, data={}, message="Success")
mock_execute.return_value = mock_result
response = await agent.process_message(user_input)
else:
response = await agent.process_message(user_input)
assert response is not None
assert len(response) > 0
class TestIntentUnderstanding:
"""Integration tests for IntentUnderstanding"""
@pytest.fixture
def intent_processor(self):
"""Create IntentUnderstanding instance"""
return IntentUnderstanding()
def test_organize_intent_detection(self, intent_processor):
"""Test detection of organize intents"""
organize_inputs = [
"整理今天的日记",
"organize today's journal",
"分析我的日记",
"help me organize my notes",
"整理昨天的笔记"
]
for user_input in organize_inputs:
intent = intent_processor.understand_intent(user_input)
assert intent["action"] == "organize"
assert "command" in intent
assert intent["command"] == "organize"
def test_date_parameter_extraction(self, intent_processor):
"""Test extraction of date parameters"""
date_inputs = [
("整理2024年1月15日的日记", "2024-01-15"),
("analyze journal from yesterday", "yesterday"),
("organize today's notes", "today"),
("分析昨天的日记", "yesterday")
]
for user_input, expected_date in date_inputs:
intent = intent_processor.understand_intent(user_input)
if expected_date in ["today", "yesterday"]:
# These should be converted to actual dates
assert "date" in intent["parameters"]
else:
assert intent["parameters"].get("date") == expected_date
def test_help_intent_detection(self, intent_processor):
"""Test detection of help intents"""
help_inputs = [
"help",
"帮助",
"你能做什么?",
"what can you do?",
"how to use this?"
]
for user_input in help_inputs:
intent = intent_processor.understand_intent(user_input)
assert intent["action"] == "help"
def test_unknown_intent_handling(self, intent_processor):
"""Test handling of unknown intents"""
unknown_inputs = [
"今天天气怎么样?",
"what's the weather like?",
"计算1+1等于多少",
"play music"
]
for user_input in unknown_inputs:
intent = intent_processor.understand_intent(user_input)
assert intent["action"] == "unknown"
assert "confidence" in intent
assert intent["confidence"] < 0.5 # Low confidence for unknown intents
class TestResponseGenerator:
"""Integration tests for ResponseGenerator"""
@pytest.fixture
def response_generator(self):
"""Create ResponseGenerator instance"""
return ResponseGenerator()
def test_success_response_generation(self, response_generator):
"""Test generation of success responses"""
result = SkillResult(
success=True,
data={
"summary": "Successfully organized journal",
"created_notes": [
{"type": "experience", "title": "Project Milestone", "path": "Knowledge/Experiences/milestone.md"},
{"type": "lesson", "title": "Python Tips", "path": "Knowledge/Lessons/python.md"}
]
},
message="Journal organized successfully"
)
response = response_generator.generate_response(result, "organize")
assert response is not None
assert "成功" in response or "successfully" in response.lower()
assert "2" in response # Should mention number of notes created
assert "经验" in response or "experience" in response.lower()
assert "教训" in response or "lesson" in response.lower()
def test_error_response_generation(self, response_generator):
"""Test generation of error responses"""
result = SkillResult(
success=False,
error="Journal file not found for date 2024-01-15",
message="Failed to organize journal"
)
response = response_generator.generate_response(result, "organize")
assert response is not None
assert "错误" in response or "error" in response.lower() or "失败" in response
assert "2024-01-15" in response # Should include the problematic date
def test_help_response_generation(self, response_generator):
"""Test generation of help responses"""
response = response_generator.generate_help_response()
assert response is not None
assert "整理" in response or "organize" in response.lower()
assert "日记" in response or "journal" in response.lower()
assert "命令" in response or "command" in response.lower()
def test_unknown_intent_response(self, response_generator):
"""Test generation of unknown intent responses"""
response = response_generator.generate_unknown_response("今天天气怎么样?")
assert response is not None
assert "不理解" in response or "不明白" in response or "understand" in response.lower()
assert "帮助" in response or "help" in response.lower()
class TestConversationState:
"""Integration tests for ConversationState"""
@pytest.fixture
def conversation_state(self):
"""Create ConversationState instance"""
return ConversationState()
def test_conversation_history_tracking(self, conversation_state):
"""Test conversation history tracking"""
# Add some conversation turns
conversation_state.add_turn("user", "整理今天的日记")
conversation_state.add_turn("assistant", "好的,我来帮您整理今天的日记。")
conversation_state.add_turn("user", "谢谢")
conversation_state.add_turn("assistant", "不客气!还有其他需要帮助的吗?")
history = conversation_state.get_history()
assert len(history) == 4
assert history[0]["role"] == "user"
assert history[0]["content"] == "整理今天的日记"
assert history[1]["role"] == "assistant"
assert history[-1]["role"] == "assistant"
def test_context_management(self, conversation_state):
"""Test conversation context management"""
# Set some context
conversation_state.set_context("last_command", "organize")
conversation_state.set_context("last_date", "2024-01-15")
conversation_state.set_context("user_preference", "detailed_summary")
# Retrieve context
assert conversation_state.get_context("last_command") == "organize"
assert conversation_state.get_context("last_date") == "2024-01-15"
assert conversation_state.get_context("user_preference") == "detailed_summary"
assert conversation_state.get_context("nonexistent") is None
def test_conversation_reset(self, conversation_state):
"""Test conversation reset functionality"""
# Add some data
conversation_state.add_turn("user", "test message")
conversation_state.set_context("test_key", "test_value")
# Verify data exists
assert len(conversation_state.get_history()) == 1
assert conversation_state.get_context("test_key") == "test_value"
# Reset conversation
conversation_state.reset()
# Verify data is cleared
assert len(conversation_state.get_history()) == 0
assert conversation_state.get_context("test_key") is None
class TestFullConversationalFlow:
"""End-to-end integration tests for the complete conversational flow"""
@pytest.mark.asyncio
async def test_complete_organize_conversation(self, sample_config, sample_journal_content):
"""Test complete conversation flow for journal organization"""
agent = ConversationalAgent(sample_config)
# Mock all external dependencies
with aioresponses() as m:
# Mock Obsidian API
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload={
"content": sample_journal_content,
"stat": {"ctime": 1642204800000, "mtime": 1642204800000, "size": len(sample_journal_content)}
},
status=200
)
# Mock note creation
m.put(
"https://localhost:27123/vault/Knowledge/Experiences/test.md",
payload={"path": "Knowledge/Experiences/test.md", "stat": {}},
status=200
)
# Mock Claude API
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
# Mock analysis response
analyze_response = {
"experiences": [{"title": "Test Experience", "description": "Test", "category": "Test"}],
"lessons": [],
"problems": [],
"achievements": []
}
mock_analyze_message = Mock()
mock_analyze_message.content = [Mock(text=json.dumps(analyze_response, ensure_ascii=False))]
mock_transform_message = Mock()
mock_transform_message.content = [Mock(text="# Test Experience\n\nTest content")]
mock_client.messages.create.side_effect = [mock_analyze_message, mock_transform_message]
# Simulate conversation
conversation_turns = [
"你好",
"整理今天的日记",
"谢谢你的帮助"
]
responses = []
for user_input in conversation_turns:
response = await agent.process_message(user_input)
responses.append(response)
assert response is not None
assert len(response) > 0
# Verify conversation flow
assert "你好" in responses[0] or "hello" in responses[0].lower() # Greeting response
assert "成功" in responses[1] or "successfully" in responses[1].lower() # Success response
assert "不客气" in responses[2] or "welcome" in responses[2].lower() # Thanks response
@pytest.mark.asyncio
async def test_error_recovery_conversation(self, sample_config):
"""Test conversation flow with error recovery"""
agent = ConversationalAgent(sample_config)
with aioresponses() as m:
# Mock journal file not found
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
status=404,
payload={"error": "File not found"}
)
# Simulate error scenario
user_input = "整理今天的日记"
response = await agent.process_message(user_input)
assert response is not None
assert "找不到" in response or "not found" in response.lower() or "错误" in response
# Follow up with help request
help_response = await agent.process_message("我应该怎么办?")
assert help_response is not None
assert "建议" in help_response or "suggest" in help_response.lower() or "帮助" in help_response
+458
View File
@@ -0,0 +1,458 @@
"""
Integration tests for Obsidian Skills.
Tests Skills with mocked API responses to verify end-to-end functionality.
"""
import pytest
import json
from unittest.mock import AsyncMock, patch, Mock
from aioresponses import aioresponses
from agent_core import CommandContext, SkillResult
from skills.obsidian_skill import (
ObsidianReadSkill, ObsidianWriteSkill, ObsidianAppendSkill,
ObsidianListFilesSkill
)
class TestObsidianReadSkill:
"""Integration tests for ObsidianReadSkill"""
@pytest.fixture
def skill(self):
"""Create ObsidianReadSkill instance"""
return ObsidianReadSkill()
@pytest.fixture
def context(self, sample_config):
"""Create command context with Obsidian configuration"""
return CommandContext(
command_name="test_read",
args={"file_path": "Daily/2024-01-15.md"},
config=sample_config
)
@pytest.mark.asyncio
async def test_read_note_success(self, skill, context):
"""Test successful note reading"""
mock_response = {
"content": "# 2024-01-15 Daily Journal\n\nTest content",
"stat": {
"ctime": 1642204800000,
"mtime": 1642204800000,
"size": 45
}
}
with aioresponses() as m:
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload=mock_response,
status=200
)
result = await skill.execute(context)
assert result.success is True
assert result.data["content"] == mock_response["content"]
assert result.data["file_path"] == "Daily/2024-01-15.md"
assert "stat" in result.data
@pytest.mark.asyncio
async def test_read_note_not_found(self, skill, context):
"""Test reading non-existent note"""
with aioresponses() as m:
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
status=404,
payload={"error": "File not found"}
)
result = await skill.execute(context)
assert result.success is False
assert "not found" in result.error.lower()
@pytest.mark.asyncio
async def test_read_note_api_error(self, skill, context):
"""Test API connection error"""
with aioresponses() as m:
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
exception=Exception("Connection failed")
)
result = await skill.execute(context)
assert result.success is False
assert "connection" in result.error.lower() or "api" in result.error.lower()
@pytest.mark.asyncio
async def test_read_note_invalid_response(self, skill, context):
"""Test handling of invalid API response"""
with aioresponses() as m:
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload="invalid json response",
status=200
)
result = await skill.execute(context)
assert result.success is False
assert "response" in result.error.lower()
class TestObsidianWriteSkill:
"""Integration tests for ObsidianWriteSkill"""
@pytest.fixture
def skill(self):
"""Create ObsidianWriteSkill instance"""
return ObsidianWriteSkill()
@pytest.fixture
def context(self, sample_config):
"""Create command context with write parameters"""
return CommandContext(
command_name="test_write",
args={
"file_path": "Knowledge/Experiences/test-experience.md",
"content": "# Test Experience\n\nThis is a test experience note."
},
config=sample_config
)
@pytest.mark.asyncio
async def test_write_note_success(self, skill, context):
"""Test successful note writing"""
mock_response = {
"path": "Knowledge/Experiences/test-experience.md",
"stat": {
"ctime": 1642204800000,
"mtime": 1642204800000,
"size": 45
}
}
with aioresponses() as m:
m.put(
"https://localhost:27123/vault/Knowledge/Experiences/test-experience.md",
payload=mock_response,
status=200
)
result = await skill.execute(context)
assert result.success is True
assert result.data["file_path"] == "Knowledge/Experiences/test-experience.md"
assert "created" in result.message.lower() or "written" in result.message.lower()
@pytest.mark.asyncio
async def test_write_note_permission_error(self, skill, context):
"""Test write permission error"""
with aioresponses() as m:
m.put(
"https://localhost:27123/vault/Knowledge/Experiences/test-experience.md",
status=403,
payload={"error": "Permission denied"}
)
result = await skill.execute(context)
assert result.success is False
assert "permission" in result.error.lower()
@pytest.mark.asyncio
async def test_write_note_missing_content(self, skill, sample_config):
"""Test writing note without content"""
context = CommandContext(
command_name="test_write",
args={"file_path": "test.md"}, # Missing content
config=sample_config
)
result = await skill.execute(context)
assert result.success is False
assert "content" in result.error.lower()
class TestObsidianAppendSkill:
"""Integration tests for ObsidianAppendSkill"""
@pytest.fixture
def skill(self):
"""Create ObsidianAppendSkill instance"""
return ObsidianAppendSkill()
@pytest.fixture
def context(self, sample_config):
"""Create command context with append parameters"""
return CommandContext(
command_name="test_append",
args={
"file_path": "Daily/2024-01-15.md",
"content": "\n\n## Additional Notes\n\nAppended content."
},
config=sample_config
)
@pytest.mark.asyncio
async def test_append_to_existing_note_success(self, skill, context):
"""Test successful content appending to existing note"""
# Mock reading existing content
existing_content = "# 2024-01-15 Daily Journal\n\nExisting content"
read_response = {
"content": existing_content,
"stat": {"ctime": 1642204800000, "mtime": 1642204800000, "size": 45}
}
# Mock writing updated content
write_response = {
"path": "Daily/2024-01-15.md",
"stat": {"ctime": 1642204800000, "mtime": 1642204900000, "size": 90}
}
with aioresponses() as m:
# Mock GET request for reading existing content
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload=read_response,
status=200
)
# Mock PUT request for writing updated content
m.put(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload=write_response,
status=200
)
result = await skill.execute(context)
assert result.success is True
assert result.data["file_path"] == "Daily/2024-01-15.md"
assert "appended" in result.message.lower()
@pytest.mark.asyncio
async def test_append_to_nonexistent_note(self, skill, context):
"""Test appending to non-existent note (should create new note)"""
write_response = {
"path": "Daily/2024-01-15.md",
"stat": {"ctime": 1642204800000, "mtime": 1642204800000, "size": 45}
}
with aioresponses() as m:
# Mock GET request returning 404 (file doesn't exist)
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
status=404
)
# Mock PUT request for creating new file
m.put(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload=write_response,
status=200
)
result = await skill.execute(context)
assert result.success is True
assert "created" in result.message.lower()
class TestObsidianListFilesSkill:
"""Integration tests for ObsidianListFilesSkill"""
@pytest.fixture
def skill(self):
"""Create ObsidianListFilesSkill instance"""
return ObsidianListFilesSkill()
@pytest.fixture
def context(self, sample_config):
"""Create command context with list parameters"""
return CommandContext(
command_name="test_list",
args={"folder_path": "Daily"},
config=sample_config
)
@pytest.mark.asyncio
async def test_list_files_success(self, skill, context):
"""Test successful file listing"""
mock_response = {
"files": [
{
"path": "Daily/2024-01-15.md",
"name": "2024-01-15.md",
"stat": {"ctime": 1642204800000, "mtime": 1642204800000, "size": 45}
},
{
"path": "Daily/2024-01-14.md",
"name": "2024-01-14.md",
"stat": {"ctime": 1642118400000, "mtime": 1642118400000, "size": 38}
}
]
}
with aioresponses() as m:
m.get(
"https://localhost:27123/vault/Daily/",
payload=mock_response,
status=200
)
result = await skill.execute(context)
assert result.success is True
assert len(result.data["files"]) == 2
assert result.data["folder_path"] == "Daily"
assert any(file["name"] == "2024-01-15.md" for file in result.data["files"])
@pytest.mark.asyncio
async def test_list_files_empty_folder(self, skill, context):
"""Test listing files in empty folder"""
mock_response = {"files": []}
with aioresponses() as m:
m.get(
"https://localhost:27123/vault/Daily/",
payload=mock_response,
status=200
)
result = await skill.execute(context)
assert result.success is True
assert len(result.data["files"]) == 0
@pytest.mark.asyncio
async def test_list_files_folder_not_found(self, skill, context):
"""Test listing files in non-existent folder"""
with aioresponses() as m:
m.get(
"https://localhost:27123/vault/Daily/",
status=404,
payload={"error": "Folder not found"}
)
result = await skill.execute(context)
assert result.success is False
assert "not found" in result.error.lower()
class TestObsidianSkillsIntegration:
"""Integration tests combining multiple Obsidian skills"""
@pytest.mark.asyncio
async def test_read_write_workflow(self, sample_config):
"""Test complete read-modify-write workflow"""
read_skill = ObsidianReadSkill()
write_skill = ObsidianWriteSkill()
# Read existing content
read_context = CommandContext(
command_name="read",
args={"file_path": "Daily/2024-01-15.md"},
config=sample_config
)
# Write modified content
write_context = CommandContext(
command_name="write",
args={
"file_path": "Knowledge/Processed/2024-01-15-summary.md",
"content": "# Summary\n\nProcessed content from daily journal."
},
config=sample_config
)
with aioresponses() as m:
# Mock read response
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload={
"content": "# 2024-01-15 Daily Journal\n\nOriginal content",
"stat": {"ctime": 1642204800000, "mtime": 1642204800000, "size": 45}
},
status=200
)
# Mock write response
m.put(
"https://localhost:27123/vault/Knowledge/Processed/2024-01-15-summary.md",
payload={
"path": "Knowledge/Processed/2024-01-15-summary.md",
"stat": {"ctime": 1642204900000, "mtime": 1642204900000, "size": 60}
},
status=200
)
# Execute read
read_result = await read_skill.execute(read_context)
assert read_result.success is True
# Execute write (in real scenario, content would be processed)
write_result = await write_skill.execute(write_context)
assert write_result.success is True
# Verify workflow completed successfully
assert read_result.data["content"] is not None
assert write_result.data["file_path"] == "Knowledge/Processed/2024-01-15-summary.md"
@pytest.mark.asyncio
async def test_list_and_read_multiple_files(self, sample_config):
"""Test listing files and reading multiple files"""
list_skill = ObsidianListFilesSkill()
read_skill = ObsidianReadSkill()
list_context = CommandContext(
command_name="list",
args={"folder_path": "Daily"},
config=sample_config
)
with aioresponses() as m:
# Mock list response
m.get(
"https://localhost:27123/vault/Daily/",
payload={
"files": [
{"path": "Daily/2024-01-15.md", "name": "2024-01-15.md"},
{"path": "Daily/2024-01-14.md", "name": "2024-01-14.md"}
]
},
status=200
)
# Mock read responses for each file
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload={"content": "Content 1", "stat": {}},
status=200
)
m.get(
"https://localhost:27123/vault/Daily/2024-01-14.md",
payload={"content": "Content 2", "stat": {}},
status=200
)
# List files
list_result = await list_skill.execute(list_context)
assert list_result.success is True
assert len(list_result.data["files"]) == 2
# Read each file
for file_info in list_result.data["files"]:
read_context = CommandContext(
command_name="read",
args={"file_path": file_info["path"]},
config=sample_config
)
read_result = await read_skill.execute(read_context)
assert read_result.success is True
assert read_result.data["content"] is not None
+488
View File
@@ -0,0 +1,488 @@
"""
Integration tests for OrganizeCommand.
Tests Commands with full skill chains to verify end-to-end functionality.
"""
import pytest
import json
from unittest.mock import AsyncMock, patch, Mock
from aioresponses import aioresponses
from agent_core import CommandContext, SkillResult, Agent
from commands.organize_command import OrganizeCommand
class TestOrganizeCommand:
"""Integration tests for OrganizeCommand"""
@pytest.fixture
def command(self):
"""Create OrganizeCommand instance"""
return OrganizeCommand()
@pytest.fixture
def context(self, sample_config):
"""Create command context for organize command"""
return CommandContext(
command_name="organize",
args={
"date": "2024-01-15",
"vault_path": sample_config["obsidian"]["vault_path"],
"daily_folder": "Daily"
},
config=sample_config
)
@pytest.fixture
def sample_journal_content(self):
"""Sample journal content for testing"""
return """# 2024-01-15 Daily Journal
## 今天的经历
- 完成了项目的重要里程碑团队协作非常顺利
- 与客户进行了产品演示获得了积极反馈
- 参加了技术分享会议学到了新的架构模式
## 学到的东西
- 学会了新的Python异步编程技巧提升了代码效率
- 理解了微服务架构的最佳实践
- 掌握了更好的错误处理和日志记录模式
## 遇到的问题
- API调用偶尔超时影响用户体验
- 配置文件格式需要改进当前格式不够灵活
- 团队沟通中存在信息不对称问题
## 今天的成就
- 成功部署了新版本到生产环境
- 解决了困扰团队一周的性能问题
- 获得了客户的正面评价
## 明天的计划
- 优化API调用的重试机制
- 重构配置管理模块
- 组织团队同步会议
"""
@pytest.mark.asyncio
async def test_organize_command_full_workflow_success(self, command, context, sample_journal_content):
"""Test complete organize command workflow with all skills"""
# Mock Claude API responses
analyze_response = {
"experiences": [
{
"title": "项目里程碑完成",
"description": "成功完成了项目的重要里程碑,团队协作非常顺利",
"category": "项目管理",
"importance": "high"
},
{
"title": "客户产品演示",
"description": "与客户进行了产品演示,获得了积极反馈",
"category": "客户关系",
"importance": "high"
}
],
"lessons": [
{
"title": "Python异步编程技巧",
"description": "学会了新的Python异步编程技巧,提升了代码效率",
"category": "技术学习",
"application": "可以应用到当前项目的API调用优化中"
},
{
"title": "微服务架构最佳实践",
"description": "理解了微服务架构的最佳实践",
"category": "架构设计",
"application": "用于指导下一个项目的架构设计"
}
],
"problems": [
{
"title": "API调用超时",
"description": "API调用偶尔超时,影响用户体验",
"severity": "medium",
"suggested_solution": "实现重试机制和超时处理"
}
],
"achievements": [
{
"title": "生产环境部署",
"description": "成功部署了新版本到生产环境",
"impact": "提升了系统稳定性和性能"
}
]
}
# Mock transformation responses
experience_note = """# 项目里程碑完成
## 经验描述
成功完成了项目的重要里程碑团队协作非常顺利这次经历展现了良好的项目管理能力和团队协作精神
## 关键要点
- 项目管理技能得到提升
- 团队协作效率显著改善
- 里程碑按时完成
## 应用场景
这个经验可以应用到未来的项目管理中特别是在设定和跟踪项目里程碑方面
## 相关标签
#项目管理 #里程碑 #团队协作
---
*创建时间: 2024-01-15*
*来源: [[Daily/2024-01-15]]*
"""
lesson_note = """# Python异步编程技巧
## 学习内容
学会了新的Python异步编程技巧提升了代码效率
## 关键概念
- 异步编程模式
- 性能优化技巧
- 代码效率提升
## 实际应用
可以应用到当前项目的API调用优化中提升系统响应速度
## 相关标签
#技术学习 #Python #异步编程
---
*创建时间: 2024-01-15*
*来源: [[Daily/2024-01-15]]*
"""
with aioresponses() as m:
# Mock Obsidian API calls
# 1. Read daily journal
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload={
"content": sample_journal_content,
"stat": {"ctime": 1642204800000, "mtime": 1642204800000, "size": len(sample_journal_content)}
},
status=200
)
# 2. Write experience note
m.put(
"https://localhost:27123/vault/Knowledge/Experiences/项目里程碑完成.md",
payload={
"path": "Knowledge/Experiences/项目里程碑完成.md",
"stat": {"ctime": 1642204900000, "mtime": 1642204900000, "size": len(experience_note)}
},
status=200
)
# 3. Write lesson note
m.put(
"https://localhost:27123/vault/Knowledge/Lessons/Python异步编程技巧.md",
payload={
"path": "Knowledge/Lessons/Python异步编程技巧.md",
"stat": {"ctime": 1642204900000, "mtime": 1642204900000, "size": len(lesson_note)}
},
status=200
)
# 4. Additional notes for other categories (problems, achievements)
m.put(
"https://localhost:27123/vault/Knowledge/Problems/API调用超时.md",
payload={"path": "Knowledge/Problems/API调用超时.md", "stat": {}},
status=200
)
m.put(
"https://localhost:27123/vault/Knowledge/Achievements/生产环境部署.md",
payload={"path": "Knowledge/Achievements/生产环境部署.md", "stat": {}},
status=200
)
# Mock Claude API
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
# Mock analyze response
mock_analyze_message = Mock()
mock_analyze_message.content = [Mock(text=json.dumps(analyze_response, ensure_ascii=False))]
# Mock transform responses (one for each item to be transformed)
mock_transform_responses = [
Mock(content=[Mock(text=experience_note)]),
Mock(content=[Mock(text=experience_note)]), # Second experience
Mock(content=[Mock(text=lesson_note)]),
Mock(content=[Mock(text=lesson_note)]), # Second lesson
Mock(content=[Mock(text="# API调用超时\n\n问题描述...")]), # Problem note
Mock(content=[Mock(text="# 生产环境部署\n\n成就描述...")]) # Achievement note
]
# Set up responses: first analyze, then multiple transforms
mock_client.messages.create.side_effect = [mock_analyze_message] + mock_transform_responses
# Execute the organize command
result = await command.execute(context)
# Verify overall success
assert result.success is True
assert "organized successfully" in result.message.lower() or "completed" in result.message.lower()
# Verify data structure
assert "summary" in result.data
assert "created_notes" in result.data
# Verify created notes
created_notes = result.data["created_notes"]
assert len(created_notes) > 0
# Should have created notes for experiences, lessons, problems, achievements
note_types = [note.get("type") for note in created_notes]
expected_types = ["experience", "lesson", "problem", "achievement"]
for expected_type in expected_types:
assert any(expected_type in note_type for note_type in note_types if note_type)
# Verify API calls were made
assert mock_client.messages.create.call_count >= 2 # At least analyze + some transforms
@pytest.mark.asyncio
async def test_organize_command_journal_not_found(self, command, context):
"""Test organize command when daily journal doesn't exist"""
with aioresponses() as m:
# Mock journal file not found
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
status=404,
payload={"error": "File not found"}
)
result = await command.execute(context)
assert result.success is False
assert "not found" in result.error.lower() or "missing" in result.error.lower()
@pytest.mark.asyncio
async def test_organize_command_claude_api_error(self, command, context, sample_journal_content):
"""Test organize command when Claude API fails"""
with aioresponses() as m:
# Mock successful journal read
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload={
"content": sample_journal_content,
"stat": {"ctime": 1642204800000, "mtime": 1642204800000, "size": len(sample_journal_content)}
},
status=200
)
# Mock Claude API error
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
mock_client.messages.create.side_effect = Exception("Claude API rate limit exceeded")
result = await command.execute(context)
assert result.success is False
assert "api" in result.error.lower() or "claude" in result.error.lower()
@pytest.mark.asyncio
async def test_organize_command_partial_success(self, command, context, sample_journal_content):
"""Test organize command with partial success (some notes created, some failed)"""
analyze_response = {
"experiences": [
{
"title": "Test Experience",
"description": "Test description",
"category": "Test",
"importance": "medium"
}
],
"lessons": [],
"problems": [],
"achievements": []
}
with aioresponses() as m:
# Mock successful journal read
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload={
"content": sample_journal_content,
"stat": {"ctime": 1642204800000, "mtime": 1642204800000, "size": len(sample_journal_content)}
},
status=200
)
# Mock successful experience note creation
m.put(
"https://localhost:27123/vault/Knowledge/Experiences/Test Experience.md",
payload={
"path": "Knowledge/Experiences/Test Experience.md",
"stat": {"ctime": 1642204900000, "mtime": 1642204900000, "size": 100}
},
status=200
)
# Mock Claude API
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
# Mock analyze response
mock_analyze_message = Mock()
mock_analyze_message.content = [Mock(text=json.dumps(analyze_response, ensure_ascii=False))]
# Mock transform response
mock_transform_message = Mock()
mock_transform_message.content = [Mock(text="# Test Experience\n\nTransformed content")]
mock_client.messages.create.side_effect = [mock_analyze_message, mock_transform_message]
result = await command.execute(context)
# Should succeed even with minimal content
assert result.success is True
assert len(result.data["created_notes"]) >= 1
@pytest.mark.asyncio
async def test_organize_command_empty_journal(self, command, context):
"""Test organize command with empty journal content"""
with aioresponses() as m:
# Mock journal with empty content
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload={
"content": "",
"stat": {"ctime": 1642204800000, "mtime": 1642204800000, "size": 0}
},
status=200
)
result = await command.execute(context)
assert result.success is False
assert "empty" in result.error.lower() or "content" in result.error.lower()
class TestOrganizeCommandWithAgent:
"""Integration tests for OrganizeCommand within Agent context"""
@pytest.fixture
def agent(self, sample_config):
"""Create Agent with OrganizeCommand registered"""
agent = Agent("test_agent", sample_config)
agent.register_command(OrganizeCommand())
return agent
@pytest.mark.asyncio
async def test_agent_execute_organize_command(self, agent, sample_journal_content):
"""Test executing organize command through Agent"""
with aioresponses() as m:
# Mock Obsidian API
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload={
"content": sample_journal_content,
"stat": {"ctime": 1642204800000, "mtime": 1642204800000, "size": len(sample_journal_content)}
},
status=200
)
# Mock note creation (simplified - just one note)
m.put(
"https://localhost:27123/vault/Knowledge/Experiences/Test.md",
payload={"path": "Knowledge/Experiences/Test.md", "stat": {}},
status=200
)
# Mock Claude API
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
# Minimal response for testing
analyze_response = {
"experiences": [{"title": "Test", "description": "Test", "category": "Test"}],
"lessons": [],
"problems": [],
"achievements": []
}
mock_analyze_message = Mock()
mock_analyze_message.content = [Mock(text=json.dumps(analyze_response, ensure_ascii=False))]
mock_transform_message = Mock()
mock_transform_message.content = [Mock(text="# Test\n\nTest content")]
mock_client.messages.create.side_effect = [mock_analyze_message, mock_transform_message]
# Execute command through agent
result = await agent.execute_command(
"organize",
args={"date": "2024-01-15"},
options={"verbose": True}
)
assert result.success is True
assert result.data is not None
@pytest.mark.asyncio
async def test_agent_execute_organize_by_alias(self, agent, sample_journal_content):
"""Test executing organize command by alias through Agent"""
with aioresponses() as m:
# Mock minimal successful workflow
m.get(
"https://localhost:27123/vault/Daily/2024-01-15.md",
payload={
"content": sample_journal_content,
"stat": {"ctime": 1642204800000, "mtime": 1642204800000, "size": len(sample_journal_content)}
},
status=200
)
# Mock Claude API with minimal response
with patch('skills.claude_skill.anthropic') as mock_anthropic:
mock_client = Mock()
mock_anthropic.Anthropic.return_value = mock_client
analyze_response = {"experiences": [], "lessons": [], "problems": [], "achievements": []}
mock_message = Mock()
mock_message.content = [Mock(text=json.dumps(analyze_response))]
mock_client.messages.create.return_value = mock_message
# Execute by alias
result = await agent.execute_command("org", args={"date": "2024-01-15"})
assert result.success is True
@pytest.mark.asyncio
async def test_agent_command_info(self, agent):
"""Test getting command information through Agent"""
commands_info = agent.get_commands_info()
assert "organize" in commands_info["commands"]
organize_info = commands_info["commands"]["organize"]
assert organize_info["name"] == "organize"
assert organize_info["description"] == "分析和整理日记内容,提取经验和要点"
assert "org" in organize_info["aliases"]
assert "organize-journal" in organize_info["aliases"]
# Verify skills are registered
assert len(organize_info["skills"]) > 0
skill_names = list(organize_info["skills"].keys())
expected_skills = ["obsidian_read", "obsidian_write", "obsidian_append", "claude_analyze", "claude_transform"]
for expected_skill in expected_skills:
assert any(expected_skill in skill_name for skill_name in skill_names)
@@ -0,0 +1,512 @@
"""
Integration tests for real Claude API endpoints
Tests validation with default Anthropic API, proxy server configurations, and different model selections
Requirements: 1.3, 2.4
"""
import os
import pytest
import asyncio
import tempfile
import yaml
from pathlib import Path
from typing import Dict, Any, Optional
from unittest.mock import patch, Mock
# Import the modules we're testing
from config_validation import ClaudeAPIConfig
from claude_api_client import ClaudeAPIClient
from error_handling import APIError
class TestRealAPIEndpoints:
"""Test with real API endpoints - requires valid API key"""
def setup_method(self):
"""Set up test fixtures"""
self.temp_dir = Path(tempfile.mkdtemp())
# Check if we have a real API key for testing
self.api_key = os.getenv('ANTHROPIC_API_KEY')
self.has_real_api_key = (
self.api_key and
self.api_key.startswith('sk-ant-') and
len(self.api_key) > 50
)
def teardown_method(self):
"""Clean up test fixtures"""
import shutil
shutil.rmtree(self.temp_dir)
@pytest.mark.skipif(
not os.getenv('ANTHROPIC_API_KEY'),
reason="Requires ANTHROPIC_API_KEY environment variable for real API testing"
)
@pytest.mark.asyncio
async def test_default_anthropic_api_validation(self):
"""Test validation with default Anthropic API"""
if not self.has_real_api_key:
pytest.skip("Requires valid ANTHROPIC_API_KEY for real API testing")
config = ClaudeAPIConfig(
api_key=self.api_key,
api_url="https://api.anthropic.com",
model="claude-3-5-sonnet-20241022"
)
client = ClaudeAPIClient(config)
# Test connection validation
is_valid = await client.validate_connection()
assert is_valid is True
# Test model availability
is_model_valid = await client.validate_model_availability()
assert is_model_valid is True
# Test comprehensive connectivity
results = await client.test_api_connectivity()
assert results['overall_status'] == 'success'
assert results['connection_test']['status'] == 'success'
assert results['model_test']['status'] == 'success'
assert results['authentication_test']['status'] == 'success'
@pytest.mark.skipif(
not os.getenv('ANTHROPIC_API_KEY'),
reason="Requires ANTHROPIC_API_KEY environment variable for real API testing"
)
@pytest.mark.asyncio
async def test_different_model_selections(self):
"""Test different Claude model selections work correctly"""
if not self.has_real_api_key:
pytest.skip("Requires valid ANTHROPIC_API_KEY for real API testing")
# Test different models that should be available
models_to_test = [
"claude-3-5-sonnet-20241022",
"claude-3-haiku-20240307",
# Note: claude-3-opus may not be available in all regions/accounts
]
for model in models_to_test:
config = ClaudeAPIConfig(
api_key=self.api_key,
api_url="https://api.anthropic.com",
model=model
)
client = ClaudeAPIClient(config)
try:
# Test that the model is available
is_valid = await client.validate_model_availability()
assert is_valid is True, f"Model {model} should be available"
# Test a simple API call with the model
response = await client.create_message([
{"role": "user", "content": "Hello"}
])
assert response is not None
assert hasattr(response, 'content')
assert len(response.content) > 0
except APIError as e:
# Some models might not be available in all regions/accounts
if "model" in str(e).lower() and "not found" in str(e).lower():
pytest.skip(f"Model {model} not available in this account/region")
else:
raise
@pytest.mark.asyncio
async def test_invalid_api_key_handling(self):
"""Test handling of invalid API keys"""
config = ClaudeAPIConfig(
api_key="sk-ant-invalid-key-12345678901234567890123456789012345678901234567890",
api_url="https://api.anthropic.com",
model="claude-3-5-sonnet-20241022"
)
client = ClaudeAPIClient(config)
with pytest.raises(APIError, match="Invalid API key or unauthorized access"):
await client.validate_connection()
@pytest.mark.asyncio
async def test_invalid_api_url_handling(self):
"""Test handling of invalid API URLs"""
config = ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
api_url="https://nonexistent-api.example.com",
model="claude-3-5-sonnet-20241022"
)
client = ClaudeAPIClient(config)
with pytest.raises(APIError, match="Connection error"):
await client.validate_connection()
@pytest.mark.asyncio
async def test_invalid_model_handling(self):
"""Test handling of invalid model names"""
if not self.has_real_api_key:
pytest.skip("Requires valid ANTHROPIC_API_KEY for real API testing")
config = ClaudeAPIConfig(
api_key=self.api_key,
api_url="https://api.anthropic.com",
model="claude-nonexistent-model"
)
client = ClaudeAPIClient(config)
with pytest.raises(APIError, match="Model.*not found"):
await client.validate_model_availability()
class TestProxyServerConfigurations:
"""Test proxy server configurations"""
def setup_method(self):
"""Set up test fixtures"""
self.temp_dir = Path(tempfile.mkdtemp())
def teardown_method(self):
"""Clean up test fixtures"""
import shutil
shutil.rmtree(self.temp_dir)
@pytest.mark.asyncio
async def test_localhost_proxy_configuration(self):
"""Test configuration for localhost proxy servers"""
config = ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
api_url="https://localhost:8080",
model="claude-3-5-sonnet-20241022"
)
client = ClaudeAPIClient(config)
# Test that SSL context is configured for localhost
async with client.create_http_session() as session:
# Should not raise SSL errors for localhost
assert session is not None
# Verify SSL context is configured for localhost
connector = session.connector
assert connector.ssl is not None
# For localhost, SSL verification should be disabled
assert not connector.ssl.check_hostname
@pytest.mark.asyncio
async def test_custom_proxy_url_configuration(self):
"""Test configuration for custom proxy URLs"""
proxy_urls = [
"https://proxy.example.com:8080",
"https://claude-proxy.internal:443",
"http://localhost:3128"
]
for proxy_url in proxy_urls:
config = ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
api_url=proxy_url,
model="claude-3-5-sonnet-20241022"
)
client = ClaudeAPIClient(config)
# Test client initialization
assert client.base_url == proxy_url
# Test client info
info = client.get_client_info()
assert info['api_url'] == proxy_url
assert info['is_custom_endpoint'] is True
def test_proxy_configuration_validation(self):
"""Test validation of proxy server configurations"""
# Valid proxy configurations
valid_configs = [
{
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'api_url': 'https://proxy.example.com:8080',
'model': 'claude-3-5-sonnet-20241022'
},
{
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'api_url': 'http://localhost:3128',
'model': 'claude-3-5-sonnet-20241022'
}
]
for config_data in valid_configs:
config = ClaudeAPIConfig(**config_data)
assert config.api_url == config_data['api_url']
# Invalid proxy configurations
invalid_configs = [
{
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'api_url': 'ftp://proxy.example.com:8080', # Invalid protocol
'model': 'claude-3-5-sonnet-20241022'
},
{
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'api_url': 'not-a-url', # Invalid URL format
'model': 'claude-3-5-sonnet-20241022'
}
]
for config_data in invalid_configs:
with pytest.raises(ValueError):
ClaudeAPIConfig(**config_data)
class TestConfigurationValidationClass:
"""Test configuration validation class methods"""
@pytest.mark.skipif(
not os.getenv('ANTHROPIC_API_KEY'),
reason="Requires ANTHROPIC_API_KEY environment variable for real API testing"
)
@pytest.mark.asyncio
async def test_validate_configuration_quick_test(self):
"""Test quick configuration validation"""
api_key = os.getenv('ANTHROPIC_API_KEY')
if not api_key or not api_key.startswith('sk-ant-'):
pytest.skip("Requires valid ANTHROPIC_API_KEY for real API testing")
config = ClaudeAPIConfig(
api_key=api_key,
api_url="https://api.anthropic.com",
model="claude-3-5-sonnet-20241022"
)
results = await ClaudeAPIClient.validate_configuration(config, quick_test=True)
assert results['config_valid'] is True
assert results['connection_valid'] is True
assert results['model_valid'] is True
assert len(results['errors']) == 0
@pytest.mark.skipif(
not os.getenv('ANTHROPIC_API_KEY'),
reason="Requires ANTHROPIC_API_KEY environment variable for real API testing"
)
@pytest.mark.asyncio
async def test_validate_configuration_comprehensive_test(self):
"""Test comprehensive configuration validation"""
api_key = os.getenv('ANTHROPIC_API_KEY')
if not api_key or not api_key.startswith('sk-ant-'):
pytest.skip("Requires valid ANTHROPIC_API_KEY for real API testing")
config = ClaudeAPIConfig(
api_key=api_key,
api_url="https://api.anthropic.com",
model="claude-3-5-sonnet-20241022"
)
results = await ClaudeAPIClient.validate_configuration(config, quick_test=False)
assert results['config_valid'] is True
assert results['connection_valid'] is True
assert results['model_valid'] is True
assert len(results['errors']) == 0
# Should have detailed test results
assert 'detailed_tests' in results
detailed = results['detailed_tests']
assert detailed['overall_status'] == 'success'
assert detailed['connection_test']['status'] == 'success'
assert detailed['model_test']['status'] == 'success'
assert detailed['authentication_test']['status'] == 'success'
@pytest.mark.asyncio
async def test_validate_configuration_invalid_key(self):
"""Test configuration validation with invalid API key"""
config = ClaudeAPIConfig(
api_key="sk-ant-invalid-key-12345678901234567890123456789012345678901234567890",
api_url="https://api.anthropic.com",
model="claude-3-5-sonnet-20241022"
)
results = await ClaudeAPIClient.validate_configuration(config, quick_test=True)
assert results['config_valid'] is True # Config format is valid
assert results['connection_valid'] is False # But connection fails
assert len(results['errors']) > 0
assert any('Invalid API key' in error for error in results['errors'])
@pytest.mark.asyncio
async def test_validate_configuration_custom_endpoint(self):
"""Test configuration validation with custom endpoint"""
config = ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
api_url="https://custom-claude-api.example.com",
model="claude-3-5-sonnet-20241022"
)
results = await ClaudeAPIClient.validate_configuration(config, quick_test=True)
assert results['config_valid'] is True
assert results['config_info']['is_custom_endpoint'] is True
assert results['config_info']['api_url'] == "https://custom-claude-api.example.com"
# Should have warning about custom endpoint
assert len(results['warnings']) > 0
assert any('custom API endpoint' in warning for warning in results['warnings'])
class TestEnvironmentVariableScenarios:
"""Test environment variable scenarios in real configurations"""
def setup_method(self):
"""Set up test fixtures"""
self.temp_dir = Path(tempfile.mkdtemp())
# Store original environment variables
self.original_env = {}
test_vars = ['TEST_CLAUDE_API_KEY', 'TEST_CLAUDE_API_URL', 'TEST_CLAUDE_MODEL']
for var in test_vars:
if var in os.environ:
self.original_env[var] = os.environ[var]
def teardown_method(self):
"""Clean up test fixtures"""
import shutil
shutil.rmtree(self.temp_dir)
# Clean up test environment variables
test_vars = ['TEST_CLAUDE_API_KEY', 'TEST_CLAUDE_API_URL', 'TEST_CLAUDE_MODEL']
for var in test_vars:
if var in os.environ:
del os.environ[var]
# Restore original environment variables
for var, value in self.original_env.items():
os.environ[var] = value
def create_test_config_file(self, config_data: Dict[str, Any]) -> Path:
"""Create a test configuration file"""
config_file = self.temp_dir / 'test_config.yaml'
with config_file.open('w') as f:
yaml.dump(config_data, f)
return config_file
def test_environment_variable_configuration_loading(self):
"""Test loading configuration with environment variables"""
# Set up environment variables
os.environ['TEST_CLAUDE_API_KEY'] = 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
os.environ['TEST_CLAUDE_API_URL'] = 'https://custom-api.example.com'
os.environ['TEST_CLAUDE_MODEL'] = 'claude-3-haiku-20240307'
config_data = {
'claude': {
'api_key': '${TEST_CLAUDE_API_KEY}',
'api_url': '${TEST_CLAUDE_API_URL}',
'model': '${TEST_CLAUDE_MODEL}'
}
}
config_file = self.create_test_config_file(config_data)
# Load configuration through the configuration loader
from configuration_loader import ConfigurationLoader
loader = ConfigurationLoader()
loaded_config = loader.load_config(config_file)
# Validate the loaded configuration
claude_config = ClaudeAPIConfig(**loaded_config['claude'])
assert claude_config.api_key == 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
assert claude_config.api_url == 'https://custom-api.example.com'
assert claude_config.model == 'claude-3-haiku-20240307'
def test_environment_variable_defaults_in_configuration(self):
"""Test environment variable defaults in configuration"""
config_data = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'api_url': '${CLAUDE_API_URL:-https://api.anthropic.com}',
'model': '${CLAUDE_MODEL:-claude-3-5-sonnet-20241022}',
'max_tokens': '${CLAUDE_MAX_TOKENS:-4096}',
'temperature': '${CLAUDE_TEMPERATURE:-0.7}'
}
}
config_file = self.create_test_config_file(config_data)
# Load configuration through the configuration loader
from configuration_loader import ConfigurationLoader
loader = ConfigurationLoader()
loaded_config = loader.load_config(config_file)
# Should use defaults since environment variables are not set
claude_config = ClaudeAPIConfig(**loaded_config['claude'])
assert claude_config.api_url == 'https://api.anthropic.com'
assert claude_config.model == 'claude-3-5-sonnet-20241022'
assert claude_config.max_tokens == 4096
assert claude_config.temperature == 0.7
def test_mixed_environment_and_direct_configuration(self):
"""Test mixed configuration (some values from files, some from environment)"""
# Set only some environment variables
os.environ['TEST_CLAUDE_API_KEY'] = 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
os.environ['TEST_CLAUDE_API_URL'] = 'https://proxy.example.com'
config_data = {
'claude': {
'api_key': '${TEST_CLAUDE_API_KEY}',
'api_url': '${TEST_CLAUDE_API_URL}',
'model': 'claude-3-5-sonnet-20241022', # Direct value
'max_tokens': 8192, # Direct value
'temperature': '${CLAUDE_TEMPERATURE:-0.5}' # Default value
}
}
config_file = self.create_test_config_file(config_data)
# Load configuration through the configuration loader
from configuration_loader import ConfigurationLoader
loader = ConfigurationLoader()
loaded_config = loader.load_config(config_file)
claude_config = ClaudeAPIConfig(**loaded_config['claude'])
# Environment variables should be expanded
assert claude_config.api_key == 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
assert claude_config.api_url == 'https://proxy.example.com'
# Direct values should be preserved
assert claude_config.model == 'claude-3-5-sonnet-20241022'
assert claude_config.max_tokens == 8192
# Default should be used
assert claude_config.temperature == 0.5
def test_missing_required_environment_variable(self):
"""Test error handling for missing required environment variables"""
config_data = {
'claude': {
'api_key': '${MISSING_API_KEY}', # Required but not set
'api_url': 'https://api.anthropic.com',
'model': 'claude-3-5-sonnet-20241022'
}
}
config_file = self.create_test_config_file(config_data)
# Should raise error for missing required environment variable
from configuration_loader import ConfigurationLoader, EnvironmentVariableError
loader = ConfigurationLoader()
with pytest.raises(EnvironmentVariableError, match="Environment variable 'MISSING_API_KEY' is not set"):
loader.load_config(config_file)
if __name__ == "__main__":
# Run tests with pytest
pytest.main([__file__, "-v"])
+1
View File
@@ -0,0 +1 @@
# Property-based tests package
+1
View File
@@ -0,0 +1 @@
# Unit tests package
+457
View File
@@ -0,0 +1,457 @@
"""
Unit tests for agent_core module.
Tests Agent, Command, Skill, SkillResult, and related classes.
"""
import pytest
from unittest.mock import Mock, AsyncMock
from datetime import datetime
from agent_core import (
Agent, Command, Skill, SkillResult, SkillChain,
CommandContext, SkillType
)
class TestSkillResult:
"""Test SkillResult class"""
def test_successful_result_creation(self):
"""Test creating a successful SkillResult"""
result = SkillResult(success=True, data={"key": "value"}, message="Success")
assert result.success is True
assert result.data == {"key": "value"}
assert result.message == "Success"
assert result.error is None
assert result.timestamp is not None
def test_failed_result_creation(self):
"""Test creating a failed SkillResult"""
result = SkillResult(success=False, error="Something went wrong", message="Failed")
assert result.success is False
assert result.error == "Something went wrong"
assert result.message == "Failed"
assert result.data is None
def test_failed_result_without_error_raises_exception(self):
"""Test that failed result without error message raises ValueError"""
with pytest.raises(ValueError, match="Failed results must include error message"):
SkillResult(success=False)
def test_successful_result_with_error_raises_exception(self):
"""Test that successful result with error message raises ValueError"""
with pytest.raises(ValueError, match="Successful results should not include error message"):
SkillResult(success=True, error="This shouldn't be here")
def test_to_dict(self):
"""Test converting SkillResult to dictionary"""
result = SkillResult(success=True, data={"test": "data"}, message="Test")
result_dict = result.to_dict()
assert isinstance(result_dict, dict)
assert result_dict["success"] is True
assert result_dict["data"] == {"test": "data"}
assert result_dict["message"] == "Test"
assert "timestamp" in result_dict
def test_to_json(self):
"""Test converting SkillResult to JSON string"""
result = SkillResult(success=True, message="Test")
json_str = result.to_json()
assert isinstance(json_str, str)
assert '"success": true' in json_str
assert '"message": "Test"' in json_str
class TestCommandContext:
"""Test CommandContext class"""
def test_context_creation(self):
"""Test creating CommandContext"""
context = CommandContext(
command_name="test_command",
args={"arg1": "value1"},
options={"option1": True},
config={"config_key": "config_value"}
)
assert context.command_name == "test_command"
assert context.args == {"arg1": "value1"}
assert context.options == {"option1": True}
assert context.config == {"config_key": "config_value"}
assert isinstance(context.metadata, dict)
def test_context_defaults(self):
"""Test CommandContext with default values"""
context = CommandContext(command_name="test")
assert context.command_name == "test"
assert context.args == {}
assert context.options == {}
assert context.config is None
assert context.metadata == {}
def test_to_dict(self):
"""Test converting CommandContext to dictionary"""
context = CommandContext(command_name="test", args={"key": "value"})
context_dict = context.to_dict()
assert isinstance(context_dict, dict)
assert context_dict["command_name"] == "test"
assert context_dict["args"] == {"key": "value"}
class MockSkill(Skill):
"""Mock Skill implementation for testing"""
def __init__(self, name: str, should_succeed: bool = True):
super().__init__(name, SkillType.READ, f"Mock skill {name}")
self.should_succeed = should_succeed
self.execute_called = False
self.execute_context = None
self.execute_kwargs = None
async def execute(self, context: CommandContext, **kwargs) -> SkillResult:
self.execute_called = True
self.execute_context = context
self.execute_kwargs = kwargs
if self.should_succeed:
return SkillResult(success=True, data={"skill": self.name}, message=f"{self.name} executed")
else:
return SkillResult(success=False, error=f"{self.name} failed", message="Execution failed")
class TestSkill:
"""Test Skill base class"""
def test_skill_creation(self):
"""Test creating a Skill"""
skill = MockSkill("test_skill")
assert skill.name == "test_skill"
assert skill.skill_type == SkillType.READ
assert skill.description == "Mock skill test_skill"
assert skill.logger is not None
def test_get_info(self):
"""Test getting skill information"""
skill = MockSkill("test_skill")
info = skill.get_info()
assert info["name"] == "test_skill"
assert info["type"] == "read"
assert info["description"] == "Mock skill test_skill"
@pytest.mark.asyncio
async def test_skill_execute_success(self):
"""Test successful skill execution"""
skill = MockSkill("test_skill", should_succeed=True)
context = CommandContext(command_name="test")
result = await skill.execute(context, param1="value1")
assert skill.execute_called is True
assert skill.execute_context == context
assert skill.execute_kwargs == {"param1": "value1"}
assert result.success is True
assert result.data == {"skill": "test_skill"}
@pytest.mark.asyncio
async def test_skill_execute_failure(self):
"""Test failed skill execution"""
skill = MockSkill("test_skill", should_succeed=False)
context = CommandContext(command_name="test")
result = await skill.execute(context)
assert result.success is False
assert result.error == "test_skill failed"
class TestSkillChain:
"""Test SkillChain class"""
def test_skill_chain_creation(self):
"""Test creating a SkillChain"""
chain = SkillChain("test_chain", "Test chain description")
assert chain.name == "test_chain"
assert chain.description == "Test chain description"
assert chain.skills == []
assert chain.logger is not None
def test_add_skill(self):
"""Test adding skills to chain"""
chain = SkillChain("test_chain")
skill1 = MockSkill("skill1")
skill2 = MockSkill("skill2")
result = chain.add_skill(skill1, {"param1": "value1"})
chain.add_skill(skill2)
assert result == chain # Test fluent interface
assert len(chain.skills) == 2
assert chain.skills[0] == (skill1, {"param1": "value1"})
assert chain.skills[1] == (skill2, {})
@pytest.mark.asyncio
async def test_skill_chain_execute_success(self):
"""Test successful skill chain execution"""
chain = SkillChain("test_chain")
skill1 = MockSkill("skill1", should_succeed=True)
skill2 = MockSkill("skill2", should_succeed=True)
chain.add_skill(skill1).add_skill(skill2)
context = CommandContext(command_name="test")
result = await chain.execute(context)
assert result.success is True
assert skill1.execute_called is True
assert skill2.execute_called is True
# Check that skill1 result was passed to context metadata
assert "skill1_result" in context.metadata
@pytest.mark.asyncio
async def test_skill_chain_execute_failure(self):
"""Test skill chain execution with failure"""
chain = SkillChain("test_chain")
skill1 = MockSkill("skill1", should_succeed=True)
skill2 = MockSkill("skill2", should_succeed=False)
skill3 = MockSkill("skill3", should_succeed=True)
chain.add_skill(skill1).add_skill(skill2).add_skill(skill3)
context = CommandContext(command_name="test")
result = await chain.execute(context)
assert result.success is False
assert skill1.execute_called is True
assert skill2.execute_called is True
assert skill3.execute_called is False # Should not execute after failure
def test_get_info(self):
"""Test getting skill chain information"""
chain = SkillChain("test_chain", "Test description")
skill1 = MockSkill("skill1")
skill2 = MockSkill("skill2")
chain.add_skill(skill1).add_skill(skill2)
info = chain.get_info()
assert info["name"] == "test_chain"
assert info["description"] == "Test description"
assert len(info["skills"]) == 2
assert info["skills"][0]["name"] == "skill1"
assert info["skills"][1]["name"] == "skill2"
class MockCommand(Command):
"""Mock Command implementation for testing"""
def __init__(self, name: str, should_succeed: bool = True):
super().__init__(name, f"Mock command {name}", ["mock_alias"])
self.should_succeed = should_succeed
self.execute_called = False
self.execute_context = None
async def execute(self, context: CommandContext) -> SkillResult:
self.execute_called = True
self.execute_context = context
if self.should_succeed:
return SkillResult(success=True, data={"command": self.name}, message=f"{self.name} executed")
else:
return SkillResult(success=False, error=f"{self.name} failed", message="Command failed")
class TestCommand:
"""Test Command base class"""
def test_command_creation(self):
"""Test creating a Command"""
command = MockCommand("test_command")
assert command.name == "test_command"
assert command.description == "Mock command test_command"
assert command.aliases == ["mock_alias"]
assert command.skills == {}
assert command.skill_chains == {}
assert command.logger is not None
def test_register_skill(self):
"""Test registering skills with command"""
command = MockCommand("test_command")
skill = MockSkill("test_skill")
result = command.register_skill(skill)
assert result == command # Test fluent interface
assert command.skills["test_skill"] == skill
def test_register_skill_chain(self):
"""Test registering skill chains with command"""
command = MockCommand("test_command")
chain = SkillChain("test_chain")
result = command.register_skill_chain(chain)
assert result == command # Test fluent interface
assert command.skill_chains["test_chain"] == chain
@pytest.mark.asyncio
async def test_command_execute_success(self):
"""Test successful command execution"""
command = MockCommand("test_command", should_succeed=True)
context = CommandContext(command_name="test_command")
result = await command.execute(context)
assert command.execute_called is True
assert command.execute_context == context
assert result.success is True
assert result.data == {"command": "test_command"}
@pytest.mark.asyncio
async def test_command_execute_failure(self):
"""Test failed command execution"""
command = MockCommand("test_command", should_succeed=False)
context = CommandContext(command_name="test_command")
result = await command.execute(context)
assert result.success is False
assert result.error == "test_command failed"
def test_get_info(self):
"""Test getting command information"""
command = MockCommand("test_command")
skill = MockSkill("test_skill")
chain = SkillChain("test_chain")
command.register_skill(skill).register_skill_chain(chain)
info = command.get_info()
assert info["name"] == "test_command"
assert info["description"] == "Mock command test_command"
assert info["aliases"] == ["mock_alias"]
assert "test_skill" in info["skills"]
assert "test_chain" in info["skill_chains"]
class TestAgent:
"""Test Agent class"""
def test_agent_creation(self):
"""Test creating an Agent"""
config = {"log_level": "DEBUG", "test_key": "test_value"}
agent = Agent("test_agent", config)
assert agent.name == "test_agent"
assert agent.config == config
assert agent.commands == {}
assert agent.command_aliases == {}
assert agent.logger is not None
def test_agent_creation_without_config(self):
"""Test creating an Agent without config"""
agent = Agent("test_agent")
assert agent.name == "test_agent"
assert agent.config == {}
def test_register_command(self):
"""Test registering commands with agent"""
agent = Agent("test_agent")
command = MockCommand("test_command")
result = agent.register_command(command)
assert result == agent # Test fluent interface
assert agent.commands["test_command"] == command
assert agent.command_aliases["mock_alias"] == "test_command"
@pytest.mark.asyncio
async def test_execute_command_success(self):
"""Test successful command execution through agent"""
agent = Agent("test_agent")
command = MockCommand("test_command", should_succeed=True)
agent.register_command(command)
result = await agent.execute_command("test_command", {"arg1": "value1"}, {"opt1": True})
assert result.success is True
assert command.execute_called is True
assert command.execute_context.command_name == "test_command"
assert command.execute_context.args == {"arg1": "value1"}
assert command.execute_context.options == {"opt1": True}
@pytest.mark.asyncio
async def test_execute_command_by_alias(self):
"""Test executing command by alias"""
agent = Agent("test_agent")
command = MockCommand("test_command")
agent.register_command(command)
result = await agent.execute_command("mock_alias")
assert result.success is True
assert command.execute_called is True
@pytest.mark.asyncio
async def test_execute_unknown_command(self):
"""Test executing unknown command"""
agent = Agent("test_agent")
result = await agent.execute_command("unknown_command")
assert result.success is False
assert "未知命令: unknown_command" in result.error
@pytest.mark.asyncio
async def test_execute_command_with_exception(self):
"""Test command execution with exception"""
agent = Agent("test_agent")
command = MockCommand("test_command")
# Mock the execute method to raise an exception
async def mock_execute_with_exception(context):
raise RuntimeError("Test exception")
command.execute = mock_execute_with_exception
agent.register_command(command)
result = await agent.execute_command("test_command")
assert result.success is False
assert "Test exception" in result.error
def test_get_commands_info(self):
"""Test getting all commands information"""
agent = Agent("test_agent")
command1 = MockCommand("command1")
command2 = MockCommand("command2")
agent.register_command(command1).register_command(command2)
info = agent.get_commands_info()
assert info["agent_name"] == "test_agent"
assert "command1" in info["commands"]
assert "command2" in info["commands"]
assert info["aliases"]["mock_alias"] == "command2" # Last registered wins
def test_list_commands(self):
"""Test listing all available commands"""
agent = Agent("test_agent")
command1 = MockCommand("command1")
command2 = MockCommand("command2")
agent.register_command(command1).register_command(command2)
commands = agent.list_commands()
assert "command1" in commands
assert "command2" in commands
assert len(commands) == 2
+710
View File
@@ -0,0 +1,710 @@
"""
Unit tests for Claude API configuration system
Tests configuration loading, validation, environment variable expansion, and migration
"""
import os
import pytest
import tempfile
import yaml
from pathlib import Path
from unittest.mock import Mock, patch, AsyncMock
from typing import Dict, Any
# Import the modules we're testing
from config_validation import (
ClaudeAPIConfig, ConfigurationValidator, SystemConfig,
ObsidianConfig, ObsidianRestAPIConfig, JournalConfig,
OutputConfig, AnalysisConfig, LoggingConfig
)
from configuration_loader import ConfigurationLoader, ConfigurationError, EnvironmentVariableError
from configuration_migrator import ConfigurationMigrator
from claude_api_client import ClaudeAPIClient
from error_handling import ClaudeConfigurationError, ClaudeAPIURLError, ClaudeModelValidationError
class TestClaudeAPIConfig:
"""Test ClaudeAPIConfig validation"""
def test_valid_claude_config(self):
"""Test creating valid Claude API configuration"""
config = ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
api_url="https://api.anthropic.com",
model="claude-3-5-sonnet-20241022",
max_tokens=4096,
temperature=0.7
)
assert config.api_key.startswith("sk-ant-")
assert config.api_url == "https://api.anthropic.com"
assert config.model == "claude-3-5-sonnet-20241022"
assert config.max_tokens == 4096
assert config.temperature == 0.7
def test_default_values(self):
"""Test default values are applied correctly"""
config = ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890"
)
assert config.api_url == "https://api.anthropic.com"
assert config.model == "claude-3-5-sonnet-20241022"
assert config.max_tokens == 4096
assert config.temperature == 0.7
def test_custom_api_url(self):
"""Test custom API URL validation"""
config = ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
api_url="https://custom-claude-api.example.com"
)
assert config.api_url == "https://custom-claude-api.example.com"
def test_api_url_trailing_slash_removal(self):
"""Test that trailing slashes are removed from API URLs"""
config = ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
api_url="https://api.anthropic.com/"
)
assert config.api_url == "https://api.anthropic.com"
def test_invalid_api_url_format(self):
"""Test validation of invalid API URL formats"""
with pytest.raises(ValueError, match="Invalid URL format"):
ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
api_url="not-a-url"
)
def test_invalid_api_url_protocol(self):
"""Test validation of invalid URL protocols"""
with pytest.raises(ValueError, match="URL must use http or https protocol"):
ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
api_url="ftp://api.anthropic.com"
)
def test_invalid_api_key_format(self):
"""Test validation of invalid API key formats"""
with pytest.raises(ValueError, match="Claude API key should start with"):
ClaudeAPIConfig(api_key="invalid-key")
def test_api_key_too_short(self):
"""Test validation of API keys that are too short"""
with pytest.raises(ValueError, match="Claude API key appears to be too short"):
ClaudeAPIConfig(api_key="sk-ant-short")
def test_valid_model_names(self):
"""Test validation of valid model names"""
valid_models = [
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307",
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
"claude-3-opus-latest",
"claude-3-sonnet-latest",
"claude-3-haiku-latest",
"claude-3-5-sonnet-latest",
"claude-3-5-haiku-latest"
]
for model in valid_models:
config = ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
model=model
)
assert config.model == model
def test_invalid_model_name(self):
"""Test validation of invalid model names"""
with pytest.raises(ValueError, match="Invalid model name"):
ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
model="invalid-model"
)
def test_invalid_max_tokens(self):
"""Test validation of invalid max_tokens values"""
with pytest.raises(ValueError):
ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
max_tokens=0
)
with pytest.raises(ValueError):
ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
max_tokens=300000 # Too high
)
def test_invalid_temperature(self):
"""Test validation of invalid temperature values"""
with pytest.raises(ValueError):
ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
temperature=-0.1
)
with pytest.raises(ValueError):
ClaudeAPIConfig(
api_key="sk-ant-test-key-12345678901234567890123456789012345678901234567890",
temperature=1.1
)
class TestConfigurationLoader:
"""Test ConfigurationLoader environment variable expansion"""
def setup_method(self):
"""Set up test fixtures"""
self.loader = ConfigurationLoader()
self.temp_dir = Path(tempfile.mkdtemp())
def teardown_method(self):
"""Clean up test fixtures"""
import shutil
shutil.rmtree(self.temp_dir)
def test_simple_env_var_expansion(self):
"""Test simple environment variable expansion"""
os.environ['TEST_API_KEY'] = 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
config_data = {
'claude': {
'api_key': '${TEST_API_KEY}'
}
}
expanded = self.loader.expand_environment_variables(config_data)
assert expanded['claude']['api_key'] == 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
# Clean up
del os.environ['TEST_API_KEY']
def test_env_var_with_default(self):
"""Test environment variable expansion with default values"""
config_data = {
'claude': {
'api_url': '${CLAUDE_API_URL:-https://api.anthropic.com}',
'model': '${CLAUDE_MODEL:-claude-3-5-sonnet-20241022}'
}
}
expanded = self.loader.expand_environment_variables(config_data)
assert expanded['claude']['api_url'] == 'https://api.anthropic.com'
assert expanded['claude']['model'] == 'claude-3-5-sonnet-20241022'
def test_env_var_override_default(self):
"""Test environment variable overriding default values"""
os.environ['CLAUDE_API_URL'] = 'https://custom-api.example.com'
config_data = {
'claude': {
'api_url': '${CLAUDE_API_URL:-https://api.anthropic.com}'
}
}
expanded = self.loader.expand_environment_variables(config_data)
assert expanded['claude']['api_url'] == 'https://custom-api.example.com'
# Clean up
del os.environ['CLAUDE_API_URL']
def test_missing_required_env_var(self):
"""Test error handling for missing required environment variables"""
config_data = {
'claude': {
'api_key': '${MISSING_API_KEY}'
}
}
with pytest.raises(EnvironmentVariableError, match="Environment variable 'MISSING_API_KEY' is not set"):
self.loader.expand_environment_variables(config_data)
def test_nested_env_var_expansion(self):
"""Test environment variable expansion in nested structures"""
os.environ['VAULT_PATH'] = '/test/vault'
os.environ['API_KEY'] = 'test-key'
config_data = {
'obsidian': {
'vault_path': '${VAULT_PATH}',
'rest_api': {
'api_key': '${API_KEY}'
}
}
}
expanded = self.loader.expand_environment_variables(config_data)
assert expanded['obsidian']['vault_path'] == '/test/vault'
assert expanded['obsidian']['rest_api']['api_key'] == 'test-key'
# Clean up
del os.environ['VAULT_PATH']
del os.environ['API_KEY']
def test_load_yaml_config(self):
"""Test loading YAML configuration file"""
config_data = {
'claude': {
'api_key': '${TEST_API_KEY:-default-key}',
'api_url': 'https://api.anthropic.com',
'model': 'claude-3-5-sonnet-20241022'
}
}
config_file = self.temp_dir / 'test_config.yaml'
with config_file.open('w') as f:
yaml.dump(config_data, f)
loaded_config = self.loader.load_config(config_file)
assert loaded_config['claude']['api_key'] == 'default-key'
assert loaded_config['claude']['api_url'] == 'https://api.anthropic.com'
def test_load_nonexistent_config(self):
"""Test error handling for nonexistent configuration files"""
nonexistent_file = self.temp_dir / 'nonexistent.yaml'
with pytest.raises(ConfigurationError, match="Configuration file not found"):
self.loader.load_config(nonexistent_file)
def test_validate_environment_variables(self):
"""Test validation of environment variables in configuration"""
config_data = {
'claude': {
'api_key': '${EXISTING_VAR}',
'api_url': '${MISSING_VAR}',
'model': '${VAR_WITH_DEFAULT:-default-model}'
}
}
os.environ['EXISTING_VAR'] = 'test-value'
missing_vars = self.loader.validate_environment_variables(config_data)
assert len(missing_vars) == 1
assert 'MISSING_VAR' in missing_vars[0]
# Clean up
del os.environ['EXISTING_VAR']
def test_get_environment_variable_references(self):
"""Test getting all environment variable references"""
config_data = {
'claude': {
'api_key': '${API_KEY}',
'api_url': '${API_URL:-default}',
'model': 'claude-3-5-sonnet-20241022'
},
'obsidian': {
'vault_path': '${VAULT_PATH}'
}
}
env_vars = self.loader.get_environment_variable_references(config_data)
assert 'API_KEY' in env_vars
assert 'API_URL' in env_vars
assert 'VAULT_PATH' in env_vars
assert 'claude.api_key' in env_vars['API_KEY']
assert 'claude.api_url' in env_vars['API_URL']
class TestConfigurationMigrator:
"""Test ConfigurationMigrator backward compatibility"""
def setup_method(self):
"""Set up test fixtures"""
self.migrator = ConfigurationMigrator()
def test_migrate_claude_config_missing_api_url(self):
"""Test migration of Claude config missing api_url"""
config_dict = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'model': 'claude-3-5-sonnet-20241022'
}
}
migrated = self.migrator.migrate_claude_config(config_dict)
assert migrated['claude']['api_url'] == 'https://api.anthropic.com'
assert migrated['claude']['api_key'] == 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
assert migrated['claude']['model'] == 'claude-3-5-sonnet-20241022'
def test_migrate_legacy_model_names(self):
"""Test migration of legacy model names"""
legacy_models = {
'claude-3-sonnet': 'claude-3-sonnet-20240229',
'claude-3-opus': 'claude-3-opus-20240229',
'claude-3-haiku': 'claude-3-haiku-20240307',
'sonnet': 'claude-3-5-sonnet-20241022'
}
for old_model, expected_new_model in legacy_models.items():
config_dict = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'model': old_model
}
}
migrated = self.migrator.migrate_claude_config(config_dict)
assert migrated['claude']['model'] == expected_new_model
def test_migrate_missing_claude_section(self):
"""Test migration when Claude section is completely missing"""
config_dict = {
'obsidian': {
'vault_path': '/test/vault'
}
}
migrated = self.migrator.migrate_claude_config(config_dict)
assert 'claude' in migrated
assert migrated['claude']['api_url'] == 'https://api.anthropic.com'
assert migrated['claude']['model'] == 'claude-3-5-sonnet-20241022'
def test_migrate_complete_configuration(self):
"""Test migration of complete configuration"""
config_dict = {
'obsidian': {
'vault_path': '/test/vault',
'rest_api': {
'url': 'https://localhost:27123',
'api_key': 'test-key'
}
},
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'model': 'claude-3-sonnet' # Legacy model name
}
}
migrated = self.migrator.migrate_configuration(config_dict)
# Check Claude migration
assert migrated['claude']['api_url'] == 'https://api.anthropic.com'
assert migrated['claude']['model'] == 'claude-3-sonnet-20240229'
# Check that other sections are added with defaults
assert 'journal' in migrated
assert 'output' in migrated
assert 'analysis' in migrated
assert 'logging' in migrated
def test_check_migration_needed(self):
"""Test checking if migration is needed"""
# Config that needs migration
config_needing_migration = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'model': 'claude-3-sonnet' # Legacy model name
}
}
assert self.migrator.check_migration_needed(config_needing_migration) is True
# Config that doesn't need migration - need all required fields
config_up_to_date = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'api_url': 'https://api.anthropic.com',
'model': 'claude-3-5-sonnet-20241022',
'max_tokens': 4096,
'temperature': 0.7
},
'journal': {
'daily_notes_folder': 'Daily',
'date_format': 'YYYY-MM-DD',
'file_extension': '.md'
},
'output': {
'experiences_folder': 'Knowledge/Experiences',
'lessons_folder': 'Knowledge/Lessons',
'tasks_folder': 'Tasks/Daily',
'problems_folder': 'Knowledge/Problems',
'achievements_folder': 'Knowledge/Achievements',
'improvements_folder': 'Knowledge/Improvements'
},
'analysis': {
'categories': [],
'extraction_rules': {}
},
'logging': {
'level': 'INFO',
'file': 'logs/journal_organizer.log'
}
}
assert self.migrator.check_migration_needed(config_up_to_date) is False
def test_get_migration_preview(self):
"""Test getting migration preview"""
config_dict = {
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'model': 'claude-3-sonnet'
}
}
preview = self.migrator.get_migration_preview(config_dict)
assert len(preview) > 0
assert any('api_url' in action for action in preview)
assert any('claude-3-sonnet' in action and 'claude-3-sonnet-20240229' in action for action in preview)
def test_get_supported_model_names(self):
"""Test getting supported model names"""
supported_models = self.migrator.get_supported_model_names()
# Should include current models
assert 'claude-3-5-sonnet-20241022' in supported_models
assert 'claude-3-opus-20240229' in supported_models
# Should include legacy models
assert 'claude-3-sonnet' in supported_models
assert 'sonnet' in supported_models
class TestConfigurationValidator:
"""Test ConfigurationValidator comprehensive validation"""
def setup_method(self):
"""Set up test fixtures"""
self.validator = ConfigurationValidator()
self.temp_dir = Path(tempfile.mkdtemp())
# Create a test vault directory
self.test_vault = self.temp_dir / 'test_vault'
self.test_vault.mkdir()
(self.test_vault / '.obsidian').mkdir()
def teardown_method(self):
"""Clean up test fixtures"""
import shutil
shutil.rmtree(self.temp_dir)
def create_test_config_file(self, config_data: Dict[str, Any]) -> Path:
"""Create a test configuration file"""
config_file = self.temp_dir / 'test_config.yaml'
with config_file.open('w') as f:
yaml.dump(config_data, f)
return config_file
def test_load_and_validate_valid_config(self):
"""Test loading and validating a valid configuration"""
config_data = {
'obsidian': {
'vault_path': str(self.test_vault),
'rest_api': {
'url': 'https://localhost:27123',
'api_key': 'test-api-key',
'verify_ssl': False
}
},
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'api_url': 'https://api.anthropic.com',
'model': 'claude-3-5-sonnet-20241022'
}
}
config_file = self.create_test_config_file(config_data)
system_config = self.validator.load_and_validate_config(config_file)
assert isinstance(system_config, SystemConfig)
assert system_config.claude.api_url == 'https://api.anthropic.com'
assert system_config.claude.model == 'claude-3-5-sonnet-20241022'
# Path resolution may add /private prefix on macOS, so check if paths resolve to same location
assert Path(system_config.obsidian.vault_path).resolve() == self.test_vault.resolve()
def test_load_and_validate_with_migration(self):
"""Test loading configuration that needs migration"""
config_data = {
'obsidian': {
'vault_path': str(self.test_vault),
'rest_api': {
'url': 'https://localhost:27123',
'api_key': 'test-api-key'
}
},
'claude': {
'api_key': 'sk-ant-test-key-12345678901234567890123456789012345678901234567890',
'model': 'claude-3-sonnet' # Legacy model name
}
}
config_file = self.create_test_config_file(config_data)
system_config = self.validator.load_and_validate_config(config_file)
# Should have migrated the model name
assert system_config.claude.model == 'claude-3-sonnet-20240229'
# Should have added default api_url
assert system_config.claude.api_url == 'https://api.anthropic.com'
def test_load_and_validate_with_env_vars(self):
"""Test loading configuration with environment variables"""
os.environ['TEST_CLAUDE_KEY'] = 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
os.environ['TEST_VAULT_PATH'] = str(self.test_vault)
config_data = {
'obsidian': {
'vault_path': '${TEST_VAULT_PATH}',
'rest_api': {
'url': 'https://localhost:27123',
'api_key': 'test-api-key'
}
},
'claude': {
'api_key': '${TEST_CLAUDE_KEY}',
'api_url': '${CLAUDE_API_URL:-https://api.anthropic.com}'
}
}
config_file = self.create_test_config_file(config_data)
system_config = self.validator.load_and_validate_config(config_file)
assert system_config.claude.api_key == 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
# Path resolution may add /private prefix on macOS, so check if paths resolve to same location
assert Path(system_config.obsidian.vault_path).resolve() == self.test_vault.resolve()
assert system_config.claude.api_url == 'https://api.anthropic.com'
# Clean up
del os.environ['TEST_CLAUDE_KEY']
del os.environ['TEST_VAULT_PATH']
def test_validation_error_handling(self):
"""Test validation error handling"""
config_data = {
'obsidian': {
'vault_path': '/nonexistent/path',
'rest_api': {
'url': 'invalid-url',
'api_key': 'test-key'
}
},
'claude': {
'api_key': 'invalid-key',
'model': 'invalid-model'
}
}
config_file = self.create_test_config_file(config_data)
with pytest.raises(ValueError, match="Configuration validation failed"):
self.validator.load_and_validate_config(config_file)
def test_validate_api_keys(self):
"""Test API key validation"""
# Create a valid system config for testing
system_config = SystemConfig(
obsidian=ObsidianConfig(
vault_path=str(self.test_vault),
rest_api=ObsidianRestAPIConfig(
url='https://localhost:27123',
api_key='test-api-key'
)
),
claude=ClaudeAPIConfig(
api_key='sk-ant-test-key-12345678901234567890123456789012345678901234567890'
)
)
issues = self.validator.validate_api_keys(system_config)
# Should have no issues with valid keys
assert len(issues) == 0
def test_validate_api_keys_with_env_vars(self):
"""Test API key validation with environment variable placeholders"""
# Skip this test as it requires complex mocking of pydantic validation
pytest.skip("Environment variable validation requires complex setup")
@pytest.mark.asyncio
class TestClaudeAPIClient:
"""Test ClaudeAPIClient functionality"""
def setup_method(self):
"""Set up test fixtures"""
self.config = ClaudeAPIConfig(
api_key='sk-ant-test-key-12345678901234567890123456789012345678901234567890',
api_url='https://api.anthropic.com',
model='claude-3-5-sonnet-20241022'
)
@patch('claude_api_client.AsyncAnthropic')
@patch('claude_api_client.aiohttp')
def test_client_initialization(self, mock_aiohttp, mock_anthropic):
"""Test Claude API client initialization"""
client = ClaudeAPIClient(self.config)
assert client.base_url == 'https://api.anthropic.com'
assert client.api_key == 'sk-ant-test-key-12345678901234567890123456789012345678901234567890'
assert client.model == 'claude-3-5-sonnet-20241022'
# Should have called AsyncAnthropic constructor
mock_anthropic.assert_called_once()
@patch('claude_api_client.AsyncAnthropic')
@patch('claude_api_client.aiohttp')
def test_client_with_custom_url(self, mock_aiohttp, mock_anthropic):
"""Test client initialization with custom API URL"""
custom_config = ClaudeAPIConfig(
api_key='sk-ant-test-key-12345678901234567890123456789012345678901234567890',
api_url='https://custom-api.example.com',
model='claude-3-5-sonnet-20241022'
)
client = ClaudeAPIClient(custom_config)
assert client.base_url == 'https://custom-api.example.com'
# Should have called AsyncAnthropic with custom base_url
mock_anthropic.assert_called_once()
call_args = mock_anthropic.call_args
assert call_args[1]['base_url'] == 'https://custom-api.example.com'
@patch('claude_api_client.AsyncAnthropic')
@patch('claude_api_client.aiohttp')
def test_get_client_info(self, mock_aiohttp, mock_anthropic):
"""Test getting client configuration information"""
client = ClaudeAPIClient(self.config)
info = client.get_client_info()
assert info['api_url'] == 'https://api.anthropic.com'
assert info['model'] == 'claude-3-5-sonnet-20241022'
assert info['max_tokens'] == 4096
assert info['temperature'] == 0.7
assert info['is_custom_endpoint'] is False
@patch('claude_api_client.AsyncAnthropic')
@patch('claude_api_client.aiohttp')
def test_get_client_info_custom_endpoint(self, mock_aiohttp, mock_anthropic):
"""Test getting client info for custom endpoint"""
custom_config = ClaudeAPIConfig(
api_key='sk-ant-test-key-12345678901234567890123456789012345678901234567890',
api_url='https://custom-api.example.com'
)
client = ClaudeAPIClient(custom_config)
info = client.get_client_info()
assert info['is_custom_endpoint'] is True
assert info['api_url'] == 'https://custom-api.example.com'
+526
View File
@@ -0,0 +1,526 @@
"""
Unit tests for error_handling module.
Tests custom exceptions, ErrorHandler, and error sanitization.
"""
import pytest
import logging
from unittest.mock import Mock, patch
from datetime import datetime
from error_handling import (
JournalOrganizerError, ConfigurationError, APIError, ValidationError,
FileSystemError, SecurityError, ErrorContext, ErrorHandler,
get_error_handler, set_error_handler, audit_error_message_security
)
class TestJournalOrganizerError:
"""Test base JournalOrganizerError class"""
def test_basic_error_creation(self):
"""Test creating basic error"""
error = JournalOrganizerError("Test error message")
assert str(error) == "Test error message"
assert error.message == "Test error message"
assert error.context == {}
assert error.cause is None
assert error.timestamp is not None
def test_error_with_context(self):
"""Test creating error with context"""
context = {"key": "value", "number": 42}
error = JournalOrganizerError("Test error", context=context)
assert error.context == context
def test_error_with_cause(self):
"""Test creating error with cause"""
original_error = ValueError("Original error")
error = JournalOrganizerError("Wrapped error", cause=original_error)
assert error.cause == original_error
def test_to_dict(self):
"""Test converting error to dictionary"""
context = {"test_key": "test_value"}
original_error = RuntimeError("Original")
error = JournalOrganizerError("Test error", context=context, cause=original_error)
error_dict = error.to_dict()
assert error_dict["error_type"] == "JournalOrganizerError"
assert error_dict["message"] == "Test error"
assert error_dict["context"] == context
assert error_dict["cause"] == "Original"
assert "timestamp" in error_dict
class TestConfigurationError:
"""Test ConfigurationError class"""
def test_basic_configuration_error(self):
"""Test basic configuration error"""
error = ConfigurationError("Config error", config_key="api_key")
assert error.message == "Config error"
assert error.context["config_key"] == "api_key"
def test_configuration_error_with_sensitive_value(self):
"""Test configuration error with sensitive value redaction"""
error = ConfigurationError(
"Invalid API key",
config_key="api_key",
config_value="sk-secret-key-123"
)
assert error.context["config_key"] == "api_key"
assert error.context["config_value"] == "[REDACTED]"
def test_configuration_error_with_non_sensitive_value(self):
"""Test configuration error with non-sensitive value"""
error = ConfigurationError(
"Invalid timeout",
config_key="timeout",
config_value="30"
)
assert error.context["config_key"] == "timeout"
assert error.context["config_value"] == "30"
class TestAPIError:
"""Test APIError class"""
def test_basic_api_error(self):
"""Test basic API error"""
error = APIError("API call failed", api_name="claude", status_code=500)
assert error.message == "API call failed"
assert error.context["api_name"] == "claude"
assert error.context["status_code"] == 500
def test_api_error_with_long_response(self):
"""Test API error with long response data truncation"""
long_response = "x" * 1000
error = APIError("API error", response_data=long_response)
assert len(error.context["response_data"]) <= 503 # 500 + "..."
assert error.context["response_data"].endswith("...")
def test_api_error_with_short_response(self):
"""Test API error with short response data"""
short_response = "Short error"
error = APIError("API error", response_data=short_response)
assert error.context["response_data"] == short_response
class TestValidationError:
"""Test ValidationError class"""
def test_basic_validation_error(self):
"""Test basic validation error"""
error = ValidationError(
"Invalid email",
field_name="email",
field_value="invalid-email",
validation_rule="email_format"
)
assert error.message == "Invalid email"
assert error.context["field_name"] == "email"
assert error.context["field_value"] == "invalid-email"
assert error.context["validation_rule"] == "email_format"
def test_validation_error_with_sensitive_field(self):
"""Test validation error with sensitive field value redaction"""
error = ValidationError(
"Invalid password",
field_name="password",
field_value="secret123"
)
assert error.context["field_name"] == "password"
assert error.context["field_value"] == "[REDACTED]"
def test_validation_error_with_long_value(self):
"""Test validation error with long field value truncation"""
long_value = "x" * 200
error = ValidationError(
"Invalid input",
field_name="description",
field_value=long_value
)
assert len(error.context["field_value"]) == 100 # Truncated to 100 chars
class TestFileSystemError:
"""Test FileSystemError class"""
def test_basic_filesystem_error(self):
"""Test basic filesystem error"""
error = FileSystemError(
"File not found",
file_path="/path/to/file.txt",
operation="read"
)
assert error.message == "File not found"
assert error.context["file_path"] == "/path/to/file.txt"
assert error.context["operation"] == "read"
class TestSecurityError:
"""Test SecurityError class"""
def test_basic_security_error(self):
"""Test basic security error"""
error = SecurityError(
"Path traversal detected",
security_issue="path_traversal",
attempted_path="../../../etc/passwd",
risk_level="critical"
)
assert error.message == "Path traversal detected"
assert error.context["security_issue"] == "path_traversal"
assert error.context["attempted_path"] == "../../../etc/passwd"
assert error.context["risk_level"] == "critical"
def test_security_error_with_long_path(self):
"""Test security error with long path truncation"""
long_path = "/" + "x" * 600
error = SecurityError("Security violation", attempted_path=long_path)
assert len(error.context["attempted_path"]) == 500 # Truncated
class TestErrorContext:
"""Test ErrorContext class"""
def test_valid_error_context(self):
"""Test creating valid error context"""
context = ErrorContext(
component="test_component",
operation="test_operation",
user_message="User friendly message",
technical_details={"key": "value"},
severity="warning"
)
assert context.component == "test_component"
assert context.operation == "test_operation"
assert context.user_message == "User friendly message"
assert context.technical_details == {"key": "value"}
assert context.severity == "warning"
def test_error_context_defaults(self):
"""Test error context with default values"""
context = ErrorContext(
component="test_component",
operation="test_operation"
)
assert context.user_message == ""
assert context.technical_details == {}
assert context.severity == "error"
def test_error_context_validation_empty_component(self):
"""Test error context validation with empty component"""
with pytest.raises(ValueError, match="component cannot be empty"):
ErrorContext(component="", operation="test_operation")
def test_error_context_validation_empty_operation(self):
"""Test error context validation with empty operation"""
with pytest.raises(ValueError, match="operation cannot be empty"):
ErrorContext(component="test_component", operation="")
def test_error_context_validation_invalid_severity(self):
"""Test error context validation with invalid severity"""
with pytest.raises(ValueError, match="severity must be one of"):
ErrorContext(
component="test_component",
operation="test_operation",
severity="invalid"
)
def test_error_context_validation_invalid_technical_details(self):
"""Test error context validation with invalid technical_details"""
with pytest.raises(ValueError, match="technical_details must be a dictionary"):
ErrorContext(
component="test_component",
operation="test_operation",
technical_details="not a dict"
)
def test_to_dict(self):
"""Test converting error context to dictionary"""
context = ErrorContext(
component="test_component",
operation="test_operation",
user_message="Test message",
technical_details={"key": "value"},
severity="info"
)
context_dict = context.to_dict()
assert context_dict["component"] == "test_component"
assert context_dict["operation"] == "test_operation"
assert context_dict["user_message"] == "Test message"
assert context_dict["technical_details"] == {"key": "value"}
assert context_dict["severity"] == "info"
class TestErrorHandler:
"""Test ErrorHandler class"""
def setup_method(self):
"""Set up test fixtures"""
self.mock_logger = Mock(spec=logging.Logger)
self.error_handler = ErrorHandler(self.mock_logger)
def test_error_handler_creation(self):
"""Test creating error handler"""
handler = ErrorHandler()
assert handler.logger is not None
handler_with_logger = ErrorHandler(self.mock_logger)
assert handler_with_logger.logger == self.mock_logger
def test_handle_error_with_custom_error(self):
"""Test handling custom JournalOrganizerError"""
error = ConfigurationError("Config error", config_key="api_key")
context = ErrorContext(
component="config",
operation="load",
user_message="Please check your configuration"
)
result = self.error_handler.handle_error(error, context)
assert result["success"] is False
assert result["error"] == "Config error"
assert result["message"] == "Please check your configuration"
assert result["component"] == "config"
assert result["operation"] == "load"
assert result["error_type"] == "ConfigurationError"
assert "timestamp" in result
# Check that logger was called
self.mock_logger.log.assert_called_once()
def test_handle_error_with_generic_error(self):
"""Test handling generic Python exception"""
error = ValueError("Generic error")
context = ErrorContext(
component="test",
operation="test_op",
severity="warning"
)
result = self.error_handler.handle_error(error, context)
assert result["success"] is False
assert result["error"] == "Generic error"
assert result["error_type"] == "ValueError"
# Check that logger was called with warning level
self.mock_logger.log.assert_called_once()
call_args = self.mock_logger.log.call_args
assert call_args[0][0] == logging.WARNING # Log level
def test_handle_api_error(self):
"""Test handling API errors"""
error = RuntimeError("Connection failed")
result = self.error_handler.handle_api_error(error, "claude", "analyze_text")
assert result["success"] is False
assert "claude API error" in result["error"]
assert result["component"] == "claude_api"
assert result["operation"] == "analyze_text"
assert "Failed to communicate with claude" in result["message"]
def test_handle_api_error_with_api_error_instance(self):
"""Test handling APIError instance"""
api_error = APIError("API failed", api_name="obsidian", status_code=404)
result = self.error_handler.handle_api_error(api_error, "obsidian", "read_note")
assert result["success"] is False
assert result["error"] == "API failed"
assert result["component"] == "obsidian_api"
def test_handle_validation_error(self):
"""Test handling validation errors"""
error = ValueError("Invalid format")
result = self.error_handler.handle_validation_error(error, "email", "validate_input")
assert result["success"] is False
assert "Invalid email" in result["error"]
assert result["component"] == "validation"
assert result["operation"] == "validate_input"
assert "Please check your email" in result["message"]
def test_handle_configuration_error(self):
"""Test handling configuration errors"""
error = ValueError("Missing key")
result = self.error_handler.handle_configuration_error(error, "api_key")
assert result["success"] is False
assert "Configuration error for api_key" in result["error"]
assert result["component"] == "configuration"
assert result["operation"] == "load_config"
assert "Please check your API key configuration" in result["message"]
def test_sanitize_error_message_api_key(self):
"""Test sanitizing error messages with API keys"""
message = "Error: api_key=sk-secret-key-123 is invalid"
sanitized = self.error_handler._sanitize_error_message(message)
assert "sk-secret-key-123" not in sanitized
assert "api_key=[REDACTED]" in sanitized
def test_sanitize_error_message_bearer_token(self):
"""Test sanitizing error messages with Bearer tokens"""
message = "Authorization failed: Bearer abc123xyz789"
sanitized = self.error_handler._sanitize_error_message(message)
assert "abc123xyz789" not in sanitized
assert "[REDACTED]" in sanitized
def test_sanitize_error_message_email(self):
"""Test sanitizing error messages with email addresses"""
message = "Failed to send email to user@example.com"
sanitized = self.error_handler._sanitize_error_message(message)
assert "user@example.com" not in sanitized
assert "[EMAIL_REDACTED]" in sanitized
def test_sanitize_error_message_file_paths(self):
"""Test sanitizing error messages with user file paths"""
message = "Cannot access /Users/john/Documents/secret.txt"
sanitized = self.error_handler._sanitize_error_message(message)
assert "john" not in sanitized
assert "[USER_REDACTED]" in sanitized
def test_sanitize_context_data(self):
"""Test sanitizing context data"""
data = {
"api_key": "secret-key-123",
"username": "john_doe",
"password": "secret123",
"timeout": 30,
"nested": {
"token": "bearer-token-xyz",
"safe_value": "public_info"
}
}
sanitized = self.error_handler._sanitize_context_data(data)
assert sanitized["api_key"] == "[REDACTED]"
assert sanitized["username"] == "john_doe" # Not sensitive
assert sanitized["password"] == "[REDACTED]"
assert sanitized["timeout"] == 30
assert sanitized["nested"]["token"] == "[REDACTED]"
assert sanitized["nested"]["safe_value"] == "public_info"
def test_sanitize_context_data_non_dict(self):
"""Test sanitizing non-dictionary context data"""
result = self.error_handler._sanitize_context_data("not a dict")
assert result == "not a dict"
class TestGlobalErrorHandler:
"""Test global error handler functions"""
def test_get_error_handler_singleton(self):
"""Test that get_error_handler returns singleton"""
handler1 = get_error_handler()
handler2 = get_error_handler()
assert handler1 is handler2
def test_set_error_handler(self):
"""Test setting custom error handler"""
custom_handler = ErrorHandler()
set_error_handler(custom_handler)
retrieved_handler = get_error_handler()
assert retrieved_handler is custom_handler
class TestAuditErrorMessageSecurity:
"""Test error message security auditing"""
def test_audit_clean_message(self):
"""Test auditing clean message with no issues"""
message = "Simple error message with no sensitive data"
result = audit_error_message_security(message)
assert result["has_issues"] is False
assert result["issues"] == []
assert result["risk_level"] == "low"
def test_audit_message_with_email(self):
"""Test auditing message with email address"""
message = "Failed to send notification to user@example.com"
result = audit_error_message_security(message)
assert result["has_issues"] is True
assert len(result["issues"]) == 1
assert result["issues"][0]["type"] == "email_address"
assert result["risk_level"] == "medium"
def test_audit_message_with_api_key(self):
"""Test auditing message with API key"""
message = "Authentication failed: api_key=sk-secret-123"
result = audit_error_message_security(message)
assert result["has_issues"] is True
assert any(issue["type"] == "credential_pattern" for issue in result["issues"])
assert result["risk_level"] == "high"
def test_audit_message_with_bearer_token(self):
"""Test auditing message with Bearer token"""
message = "Authorization header: Bearer abc123xyz789"
result = audit_error_message_security(message)
assert result["has_issues"] is True
assert any(issue["type"] == "bearer_token" for issue in result["issues"])
assert result["risk_level"] == "high"
def test_audit_message_with_ip_address(self):
"""Test auditing message with IP address"""
message = "Connection failed to 192.168.1.100"
result = audit_error_message_security(message)
assert result["has_issues"] is True
assert any(issue["type"] == "ip_address" for issue in result["issues"])
assert result["risk_level"] == "medium"
def test_audit_message_with_multiple_issues(self):
"""Test auditing message with multiple security issues"""
message = "Failed to connect to 192.168.1.100 with api_key=secret123 for user@example.com"
result = audit_error_message_security(message)
assert result["has_issues"] is True
assert len(result["issues"]) >= 2 # Should find multiple issues
assert result["risk_level"] == "high" # High due to credential pattern