e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
322 lines
11 KiB
Python
322 lines
11 KiB
Python
#!/usr/bin/env python
|
|
"""
|
|
Configuration Loader
|
|
====================
|
|
|
|
Unified configuration loading for all DeepTutor modules.
|
|
Provides YAML configuration loading, path resolution, and language parsing.
|
|
"""
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
from deeptutor.runtime.home import get_runtime_home
|
|
from deeptutor.services.path_service import get_path_service
|
|
|
|
# Runtime workspace root. Application settings live under PROJECT_ROOT/data/user/settings.
|
|
PROJECT_ROOT = get_runtime_home()
|
|
|
|
|
|
def get_runtime_settings_dir(project_root: Path | None = None) -> Path:
|
|
"""Return the canonical runtime settings directory under ``data/user/settings``."""
|
|
root = project_root or PROJECT_ROOT
|
|
return root / "data" / "user" / "settings"
|
|
|
|
|
|
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
"""
|
|
Deep merge two dictionaries, values in override will override values in base
|
|
|
|
Args:
|
|
base: Base configuration
|
|
override: Override configuration
|
|
|
|
Returns:
|
|
Merged configuration
|
|
"""
|
|
result = base.copy()
|
|
|
|
for key, value in override.items():
|
|
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
# Recursively merge dictionaries
|
|
result[key] = _deep_merge(result[key], value)
|
|
else:
|
|
# Direct override
|
|
result[key] = value
|
|
|
|
return result
|
|
|
|
|
|
def _load_yaml_file(file_path: Path) -> dict[str, Any]:
|
|
"""Load a YAML file and return its contents as a dict."""
|
|
with open(file_path, encoding="utf-8") as f:
|
|
return yaml.safe_load(f) or {}
|
|
|
|
|
|
def _inject_runtime_paths(config: dict[str, Any]) -> dict[str, Any]:
|
|
"""Expose canonical runtime paths without treating YAML paths as user-editable state."""
|
|
path_service = get_path_service()
|
|
normalized = dict(config or {})
|
|
tools = dict(normalized.get("tools", {}) or {})
|
|
run_code = dict(tools.get("run_code", {}) or {})
|
|
run_code["workspace"] = str(path_service.get_chat_feature_dir("_detached_code_execution"))
|
|
tools["run_code"] = run_code
|
|
normalized["tools"] = tools
|
|
normalized["paths"] = {
|
|
"user_data_dir": str(path_service.get_user_root()),
|
|
"knowledge_bases_dir": str(path_service.get_knowledge_bases_root()),
|
|
"user_log_dir": str(path_service.get_logs_dir()),
|
|
"performance_log_dir": str(path_service.get_logs_dir() / "performance"),
|
|
"question_output_dir": str(path_service.get_chat_feature_dir("deep_question")),
|
|
"research_output_dir": str(path_service.get_research_dir()),
|
|
"research_reports_dir": str(path_service.get_research_reports_dir()),
|
|
"solve_output_dir": str(path_service.get_chat_feature_dir("deep_solve")),
|
|
}
|
|
return normalized
|
|
|
|
|
|
async def _load_yaml_file_async(file_path: Path) -> dict[str, Any]:
|
|
"""Async version of _load_yaml_file."""
|
|
return await asyncio.to_thread(_load_yaml_file, file_path)
|
|
|
|
|
|
def resolve_config_path(
|
|
config_file: str,
|
|
project_root: Path | None = None,
|
|
) -> tuple[Path, bool]:
|
|
"""
|
|
Resolve *config_file* inside ``data/user/settings/``.
|
|
|
|
Returns:
|
|
``(path, False)``
|
|
|
|
Raises:
|
|
FileNotFoundError: If the requested config does not exist.
|
|
"""
|
|
if project_root is None:
|
|
project_root = PROJECT_ROOT
|
|
|
|
settings_dir = get_runtime_settings_dir(project_root)
|
|
config_path = settings_dir / config_file
|
|
if config_path.exists():
|
|
return config_path, False
|
|
raise FileNotFoundError(
|
|
f"Configuration file not found: {config_file} (expected under {settings_dir})"
|
|
)
|
|
|
|
|
|
def load_config_with_main(config_file: str, project_root: Path | None = None) -> dict[str, Any]:
|
|
"""
|
|
Load configuration file, automatically merge with main.yaml common configuration
|
|
|
|
Args:
|
|
config_file: Configuration file name (e.g., "main.yaml")
|
|
project_root: Project root directory (if None, will try to auto-detect)
|
|
|
|
Returns:
|
|
Merged configuration dictionary
|
|
"""
|
|
if project_root is None:
|
|
project_root = PROJECT_ROOT
|
|
|
|
config_path, _ = resolve_config_path(config_file, project_root)
|
|
return _inject_runtime_paths(_load_yaml_file(config_path))
|
|
|
|
|
|
async def load_config_with_main_async(
|
|
config_file: str, project_root: Path | None = None
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Async version of load_config_with_main for non-blocking file operations.
|
|
|
|
Load configuration file, automatically merge with main.yaml common configuration
|
|
|
|
Args:
|
|
config_file: Configuration file name (e.g., "main.yaml")
|
|
project_root: Project root directory (if None, will try to auto-detect)
|
|
|
|
Returns:
|
|
Merged configuration dictionary
|
|
"""
|
|
if project_root is None:
|
|
project_root = PROJECT_ROOT
|
|
|
|
config_path, _ = resolve_config_path(config_file, project_root)
|
|
return _inject_runtime_paths(await _load_yaml_file_async(config_path))
|
|
|
|
|
|
def get_path_from_config(config: dict[str, Any], path_key: str, default: str = None) -> str:
|
|
"""
|
|
Get path from configuration.
|
|
|
|
Args:
|
|
config: Configuration dictionary
|
|
path_key: Path key name (e.g., "log_dir", "workspace")
|
|
default: Default value
|
|
|
|
Returns:
|
|
Path string
|
|
"""
|
|
injected = _inject_runtime_paths(config)
|
|
if "paths" in injected and path_key in injected["paths"]:
|
|
return injected["paths"][path_key]
|
|
if path_key == "workspace":
|
|
return injected.get("tools", {}).get("run_code", {}).get("workspace", default)
|
|
return default
|
|
|
|
|
|
def parse_language(language: Any) -> str:
|
|
"""
|
|
Unified language configuration parser, supports multiple input formats
|
|
|
|
Supported language representations:
|
|
- English: "en", "english", "English"
|
|
- Chinese: "zh", "chinese", "Chinese"
|
|
|
|
Args:
|
|
language: Language configuration value (can be "zh"/"en"/"Chinese"/"English" etc.)
|
|
|
|
Returns:
|
|
Standardized language code: 'zh' or 'en', defaults to 'zh'
|
|
"""
|
|
if not language:
|
|
return "zh"
|
|
|
|
if isinstance(language, str):
|
|
lang_lower = language.lower()
|
|
if lang_lower in ["en", "english"]:
|
|
return "en"
|
|
if lang_lower in ["zh", "chinese", "cn"]:
|
|
return "zh"
|
|
|
|
return "zh" # Default Chinese
|
|
|
|
|
|
def get_agent_params(module_name: str) -> dict:
|
|
"""
|
|
Get agent parameters (temperature, max_tokens) for a specific module.
|
|
|
|
This function loads parameters from config/agents.yaml which serves as the
|
|
SINGLE source of truth for all agent temperature and max_tokens settings.
|
|
|
|
Args:
|
|
module_name: Module name, one of:
|
|
- "solve": Solve module agents
|
|
- "research": Research module agents
|
|
- "question": Question module agents
|
|
- "brainstorm": Brainstorm tool settings
|
|
- "co_writer": CoWriter module agents
|
|
- "narrator": Narrator agent (independent, for TTS)
|
|
- "llm_probe": Settings → LLM diagnostic probe
|
|
|
|
Returns:
|
|
dict: Dictionary containing:
|
|
- temperature: float, default 0.5
|
|
- max_tokens: int, default 4096
|
|
|
|
Example:
|
|
>>> params = get_agent_params("solve")
|
|
>>> params["temperature"] # 0.3
|
|
>>> params["max_tokens"] # 8192
|
|
"""
|
|
global_defaults = {
|
|
"temperature": 0.5,
|
|
"max_tokens": 4096,
|
|
}
|
|
section_map = {
|
|
"solve": ("capabilities", "solve"),
|
|
"research": ("capabilities", "research"),
|
|
"question": ("capabilities", "question"),
|
|
"co_writer": ("capabilities", "co_writer"),
|
|
"visualize": ("capabilities", "visualize"),
|
|
"brainstorm": ("tools", "brainstorm"),
|
|
"vision_solver": ("plugins", "vision_solver"),
|
|
"math_animator": ("plugins", "math_animator"),
|
|
"llm_probe": ("diagnostics", "llm_probe"),
|
|
}
|
|
path = get_runtime_settings_dir(PROJECT_ROOT) / "agents.yaml"
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"Missing required configuration file: {path}")
|
|
section = section_map.get(module_name)
|
|
if section is None:
|
|
return global_defaults
|
|
|
|
# Per-module defaults come from the shipped DEFAULT_AGENTS_SETTINGS so that
|
|
# adding a new capability seeded with non-default tokens (e.g. visualize at
|
|
# 16k) doesn't require existing users to hand-edit their stale agents.yaml.
|
|
# Imported lazily to avoid a circular dependency with services.setup.
|
|
from deeptutor.services.setup.init import DEFAULT_AGENTS_SETTINGS
|
|
|
|
seeded: dict[str, Any] = DEFAULT_AGENTS_SETTINGS
|
|
for key in section:
|
|
seeded = seeded.get(key, {}) if isinstance(seeded, dict) else {}
|
|
module_defaults = {
|
|
"temperature": seeded.get("temperature", global_defaults["temperature"])
|
|
if isinstance(seeded, dict)
|
|
else global_defaults["temperature"],
|
|
"max_tokens": seeded.get("max_tokens", global_defaults["max_tokens"])
|
|
if isinstance(seeded, dict)
|
|
else global_defaults["max_tokens"],
|
|
}
|
|
|
|
with open(path, encoding="utf-8") as f:
|
|
agents_config = yaml.safe_load(f) or {}
|
|
module_config: dict[str, Any] = agents_config
|
|
for key in section:
|
|
module_config = module_config.get(key, {}) if isinstance(module_config, dict) else {}
|
|
return {
|
|
"temperature": module_config.get("temperature", module_defaults["temperature"]),
|
|
"max_tokens": module_config.get("max_tokens", module_defaults["max_tokens"]),
|
|
}
|
|
|
|
|
|
DEFAULT_CHAT_PARAMS: dict[str, Any] = {
|
|
"temperature": 0.2,
|
|
# Exploring-loop budget: max LLM rounds in one turn's loop (a round
|
|
# without tool calls ends the loop early — the normal exit).
|
|
"max_rounds": 8,
|
|
"exploring": {"max_tokens": 1600},
|
|
"responding": {"max_tokens": 8000},
|
|
}
|
|
|
|
|
|
def get_chat_params() -> dict[str, Any]:
|
|
"""
|
|
Read ``capabilities.chat`` from agents.yaml with deep-merged defaults.
|
|
|
|
Unlike :func:`get_agent_params`, the chat capability has per-stage
|
|
sub-sections (``exploring``, ``responding``), each with its own
|
|
``max_tokens``. A single ``temperature`` and round budget are shared
|
|
across the chat loop. Legacy keys from the targeting-era schema
|
|
(``max_iterations``, ``max_explore_rounds``, …) are filtered out.
|
|
|
|
Returns:
|
|
dict: Deep-merged chat configuration. Always contains every stage key
|
|
from :data:`DEFAULT_CHAT_PARAMS` so callers can index without checks.
|
|
"""
|
|
path = get_runtime_settings_dir(PROJECT_ROOT) / "agents.yaml"
|
|
cfg: dict[str, Any] = {}
|
|
if path.exists():
|
|
with open(path, encoding="utf-8") as f:
|
|
agents_config = yaml.safe_load(f) or {}
|
|
cfg = (agents_config.get("capabilities", {}) or {}).get("chat", {}) or {}
|
|
known_keys = set(DEFAULT_CHAT_PARAMS)
|
|
filtered_cfg = {key: value for key, value in cfg.items() if key in known_keys}
|
|
return _deep_merge(DEFAULT_CHAT_PARAMS, filtered_cfg)
|
|
|
|
|
|
__all__ = [
|
|
"PROJECT_ROOT",
|
|
"get_runtime_settings_dir",
|
|
"load_config_with_main",
|
|
"get_path_from_config",
|
|
"parse_language",
|
|
"get_agent_params",
|
|
"get_chat_params",
|
|
"DEFAULT_CHAT_PARAMS",
|
|
"_deep_merge",
|
|
]
|