Probably functional. Testing
This commit is contained in:
+220
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
Configuration management module
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
def __init__(self):
|
||||
self.config_dir = self._get_config_dir()
|
||||
self.env_file = self.config_dir / '.env'
|
||||
self.templates_dir = self.config_dir / 'templates'
|
||||
self.rules_dir = self.config_dir / 'rules'
|
||||
self.prompts_dir = self.config_dir / 'prompts'
|
||||
|
||||
def _get_config_dir(self) -> Path:
|
||||
"""Get platform-specific config directory"""
|
||||
if sys.platform == 'win32':
|
||||
# Windows: %USERPROFILE%\.config\madomeda
|
||||
base = Path(os.environ.get('USERPROFILE', os.path.expanduser('~')))
|
||||
config_dir = base / '.config' / 'madomeda'
|
||||
elif sys.platform == 'darwin':
|
||||
# macOS: ~/.config/madomeda
|
||||
config_dir = Path.home() / '.config' / 'madomeda'
|
||||
else:
|
||||
# Linux: ~/.config/madomeda
|
||||
config_dir = Path.home() / '.config' / 'madomeda'
|
||||
|
||||
return config_dir
|
||||
|
||||
def ensure_config_exists(self):
|
||||
"""Ensure configuration directory exists with default files"""
|
||||
if not self.config_dir.exists():
|
||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._create_default_config()
|
||||
|
||||
# Ensure subdirectories exist
|
||||
self.templates_dir.mkdir(exist_ok=True)
|
||||
self.rules_dir.mkdir(exist_ok=True)
|
||||
self.prompts_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Copy defaults if they don't exist
|
||||
if not (self.templates_dir / 'default.json').exists():
|
||||
self._create_default_template()
|
||||
|
||||
if not list(self.rules_dir.glob('*.json')):
|
||||
self._create_default_rules()
|
||||
|
||||
if not (self.prompts_dir / 'default.txt').exists():
|
||||
self._create_default_prompt()
|
||||
|
||||
def _create_default_config(self):
|
||||
"""Create default .env file"""
|
||||
default_env = """# Madomeda Configuration
|
||||
# OpenAI-compatible API settings
|
||||
|
||||
# API endpoint URL
|
||||
OPENAI_API_URL=https://api.openai.com/v1
|
||||
|
||||
# Model to use
|
||||
OPENAI_MODEL=gpt-4
|
||||
|
||||
# API Key options:
|
||||
# 1. Empty string "" = No API key needed (LLM features disabled)
|
||||
# 2. $ENV_VAR_NAME = Read from environment variable
|
||||
# 3. Direct key = Use the key (WARNING: security risk in plain text)
|
||||
OPENAI_API_KEY=""
|
||||
"""
|
||||
with open(self.env_file, 'w', encoding='utf-8') as f:
|
||||
f.write(default_env)
|
||||
|
||||
def _create_default_template(self):
|
||||
"""Create default template"""
|
||||
import json
|
||||
default_template = {
|
||||
"title": "heur|orig",
|
||||
"created": "heur|orig",
|
||||
"changed": "heur|orig",
|
||||
"authors": "heur|orig",
|
||||
"version": "heur|orig",
|
||||
"tags": "heur|orig|ai default"
|
||||
}
|
||||
with open(self.templates_dir / 'default.json', 'w', encoding='utf-8') as f:
|
||||
json.dump(default_template, f, indent=2)
|
||||
|
||||
def _create_default_rules(self):
|
||||
"""Create default rules"""
|
||||
import json
|
||||
|
||||
rules = {
|
||||
'tags_normalization.json': {
|
||||
"name": "tags_normalization",
|
||||
"description": "Normalize tags to lowercase with underscores",
|
||||
"type": "tags"
|
||||
},
|
||||
'tag_to_tags.json': {
|
||||
"name": "tag_to_tags",
|
||||
"description": "Rename 'tag' key to 'tags'",
|
||||
"type": "rename"
|
||||
},
|
||||
'lowercase_keys.json': {
|
||||
"name": "lowercase_keys",
|
||||
"description": "All frontmatter keys must be lowercase",
|
||||
"type": "keys"
|
||||
},
|
||||
'summary_to_description.json': {
|
||||
"name": "summary_to_description",
|
||||
"description": "Rename 'summary' key to 'description'",
|
||||
"type": "rename"
|
||||
}
|
||||
}
|
||||
|
||||
for filename, rule_data in rules.items():
|
||||
with open(self.rules_dir / filename, 'w', encoding='utf-8') as f:
|
||||
json.dump(rule_data, f, indent=2)
|
||||
|
||||
def _create_default_prompt(self):
|
||||
"""Create default prompt"""
|
||||
default_prompt = """You are a metadata extraction assistant for markdown documents.
|
||||
|
||||
Your task is to analyze the document content and suggest appropriate frontmatter metadata.
|
||||
|
||||
Guidelines:
|
||||
- For tags: prefer selecting from the provided existing tags list when appropriate
|
||||
- You may create new tags if they better fit the content
|
||||
- New tags must follow the rules: lowercase letters, numbers, and underscores only
|
||||
- Be concise and accurate
|
||||
- Preserve important existing metadata when present"""
|
||||
|
||||
with open(self.prompts_dir / 'default.txt', 'w', encoding='utf-8') as f:
|
||||
f.write(default_prompt)
|
||||
|
||||
def get_api_config(self) -> dict:
|
||||
"""Get API configuration with environment variable resolution"""
|
||||
config = {
|
||||
'url': 'https://api.openai.com/v1',
|
||||
'model': 'gpt-4',
|
||||
'api_key': '',
|
||||
'api_key_source': 'none'
|
||||
}
|
||||
|
||||
if not self.env_file.exists():
|
||||
return config
|
||||
|
||||
# Parse .env file
|
||||
env_vars = {}
|
||||
with open(self.env_file, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
if '=' in line:
|
||||
key, value = line.split('=', 1)
|
||||
env_vars[key.strip()] = value.strip()
|
||||
|
||||
# Get URL
|
||||
if 'OPENAI_API_URL' in env_vars:
|
||||
config['url'] = env_vars['OPENAI_API_URL']
|
||||
|
||||
# Get model
|
||||
if 'OPENAI_MODEL' in env_vars:
|
||||
config['model'] = env_vars['OPENAI_MODEL']
|
||||
|
||||
# Get API key with special handling
|
||||
if 'OPENAI_API_KEY' in env_vars:
|
||||
api_key_value = env_vars['OPENAI_API_KEY']
|
||||
|
||||
# Remove quotes if present
|
||||
api_key_value = api_key_value.strip('"').strip("'")
|
||||
|
||||
if not api_key_value:
|
||||
# Empty string = no key needed
|
||||
config['api_key'] = ''
|
||||
config['api_key_source'] = 'none'
|
||||
elif api_key_value.startswith('$'):
|
||||
# Environment variable reference
|
||||
env_var_name = api_key_value[1:]
|
||||
env_value = os.environ.get(env_var_name)
|
||||
if env_value is None:
|
||||
raise ValueError(
|
||||
f"Environment variable '{env_var_name}' not found. "
|
||||
f"Referenced in .env as OPENAI_API_KEY={api_key_value}"
|
||||
)
|
||||
config['api_key'] = env_value
|
||||
config['api_key_source'] = f'env:{env_var_name}'
|
||||
else:
|
||||
# Plain text key (security warning will be issued)
|
||||
config['api_key'] = api_key_value
|
||||
config['api_key_source'] = 'plaintext'
|
||||
|
||||
return config
|
||||
|
||||
def print_config_info(self):
|
||||
"""Print configuration location and status"""
|
||||
print(f"Configuration directory: {self.config_dir}")
|
||||
print(f" .env file: {self.env_file}")
|
||||
print(f" Templates: {self.templates_dir}")
|
||||
print(f" Rules: {self.rules_dir}")
|
||||
print(f" Prompts: {self.prompts_dir}")
|
||||
|
||||
# Get API config to show status
|
||||
try:
|
||||
api_config = self.get_api_config()
|
||||
|
||||
if api_config['api_key_source'] == 'none':
|
||||
print(f" API Key: Not configured (LLM features disabled)")
|
||||
elif api_config['api_key_source'] == 'plaintext':
|
||||
print(f" API Key: Configured (plain text)")
|
||||
print(f" WARNING: Storing API keys in plain text is a security risk!")
|
||||
print(f" Consider using environment variable: OPENAI_API_KEY=$YOUR_ENV_VAR")
|
||||
elif api_config['api_key_source'].startswith('env:'):
|
||||
env_var = api_config['api_key_source'].split(':', 1)[1]
|
||||
print(f" API Key: From environment variable ${env_var}")
|
||||
except ValueError as e:
|
||||
print(f" API Key: ERROR - {e}")
|
||||
|
||||
print()
|
||||
+4
-26
@@ -5,17 +5,14 @@ import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from dotenv import load_dotenv
|
||||
from openai import OpenAI
|
||||
|
||||
|
||||
class LLMClient:
|
||||
def __init__(self, prompts_dir: Path):
|
||||
load_dotenv()
|
||||
|
||||
self.api_url = os.getenv('OPENAI_API_URL', 'https://api.openai.com/v1')
|
||||
self.model = os.getenv('OPENAI_MODEL', 'gpt-4')
|
||||
self.api_key = os.getenv('OPENAI_API_KEY', '')
|
||||
def __init__(self, prompts_dir: Path, api_config: dict):
|
||||
self.api_url = api_config.get('url', 'https://api.openai.com/v1')
|
||||
self.model = api_config.get('model', 'gpt-4')
|
||||
self.api_key = api_config.get('api_key', '')
|
||||
|
||||
self.client = OpenAI(
|
||||
base_url=self.api_url,
|
||||
@@ -23,25 +20,6 @@ class LLMClient:
|
||||
) if self.api_key else None
|
||||
|
||||
self.prompts_dir = prompts_dir
|
||||
if not prompts_dir.exists():
|
||||
prompts_dir.mkdir(parents=True)
|
||||
self._create_default_prompt()
|
||||
|
||||
def _create_default_prompt(self):
|
||||
"""Create default system prompt"""
|
||||
default_prompt = """You are a metadata extraction assistant for markdown documents.
|
||||
|
||||
Your task is to analyze the document content and suggest appropriate frontmatter metadata.
|
||||
|
||||
Guidelines:
|
||||
- For tags: prefer selecting from the provided existing tags list when appropriate
|
||||
- You may create new tags if they better fit the content
|
||||
- New tags must follow the rules: lowercase letters, numbers, and underscores only
|
||||
- Be concise and accurate
|
||||
- Preserve important existing metadata when present"""
|
||||
|
||||
with open(self.prompts_dir / 'default.txt', 'w', encoding='utf-8') as f:
|
||||
f.write(default_prompt)
|
||||
|
||||
def load_prompt(self, name: str = 'default') -> str:
|
||||
"""Load prompt template"""
|
||||
|
||||
+18
-5
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Main frontmatter processor
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from core.database import Database
|
||||
@@ -10,6 +11,7 @@ from core.rules import RulesEngine
|
||||
from core.template import TemplateManager
|
||||
from core.llm import LLMClient
|
||||
from core.changelog import ChangeLog
|
||||
from core.config import ConfigManager
|
||||
|
||||
|
||||
class FrontmatterProcessor:
|
||||
@@ -22,15 +24,26 @@ class FrontmatterProcessor:
|
||||
self.force = force
|
||||
self.add_only = add_only
|
||||
|
||||
# Initialize configuration
|
||||
self.config = ConfigManager()
|
||||
self.config.ensure_config_exists()
|
||||
|
||||
# Get API configuration
|
||||
api_config = self.config.get_api_config()
|
||||
|
||||
self.db = Database(repo.path / 'madomeda.db')
|
||||
self.rules = RulesEngine(repo.path / 'rules')
|
||||
self.templates = TemplateManager(repo.path / 'templates')
|
||||
self.llm = LLMClient(repo.path / 'prompts')
|
||||
self.rules = RulesEngine(self.config.rules_dir)
|
||||
self.templates = TemplateManager(self.config.templates_dir)
|
||||
self.llm = LLMClient(self.config.prompts_dir, api_config)
|
||||
self.changelog = ChangeLog(repo.path)
|
||||
self.parser = FrontmatterParser()
|
||||
|
||||
def process(self):
|
||||
"""Main processing loop"""
|
||||
# Print configuration info if running in interactive shell
|
||||
if sys.stdin.isatty():
|
||||
self.config.print_config_info()
|
||||
|
||||
print(f"Processing repository: {self.repo.path}")
|
||||
print(f"Template: {self.template_name}")
|
||||
print(f"Mode: {'DRY RUN' if self.whatif else 'LIVE'}")
|
||||
@@ -235,8 +248,8 @@ class FrontmatterProcessor:
|
||||
|
||||
elif field == 'tags':
|
||||
tags = normalized_fm.get('tags', [])
|
||||
# Always return the list (even if empty) so it's preserved
|
||||
return tags
|
||||
# Return None if empty so AI inference is tried
|
||||
return tags if tags else None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
+4
-46
@@ -14,56 +14,14 @@ class RulesEngine:
|
||||
|
||||
def _load_rules(self) -> List[dict]:
|
||||
"""Load all rule files"""
|
||||
if not self.rules_dir.exists():
|
||||
self.rules_dir.mkdir(parents=True)
|
||||
self._create_default_rules()
|
||||
|
||||
rules = []
|
||||
for rule_file in self.rules_dir.glob('*.json'):
|
||||
with open(rule_file, 'r', encoding='utf-8') as f:
|
||||
rules.append(json.load(f))
|
||||
if self.rules_dir.exists():
|
||||
for rule_file in self.rules_dir.glob('*.json'):
|
||||
with open(rule_file, 'r', encoding='utf-8') as f:
|
||||
rules.append(json.load(f))
|
||||
|
||||
return rules
|
||||
|
||||
def _create_default_rules(self):
|
||||
"""Create default rule files"""
|
||||
# Tags normalization rule
|
||||
tags_rule = {
|
||||
"name": "tags_normalization",
|
||||
"description": "Normalize tags to lowercase with underscores",
|
||||
"type": "tags"
|
||||
}
|
||||
|
||||
tag_to_tags_rule = {
|
||||
"name": "tag_to_tags",
|
||||
"description": "Rename 'tag' key to 'tags'",
|
||||
"type": "rename"
|
||||
}
|
||||
|
||||
lowercase_keys_rule = {
|
||||
"name": "lowercase_keys",
|
||||
"description": "All frontmatter keys must be lowercase",
|
||||
"type": "keys"
|
||||
}
|
||||
|
||||
summary_to_description_rule = {
|
||||
"name": "summary_to_description",
|
||||
"description": "Rename 'summary' key to 'description'",
|
||||
"type": "rename"
|
||||
}
|
||||
|
||||
with open(self.rules_dir / 'tags_normalization.json', 'w', encoding='utf-8') as f:
|
||||
json.dump(tags_rule, f, indent=2)
|
||||
|
||||
with open(self.rules_dir / 'tag_to_tags.json', 'w', encoding='utf-8') as f:
|
||||
json.dump(tag_to_tags_rule, f, indent=2)
|
||||
|
||||
with open(self.rules_dir / 'lowercase_keys.json', 'w', encoding='utf-8') as f:
|
||||
json.dump(lowercase_keys_rule, f, indent=2)
|
||||
|
||||
with open(self.rules_dir / 'summary_to_description.json', 'w', encoding='utf-8') as f:
|
||||
json.dump(summary_to_description_rule, f, indent=2)
|
||||
|
||||
def apply_rules(self, frontmatter: dict) -> Tuple[dict, bool]:
|
||||
"""Apply all rules to frontmatter, return (modified_fm, is_conformant)"""
|
||||
if not frontmatter:
|
||||
|
||||
@@ -9,24 +9,6 @@ from typing import Optional
|
||||
class TemplateManager:
|
||||
def __init__(self, templates_dir: Path):
|
||||
self.templates_dir = templates_dir
|
||||
|
||||
if not templates_dir.exists():
|
||||
templates_dir.mkdir(parents=True)
|
||||
self._create_default_template()
|
||||
|
||||
def _create_default_template(self):
|
||||
"""Create default template"""
|
||||
default_template = {
|
||||
"title": "heur|orig",
|
||||
"created": "heur|orig",
|
||||
"changed": "heur|orig",
|
||||
"authors": "heur|orig",
|
||||
"version": "heur|orig",
|
||||
"tags": "orig|ai default"
|
||||
}
|
||||
|
||||
with open(self.templates_dir / 'default.json', 'w', encoding='utf-8') as f:
|
||||
json.dump(default_template, f, indent=2)
|
||||
|
||||
def load_template(self, name: str) -> dict:
|
||||
"""Load template by name"""
|
||||
@@ -35,8 +17,6 @@ class TemplateManager:
|
||||
if not template_path.exists():
|
||||
# Fallback to default
|
||||
template_path = self.templates_dir / 'default.json'
|
||||
if not template_path.exists():
|
||||
self._create_default_template()
|
||||
|
||||
with open(template_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
Reference in New Issue
Block a user