| """Configuration management with Pydantic Settings.""" |
|
|
| import os |
| import re |
| from pathlib import Path |
|
|
| |
| |
| os.environ.setdefault("GRPC_VERBOSITY", "ERROR") |
| os.environ.setdefault("GRPC_TRACE", "none") |
| os.environ.setdefault("GRPC_KEEPALIVE_TIME_MS", "60000") |
| from typing import Optional |
|
|
| import yaml |
| from pydantic import BaseModel, Field, model_validator |
| from pydantic_settings import BaseSettings, SettingsConfigDict |
|
|
|
|
| _PREFIX = "" |
|
|
|
|
| class LLMProviderConfig(BaseSettings): |
| """Configuration for a single LLM provider.""" |
| api_key: str = "" |
| api_base: str = "" |
| model: str = "" |
| max_tokens: int = 4096 |
| temperature: float = 0.7 |
| frequency_penalty: float = 0.3 |
| presence_penalty: float = 0.3 |
| vision_model: str = "" |
| enable_native_tool_calls: bool = True |
|
|
|
|
| class EmbeddingConfig(BaseModel): |
| """Dedicated embedding model configuration. |
| |
| Separated from LLM because: |
| - Embedding may use a different provider (e.g., Claude for LLM + OpenAI for embeddings) |
| - Self-hosted embedding services have their own API endpoint |
| - The dimension must match the vector store schema |
| """ |
| provider: str = "openai" |
| model: str = "text-embedding-3-small" |
| model_type: str = "text" |
| dim: int = 1536 |
| api_key: str = "" |
| api_base: str = "" |
| batch_size: int = 100 |
|
|
|
|
| class MilvusConfig(BaseSettings): |
| """Milvus vector database configuration.""" |
| host: str = "localhost" |
| port: int = 19530 |
| collection_prefix: str = "agentic_rag" |
| dim: int = 1536 |
| index_type: str = "IVF_FLAT" |
| metric_type: str = "COSINE" |
|
|
|
|
| class MemoryConfig(BaseSettings): |
| """Memory configuration.""" |
| short_term_max_tokens: int = 8000 |
| long_term_top_k: int = 10 |
| working_memory_max_keys: int = 50 |
|
|
|
|
| class SessionConfig(BaseSettings): |
| """Session configuration.""" |
| ttl_seconds: int = 3600 |
| cleanup_interval_seconds: int = 300 |
|
|
|
|
| class APIConfig(BaseSettings): |
| """API server configuration.""" |
| host: str = "0.0.0.0" |
| port: int = 8000 |
| cors_origins: list[str] = ["*"] |
| rate_limit_per_minute: int = 60 |
|
|
|
|
| class OCRConfig(BaseSettings): |
| """PaddleOCR-VL / vLLM OCR configuration.""" |
| enabled: bool = True |
| api_base: str = "http://localhost:8000/v1" |
| model: str = "PaddlePaddle/PaddleOCR-VL" |
| api_key: str = "not-needed" |
| max_pages: int = 50 |
|
|
|
|
| class WeChatWorkGatewayConfig(BaseSettings): |
| """企业微信自建应用 gateway configuration. |
| |
| Reference: https://developer.work.weixin.qq.com/document/path/90238 |
| """ |
| enabled: bool = False |
| corp_id: str = "" |
| token: str = "" |
| encoding_aes_key: str = "" |
| agent_id: str = "" |
| secret: str = "" |
| webhook_path: str = "/gateway/wechat_work" |
|
|
|
|
| class QQBotGatewayConfig(BaseSettings): |
| """QQ Bot (官方) gateway configuration. |
| |
| 沙箱模式: wss://sandbox.api.sgroup.qq.com/websocket |
| 正式环境: wss://api.sgroup.qq.com/websocket |
| """ |
| enabled: bool = False |
| app_id: str = "" |
| app_secret: str = "" |
| sandbox: bool = True |
| webhook_path: str = "/gateway/qqbot" |
|
|
|
|
| class DingTalkGatewayConfig(BaseSettings): |
| """钉钉 bot gateway configuration.""" |
| enabled: bool = False |
| app_key: str = "" |
| app_secret: str = "" |
| webhook_path: str = "/gateway/dingtalk" |
|
|
|
|
| class GatewayConfig(BaseSettings): |
| """Messaging platform gateway configuration.""" |
| enabled: bool = False |
| response_mode: str = "sync" |
| max_reply_length: int = 2000 |
| wechat_work: WeChatWorkGatewayConfig = Field(default_factory=WeChatWorkGatewayConfig) |
| dingtalk: DingTalkGatewayConfig = Field(default_factory=DingTalkGatewayConfig) |
| qqbot: QQBotGatewayConfig = Field(default_factory=QQBotGatewayConfig) |
|
|
|
|
| class VoiceConfig(BaseSettings): |
| """Voice/STT/TTS configuration.""" |
| |
| stt_provider: str = "sensevoice" |
| stt_model: str = "sensevoice" |
| stt_api_base: str = "http://localhost:8000" |
| stt_api_key: str = "" |
| stt_language: str = "auto" |
| |
| tts_provider: str = "qwen" |
| tts_model: str = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign" |
| tts_api_base: str = "http://localhost:8091" |
| tts_api_key: str = "EMPTY" |
| tts_task_type: str = "VoiceDesign" |
| tts_instructions: str = "A clear, professional voice in Chinese" |
| tts_language: str = "Chinese" |
| tts_speaker: str = "" |
| tts_voice: str = "zh-CN-XiaoxiaoNeural" |
| tts_speed: float = 1.0 |
| tts_response_format: str = "wav" |
| sample_rate: int = 16000 |
|
|
|
|
| def _parse_llm_providers_from_env() -> dict[str, LLMProviderConfig]: |
| """Manually extract LLM provider configs from environment + .env file. |
| |
| pydantic-settings cannot auto-populate ``dict[str, Model]`` from env vars |
| because the dict keys are dynamic. We scan for the pattern:: |
| |
| RAG__LLM_PROVIDERS__<NAME>__<FIELD>=<value> |
| |
| in both ``os.environ`` AND the ``.env`` file (since pydantic-settings reads |
| ``.env`` internally but does NOT export into ``os.environ``). |
| """ |
| providers: dict[str, dict] = {} |
| pattern = re.compile(rf"^{re.escape(_PREFIX)}_?LLM_PROVIDERS__([A-Z0-9]+)__([A-Z_]+)$") |
|
|
| def _collect(source: dict[str, str]) -> None: |
| for key, value in source.items(): |
| m = pattern.match(key) |
| if not m: |
| continue |
| provider_name = m.group(1).lower() |
| field_name = m.group(2).lower() |
| providers.setdefault(provider_name, {})[field_name] = value |
|
|
| |
| _collect(dict(os.environ)) |
|
|
| |
| env_path = Path(".env") |
| if env_path.exists(): |
| env_vars = _parse_dotenv(env_path) |
| _collect(env_vars) |
|
|
| return {name: LLMProviderConfig(**fields) for name, fields in providers.items()} |
|
|
|
|
| def _parse_mcp_servers_from_env() -> dict[str, dict]: |
| """Extract MCP server configs. |
| |
| Priority: |
| 1. ``mcp_servers.json`` — standard MCP config (like Claude Code) |
| 2. ``mcp_servers.yaml`` — YAML alternative |
| 3. Environment variables: ``RAG__MCP_SERVERS__<NAME>__<FIELD>=<value>`` |
| """ |
| |
| json_path = Path("mcp_servers.json") |
| if json_path.exists(): |
| try: |
| import json as _json |
| with open(json_path) as f: |
| data = _json.load(f) |
| servers_raw = data.get("mcpServers", {}) |
| result: dict[str, dict] = {} |
| for name, cfg in servers_raw.items(): |
| if cfg.get("disabled", False): |
| continue |
| args = cfg.get("args", []) |
| |
| if isinstance(args, list): |
| args = " ".join(args) |
| result[name.lower()] = { |
| "command": cfg.get("command", ""), |
| "args": args, |
| } |
| |
| |
| |
| |
| env = cfg.get("env", {}) |
| if isinstance(env, dict) and env: |
| result[name.lower()]["env"] = { |
| str(k): str(v) for k, v in env.items() if v |
| } |
| if result: |
| return result |
| except Exception: |
| pass |
|
|
| |
| yaml_path = Path("mcp_servers.yaml") |
| if yaml_path.exists(): |
| try: |
| import yaml as _yaml |
| with open(yaml_path) as f: |
| data = _yaml.safe_load(f) or {} |
| if isinstance(data, dict) and "servers" in data: |
| return {k.lower(): v for k, v in data["servers"].items()} |
| except Exception: |
| pass |
|
|
| |
| servers: dict[str, dict] = {} |
| pattern = re.compile(rf"^{re.escape(_PREFIX)}_?MCP_SERVERS__([A-Z0-9]+)__([A-Z_]+)$") |
|
|
| def _collect(source: dict[str, str]) -> None: |
| for key, value in source.items(): |
| m = pattern.match(key) |
| if not m: |
| continue |
| server_name = m.group(1).lower() |
| field_name = m.group(2).lower() |
| servers.setdefault(server_name, {})[field_name] = value |
|
|
| _collect(dict(os.environ)) |
| env_path = Path(".env") |
| if env_path.exists(): |
| _collect(_parse_dotenv(env_path)) |
|
|
| return servers |
|
|
|
|
| def _parse_dotenv(path: Path) -> dict[str, str]: |
| """Parse a .env file into a dict (without touching os.environ).""" |
| result: dict[str, str] = {} |
| with open(path) as f: |
| for line in f: |
| line = line.strip() |
| if not line or line.startswith("#"): |
| continue |
| if "=" in line: |
| k, v = line.split("=", 1) |
| result[k.strip()] = v.strip() |
| return result |
|
|
|
|
| class Settings(BaseSettings): |
| """Root settings for Agentic RAG.""" |
|
|
| model_config = SettingsConfigDict( |
| env_file=".env", |
| env_file_encoding="utf-8", |
| env_nested_delimiter="__", |
| env_prefix="", |
| extra="ignore", |
| ) |
|
|
| |
| app_name: str = "agentic_rag" |
| debug: bool = False |
| log_level: str = "INFO" |
|
|
| |
| default_provider: str = "openai" |
| llm_providers: dict[str, LLMProviderConfig] = Field(default_factory=dict) |
|
|
| |
| embedding: EmbeddingConfig = Field(default_factory=EmbeddingConfig) |
|
|
| |
| milvus: MilvusConfig = Field(default_factory=MilvusConfig) |
| memory: MemoryConfig = Field(default_factory=MemoryConfig) |
| session: SessionConfig = Field(default_factory=SessionConfig) |
| api: APIConfig = Field(default_factory=APIConfig) |
| ocr: OCRConfig = Field(default_factory=OCRConfig) |
| voice: VoiceConfig = Field(default_factory=VoiceConfig) |
|
|
| |
| db_path: str = "data/agentic_rag.db" |
|
|
| |
| gateway: GatewayConfig = Field(default_factory=GatewayConfig) |
|
|
| |
| mcp_servers: dict[str, dict] = Field(default_factory=dict) |
|
|
| |
| workspace_dir: str = "workspace" |
|
|
| @model_validator(mode="after") |
| def _inject_dict_fields(self): |
| """Populate dict fields from env vars (pydantic-settings can't do dynamic keys).""" |
| if not self.llm_providers: |
| self.llm_providers = _parse_llm_providers_from_env() |
| if not self.mcp_servers: |
| self.mcp_servers = _parse_mcp_servers_from_env() |
| return self |
|
|
| @classmethod |
| def from_yaml(cls, yaml_path: str | Path) -> "Settings": |
| """Load settings from a YAML file, then overlay env vars.""" |
| path = Path(yaml_path) |
| if path.exists(): |
| with open(path) as f: |
| data = yaml.safe_load(f) or {} |
| else: |
| data = {} |
| return cls(**data) |
|
|
|
|
| |
| _settings: Optional[Settings] = None |
|
|
|
|
| def get_settings() -> Settings: |
| """Get the global settings instance.""" |
| global _settings |
| if _settings is None: |
| _settings = Settings() |
| return _settings |
|
|
|
|
| def init_settings(**kwargs) -> Settings: |
| """Initialize settings (called at app startup).""" |
| global _settings |
| _settings = Settings(**kwargs) |
| return _settings |
|
|