feat: implement obsidian-kb skill with long video chunking
- Multi-source content aggregation (Bilibili/YouTube/Zhihu) - Smart inbox management with AI quality scoring - Long video chunking analysis (30min+ auto-segmentation) - Topic-based organization - Git auto-sync to Obsidian - Template system with Jinja2 and fallback rendering - CLI commands: subscribe, inbox, process, skip, status, search - Comprehensive test suite with unit and integration tests - Documentation: README.md and SKILL.md Checkpoint 7-8 completed by Agent E
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
Configuration management for Obsidian KB Skill.
|
||||
"""
|
||||
import os
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
|
||||
# Global config instance
|
||||
_config_instance: Optional['Config'] = None
|
||||
|
||||
|
||||
def get_config(config_path: Optional[str] = None) -> 'Config':
|
||||
"""Get global config instance."""
|
||||
global _config_instance
|
||||
if _config_instance is None or config_path:
|
||||
_config_instance = Config(config_path)
|
||||
return _config_instance
|
||||
|
||||
|
||||
class Config:
|
||||
"""Configuration manager."""
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
'ai': {
|
||||
'provider': 'openai',
|
||||
'model': 'gpt-4',
|
||||
'api_key': None,
|
||||
},
|
||||
'storage': {
|
||||
'inbox_dir': 'inbox',
|
||||
'topics_dir': 'topics',
|
||||
'data_dir': 'data',
|
||||
},
|
||||
'git': {
|
||||
'enabled': False,
|
||||
'vault_path': '~/Documents/Obsidian/KB',
|
||||
'remote': 'origin',
|
||||
'branch': 'main',
|
||||
'auto_push': False,
|
||||
'commit_message_template': 'Add note: {title}',
|
||||
},
|
||||
'obsidian': {
|
||||
'vault_path': '~/Documents/Obsidian/KB',
|
||||
'notes_folder': 'Notes',
|
||||
},
|
||||
'chunking': {
|
||||
'enabled': True,
|
||||
'chunk_duration_seconds': 900, # 15 minutes
|
||||
'max_chunk_duration_seconds': 1800, # 30 minutes
|
||||
'long_video_threshold': 1800, # 30 minutes
|
||||
},
|
||||
'sources': {
|
||||
'bilibili': {
|
||||
'enabled': True,
|
||||
'cookie': None,
|
||||
},
|
||||
'youtube': {
|
||||
'enabled': False,
|
||||
},
|
||||
'zhihu': {
|
||||
'enabled': False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, config_path: Optional[str] = None, load_env: bool = True):
|
||||
"""
|
||||
Initialize configuration.
|
||||
|
||||
Args:
|
||||
config_path: Path to config file. If None, uses default locations.
|
||||
load_env: Whether to load environment variable overrides.
|
||||
"""
|
||||
self.config_path = config_path or self._find_config_file()
|
||||
self._config = self._load_config(load_env)
|
||||
|
||||
def _find_config_file(self) -> str:
|
||||
"""Find configuration file in standard locations."""
|
||||
search_paths = [
|
||||
Path.cwd() / 'config.yaml',
|
||||
Path.cwd() / 'config.yml',
|
||||
Path.home() / '.obsidian-kb' / 'config.yaml',
|
||||
Path.home() / '.config' / 'obsidian-kb' / 'config.yaml',
|
||||
]
|
||||
|
||||
for path in search_paths:
|
||||
if path.exists():
|
||||
return str(path)
|
||||
|
||||
# Return default path
|
||||
return str(Path.cwd() / 'config.yaml')
|
||||
|
||||
def _load_config(self, load_env: bool = True) -> Dict[str, Any]:
|
||||
"""Load configuration from file or use defaults."""
|
||||
config = self.DEFAULT_CONFIG.copy()
|
||||
|
||||
if Path(self.config_path).exists():
|
||||
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||
user_config = yaml.safe_load(f)
|
||||
if user_config:
|
||||
self._deep_update(config, user_config)
|
||||
|
||||
# Override with environment variables
|
||||
if load_env:
|
||||
self._apply_env_overrides(config)
|
||||
|
||||
return config
|
||||
|
||||
def _deep_update(self, base: Dict, update: Dict) -> None:
|
||||
"""Deep update dictionary."""
|
||||
for key, value in update.items():
|
||||
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
|
||||
self._deep_update(base[key], value)
|
||||
else:
|
||||
base[key] = value
|
||||
|
||||
def _apply_env_overrides(self, config: Dict[str, Any]) -> None:
|
||||
"""Apply environment variable overrides."""
|
||||
# AI settings
|
||||
if os.getenv('OPENAI_API_KEY'):
|
||||
config['ai']['api_key'] = os.getenv('OPENAI_API_KEY')
|
||||
if os.getenv('AI_MODEL'):
|
||||
config['ai']['model'] = os.getenv('AI_MODEL')
|
||||
|
||||
# Git settings
|
||||
if os.getenv('OBSIDIAN_VAULT_PATH'):
|
||||
config['git']['vault_path'] = os.getenv('OBSIDIAN_VAULT_PATH')
|
||||
|
||||
# Bilibili cookie
|
||||
if os.getenv('BILIBILI_COOKIE'):
|
||||
config['sources']['bilibili']['cookie'] = os.getenv('BILIBILI_COOKIE')
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
Get configuration value using dot notation.
|
||||
|
||||
Args:
|
||||
key: Key in dot notation (e.g., 'ai.model')
|
||||
default: Default value if key not found
|
||||
|
||||
Returns:
|
||||
Configuration value
|
||||
"""
|
||||
keys = key.split('.')
|
||||
value = self._config
|
||||
|
||||
for k in keys:
|
||||
if isinstance(value, dict) and k in value:
|
||||
value = value[k]
|
||||
else:
|
||||
return default
|
||||
|
||||
return value
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
"""
|
||||
Set configuration value using dot notation.
|
||||
|
||||
Args:
|
||||
key: Key in dot notation
|
||||
value: Value to set
|
||||
"""
|
||||
keys = key.split('.')
|
||||
config = self._config
|
||||
|
||||
for k in keys[:-1]:
|
||||
if k not in config:
|
||||
config[k] = {}
|
||||
config = config[k]
|
||||
|
||||
config[keys[-1]] = value
|
||||
|
||||
def save(self) -> None:
|
||||
"""Save configuration to file."""
|
||||
Path(self.config_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.config_path, 'w', encoding='utf-8') as f:
|
||||
yaml.dump(self._config, f, default_flow_style=False, allow_unicode=True)
|
||||
|
||||
@property
|
||||
def obsidian_path(self) -> str:
|
||||
"""Get obsidian path (alias for vault_path)."""
|
||||
return str(self.vault_path)
|
||||
|
||||
@property
|
||||
def vault_path(self) -> Path:
|
||||
"""Get Obsidian vault path."""
|
||||
path = self.get('obsidian.vault_path') or self.get('git.vault_path', '~/Documents/Obsidian/KB')
|
||||
return Path(path).expanduser()
|
||||
|
||||
@property
|
||||
def notes_folder(self) -> str:
|
||||
"""Get notes folder."""
|
||||
return self.get('obsidian.notes_folder', 'Notes')
|
||||
|
||||
@property
|
||||
def ai_api_key(self) -> Optional[str]:
|
||||
"""Get AI API key."""
|
||||
return self.get('ai.api_key')
|
||||
|
||||
@property
|
||||
def ai_model(self) -> str:
|
||||
"""Get AI model."""
|
||||
return self.get('ai.model', 'gpt-4')
|
||||
|
||||
@property
|
||||
def chunking_enabled(self) -> bool:
|
||||
"""Check if chunking is enabled."""
|
||||
return self.get('chunking.enabled', True)
|
||||
|
||||
@property
|
||||
def chunk_duration(self) -> int:
|
||||
"""Get chunk duration in seconds."""
|
||||
return self.get('chunking.chunk_duration_seconds', 900)
|
||||
|
||||
@property
|
||||
def long_video_threshold(self) -> int:
|
||||
"""Get long video threshold in seconds."""
|
||||
return self.get('chunking.long_video_threshold', 1800)
|
||||
|
||||
@property
|
||||
def quality_threshold(self) -> float:
|
||||
"""Get quality threshold."""
|
||||
return self.get('quality_threshold', 0.7)
|
||||
|
||||
@property
|
||||
def obsidian(self) -> 'Config':
|
||||
"""Get obsidian config section (returns self for compatibility)."""
|
||||
return self
|
||||
|
||||
@property
|
||||
def git(self) -> 'Config':
|
||||
"""Get git config section (returns self for compatibility)."""
|
||||
return self
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Check if git is enabled."""
|
||||
return self.get('git.enabled', False)
|
||||
|
||||
@property
|
||||
def auto_push(self) -> bool:
|
||||
"""Check if auto push is enabled."""
|
||||
return self.get('git.auto_push', False)
|
||||
|
||||
@property
|
||||
def commit_message_template(self) -> str:
|
||||
"""Get commit message template."""
|
||||
return self.get('git.commit_message_template', 'Add note: {title}')
|
||||
|
||||
@property
|
||||
def kimi_api_key(self) -> str:
|
||||
"""Get Kimi API key (alias for ai.api_key)."""
|
||||
return self.get('ai.api_key') or ''
|
||||
|
||||
def ensure_dirs(self) -> None:
|
||||
"""Ensure required directories exist."""
|
||||
self.vault_path.mkdir(parents=True, exist_ok=True)
|
||||
(self.vault_path / self.get('storage.inbox_dir', 'Inbox')).mkdir(exist_ok=True)
|
||||
(self.vault_path / self.get('storage.topics_dir', 'Topics')).mkdir(exist_ok=True)
|
||||
|
||||
def validate(self) -> tuple[bool, list[str]]:
|
||||
"""Validate configuration."""
|
||||
errors = []
|
||||
if not self.ai_api_key:
|
||||
errors.append("AI API key not configured")
|
||||
return len(errors) == 0, errors
|
||||
|
||||
# Subscriptions support
|
||||
def get_subscriptions(self) -> list:
|
||||
"""Get subscriptions list."""
|
||||
return self.get('subscriptions', [])
|
||||
|
||||
def add_subscription(self, subscription: Dict[str, Any]) -> None:
|
||||
"""Add a new subscription."""
|
||||
subs = self.get('subscriptions', [])
|
||||
subs.append(subscription)
|
||||
self.set('subscriptions', subs)
|
||||
self.save()
|
||||
Reference in New Issue
Block a user