diff --git a/.env.example b/.env.example index 694285e..03a36c9 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ -# Reference only: am.py and start.bat do not automatically load .env files. -# PowerShell: $env:DEEPSEEK_API_KEY = "" -# Linux/macOS: export DEEPSEEK_API_KEY="" +# Copy this file to .env in the repository root and enter your DeepSeek API key. +# The application reads .env at startup. Restart after changing this file. +# The process environment takes precedence, including an explicitly empty value. +# Keep real credentials in .env; never put them in this example or source files. DEEPSEEK_API_KEY= diff --git a/AGENTS.md b/AGENTS.md index 4c2b7cc..5a83285 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ | Human support | `logistics_chat/human_service.py`; `logistics_chat/routes/human.py` | | Business / files | `logistics_chat/business_service.py`, `logistics_chat/file_service.py`; `logistics_chat/routes/` | | UI | `templates/`; `static/css/theme.css`: tokens; page CSS; `static/js/chat.js`: `doSend`, `I18N`; `static/js/admin.js`: `poll`, `sendReply`, `setChatView` | -| Startup / hooks | `logistics_chat/__init__.py`: `create_app`; `config.py`, `hooks.py`, `runtime.py` in that package | +| Startup / hooks | `logistics_chat/__init__.py`: `create_app`; `config.py`: `default_config`; `hooks.py`, `runtime.py` in that package | | CLI / tests | `start.bat`: `probe_python`; `scripts/`, `tests/`, `examples/requests/` | ## Efficient Search & File Boundaries @@ -35,12 +35,12 @@ Run from the repository root: ```powershell py -3 -m venv .venv .\.venv\Scripts\python.exe -m pip install -r requirements.txt -$env:DEEPSEEK_API_KEY = "" +Copy-Item .env.example .env # First run only; set DEEPSEEK_API_KEY inside .env. .\.venv\Scripts\python.exe am.py .\.venv\Scripts\python.exe -m unittest discover -s tests -v ``` -Commands use project `.venv`. Optional `start.bat` prefers active `VIRTUAL_ENV`; verify selection with `start.bat --check-python`. No frontend build is required. +Optional `start.bat` prefers active `VIRTUAL_ENV`; verify selection with `start.bat --check-python`. No frontend build is required. ## Coding Style & Naming Conventions @@ -58,4 +58,4 @@ History uses concise Chinese summaries. Keep commits focused. PRs explain behavi ## Security & Configuration Tips -Never commit credentials or runtime/customer data. Revoke exposed keys. Keep `uploads/.gitkeep`. `.env` is not loaded automatically. Imports are inert; `create_app` initializes runtime storage unless `INITIALIZE_RUNTIME=False`. Default HOST remains `0.0.0.0`. Missing `agents/orders/tickets` initialization and complete admin authentication remain unresolved. +Never commit credentials or runtime/customer data. Revoke exposed keys. Keep `uploads/.gitkeep`. Startup reads the root `.env`; environment variables override it. Restart after changes. Imports are inert; `create_app` initializes runtime storage unless `INITIALIZE_RUNTIME=False`. Default HOST remains `0.0.0.0`. Missing `agents/orders/tickets` initialization and complete admin authentication remain unresolved. diff --git a/README.md b/README.md index 41ff95a..ce7bd0a 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,8 @@ ```powershell py -3 -m venv .venv .\.venv\Scripts\python.exe -m pip install -r requirements.txt -$env:DEEPSEEK_API_KEY = "" +Copy-Item .env.example .env # 仅首次运行;已有 .env 时跳过 +# 编辑 .env,填写 DEEPSEEK_API_KEY 后再启动 .\.venv\Scripts\python.exe am.py ``` @@ -96,17 +97,20 @@ $env:DEEPSEEK_API_KEY = "" ```bash python3 -m venv .venv .venv/bin/python -m pip install -r requirements.txt -export DEEPSEEK_API_KEY="" +cp .env.example .env # 仅首次运行;已有 .env 时跳过 +# 编辑 .env,填写 DEEPSEEK_API_KEY 后再启动 .venv/bin/python am.py ``` ### 配置说明 -- [`.env.example`](.env.example) 仅列出配置格式,当前程序和启动脚本**不会自动读取 `.env`**;请在环境变量中配置密钥。 -- PowerShell 的 `$env:DEEPSEEK_API_KEY` 只影响当前终端及其子进程。双击启动脚本时,需要提前配置系统环境变量并重新打开窗口。 +- 本地密钥放在项目根目录的 `.env` 中:首次运行复制 [`.env.example`](.env.example),填写 `DEEPSEEK_API_KEY=你的密钥`。`.env` 已被 Git 忽略,示例文件中的值应保持为空。 +- 应用启动时自动读取根目录 `.env`,不依赖终端的当前目录;`am.py`、Windows 启动脚本和 WSGI 应用工厂均使用同一配置入口。修改密钥后须重启服务。 +- 配置优先级为:显式传给 `create_app` 的配置 > 进程环境变量 > `.env` > 空值。环境变量即使为空也会覆盖 `.env`;本地填写后仍提示未配置时,请检查并移除旧的空环境变量。 +- 部署环境仍可注入 `DEEPSEEK_API_KEY`。PowerShell 的 `$env:DEEPSEEK_API_KEY` 只影响当前终端及其子进程,双击启动脚本可直接使用 `.env`,无需配置系统环境变量。 - 密钥申请或更换可在 [DeepSeek 平台](https://platform.deepseek.com/api_keys) 完成;不要把密钥写入脚本、提交到 Git,或打印到日志。 - 当前服务监听 `0.0.0.0:5000`,局域网设备可能访问。仅供本机开发时,将 `logistics_chat/config.py` 中默认的 `HOST` 设置为 `"127.0.0.1"`。 -- 历史启动脚本曾提交明文密钥。当前文件已移除该配置,但 Git 历史仍保留旧内容,应在平台作废旧密钥并通过环境变量配置新密钥。 +- 历史启动脚本曾提交明文密钥。当前文件已移除该配置,但 Git 历史仍保留旧内容,应在平台作废旧密钥,并在 `.env` 或部署环境中配置新密钥。 ## 目录结构 @@ -116,7 +120,7 @@ baiyue-logistics-chat/ ├── rates_extra.py 扩展数据旧导入路径的兼容转发 ├── logistics_chat/ │ ├── __init__.py create_app 应用工厂 -│ ├── config.py 环境与绝对路径配置 +│ ├── config.py .env、环境变量与绝对路径配置 │ ├── db.py SQLite、历史持久化、备份 │ ├── knowledge.py 知识与关键词检索 │ ├── pricing.py 运费解析与计算 @@ -164,7 +168,7 @@ baiyue-logistics-chat/ 安装 Node.js 后,Python 测试还会运行 `tests/frontend/admin.test.cjs`,验证发送失败保留草稿、重复点击、切换会话、输入法及手机抽屉;也可单独执行 `node --test tests/frontend/admin.test.cjs`,无需安装 npm 依赖。未安装 Node.js 时该项明确跳过。 -覆盖范围包括运费边界与单位换算、知识检索、运单识别、旧 SQLite schema 迁移、事务回滚、附件、人工状态、历史、CSV、SSE 压缩/追问/异常/取消,以及 Windows 解释器选择。Windows 启动测试在其他系统上跳过,三个页面的外置资源也由接口测试检查。 +覆盖范围包括配置文件加载与优先级、运费边界与单位换算、知识检索、运单识别、旧 SQLite schema 迁移、事务回滚、附件、人工状态、历史、CSV、SSE 压缩/追问/异常/取消,以及 Windows 解释器选择。Windows 启动测试在其他系统上跳过,三个页面的外置资源也由接口测试检查。 模型请求使用模拟响应,数据库和附件使用临时目录;这不代表完成真实 DeepSeek 或真实物流接口联调。旧文档提及的原始 52 个业务测试和 29 个场景记录未随仓库提供,当前结果以实际测试输出为准。 diff --git a/docs/architecture.md b/docs/architecture.md index 5445e63..96c27ca 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -31,7 +31,7 @@ PowerShell 下把目录和 `-g '*.py'` 分开传给 `rg`。报价字典可能整 | 附件 | `logistics_chat/file_service.py`:`upload_file`、`cleanup_uploads`;`routes/files.py`:`uploaded_file` | 扩展名/大小/目录由配置提供;客户端 `addMsg`,客服端 `appendMsg` | | 登录、统计、订单、留言、导出 | `logistics_chat/business_service.py`:`admin_login`、`admin_stats`、`my_orders`、`add_ticket`、`list_tickets`、`export_chat` | `routes/admin.py`、`routes/business.py`;登录页 `static/js/admin_login.js` | | 页面 | `templates/index.html`、`templates/admin.html`、`templates/admin_login.html` | 先加载共享 `static/css/theme.css`,再加载对应 `chat`、`admin`、`admin_login` 的 CSS/JS;保留全局事件入口 | -| 回归 | `tests/test_rules.py`、`test_storage.py`、`test_api.py`、`test_startup.py`、`test_maintenance_scripts.py`、`test_frontend.py`;`tests/frontend/admin.test.cjs` | 数据、HTTP 与 SSE 使用隔离配置和模拟模型;Node 内置测试验证草稿、异步发送和服务资料抽屉;浏览器使用临时测试库 | +| 回归 | `tests/test_rules.py`、`test_storage.py`、`test_api.py`、`test_config.py`、`test_startup.py`、`test_maintenance_scripts.py`、`test_frontend.py`;`tests/frontend/admin.test.cjs` | 配置测试隔离 `.env` 和环境变量;数据、HTTP 与 SSE 使用隔离配置和模拟模型;Node 内置测试验证草稿、异步发送和服务资料抽屉;浏览器使用临时测试库 | 除标明的根目录文件外,表内缩写的 Python 文件均相对 `logistics_chat/`。 @@ -42,7 +42,7 @@ am.py # CLI;显式请求 am.app 时创建默认 WSGI rates_extra.py # 旧导入路径的兼容转发 logistics_chat/ ├── __init__.py # create_app 与显式运行时初始化 -├── config.py # 环境变量、稳定根路径、服务/资源配置 +├── config.py # 根目录 .env、环境变量、稳定根路径、服务/资源配置 ├── db.py # 连接、schema、历史持久化、备份 ├── knowledge.py # 知识与关键词检索 ├── pricing.py # 运费规则、解析、换算、附加费说明 @@ -79,6 +79,7 @@ tests/ # 按业务模块组织的回归 - 所有模型 HTTP 经 `ai_client.post_completion`。保留流式问答 `(5, 90)`、压缩 `(5, 30)`、追问 `(5, 15)` 三组连接/读取超时;不能因共用客户端而合并期限。流结束或取消时释放 HTTP 响应。 - 模块导入不建库、不备份、不清理附件、不访问模型。`create_app` 默认显式初始化运行目录、数据库、备份与旧附件清理;`INITIALIZE_RUNTIME=False` 可禁止这些动作,此时调用方负责提供已有 schema。访问兼容入口 `am.app` 视为显式创建应用。 - `config.ROOT_DIR` 指向仓库根目录,模板/静态目录显式提供给 Flask。数据库、上传、备份、日志使用配置中的绝对路径,避免包文件移动改变数据位置。 +- `default_config` 用 `python-dotenv` 读取根目录 `.env`,不修改 `os.environ`;只读取已支持的 `DEEPSEEK_API_KEY`,不展开 `${...}`。显式工厂配置优先于进程环境(含空值),再使用文件值。配置在应用创建时确定,修改后重启;真实 `.env` 不纳入 Git。 - 限流、输入状态由应用实例持有,不能每请求重置或跨测试应用共享。维护 CLI 和纯规则可用标准库加载,包入口延迟导入 Web 依赖。 - 单文件围绕一个职责;超过约 300–400 行时检查职责,而非按行数机械拆分。报价数据保持单一来源;兼容转发不复制字典。页面脚本按页面保留既有执行顺序和全局函数,不擅自改为 ES Modules。 - 配色、字体基线和通用控件放在 `theme.css`,页面布局放在对应 CSS。客户页通过 `body.dark` 覆盖主题令牌;新增可翻译文案加入 `chat.js` 的 `I18N`,静态文案用 `data-i18n`。调整样式后检查桌面、390px 窄屏、侧栏、聊天卡片和客服空/活动状态。 diff --git a/logistics_chat/chat_service.py b/logistics_chat/chat_service.py index 600ed7e..f1f8b8e 100644 --- a/logistics_chat/chat_service.py +++ b/logistics_chat/chat_service.py @@ -36,7 +36,7 @@ def create_chat_stream(msg, conv_id): yield sse({'d': 1}) return (track_stream(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) if not get_setting('DEEPSEEK_API_KEY'): - return (sse({'e': '服务未配置 API Key,请设置环境变量 DEEPSEEK_API_KEY 后重启服务'}), {}) + return (sse({'e': '服务未配置 API Key,请在 .env 或环境变量中设置 DEEPSEEK_API_KEY 后重启服务'}), {}) know_text = search_knowledge(msg) ship = parse_shipping_intent(msg) ship['dim_unit'] = detect_dim_unit(msg) diff --git a/logistics_chat/config.py b/logistics_chat/config.py index c28c873..2587a14 100644 --- a/logistics_chat/config.py +++ b/logistics_chat/config.py @@ -1,4 +1,4 @@ -"""Configuration defaults; importing this module does not initialize storage.""" +"""Read local configuration without changing the process environment or storage.""" import os from pathlib import Path @@ -10,12 +10,16 @@ MAX_UPLOAD_MB = 10 def default_config(): + from dotenv import dotenv_values + + local_config = dotenv_values(ROOT_DIR / '.env', encoding='utf-8-sig', interpolate=False) + api_key = os.environ.get('DEEPSEEK_API_KEY', local_config.get('DEEPSEEK_API_KEY')) return { 'DATABASE': ROOT_DIR / 'history.db', 'UPLOAD_DIR': ROOT_DIR / 'uploads', 'BACKUP_DIR': ROOT_DIR / 'backups', 'LOG_DIR': ROOT_DIR / 'logs', - 'DEEPSEEK_API_KEY': os.environ.get('DEEPSEEK_API_KEY', '').strip(), + 'DEEPSEEK_API_KEY': (api_key or '').strip(), 'MODEL_URL': 'https://api.deepseek.com/v1/chat/completions', 'MODEL_NAME': 'deepseek-chat', 'MAX_CONTEXT': 4, diff --git a/requirements.txt b/requirements.txt index 3ad8e2a..bfe1ad8 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/start.bat b/start.bat index ad2a3ed..2ec3d0c 100644 --- a/start.bat +++ b/start.bat @@ -59,21 +59,8 @@ if not errorlevel 1 ( ) echo OK - port is free. -echo [2/3] Checking DEEPSEEK_API_KEY ... -if not defined DEEPSEEK_API_KEY ( - echo. - echo [WARN] DEEPSEEK_API_KEY is empty in this window. - echo .env files are not loaded automatically. - echo If you just ran setx, reopen this window or restart VS Code. - echo setx DEEPSEEK_API_KEY "sk-xxxxxxxx" - echo. - pause -) else ( - echo OK - key detected. -) - -echo [3/3] Checking dependencies ... -"%PYTHON_EXE%" %PYTHON_ARGS% -c "import flask, requests, waitress" >nul 2>nul +echo [2/3] Checking dependencies ... +"%PYTHON_EXE%" %PYTHON_ARGS% -c "import flask, requests, waitress, dotenv" >nul 2>nul if errorlevel 1 ( echo Missing packages, installing ... "%PYTHON_EXE%" %PYTHON_ARGS% -m pip install -r requirements.txt @@ -85,6 +72,15 @@ if errorlevel 1 ( ) echo OK - dependencies ready. +echo [3/3] Checking DeepSeek configuration ... +"%PYTHON_EXE%" %PYTHON_ARGS% -c "import sys; from logistics_chat.config import default_config; sys.exit(0 if default_config()['DEEPSEEK_API_KEY'] else 1)" >nul +if errorlevel 1 ( + echo [WARN] No DeepSeek API key configured. + echo Copy .env.example to .env and set DEEPSEEK_API_KEY, then restart. +) else ( + echo OK - key detected in .env or environment. +) + echo. echo ============================================ echo Starting: http://127.0.0.1:5000 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..da54ed7 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,96 @@ +"""Configuration boundaries; credentials and model calls use synthetic fixtures.""" + +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch, sentinel + +from logistics_chat import config, create_app + + +class ConfigurationTests(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name) + root_patch = patch.object(config, 'ROOT_DIR', self.root) + root_patch.start() + self.addCleanup(root_patch.stop) + environment = patch.dict(os.environ) + environment.start() + self.addCleanup(environment.stop) + os.environ.pop('DEEPSEEK_API_KEY', None) + + def write_env(self, text): + (self.root / '.env').write_text(text, encoding='utf-8') + + def application(self, **overrides): + return create_app({'TESTING': True, 'INITIALIZE_RUNTIME': False, **overrides}) + + def test_missing_file_leaves_ai_disabled_without_creating_files(self): + self.assertEqual(config.default_config()['DEEPSEEK_API_KEY'], '') + self.assertEqual(list(self.root.iterdir()), []) + + def test_file_accepts_bom_quotes_comments_and_whitespace(self): + self.write_env('\ufeff# Local configuration\nexport DEEPSEEK_API_KEY=" file-key " # comment\n') + self.assertEqual(self.application().config['DEEPSEEK_API_KEY'], 'file-key') + self.assertNotIn('DEEPSEEK_API_KEY', os.environ) + + def test_empty_or_unassigned_file_value_leaves_ai_disabled(self): + for line in ('DEEPSEEK_API_KEY=', 'DEEPSEEK_API_KEY', 'OTHER_SETTING=unused'): + with self.subTest(line=line): + self.write_env(line + '\n') + self.assertEqual(config.default_config()['DEEPSEEK_API_KEY'], '') + + def test_environment_overrides_file_including_an_explicit_empty_value(self): + self.write_env('DEEPSEEK_API_KEY=file-key\n') + for value, expected in ((' environment-key ', 'environment-key'), ('', '')): + with self.subTest(value=value): + os.environ['DEEPSEEK_API_KEY'] = value + self.assertEqual(self.application().config['DEEPSEEK_API_KEY'], expected) + + def test_file_values_are_literal_and_do_not_expand_environment_variables(self): + os.environ['SYNTHETIC_CONFIG_VALUE'] = 'expanded' + self.write_env("DEEPSEEK_API_KEY='literal-${SYNTHETIC_CONFIG_VALUE}'\n") + self.assertEqual(config.default_config()['DEEPSEEK_API_KEY'], 'literal-${SYNTHETIC_CONFIG_VALUE}') + + def test_file_is_resolved_from_repository_root_instead_of_working_directory(self): + self.write_env('DEEPSEEK_API_KEY=repository-key\n') + with tempfile.TemporaryDirectory() as directory: + (Path(directory) / '.env').write_text('DEEPSEEK_API_KEY=wrong-key\n', encoding='utf-8') + previous = Path.cwd() + try: + os.chdir(directory) + self.assertEqual(self.application().config['DEEPSEEK_API_KEY'], 'repository-key') + finally: + os.chdir(previous) + + def test_explicit_factory_configuration_has_highest_priority(self): + self.write_env('DEEPSEEK_API_KEY=file-key\n') + os.environ['DEEPSEEK_API_KEY'] = 'environment-key' + for value in ('override-key', ''): + with self.subTest(value=value): + self.assertEqual(self.application(DEEPSEEK_API_KEY=value).config['DEEPSEEK_API_KEY'], value) + + def test_new_factory_reads_updated_file_without_changing_an_existing_app(self): + self.write_env('DEEPSEEK_API_KEY=first-key\n') + first = self.application() + self.write_env('DEEPSEEK_API_KEY=second-key\n') + self.assertEqual(self.application().config['DEEPSEEK_API_KEY'], 'second-key') + self.assertEqual(first.config['DEEPSEEK_API_KEY'], 'first-key') + self.assertNotIn('DEEPSEEK_API_KEY', os.environ) + + def test_model_client_sends_file_key_without_a_real_http_request(self): + from logistics_chat.ai_client import post_completion + + self.write_env('DEEPSEEK_API_KEY=file-key\n') + app = self.application() + with app.app_context(), patch('logistics_chat.ai_client.requests.post', return_value=sentinel.response) as post: + response = post_completion({'model': 'deepseek-chat'}, timeout=(5, 90), stream=True) + self.assertIs(response, sentinel.response) + self.assertEqual(post.call_args.kwargs['headers']['Authorization'], 'Bearer file-key') + + +if __name__ == '__main__': + unittest.main()