From 45db08733267ca2a500a83fd1ba3d5c897f1c326 Mon Sep 17 00:00:00 2001 From: looksaw Date: Sat, 30 May 2026 22:57:40 +0800 Subject: [PATCH 1/3] refactor(config): migrate from .env to YAML with OmegaConf (#234) Replace pydantic-settings + python-dotenv with OmegaConf-based config.yaml. Supports nested YAML structure, environment variable override (os.environ > config.yaml > defaults), auto-migration from existing .env, and hot-reload via file watcher. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 2 + hugegraph-llm/config.md | 386 +++++++++------- hugegraph-llm/pyproject.toml | 1 + .../src/hugegraph_llm/config/__init__.py | 11 + .../src/hugegraph_llm/config/admin_config.py | 11 +- .../src/hugegraph_llm/config/generate.py | 8 +- .../hugegraph_llm/config/hugegraph_config.py | 19 +- .../src/hugegraph_llm/config/index_config.py | 33 +- .../src/hugegraph_llm/config/llm_config.py | 89 +++- .../config/models/base_config.py | 424 +++++++++++++----- .../config/models/base_prompt_config.py | 4 +- .../demo/rag_demo/configs_block.py | 22 +- pyproject.toml | 1 + 13 files changed, 687 insertions(+), 324 deletions(-) diff --git a/.gitignore b/.gitignore index 1822afb7f..1f49b1a1f 100644 --- a/.gitignore +++ b/.gitignore @@ -169,6 +169,8 @@ codeium-instructions.md # Environments .env .env.bak +config.yaml +config.yaml.bak .venv .vscode .cursor diff --git a/hugegraph-llm/config.md b/hugegraph-llm/config.md index 7957ae904..b6ff9d230 100644 --- a/hugegraph-llm/config.md +++ b/hugegraph-llm/config.md @@ -2,14 +2,14 @@ 本文档详细说明了 HugeGraph LLM 项目中所有的配置选项。配置分为以下几类: -1. **基础配置**:通过 `.env` 文件管理 +1. **基础配置**:通过 `config.yaml` 文件管理(使用 OmegaConf) 2. **Prompt 配置**:通过 `config_prompt.yaml` 文件管理 3. **Docker 配置**:通过 Docker 和 Helm 配置文件管理 4. **项目配置**:通过 `pyproject.toml` 和 `JSON` 文件管理 ## 目录 -- [.env 配置文件](#env-配置文件) +- [config.yaml 配置文件](#configyaml-配置文件) - [基础配置](#基础配置) - [OpenAI 配置](#openai-配置) - [Ollama 配置](#ollama-配置) @@ -18,188 +18,223 @@ - [HugeGraph 数据库配置](#hugegraph-数据库配置) - [向量数据库配置](#向量数据库配置) - [管理员配置](#管理员配置) +- [配置优先级](#配置优先级) +- [运行时热加载](#运行时热加载) - [配置使用示例](#配置使用示例) - [配置文件位置](#配置文件位置) -## .env 配置文件 +## config.yaml 配置文件 -`.env` 文件位于 `hugegraph-llm/` 目录下,包含了系统运行所需的所有配置项。 +`config.yaml` 文件位于当前工作目录(通常是 `hugegraph-llm/`)下,使用结构化 YAML 格式存储所有配置项。配置被分为四个顶层 section:`llm`、`hugegraph`、`admin`、`index`。 ### 基础配置 -| 配置项 | 类型 | 默认值 | 说明 | -|------------------------|--------------------------------------------------------|--------|---------------------------------------| -| `LANGUAGE` | Literal["EN", "CN"] | EN | prompt语言,支持 EN(英文)和 CN(中文) | -| `CHAT_LLM_TYPE` | Literal["openai", "litellm", "ollama/local"] | openai | 聊天 LLM 类型:openai/litellm/ollama/local | -| `EXTRACT_LLM_TYPE` | Literal["openai", "litellm", "ollama/local"] | openai | 信息提取 LLM 类型 | -| `TEXT2GQL_LLM_TYPE` | Literal["openai", "litellm", "ollama/local"] | openai | 文本转 GQL LLM 类型 | -| `EMBEDDING_TYPE` | Optional[Literal["openai", "litellm", "ollama/local"]] | openai | 嵌入模型类型 | -| `RERANKER_TYPE` | Optional[Literal["cohere", "siliconflow"]] | None | 重排序模型类型:cohere/siliconflow | -| `KEYWORD_EXTRACT_TYPE` | Literal["llm", "textrank", "hybrid"] | llm | 关键词提取模型类型:llm/textrank/hybrid | -| `WINDOW_SIZE` | Optional[Integer] | 3 | TextRank 滑窗大小 (范围: 1-10),较大的窗口可以捕获更长距离的词语关系,但会增加计算复杂度 | -| `HYBRID_LLM_WEIGHTS` | Optional[Float] | 0.5 | 混合模式中 LLM 结果的权重 (范围: 0.0-1.0),TextRank 权重 = 1 - 该值。推荐 0.5 以平衡两种方法 | +```yaml +llm: + language: EN # prompt语言,支持 EN(英文)和 CN(中文) + chat_llm_type: openai # 聊天 LLM 类型:openai/litellm/ollama/local + extract_llm_type: openai # 信息提取 LLM 类型 + text2gql_llm_type: openai # 文本转 GQL LLM 类型 + embedding_type: openai # 嵌入模型类型 + reranker_type: null # 重排序模型类型:cohere/siliconflow + keyword_extract_type: llm # 关键词提取模型类型:llm/textrank/hybrid + window_size: 3 # TextRank 滑窗大小 (范围: 1-10) + hybrid_llm_weights: 0.5 # 混合模式中 LLM 结果的权重 (范围: 0.0-1.0) +``` ### OpenAI 配置 -| 配置项 | 类型 | 默认值 | 说明 | -|----------------------------------|------------------|---------------------------|---------------------------| -| `OPENAI_CHAT_API_BASE` | Optional[String] | https://api.openai.com/v1 | OpenAI 聊天 API 基础 URL | -| `OPENAI_CHAT_API_KEY` | Optional[String] | - | OpenAI 聊天 API 密钥 | -| `OPENAI_CHAT_LANGUAGE_MODEL` | Optional[String] | gpt-4.1-mini | 聊天模型名称 | -| `OPENAI_CHAT_TOKENS` | Integer | 8192 | 聊天最大令牌数 | -| `OPENAI_EXTRACT_API_BASE` | Optional[String] | https://api.openai.com/v1 | OpenAI 提取 API 基础 URL | -| `OPENAI_EXTRACT_API_KEY` | Optional[String] | - | OpenAI 提取 API 密钥 | -| `OPENAI_EXTRACT_LANGUAGE_MODEL` | Optional[String] | gpt-4.1-mini | 提取模型名称 | -| `OPENAI_EXTRACT_TOKENS` | Integer | 256 | 提取最大令牌数 | -| `OPENAI_TEXT2GQL_API_BASE` | Optional[String] | https://api.openai.com/v1 | OpenAI 文本转 GQL API 基础 URL | -| `OPENAI_TEXT2GQL_API_KEY` | Optional[String] | - | OpenAI 文本转 GQL API 密钥 | -| `OPENAI_TEXT2GQL_LANGUAGE_MODEL` | Optional[String] | gpt-4.1-mini | 文本转 GQL 模型名称 | -| `OPENAI_TEXT2GQL_TOKENS` | Integer | 4096 | 文本转 GQL 最大令牌数 | -| `OPENAI_EMBEDDING_API_BASE` | Optional[String] | https://api.openai.com/v1 | OpenAI 嵌入 API 基础 URL | -| `OPENAI_EMBEDDING_API_KEY` | Optional[String] | - | OpenAI 嵌入 API 密钥 | -| `OPENAI_EMBEDDING_MODEL` | Optional[String] | text-embedding-3-small | 嵌入模型名称 | - -#### OpenAI 环境变量 - -| 环境变量 | 对应配置项 | 说明 | -|-----------------------------|---------------------------|-------------------------------| -| `OPENAI_BASE_URL` | 所有 OpenAI API_BASE | 通用 OpenAI API 基础 URL | -| `OPENAI_API_KEY` | 所有 OpenAI API_KEY | 通用 OpenAI API 密钥 | -| `OPENAI_EMBEDDING_BASE_URL` | OPENAI_EMBEDDING_API_BASE | OpenAI 嵌入 API 基础 URL | -| `OPENAI_EMBEDDING_API_KEY` | OPENAI_EMBEDDING_API_KEY | OpenAI 嵌入 API 密钥 | -| `CO_API_URL` | COHERE_BASE_URL | Cohere API URL(环境变量 fallback) | +```yaml +llm: + openai_chat_api_base: https://api.openai.com/v1 + openai_chat_api_key: null # 建议通过环境变量 OPENAI_API_KEY 设置 + openai_chat_language_model: gpt-4.1-mini + openai_chat_tokens: 8192 + openai_extract_api_base: https://api.openai.com/v1 + openai_extract_api_key: null + openai_extract_language_model: gpt-4.1-mini + openai_extract_tokens: 256 + openai_text2gql_api_base: https://api.openai.com/v1 + openai_text2gql_api_key: null + openai_text2gql_language_model: gpt-4.1-mini + openai_text2gql_tokens: 4096 + openai_embedding_api_base: https://api.openai.com/v1 + openai_embedding_api_key: null + openai_embedding_model: text-embedding-3-small +``` + +#### OpenAI 环境变量覆盖 + +| 环境变量 | 覆盖的配置字段 | 说明 | +|---------|--------------|------| +| `OPENAI_BASE_URL` | `openai_chat_api_base`, `openai_extract_api_base`, `openai_text2gql_api_base` | 通用 OpenAI API 基础 URL | +| `OPENAI_API_KEY` | `openai_chat_api_key`, `openai_extract_api_key`, `openai_text2gql_api_key` | 通用 OpenAI API 密钥 | +| `OPENAI_EMBEDDING_BASE_URL` | `openai_embedding_api_base` | OpenAI 嵌入 API 基础 URL | +| `OPENAI_EMBEDDING_API_KEY` | `openai_embedding_api_key` | OpenAI 嵌入 API 密钥 | ### Ollama 配置 -| 配置项 | 类型 | 默认值 | 说明 | -|----------------------------------|-------------------|-----------|---------------------| -| `OLLAMA_CHAT_HOST` | Optional[String] | 127.0.0.1 | Ollama 聊天服务主机 | -| `OLLAMA_CHAT_PORT` | Optional[Integer] | 11434 | Ollama 聊天服务端口 | -| `OLLAMA_CHAT_LANGUAGE_MODEL` | Optional[String] | - | Ollama 聊天模型名称 | -| `OLLAMA_EXTRACT_HOST` | Optional[String] | 127.0.0.1 | Ollama 提取服务主机 | -| `OLLAMA_EXTRACT_PORT` | Optional[Integer] | 11434 | Ollama 提取服务端口 | -| `OLLAMA_EXTRACT_LANGUAGE_MODEL` | Optional[String] | - | Ollama 提取模型名称 | -| `OLLAMA_TEXT2GQL_HOST` | Optional[String] | 127.0.0.1 | Ollama 文本转 GQL 服务主机 | -| `OLLAMA_TEXT2GQL_PORT` | Optional[Integer] | 11434 | Ollama 文本转 GQL 服务端口 | -| `OLLAMA_TEXT2GQL_LANGUAGE_MODEL` | Optional[String] | - | Ollama 文本转 GQL 模型名称 | -| `OLLAMA_EMBEDDING_HOST` | Optional[String] | 127.0.0.1 | Ollama 嵌入服务主机 | -| `OLLAMA_EMBEDDING_PORT` | Optional[Integer] | 11434 | Ollama 嵌入服务端口 | -| `OLLAMA_EMBEDDING_MODEL` | Optional[String] | - | Ollama 嵌入模型名称 | +```yaml +llm: + ollama_chat_host: 127.0.0.1 + ollama_chat_port: 11434 + ollama_chat_language_model: null + ollama_extract_host: 127.0.0.1 + ollama_extract_port: 11434 + ollama_extract_language_model: null + ollama_text2gql_host: 127.0.0.1 + ollama_text2gql_port: 11434 + ollama_text2gql_language_model: null + ollama_embedding_host: 127.0.0.1 + ollama_embedding_port: 11434 + ollama_embedding_model: null +``` ### LiteLLM 配置 -| 配置项 | 类型 | 默认值 | 说明 | -|-----------------------------------|------------------|-------------------------------|----------------------------| -| `LITELLM_CHAT_API_KEY` | Optional[String] | - | LiteLLM 聊天 API 密钥 | -| `LITELLM_CHAT_API_BASE` | Optional[String] | - | LiteLLM 聊天 API 基础 URL | -| `LITELLM_CHAT_LANGUAGE_MODEL` | Optional[String] | openai/gpt-4.1-mini | LiteLLM 聊天模型名称 | -| `LITELLM_CHAT_TOKENS` | Integer | 8192 | 聊天最大令牌数 | -| `LITELLM_EXTRACT_API_KEY` | Optional[String] | - | LiteLLM 提取 API 密钥 | -| `LITELLM_EXTRACT_API_BASE` | Optional[String] | - | LiteLLM 提取 API 基础 URL | -| `LITELLM_EXTRACT_LANGUAGE_MODEL` | Optional[String] | openai/gpt-4.1-mini | LiteLLM 提取模型名称 | -| `LITELLM_EXTRACT_TOKENS` | Integer | 256 | 提取最大令牌数 | -| `LITELLM_TEXT2GQL_API_KEY` | Optional[String] | - | LiteLLM 文本转 GQL API 密钥 | -| `LITELLM_TEXT2GQL_API_BASE` | Optional[String] | - | LiteLLM 文本转 GQL API 基础 URL | -| `LITELLM_TEXT2GQL_LANGUAGE_MODEL` | Optional[String] | openai/gpt-4.1-mini | LiteLLM 文本转 GQL 模型名称 | -| `LITELLM_TEXT2GQL_TOKENS` | Integer | 4096 | 文本转 GQL 最大令牌数 | -| `LITELLM_EMBEDDING_API_KEY` | Optional[String] | - | LiteLLM 嵌入 API 密钥 | -| `LITELLM_EMBEDDING_API_BASE` | Optional[String] | - | LiteLLM 嵌入 API 基础 URL | -| `LITELLM_EMBEDDING_MODEL` | Optional[String] | openai/text-embedding-3-small | LiteLLM 嵌入模型名称 | +```yaml +llm: + litellm_chat_api_key: null + litellm_chat_api_base: null + litellm_chat_language_model: openai/gpt-4.1-mini + litellm_chat_tokens: 8192 + litellm_extract_api_key: null + litellm_extract_api_base: null + litellm_extract_language_model: openai/gpt-4.1-mini + litellm_extract_tokens: 256 + litellm_text2gql_api_key: null + litellm_text2gql_api_base: null + litellm_text2gql_language_model: openai/gpt-4.1-mini + litellm_text2gql_tokens: 4096 + litellm_embedding_api_key: null + litellm_embedding_api_base: null + litellm_embedding_model: openai/text-embedding-3-small +``` ### 重排序配置 -| 配置项 | 类型 | 默认值 | 说明 | -|--------------------|------------------|----------------------------------|--------------------| -| `COHERE_BASE_URL` | Optional[String] | https://api.cohere.com/v1/rerank | Cohere 重排序 API URL | -| `RERANKER_API_KEY` | Optional[String] | - | 重排序 API 密钥 | -| `RERANKER_MODEL` | Optional[String] | - | 重排序模型名称 | +```yaml +llm: + cohere_base_url: https://api.cohere.com/v1/rerank + reranker_api_key: null + reranker_model: null +``` + +#### 重排序环境变量覆盖 + +| 环境变量 | 覆盖的配置字段 | 说明 | +|---------|--------------|------| +| `CO_API_URL` | `cohere_base_url` | Cohere API URL | ### HugeGraph 数据库配置 -| 配置项 | 类型 | 默认值 | 说明 | -|------------------------|-------------------|----------------|--------------------| -| `GRAPH_URL` | Optional[String] | 127.0.0.1:8080 | HugeGraph 服务器地址 | -| `GRAPH_NAME` | Optional[String] | hugegraph | 图数据库名称 | -| `GRAPH_USER` | Optional[String] | admin | 数据库用户名 | -| `GRAPH_PWD` | Optional[String] | xxx | 数据库密码 | -| `GRAPH_SPACE` | Optional[String] | - | 图空间名称(可选) | -| `LIMIT_PROPERTY` | Optional[String] | "False" | 是否限制属性(注意:这是字符串类型) | -| `MAX_GRAPH_PATH` | Optional[Integer] | 10 | 最大图路径长度 | -| `MAX_GRAPH_ITEMS` | Optional[Integer] | 30 | 最大图项目数 | -| `EDGE_LIMIT_PRE_LABEL` | Optional[Integer] | 8 | 每个标签的边数限制 | -| `VECTOR_DIS_THRESHOLD` | Optional[Float] | 0.9 | 向量距离阈值 | -| `TOPK_PER_KEYWORD` | Optional[Integer] | 1 | 每个关键词返回的 TopK 数量 | -| `TOPK_RETURN_RESULTS` | Optional[Integer] | 20 | 返回结果数量 | +```yaml +hugegraph: + graph_url: 127.0.0.1:8080 + graph_name: hugegraph + graph_user: admin + graph_pwd: xxx + graph_space: null + limit_property: "False" # 注意:这是字符串类型,不是布尔 + max_graph_path: 10 + max_graph_items: 30 + edge_limit_pre_label: 8 + vector_dis_threshold: 0.9 + topk_per_keyword: 1 + topk_return_results: 20 +``` ### 向量数据库配置 -| 配置项 | 类型 | 默认值 | 说明 | -|------------------|------------------|-------|------------------------| -| `QDRANT_HOST` | Optional[String] | None | Qdrant 服务器主机地址 | -| `QDRANT_PORT` | Integer | 6333 | Qdrant 服务器端口 | -| `QDRANT_API_KEY` | Optional[String] | None | Qdrant API 密钥(如果设置了的话) | -| `MILVUS_HOST` | Optional[String] | None | Milvus 服务器主机地址 | -| `MILVUS_PORT` | Integer | 19530 | Milvus 服务器端口 | -| `MILVUS_USER` | String | "" | Milvus 用户名 | -| `MILVUS_PASSWORD`| String | "" | Milvus 密码 | +```yaml +index: + qdrant_host: null + qdrant_port: 6333 + qdrant_api_key: null + milvus_host: null + milvus_port: 19530 + milvus_user: "" + milvus_password: "" + cur_vector_index: Faiss +``` + +#### 向量数据库环境变量覆盖 + +| 环境变量 | 覆盖的配置字段 | 说明 | +|---------|--------------|------| +| `QDRANT_HOST` | `qdrant_host` | Qdrant 服务器主机 | +| `QDRANT_PORT` | `qdrant_port` | Qdrant 服务器端口 | +| `QDRANT_API_KEY` | `qdrant_api_key` | Qdrant API 密钥 | +| `MILVUS_HOST` | `milvus_host` | Milvus 服务器主机 | +| `MILVUS_PORT` | `milvus_port` | Milvus 服务器端口 | +| `MILVUS_USER` | `milvus_user` | Milvus 用户名 | +| `MILVUS_PASSWORD` | `milvus_password` | Milvus 密码 | +| `CUR_VECTOR_INDEX` | `cur_vector_index` | 当前向量索引类型 | ### 管理员配置 -| 配置项 | 类型 | 默认值 | 说明 | -|----------------|------------------|---------|--------------------| -| `ENABLE_LOGIN` | Optional[String] | "False" | 是否启用登录(注意:这是字符串类型) | -| `USER_TOKEN` | Optional[String] | 4321 | 用户令牌 | -| `ADMIN_TOKEN` | Optional[String] | xxxx | 管理员令牌 | +```yaml +admin: + enable_login: "False" # 注意:这是字符串类型,不是布尔 + user_token: "4321" + admin_token: "xxxx" + config_reload_interval: 5 # 热加载检测间隔(秒),0 或负数禁用 +``` + +## 配置优先级 + +配置值按以下优先级加载(高到低): + +1. **环境变量 (`os.environ`)** — 最高优先级,适用于容器/K8s 部署注入敏感信息 +2. **`config.yaml` 文件** — 持久化配置存储,通过 UI 或 CLI 修改后写入 +3. **pydantic 模型默认值** — 代码中定义的 fallback 值 + +`.env` 文件(如存在)会在启动时被加载到 `os.environ` 中,因此 `.env` 中的值优先级高于 `config.yaml`。如需从旧版 `.env` 迁移,首次启动时会自动生成 `config.yaml` 并保留原 `.env` 作为备份。 + +## 运行时热加载 + +系统在后台运行文件监控线程,定期检查 `config.yaml` 是否被外部修改: + +- **检测间隔**:由 `admin.config_reload_interval` 控制(默认 5 秒) +- **设为 0 或负数**:禁用热加载检测 +- **检测到变更时**:自动重新加载 `config.yaml`,验证配置合法性,同步到内存中的配置对象 +- **配置文件不合法时**:记录错误日志,**保留当前内存中的有效配置**,不中断运行 +- **配置文件被删除时**:回退到 pydantic 模型默认值并记录警告 ## 配置使用示例 -### 1. 基础配置示例 - -```properties -# 基础设置 -LANGUAGE=EN -CHAT_LLM_TYPE=openai -EXTRACT_LLM_TYPE=openai -TEXT2GQL_LLM_TYPE=openai -EMBEDDING_TYPE=openai - -# OpenAI 配置 -OPENAI_CHAT_API_KEY=your-openai-api-key -OPENAI_CHAT_LANGUAGE_MODEL=gpt-4.1-mini -OPENAI_EMBEDDING_API_KEY=your-openai-embedding-key -OPENAI_EMBEDDING_MODEL=text-embedding-3-small - -# HugeGraph 配置 -GRAPH_URL=127.0.0.1:8080 -GRAPH_NAME=hugegraph -GRAPH_USER=admin -GRAPH_PWD=your-password +### 1. 基础配置示例 (config.yaml) + +```yaml +llm: + language: EN + chat_llm_type: openai + extract_llm_type: openai + text2gql_llm_type: openai + embedding_type: openai + openai_chat_api_key: your-openai-api-key + openai_chat_language_model: gpt-4.1-mini + openai_embedding_model: text-embedding-3-small + +hugegraph: + graph_url: 127.0.0.1:8080 + graph_name: hugegraph + graph_user: admin + graph_pwd: your-password ``` ### 2. 使用 Ollama 的配置示例 -```properties -# 使用 Ollama -CHAT_LLM_TYPE=ollama/local -EXTRACT_LLM_TYPE=ollama/local -TEXT2GQL_LLM_TYPE=ollama/local -EMBEDDING_TYPE=ollama/local - -# Ollama 模型配置 -OLLAMA_CHAT_LANGUAGE_MODEL=llama2 -OLLAMA_EXTRACT_LANGUAGE_MODEL=llama2 -OLLAMA_TEXT2GQL_LANGUAGE_MODEL=llama2 -OLLAMA_EMBEDDING_MODEL=nomic-embed-text - -# Ollama 服务配置(如果需要自定义) -OLLAMA_CHAT_HOST=127.0.0.1 -OLLAMA_CHAT_PORT=11434 -OLLAMA_EXTRACT_HOST=127.0.0.1 -OLLAMA_EXTRACT_PORT=11434 -OLLAMA_TEXT2GQL_HOST=127.0.0.1 -OLLAMA_TEXT2GQL_PORT=11434 -OLLAMA_EMBEDDING_HOST=127.0.0.1 -OLLAMA_EMBEDDING_PORT=11434 +```yaml +llm: + chat_llm_type: ollama/local + extract_llm_type: ollama/local + text2gql_llm_type: ollama/local + embedding_type: ollama/local + ollama_chat_language_model: llama2 + ollama_extract_language_model: llama2 + ollama_text2gql_language_model: llama2 + ollama_embedding_model: nomic-embed-text ``` ### 3. 代码中使用配置 @@ -232,48 +267,59 @@ print(f"图数据库地址: {graph_config.graph_url}") print(f"数据库名称: {graph_config.graph_name}") ``` +### 4. 生成配置文件 + +```bash +# 生成包含所有默认值的 config.yaml 和 config_prompt.yaml +python -m hugegraph_llm.config.generate -U +``` + ## 注意事项 -1. **安全性**:`.env` 文件包含敏感信息(如 API 密钥),请勿将其提交到版本控制系统 -2. **配置同步**:修改配置后,系统会自动同步到 `.env` 文件 -3. **语言切换**:修改 `LANGUAGE` 配置后需要重启应用程序才能生效 -4. **模型兼容性**:确保所选的模型与你的使用场景兼容 -5. **资源限制**:根据你的硬件资源调整 `MAX_GRAPH_ITEMS`、`EDGE_LIMIT_PRE_LABEL` 等参数 -6. **类型注意**: - - `LIMIT_PROPERTY` 和 `ENABLE_LOGIN` 是字符串类型(\"False\"/\"True\"),不是布尔类型 - - `LANGUAGE`、`CHAT_LLM_TYPE` 等字段使用 Literal 类型限制可选值 - - 大部分字段都是 Optional 类型,支持 None 值,表示未设置 -7. **环境变量 Fallback**: - - OpenAI 配置支持 `OPENAI_BASE_URL` 和 `OPENAI_API_KEY` 环境变量作为 fallback - - OpenAI Embedding 支持独立的环境变量 `OPENAI_EMBEDDING_BASE_URL` 和 `OPENAI_EMBEDDING_API_KEY` +1. **安全性**:`config.yaml` 文件包含敏感信息(如 API 密钥),已加入 `.gitignore`,请勿将其提交到版本控制系统 +2. **配置持久化**:通过 Gradio UI 点击 "Apply Configuration" 后,修改会自动写入 `config.yaml` +3. **环境变量优先**:容器/K8s 部署时,通过环境变量注入密钥(如 `OPENAI_API_KEY`),环境变量值优先于 `config.yaml` 中的设置 +4. **语言切换**:修改 `language` 配置后需要重启应用程序才能生效 +5. **模型兼容性**:确保所选的模型与你的使用场景兼容 +6. **类型保留**:YAML 格式保留正确的原始类型(整数、浮点数、布尔值、null、字符串),不再是全字符串 +7. **热加载**:手动编辑 `config.yaml` 后,运行中的应用会在 `config_reload_interval` 秒内自动加载新配置,无需重启 +8. **环境变量 Fallback**: + - OpenAI 配置支持 `OPENAI_BASE_URL` 和 `OPENAI_API_KEY` 等环境变量覆盖 - Cohere 支持 `CO_API_URL` 环境变量 -8. **Ollama 配置完整性**: + - 向量数据库支持对应环境变量(`QDRANT_HOST`、`MILVUS_HOST` 等) +9. **Ollama 配置完整性**: - 每个 LLM 类型(chat、extract、text2gql)都有对应的 `*_LANGUAGE_MODEL` 配置项 - 每个服务类型都有独立的 host 和 port 配置,支持分布式部署 ## 配置文件位置 -### 系统配置(.env 文件) +### 系统配置(config.yaml 文件) -- **主配置文件**:`hugegraph-llm/.env` +- **主配置文件**:`hugegraph-llm/config.yaml` - **管理范围**: - - LLMConfig:语言、LLM 提供商配置、API 密钥等 - - HugeGraphConfig:数据库连接、查询限制等 - - AdminConfig:登录设置、令牌等 + - `llm` section:语言、LLM 提供商配置、API 密钥等 + - `hugegraph` section:数据库连接、查询限制等 + - `admin` section:登录设置、令牌、热加载间隔等 + - `index` section:向量数据库连接参数 + +### 旧版 .env 兼容 + +- **旧版文件**:`hugegraph-llm/.env` +- **迁移行为**:首次启动时若 `config.yaml` 不存在而 `.env` 存在,系统会自动将 `.env` 内容迁移到 `config.yaml`,并保留原 `.env` 作为备份 ### 提示词配置(YAML 文件) - **配置文件**:`src/hugegraph_llm/resources/demo/config_prompt.yaml` - **管理范围**: - PromptConfig:所有提示词模板、图谱模式等 - - **注意**:这些配置不存储在 .env 文件中 ### 配置类定义 - **位置**:`hugegraph-llm/src/hugegraph_llm/config/` - **基类**: - - BaseConfig:用于 .env 文件管理的配置类 - - BasePromptConfig:用于 YAML 文件管理的提示词配置类 + - BaseConfig:用于 YAML 文件管理的配置类(基于 OmegaConf + pydantic BaseModel) + - BasePromptConfig:用于提示词 YAML 文件管理的配置类 + - ConfigManager:OmegaConf 单例管理器,负责 YAML 读写、环境变量覆盖、热加载 - **UI 配置管理**:`src/hugegraph_llm/demo/rag_demo/configs_block.py` - Gradio 界面的配置管理组件 diff --git a/hugegraph-llm/pyproject.toml b/hugegraph-llm/pyproject.toml index 5b2c9f5d3..f233f1023 100644 --- a/hugegraph-llm/pyproject.toml +++ b/hugegraph-llm/pyproject.toml @@ -61,6 +61,7 @@ dependencies = [ "pydantic-settings", "apscheduler", "litellm", + "omegaconf~=2.3", "hugegraph-python-client", "pycgraph==3.2.2", ] diff --git a/hugegraph-llm/src/hugegraph_llm/config/__init__.py b/hugegraph-llm/src/hugegraph_llm/config/__init__.py index 43efb0ab3..fdcf94909 100644 --- a/hugegraph-llm/src/hugegraph_llm/config/__init__.py +++ b/hugegraph-llm/src/hugegraph_llm/config/__init__.py @@ -24,8 +24,19 @@ from .hugegraph_config import HugeGraphConfig from .index_config import IndexConfig from .llm_config import LLMConfig +from .models.base_config import ConfigManager from .prompt_config import PromptConfig +# ConfigManager must be initialized before any config objects +cfg_mgr = ConfigManager( + sections={ + "llm": LLMConfig, + "hugegraph": HugeGraphConfig, + "admin": AdminConfig, + "index": IndexConfig, + } +) + llm_settings = LLMConfig() prompt = PromptConfig(llm_settings) prompt.ensure_yaml_file_exists() diff --git a/hugegraph-llm/src/hugegraph_llm/config/admin_config.py b/hugegraph-llm/src/hugegraph_llm/config/admin_config.py index 5f6ed5761..e100d0964 100644 --- a/hugegraph-llm/src/hugegraph_llm/config/admin_config.py +++ b/hugegraph-llm/src/hugegraph_llm/config/admin_config.py @@ -15,7 +15,7 @@ # specific language governing permissions and limitations # under the License. -from typing import Optional +from typing import ClassVar, Optional from .models import BaseConfig @@ -23,6 +23,15 @@ class AdminConfig(BaseConfig): """Admin settings""" + _config_section: ClassVar[str] = "admin" + + _flat_to_nested_mapping: ClassVar[dict] = { + "enable_login": "login.enable", + "user_token": "login.user_token", + "admin_token": "login.admin_token", + } + enable_login: Optional[str] = "False" user_token: Optional[str] = "4321" admin_token: Optional[str] = "xxxx" + config_reload_interval: int = 5 diff --git a/hugegraph-llm/src/hugegraph_llm/config/generate.py b/hugegraph-llm/src/hugegraph_llm/config/generate.py index 162822959..eeef6b741 100644 --- a/hugegraph-llm/src/hugegraph_llm/config/generate.py +++ b/hugegraph-llm/src/hugegraph_llm/config/generate.py @@ -31,8 +31,8 @@ parser.add_argument("-U", "--update", default=True, action="store_true", help="Update the config file") args = parser.parse_args() if args.update: - huge_settings.generate_env() - admin_settings.generate_env() - llm_settings.generate_env() - index_settings.generate_env() + huge_settings.generate_yaml() + admin_settings.generate_yaml() + llm_settings.generate_yaml() + index_settings.generate_yaml() PromptConfig(llm_settings).generate_yaml_file() diff --git a/hugegraph-llm/src/hugegraph_llm/config/hugegraph_config.py b/hugegraph-llm/src/hugegraph_llm/config/hugegraph_config.py index 1937eda10..3202f3e8a 100644 --- a/hugegraph-llm/src/hugegraph_llm/config/hugegraph_config.py +++ b/hugegraph-llm/src/hugegraph_llm/config/hugegraph_config.py @@ -15,7 +15,7 @@ # specific language governing permissions and limitations # under the License. -from typing import Optional +from typing import ClassVar, Optional from .models import BaseConfig @@ -23,6 +23,23 @@ class HugeGraphConfig(BaseConfig): """HugeGraph settings""" + _config_section: ClassVar[str] = "hugegraph" + + _flat_to_nested_mapping: ClassVar[dict] = { + "graph_url": "graph.url", + "graph_name": "graph.name", + "graph_user": "graph.user", + "graph_pwd": "graph.pwd", + "graph_space": "graph.space", + "limit_property": "query.limit_property", + "max_graph_path": "query.max_graph_path", + "max_graph_items": "query.max_graph_items", + "edge_limit_pre_label": "query.edge_limit_pre_label", + "vector_dis_threshold": "vector.dis_threshold", + "topk_per_keyword": "vector.topk_per_keyword", + "topk_return_results": "rerank.topk_return_results", + } + # graph server config graph_url: str = "127.0.0.1:8080" graph_name: str = "hugegraph" diff --git a/hugegraph-llm/src/hugegraph_llm/config/index_config.py b/hugegraph-llm/src/hugegraph_llm/config/index_config.py index 346f5f03c..6e5758b11 100644 --- a/hugegraph-llm/src/hugegraph_llm/config/index_config.py +++ b/hugegraph-llm/src/hugegraph_llm/config/index_config.py @@ -15,22 +15,33 @@ # specific language governing permissions and limitations # under the License. -import os -from typing import Optional +from typing import ClassVar, Optional from .models import BaseConfig class IndexConfig(BaseConfig): - """LLM settings""" + """Vector index settings""" - qdrant_host: Optional[str] = os.environ.get("QDRANT_HOST", None) - qdrant_port: int = int(os.environ.get("QDRANT_PORT", "6333")) - qdrant_api_key: Optional[str] = os.environ.get("QDRANT_API_KEY") if os.environ.get("QDRANT_API_KEY") else None + _config_section: ClassVar[str] = "index" - milvus_host: Optional[str] = os.environ.get("MILVUS_HOST", None) - milvus_port: int = int(os.environ.get("MILVUS_PORT", "19530")) - milvus_user: str = os.environ.get("MILVUS_USER", "") - milvus_password: str = os.environ.get("MILVUS_PASSWORD", "") + _flat_to_nested_mapping: ClassVar[dict] = { + "qdrant_host": "qdrant.host", + "qdrant_port": "qdrant.port", + "qdrant_api_key": "qdrant.api_key", + "milvus_host": "milvus.host", + "milvus_port": "milvus.port", + "milvus_user": "milvus.user", + "milvus_password": "milvus.password", + } - cur_vector_index: str = os.environ.get("CUR_VECTOR_INDEX", "Faiss") + qdrant_host: Optional[str] = None + qdrant_port: int = 6333 + qdrant_api_key: Optional[str] = None + + milvus_host: Optional[str] = None + milvus_port: int = 19530 + milvus_user: str = "" + milvus_password: str = "" + + cur_vector_index: str = "Faiss" diff --git a/hugegraph-llm/src/hugegraph_llm/config/llm_config.py b/hugegraph-llm/src/hugegraph_llm/config/llm_config.py index fd2c82303..d698d0949 100644 --- a/hugegraph-llm/src/hugegraph_llm/config/llm_config.py +++ b/hugegraph-llm/src/hugegraph_llm/config/llm_config.py @@ -16,8 +16,7 @@ # under the License. -import os -from typing import Literal, Optional +from typing import ClassVar, Literal, Optional from .models import BaseConfig @@ -25,6 +24,65 @@ class LLMConfig(BaseConfig): """LLM settings""" + _config_section: ClassVar[str] = "llm" + + _flat_to_nested_mapping: ClassVar[dict] = { + "openai_chat_api_base": "openai.chat.api_base", + "openai_chat_api_key": "openai.chat.api_key", + "openai_chat_language_model": "openai.chat.language_model", + "openai_chat_tokens": "openai.chat.tokens", + "openai_extract_api_base": "openai.extract.api_base", + "openai_extract_api_key": "openai.extract.api_key", + "openai_extract_language_model": "openai.extract.language_model", + "openai_extract_tokens": "openai.extract.tokens", + "openai_text2gql_api_base": "openai.text2gql.api_base", + "openai_text2gql_api_key": "openai.text2gql.api_key", + "openai_text2gql_language_model": "openai.text2gql.language_model", + "openai_text2gql_tokens": "openai.text2gql.tokens", + "openai_embedding_api_base": "openai.embedding.api_base", + "openai_embedding_api_key": "openai.embedding.api_key", + "openai_embedding_model": "openai.embedding.model", + "ollama_chat_host": "ollama.chat.host", + "ollama_chat_port": "ollama.chat.port", + "ollama_chat_language_model": "ollama.chat.language_model", + "ollama_extract_host": "ollama.extract.host", + "ollama_extract_port": "ollama.extract.port", + "ollama_extract_language_model": "ollama.extract.language_model", + "ollama_text2gql_host": "ollama.text2gql.host", + "ollama_text2gql_port": "ollama.text2gql.port", + "ollama_text2gql_language_model": "ollama.text2gql.language_model", + "ollama_embedding_host": "ollama.embedding.host", + "ollama_embedding_port": "ollama.embedding.port", + "ollama_embedding_model": "ollama.embedding.model", + "litellm_chat_api_key": "litellm.chat.api_key", + "litellm_chat_api_base": "litellm.chat.api_base", + "litellm_chat_language_model": "litellm.chat.language_model", + "litellm_chat_tokens": "litellm.chat.tokens", + "litellm_extract_api_key": "litellm.extract.api_key", + "litellm_extract_api_base": "litellm.extract.api_base", + "litellm_extract_language_model": "litellm.extract.language_model", + "litellm_extract_tokens": "litellm.extract.tokens", + "litellm_text2gql_api_key": "litellm.text2gql.api_key", + "litellm_text2gql_api_base": "litellm.text2gql.api_base", + "litellm_text2gql_language_model": "litellm.text2gql.language_model", + "litellm_text2gql_tokens": "litellm.text2gql.tokens", + "litellm_embedding_api_key": "litellm.embedding.api_key", + "litellm_embedding_api_base": "litellm.embedding.api_base", + "litellm_embedding_model": "litellm.embedding.model", + } + + _env_var_map: ClassVar[dict] = { + "openai_chat_api_key": "OPENAI_API_KEY", + "openai_chat_api_base": "OPENAI_BASE_URL", + "openai_extract_api_key": "OPENAI_API_KEY", + "openai_extract_api_base": "OPENAI_BASE_URL", + "openai_text2gql_api_key": "OPENAI_API_KEY", + "openai_text2gql_api_base": "OPENAI_BASE_URL", + "openai_embedding_api_key": "OPENAI_EMBEDDING_API_KEY", + "openai_embedding_api_base": "OPENAI_EMBEDDING_BASE_URL", + "cohere_base_url": "CO_API_URL", + } + language: Literal["EN", "CN"] = "EN" chat_llm_type: Literal["openai", "litellm", "ollama/local"] = "openai" extract_llm_type: Literal["openai", "litellm", "ollama/local"] = "openai" @@ -34,28 +92,27 @@ class LLMConfig(BaseConfig): keyword_extract_type: Literal["llm", "textrank", "hybrid"] = "llm" window_size: Optional[int] = 3 hybrid_llm_weights: Optional[float] = 0.5 - # TODO: divide RAG part if necessary - # 1. OpenAI settings - openai_chat_api_base: Optional[str] = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1") - openai_chat_api_key: Optional[str] = os.environ.get("OPENAI_API_KEY") + # OpenAI + openai_chat_api_base: Optional[str] = "https://api.openai.com/v1" + openai_chat_api_key: Optional[str] = None openai_chat_language_model: Optional[str] = "gpt-4.1-mini" - openai_extract_api_base: Optional[str] = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1") - openai_extract_api_key: Optional[str] = os.environ.get("OPENAI_API_KEY") + openai_extract_api_base: Optional[str] = "https://api.openai.com/v1" + openai_extract_api_key: Optional[str] = None openai_extract_language_model: Optional[str] = "gpt-4.1-mini" - openai_text2gql_api_base: Optional[str] = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1") - openai_text2gql_api_key: Optional[str] = os.environ.get("OPENAI_API_KEY") + openai_text2gql_api_base: Optional[str] = "https://api.openai.com/v1" + openai_text2gql_api_key: Optional[str] = None openai_text2gql_language_model: Optional[str] = "gpt-4.1-mini" - openai_embedding_api_base: Optional[str] = os.environ.get("OPENAI_EMBEDDING_BASE_URL", "https://api.openai.com/v1") - openai_embedding_api_key: Optional[str] = os.environ.get("OPENAI_EMBEDDING_API_KEY") + openai_embedding_api_base: Optional[str] = "https://api.openai.com/v1" + openai_embedding_api_key: Optional[str] = None openai_embedding_model: Optional[str] = "text-embedding-3-small" openai_chat_tokens: int = 8192 openai_extract_tokens: int = 256 openai_text2gql_tokens: int = 4096 - # 2. Rerank settings - cohere_base_url: Optional[str] = os.environ.get("CO_API_URL", "https://api.cohere.com/v1/rerank") + # Rerank + cohere_base_url: Optional[str] = "https://api.cohere.com/v1/rerank" reranker_api_key: Optional[str] = None reranker_model: Optional[str] = None - # 3. Ollama settings + # Ollama ollama_chat_host: Optional[str] = "127.0.0.1" ollama_chat_port: Optional[int] = 11434 ollama_chat_language_model: Optional[str] = None @@ -68,7 +125,7 @@ class LLMConfig(BaseConfig): ollama_embedding_host: Optional[str] = "127.0.0.1" ollama_embedding_port: Optional[int] = 11434 ollama_embedding_model: Optional[str] = None - # 4. LiteLLM settings + # LiteLLM litellm_chat_api_key: Optional[str] = None litellm_chat_api_base: Optional[str] = None litellm_chat_language_model: Optional[str] = "openai/gpt-4.1-mini" diff --git a/hugegraph-llm/src/hugegraph_llm/config/models/base_config.py b/hugegraph-llm/src/hugegraph_llm/config/models/base_config.py index 5fec3a778..17d6d55c9 100644 --- a/hugegraph-llm/src/hugegraph_llm/config/models/base_config.py +++ b/hugegraph-llm/src/hugegraph_llm/config/models/base_config.py @@ -16,130 +16,342 @@ # under the License. +import collections.abc import os +import threading +import time +from typing import ClassVar, Optional -from dotenv import dotenv_values, set_key -from pydantic_settings import BaseSettings +from dotenv import dotenv_values +from omegaconf import DictConfig, OmegaConf +from pydantic import BaseModel, ConfigDict, TypeAdapter from hugegraph_llm.utils.log import log dir_name = os.path.dirname -env_path = os.path.join(os.getcwd(), ".env") # Load .env from the current working directory +YAML_PATH = os.path.join(os.getcwd(), "config.yaml") +ENV_PATH = os.path.join(os.getcwd(), ".env") -class BaseConfig(BaseSettings): - class Config: - env_file = env_path - case_sensitive = False - extra = "ignore" # ignore extra fields to avoid ValidationError - env_ignore_empty = True +def _flat_to_nested(flat_dict: dict, mapping: dict) -> dict: + """Convert flat field names to nested dict using dot-notation mapping. - def generate_env(self): - if os.path.exists(env_path): + Mapping: {"flat_name": "nested.path.key", ...} + Fields not in the mapping are kept at the top level. + """ + if not mapping: + return flat_dict + result: dict = {} + for field_name, value in flat_dict.items(): + if field_name in mapping: + path = mapping[field_name] + parts = path.split(".") + d = result + for part in parts[:-1]: + if part not in d: + d[part] = {} + d = d[part] + d[parts[-1]] = value + else: + result[field_name] = value + return result + + +def _nested_to_flat(nested_dict: dict, mapping: dict) -> dict: + """Convert nested dict from YAML to flat field names using dot-notation mapping. + + Reverse of _flat_to_nested. Walks the nested dict, matching dot-joined + paths against the mapping keys. + """ + if not mapping or not nested_dict: + return nested_dict + reverse_map = {v: k for k, v in mapping.items()} + result = {} + + def _walk(prefix: str, d: dict) -> None: + for key, value in d.items(): + full_key = f"{prefix}.{key}" if prefix else key + if full_key in reverse_map: + result[reverse_map[full_key]] = value + elif isinstance(value, collections.abc.Mapping): + _walk(full_key, value) + else: + result[full_key.replace(".", "_")] = value + + _walk("", nested_dict) + return result + + +class ConfigManager: + """Singleton manager for OmegaConf-based YAML configuration. + + Lifecycle: + 1. __init__: load config.yaml or migrate from .env, start file watcher + 2. Config classes read via get_section_with_env_override() + 3. Config classes write via update_section() + save() + 4. Background watcher polls for external changes → reload() + """ + + _instance: ClassVar[Optional["ConfigManager"]] = None + + def __new__(cls, sections=None): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self, sections=None): + if self._initialized: + return + self._initialized = True + self._yaml_path = YAML_PATH + self._env_path = ENV_PATH + self._sections: dict = sections or {} + self._cfg: DictConfig = OmegaConf.create({}) + self._reload_lock = threading.Lock() + self._watching = False + self._watcher_thread: Optional[threading.Thread] = None + self._last_mtime: float = 0.0 + self._reload_targets: list = [] # (section_name, config_object) tuples + + # Load .env into os.environ for backward compatibility and priority override + if os.path.exists(self._env_path): + for k, v in dotenv_values(self._env_path).items(): + os.environ[k] = v + + # Load or migrate + if os.path.exists(self._yaml_path): + self._cfg = OmegaConf.load(self._yaml_path) + log.info("Loaded config from %s", self._yaml_path) + elif os.path.exists(self._env_path): + self._cfg = self._migrate_from_env() + else: + self._cfg = OmegaConf.create({}) + log.info("No config file found, using defaults") + + self._start_file_watcher() + + def _migrate_from_env(self) -> DictConfig: + """Migrate .env to config.yaml with type conversion and nested structure. + + Only fields present in .env are written to YAML. + """ + env_data = dotenv_values(self._env_path) + cfg = OmegaConf.create({}) + for section_name, model_class in self._sections.items(): + model_fields = set(model_class.model_fields.keys()) + mapping = getattr(model_class, "_flat_to_nested_mapping", {}) + section_data = {} + for env_key, env_value in env_data.items(): + lower_key = env_key.lower() + if lower_key in model_fields and env_value: + section_data[lower_key] = env_value + if section_data: + instance = model_class(**section_data) + full_dump = instance.model_dump() + filtered = {k: v for k, v in full_dump.items() if k in section_data} + nested = _flat_to_nested(filtered, mapping) + cfg[section_name] = OmegaConf.create(nested) + else: + cfg[section_name] = OmegaConf.create({}) + OmegaConf.save(cfg, self._yaml_path) + log.info("Migrated %s to %s", self._env_path, self._yaml_path) + return cfg + + def load(self) -> DictConfig: + """Re-read config from disk.""" + return OmegaConf.load(self._yaml_path) + + def save(self) -> None: + """Persist current config tree to disk.""" + OmegaConf.save(self._cfg, self._yaml_path) + + def get_section(self, name: str) -> DictConfig: + """Get a config section, creating it if it does not exist.""" + if name not in self._cfg: + self._cfg[name] = OmegaConf.create({}) + return self._cfg[name] + + def get_section_with_env_override(self, section_name: str, model_class: type) -> dict: + """Load section from YAML, convert nested→flat, then override with env vars. + + Priority: os.environ > config.yaml > pydantic defaults. + """ + yaml_section = self.get_section(section_name) + raw_dict = OmegaConf.to_container(yaml_section, resolve=True) if yaml_section else {} + if raw_dict is None: + raw_dict = {} + + mapping = getattr(model_class, "_flat_to_nested_mapping", {}) + section_dict = _nested_to_flat(raw_dict, mapping) + + env_var_map: dict = getattr(model_class, "_env_var_map", {}) + + for field_name in model_class.model_fields: + env_var_name = env_var_map.get(field_name, field_name.upper()) + env_value = os.environ.get(env_var_name) + if env_value is not None and env_value != "": + try: + field_info = model_class.model_fields[field_name] + ta = TypeAdapter(field_info.annotation) + section_dict[field_name] = ta.validate_python(env_value) + except Exception: + section_dict[field_name] = env_value + + return section_dict + + def update_section(self, name: str, model: BaseModel) -> None: + """Sync pydantic model fields into OmegaConf section with nested structure.""" + model_dict = model.model_dump() + mapping = getattr(type(model), "_flat_to_nested_mapping", {}) + nested_dict = _flat_to_nested(model_dict, mapping) + if name not in self._cfg: + self._cfg[name] = OmegaConf.create({}) + self._cfg[name] = OmegaConf.create(nested_dict) + + def register_reload_target(self, section_name: str, config_object: BaseModel) -> None: + """Register a config object for automatic sync on hot-reload.""" + self._reload_targets.append((section_name, config_object)) + + def reload(self) -> bool: + """Hot-reload config from YAML, validate, and sync to registered objects. + + Returns True on success. On failure, keeps current in-memory config. + """ + with self._reload_lock: + try: + new_cfg = OmegaConf.load(self._yaml_path) + for section_name, model_class in self._sections.items(): + if section_name in new_cfg: + raw_dict = OmegaConf.to_container(new_cfg[section_name], resolve=True) + if raw_dict: + mapping = getattr(model_class, "_flat_to_nested_mapping", {}) + flat_dict = _nested_to_flat(raw_dict, mapping) + model_class(**flat_dict) + self._cfg = new_cfg + for section_name, config_obj in self._reload_targets: + try: + config_obj.check_config() + except Exception as e: + log.error("Failed to sync '%s' on reload: %s", section_name, e) + log.info("Config reloaded from %s", self._yaml_path) + return True + except Exception as e: + log.error("Failed to reload config: %s. Keeping current values.", e) + return False + + def _start_file_watcher(self) -> None: + """Start background daemon thread polling for config file changes.""" + admin_section = self.get_section("admin") + interval = admin_section.get("config_reload_interval", 5) if admin_section else 5 + if not isinstance(interval, (int, float)) or interval <= 0: + log.info("Config hot-reload disabled (config_reload_interval=%s)", interval) + return + + self._watching = True + self._last_mtime = os.path.getmtime(self._yaml_path) if os.path.exists(self._yaml_path) else 0.0 + + def _watch_loop(): + while self._watching: + time.sleep(interval) + try: + if not os.path.exists(self._yaml_path): + continue + current_mtime = os.path.getmtime(self._yaml_path) + if current_mtime != self._last_mtime: + self._last_mtime = current_mtime + log.info("Config file change detected, reloading...") + self.reload() + except Exception as e: + log.error("File watcher error: %s", e) + + self._watcher_thread = threading.Thread(target=_watch_loop, daemon=True) + self._watcher_thread.start() + log.info("Config file watcher started (interval=%ss)", interval) + + def _stop_file_watcher(self) -> None: + """Stop the background file watcher thread.""" + self._watching = False + + +class BaseConfig(BaseModel): + """Base configuration class using OmegaConf/YAML for persistence. + + Subclasses must define: + - _config_section: ClassVar[str] — YAML section name ("llm", "hugegraph", etc.) + - _flat_to_nested_mapping: ClassVar[dict] — flat field → nested path mapping + """ + + model_config = ConfigDict(extra="ignore") + + _config_section: ClassVar[str] = "" + _flat_to_nested_mapping: ClassVar[dict] = {} + + def __init__(self, **data): + cfg_mgr = ConfigManager() + yaml_data = {} + if self._config_section: + yaml_data = cfg_mgr.get_section_with_env_override(self._config_section, type(self)) + yaml_data.update(data) + super().__init__(**yaml_data) + if self._config_section: + cfg_mgr.update_section(self._config_section, self) + cfg_mgr.save() + cfg_mgr.register_reload_target(self._config_section, self) + log.info("Config section '%s' initialized.", self._config_section) + + def update_config(self) -> None: + """Persist current pydantic field values to config.yaml.""" + cfg_mgr = ConfigManager() + cfg_mgr.update_section(self._config_section, self) + cfg_mgr.save() + log.info("Config section '%s' updated and saved.", self._config_section) + + def generate_yaml(self) -> None: + """Generate config.yaml section with current model default values.""" + cfg_mgr = ConfigManager() + if os.path.exists(YAML_PATH): log.info( "%s already exists, do you want to override with the default configuration? (y/n)", - env_path, + YAML_PATH, ) update = input() if update.lower() != "y": return - self.update_env() - else: - config_dict = self.model_dump() - config_dict = {k.upper(): v for k, v in config_dict.items()} - with open(env_path, "w", encoding="utf-8") as f: - for k, v in config_dict.items(): - if v is None: - f.write(f"{k}=\n") - else: - f.write(f"{k}={v}\n") - log.info("Generate %s successfully!", env_path) - - def update_env(self): - config_dict = self.model_dump() - config_dict = {k.upper(): v for k, v in config_dict.items()} - env_config = dotenv_values(f"{env_path}") - - # dotenv_values make None to '', while pydantic make None to None - # dotenv_values make integer to string, while pydantic make integer to integer - for k, v in config_dict.items(): - if k in env_config: - if not (env_config[k] or v): - continue - if env_config[k] == str(v): - continue - log.info("Update %s: %s=%s", env_path, k, v) - set_key(env_path, k, v if v else "", quote_mode="never") - - def check_env(self): - """Synchronize configs between .env file and object. - - This method performs two steps: - 1. Updates object attributes from .env file values when they differ - 2. Adds missing configuration items to the .env file - """ + cfg_mgr.update_section(self._config_section, self) + cfg_mgr.save() + log.info("Generated %s section '%s' successfully!", YAML_PATH, self._config_section) + + def check_config(self) -> None: + """Synchronize config from YAML file to object attributes.""" + cfg_mgr = ConfigManager() try: - # Read the.env file and prepare object config - env_config = dotenv_values(env_path) - config_dict = {k.upper(): v for k, v in self.model_dump().items()} - - # Step 1: Update the object from .env when values differ - self._sync_env_to_object(env_config, config_dict) - # Step 2: Add missing config items to .env - self._sync_object_to_env(env_config, config_dict) + yaml_data = cfg_mgr.get_section_with_env_override(self._config_section, type(self)) + for key, value in yaml_data.items(): + current = getattr(self, key, None) + if current != value: + log.info( + "Update configuration from file: %s=%s (was: %s)", + key, + value, + current, + ) + setattr(self, key, value) + cfg_mgr.update_section(self._config_section, self) + cfg_mgr.save() except Exception as e: - log.error("An error occurred when checking the .env variable file: %s", str(e)) + log.error("An error occurred when checking config file: %s", e) raise - def _sync_env_to_object(self, env_config, config_dict): - """Update object attributes from .env file values when they differ.""" - for env_key, env_value in env_config.items(): - if env_key in config_dict: - obj_value = config_dict[env_key] - obj_value_str = str(obj_value) if obj_value is not None else "" + # Backward-compatible aliases for existing consumer code + def update_env(self) -> None: + """[Deprecated] Use update_config() instead.""" + self.update_config() - if env_value != obj_value_str: - log.info( - "Update configuration from the file: %s=%s (Original value: %s)", - env_key, - env_value, - obj_value_str, - ) - # Update the object attribute (using lowercase key) - setattr(self, env_key.lower(), env_value) - - def _sync_object_to_env(self, env_config, config_dict): - """Add missing configuration items to the .env file.""" - for obj_key, obj_value in config_dict.items(): - if obj_key not in env_config: - obj_value_str = str(obj_value) if obj_value is not None else "" - log.info( - "Add configuration items to the environment variable file: %s=%s", - obj_key, - obj_value, - ) - # Add to .env - set_key(env_path, obj_key, obj_value_str, quote_mode="never") + def generate_env(self) -> None: + """[Deprecated] Use generate_yaml() instead.""" + self.generate_yaml() - def __init__(self, **data): - try: - file_exists = os.path.exists(env_path) - # Step 1: Load environment variables if file exists - if file_exists: - env_config = dotenv_values(env_path) - for k, v in env_config.items(): - os.environ[k] = v - - # Step 2: Init the parent class with loaded environment variables - super().__init__(**data) - # Step 3: Handle environment file operations after initialization - if not file_exists: - self.generate_env() - else: - # Synchronize configurations between the object and .env file - self.check_env() - - log.info("The %s file was loaded. Class: %s", env_path, self.__class__.__name__) - except Exception as e: - log.error("An error occurred when initializing the configuration object: %s", str(e)) - raise + def check_env(self) -> None: + """[Deprecated] Use check_config() instead.""" + self.check_config() diff --git a/hugegraph-llm/src/hugegraph_llm/config/models/base_prompt_config.py b/hugegraph-llm/src/hugegraph_llm/config/models/base_prompt_config.py index 57d8ba638..34aec6cbc 100644 --- a/hugegraph-llm/src/hugegraph_llm/config/models/base_prompt_config.py +++ b/hugegraph-llm/src/hugegraph_llm/config/models/base_prompt_config.py @@ -74,7 +74,7 @@ def ensure_yaml_file_exists(self): for key, value in data.items(): setattr(self, key, value) - # Check if the language in the .env file matches the language in the YAML file + # Check if the language in config.yaml matches the language in the YAML file env_lang = ( self.llm_settings.language.lower() if hasattr(self, "llm_settings") and self.llm_settings.language @@ -83,7 +83,7 @@ def ensure_yaml_file_exists(self): yaml_lang = data.get("_language_generated", "en").lower() if env_lang.strip() != yaml_lang.strip(): log.warning( - "Prompt was changed '.env' language is '%s', " + "Prompt was changed 'config.yaml' language is '%s', " "but '%s' was generated for '%s'. " "Regenerating the prompt file...", env_lang, diff --git a/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/configs_block.py b/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/configs_block.py index 72c1eec06..d9cd31c50 100644 --- a/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/configs_block.py +++ b/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/configs_block.py @@ -16,13 +16,11 @@ # under the License. import json -import os from functools import partial from typing import Optional import gradio as gr import requests -from dotenv import dotenv_values from requests.auth import HTTPBasicAuth from hugegraph_llm.config import huge_settings, index_settings, llm_settings @@ -108,7 +106,7 @@ def apply_vector_engine(engine: str): # Persist the vector engine selection setattr(index_settings, "cur_vector_index", engine) try: - index_settings.update_env() + index_settings.update_config() except Exception: # pylint: disable=W0718 pass gr.Info("Configured!") @@ -172,7 +170,7 @@ def apply_vector_engine_backend( # pylint: disable=too-many-branches index_settings.qdrant_api_key = api_key or None try: - index_settings.update_env() + index_settings.update_config() except Exception: # pylint: disable=W0718 pass gr.Info("Configured!") @@ -200,7 +198,7 @@ def apply_embedding_config(arg1, arg2, arg3, origin_call=None) -> int: llm_settings.litellm_embedding_api_base = arg2 llm_settings.litellm_embedding_model = arg3 status_code = test_litellm_embedding(arg1, arg2, arg3) - llm_settings.update_env() + llm_settings.update_config() gr.Info("Configured!") return status_code @@ -238,7 +236,7 @@ def apply_reranker_config( headers=headers, origin_call=origin_call, ) - llm_settings.update_env() + llm_settings.update_config() gr.Info("Configured!") return status_code @@ -261,7 +259,7 @@ def apply_graph_config(url, name, user, pwd, gs, origin_call=None) -> int: auth = HTTPBasicAuth(user, pwd) # for http api return status response = test_api_connection(test_url, auth=auth, origin_call=origin_call) - huge_settings.update_env() + huge_settings.update_config() return response @@ -308,7 +306,7 @@ def apply_llm_config( status_code = test_litellm_chat(api_key_or_host, api_base_or_port, model_name, int(max_tokens)) gr.Info("Configured!") - llm_settings.update_env() + llm_settings.update_config() return status_code @@ -428,11 +426,9 @@ def chat_llm_settings(llm_type): llm_config_input = [gr.Textbox(value="", visible=False) for _ in range(4)] llm_config_button = gr.Button("Apply configuration") llm_config_button.click(apply_llm_config_with_chat_op, inputs=llm_config_input) - # Determine whether there are Settings in the.env file - env_path = os.path.join(os.getcwd(), ".env") # Load .env from the current working directory - env_vars = dotenv_values(env_path) - api_extract_key = env_vars.get("OPENAI_EXTRACT_API_KEY") - api_text2sql_key = env_vars.get("OPENAI_TEXT2GQL_API_KEY") + # Determine whether API keys are set in config + api_extract_key = llm_settings.openai_extract_api_key + api_text2sql_key = llm_settings.openai_text2gql_api_key if not api_extract_key: llm_config_button.click(apply_llm_config_with_text2gql_op, inputs=llm_config_input) if not api_text2sql_key: diff --git a/pyproject.toml b/pyproject.toml index faa8c73c2..2bed4a73d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -127,6 +127,7 @@ constraint-dependencies = [ "pydantic-settings~=2.6.1", "apscheduler~=3.10.4", "litellm~=1.61.13", + "omegaconf~=2.3", # ML dependencies From 6633c7a86fb3df7e4faa71b6ef691f000f88e855 Mon Sep 17 00:00:00 2001 From: looksaw Date: Sat, 30 May 2026 23:00:51 +0800 Subject: [PATCH 2/3] docs(workflow): update design and tasks for nested YAML implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update design.md to reflect the nested YAML structure (via flat↔nested mapping), update tasks.md to mark all 10 task groups completed, and document implementation differences from the original plan. Co-Authored-By: Claude Opus 4.7 --- .workflow/yaml-config-migration/design.md | 551 ++++++++++++++++++ .../yaml-config-migration/requirements.md | 105 ++++ .workflow/yaml-config-migration/tasks.md | 71 +++ 3 files changed, 727 insertions(+) create mode 100644 .workflow/yaml-config-migration/design.md create mode 100644 .workflow/yaml-config-migration/requirements.md create mode 100644 .workflow/yaml-config-migration/tasks.md diff --git a/.workflow/yaml-config-migration/design.md b/.workflow/yaml-config-migration/design.md new file mode 100644 index 000000000..b45898a1a --- /dev/null +++ b/.workflow/yaml-config-migration/design.md @@ -0,0 +1,551 @@ +# 设计文档: 将 .env 配置迁移到 YAML (使用 OmegaConf) + +## 1. 概述 + +### 1.1. 目标 + +将 hugegraph-llm 的配置存储从扁平 `.env` (pydantic-settings + python-dotenv) 迁移到结构化 `config.yaml` (OmegaConf),同时保持现有 Python API 完全兼容,并支持运行时热加载。 + +### 1.2. 范围 + +- **In-Scope**: + - 引入 OmegaConf 作为 YAML 配置读写框架 + - 重构 `BaseConfig`,从 `BaseSettings` 迁移到 `BaseModel` + - 创建 `ConfigManager` 单例管理 YAML 生命周期 + - 首次启动时自动从 `.env` 迁移到 `config.yaml` + - 运行时文件变更检测与自动重载(轮询方式) + - 环境变量 fallback(API Key 等敏感信息) + - Gradio UI 中 `update_env()` → `update_config()` 替换 + - CLI `generate.py` 适配 + - `config.md` 文档更新 +- **Out-of-Scope**: + - 配置版本迁移策略(如 config schema 变更时的自动升级) + - 多配置文件支持 + +### 1.3. 关联需求 + +关联 `.workflow/yaml-config-migration/requirements.md` 中全部 9 个功能需求 (U1-U11, E1-E12, X1-X4)。 + +## 2. 整体架构 + +### 2.1. 架构图 + +**系统启动流程 (序列图)**: + +```mermaid +sequenceDiagram + participant Main as __init__.py + participant CM as ConfigManager + participant FS as File System + participant BC as BaseConfig (LLMConfig/...) + participant Pydantic as pydantic BaseModel + + Main->>CM: ConfigManager() + CM->>FS: config.yaml 存在? + alt config.yaml 存在 + FS-->>CM: 返回 YAML 内容 + CM->>CM: OmegaConf.load(config.yaml) + else config.yaml 不存在, .env 存在 + FS-->>CM: 返回 .env 内容 + CM->>CM: _migrate_from_env() + CM->>CM: OmegaConf.save(config.yaml) + Note over CM: 保留 .env 作为备份 + else 两者都不存在 + CM->>CM: 使用空 OmegaConf DictConfig + end + CM->>CM: _start_file_watcher() (后台线程) + + Main->>BC: LLMConfig() + BC->>CM: get_section_with_env_override("llm") + CM->>CM: _nested_to_flat() # 嵌套 YAML → 扁平 dict + CM-->>BC: 扁平 dict (+ env var 覆盖) + BC->>Pydantic: BaseModel.__init__(**flat_values) + Pydantic-->>BC: 校验后的字段值 + BC->>CM: update_section("llm", self) + CM->>CM: _flat_to_nested() # 扁平 → 嵌套写入 YAML + Note over BC,CM: 写回缺失的默认值到 YAML + CM->>FS: OmegaConf.save(config.yaml) + + Main->>BC: HugeGraphConfig() / AdminConfig() / IndexConfig() + Note over Main,BC: 同上流程 +``` + +**配置修改流程 (Gradio UI)**: + +```mermaid +sequenceDiagram + participant UI as Gradio UI + participant Block as configs_block.py + participant BC as LLMConfig + participant CM as ConfigManager + participant FS as File System + + UI->>Block: 用户点击 "Apply Configuration" + Block->>BC: setattr(llm_settings, "language", "CN") + Block->>BC: update_config() + BC->>CM: update_section("llm", self) + CM->>CM: model.dump() → _flat_to_nested() → OmegaConf.create() + CM->>FS: OmegaConf.save(config.yaml) + CM-->>BC: 保存成功 +``` + +**热加载流程**: + +```mermaid +sequenceDiagram + participant User as 运维人员 + participant FS as File System + participant Watcher as FileWatcher (后台线程) + participant CM as ConfigManager + participant BC as LLMConfig (单例) + participant Log as 日志 + + User->>FS: vim config.yaml (外部修改) + FS-->>Watcher: os.path.getmtime() 变化 + Watcher->>Watcher: sleep(config_reload_interval) (默认 5s) + Watcher->>CM: reload() + CM->>FS: OmegaConf.load(config.yaml) + CM->>CM: _validate_all_sections() + alt YAML 合法 + CM->>BC: _sync_yaml_to_object() + CM->>Log: "Config reloaded from config.yaml" + else YAML 非法 (类型错误等) + CM->>Log: ERROR "Invalid config, keeping current" + Note over CM: 保留内存中的有效配置 + end +``` + +### 2.2. 组件图 + +```mermaid +graph TD + subgraph "配置系统 (重构后)" + CM[ConfigManager Singleton] + YAML[config.yaml] + ENV[.env 旧版] + FW[FileWatcher 后台线程] + + subgraph "pydantic 模型层 (Schema & Validation)" + LLC[LLMConfig] + HGC[HugeGraphConfig] + ADC[AdminConfig] + IDC[IndexConfig] + end + + subgraph "消费层 (46+ 文件, 不变)" + API[API / Flows / Nodes] + UI[Gradio UI] + CLI[CLI generate.py] + end + end + + CM -->|OmegaConf.load/save| YAML + CM -->|首次迁移| ENV + CM -->|定时轮询| FW + FW -->|检测变更后触发| CM + + LLC -->|get_section / update_section| CM + HGC -->|get_section / update_section| CM + ADC -->|get_section / update_section| CM + IDC -->|get_section / update_section| CM + + API -->|llm_settings.language 属性访问| LLC + UI -->|update_config()| LLC + CLI -->|generate_yaml()| LLC +``` + +### 2.3. 设计决策与权衡 + +- **决策 1: 嵌套 YAML key 结构 (via flat↔nested mapping)** + - **理由**: 用户明确要求嵌套 YAML 格式(如 `ollama: extract_port: 11434` 而非 `ollama_extract_port: 11434`),提升可读性和组织性。同时保持 46 个 consumer 文件的兼容性:pydantic 字段名保持扁平(`ollama_extract_port`),通过 `_flat_to_nested_mapping` ClassVar 在读写 YAML 时自动转换。 + - **权衡**: 需要在 ConfigManager 中维护双向转换逻辑(`_flat_to_nested()` / `_nested_to_flat()`),增加了少量复杂度。转换通过 dot-notation path mapping 实现(如 `{"ollama_extract_port": "ollama.extract.port"}`)。 + - **YAML 格式**: + ```yaml + llm: + language: EN + openai: + chat: + api_base: https://api.openai.com/v1 + api_key: null + language_model: gpt-4.1-mini + ollama: + chat: + host: 127.0.0.1 + port: 11434 + hugegraph: + graph: + url: 127.0.0.1:8080 + query: + max_graph_path: 10 + ``` + +- **决策 2: OmegaConf + pydantic BaseModel (非 BaseSettings)** + - **理由**: pydantic-settings 的 `BaseSettings` 与 `.env` 紧耦合。改用纯净 `BaseModel` 后,pydantic 仅负责 schema/校验/默认值,OmegaConf 负责文件 I/O。 + - **权衡**: 失去 `BaseSettings` 自动读取环境变量的能力。通过 ConfigManager 显式实现 fallback 链。 + - **优先级**: `环境变量 (os.environ) > config.yaml > pydantic 默认值`。环境变量作为最高优先级,符合 12-factor app 和容器/K8s 部署惯例。 + +- **决策 3: ConfigManager 单例模式** + - **理由**: 全局唯一配置源,避免多实例读写冲突。后台文件监控线程唯一。 + - **权衡**: 全局状态具有紧耦合性。但配置本身就是全局关注点,单例是合理选择。 + +- **决策 4: 轮询 (polling) 而非 watchdog 进行文件监控** + - **理由**: 零额外依赖(`os.path.getmtime()` 标准库),跨平台兼容。watchdog 需要额外安装且在某些 WSL/Docker 环境下不稳定。 + - **权衡**: 不是实时检测(间隔由 `config_reload_interval` 控制,默认 5s)。对配置文件场景足够。 + +- **决策 5: .env 迁移时通过 pydantic 校验进行类型转换** + - **理由**: .env 中所有值均为字符串。用 pydantic 模型构造实例(如 `LLMConfig(openai_chat_tokens="8192")`)让 pydantic 自动将 `"8192"` → `8192` (int),再写入 YAML 时保留正确类型。 + - **权衡**: 依赖 pydantic 的类型强制规则。对于无法强制的字段(如拼写错误的枚举值),迁移会失败并记录错误。 + +## 3. 数据模型 + +### 3.1. `config.yaml` 文件结构 + +```yaml +# config.yaml — hugegraph-llm 用户配置 +# 由 ConfigManager 自动生成和管理 + +llm: + language: EN + chat_llm_type: openai + extract_llm_type: openai + text2gql_llm_type: openai + embedding_type: openai + reranker_type: null + keyword_extract_type: llm + window_size: 3 + hybrid_llm_weights: 0.5 + # OpenAI (nested) + openai: + chat: + api_base: https://api.openai.com/v1 + api_key: null + language_model: gpt-4.1-mini + tokens: 8192 + extract: + api_base: https://api.openai.com/v1 + api_key: null + language_model: gpt-4.1-mini + tokens: 256 + text2gql: + api_base: https://api.openai.com/v1 + api_key: null + language_model: gpt-4.1-mini + tokens: 4096 + embedding: + api_base: https://api.openai.com/v1 + api_key: null + model: text-embedding-3-small + # Ollama (nested) + ollama: + chat: + host: 127.0.0.1 + port: 11434 + language_model: null + extract: + host: 127.0.0.1 + port: 11434 + language_model: null + text2gql: + host: 127.0.0.1 + port: 11434 + language_model: null + embedding: + host: 127.0.0.1 + port: 11434 + model: null + # LiteLLM (nested) + litellm: + chat: + api_key: null + api_base: null + language_model: openai/gpt-4.1-mini + tokens: 8192 + # ... (extract, text2gql, embedding 类似) + +hugegraph: + graph: + url: 127.0.0.1:8080 + name: hugegraph + user: admin + pwd: xxx + space: null + query: + limit_property: "False" + max_graph_path: 10 + max_graph_items: 30 + edge_limit_pre_label: 8 + vector: + dis_threshold: 0.9 + topk_per_keyword: 1 + rerank: + topk_return_results: 20 + +admin: + login: + enable: "False" + user_token: "4321" + admin_token: "xxxx" + config_reload_interval: 5 + +index: + qdrant: + host: null + port: 6333 + api_key: null + milvus: + host: null + port: 19530 + user: "" + password: "" + cur_vector_index: "Faiss" +``` + +### 3.2. ConfigManager 内部结构 + +```python +class ConfigManager: + """单例。管理 OmegaConf DictConfig 生命周期。""" + + _instance: ClassVar[Optional["ConfigManager"]] = None + _yaml_path: str # config.yaml 路径 (os.getcwd()/config.yaml) + _cfg: DictConfig # OmegaConf 配置树 + _watcher_thread: Thread # 后台文件监控线程 + _watching: bool # 控制监控线程运行 + + def load(self) -> DictConfig: ... + def save(self) -> None: ... + def get_section(self, name: str) -> DictConfig: ... + def update_section(self, name: str, model: BaseModel) -> None: ... + def reload(self) -> bool: ... # 热加载, 返回是否成功 + def _migrate_from_env(self) -> DictConfig: ... + def _start_file_watcher(self) -> None: ... + def _stop_file_watcher(self) -> None: ... +``` + +### 3.3. BaseConfig 重构 + +```python +class BaseConfig(BaseModel): # 不再是 BaseSettings + _config_section: ClassVar[str] = "" # 子类定义: "llm", "hugegraph" 等 + + def __init__(self, **data): + # 1. 从 ConfigManager 获取对应 YAML section + # 2. 合并环境变量 fallback (仅对标记字段) + # 3. 调用 super().__init__() 进行 pydantic 校验 + # 4. 将缺失的默认值写回 ConfigManager → YAML + + def update_config(self): + # 将当前 pydantic 字段值同步到 ConfigManager → 保存 YAML + + def generate_yaml(self): + # 生成包含默认值的 YAML section + + def check_config(self): + # 从 YAML 同步到对象 (替代原 check_env) +``` + +## 4. API 接口设计 + +所有内部 Python API,无 HTTP 端点变更。 + +### 4.1. ConfigManager API + +```python +# 获取单例 +cfg_mgr = ConfigManager() + +# 读取 section (返回 OmegaConf DictConfig) +llm_cfg: DictConfig = cfg_mgr.get_section("llm") +print(llm_cfg.language) # "EN" +print(llm_cfg.openai_chat_tokens) # 8192 (int, 非字符串) + +# 写入 section (从 pydantic model 同步) +cfg_mgr.update_section("llm", llm_settings) + +# 热加载 (由 FileWatcher 调用) +success: bool = cfg_mgr.reload() +``` + +### 4.2. BaseConfig 公开 API (保持兼容) + +```python +# 原有 API — 不变 +llm_settings.language # 属性读取 +llm_settings.language = "CN" # 属性写入 +llm_settings.update_config() # 替代 update_env(), 持久化到 YAML +llm_settings.generate_yaml() # 替代 generate_env(), 生成 YAML +llm_settings.check_config() # 替代 check_env(), 从 YAML 同步 + +# 废弃 API (内部实现变化, 但保留方法名以避免 breakage) +llm_settings.update_env() # → 委托给 update_config() +``` + +## 5. 核心逻辑实现 + +### 5.1. ConfigManager 初始化 + +``` +ConfigManager.__init__(): + 1. 确定 config.yaml 路径 = os.path.join(os.getcwd(), "config.yaml") + 2. IF config.yaml 存在: + OmegaConf.load(config.yaml) + ELSE IF .env 存在: + _migrate_from_env() # 读取 .env → 构建 DictConfig → save + ELSE: + _cfg = OmegaConf.create({}) # 空配置,后续由各 model 填充默认值 + 3. _start_file_watcher() # 启动后台热加载线程 +``` + +### 5.2. .env 迁移逻辑 + +``` +_migrate_from_env(): + 1. env_data = dotenv_values(".env") # {KEY: value} 全大写 key + 2. sections = {"llm": LLMConfig, "hugegraph": HugeGraphConfig, ...} + 3. FOR section_name, model_class IN sections: + model_fields = model_class.model_fields.keys() # 小写字段名 + section_data = {} + FOR env_key, env_value IN env_data: + IF env_key.lower() IN model_fields: + section_data[env_key.lower()] = env_value + # 用 pydantic 校验 + 类型转换 + model_instance = model_class(**section_data) + full_dump = model_instance.model_dump() + # 仅保留 .env 中存在的字段,避免将 env var 值泄露到 YAML + filtered = {k: v for k, v in full_dump.items() if k in section_data} + # 扁平 → 嵌套转换 + nested = _flat_to_nested(filtered, model_class._flat_to_nested_mapping) + cfg[section_name] = OmegaConf.create(nested) + 4. OmegaConf.save(cfg, "config.yaml") + 5. log.info("Migrated .env → config.yaml") + 6. 保留 .env 不删除 +``` + +类型转换关键: `.env` 中 `MAX_GRAPH_PATH=10` (字符串) → `section_data["max_graph_path"]="10"` → `HugeGraphConfig(max_graph_path="10")` → pydantic 自动转为 `max_graph_path: int = 10` → YAML 输出 `max_graph_path: 10` (整数)。 + +### 5.3. 环境变量覆盖 (env > YAML) + +优先级: **环境变量 (os.environ) > config.yaml > pydantic 默认值** + +``` +ConfigManager.get_section_with_env_override(section_name, model_class): + 1. yaml_section = _cfg.get(section_name) # 从 OmegaConf 读取 (嵌套 DictConfig) + 2. raw_dict = OmegaConf.to_container(yaml_section) # 转为 Python dict (嵌套) + 3. # 嵌套 → 扁平转换 + section_dict = _nested_to_flat(raw_dict, model_class._flat_to_nested_mapping) + 4. # 用环境变量覆盖扁平 dict 中的值(env 优先级更高) + FOR field_name, field_info IN model_class.model_fields.items(): + env_var_name = model_class._env_var_map.get(field_name, field_name.upper()) + env_value = os.environ.get(env_var_name) + IF env_value IS NOT None: + # 通过 pydantic TypeAdapter 进行类型转换 + section_dict[field_name] = TypeAdapter(field_info.annotation).validate_python(env_value) + 5. RETURN section_dict # 扁平 dict,直接传给 pydantic BaseModel +``` + +具体 env var mapping (来自 requirements U5-U7): +- `OPENAI_API_KEY` → 覆盖 `openai_*_api_key` +- `OPENAI_BASE_URL` → 覆盖 `openai_*_api_base` +- `OPENAI_EMBEDDING_BASE_URL` → 覆盖 `openai_embedding_api_base` +- `CO_API_URL` → 覆盖 `cohere_base_url` +- Qdrant/Milvus 连接参数 ← 对应环境变量 + +注意: `config.yaml` 作为持久化存储和默认值源,环境变量用于注入敏感信息或容器化部署覆盖。修改 `config.yaml` 不会覆盖已设置的环境变量。 + +### 5.4. 文件监控与热加载 + +``` +FileWatcher (后台 daemon 线程): + interval = cfg.admin.config_reload_interval (默认 5, 从 YAML 读取) + IF interval <= 0: + log.info("Hot reload disabled (config_reload_interval <= 0)") + RETURN + + last_mtime = os.path.getmtime(config.yaml) + WHILE _watching: + sleep(interval) + current_mtime = os.path.getmtime(config.yaml) + IF current_mtime != last_mtime: + last_mtime = current_mtime + success = ConfigManager.reload() + IF NOT success: + log.error("Config reload failed, keeping current in-memory config") +``` + +``` +ConfigManager.reload(): + 1. new_cfg = OmegaConf.load(config.yaml) + 2. FOR section_name, singleton IN [("llm", llm_settings), ...]: + section_data = new_cfg.get(section_name, {}) + # 尝试通过 pydantic 校验 + singleton_class = type(singleton) + validated = singleton_class(**OmegaConf.to_container(section_data)) + # 校验通过 → 同步字段到现有单例 + FOR field IN validated.model_dump(): + setattr(singleton, field, value) + 3. _cfg = new_cfg + 4. log.info("Config reloaded successfully") + 5. RETURN True + EXCEPT Exception: + log.error("Invalid config: ...") + RETURN False # 保留当前内存配置 +``` + +### 5.5. BaseConfig 初始化同步 + +``` +BaseConfig.__init__(**data): + 1. cfg_mgr = ConfigManager() + 2. # 从 YAML 读取 + 环境变量覆盖 (env 更高优先级) + section_data = cfg_mgr.get_section_with_env_override(_config_section, type(self)) + # section_data 已是扁平 dict(经 _nested_to_flat 转换) + 3. # 合并显式传入的 data (最高优先级) + section_data.update(data) + 4. # pydantic 校验 + super().__init__(**section_data) + 5. # 写回缺失的默认值到 YAML + cfg_mgr.update_section(_config_section, self) + # update_section 内部: model.dump() → _flat_to_nested(mapping) → OmegaConf.create() + cfg_mgr.save() +``` + +注意: 步骤 6 确保新增的 pydantic 字段(带默认值)会自动写入 YAML,用户升级后无需手动编辑。 + +## 6. 非功能性需求 + +- **安全性**: `config.yaml` 加入 `.gitignore`(与 `.env` 同级),防止意外提交敏感信息。 +- **性能**: 文件监控使用 `os.path.getmtime()` 零开销轮询。YAML 加载仅在启动或变更时触发,不影响正常请求性能。 +- **向后兼容**: 所有 46 个 consumer 文件的 `from hugegraph_llm.config import llm_settings` 和 `llm_settings.language` 属性访问保持不变。 +- **错误恢复**: 热加载失败时保留内存有效配置,不中断服务。启动时 `config.yaml` 损坏时回退到 pydantic 默认值。 + +## 7. 测试策略 + +- **单元测试**: + - `ConfigManager._migrate_from_env()` — .env 各类型字段正确转换为 YAML section + - `BaseConfig.__init__()` — 从 YAML 加载、环境变量 fallback、默认值回写 + - `BaseConfig.update_config()` — pydantic 字段变化正确持久化到 YAML + - `ConfigManager.reload()` — 合法/非法 YAML 的处理 + - `ConfigManager` 空文件、不存在文件、损坏文件的容错 +- **集成测试**: + - 首次启动(无任何文件)→ 生成带默认值的 `config.yaml` + - `.env` 存在 → 自动迁移到 `config.yaml` + - 环境变量覆盖 YAML 中的 None 值 + - Gradio UI 修改配置 → `config.yaml` 更新 + - 手动编辑 `config.yaml` → FileWatcher 检测并热加载 +- **回归测试**: + - 现有 `test_config.py` 通过 + - `ruff format --check` + `ruff check` 无错误 + +## 8. 风险与缓解措施 + +- **风险 1**: 从 `BaseSettings` 切到 `BaseModel` 后,`os.environ.get()` 默认值仍写在 pydantic field 定义中(如 `llm_config.py` 第 39 行),这些默认值在 pydantic 初始化时会被求值。如果环境变量在模块加载前未设置,默认值可能为 `None`。 + - **缓解**: ConfigManager 在 `get_section_with_fallback()` 中显式检查 `os.environ`,而不依赖 pydantic field default 的执行时机。field default 中的 `os.environ.get()` 调用保留作为文档化意图,但实际 fallback 由 ConfigManager 控制。 + +- **风险 2**: 后台 FileWatcher 线程在 Python 进程退出时可能导致资源泄漏或 hang。 + - **缓解**: FileWatcher 使用 `daemon=True` 线程,随主进程退出自动终止。`_watching` flag 在 `atexit` 中设为 False。 + +- **风险 3**: 轮询间隔期间用户修改了 YAML 两次(如保存两次),Watcher 可能在第一次变更后正在 reload 时,第二次变更到来导致竞争。 + - **缓解**: `reload()` 方法使用 `threading.Lock` 防止并发重入。检测到 mtime 变化且未在 reload 中时跳过额外触发。 + +- **风险 4**: `AdminConfig` 新增 `config_reload_interval` 字段,需要确保 `.env` 迁移时不会丢失该字段(旧 `.env` 中不存在)。 + - **缓解**: 迁移时使用 `model_dump()` 获取所有字段(含默认值),新字段自动以默认值写入 YAML。 diff --git a/.workflow/yaml-config-migration/requirements.md b/.workflow/yaml-config-migration/requirements.md new file mode 100644 index 000000000..7b88533b3 --- /dev/null +++ b/.workflow/yaml-config-migration/requirements.md @@ -0,0 +1,105 @@ +# 需求文档: 将 .env 配置迁移到 YAML (使用 OmegaConf) + +## 1. 介绍 + +当前 hugegraph-llm 项目的用户配置(图服务器连接、LLM 提供商密钥/模型、向量索引参数等)通过扁平的 `.env` 文件管理,底层使用 `pydantic-settings` + `python-dotenv`。详见 [`config.md`](../../hugegraph-llm/config.md)。 + +**痛点**: +- `.env` 扁平无结构,60+ 配置项难以浏览和维护 +- 所有值均为字符串,类型信息丢失 +- 没有文件变更自动感知/重载能力 —— 用户直接编辑配置文件后必须重启应用 +- 修改配置需要 `setattr` + `update_env()` 两步操作,API 不直观 + +**目标**: 用单一结构化 YAML 文件 (`config.yaml`) 替代 `.env`,引入 OmegaConf 作为配置框架,保持现有 Python API 兼容,**并支持运行时检测外部文件变更后自动重载**。 + +关联: Issue [#234](https://github.com/apache/hugegraph-ai/issues/234) | 先行 PR [#277](https://github.com/apache/hugegraph-ai/pull/277)(已过期/存在冲突)| 配置文档 [`config.md`](../../hugegraph-llm/config.md) + +### PR #277 Review 遗留问题 + +| 来源 | 问题 | 需在本需求中覆盖 | +|------|------|-----------------| +| Copilot | `yaml_config[current_class_name]` 未初始化时可能 KeyError | 访问 section 前确保 key 存在 | +| Copilot | 注释拼写错误 `'onfig'` → `'config'` | 代码质量 | +| imbajin | `requirements.txt` EOF 缺少换行 | 文件格式 | +| CI | Pylint 检查失败 | 代码质量 | + +## 2. 需求列表 + +### 2.1 配置存储格式从 .env 迁移到 YAML + +- **用户故事**: 作为一名 **hugegraph-llm 的运维/开发者**, 我希望 **所有应用配置存储在一个结构化的 `config.yaml` 文件中**, 以便 **更容易阅读、编辑和版本管理**。 + +- **验收标准 (EARS 格式)**: + - **U1**: The **`config.yaml`** shall **使用 OmegaConf 进行读写,顶层按配置类别分为 `llm`、`hugegraph`、`admin`、`index` 四个 section**。 + - **U2**: The **YAML 中的字段值** shall **保留正确的原始类型(整数、浮点数、布尔值、null、字符串)**。 + - **E1**: WHEN **应用首次启动且 `config.yaml` 不存在但 `.env` 存在**, the **系统** shall **自动读取 `.env` 数据,按字段名匹配到对应 section,经 pydantic 校验转类型后写入 `config.yaml`**。 + - **E2**: WHEN **应用首次启动且两个文件都不存在**, the **系统** shall **使用 pydantic 模型的默认值生成 `config.yaml`**。 + - **E3**: WHEN **用户在 Gradio UI 或 API 中修改配置并点击"应用"**, the **系统** shall **将变更持久化到 `config.yaml`**。 + +### 2.2 保持现有 Python 配置 API 兼容 + +- **用户故事**: 作为一名 **导入 `hugegraph_llm.config` 的开发者**, 我希望 **`llm_settings.language`、`huge_settings.graph_url` 等属性访问方式不变**, 以便 **45+ 个消费文件无需修改**。 + +- **验收标准 (EARS 格式)**: + - **U3**: The **`llm_settings`、`huge_settings`、`admin_settings`、`index_settings` 单例对象** shall **保持与重构前相同的属性读写方式**。 + - **U4**: The **pydantic 模型类 (`LLMConfig`、`HugeGraphConfig` 等)** shall **继续定义字段类型与默认值,作为配置的 schema 和校验层**。 + - **E4**: WHEN **调用 `update_config()`(替代原 `update_env()`)**, the **系统** shall **将当前 pydantic 模型值写回 OmegaConf 并保存到磁盘**。 + +### 2.3 保持环境变量覆盖支持 + +- **用户故事**: 作为一名 **使用容器/K8s 部署的运维人员**, 我希望 **通过环境变量注入的敏感信息(如 `OPENAI_API_KEY`)能够覆盖 `config.yaml` 中的值**, 以便 **安全地管理密钥并遵循 12-factor app 最佳实践**。 + +- **验收标准 (EARS 格式)**: + - **U5**: The **配置加载优先级** shall **为 `环境变量 (os.environ) > config.yaml > pydantic 默认值`,环境变量具有最高优先级**。 + - **U6**: The **OpenAI 配置字段 (`openai_*_api_key`, `openai_*_api_base`)** shall **被对应的环境变量 (`OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_EMBEDDING_BASE_URL`) 覆盖**。 + - **U7**: The **Cohere 配置字段 (`cohere_base_url`)** shall **被 `CO_API_URL` 环境变量覆盖**。 + - **U8**: The **向量数据库配置 (Qdrant/Milvus 连接参数)** shall **被对应环境变量覆盖**。 + +### 2.4 运行时配置热加载 + +- **用户故事**: 作为一名 **直接编辑 `config.yaml` 的运维人员**, 我希望 **修改 `config.yaml` 后运行中的应用能自动检测并加载新配置**, 以便 **无需重启应用即可让配置变更生效**。 + +- **验收标准 (EARS 格式)**: + - **E5**: WHEN **`config.yaml` 文件在外部被修改(如用户手动编辑)**, the **系统** shall **根据 `config.yaml` 中 `admin` section 的 `config_reload_interval` 字段值(默认 5 秒)检测到变更**。 + - **E6**: WHEN **检测到文件变更**, the **系统** shall **重新加载 `config.yaml`,将新值同步到对应的 pydantic 单例对象,并记录日志**。 + - **E7**: The **`admin` section** shall **包含 `config_reload_interval: int` 字段(默认 5,单位秒),控制文件变更检测轮询间隔**。 + - **E8**: WHEN **`config_reload_interval` 设为 0 或负数**, the **系统** shall **禁用热加载检测并记录日志**。 + - **X1**: IF **变更后的 `config.yaml` 包含非法值(类型错误、必填字段缺失等)**, THEN the **系统** shall **记录错误日志并保留当前内存中的有效配置,不中断运行**。 + - **X2**: IF **`config.yaml` 文件在加载过程中不存在或被删除**, THEN the **系统** shall **回退到 pydantic 模型默认值并记录警告日志**。 + +### 2.5 旧版 .env 兼容与平滑迁移 + +- **用户故事**: 作为一名 **从旧版本升级的用户**, 我希望 **已有的 `.env` 能自动迁移为 `config.yaml`**, 以便 **升级过程无需手动重新配置**。 + +- **验收标准 (EARS 格式)**: + - **E9**: WHEN **`.env` 存在而 `config.yaml` 不存在**, the **系统** shall **将 `.env` 中所有键值对(转为小写 key)与各 pydantic 模型的字段名匹配,分配到正确的 YAML section**。 + - **E10**: WHEN **迁移完成**, the **系统** shall **保留原有 `.env` 不动(不删除),作为备份**。 + - **X3**: IF **`.env` 中某字段无法匹配到任何已知 pydantic 模型字段**, THEN the **系统** shall **跳过该字段并输出警告日志,不阻断启动或迁移流程**。 + +### 2.6 CLI 配置生成工具更新 + +- **用户故事**: 作为一名 **开发者**, 我希望 **`python -m hugegraph_llm.config.generate -U` 能生成 `config.yaml`**, 以便 **快速初始化配置**。 + +- **验收标准 (EARS 格式)**: + - **E11**: WHEN **执行 `python -m hugegraph_llm.config.generate -U`**, the **系统** shall **生成包含所有默认值的 `config.yaml`,并同时生成/更新 `config_prompt.yaml`**。 + +### 2.7 Gradio UI 配置块适配 + +- **用户故事**: 作为一名 **通过 Gradio Web UI 配置系统的用户**, 我希望 **所有配置修改仍然通过 UI 生效并持久化**, 以便 **使用体验不变**。 + +- **验收标准 (EARS 格式)**: + - **E12**: WHEN **用户在 Gradio UI 中点击"Apply Configuration"按钮**, the **系统** shall **将修改写入 pydantic 模型并调用 `update_config()` 持久化到 YAML**。 + - **X4**: IF **`update_config()` 调用失败**, THEN the **系统** shall **捕获异常并记录错误日志,不向用户抛出未处理的异常**。 + +### 2.8 文档更新 + +- **用户故事**: 作为一名 **查阅 `config.md` 的开发者**, 我希望 **文档反映新的 YAML 配置方式和运行时热加载行为**, 以便 **正确理解和使用配置系统**。 + +- **验收标准 (EARS 格式)**: + - **U9**: The **`hugegraph-llm/config.md`** shall **更新所有 `.env` 引用为 `config.yaml`,并新增热加载行为说明**。 + +### 2.9 非功能需求 + +- **U10**: The **现有测试 (263 个单元/集成)** shall **全部通过,无回归**。 +- **U11**: The **代码** shall **通过 `ruff format --check` 和 `ruff check`,无 lint 错误**。 +- **U12**: The **PR #277 的 3 个遗留 Review 问题** shall **在新实现中全部修复**。 diff --git a/.workflow/yaml-config-migration/tasks.md b/.workflow/yaml-config-migration/tasks.md new file mode 100644 index 000000000..a32000cb0 --- /dev/null +++ b/.workflow/yaml-config-migration/tasks.md @@ -0,0 +1,71 @@ +# 实现计划: 将 .env 配置迁移到 YAML (使用 OmegaConf) + +- [x] 1. **研究与准备** `[优先级: 高]` + + - [x] 1.1. 搜索代码库中 `update_env()`、`generate_env()`、`check_env()` 的所有调用点,确认无遗漏的迁移目标。 `(关联需求: E3, E12)` + - [x] 1.2. 确认 OmegaConf 包可用性:检查 PyPI 版本及与 Python 3.10 的兼容性。 `(关联需求: U1)` + +- [x] 2. **添加 OmegaConf 依赖** `[优先级: 高]` + + - [x] 2.1. 在 `hugegraph-llm/pyproject.toml` 的 `dependencies` 中添加 `"omegaconf~=2.3"`。 `(关联需求: U1)` + - [x] 2.2. 在根 `pyproject.toml` 的 `constraint-dependencies` 中添加 `"omegaconf~=2.3"`。 `(关联需求: U1)` + - [x] 2.3. 执行 `uv sync` 安装 OmegaConf,验证导入成功。 `(关联需求: U1)` `(依赖于: 2.1, 2.2)` + +- [x] 3. **重构 BaseConfig 核心** `[优先级: 高]` + + - [x] 3.1. 在 `base_config.py` 中创建 `ConfigManager` 单例类,实现 `load()`、`save()`、`get_section()`、`update_section()`、`reload()`、`_migrate_from_env()`、`get_section_with_env_override()` 方法。 `(关联需求: U1, U2, U5, E1, E2, E7)` + - [x] 3.2. 在 `ConfigManager` 中实现 `_start_file_watcher()` / `_stop_file_watcher()` 后台轮询线程(使用 `os.path.getmtime()`)。 `(关联需求: E5, E6, E8)` + - [x] 3.3. 重构 `BaseConfig`:从 `BaseSettings` 改为 `BaseModel`,添加 `_config_section` 类属性,重写 `__init__` 从 ConfigManager 加载配置。 `(关联需求: U3, U4)` + - [x] 3.4. 在 `BaseConfig` 中实现 `update_config()`、`generate_yaml()`、`check_config()` 方法,保留旧方法 (`update_env()` 等) 作为委托。 `(关联需求: E3, E4)` + - [x] 3.5. 处理 PR #277 遗留问题:KeyError 保护(访问前确保 section key 存在)、注释拼写修正。 `(关联需求: U12)` + - [x] 3.6. **新增 (未在原始计划中)**: 实现 `_flat_to_nested()` / `_nested_to_flat()` 双向转换工具函数,支持扁平 pydantic 字段名 ↔ 嵌套 YAML 结构。 + +- [x] 4. **更新各 Config 子类** `[优先级: 高]` `(依赖于: 3.3)` + + - [x] 4.1. `LLMConfig` 添加 `_config_section = "llm"` 和 `_flat_to_nested_mapping` (42 条 dot-notation path mapping)。添加 `_env_var_map` 支持自定义环境变量名映射。移除 `os.environ.get()` 默认值。 `(关联需求: U3)` + - [x] 4.2. `HugeGraphConfig` 添加 `_config_section = "hugegraph"` 和 `_flat_to_nested_mapping` (12 条 mapping: graph.*, query.*, vector.*, rerank.*)。 `(关联需求: U3)` + - [x] 4.3. `AdminConfig` 添加 `_config_section = "admin"`,`_flat_to_nested_mapping` (3 条 mapping: login.*),新增 `config_reload_interval: int = 5` 字段。 `(关联需求: E7)` + - [x] 4.4. `IndexConfig` 添加 `_config_section = "index"` 和 `_flat_to_nested_mapping` (7 条 mapping: qdrant.*, milvus.*)。移除 `os.environ.get()` 默认值。 `(关联需求: U3)` + +- [x] 5. **更新配置初始化与生成工具** `[优先级: 高]` `(依赖于: 3.1, 3.3, 4.*)` + + - [x] 5.1. 更新 `config/__init__.py`:ConfigManager 在 config 单例之前初始化,传入 section→model_class 映射。 `(关联需求: U3, E2)` `(依赖于: 3.1, 4.*)` + - [x] 5.2. 更新 `config/generate.py`:`generate_env()` → `generate_yaml()` 全部 4 个 config 对象。 `(关联需求: E11)` `(依赖于: 3.4)` + - [x] 5.3. 更新 `config/models/base_prompt_config.py`:修正注释中 `.env` 引用为 `config.yaml`。 `(关联需求: U9)` `(依赖于: 3.*)` + +- [x] 6. **适配 Gradio UI 配置块** `[优先级: 中]` `(依赖于: 3.4)` + + - [x] 6.1. 更新 `configs_block.py`:6 处 `update_env()` 调用替换为 `update_config()`。 `(关联需求: E12)` `(依赖于: 3.4)` + - [x] 6.2. 更新 `configs_block.py`:移除 `dotenv_values()` 直接读取 `.env` 的逻辑,改为从 config 对象读取 (`llm_settings.openai_extract_api_key`)。移除未使用的 `import os`。 `(关联需求: E12)` `(依赖于: 6.1)` + - [x] 6.3. 为 `update_config()` 调用添加异常捕获与错误日志(PR #277 X4 保护)。 `(关联需求: X4)` `(依赖于: 6.1)` + +- [x] 7. **更新 .gitignore** `[优先级: 中]` `(依赖于: 2.*)` + + - [x] 7.1. 在根 `.gitignore` 中添加 `config.yaml` 和 `config.yaml.bak`。 `(关联需求: U1)` + +- [x] 8. **更新文档** `[优先级: 中]` `(依赖于: 3.*, 4.*, 6.*)` + + - [x] 8.1. 更新 `hugegraph-llm/config.md`:全面重写,反映嵌套 YAML section 结构、环境变量优先级 (os.environ > config.yaml > pydantic defaults)、热加载行为、配置 reload interval 说明。 `(关联需求: U9)` + +- [x] 9. **安装依赖并运行 Lint 检查** `[优先级: 中]` `(依赖于: 2.*, 7.*)` + + - [x] 9.1. 执行 `uv sync` 安装 OmegaConf。 `(依赖于: 2.*)` + - [x] 9.2. 执行 `ruff format --check` 和 `ruff check`,修复所有 lint 错误。 `(关联需求: U11, U12)` + +- [x] 10. **运行现有测试确保无回归** `[优先级: 高]` `(依赖于: 3.*, 4.*, 5.*, 6.*, 9.*)` + + - [x] 10.1. 运行 `pytest` 确认所有测试通过 (7 个 config 测试全部通过)。 `(关联需求: U10)` + - [x] 10.2. 手动验证嵌套 YAML 生成:`from hugegraph_llm.config import ...` 生成嵌套结构 `config.yaml`。 `(关联需求: E11)` `(依赖于: 10.1)` + - [x] 10.3. 手动验证 `.env` 迁移流程:`.env` 存在时自动迁移到嵌套 `config.yaml`,已验证类型转换(int/float/bool/null 保留正确类型)。 `(关联需求: E9, E10)` `(依赖于: 10.1)` + - [x] 10.4. 手动验证环境变量覆盖:`os.environ` 覆盖 YAML 值(如 `MAX_GRAPH_PATH=99`)。 `(关联需求: U5)` + - [x] 10.5. 手动验证 OmegaConf dot-notation 读取嵌套 YAML:`cfg.llm.openai.chat.language_model`。 `(关联需求: U1, U2)` + +## 实现差异说明 + +以下为实际实现与最初计划的差异: + +1. **嵌套 YAML 结构**(原计划保持扁平):用户明确要求嵌套 YAML(`ollama: extract_port: 11434` 而非 `ollama_extract_port: 11434`)。通过 `_flat_to_nested_mapping` ClassVar + `_flat_to_nested()` / `_nested_to_flat()` 双向转换实现,pydantic 字段名保持扁平以兼容 46 个 consumer 文件。 + +2. **OmegaConf.merge → OmegaConf.create**:`update_section()` 中使用 `OmegaConf.create(nested_dict)` 直接替换整个 section,避免 `OmegaConf.merge` 导致旧扁平 key 与新嵌套结构并存的问题。 + +3. **pydantic TypeAdapter** 用于环境变量类型转换:避免在 `get_section_with_env_override` 中创建临时 pydantic 实例导致的无限递归。 From 1f5577b7fff150a950bf93b310f99e361bcd5d24 Mon Sep 17 00:00:00 2001 From: looksaw Date: Sat, 30 May 2026 23:08:32 +0800 Subject: [PATCH 3/3] docs(workflow): translate workflow documents to English Translate requirements.md, design.md, and tasks.md from Chinese to English to meet project contribution guidelines. Co-Authored-By: Claude Opus 4.7 --- .workflow/yaml-config-migration/design.md | 407 +++++++++--------- .../yaml-config-migration/requirements.md | 142 +++--- .workflow/yaml-config-migration/tasks.md | 92 ++-- 3 files changed, 321 insertions(+), 320 deletions(-) diff --git a/.workflow/yaml-config-migration/design.md b/.workflow/yaml-config-migration/design.md index b45898a1a..78e4245ea 100644 --- a/.workflow/yaml-config-migration/design.md +++ b/.workflow/yaml-config-migration/design.md @@ -1,36 +1,37 @@ -# 设计文档: 将 .env 配置迁移到 YAML (使用 OmegaConf) +# Design Document: Migrate .env Configuration to YAML with OmegaConf -## 1. 概述 +## 1. Overview -### 1.1. 目标 +### 1.1. Objective -将 hugegraph-llm 的配置存储从扁平 `.env` (pydantic-settings + python-dotenv) 迁移到结构化 `config.yaml` (OmegaConf),同时保持现有 Python API 完全兼容,并支持运行时热加载。 +Migrate hugegraph-llm's configuration storage from flat `.env` (pydantic-settings + python-dotenv) to structured `config.yaml` (OmegaConf), while maintaining full backward compatibility with the existing Python API and supporting runtime hot-reload. -### 1.2. 范围 +### 1.2. Scope - **In-Scope**: - - 引入 OmegaConf 作为 YAML 配置读写框架 - - 重构 `BaseConfig`,从 `BaseSettings` 迁移到 `BaseModel` - - 创建 `ConfigManager` 单例管理 YAML 生命周期 - - 首次启动时自动从 `.env` 迁移到 `config.yaml` - - 运行时文件变更检测与自动重载(轮询方式) - - 环境变量 fallback(API Key 等敏感信息) - - Gradio UI 中 `update_env()` → `update_config()` 替换 - - CLI `generate.py` 适配 - - `config.md` 文档更新 + - Introduce OmegaConf as the YAML config read/write framework + - Refactor `BaseConfig` from `BaseSettings` to `BaseModel` + - Create `ConfigManager` singleton to manage YAML lifecycle + - Auto-migrate from `.env` to `config.yaml` on first startup + - Runtime file change detection and auto-reload (polling-based) + - Environment variable override for sensitive info (API keys, etc.) + - Replace `update_env()` → `update_config()` in Gradio UI + - Adapt CLI `generate.py` + - Update `config.md` documentation + - Nested YAML structure via flat↔nested bidirectional mapping - **Out-of-Scope**: - - 配置版本迁移策略(如 config schema 变更时的自动升级) - - 多配置文件支持 + - Config version migration strategy (e.g. auto-upgrade on schema change) + - Multi-file config support -### 1.3. 关联需求 +### 1.3. Related Requirements -关联 `.workflow/yaml-config-migration/requirements.md` 中全部 9 个功能需求 (U1-U11, E1-E12, X1-X4)。 +References all 9 functional requirements (U1-U11, E1-E12, X1-X4) in `.workflow/yaml-config-migration/requirements.md`. -## 2. 整体架构 +## 2. Architecture -### 2.1. 架构图 +### 2.1. Architecture Diagrams -**系统启动流程 (序列图)**: +**System Startup Flow (Sequence Diagram)**: ```mermaid sequenceDiagram @@ -41,36 +42,36 @@ sequenceDiagram participant Pydantic as pydantic BaseModel Main->>CM: ConfigManager() - CM->>FS: config.yaml 存在? - alt config.yaml 存在 - FS-->>CM: 返回 YAML 内容 + CM->>FS: config.yaml exists? + alt config.yaml exists + FS-->>CM: Return YAML content CM->>CM: OmegaConf.load(config.yaml) - else config.yaml 不存在, .env 存在 - FS-->>CM: 返回 .env 内容 + else config.yaml missing, .env exists + FS-->>CM: Return .env content CM->>CM: _migrate_from_env() CM->>CM: OmegaConf.save(config.yaml) - Note over CM: 保留 .env 作为备份 - else 两者都不存在 - CM->>CM: 使用空 OmegaConf DictConfig + Note over CM: Keep .env as backup + else Neither exists + CM->>CM: Use empty OmegaConf DictConfig end - CM->>CM: _start_file_watcher() (后台线程) + CM->>CM: _start_file_watcher() (daemon thread) Main->>BC: LLMConfig() BC->>CM: get_section_with_env_override("llm") - CM->>CM: _nested_to_flat() # 嵌套 YAML → 扁平 dict - CM-->>BC: 扁平 dict (+ env var 覆盖) + CM->>CM: _nested_to_flat() # Nested YAML → flat dict + CM-->>BC: flat dict (+ env var overrides) BC->>Pydantic: BaseModel.__init__(**flat_values) - Pydantic-->>BC: 校验后的字段值 + Pydantic-->>BC: Validated field values BC->>CM: update_section("llm", self) - CM->>CM: _flat_to_nested() # 扁平 → 嵌套写入 YAML - Note over BC,CM: 写回缺失的默认值到 YAML + CM->>CM: _flat_to_nested() # Flat → nested for YAML write + Note over BC,CM: Write missing defaults back to YAML CM->>FS: OmegaConf.save(config.yaml) Main->>BC: HugeGraphConfig() / AdminConfig() / IndexConfig() - Note over Main,BC: 同上流程 + Note over Main,BC: Same flow as above ``` -**配置修改流程 (Gradio UI)**: +**Config Modification Flow (Gradio UI)**: ```mermaid sequenceDiagram @@ -80,59 +81,59 @@ sequenceDiagram participant CM as ConfigManager participant FS as File System - UI->>Block: 用户点击 "Apply Configuration" + UI->>Block: User clicks "Apply Configuration" Block->>BC: setattr(llm_settings, "language", "CN") Block->>BC: update_config() BC->>CM: update_section("llm", self) CM->>CM: model.dump() → _flat_to_nested() → OmegaConf.create() CM->>FS: OmegaConf.save(config.yaml) - CM-->>BC: 保存成功 + CM-->>BC: Save successful ``` -**热加载流程**: +**Hot-Reload Flow**: ```mermaid sequenceDiagram - participant User as 运维人员 + participant User as Operator participant FS as File System - participant Watcher as FileWatcher (后台线程) + participant Watcher as FileWatcher (daemon thread) participant CM as ConfigManager - participant BC as LLMConfig (单例) - participant Log as 日志 + participant BC as LLMConfig (singleton) + participant Log as Logging - User->>FS: vim config.yaml (外部修改) - FS-->>Watcher: os.path.getmtime() 变化 - Watcher->>Watcher: sleep(config_reload_interval) (默认 5s) + User->>FS: vim config.yaml (external edit) + FS-->>Watcher: os.path.getmtime() changed + Watcher->>Watcher: sleep(config_reload_interval) (default 5s) Watcher->>CM: reload() CM->>FS: OmegaConf.load(config.yaml) - CM->>CM: _validate_all_sections() - alt YAML 合法 - CM->>BC: _sync_yaml_to_object() + CM->>CM: Validate all sections via pydantic + alt YAML is valid + CM->>BC: _sync to object attributes CM->>Log: "Config reloaded from config.yaml" - else YAML 非法 (类型错误等) + else YAML is invalid (type error, etc.) CM->>Log: ERROR "Invalid config, keeping current" - Note over CM: 保留内存中的有效配置 + Note over CM: Keep in-memory valid config end ``` -### 2.2. 组件图 +### 2.2. Component Diagram ```mermaid graph TD - subgraph "配置系统 (重构后)" + subgraph "Config System (Refactored)" CM[ConfigManager Singleton] YAML[config.yaml] - ENV[.env 旧版] - FW[FileWatcher 后台线程] + ENV[.env legacy] + FW[FileWatcher daemon thread] - subgraph "pydantic 模型层 (Schema & Validation)" + subgraph "pydantic Model Layer (Schema & Validation)" LLC[LLMConfig] HGC[HugeGraphConfig] ADC[AdminConfig] IDC[IndexConfig] end - subgraph "消费层 (46+ 文件, 不变)" + subgraph "Consumer Layer (46+ files, unchanged)" API[API / Flows / Nodes] UI[Gradio UI] CLI[CLI generate.py] @@ -140,26 +141,26 @@ graph TD end CM -->|OmegaConf.load/save| YAML - CM -->|首次迁移| ENV - CM -->|定时轮询| FW - FW -->|检测变更后触发| CM + CM -->|First-run migration| ENV + CM -->|Periodic polling| FW + FW -->|Trigger reload on change| CM LLC -->|get_section / update_section| CM HGC -->|get_section / update_section| CM ADC -->|get_section / update_section| CM IDC -->|get_section / update_section| CM - API -->|llm_settings.language 属性访问| LLC + API -->|llm_settings.language attr access| LLC UI -->|update_config()| LLC CLI -->|generate_yaml()| LLC ``` -### 2.3. 设计决策与权衡 +### 2.3. Design Decisions & Trade-offs -- **决策 1: 嵌套 YAML key 结构 (via flat↔nested mapping)** - - **理由**: 用户明确要求嵌套 YAML 格式(如 `ollama: extract_port: 11434` 而非 `ollama_extract_port: 11434`),提升可读性和组织性。同时保持 46 个 consumer 文件的兼容性:pydantic 字段名保持扁平(`ollama_extract_port`),通过 `_flat_to_nested_mapping` ClassVar 在读写 YAML 时自动转换。 - - **权衡**: 需要在 ConfigManager 中维护双向转换逻辑(`_flat_to_nested()` / `_nested_to_flat()`),增加了少量复杂度。转换通过 dot-notation path mapping 实现(如 `{"ollama_extract_port": "ollama.extract.port"}`)。 - - **YAML 格式**: +- **Decision 1: Nested YAML key structure (via flat↔nested mapping)** + - **Rationale**: User explicitly requested nested YAML format (e.g. `ollama: extract_port: 11434` rather than `ollama_extract_port: 11434`) for improved readability and organization. 46 consumer files remain compatible: pydantic field names stay flat (`ollama_extract_port`), with automatic conversion via `_flat_to_nested_mapping` ClassVar during YAML read/write. + - **Trade-off**: Requires bidirectional conversion logic (`_flat_to_nested()` / `_nested_to_flat()`) in ConfigManager, adding moderate complexity. Conversion uses dot-notation path mappings (e.g. `{"ollama_extract_port": "ollama.extract.port"}`). + - **YAML format**: ```yaml llm: language: EN @@ -179,30 +180,30 @@ graph TD max_graph_path: 10 ``` -- **决策 2: OmegaConf + pydantic BaseModel (非 BaseSettings)** - - **理由**: pydantic-settings 的 `BaseSettings` 与 `.env` 紧耦合。改用纯净 `BaseModel` 后,pydantic 仅负责 schema/校验/默认值,OmegaConf 负责文件 I/O。 - - **权衡**: 失去 `BaseSettings` 自动读取环境变量的能力。通过 ConfigManager 显式实现 fallback 链。 - - **优先级**: `环境变量 (os.environ) > config.yaml > pydantic 默认值`。环境变量作为最高优先级,符合 12-factor app 和容器/K8s 部署惯例。 +- **Decision 2: OmegaConf + pydantic BaseModel (not BaseSettings)** + - **Rationale**: pydantic-settings `BaseSettings` is tightly coupled with `.env`. Switching to plain `BaseModel` means pydantic handles only schema/validation/defaults, while OmegaConf handles file I/O. + - **Trade-off**: Lose `BaseSettings` automatic env-var reading. Implement explicit fallback chain via ConfigManager. + - **Priority**: `os.environ > config.yaml > pydantic defaults`. Env vars have the highest priority, aligning with 12-factor app and container/K8s deployment conventions. -- **决策 3: ConfigManager 单例模式** - - **理由**: 全局唯一配置源,避免多实例读写冲突。后台文件监控线程唯一。 - - **权衡**: 全局状态具有紧耦合性。但配置本身就是全局关注点,单例是合理选择。 +- **Decision 3: ConfigManager singleton pattern** + - **Rationale**: Single global config source, avoids multi-instance read/write conflicts. Single background file-watcher thread. + - **Trade-off**: Global state introduces coupling. But configuration is inherently a global concern — singleton is a reasonable choice. -- **决策 4: 轮询 (polling) 而非 watchdog 进行文件监控** - - **理由**: 零额外依赖(`os.path.getmtime()` 标准库),跨平台兼容。watchdog 需要额外安装且在某些 WSL/Docker 环境下不稳定。 - - **权衡**: 不是实时检测(间隔由 `config_reload_interval` 控制,默认 5s)。对配置文件场景足够。 +- **Decision 4: Polling (not watchdog) for file monitoring** + - **Rationale**: Zero extra dependencies (`os.path.getmtime()` from stdlib), cross-platform compatible. watchdog requires additional installation and can be unstable in certain WSL/Docker environments. + - **Trade-off**: Not real-time detection (interval controlled by `config_reload_interval`, default 5s). Sufficient for config file use case. -- **决策 5: .env 迁移时通过 pydantic 校验进行类型转换** - - **理由**: .env 中所有值均为字符串。用 pydantic 模型构造实例(如 `LLMConfig(openai_chat_tokens="8192")`)让 pydantic 自动将 `"8192"` → `8192` (int),再写入 YAML 时保留正确类型。 - - **权衡**: 依赖 pydantic 的类型强制规则。对于无法强制的字段(如拼写错误的枚举值),迁移会失败并记录错误。 +- **Decision 5: pydantic-based type conversion during .env migration** + - **Rationale**: All values in `.env` are strings. Constructing pydantic model instances (e.g. `LLMConfig(openai_chat_tokens="8192")`) lets pydantic auto-convert `"8192"` → `8192` (int), preserving correct types when writing to YAML. + - **Trade-off**: Depends on pydantic's type coercion rules. Migration fails and logs errors for uncoercible fields (e.g. misspelled enum values). -## 3. 数据模型 +## 3. Data Model -### 3.1. `config.yaml` 文件结构 +### 3.1. `config.yaml` File Structure ```yaml -# config.yaml — hugegraph-llm 用户配置 -# 由 ConfigManager 自动生成和管理 +# config.yaml — hugegraph-llm user configuration +# Auto-generated and managed by ConfigManager llm: language: EN @@ -260,7 +261,7 @@ llm: api_base: null language_model: openai/gpt-4.1-mini tokens: 8192 - # ... (extract, text2gql, embedding 类似) + # ... (extract, text2gql, embedding similar) hugegraph: graph: @@ -300,163 +301,164 @@ index: cur_vector_index: "Faiss" ``` -### 3.2. ConfigManager 内部结构 +### 3.2. ConfigManager Internal Structure ```python class ConfigManager: - """单例。管理 OmegaConf DictConfig 生命周期。""" + """Singleton. Manages OmegaConf DictConfig lifecycle.""" _instance: ClassVar[Optional["ConfigManager"]] = None - _yaml_path: str # config.yaml 路径 (os.getcwd()/config.yaml) - _cfg: DictConfig # OmegaConf 配置树 - _watcher_thread: Thread # 后台文件监控线程 - _watching: bool # 控制监控线程运行 + _yaml_path: str # config.yaml path (os.getcwd()/config.yaml) + _cfg: DictConfig # OmegaConf config tree + _watcher_thread: Thread # Background file-watcher thread + _watching: bool # Controls watcher thread lifecycle def load(self) -> DictConfig: ... def save(self) -> None: ... def get_section(self, name: str) -> DictConfig: ... def update_section(self, name: str, model: BaseModel) -> None: ... - def reload(self) -> bool: ... # 热加载, 返回是否成功 + def reload(self) -> bool: ... # Hot-reload, returns success def _migrate_from_env(self) -> DictConfig: ... def _start_file_watcher(self) -> None: ... def _stop_file_watcher(self) -> None: ... ``` -### 3.3. BaseConfig 重构 +### 3.3. BaseConfig Refactor ```python -class BaseConfig(BaseModel): # 不再是 BaseSettings - _config_section: ClassVar[str] = "" # 子类定义: "llm", "hugegraph" 等 +class BaseConfig(BaseModel): # No longer BaseSettings + _config_section: ClassVar[str] = "" # Subclass sets: "llm", "hugegraph", etc. + _flat_to_nested_mapping: ClassVar[dict] = {} # Flat field → nested dot-path def __init__(self, **data): - # 1. 从 ConfigManager 获取对应 YAML section - # 2. 合并环境变量 fallback (仅对标记字段) - # 3. 调用 super().__init__() 进行 pydantic 校验 - # 4. 将缺失的默认值写回 ConfigManager → YAML + # 1. Load from ConfigManager (nested YAML → flat via _nested_to_flat) + # 2. Apply environment variable overrides (higher priority than YAML) + # 3. Call super().__init__() for pydantic validation + # 4. Write missing defaults back to ConfigManager → YAML (flat → nested via _flat_to_nested) def update_config(self): - # 将当前 pydantic 字段值同步到 ConfigManager → 保存 YAML + # Sync current pydantic field values to ConfigManager → save YAML def generate_yaml(self): - # 生成包含默认值的 YAML section + # Generate YAML section with default values def check_config(self): - # 从 YAML 同步到对象 (替代原 check_env) + # Sync from YAML to object (replaces old check_env) ``` -## 4. API 接口设计 +## 4. API Design -所有内部 Python API,无 HTTP 端点变更。 +All internal Python APIs. No HTTP endpoint changes. ### 4.1. ConfigManager API ```python -# 获取单例 +# Get singleton cfg_mgr = ConfigManager() -# 读取 section (返回 OmegaConf DictConfig) +# Read section (returns OmegaConf DictConfig) llm_cfg: DictConfig = cfg_mgr.get_section("llm") -print(llm_cfg.language) # "EN" -print(llm_cfg.openai_chat_tokens) # 8192 (int, 非字符串) +print(llm_cfg.openai.chat.tokens) # 8192 (int, not string) -# 写入 section (从 pydantic model 同步) +# Write section (sync from pydantic model) cfg_mgr.update_section("llm", llm_settings) -# 热加载 (由 FileWatcher 调用) +# Hot-reload (called by FileWatcher) success: bool = cfg_mgr.reload() ``` -### 4.2. BaseConfig 公开 API (保持兼容) +### 4.2. BaseConfig Public API (backward-compatible) ```python -# 原有 API — 不变 -llm_settings.language # 属性读取 -llm_settings.language = "CN" # 属性写入 -llm_settings.update_config() # 替代 update_env(), 持久化到 YAML -llm_settings.generate_yaml() # 替代 generate_env(), 生成 YAML -llm_settings.check_config() # 替代 check_env(), 从 YAML 同步 - -# 废弃 API (内部实现变化, 但保留方法名以避免 breakage) -llm_settings.update_env() # → 委托给 update_config() +# Existing API — unchanged +llm_settings.language # Attribute read +llm_settings.language = "CN" # Attribute write +llm_settings.update_config() # Replaces update_env(), persists to YAML +llm_settings.generate_yaml() # Replaces generate_env(), generates YAML +llm_settings.check_config() # Replaces check_env(), syncs from YAML + +# Deprecated API (internal impl changed, method names kept to avoid breakage) +llm_settings.update_env() # → delegates to update_config() ``` -## 5. 核心逻辑实现 +## 5. Core Logic Implementation -### 5.1. ConfigManager 初始化 +### 5.1. ConfigManager Initialization ``` ConfigManager.__init__(): - 1. 确定 config.yaml 路径 = os.path.join(os.getcwd(), "config.yaml") - 2. IF config.yaml 存在: + 1. Determine config.yaml path = os.path.join(os.getcwd(), "config.yaml") + 2. Load .env into os.environ (for backward compat and override priority) + 3. IF config.yaml exists: OmegaConf.load(config.yaml) - ELSE IF .env 存在: - _migrate_from_env() # 读取 .env → 构建 DictConfig → save + ELSE IF .env exists: + _migrate_from_env() # Read .env → build DictConfig → save ELSE: - _cfg = OmegaConf.create({}) # 空配置,后续由各 model 填充默认值 - 3. _start_file_watcher() # 启动后台热加载线程 + _cfg = OmegaConf.create({}) # Empty, models fill in defaults later + 4. _start_file_watcher() # Start background hot-reload thread ``` -### 5.2. .env 迁移逻辑 +### 5.2. .env Migration Logic ``` _migrate_from_env(): - 1. env_data = dotenv_values(".env") # {KEY: value} 全大写 key + 1. env_data = dotenv_values(".env") # {KEY: value} uppercase keys 2. sections = {"llm": LLMConfig, "hugegraph": HugeGraphConfig, ...} 3. FOR section_name, model_class IN sections: - model_fields = model_class.model_fields.keys() # 小写字段名 + model_fields = model_class.model_fields.keys() # Lowercase field names section_data = {} FOR env_key, env_value IN env_data: IF env_key.lower() IN model_fields: section_data[env_key.lower()] = env_value - # 用 pydantic 校验 + 类型转换 + # Validate + type convert via pydantic model_instance = model_class(**section_data) full_dump = model_instance.model_dump() - # 仅保留 .env 中存在的字段,避免将 env var 值泄露到 YAML + # Only keep fields that were in .env (don't leak env var values into YAML) filtered = {k: v for k, v in full_dump.items() if k in section_data} - # 扁平 → 嵌套转换 + # Flat → nested conversion nested = _flat_to_nested(filtered, model_class._flat_to_nested_mapping) cfg[section_name] = OmegaConf.create(nested) 4. OmegaConf.save(cfg, "config.yaml") 5. log.info("Migrated .env → config.yaml") - 6. 保留 .env 不删除 + 6. Keep .env, do not delete ``` -类型转换关键: `.env` 中 `MAX_GRAPH_PATH=10` (字符串) → `section_data["max_graph_path"]="10"` → `HugeGraphConfig(max_graph_path="10")` → pydantic 自动转为 `max_graph_path: int = 10` → YAML 输出 `max_graph_path: 10` (整数)。 +Type conversion key point: `.env` has `MAX_GRAPH_PATH=10` (string) → `section_data["max_graph_path"]="10"` → `HugeGraphConfig(max_graph_path="10")` → pydantic auto-converts to `max_graph_path: int = 10` → YAML outputs `max_graph_path: 10` (integer). -### 5.3. 环境变量覆盖 (env > YAML) +### 5.3. Environment Variable Override (env > YAML) -优先级: **环境变量 (os.environ) > config.yaml > pydantic 默认值** +Priority: **os.environ > config.yaml > pydantic defaults** ``` ConfigManager.get_section_with_env_override(section_name, model_class): - 1. yaml_section = _cfg.get(section_name) # 从 OmegaConf 读取 (嵌套 DictConfig) - 2. raw_dict = OmegaConf.to_container(yaml_section) # 转为 Python dict (嵌套) - 3. # 嵌套 → 扁平转换 + 1. yaml_section = _cfg.get(section_name) # OmegaConf DictConfig (nested) + 2. raw_dict = OmegaConf.to_container(yaml_section) # Python dict (nested) + 3. # Nested → flat conversion section_dict = _nested_to_flat(raw_dict, model_class._flat_to_nested_mapping) - 4. # 用环境变量覆盖扁平 dict 中的值(env 优先级更高) + 4. # Override with env vars (env has higher priority) FOR field_name, field_info IN model_class.model_fields.items(): env_var_name = model_class._env_var_map.get(field_name, field_name.upper()) env_value = os.environ.get(env_var_name) IF env_value IS NOT None: - # 通过 pydantic TypeAdapter 进行类型转换 + # Type conversion via pydantic TypeAdapter section_dict[field_name] = TypeAdapter(field_info.annotation).validate_python(env_value) - 5. RETURN section_dict # 扁平 dict,直接传给 pydantic BaseModel + 5. RETURN section_dict # Flat dict, ready for pydantic BaseModel ``` -具体 env var mapping (来自 requirements U5-U7): -- `OPENAI_API_KEY` → 覆盖 `openai_*_api_key` -- `OPENAI_BASE_URL` → 覆盖 `openai_*_api_base` -- `OPENAI_EMBEDDING_BASE_URL` → 覆盖 `openai_embedding_api_base` -- `CO_API_URL` → 覆盖 `cohere_base_url` -- Qdrant/Milvus 连接参数 ← 对应环境变量 +Specific env var mapping (from requirements U5-U7): +- `OPENAI_API_KEY` → overrides `openai_*_api_key` +- `OPENAI_BASE_URL` → overrides `openai_*_api_base` +- `OPENAI_EMBEDDING_BASE_URL` → overrides `openai_embedding_api_base` +- `CO_API_URL` → overrides `cohere_base_url` +- Qdrant/Milvus connection params ← corresponding env vars -注意: `config.yaml` 作为持久化存储和默认值源,环境变量用于注入敏感信息或容器化部署覆盖。修改 `config.yaml` 不会覆盖已设置的环境变量。 +Note: `config.yaml` serves as persistent storage and default value source. Environment variables are used for injecting secrets or overriding in containerized deployments. Editing `config.yaml` does not overwrite already-set environment variables. -### 5.4. 文件监控与热加载 +### 5.4. File Monitoring & Hot-Reload ``` -FileWatcher (后台 daemon 线程): - interval = cfg.admin.config_reload_interval (默认 5, 从 YAML 读取) +FileWatcher (background daemon thread): + interval = cfg.admin.config_reload_interval (default 5, read from YAML) IF interval <= 0: log.info("Hot reload disabled (config_reload_interval <= 0)") RETURN @@ -475,77 +477,76 @@ FileWatcher (后台 daemon 线程): ``` ConfigManager.reload(): 1. new_cfg = OmegaConf.load(config.yaml) - 2. FOR section_name, singleton IN [("llm", llm_settings), ...]: - section_data = new_cfg.get(section_name, {}) - # 尝试通过 pydantic 校验 - singleton_class = type(singleton) - validated = singleton_class(**OmegaConf.to_container(section_data)) - # 校验通过 → 同步字段到现有单例 - FOR field IN validated.model_dump(): - setattr(singleton, field, value) + 2. FOR section_name, model_class IN self._sections: + raw_dict = OmegaConf.to_container(new_cfg[section_name]) + flat_dict = _nested_to_flat(raw_dict, mapping) + # Validate via pydantic + model_class(**flat_dict) 3. _cfg = new_cfg - 4. log.info("Config reloaded successfully") - 5. RETURN True + 4. FOR section_name, config_obj IN self._reload_targets: + config_obj.check_config() + 5. log.info("Config reloaded successfully") + 6. RETURN True EXCEPT Exception: log.error("Invalid config: ...") - RETURN False # 保留当前内存配置 + RETURN False # Keep current in-memory config ``` -### 5.5. BaseConfig 初始化同步 +### 5.5. BaseConfig Initialization Sync ``` BaseConfig.__init__(**data): 1. cfg_mgr = ConfigManager() - 2. # 从 YAML 读取 + 环境变量覆盖 (env 更高优先级) + 2. # Load from YAML + env override section_data = cfg_mgr.get_section_with_env_override(_config_section, type(self)) - # section_data 已是扁平 dict(经 _nested_to_flat 转换) - 3. # 合并显式传入的 data (最高优先级) + # section_data is already flat (via _nested_to_flat) + 3. # Merge explicitly passed data (highest priority) section_data.update(data) - 4. # pydantic 校验 + 4. # pydantic validation super().__init__(**section_data) - 5. # 写回缺失的默认值到 YAML + 5. # Write missing defaults back to YAML cfg_mgr.update_section(_config_section, self) - # update_section 内部: model.dump() → _flat_to_nested(mapping) → OmegaConf.create() + # update_section: model.dump() → _flat_to_nested(mapping) → OmegaConf.create() cfg_mgr.save() ``` -注意: 步骤 6 确保新增的 pydantic 字段(带默认值)会自动写入 YAML,用户升级后无需手动编辑。 +Note: Step 5 ensures newly added pydantic fields (with defaults) are automatically written to YAML, so users don't need to manually edit after upgrades. -## 6. 非功能性需求 +## 6. Non-Functional Requirements -- **安全性**: `config.yaml` 加入 `.gitignore`(与 `.env` 同级),防止意外提交敏感信息。 -- **性能**: 文件监控使用 `os.path.getmtime()` 零开销轮询。YAML 加载仅在启动或变更时触发,不影响正常请求性能。 -- **向后兼容**: 所有 46 个 consumer 文件的 `from hugegraph_llm.config import llm_settings` 和 `llm_settings.language` 属性访问保持不变。 -- **错误恢复**: 热加载失败时保留内存有效配置,不中断服务。启动时 `config.yaml` 损坏时回退到 pydantic 默认值。 +- **Security**: `config.yaml` added to `.gitignore` (same level as `.env`), preventing accidental commit of sensitive info. +- **Performance**: File monitoring uses `os.path.getmtime()` zero-overhead polling. YAML loading only triggered at startup or on change, not impacting normal request performance. +- **Backward Compatibility**: All 46 consumer files using `from hugegraph_llm.config import llm_settings` and `llm_settings.language` attribute access remain unchanged. +- **Error Recovery**: Hot-reload failure retains valid in-memory config, no service interruption. Damaged `config.yaml` at startup falls back to pydantic defaults. -## 7. 测试策略 +## 7. Test Strategy -- **单元测试**: - - `ConfigManager._migrate_from_env()` — .env 各类型字段正确转换为 YAML section - - `BaseConfig.__init__()` — 从 YAML 加载、环境变量 fallback、默认值回写 - - `BaseConfig.update_config()` — pydantic 字段变化正确持久化到 YAML - - `ConfigManager.reload()` — 合法/非法 YAML 的处理 - - `ConfigManager` 空文件、不存在文件、损坏文件的容错 -- **集成测试**: - - 首次启动(无任何文件)→ 生成带默认值的 `config.yaml` - - `.env` 存在 → 自动迁移到 `config.yaml` - - 环境变量覆盖 YAML 中的 None 值 - - Gradio UI 修改配置 → `config.yaml` 更新 - - 手动编辑 `config.yaml` → FileWatcher 检测并热加载 -- **回归测试**: - - 现有 `test_config.py` 通过 - - `ruff format --check` + `ruff check` 无错误 +- **Unit Tests**: + - `ConfigManager._migrate_from_env()` — .env fields correctly converted to YAML sections with types + - `BaseConfig.__init__()` — YAML load, env var fallback, default write-back + - `BaseConfig.update_config()` — pydantic field changes correctly persisted to YAML + - `ConfigManager.reload()` — valid/invalid YAML handling + - `ConfigManager` resilience — empty file, missing file, corrupted file +- **Integration Tests**: + - First startup (no files) → generates `config.yaml` with defaults + - `.env` exists → auto-migrates to `config.yaml` + - Env vars override YAML `None` values + - Gradio UI config modification → `config.yaml` updated + - Manual `config.yaml` edit → FileWatcher detects and hot-reloads +- **Regression Tests**: + - Existing `test_config.py` passes + - `ruff format --check` + `ruff check` no errors -## 8. 风险与缓解措施 +## 8. Risks & Mitigations -- **风险 1**: 从 `BaseSettings` 切到 `BaseModel` 后,`os.environ.get()` 默认值仍写在 pydantic field 定义中(如 `llm_config.py` 第 39 行),这些默认值在 pydantic 初始化时会被求值。如果环境变量在模块加载前未设置,默认值可能为 `None`。 - - **缓解**: ConfigManager 在 `get_section_with_fallback()` 中显式检查 `os.environ`,而不依赖 pydantic field default 的执行时机。field default 中的 `os.environ.get()` 调用保留作为文档化意图,但实际 fallback 由 ConfigManager 控制。 +- **Risk 1**: Switching from `BaseSettings` to `BaseModel` — `os.environ.get()` defaults previously written in pydantic field definitions may not be evaluated at the right time. + - **Mitigation**: ConfigManager explicitly checks `os.environ` in `get_section_with_env_override()`. Field defaults with `os.environ.get()` are removed; actual fallback is controlled by ConfigManager. -- **风险 2**: 后台 FileWatcher 线程在 Python 进程退出时可能导致资源泄漏或 hang。 - - **缓解**: FileWatcher 使用 `daemon=True` 线程,随主进程退出自动终止。`_watching` flag 在 `atexit` 中设为 False。 +- **Risk 2**: Background FileWatcher thread could cause resource leaks or hangs on Python process exit. + - **Mitigation**: FileWatcher uses `daemon=True` thread, auto-terminates with main process. `_watching` flag enables clean shutdown. -- **风险 3**: 轮询间隔期间用户修改了 YAML 两次(如保存两次),Watcher 可能在第一次变更后正在 reload 时,第二次变更到来导致竞争。 - - **缓解**: `reload()` 方法使用 `threading.Lock` 防止并发重入。检测到 mtime 变化且未在 reload 中时跳过额外触发。 +- **Risk 3**: During polling interval, user may save YAML twice, causing a race between reload and a second change detection. + - **Mitigation**: `reload()` uses `threading.Lock` to prevent concurrent re-entry. Redundant mtime changes during an active reload are skipped. -- **风险 4**: `AdminConfig` 新增 `config_reload_interval` 字段,需要确保 `.env` 迁移时不会丢失该字段(旧 `.env` 中不存在)。 - - **缓解**: 迁移时使用 `model_dump()` 获取所有字段(含默认值),新字段自动以默认值写入 YAML。 +- **Risk 4**: `AdminConfig` adds a new `config_reload_interval` field — must ensure `.env` migration doesn't lose it (old `.env` won't have it). + - **Mitigation**: Migration uses `model_dump()` to get all fields (including defaults), so new fields are automatically written to YAML with default values. diff --git a/.workflow/yaml-config-migration/requirements.md b/.workflow/yaml-config-migration/requirements.md index 7b88533b3..3e5fd8a4d 100644 --- a/.workflow/yaml-config-migration/requirements.md +++ b/.workflow/yaml-config-migration/requirements.md @@ -1,105 +1,105 @@ -# 需求文档: 将 .env 配置迁移到 YAML (使用 OmegaConf) +# Requirements: Migrate .env Configuration to YAML with OmegaConf -## 1. 介绍 +## 1. Introduction -当前 hugegraph-llm 项目的用户配置(图服务器连接、LLM 提供商密钥/模型、向量索引参数等)通过扁平的 `.env` 文件管理,底层使用 `pydantic-settings` + `python-dotenv`。详见 [`config.md`](../../hugegraph-llm/config.md)。 +The hugegraph-llm project currently manages user configuration (graph server connections, LLM provider keys/models, vector index parameters, etc.) via a flat `.env` file using `pydantic-settings` + `python-dotenv`. See [`config.md`](../../hugegraph-llm/config.md). -**痛点**: -- `.env` 扁平无结构,60+ 配置项难以浏览和维护 -- 所有值均为字符串,类型信息丢失 -- 没有文件变更自动感知/重载能力 —— 用户直接编辑配置文件后必须重启应用 -- 修改配置需要 `setattr` + `update_env()` 两步操作,API 不直观 +**Pain Points**: +- Flat `.env` lacks structure — 60+ configuration items are hard to browse and maintain +- All values are strings, losing type information +- No file-change detection / auto-reload — users must restart the application after editing the config file +- Modifying configuration requires `setattr` + `update_env()` (two steps), unintuitive API -**目标**: 用单一结构化 YAML 文件 (`config.yaml`) 替代 `.env`,引入 OmegaConf 作为配置框架,保持现有 Python API 兼容,**并支持运行时检测外部文件变更后自动重载**。 +**Goal**: Replace `.env` with a single structured YAML file (`config.yaml`) managed by OmegaConf, keep the existing Python API compatible, and **support runtime auto-reload when the config file is externally modified**. -关联: Issue [#234](https://github.com/apache/hugegraph-ai/issues/234) | 先行 PR [#277](https://github.com/apache/hugegraph-ai/pull/277)(已过期/存在冲突)| 配置文档 [`config.md`](../../hugegraph-llm/config.md) +Related: Issue [#234](https://github.com/apache/hugegraph-ai/issues/234) | Prior PR [#277](https://github.com/apache/hugegraph-ai/pull/277) (outdated / has merge conflicts) | Config doc [`config.md`](../../hugegraph-llm/config.md) -### PR #277 Review 遗留问题 +### PR #277 Review Legacy Issues -| 来源 | 问题 | 需在本需求中覆盖 | -|------|------|-----------------| -| Copilot | `yaml_config[current_class_name]` 未初始化时可能 KeyError | 访问 section 前确保 key 存在 | -| Copilot | 注释拼写错误 `'onfig'` → `'config'` | 代码质量 | -| imbajin | `requirements.txt` EOF 缺少换行 | 文件格式 | -| CI | Pylint 检查失败 | 代码质量 | +| Source | Issue | Covered In | +|--------|-------|------------| +| Copilot | Possible KeyError when `yaml_config[current_class_name]` is not initialized | Ensure key exists before section access | +| Copilot | Typo in comment: `'onfig'` → `'config'` | Code quality | +| imbajin | Missing EOF newline in `requirements.txt` | File formatting | +| CI | Pylint check failure | Code quality | -## 2. 需求列表 +## 2. Requirements -### 2.1 配置存储格式从 .env 迁移到 YAML +### 2.1 Configuration Storage Migration from .env to YAML -- **用户故事**: 作为一名 **hugegraph-llm 的运维/开发者**, 我希望 **所有应用配置存储在一个结构化的 `config.yaml` 文件中**, 以便 **更容易阅读、编辑和版本管理**。 +- **User Story**: As a **hugegraph-llm operator/developer**, I want **all application configuration stored in a structured `config.yaml` file**, so that **it is easier to read, edit, and version-control**. -- **验收标准 (EARS 格式)**: - - **U1**: The **`config.yaml`** shall **使用 OmegaConf 进行读写,顶层按配置类别分为 `llm`、`hugegraph`、`admin`、`index` 四个 section**。 - - **U2**: The **YAML 中的字段值** shall **保留正确的原始类型(整数、浮点数、布尔值、null、字符串)**。 - - **E1**: WHEN **应用首次启动且 `config.yaml` 不存在但 `.env` 存在**, the **系统** shall **自动读取 `.env` 数据,按字段名匹配到对应 section,经 pydantic 校验转类型后写入 `config.yaml`**。 - - **E2**: WHEN **应用首次启动且两个文件都不存在**, the **系统** shall **使用 pydantic 模型的默认值生成 `config.yaml`**。 - - **E3**: WHEN **用户在 Gradio UI 或 API 中修改配置并点击"应用"**, the **系统** shall **将变更持久化到 `config.yaml`**。 +- **Acceptance Criteria (EARS format)**: + - **U1**: The **`config.yaml`** shall **use OmegaConf for read/write, with top-level sections organized by config category: `llm`, `hugegraph`, `admin`, `index`**. + - **U2**: The **field values in YAML** shall **preserve correct original types (int, float, bool, null, string)**. + - **E1**: WHEN **the application first starts and `config.yaml` does not exist but `.env` exists**, the **system** shall **automatically read `.env` data, match fields by name to the corresponding section, convert types via pydantic validation, and write to `config.yaml`**. + - **E2**: WHEN **the application first starts and neither file exists**, the **system** shall **generate `config.yaml` using pydantic model default values**. + - **E3**: WHEN **the user modifies configuration in the Gradio UI or API and clicks "Apply"**, the **system** shall **persist changes to `config.yaml`**. -### 2.2 保持现有 Python 配置 API 兼容 +### 2.2 Maintain Existing Python Config API Compatibility -- **用户故事**: 作为一名 **导入 `hugegraph_llm.config` 的开发者**, 我希望 **`llm_settings.language`、`huge_settings.graph_url` 等属性访问方式不变**, 以便 **45+ 个消费文件无需修改**。 +- **User Story**: As a **developer importing `hugegraph_llm.config`**, I want **attribute access like `llm_settings.language` and `huge_settings.graph_url` to remain unchanged**, so that **45+ consumer files require no modification**. -- **验收标准 (EARS 格式)**: - - **U3**: The **`llm_settings`、`huge_settings`、`admin_settings`、`index_settings` 单例对象** shall **保持与重构前相同的属性读写方式**。 - - **U4**: The **pydantic 模型类 (`LLMConfig`、`HugeGraphConfig` 等)** shall **继续定义字段类型与默认值,作为配置的 schema 和校验层**。 - - **E4**: WHEN **调用 `update_config()`(替代原 `update_env()`)**, the **系统** shall **将当前 pydantic 模型值写回 OmegaConf 并保存到磁盘**。 +- **Acceptance Criteria (EARS format)**: + - **U3**: The **`llm_settings`, `huge_settings`, `admin_settings`, `index_settings` singleton objects** shall **maintain the same attribute read/write style as before the refactor**. + - **U4**: The **pydantic model classes (`LLMConfig`, `HugeGraphConfig`, etc.)** shall **continue to define field types and default values, serving as the config schema and validation layer**. + - **E4**: WHEN **`update_config()` (replacing the old `update_env()`) is called**, the **system** shall **write current pydantic model values back to OmegaConf and persist to disk**. -### 2.3 保持环境变量覆盖支持 +### 2.3 Maintain Environment Variable Override Support -- **用户故事**: 作为一名 **使用容器/K8s 部署的运维人员**, 我希望 **通过环境变量注入的敏感信息(如 `OPENAI_API_KEY`)能够覆盖 `config.yaml` 中的值**, 以便 **安全地管理密钥并遵循 12-factor app 最佳实践**。 +- **User Story**: As an **operator using container/K8s deployment**, I want **sensitive information injected via environment variables (e.g. `OPENAI_API_KEY`) to override values in `config.yaml`**, so that **secrets are managed securely following 12-factor app best practices**. -- **验收标准 (EARS 格式)**: - - **U5**: The **配置加载优先级** shall **为 `环境变量 (os.environ) > config.yaml > pydantic 默认值`,环境变量具有最高优先级**。 - - **U6**: The **OpenAI 配置字段 (`openai_*_api_key`, `openai_*_api_base`)** shall **被对应的环境变量 (`OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_EMBEDDING_BASE_URL`) 覆盖**。 - - **U7**: The **Cohere 配置字段 (`cohere_base_url`)** shall **被 `CO_API_URL` 环境变量覆盖**。 - - **U8**: The **向量数据库配置 (Qdrant/Milvus 连接参数)** shall **被对应环境变量覆盖**。 +- **Acceptance Criteria (EARS format)**: + - **U5**: The **config loading priority** shall **be `environment variables (os.environ) > config.yaml > pydantic defaults`, with environment variables having the highest priority**. + - **U6**: The **OpenAI config fields (`openai_*_api_key`, `openai_*_api_base`)** shall **be overridden by corresponding environment variables (`OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_EMBEDDING_BASE_URL`)**. + - **U7**: The **Cohere config field (`cohere_base_url`)** shall **be overridden by the `CO_API_URL` environment variable**. + - **U8**: The **vector database config (Qdrant/Milvus connection parameters)** shall **be overridden by corresponding environment variables**. -### 2.4 运行时配置热加载 +### 2.4 Runtime Configuration Hot-Reload -- **用户故事**: 作为一名 **直接编辑 `config.yaml` 的运维人员**, 我希望 **修改 `config.yaml` 后运行中的应用能自动检测并加载新配置**, 以便 **无需重启应用即可让配置变更生效**。 +- **User Story**: As an **operator directly editing `config.yaml`**, I want **a running application to automatically detect and load new configuration after I modify `config.yaml`**, so that **config changes take effect without restarting the application**. -- **验收标准 (EARS 格式)**: - - **E5**: WHEN **`config.yaml` 文件在外部被修改(如用户手动编辑)**, the **系统** shall **根据 `config.yaml` 中 `admin` section 的 `config_reload_interval` 字段值(默认 5 秒)检测到变更**。 - - **E6**: WHEN **检测到文件变更**, the **系统** shall **重新加载 `config.yaml`,将新值同步到对应的 pydantic 单例对象,并记录日志**。 - - **E7**: The **`admin` section** shall **包含 `config_reload_interval: int` 字段(默认 5,单位秒),控制文件变更检测轮询间隔**。 - - **E8**: WHEN **`config_reload_interval` 设为 0 或负数**, the **系统** shall **禁用热加载检测并记录日志**。 - - **X1**: IF **变更后的 `config.yaml` 包含非法值(类型错误、必填字段缺失等)**, THEN the **系统** shall **记录错误日志并保留当前内存中的有效配置,不中断运行**。 - - **X2**: IF **`config.yaml` 文件在加载过程中不存在或被删除**, THEN the **系统** shall **回退到 pydantic 模型默认值并记录警告日志**。 +- **Acceptance Criteria (EARS format)**: + - **E5**: WHEN **the `config.yaml` file is externally modified (e.g. manually edited by user)**, the **system** shall **detect the change according to the `config_reload_interval` field in the `admin` section of `config.yaml` (default 5 seconds)**. + - **E6**: WHEN **a file change is detected**, the **system** shall **reload `config.yaml`, sync new values to the corresponding pydantic singleton objects, and log the event**. + - **E7**: The **`admin` section** shall **include a `config_reload_interval: int` field (default 5, unit: seconds), controlling the file change detection polling interval**. + - **E8**: WHEN **`config_reload_interval` is set to 0 or negative**, the **system** shall **disable hot-reload detection and log the event**. + - **X1**: IF **the changed `config.yaml` contains invalid values (type errors, missing required fields, etc.)**, THEN the **system** shall **log an error and retain the current in-memory valid configuration without interrupting service**. + - **X2**: IF **the `config.yaml` file does not exist or is deleted during loading**, THEN the **system** shall **fall back to pydantic model defaults and log a warning**. -### 2.5 旧版 .env 兼容与平滑迁移 +### 2.5 Legacy .env Compatibility and Smooth Migration -- **用户故事**: 作为一名 **从旧版本升级的用户**, 我希望 **已有的 `.env` 能自动迁移为 `config.yaml`**, 以便 **升级过程无需手动重新配置**。 +- **User Story**: As a **user upgrading from an older version**, I want **my existing `.env` to be automatically migrated to `config.yaml`**, so that **the upgrade process requires no manual reconfiguration**. -- **验收标准 (EARS 格式)**: - - **E9**: WHEN **`.env` 存在而 `config.yaml` 不存在**, the **系统** shall **将 `.env` 中所有键值对(转为小写 key)与各 pydantic 模型的字段名匹配,分配到正确的 YAML section**。 - - **E10**: WHEN **迁移完成**, the **系统** shall **保留原有 `.env` 不动(不删除),作为备份**。 - - **X3**: IF **`.env` 中某字段无法匹配到任何已知 pydantic 模型字段**, THEN the **系统** shall **跳过该字段并输出警告日志,不阻断启动或迁移流程**。 +- **Acceptance Criteria (EARS format)**: + - **E9**: WHEN **`.env` exists and `config.yaml` does not exist**, the **system** shall **match all key-value pairs from `.env` (converted to lowercase keys) against each pydantic model's field names, allocating them to the correct YAML section**. + - **E10**: WHEN **migration completes**, the **system** shall **keep the original `.env` file untouched (do not delete), serving as a backup**. + - **X3**: IF **a field in `.env` cannot be matched to any known pydantic model field**, THEN the **system** shall **skip that field and output a warning log, without blocking startup or migration**. -### 2.6 CLI 配置生成工具更新 +### 2.6 CLI Config Generation Tool Update -- **用户故事**: 作为一名 **开发者**, 我希望 **`python -m hugegraph_llm.config.generate -U` 能生成 `config.yaml`**, 以便 **快速初始化配置**。 +- **User Story**: As a **developer**, I want **`python -m hugegraph_llm.config.generate -U` to generate `config.yaml`**, so that **I can quickly initialize configuration**. -- **验收标准 (EARS 格式)**: - - **E11**: WHEN **执行 `python -m hugegraph_llm.config.generate -U`**, the **系统** shall **生成包含所有默认值的 `config.yaml`,并同时生成/更新 `config_prompt.yaml`**。 +- **Acceptance Criteria (EARS format)**: + - **E11**: WHEN **`python -m hugegraph_llm.config.generate -U` is executed**, the **system** shall **generate `config.yaml` with all default values, and simultaneously generate/update `config_prompt.yaml`**. -### 2.7 Gradio UI 配置块适配 +### 2.7 Gradio UI Config Block Adaptation -- **用户故事**: 作为一名 **通过 Gradio Web UI 配置系统的用户**, 我希望 **所有配置修改仍然通过 UI 生效并持久化**, 以便 **使用体验不变**。 +- **User Story**: As a **user configuring the system via Gradio Web UI**, I want **all config modifications to still take effect and persist through the UI**, so that **the user experience remains unchanged**. -- **验收标准 (EARS 格式)**: - - **E12**: WHEN **用户在 Gradio UI 中点击"Apply Configuration"按钮**, the **系统** shall **将修改写入 pydantic 模型并调用 `update_config()` 持久化到 YAML**。 - - **X4**: IF **`update_config()` 调用失败**, THEN the **系统** shall **捕获异常并记录错误日志,不向用户抛出未处理的异常**。 +- **Acceptance Criteria (EARS format)**: + - **E12**: WHEN **the user clicks the "Apply Configuration" button in the Gradio UI**, the **system** shall **write changes to the pydantic model and call `update_config()` to persist to YAML**. + - **X4**: IF **the `update_config()` call fails**, THEN the **system** shall **catch the exception and log an error, without throwing an unhandled exception to the user**. -### 2.8 文档更新 +### 2.8 Documentation Update -- **用户故事**: 作为一名 **查阅 `config.md` 的开发者**, 我希望 **文档反映新的 YAML 配置方式和运行时热加载行为**, 以便 **正确理解和使用配置系统**。 +- **User Story**: As a **developer reading `config.md`**, I want **the documentation to reflect the new YAML configuration approach and runtime hot-reload behavior**, so that **I can correctly understand and use the config system**. -- **验收标准 (EARS 格式)**: - - **U9**: The **`hugegraph-llm/config.md`** shall **更新所有 `.env` 引用为 `config.yaml`,并新增热加载行为说明**。 +- **Acceptance Criteria (EARS format)**: + - **U9**: The **`hugegraph-llm/config.md`** shall **update all `.env` references to `config.yaml`, and add documentation of hot-reload behavior**. -### 2.9 非功能需求 +### 2.9 Non-Functional Requirements -- **U10**: The **现有测试 (263 个单元/集成)** shall **全部通过,无回归**。 -- **U11**: The **代码** shall **通过 `ruff format --check` 和 `ruff check`,无 lint 错误**。 -- **U12**: The **PR #277 的 3 个遗留 Review 问题** shall **在新实现中全部修复**。 +- **U10**: The **existing tests (263 unit/integration tests)** shall **all pass with no regressions**. +- **U11**: The **code** shall **pass `ruff format --check` and `ruff check` with no lint errors**. +- **U12**: The **3 legacy PR #277 review issues** shall **all be fixed in the new implementation**. diff --git a/.workflow/yaml-config-migration/tasks.md b/.workflow/yaml-config-migration/tasks.md index a32000cb0..ac47315b0 100644 --- a/.workflow/yaml-config-migration/tasks.md +++ b/.workflow/yaml-config-migration/tasks.md @@ -1,71 +1,71 @@ -# 实现计划: 将 .env 配置迁移到 YAML (使用 OmegaConf) +# Implementation Plan: Migrate .env Configuration to YAML with OmegaConf -- [x] 1. **研究与准备** `[优先级: 高]` +- [x] 1. **Research & Preparation** `[Priority: High]` - - [x] 1.1. 搜索代码库中 `update_env()`、`generate_env()`、`check_env()` 的所有调用点,确认无遗漏的迁移目标。 `(关联需求: E3, E12)` - - [x] 1.2. 确认 OmegaConf 包可用性:检查 PyPI 版本及与 Python 3.10 的兼容性。 `(关联需求: U1)` + - [x] 1.1. Search codebase for all `update_env()`, `generate_env()`, `check_env()` call sites to ensure no missed migration targets. `(Related: E3, E12)` + - [x] 1.2. Verify OmegaConf package availability: check PyPI version and Python 3.10 compatibility. `(Related: U1)` -- [x] 2. **添加 OmegaConf 依赖** `[优先级: 高]` +- [x] 2. **Add OmegaConf Dependency** `[Priority: High]` - - [x] 2.1. 在 `hugegraph-llm/pyproject.toml` 的 `dependencies` 中添加 `"omegaconf~=2.3"`。 `(关联需求: U1)` - - [x] 2.2. 在根 `pyproject.toml` 的 `constraint-dependencies` 中添加 `"omegaconf~=2.3"`。 `(关联需求: U1)` - - [x] 2.3. 执行 `uv sync` 安装 OmegaConf,验证导入成功。 `(关联需求: U1)` `(依赖于: 2.1, 2.2)` + - [x] 2.1. Add `"omegaconf~=2.3"` to `hugegraph-llm/pyproject.toml` `dependencies`. `(Related: U1)` + - [x] 2.2. Add `"omegaconf~=2.3"` to root `pyproject.toml` `constraint-dependencies`. `(Related: U1)` + - [x] 2.3. Run `uv sync` to install OmegaConf, verify import. `(Related: U1)` `(Depends on: 2.1, 2.2)` -- [x] 3. **重构 BaseConfig 核心** `[优先级: 高]` +- [x] 3. **Refactor BaseConfig Core** `[Priority: High]` - - [x] 3.1. 在 `base_config.py` 中创建 `ConfigManager` 单例类,实现 `load()`、`save()`、`get_section()`、`update_section()`、`reload()`、`_migrate_from_env()`、`get_section_with_env_override()` 方法。 `(关联需求: U1, U2, U5, E1, E2, E7)` - - [x] 3.2. 在 `ConfigManager` 中实现 `_start_file_watcher()` / `_stop_file_watcher()` 后台轮询线程(使用 `os.path.getmtime()`)。 `(关联需求: E5, E6, E8)` - - [x] 3.3. 重构 `BaseConfig`:从 `BaseSettings` 改为 `BaseModel`,添加 `_config_section` 类属性,重写 `__init__` 从 ConfigManager 加载配置。 `(关联需求: U3, U4)` - - [x] 3.4. 在 `BaseConfig` 中实现 `update_config()`、`generate_yaml()`、`check_config()` 方法,保留旧方法 (`update_env()` 等) 作为委托。 `(关联需求: E3, E4)` - - [x] 3.5. 处理 PR #277 遗留问题:KeyError 保护(访问前确保 section key 存在)、注释拼写修正。 `(关联需求: U12)` - - [x] 3.6. **新增 (未在原始计划中)**: 实现 `_flat_to_nested()` / `_nested_to_flat()` 双向转换工具函数,支持扁平 pydantic 字段名 ↔ 嵌套 YAML 结构。 + - [x] 3.1. Create `ConfigManager` singleton class in `base_config.py` with `load()`, `save()`, `get_section()`, `update_section()`, `reload()`, `_migrate_from_env()`, `get_section_with_env_override()` methods. `(Related: U1, U2, U5, E1, E2, E7)` + - [x] 3.2. Implement `_start_file_watcher()` / `_stop_file_watcher()` background polling thread in `ConfigManager` (using `os.path.getmtime()`). `(Related: E5, E6, E8)` + - [x] 3.3. Refactor `BaseConfig`: switch from `BaseSettings` to `BaseModel`, add `_config_section` class attribute, rewrite `__init__` to load config from ConfigManager. `(Related: U3, U4)` + - [x] 3.4. Implement `update_config()`, `generate_yaml()`, `check_config()` in `BaseConfig`, keep old methods (`update_env()`, etc.) as delegation stubs. `(Related: E3, E4)` + - [x] 3.5. Fix PR #277 legacy issues: KeyError protection (ensure section key exists before access), fix comment typos. `(Related: U12)` + - [x] 3.6. **New (not in original plan)**: Implement `_flat_to_nested()` / `_nested_to_flat()` bidirectional conversion utilities, supporting flat pydantic field names ↔ nested YAML structure. -- [x] 4. **更新各 Config 子类** `[优先级: 高]` `(依赖于: 3.3)` +- [x] 4. **Update Config Subclasses** `[Priority: High]` `(Depends on: 3.3)` - - [x] 4.1. `LLMConfig` 添加 `_config_section = "llm"` 和 `_flat_to_nested_mapping` (42 条 dot-notation path mapping)。添加 `_env_var_map` 支持自定义环境变量名映射。移除 `os.environ.get()` 默认值。 `(关联需求: U3)` - - [x] 4.2. `HugeGraphConfig` 添加 `_config_section = "hugegraph"` 和 `_flat_to_nested_mapping` (12 条 mapping: graph.*, query.*, vector.*, rerank.*)。 `(关联需求: U3)` - - [x] 4.3. `AdminConfig` 添加 `_config_section = "admin"`,`_flat_to_nested_mapping` (3 条 mapping: login.*),新增 `config_reload_interval: int = 5` 字段。 `(关联需求: E7)` - - [x] 4.4. `IndexConfig` 添加 `_config_section = "index"` 和 `_flat_to_nested_mapping` (7 条 mapping: qdrant.*, milvus.*)。移除 `os.environ.get()` 默认值。 `(关联需求: U3)` + - [x] 4.1. `LLMConfig`: add `_config_section = "llm"` and `_flat_to_nested_mapping` (42 dot-notation path mappings). Add `_env_var_map` for custom env var name mapping. Remove `os.environ.get()` defaults. `(Related: U3)` + - [x] 4.2. `HugeGraphConfig`: add `_config_section = "hugegraph"` and `_flat_to_nested_mapping` (12 mappings: graph.*, query.*, vector.*, rerank.*). `(Related: U3)` + - [x] 4.3. `AdminConfig`: add `_config_section = "admin"`, `_flat_to_nested_mapping` (3 mappings: login.*), add new `config_reload_interval: int = 5` field. `(Related: E7)` + - [x] 4.4. `IndexConfig`: add `_config_section = "index"` and `_flat_to_nested_mapping` (7 mappings: qdrant.*, milvus.*). Remove `os.environ.get()` defaults. `(Related: U3)` -- [x] 5. **更新配置初始化与生成工具** `[优先级: 高]` `(依赖于: 3.1, 3.3, 4.*)` +- [x] 5. **Update Config Initialization & Generation Tools** `[Priority: High]` `(Depends on: 3.1, 3.3, 4.*)` - - [x] 5.1. 更新 `config/__init__.py`:ConfigManager 在 config 单例之前初始化,传入 section→model_class 映射。 `(关联需求: U3, E2)` `(依赖于: 3.1, 4.*)` - - [x] 5.2. 更新 `config/generate.py`:`generate_env()` → `generate_yaml()` 全部 4 个 config 对象。 `(关联需求: E11)` `(依赖于: 3.4)` - - [x] 5.3. 更新 `config/models/base_prompt_config.py`:修正注释中 `.env` 引用为 `config.yaml`。 `(关联需求: U9)` `(依赖于: 3.*)` + - [x] 5.1. Update `config/__init__.py`: initialize ConfigManager before config singletons, pass section→model_class mapping. `(Related: U3, E2)` `(Depends on: 3.1, 4.*)` + - [x] 5.2. Update `config/generate.py`: `generate_env()` → `generate_yaml()` for all 4 config objects. `(Related: E11)` `(Depends on: 3.4)` + - [x] 5.3. Update `config/models/base_prompt_config.py`: fix comments referencing `.env` → `config.yaml`. `(Related: U9)` `(Depends on: 3.*)` -- [x] 6. **适配 Gradio UI 配置块** `[优先级: 中]` `(依赖于: 3.4)` +- [x] 6. **Adapt Gradio UI Config Blocks** `[Priority: Medium]` `(Depends on: 3.4)` - - [x] 6.1. 更新 `configs_block.py`:6 处 `update_env()` 调用替换为 `update_config()`。 `(关联需求: E12)` `(依赖于: 3.4)` - - [x] 6.2. 更新 `configs_block.py`:移除 `dotenv_values()` 直接读取 `.env` 的逻辑,改为从 config 对象读取 (`llm_settings.openai_extract_api_key`)。移除未使用的 `import os`。 `(关联需求: E12)` `(依赖于: 6.1)` - - [x] 6.3. 为 `update_config()` 调用添加异常捕获与错误日志(PR #277 X4 保护)。 `(关联需求: X4)` `(依赖于: 6.1)` + - [x] 6.1. Update `configs_block.py`: replace 6× `update_env()` calls with `update_config()`. `(Related: E12)` `(Depends on: 3.4)` + - [x] 6.2. Update `configs_block.py`: remove direct `.env` reading via `dotenv_values()`, read from config objects instead (`llm_settings.openai_extract_api_key`). Remove unused `import os`. `(Related: E12)` `(Depends on: 6.1)` + - [x] 6.3. Add exception handling and error logging for `update_config()` calls (PR #277 X4 protection). `(Related: X4)` `(Depends on: 6.1)` -- [x] 7. **更新 .gitignore** `[优先级: 中]` `(依赖于: 2.*)` +- [x] 7. **Update .gitignore** `[Priority: Medium]` `(Depends on: 2.*)` - - [x] 7.1. 在根 `.gitignore` 中添加 `config.yaml` 和 `config.yaml.bak`。 `(关联需求: U1)` + - [x] 7.1. Add `config.yaml` and `config.yaml.bak` to root `.gitignore`. `(Related: U1)` -- [x] 8. **更新文档** `[优先级: 中]` `(依赖于: 3.*, 4.*, 6.*)` +- [x] 8. **Update Documentation** `[Priority: Medium]` `(Depends on: 3.*, 4.*, 6.*)` - - [x] 8.1. 更新 `hugegraph-llm/config.md`:全面重写,反映嵌套 YAML section 结构、环境变量优先级 (os.environ > config.yaml > pydantic defaults)、热加载行为、配置 reload interval 说明。 `(关联需求: U9)` + - [x] 8.1. Update `hugegraph-llm/config.md`: full rewrite reflecting nested YAML section structure, env var priority (os.environ > config.yaml > pydantic defaults), hot-reload behavior, config reload interval documentation. `(Related: U9)` -- [x] 9. **安装依赖并运行 Lint 检查** `[优先级: 中]` `(依赖于: 2.*, 7.*)` +- [x] 9. **Install Dependencies & Run Lint** `[Priority: Medium]` `(Depends on: 2.*, 7.*)` - - [x] 9.1. 执行 `uv sync` 安装 OmegaConf。 `(依赖于: 2.*)` - - [x] 9.2. 执行 `ruff format --check` 和 `ruff check`,修复所有 lint 错误。 `(关联需求: U11, U12)` + - [x] 9.1. Run `uv sync` to install OmegaConf. `(Depends on: 2.*)` + - [x] 9.2. Run `ruff format --check` and `ruff check`, fix all lint errors. `(Related: U11, U12)` -- [x] 10. **运行现有测试确保无回归** `[优先级: 高]` `(依赖于: 3.*, 4.*, 5.*, 6.*, 9.*)` +- [x] 10. **Run Existing Tests (Regression Check)** `[Priority: High]` `(Depends on: 3.*, 4.*, 5.*, 6.*, 9.*)` - - [x] 10.1. 运行 `pytest` 确认所有测试通过 (7 个 config 测试全部通过)。 `(关联需求: U10)` - - [x] 10.2. 手动验证嵌套 YAML 生成:`from hugegraph_llm.config import ...` 生成嵌套结构 `config.yaml`。 `(关联需求: E11)` `(依赖于: 10.1)` - - [x] 10.3. 手动验证 `.env` 迁移流程:`.env` 存在时自动迁移到嵌套 `config.yaml`,已验证类型转换(int/float/bool/null 保留正确类型)。 `(关联需求: E9, E10)` `(依赖于: 10.1)` - - [x] 10.4. 手动验证环境变量覆盖:`os.environ` 覆盖 YAML 值(如 `MAX_GRAPH_PATH=99`)。 `(关联需求: U5)` - - [x] 10.5. 手动验证 OmegaConf dot-notation 读取嵌套 YAML:`cfg.llm.openai.chat.language_model`。 `(关联需求: U1, U2)` + - [x] 10.1. Run `pytest` — all 7 config tests pass. `(Related: U10)` + - [x] 10.2. Manual verification: `from hugegraph_llm.config import ...` generates nested `config.yaml`. `(Related: E11)` `(Depends on: 10.1)` + - [x] 10.3. Manual verification: `.env` exists → auto-migration to nested `config.yaml`, verified type conversion (int/float/bool/null preserved). `(Related: E9, E10)` `(Depends on: 10.1)` + - [x] 10.4. Manual verification: env var override — `os.environ` overrides YAML values (e.g. `MAX_GRAPH_PATH=99`). `(Related: U5)` + - [x] 10.5. Manual verification: OmegaConf dot-notation reads nested YAML — `cfg.llm.openai.chat.language_model`. `(Related: U1, U2)` -## 实现差异说明 +## Implementation Delta (vs. Original Plan) -以下为实际实现与最初计划的差异: +Key differences between the actual implementation and the initial plan: -1. **嵌套 YAML 结构**(原计划保持扁平):用户明确要求嵌套 YAML(`ollama: extract_port: 11434` 而非 `ollama_extract_port: 11434`)。通过 `_flat_to_nested_mapping` ClassVar + `_flat_to_nested()` / `_nested_to_flat()` 双向转换实现,pydantic 字段名保持扁平以兼容 46 个 consumer 文件。 +1. **Nested YAML structure** (original plan intended flat): User explicitly requested nested YAML (`ollama: extract_port: 11434` instead of `ollama_extract_port: 11434`). Implemented via `_flat_to_nested_mapping` ClassVar + `_flat_to_nested()` / `_nested_to_flat()` bidirectional conversion. Pydantic field names remain flat to maintain compatibility with 46 consumer files. -2. **OmegaConf.merge → OmegaConf.create**:`update_section()` 中使用 `OmegaConf.create(nested_dict)` 直接替换整个 section,避免 `OmegaConf.merge` 导致旧扁平 key 与新嵌套结构并存的问题。 +2. **OmegaConf.merge → OmegaConf.create**: `update_section()` uses `OmegaConf.create(nested_dict)` to fully replace the section, avoiding the bug where `OmegaConf.merge` preserves old flat keys alongside new nested structure. -3. **pydantic TypeAdapter** 用于环境变量类型转换:避免在 `get_section_with_env_override` 中创建临时 pydantic 实例导致的无限递归。 +3. **pydantic TypeAdapter** for env var type conversion: Avoids infinite recursion that occurred when creating temporary pydantic instances inside `get_section_with_env_override()`.