""" Config Registry Service ======================= Версійоване сховище конфігурацій для DAARION platform. Зберігає: - Системні промпти (версійовані) - Routing rules - Agent configurations - Feature flags - Quotas per user/team/agent """ import json import hashlib import logging from datetime import datetime from typing import Dict, Any, Optional, List from dataclasses import dataclass, field from pathlib import Path import yaml logger = logging.getLogger(__name__) @dataclass class ConfigVersion: """Версія конфігурації""" version: str content: Dict[str, Any] content_hash: str created_at: datetime created_by: str is_active: bool = False description: str = "" @dataclass class PromptConfig: """Конфігурація системного промпту""" agent_id: str current_version: str versions: Dict[str, ConfigVersion] = field(default_factory=dict) def get_active_prompt(self) -> str: """Повертає активний промпт""" if self.current_version in self.versions: return self.versions[self.current_version].content.get("prompt", "") return "" @dataclass class RoutingRule: """Правило маршрутизації""" rule_id: str name: str conditions: Dict[str, Any] # {"intent": "energy", "confidence_min": 0.7} target_agent: str priority: int = 0 enabled: bool = True @dataclass class AgentQuota: """Квота для агента/користувача""" entity_type: str # "user", "team", "agent" entity_id: str quota_type: str # "tokens", "requests", "storage" limit: int used: int = 0 reset_period: str = "daily" # "daily", "weekly", "monthly" last_reset: Optional[datetime] = None class ConfigRegistry: """ Централізований реєстр конфігурацій. Підтримує: - Версіонування промптів - Routing rules - Feature flags - Quotas """ def __init__(self, config_dir: str = "/opt/microdao-daarion/config"): self.config_dir = Path(config_dir) self.config_dir.mkdir(parents=True, exist_ok=True) # In-memory cache self._prompts: Dict[str, PromptConfig] = {} self._routing_rules: Dict[str, RoutingRule] = {} self._feature_flags: Dict[str, bool] = {} self._quotas: Dict[str, AgentQuota] = {} # Load existing configs self._load_all() def _load_all(self): """Завантажує всі конфігурації з файлів""" self._load_prompts() self._load_routing_rules() self._load_feature_flags() self._load_quotas() def _compute_hash(self, content: Any) -> str: """Обчислює SHA256 хеш контенту""" content_str = json.dumps(content, sort_keys=True, default=str) return hashlib.sha256(content_str.encode()).hexdigest()[:16] # ==================== PROMPTS ==================== def _load_prompts(self): """Завантажує промпти з файлів""" prompts_dir = self.config_dir / "prompts" if not prompts_dir.exists(): prompts_dir.mkdir(parents=True, exist_ok=True) return for prompt_file in prompts_dir.glob("*.yaml"): try: with open(prompt_file, 'r', encoding='utf-8') as f: data = yaml.safe_load(f) if data: agent_id = prompt_file.stem self._prompts[agent_id] = self._parse_prompt_config(agent_id, data) except Exception as e: logger.error(f"Failed to load prompt {prompt_file}: {e}") def _parse_prompt_config(self, agent_id: str, data: Dict) -> PromptConfig: """Парсить конфігурацію промпту""" versions = {} for ver_id, ver_data in data.get("versions", {}).items(): versions[ver_id] = ConfigVersion( version=ver_id, content=ver_data.get("content", {}), content_hash=self._compute_hash(ver_data.get("content", {})), created_at=datetime.fromisoformat(ver_data.get("created_at", datetime.now().isoformat())), created_by=ver_data.get("created_by", "system"), is_active=ver_data.get("is_active", False), description=ver_data.get("description", "") ) return PromptConfig( agent_id=agent_id, current_version=data.get("current_version", "v1"), versions=versions ) def get_prompt(self, agent_id: str, version: str = None) -> Optional[str]: """ Повертає промпт для агента. Args: agent_id: ID агента version: Версія (або None для активної) """ if agent_id not in self._prompts: return None config = self._prompts[agent_id] target_version = version or config.current_version if target_version in config.versions: return config.versions[target_version].content.get("prompt", "") return None def set_prompt(self, agent_id: str, prompt: str, version: str = None, created_by: str = "system", description: str = "", activate: bool = True) -> str: """ Створює нову версію промпту. Returns: Версія нового промпту """ if agent_id not in self._prompts: self._prompts[agent_id] = PromptConfig( agent_id=agent_id, current_version="v1", versions={} ) config = self._prompts[agent_id] # Generate version if not provided if version is None: version = f"v{len(config.versions) + 1}" # Create new version content = {"prompt": prompt} config.versions[version] = ConfigVersion( version=version, content=content, content_hash=self._compute_hash(content), created_at=datetime.now(), created_by=created_by, is_active=activate, description=description ) if activate: # Deactivate previous for v in config.versions.values(): if v.version != version: v.is_active = False config.current_version = version # Save to file self._save_prompt(agent_id) logger.info(f"Created prompt version {version} for {agent_id}") return version def _save_prompt(self, agent_id: str): """Зберігає промпт у файл""" prompts_dir = self.config_dir / "prompts" prompts_dir.mkdir(parents=True, exist_ok=True) config = self._prompts[agent_id] data = { "agent_id": agent_id, "current_version": config.current_version, "versions": { v.version: { "content": v.content, "content_hash": v.content_hash, "created_at": v.created_at.isoformat(), "created_by": v.created_by, "is_active": v.is_active, "description": v.description } for v in config.versions.values() } } with open(prompts_dir / f"{agent_id}.yaml", 'w', encoding='utf-8') as f: yaml.dump(data, f, allow_unicode=True, default_flow_style=False) def list_prompt_versions(self, agent_id: str) -> List[Dict]: """Повертає список версій промпту""" if agent_id not in self._prompts: return [] return [ { "version": v.version, "content_hash": v.content_hash, "created_at": v.created_at.isoformat(), "created_by": v.created_by, "is_active": v.is_active, "description": v.description } for v in self._prompts[agent_id].versions.values() ] def rollback_prompt(self, agent_id: str, version: str) -> bool: """Відкатує промпт до вказаної версії""" if agent_id not in self._prompts: return False config = self._prompts[agent_id] if version not in config.versions: return False # Deactivate all for v in config.versions.values(): v.is_active = False # Activate target config.versions[version].is_active = True config.current_version = version self._save_prompt(agent_id) logger.info(f"Rolled back {agent_id} prompt to {version}") return True # ==================== ROUTING RULES ==================== def _load_routing_rules(self): """Завантажує routing rules""" rules_file = self.config_dir / "routing_rules.yaml" if not rules_file.exists(): return try: with open(rules_file, 'r', encoding='utf-8') as f: data = yaml.safe_load(f) or {} for rule_id, rule_data in data.get("rules", {}).items(): self._routing_rules[rule_id] = RoutingRule( rule_id=rule_id, name=rule_data.get("name", rule_id), conditions=rule_data.get("conditions", {}), target_agent=rule_data.get("target_agent", ""), priority=rule_data.get("priority", 0), enabled=rule_data.get("enabled", True) ) except Exception as e: logger.error(f"Failed to load routing rules: {e}") def get_routing_rules(self, enabled_only: bool = True) -> List[RoutingRule]: """Повертає routing rules, відсортовані за пріоритетом""" rules = list(self._routing_rules.values()) if enabled_only: rules = [r for r in rules if r.enabled] return sorted(rules, key=lambda r: r.priority, reverse=True) def add_routing_rule(self, rule: RoutingRule): """Додає routing rule""" self._routing_rules[rule.rule_id] = rule self._save_routing_rules() def _save_routing_rules(self): """Зберігає routing rules""" data = { "rules": { r.rule_id: { "name": r.name, "conditions": r.conditions, "target_agent": r.target_agent, "priority": r.priority, "enabled": r.enabled } for r in self._routing_rules.values() } } with open(self.config_dir / "routing_rules.yaml", 'w', encoding='utf-8') as f: yaml.dump(data, f, allow_unicode=True, default_flow_style=False) # ==================== FEATURE FLAGS ==================== def _load_feature_flags(self): """Завантажує feature flags""" flags_file = self.config_dir / "feature_flags.yaml" if not flags_file.exists(): # Default flags self._feature_flags = { "orchestration_enabled": True, "soft_handoff_enabled": True, "auto_handoff_enabled": False, "privacy_gate_enabled": True, "audit_logging_enabled": True, "multi_agent_workflows": False, } self._save_feature_flags() return try: with open(flags_file, 'r', encoding='utf-8') as f: self._feature_flags = yaml.safe_load(f) or {} except Exception as e: logger.error(f"Failed to load feature flags: {e}") def get_feature_flag(self, flag_name: str, default: bool = False) -> bool: """Повертає значення feature flag""" return self._feature_flags.get(flag_name, default) def set_feature_flag(self, flag_name: str, value: bool): """Встановлює feature flag""" self._feature_flags[flag_name] = value self._save_feature_flags() logger.info(f"Feature flag {flag_name} set to {value}") def _save_feature_flags(self): """Зберігає feature flags""" with open(self.config_dir / "feature_flags.yaml", 'w', encoding='utf-8') as f: yaml.dump(self._feature_flags, f, allow_unicode=True) # ==================== QUOTAS ==================== def _load_quotas(self): """Завантажує квоти""" quotas_file = self.config_dir / "quotas.yaml" if not quotas_file.exists(): return try: with open(quotas_file, 'r', encoding='utf-8') as f: data = yaml.safe_load(f) or {} for quota_id, quota_data in data.get("quotas", {}).items(): self._quotas[quota_id] = AgentQuota( entity_type=quota_data.get("entity_type", "user"), entity_id=quota_data.get("entity_id", ""), quota_type=quota_data.get("quota_type", "requests"), limit=quota_data.get("limit", 1000), used=quota_data.get("used", 0), reset_period=quota_data.get("reset_period", "daily"), last_reset=datetime.fromisoformat(quota_data["last_reset"]) if quota_data.get("last_reset") else None ) except Exception as e: logger.error(f"Failed to load quotas: {e}") def get_quota(self, entity_type: str, entity_id: str, quota_type: str) -> Optional[AgentQuota]: """Повертає квоту""" quota_id = f"{entity_type}:{entity_id}:{quota_type}" return self._quotas.get(quota_id) def check_quota(self, entity_type: str, entity_id: str, quota_type: str, amount: int = 1) -> bool: """ Перевіряє, чи є достатньо квоти. Returns: True якщо квота дозволяє, False якщо ліміт перевищено """ quota = self.get_quota(entity_type, entity_id, quota_type) if quota is None: return True # No quota = unlimited return (quota.used + amount) <= quota.limit def consume_quota(self, entity_type: str, entity_id: str, quota_type: str, amount: int = 1) -> bool: """ Споживає квоту. Returns: True якщо успішно, False якщо ліміт перевищено """ quota_id = f"{entity_type}:{entity_id}:{quota_type}" if quota_id not in self._quotas: # Create default quota self._quotas[quota_id] = AgentQuota( entity_type=entity_type, entity_id=entity_id, quota_type=quota_type, limit=10000, # Default limit used=0, reset_period="daily" ) quota = self._quotas[quota_id] if (quota.used + amount) > quota.limit: return False quota.used += amount self._save_quotas() return True def _save_quotas(self): """Зберігає квоти""" data = { "quotas": { f"{q.entity_type}:{q.entity_id}:{q.quota_type}": { "entity_type": q.entity_type, "entity_id": q.entity_id, "quota_type": q.quota_type, "limit": q.limit, "used": q.used, "reset_period": q.reset_period, "last_reset": q.last_reset.isoformat() if q.last_reset else None } for q in self._quotas.values() } } with open(self.config_dir / "quotas.yaml", 'w', encoding='utf-8') as f: yaml.dump(data, f, allow_unicode=True, default_flow_style=False) # Global instance _config_registry: Optional[ConfigRegistry] = None def get_config_registry() -> ConfigRegistry: """Повертає глобальний екземпляр ConfigRegistry""" global _config_registry if _config_registry is None: _config_registry = ConfigRegistry() return _config_registry # ==================== HTTP API ==================== from fastapi import APIRouter, HTTPException from pydantic import BaseModel router = APIRouter(prefix="/config", tags=["config"]) class PromptRequest(BaseModel): prompt: str version: Optional[str] = None description: str = "" activate: bool = True class FeatureFlagRequest(BaseModel): value: bool @router.get("/prompts/{agent_id}") async def get_prompt(agent_id: str, version: str = None): """Отримати промпт для агента""" registry = get_config_registry() prompt = registry.get_prompt(agent_id, version) if prompt is None: raise HTTPException(404, f"Prompt not found for {agent_id}") return {"agent_id": agent_id, "prompt": prompt} @router.get("/prompts/{agent_id}/versions") async def list_prompt_versions(agent_id: str): """Список версій промпту""" registry = get_config_registry() versions = registry.list_prompt_versions(agent_id) return {"agent_id": agent_id, "versions": versions} @router.post("/prompts/{agent_id}") async def set_prompt(agent_id: str, request: PromptRequest): """Створити нову версію промпту""" registry = get_config_registry() version = registry.set_prompt( agent_id=agent_id, prompt=request.prompt, version=request.version, description=request.description, activate=request.activate ) return {"agent_id": agent_id, "version": version, "activated": request.activate} @router.post("/prompts/{agent_id}/rollback/{version}") async def rollback_prompt(agent_id: str, version: str): """Відкатити промпт до версії""" registry = get_config_registry() success = registry.rollback_prompt(agent_id, version) if not success: raise HTTPException(404, f"Version {version} not found") return {"agent_id": agent_id, "version": version, "status": "rolled_back"} @router.get("/feature-flags") async def get_feature_flags(): """Отримати всі feature flags""" registry = get_config_registry() return {"flags": registry._feature_flags} @router.get("/feature-flags/{flag_name}") async def get_feature_flag(flag_name: str): """Отримати feature flag""" registry = get_config_registry() value = registry.get_feature_flag(flag_name) return {"flag": flag_name, "value": value} @router.put("/feature-flags/{flag_name}") async def set_feature_flag(flag_name: str, request: FeatureFlagRequest): """Встановити feature flag""" registry = get_config_registry() registry.set_feature_flag(flag_name, request.value) return {"flag": flag_name, "value": request.value} @router.get("/routing-rules") async def get_routing_rules(enabled_only: bool = True): """Отримати routing rules""" registry = get_config_registry() rules = registry.get_routing_rules(enabled_only) return {"rules": [ { "rule_id": r.rule_id, "name": r.name, "conditions": r.conditions, "target_agent": r.target_agent, "priority": r.priority, "enabled": r.enabled } for r in rules ]} @router.get("/health") async def health(): """Health check""" return {"status": "ok", "service": "config-registry"}