Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

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",
]