# 变更日志 #10 — .env 文件加载优先级优化

> 变更日期：2026-08-24
> 变更范围：`src/recruiter_msg/llm.py`

---

## 变更概述

优化 `.env` 文件加载逻辑，支持多路径回退：**当前目录** → **家目录**。若两者都不存在，则抛出异常并给出详细配置教程。用户无需在每个项目目录下配置 `.env`，可在家目录统一管理 LLM API 配置。

---

## 当前实现分析

### 现有机制

`llm.py` 中的 `LLMClient` 类直接使用 `os.getenv` 读取环境变量：

```python
self.api_key = api_key or os.getenv("LLM_API_KEY", "sk-1234")
self.base_url = (base_url or os.getenv("LLM_BASE_URL", "http://api.openai.com/v1")).rstrip("/")
self.model = model or os.getenv("LLM_MODEL", "deepseek-v3.1-terminus-chat")
self.timeout = timeout or float(os.getenv("LLM_TIMEOUT", "120"))
self.max_retries = max_retries or int(os.getenv("LLM_MAX_RETRIES", "3"))
```

### 问题

1. 依赖外部工具（如 `python-dotenv`）预加载 `.env`，项目未集成该依赖。
2. 无自动加载 `.env` 能力，用户需手动 `source .env` 或使用 IDE 插件。
3. 无法跨项目复用配置，每个项目目录需单独维护 `.env`。
4. 使用内置默认值（`sk-1234`）可能导致静默失败，难以排查。

---

## 设计方案

### 加载优先级

1. **显式路径**：用户通过 `env_file` 参数指定 `.env` 路径（最高优先级）
2. **当前目录**：`Path.cwd() / ".env"`
3. **家目录**：`Path.home() / ".env"`（最低优先级）

### 异常策略

- **EnvNotFoundError**：当前目录和家目录均无 `.env` 时抛出，包含详细配置教程
- **ValueError**：`.env` 文件存在但缺少 `LLM_API_KEY` 时抛出，提示必需配置项

### 实现方式

新增 `_load_dotenv(env_file: Optional[str] = None)` 函数，手动解析 `.env` 文件并写入 `os.environ`，支持引号包裹值。

---

## 文件变更清单

| 文件 | 变更类型 | 说明 |
|---|---|---|
| `src/recruiter_msg/llm.py` | 修改 | 新增 `EnvNotFoundError` 异常、`_load_dotenv` 返回加载路径并抛出异常、`LLMClient.__init__` 新增 `env_file` 参数和配置验证 |

---

## 详细变更

### src/recruiter_msg/llm.py

#### 变更前

```python
"""LLM 客户端 - 轻量级 OpenAI 兼容 API 客户端"""

from __future__ import annotations

import os
import json
import time
from typing import Optional, Union, Dict, List
from openai import OpenAI
from openai._exceptions import RateLimitError, APITimeoutError


class LLMClient:
    """
    轻量级 LLM 客户端，基于 openai 官方库封装。
    支持 OpenAI / DeepSeek / 本地 Ollama 等任何兼容 OpenAI Chat API 的服务。
    """

    def __init__(
        self,
        api_key: Optional[str] = None,
        base_url: Optional[str] = None,
        model: Optional[str] = None,
        timeout: Optional[float] = None,
        max_retries: Optional[int] = None,
    ):
        self.api_key = api_key or os.getenv("LLM_API_KEY", "sk-1234")
        self.base_url = (base_url or os.getenv("LLM_BASE_URL", "http://api.openai.com/v1")).rstrip("/")
        self.model = model or os.getenv("LLM_MODEL", "deepseek-v3.1-terminus-chat")
        self.timeout = timeout or float(os.getenv("LLM_TIMEOUT", "120"))
        self.max_retries = max_retries or int(os.getenv("LLM_MAX_RETRIES", "3"))

        self.client = OpenAI(
            api_key=self.api_key,
            base_url=self.base_url,
            timeout=self.timeout,
            max_retries=0,
        )
```

#### 变更后

```python
"""LLM 客户端 - 轻量级 OpenAI 兼容 API 客户端"""

from __future__ import annotations

import os
import json
import time
from pathlib import Path
from typing import Optional, Union, Dict, List
from openai import OpenAI
from openai._exceptions import RateLimitError, APITimeoutError


class EnvNotFoundError(Exception):
    """环境配置文件未找到异常"""
    pass


def _load_dotenv(env_file: Optional[str] = None) -> str:
    """手动加载 .env 文件，优先级：当前目录 > 家目录
    返回加载的 .env 文件路径，若未找到则抛出 EnvNotFoundError"""
    if env_file:
        env_path = Path(env_file)
        if env_path.exists():
            with open(env_path, "r", encoding="utf-8") as f:
                for line in f:
                    line = line.strip()
                    if not line or line.startswith("#") or "=" not in line:
                        continue
                    key, value = line.split("=", 1)
                    key = key.strip()
                    value = value.strip()
                    if value.startswith('"') and value.endswith('"'):
                        value = value[1:-1]
                    elif value.startswith("'") and value.endswith("'"):
                        value = value[1:-1]
                    os.environ[key] = value
            return str(env_path.absolute())
        else:
            raise EnvNotFoundError(f"指定的环境配置文件不存在: {env_file.absolute()}")

    # 当前目录 .env
    current_env = Path.cwd() / ".env"
    if current_env.exists():
        with open(current_env, "r", encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith("#") or "=" not in line:
                    continue
                key, value = line.split("=", 1)
                key = key.strip()
                value = value.strip()
                if value.startswith('"') and value.endswith('"'):
                    value = value[1:-1]
                elif value.startswith("'") and value.endswith("'"):
                    value = value[1:-1]
                os.environ[key] = value
        return str(current_env.absolute())

    # 家目录 .env
    home_env = Path.home() / ".env"
    if home_env.exists():
        with open(home_env, "r", encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith("#") or "=" not in line:
                    continue
                key, value = line.split("=", 1)
                key = key.strip()
                value = value.strip()
                if value.startswith('"') and value.endswith('"'):
                    value = value[1:-1]
                elif value.startswith("'") and value.endswith("'"):
                    value = value[1:-1]
                os.environ[key] = value
        return str(home_env.absolute())

    # 未找到任何 .env 文件
    raise EnvNotFoundError(
        "未找到环境配置文件 .env，请按以下步骤配置：\n"
        "1. 在当前目录创建 .env 文件：\n"
        "   echo 'LLM_API_KEY=your-api-key-here' > .env\n"
        "2. 或在家目录创建 .env 文件（推荐，跨项目复用）：\n"
        "   echo 'LLM_API_KEY=your-api-key-here' > ~/.env\n"
        "3. .env 文件内容示例：\n"
        "   LLM_API_KEY=your-api-key-here\n"
        "   LLM_BASE_URL=http://api.openai.com/v1\n"
        "   LLM_MODEL=deepseek-v3.1-terminus-chat\n"
        "   LLM_TIMEOUT=120\n"
        "   LLM_MAX_RETRIES=3\n"
        f"   当前目录: {Path.cwd()}\n"
        f"   家目录: {Path.home()}"
    )


class LLMClient:
    """
    轻量级 LLM 客户端，基于 openai 官方库封装。
    支持 OpenAI / DeepSeek / 本地 Ollama 等任何兼容 OpenAI Chat API 的服务。
    """

    def __init__(
        self,
        api_key: Optional[str] = None,
        base_url: Optional[str] = None,
        model: Optional[str] = None,
        timeout: Optional[float] = None,
        max_retries: Optional[int] = None,
        env_file: Optional[str] = None,
    ):
        loaded_env_path = _load_dotenv(env_file)

        self.api_key = api_key or os.getenv("LLM_API_KEY")
        self.base_url = (base_url or os.getenv("LLM_BASE_URL", "http://api.openai.com/v1")).rstrip("/")
        self.model = model or os.getenv("LLM_MODEL", "deepseek-v3.1-terminus-chat")
        self.timeout = timeout or float(os.getenv("LLM_TIMEOUT", "120"))
        self.max_retries = max_retries or int(os.getenv("LLM_MAX_RETRIES", "3"))

        if not self.api_key:
            raise ValueError(
                f"未找到 LLM_API_KEY 配置，请检查 .env 文件（已加载: {loaded_env_path}）。\n"
                "必需配置项：LLM_API_KEY"
            )

        self.client = OpenAI(
            api_key=self.api_key,
            base_url=self.base_url,
            timeout=self.timeout,
            max_retries=0,
        )
```

#### 变更点

| 位置 | 改造前 | 改造后 |
|---|---|---|
| 导入区 | 无 `Path` | 新增 `from pathlib import Path` |
| 新增异常类 | 无 | `class EnvNotFoundError(Exception)` |
| `_load_dotenv` 返回值 | `None` | `str`（返回加载的 .env 文件绝对路径） |
| `_load_dotenv` 未找到文件 | 静默返回 | 抛出 `EnvNotFoundError`，含详细配置教程 |
| `LLMClient.__init__` 参数 | 无 `env_file` | 新增 `env_file: Optional[str] = None` |
| `LLMClient.__init__` 首行 | 无 | `loaded_env_path = _load_dotenv(env_file)` |
| `LLMClient.__init__` api_key | 默认值 `"sk-1234"` | 无默认值，缺失时抛出 `ValueError` |

---

## 新增异常说明

### `EnvNotFoundError`

当当前目录和家目录均无 `.env` 文件时抛出，异常消息包含：

1. **当前目录创建命令**：`echo 'LLM_API_KEY=your-api-key-here' > .env`
2. **家目录创建命令**：`echo 'LLM_API_KEY=your-api-key-here' > ~/.env`
3. **.env 文件内容示例**：完整的 5 个配置项示例
4. **路径提示**：当前目录和家目录的绝对路径

### `ValueError`

当 `.env` 文件存在但缺少 `LLM_API_KEY` 时抛出，提示：
- 已加载的 `.env` 文件路径
- 必需配置项：`LLM_API_KEY`

---

## 新增函数说明

### `_load_dotenv(env_file: Optional[str] = None) -> str`

手动解析 `.env` 文件并写入 `os.environ`，返回加载的文件绝对路径，若未找到则抛出 `EnvNotFoundError`。

#### 解析规则

- 跳过空行、注释行（`#` 开头）
- 按 `=` 分割键值对，仅取第一个 `=`
- 去除键值两侧空白
- 支持双引号 `"` 和单引号 `'` 包裹值，自动去除

#### 加载逻辑

1. **显式路径**：若 `env_file` 指定，仅加载该路径，不存在则抛出异常
2. **当前目录**：`Path.cwd() / ".env"` 存在则加载并返回路径
3. **家目录**：`Path.home() / ".env"` 存在则加载并返回路径
4. **未找到**：抛出 `EnvNotFoundError`，含配置教程

---

## 使用示例

### 1. 使用家目录 .env（推荐）

```bash
# 在家目录创建 .env
echo "LLM_API_KEY=sk-xxx" > ~/.env

# 任意项目目录运行，自动加载家目录 .env
recruiter-msg run --llm
```

### 2. 使用当前目录 .env

```bash
# 在项目目录创建 .env
echo "LLM_API_KEY=sk-xxx" > .env

# 运行时优先加载当前目录 .env
recruiter-msg run --llm
```

### 3. 显式指定 .env 路径

```python
from recruiter_msg import LLMClient

client = LLMClient(env_file="/custom/path/.env")
```

### 4. 未配置 .env 时的错误提示

```bash
recruiter-msg run --llm
```

```
Traceback (most recent call last):
  ...
recruiter_msg.llm.EnvNotFoundError: 未找到环境配置文件 .env，请按以下步骤配置：
1. 在当前目录创建 .env 文件：
   echo 'LLM_API_KEY=your-api-key-here' > .env
2. 或在家目录创建 .env 文件（推荐，跨项目复用）：
   echo 'LLM_API_KEY=your-api-key-here' > ~/.env
3. .env 文件内容示例：
   LLM_API_KEY=your-api-key-here
   LLM_BASE_URL=http://api.openai.com/v1
   LLM_MODEL=deepseek-v3.1-terminus-chat
   LLM_TIMEOUT=120
   LLM_MAX_RETRIES=3
   当前目录: C:\Users\s84336076\IDEProjects\recruiter_msg
   家目录: C:\Users\s84336076
```

### 5. .env 缺少 LLM_API_KEY 时的错误提示

```bash
recruiter-msg run --llm
```

```
Traceback (most recent call last):
  ...
ValueError: 未找到 LLM_API_KEY 配置，请检查 .env 文件（已加载: C:\Users\s84336076\.env）。
必需配置项：LLM_API_KEY
```

---

## 设计原则

1. **零依赖**：不引入 `python-dotenv`，手动实现轻量级解析。
2. **用户友好**：家目录 `.env` 支持跨项目复用，减少重复配置。
3. **优先级明确**：显式路径 > 当前目录 > 家目录，避免配置冲突。
4. **快速失败**：未找到 `.env` 或缺少必需配置时立即报错，避免静默失败。
5. **清晰指引**：异常消息包含完整配置教程，用户无需查阅文档即可解决。
6. **向后兼容性破坏**：移除内置默认值（`sk-1234`），未配置 `.env` 时不再静默使用假密钥，强制用户配置。

---

## 向后兼容性

- **原有代码**：若外部已预加载 `.env`（如 IDE 插件），`os.getenv` 仍能读取，不受影响。
- **破坏性变更**：未配置 `.env` 时，原先使用默认值 `"sk-1234"`，现在直接抛出 `EnvNotFoundError`。这是有意为之，避免用户因未配置而使用无效密钥导致静默失败。