diff --git a/.env.example b/.env.example index 96dc4e1..b554f28 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,21 @@ APP_ID=replace-with-fastgpt-app-id # Voice demo (Pipecat /ws-product). Relative to project root, or an absolute path. VOICE_CONFIG=config/voice.json +# 会话持久化:每个应用实例只选一个,修改后重启。 +# 默认 memory;数据库不可用不会回退到 memory。 +# AGENT_STORAGE=sqlite +# AGENT_SQLITE_PATH=./data/agent.sqlite3 +# AGENT_POSTGRES_DSN=postgresql://agent:replace-me@127.0.0.1:5432/agent +# AGENT_DB_POOL_SIZE=10 +# AGENT_DB_LOCK_TIMEOUT_SECONDS=30 +# 开发环境可自动建表;默认 false,部署前运行 python -m src.storage.migrate。 +# AGENT_DB_AUTO_MIGRATE=false +# 可选本地 PostgreSQL compose 配置(密码须与 DSN 一致): +# AGENT_POSTGRES_USER=agent +# AGENT_POSTGRES_DB=agent +# AGENT_POSTGRES_PASSWORD=replace-me +# AGENT_POSTGRES_PORT=5432 + # LangGraph chat backend(作业 2) # AGENT_BACKEND=langgraph # diff --git a/.gitignore b/.gitignore index 6636135..8eea6e2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,16 @@ __pycache__/ logs/ *.log .env +*.db +*.db-shm +*.db-wal +*.sqlite +*.sqlite-shm +*.sqlite-wal +*.sqlite3 +*.sqlite3-shm +*.sqlite3-wal +*.instance.lock evals/results/ .agents/ .claude/ diff --git a/README.md b/README.md index f916479..5c5e623 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,64 @@ # AI 事故信息采集 API(LangGraph 迁移分支) -本分支是“将对话后端从 FastGPT 工作流迁移到 LangGraph”课程的迁移准备分支。 +本分支把事故采集、拍照和信息核实从 FastGPT workflow 迁移到嵌入式 LangGraph。 +文本四接口通过 `AGENT_BACKEND` 选择 FastGPT 或 LangGraph,HTTP schema 与 SSE 事件保持兼容。 +迁移作业计划见 [docs/langgraph-backend-migration-plan.md](docs/langgraph-backend-migration-plan.md), +字段兼容约定见 [docs/assignment6-compat.md](docs/assignment6-compat.md)。 -当前状态:对外 API 契约保持不变,`/chat` 的供应商细节已封装到后端适配层(FastGPT 仍 -为唯一实现),迁移计划见 [docs/langgraph-backend-migration-plan.md](docs/langgraph-backend-migration-plan.md)。 -本阶段交付说明与验证证据见 [docs/assignment-baseline.md](docs/assignment-baseline.md)。 +## 环境与安装 -## 环境要求 - -- Python 3.11(使用 Conda 环境 `py3.11`,见 [AGENTS.md](AGENTS.md)) -- `fastgpt-client` SDK(当前以本地可编辑方式安装,见下文) - -## 安装 +Python 3.11 使用 Conda 环境 `py3.11`(见 [AGENTS.md](AGENTS.md))。 ```bash conda activate py3.11 - -# 项目依赖 pip install -r requirements.txt -# FastGPT SDK(本地可编辑安装;SDK 源码在独立仓库维护) +# 项目仍使用该 SDK 支持 FastGPT 路径;源码在独立仓库维护。 pip install -e /path/to/fastgpt-python-sdk ``` -## 配置 +## 配置与后端选择 -服务端通过环境变量读取 FastGPT 连接信息。复制示例配置并按环境填写: +复制 `.env.example` 为 `.env` 并填写配置;`.env` 不进入 Git。 -```bash -cp .env.example .env +- FastGPT:`AGENT_BACKEND=fastgpt`,填写 `ANALYSIS_SERVICE_URL`、`ANALYSIS_AUTH_TOKEN`、`APP_ID`。 +- LangGraph:`AGENT_BACKEND=langgraph`,按 `.env.example` 填写命名 LLM 配置, + 由 `LLM_PROVIDER` 选择模型;文本后端不要求 FastGPT 凭证。 +- LangGraph 存储:`AGENT_STORAGE=memory|sqlite|postgresql`。每个实例只选一个,修改后重启。 + `DATABASE_URL` 不控制 LangGraph 会话存储,切换数据库不自动搬迁旧数据。 + +本地 SQLite 示例: + +```dotenv +AGENT_BACKEND=langgraph +AGENT_STORAGE=sqlite +AGENT_SQLITE_PATH=./data/agent.sqlite3 +AGENT_DB_AUTO_MIGRATE=false ``` -需要设置的变量: - -| 变量 | 说明 | -|---|---| -| `ANALYSIS_SERVICE_URL` | FastGPT 服务地址 | -| `ANALYSIS_AUTH_TOKEN` | FastGPT API Key | -| `APP_ID` | FastGPT 应用 ID | - -## 运行 +首次初始化和运行: ```bash -python -m uvicorn src.main:app --host 127.0.0.1 --port 8000 +conda run -n py3.11 python -m src.storage.migrate --backend sqlite +conda run -n py3.11 python -m src.serve --port 8000 ``` -- `GET /` 存活检查 -- `POST /chat` 对话(`?stream=true` 为 SSE 流式) -- `POST /set_info`、`POST /get_info`、`DELETE /delete_session` 业务数据接口 +PostgreSQL 的连接池、迁移、本地 compose 与多 worker 部署见 +[docs/agent-storage.md](docs/agent-storage.md)。Windows 使用 `python -m src.serve`, +它为 psycopg 选择兼容的事件循环;Linux 也可用常规 uvicorn 命令启动。 + +## HTTP 接口 + +- `GET /`:存活检查。 +- `POST /chat`:对话;`?stream=true` 使用真实增量 SSE。 +- `POST /set_info`、`POST /get_info`:业务字段读写,不调用模型。 +- `DELETE /delete_session`:清理 checkpoint 和业务状态,真实存储错误返回失败。 +- SQLite/PostgreSQL 模式可给 `/chat` 添加 `Idempotency-Key` 请求头, + 重放已提交结果;复用 key 但请求内容不同返回业务码 409。 + +SSE 的 `error` 是失败终止,`done` 是成功结束。正文可以先流出, +调用方必须在收到 `done` 后才将本轮视为成功。`needFormUpdate=false` +只隐藏对外差量,不影响内部字段提交。 ## 测试 @@ -55,36 +66,35 @@ python -m uvicorn src.main:app --host 127.0.0.1 --port 8000 conda run -n py3.11 python -m pytest -q ``` -测试分三层: +测试覆盖公共 schema、两后端适配、流式校验/取消、会话串行、字段别名、错误码与持久化恢复。 +SQLite 集成测试使用临时真实文件;PostgreSQL 集成测试需要 +`AGENT_TEST_POSTGRES_DSN` 指向隔离测试库。未配置时 PostgreSQL 用例会明确跳过。 +两端都有直接退出子进程的恢复测试,不调用真实 LLM。 -- `test/api/test_public_schema_contract.py`:公共请求/响应 schema 的契约冻结测试 -- `test/api/test_chat_backend_boundary.py`:API 层与后端适配层的边界测试(fake 后端) -- `test/backends/test_fastgpt_backend.py`:FastGPT 适配器的翻译测试(fake SDK) +真实模型评测使用 `evals/run.py`,结果按每轮模型与代码版本单独保存, +历史轮次通过率不代替当前提交的业务验收。 ## 目录结构 ```text src/ - api/endpoints.py # HTTP 协议层:schema、state 前缀解析、SSE 事件 - backends/chat.py # 后端中立契约(ChatBackend Protocol、ChatInput/ChatResult/事件) - backends/fastgpt.py # FastGPT 适配器:SDK 调用与响应翻译 - core/fastgpt_client.py # 客户端生命周期与依赖注入(lifespan) - schemas/models.py # 公共 Pydantic 模型 -docs/langgraph-backend-migration-plan.md # 十阶段迁移作业计划 -evals/ # 调用真实服务的评测用例与 runner + api/endpoints.py # HTTP schema、阶段标签解析、SSE 协议 + agent/ # 流程图、节点、版本化提示词与模型服务 + backends/ # FastGPT / LangGraph 中立适配 + services/ # 四接口应用服务、字段注册表、会话锁 + storage/ # SQLite / PostgreSQL、轮次日志、migration + core/fastgpt_client.py # 后端与存储生命周期、依赖注入 + serve.py # 跨平台单 worker 启动入口 + schemas/models.py # 公共请求/响应模型 +test/storage/ # 两种数据库的真实集成与故障恢复测试 +evals/ # 真实模型评测用例与 runner ``` -## 当前状态与已知限制 +## 部署边界 -作业 1—5 已完成(LLM 提议 + 代码校验架构,提示词与 `workflow/20261020` -逐字对齐,全量测试 143 通过)。逐作业进度、真实模型评测结果与开放问题 -见 [docs/langgraph-backend-migration-plan.md](docs/langgraph-backend-migration-plan.md) -文末“当前进度与已知差异”。 - -- 真实模型黄金评测:`collection_skip_known_info` 12/12 通过; - `photo_skip_plate_confirm` 0/4、`no_false` 2/8,统一卡在短促回答“对” - 的分流语义上,待与 FastGPT 应用层配置对账(差异已留档,未叠代码守卫)。 -- `set_info` / `get_info` 仍直接依赖 FastGPT 状态与历史,迁移到共享 - 业务状态属于作业 6;`delete_session` 已按后端分流(langgraph 清 checkpoint)。 -- 服务启动(langgraph 模式)不需要 FastGPT 配置;需要 LLM provider 配置。 -- 流式为“完整答案切片”,真实增量输出属作业 8;`formUpdate` 差量属作业 6。 +- memory 是临时存储,重启丢失;SQLite 限定单实例、单 worker。 +- PostgreSQL 使用数据库级会话锁、版本和 epoch 校验;仍需按目标负载验证池容量与吞吐。 +- 业务快照是已提交状态的权威源,旧 checkpoint 执行进度在下一轮重建, + 不承诺从未提交的 LLM 调用中途续跑。 +- 语音 WebSocket 管线仍有独立配置,不由本次数据库选择自动迁移。 +- 未知字段写入、敏感字段访问边界等兼容事项继续按兼容清单处理。 diff --git a/compose.agent-db.yml b/compose.agent-db.yml new file mode 100644 index 0000000..3e30517 --- /dev/null +++ b/compose.agent-db.yml @@ -0,0 +1,18 @@ +services: + postgres: + image: postgres:17-alpine + ports: + - "127.0.0.1:${AGENT_POSTGRES_PORT:-5432}:5432" + environment: + POSTGRES_USER: ${AGENT_POSTGRES_USER:-agent} + POSTGRES_DB: ${AGENT_POSTGRES_DB:-agent} + POSTGRES_PASSWORD: ${AGENT_POSTGRES_PASSWORD:?Set AGENT_POSTGRES_PASSWORD in .env} + volumes: + - agent_postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] + interval: 5s + timeout: 3s + retries: 20 +volumes: + agent_postgres_data: diff --git a/docs/agent-storage.md b/docs/agent-storage.md new file mode 100644 index 0000000..b591e7a --- /dev/null +++ b/docs/agent-storage.md @@ -0,0 +1,141 @@ +# LangGraph 会话存储:memory / SQLite / PostgreSQL + +每个应用实例只选择一个存储后端。两个数据库地址可以同时写在 `.env`, +启动时只连接 `AGENT_STORAGE` 指定的一套;切换后需要重启,配置切换不搬迁历史数据。 +现有 `DATABASE_URL` 不控制本模块。 + +| 模式 | 用途 | checkpoint | 会话互斥 | +|---|---|---|---| +| memory(默认) | 离线测试与临时开发,重启丢失 | InMemorySaver | 进程内会话锁 | +| sqlite | 单实例、单 worker,本地持久化 | AsyncSqliteSaver | 进程内会话锁 + 数据库文件的实例锁 | +| postgresql | 多 worker、多实例 | AsyncPostgresSaver | 进程内锁 + PostgreSQL session advisory lock | + +SQLite 实例锁由操作系统管理,第二个实例打开同一数据库会失败,进程退出自动释放。 +SQLite 文件必须放在本地持久化磁盘,不作为多主机共享文件使用。PostgreSQL 的 +锁连接与业务事务连接使用独立连接池;LLM 运行期间不持有业务行事务。 + +## SQLite 启用步骤 + +先安装 `requirements.txt`,Python 使用 Conda `py3.11` 环境。在 `.env` 中设置: + +```dotenv +AGENT_BACKEND=langgraph +AGENT_STORAGE=sqlite +AGENT_SQLITE_PATH=./data/agent.sqlite3 +AGENT_DB_AUTO_MIGRATE=false +``` + +首次启动前初始化两类表: + +```bash +conda run -n py3.11 python -m src.storage.migrate --backend sqlite +conda run -n py3.11 python -m src.serve --port 8000 +``` + +可用 `--sqlite-path` 指定迁移目标,但必须与服务配置相同。 +本地开发可以显式设 `AGENT_DB_AUTO_MIGRATE=true`,在启动阶段建表;请求处理不建表。 +默认关闭自动 migration,未初始化、版本不匹配或数据库不可用时启动失败,不回退内存。 + +## PostgreSQL 启用步骤 + +使用已有 PostgreSQL 数据库,或用 `compose.agent-db.yml` 启动本地开发库。 +建议为本服务使用独立数据库,业务表使用 `agent_` 前缀,checkpointer 管理自己的表。 + +```dotenv +AGENT_BACKEND=langgraph +AGENT_STORAGE=postgresql +AGENT_POSTGRES_DSN=postgresql://agent:replace-me@127.0.0.1:5432/agent +AGENT_DB_POOL_SIZE=10 +AGENT_DB_LOCK_TIMEOUT_SECONDS=30 +AGENT_DB_AUTO_MIGRATE=false + +# 以下四项供本地 compose 使用;密码必须与 DSN 一致。 +AGENT_POSTGRES_USER=agent +AGENT_POSTGRES_DB=agent +AGENT_POSTGRES_PASSWORD=replace-me +AGENT_POSTGRES_PORT=5432 +``` + +`replace-me` 是占位值,需要改为自己的密码;含特殊字符的 DSN 密码需 URL 编码。 + +```bash +docker compose -f compose.agent-db.yml up -d +conda run -n py3.11 python -m src.storage.migrate --backend postgresql +conda run -n py3.11 python -m src.serve --port 8000 +``` + +Linux 部署也可使用 `uvicorn src.main:app --host 0.0.0.0 --port 8000 --workers 4`。 +Windows 本地使用 `python -m src.serve`,该入口选择 psycopg 支持的 SelectorEventLoop。 +连接池在 lifespan 内创建、关闭;每个 worker 使用独立池。业务、checkpoint、 +会话锁各有一个池,每池最大连接数由 `AGENT_DB_POOL_SIZE` 控制,需计入数据库容量。 +同一组 worker 必须使用相同数据库,不能混用 SQLite 和 PostgreSQL。 + +## 数据结构与恢复规则 + +- `agent_sessions`:强类型业务字段、单调递增版本、会话 epoch、最后提交的完整图快照。 +- `agent_turns`:稳定 turn ID、pending/committed/aborted 状态、可选幂等键、请求摘要、已提交结果。 +- `agent_schema`:本模块 migration 版本。LangGraph saver 独立管理自己的 migration 和 checkpoint 表。 +- `/get_info` 以业务字段为权威读源。图快照保存历史、阶段和簿记;未确认的 checkpoint 不作为成功业务结果。 + +```mermaid +sequenceDiagram + participant A as 应用服务 + participant B as 业务数据库 + participant G as LangGraph/checkpointer + A->>B: 同会话锁内记录 pending turn + B-->>A: 已提交字段、版本、完整图快照 + A->>G: 清除旧执行 checkpoint,从提交快照执行本轮 + G-->>A: 合法前缀及暂定正文增量 + G->>G: 完成节点与 checkpoint 写入 + G->>A: await 提交回调(完整结果) + A->>B: 同一事务提交字段、快照、版本和 committed 结果 + B-->>A: 提交成功 + A-->>A: 允许成功响应 / SSE done +``` + +第一版每轮从业务提交快照重建图执行,丢弃旧 checkpoint 执行进度;不会从中断节点中途续跑。 +这会减少 saver 中的历史 checkpoint,但已提交的对话历史保存在业务图快照中, +轮次结果保存在业务日志中。LLM 决策与流程提示词保持不变。 + +| 故障窗口 | 下次请求的确定行为 | +|---|---| +| checkpoint 先写入,业务提交前退出 | pending 轮次转为 aborted,丢弃未提交执行进度,从上次业务快照开始 | +| 业务提交成功,checkpoint 缺失或滞后 | 业务快照是权威,下次重新构建 checkpoint,不重复应用字段差量 | +| 业务提交成功,成功响应发送前退出 | 同一 Idempotency-Key 重放原结果,不再调用 LLM 或推进版本 | + +set/get/delete 与 chat 使用同一个会话锁。字段写入带版本条件,过时 turn 或旧 epoch +不能覆盖新值;即使 advisory-lock 连接丢失,版本与 epoch 校验仍拒绝迟到提交。 +数据库不可用时返回失败,未提交的流不会发 done。客户端已收到的流式正文不可撤回, +必须把 done 当作本轮成功结束标记。 + +## 可选 Idempotency-Key + +SQLite/PostgreSQL 模式的 `/chat` 接受请求头 `Idempotency-Key`(1—128 字符)。 +同一会话 epoch 下,相同 key 和相同 text/clientMode/needFormUpdate 重放已提交结果; +相同 key 对应不同请求返回业务码 409,SSE 使用 error 事件。 +未提交请求可使用相同 key 重试;流式结果重放来自已提交结果,不会再次流式调用模型。 + +未提供 key 时,每次 POST 都是新轮次,不承诺网络重试自动去重。 +memory 模式收到此头返回 409,不假装提供持久化幂等能力。FastGPT 路径不提供本模块的幂等保证。 + +## 删除与数据库切换 + +删除先清理图 checkpoint,再删除业务数据与轮次日志。真实故障返回失败、允许重试。 +数据库保留不含业务字段/历史的 epoch 与版本墓碑,用于拒绝旧任务提交;重复删除成功。 +删除后新聊天进入新的 epoch,不会重放旧的幂等结果。 + +把 `AGENT_STORAGE=sqlite` 改为 `postgresql` 并重启,仅改变数据源。 +原 SQLite 文件仍保留,PostgreSQL 不会自动出现其中的会话。本版不提供自动跨数据库迁移。 +切换生产数据源需要单独的数据迁移与验证步骤。备份 SQLite 时先停止服务或使用 SQLite +备份机制;PostgreSQL 使用数据库备份工具。本地 compose 的 `down` 保留数据卷。 + +## 验证与测试资源 + +```bash +conda run -n py3.11 python -m pytest test/storage/ -q +``` + +默认跑真实 SQLite;PostgreSQL 测试需要 `AGENT_TEST_POSTGRES_DSN` 指向隔离测试库, +没有该变量时跳过 PostgreSQL 用例,不把跳过当作 PostgreSQL 已验证。 +测试使用 fake LLM,不消耗模型额度,覆盖:重启续聊、四接口、两种数据类型、 +幂等重放、版本冲突、checkpoint 缺失/超前、提交前后进程直接退出、数据库锁和删除恢复。 diff --git a/docs/langgraph-backend-migration-plan.md b/docs/langgraph-backend-migration-plan.md index 955a584..48e181f 100644 --- a/docs/langgraph-backend-migration-plan.md +++ b/docs/langgraph-backend-migration-plan.md @@ -357,7 +357,7 @@ docs/langgraph/ | FastGPT 应用层配置(应用级系统提示词、思考模式开关)与线上部署差异 | 作业 4/5 验收对账前 | 移植代码、离线评测与差异记录 | | 评测案例与工作流导出的分歧(“对”分流语义、2004 走向)的权威口径 | 作业 5 验收前 | 白名单双走向、逐字比对测试、失败证据留档 | -## 当前进度与已知差异(2026-10-21 更新) +## 当前进度与已知差异(数据库实现更新,模型评测为历史记录) ### 作业进度 @@ -368,15 +368,20 @@ docs/langgraph/ | 3. 领域状态与事件 | ✅ 完成 | 事件解析 14 例、白名单校验 24 例;`LEGAL_TRANSITIONS` 17 条均带 `source_ref` | | 4. 采集 + LLM | ✅ 完成 | 真实模型 `collection_skip_known_info` 12/12;单测含逐字比对 | | 5. 拍照与核实 | ✅ 完成 | 真实模型 4/4 + 12/12 + 2/2(20261021 基准) | -| 6—10 | 未开始 | — | +| 6. 四接口业务状态 | 已实现 | 应用服务、异步 repository、字段别名、会话串行与接口回归 | +| 7. 持久化 | SQLite/PostgreSQL 初版已实现 | `src/storage/`;真实两库集成、进程退出恢复、幂等和版本冲突;见 [agent-storage.md](agent-storage.md) | +| 8. 真流式 | 已实现核心路径 | 模型增量、前缀校验、取消与提交回调;流式终止事件回归 | +| 9—10 | 未完成 | 生产访问边界、容量验证与双后端等价性验收继续保留 | -全量单元测试 143 通过;七份提示词与 20261020 工作流逐字比对由参数化测试锁定。 +本轮全量测试 315 通过,包含真实 SQLite/PostgreSQL 集成测试。提示词来源和 +逐字比对见 `test/agent/test_workflow_prompts.py`。下方真实模型结果是此前轮次, +本次数据库开发未重新运行 46 条真实模型黄金评测。 ### 已落地的架构决策 1. **LLM 提议、代码校验(propose-validate)**:所有 stage_code 由节点内 LLM 产出(提示词即程序),代码只做前缀解析、白名单校验(含 clientMode 出口)和落库;曾尝试的代码强制推进/短促词守卫已按此分工移除。 2. **依赖注入走 `context_schema`**:`GraphDeps(llm=...)` 经 `Runtime` 注入调用期,不进 state/checkpoint;`build_graph()` 与 LLM 解耦。 -3. **与工作流逐字对齐**:提示词原文渲染(含 `{{$变量}}` 替换:系统时间按 Asia/Shanghai、人员预填字段缺失时显式标注“未提供”);聊天历史按 user/assistant 消息传递(上限 50 条),对齐 chatNode `history=50`;welcome 话术为 Assigned Reply#3 原文并计入对话历史。 +3. **与工作流逐字对齐**:提示词原文渲染(含 `{{$变量}}` 替换:系统时间按 Asia/Shanghai、人员预填字段缺失时显式标注“未提供”);聊天历史按 user/assistant 消息传递(`AGENT_HISTORY_MESSAGES` 默认 50,0 为不截断);welcome 话术为 Assigned Reply#3 原文并计入对话历史。 4. **不引入 LangGraph Server / langgraph_sdk**:生产形态的 Agent Server 需要 LangSmith license key、外网 beacon 出网及 Postgres+Redis 全栈,与课程目标(作业 7 亲手实现持久化)和部署环境不符;`langgraph dev` 仅为内存态,无增量能力。自部署高并发方案 = 嵌入库模式:无状态 FastAPI 多副本 + AsyncPostgresSaver + 每会话 advisory lock 串行 + 连接池预算(社区参照:langgraph-agent-toolkit、agent-bakery)。 5. **2004 语义**:确认单车车牌;车牌已明确时可 2003→2005 跳过,未明确时 2003→2004→2005。两种走向均在白名单登记,由模型判断。 diff --git a/requirements.txt b/requirements.txt index 64f244a..fd24525 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,3 +22,7 @@ sqlalchemy pymysql PyYAML>=6.0 langgraph==1.2.9 +langgraph-checkpoint-sqlite==3.1.2 +langgraph-checkpoint-postgres==3.2.0 +psycopg[binary,pool]>=3.2,<4 +aiosqlite>=0.20,<1 diff --git a/src/api/endpoints.py b/src/api/endpoints.py index fb95f41..314375b 100644 --- a/src/api/endpoints.py +++ b/src/api/endpoints.py @@ -1,4 +1,5 @@ -from fastapi import APIRouter, HTTPException, Depends +from typing import Annotated +from fastapi import APIRouter, HTTPException, Depends, Header from fastapi.responses import StreamingResponse from ..schemas.models import ProcessRequest_chat, ProcessResponse_chat, ProcessRequest_get, ProcessResponse_get, ProcessRequest_set, ProcessResponse_set, ProcessResponse_delete_session, ProcessRequest_delete_session from fastgpt_client import AsyncChatClient @@ -6,6 +7,7 @@ from ..backends.chat import ( ChatBackend, ChatBackendAPIError, ChatBackendAuthenticationError, + ChatBackendConflictError, ChatBackendRateLimitError, ChatInput, FormUpdate, @@ -156,7 +158,8 @@ def create_sse_event(event: str, data: dict) -> str: async def chat( request: ProcessRequest_chat, stream: bool = False, - backend: ChatBackend = Depends(get_chat_backend) + backend: ChatBackend = Depends(get_chat_backend), + idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key", min_length=1, max_length=128)] = None, ): """Handle chat completion request. @@ -172,6 +175,7 @@ async def chat( text=json_data['text'], need_form_update=need_form_update, client_mode=client_mode, + request_key=idempotency_key, ) # 应用服务:langgraph 模式下包装字段注入/写回;fastgpt 模式透传。 # 单测直调函数(未走 lifespan)时回退 backend 直调。 @@ -419,6 +423,8 @@ async def chat( code, msg = "401", "认证失败" elif isinstance(e, ChatBackendRateLimitError): code, msg = "429", "请求过于频繁,请稍后重试" + elif isinstance(e, ChatBackendConflictError): + code, msg = "409", "请求幂等键或会话版本冲突" else: code, msg = "500", "大模型服务器无响应" yield create_sse_event("error", {"msg": msg, "code": code}) @@ -458,6 +464,12 @@ async def chat( code="429", msg="请求过于频繁,请稍后重试" ) + except ChatBackendConflictError: + return ProcessResponse_chat( + sessionId=json_data['sessionId'], timeStamp=json_data['timeStamp'], + outputText="", nextStage="", nextStageCode="", code="409", + msg="请求幂等键或会话版本冲突", + ) except ChatBackendAPIError as e: logger.error(f"API error: {e}") return ProcessResponse_chat( diff --git a/src/backends/chat.py b/src/backends/chat.py index 3a3f916..a93a27c 100644 --- a/src/backends/chat.py +++ b/src/backends/chat.py @@ -1,6 +1,6 @@ """Backend-neutral chat contract.""" -from collections.abc import AsyncIterator, Callable +from collections.abc import AsyncIterator, Awaitable, Callable from dataclasses import dataclass, field from typing import Any, Protocol @@ -18,7 +18,8 @@ class ChatInput: # FastGPT 后端忽略该字段。 injected_fields: dict | None = None # 图提交后、公开结果之前同步业务字段;不进入图状态或 HTTP schema。 - on_commit: Callable[[dict], None] | None = field(default=None, repr=False, compare=False) + on_commit: Callable[..., Awaitable[None] | None] | None = field(default=None, repr=False, compare=False) + request_key: str | None = None @dataclass(frozen=True) @@ -69,6 +70,10 @@ class ChatBackendError(Exception): """Base error raised by a chat backend adapter.""" +class ChatBackendConflictError(ChatBackendError): + """请求幂等键或会话版本冲突。""" + + class ChatBackendAuthenticationError(ChatBackendError): """The backend rejected its configured credentials.""" diff --git a/src/backends/langgraph.py b/src/backends/langgraph.py index 2d8c785..22c3868 100644 --- a/src/backends/langgraph.py +++ b/src/backends/langgraph.py @@ -15,6 +15,7 @@ test/backends/test_langgraph_streaming.py """ import asyncio +import inspect from contextlib import suppress from collections.abc import AsyncIterator, Awaitable, Callable @@ -103,6 +104,10 @@ class LangGraphBackend(ChatBackend): self._graph = graph if graph is not None else build_graph() self._llm = llm if llm is not None else workflow_fake_llm() + async def reset_checkpoint(self, session_id): + from ..storage.checkpoints import delete_checkpoint + await delete_checkpoint(self._graph, session_id) + async def complete(self, chat_input: ChatInput) -> ChatResult: """非流式:执行一轮图并翻译结果。 @@ -135,15 +140,13 @@ class LangGraphBackend(ChatBackend): LLMAuthError -> ChatBackendAuthenticationError(API 层 401); LLMRateLimitError -> ChatBackendRateLimitError(API 层 429)。 """ - graph_input: dict = { + graph_input: dict = dict(chat_input.injected_fields or {}) + # 恢复快照中的上一轮 input_text 不得覆盖本次请求。 + graph_input.update({ "session_id": chat_input.session_id, "input_text": chat_input.text, "client_mode": chat_input.client_mode, - } - # 作业 6:应用服务注入的最新业务字段覆盖本轮输入, - # 使 set_info 写入后本轮 chat 立即可见(输入覆盖 checkpoint 同键)。 - if chat_input.injected_fields: - graph_input.update(chat_input.injected_fields) + }) try: result = await self._graph.ainvoke( graph_input, @@ -152,7 +155,9 @@ class LangGraphBackend(ChatBackend): ) # 即使消费者在图完成后断开,业务 repository 仍与 checkpoint 一致。 if chat_input.on_commit is not None: - chat_input.on_commit(result.get("turn_patch") or {}) + pending = chat_input.on_commit(result.get("turn_patch") or {}, state=result) + if inspect.isawaitable(pending): + await pending return result except LLMAuthError as exc: raise ChatBackendAuthenticationError(str(exc)) from exc diff --git a/src/core/config.py b/src/core/config.py index cee3594..6a34b22 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -19,6 +19,14 @@ class Config: # "fastgpt" 保持现有行为;"langgraph" 使用内存图 fake 回复。 AGENT_BACKEND = os.getenv("AGENT_BACKEND", "fastgpt") + # 每个实例只选一个会话存储;与通用 DATABASE_URL 独立。 + AGENT_STORAGE = os.getenv("AGENT_STORAGE", "memory").strip().lower() + AGENT_SQLITE_PATH = os.getenv("AGENT_SQLITE_PATH", "./data/agent.sqlite3") + AGENT_POSTGRES_DSN = os.getenv("AGENT_POSTGRES_DSN", "") + AGENT_DB_AUTO_MIGRATE = os.getenv("AGENT_DB_AUTO_MIGRATE", "false").lower() in ("true", "1") + AGENT_DB_POOL_SIZE = int(os.getenv("AGENT_DB_POOL_SIZE", "10")) + AGENT_DB_LOCK_TIMEOUT_SECONDS = float(os.getenv("AGENT_DB_LOCK_TIMEOUT_SECONDS", "30")) + # 命名 LLM 提供商列表(均为 OpenAI 兼容接口),两种写法二选一: # ① JSON 字典(推荐,单变量管理): # LLM_PROVIDERS={"deepseek": {"base_url": "https://...", "api_key": "...", @@ -138,6 +146,17 @@ class Config: raise ValueError( f"AGENT_BACKEND must be 'fastgpt' or 'langgraph', got {cls.AGENT_BACKEND!r}" ) + if cls.AGENT_BACKEND == "langgraph": + if cls.AGENT_STORAGE not in ("memory", "sqlite", "postgresql"): + raise ValueError("AGENT_STORAGE must be memory, sqlite or postgresql") + if cls.AGENT_STORAGE == "sqlite" and ( + not cls.AGENT_SQLITE_PATH.strip() or cls.AGENT_SQLITE_PATH == ":memory:" + ): + raise ValueError("AGENT_SQLITE_PATH must be a persistent file path") + if cls.AGENT_STORAGE == "postgresql" and not cls.AGENT_POSTGRES_DSN.strip(): + raise ValueError("AGENT_POSTGRES_DSN is required for postgresql storage") + if cls.AGENT_DB_POOL_SIZE < 1 or cls.AGENT_DB_LOCK_TIMEOUT_SECONDS <= 0: + raise ValueError("Database pool size and lock timeout must be positive") profiles = cls.llm_profiles() # JSON 非法时在此抛出明确错误 incomplete = [n for n in cls._declared_provider_names() if n not in profiles] if incomplete: diff --git a/src/core/fastgpt_client.py b/src/core/fastgpt_client.py index 300631f..d073bfd 100644 --- a/src/core/fastgpt_client.py +++ b/src/core/fastgpt_client.py @@ -1,107 +1,79 @@ -"""FastGPT client and chat backend dependency injection.""" -from contextlib import asynccontextmanager +"""后端与单一会话存储的生命周期、依赖注入。""" +from contextlib import AsyncExitStack, asynccontextmanager + from fastapi import FastAPI from fastgpt_client import AsyncChatClient + from ..agent.graph import build_graph from ..backends.chat import ChatBackend from ..backends.fastgpt import FastGPTBackend from ..backends.langgraph import LangGraphBackend from ..services.app_service import AccidentAppService -from ..services.repository import InMemorySessionRepository +from ..storage.checkpoints import delete_checkpoint from .config import Config - _fastgpt_client: AsyncChatClient | None = None _chat_backend: ChatBackend | None = None _langgraph_graph = None _app_service: AccidentAppService | None = None -# 两后端缺省 60 秒、最多额外重试 3 次,1/2/4 秒退避。 -# FastGPT SDK 的 max_retries 实为总尝试数,注入时转换为 1 + 重试次数。 -# LLM 关闭 SDK 自动重试;流式发出文本后不再重试。 -# LLM 预算按单次模型调用计算,FastGPT timeout 为 HTTP SDK 超时, -# 均不承诺多次模型调用的整个聊天回合具有相同总期限。 FASTGPT_TIMEOUT_SECONDS = 60.0 FASTGPT_MAX_RETRIES = 3 @asynccontextmanager async def lifespan(app: FastAPI): - """Manage chat backend resource lifecycle.""" - global _chat_backend, _fastgpt_client + global _chat_backend, _fastgpt_client, _langgraph_graph, _app_service Config.validate() llm = None + async with AsyncExitStack() as resources: + try: + if Config.AGENT_BACKEND == 'langgraph': + from ..agent.llm import build_llm_service + from ..storage.runtime import StorageSettings, open_storage - if Config.AGENT_BACKEND == "langgraph": - # 作业 2:LangGraph fake 模式不要求 FastGPT 密钥。 - # TODO(作业 2): 若语音仍启用,需在此单独初始化并校验 FastGPT 配置。 - from ..agent.llm import build_llm_service - - global _langgraph_graph, _app_service - _langgraph_graph = build_graph() - llm = build_llm_service() - _chat_backend = LangGraphBackend(_langgraph_graph, llm=llm) - # 作业 6:应用服务 + 内存 repository(作业 7 换 PostgreSQL)。 - _app_service = AccidentAppService( - _chat_backend, InMemorySessionRepository() - ) - else: - _fastgpt_client = AsyncChatClient( - api_key=Config.FASTGPT_API_KEY, - base_url=Config.FASTGPT_BASE_URL, - timeout=FASTGPT_TIMEOUT_SECONDS, - max_retries=FASTGPT_MAX_RETRIES + 1, - retry_delay=1.0, - enable_logging=Config.DEBUG, - ) - await _fastgpt_client.__aenter__() - _chat_backend = FastGPTBackend(_fastgpt_client) - - try: - yield - finally: - _chat_backend = None - _langgraph_graph = None - _app_service = None - if llm is not None and hasattr(llm, "aclose"): - await llm.aclose() - if _fastgpt_client: - await _fastgpt_client.__aexit__(None, None, None) - _fastgpt_client = None + storage = await resources.enter_async_context(open_storage(StorageSettings.from_config(Config))) + _langgraph_graph = build_graph(storage.checkpointer) + llm = build_llm_service() + _chat_backend = LangGraphBackend(_langgraph_graph, llm=llm) + _app_service = AccidentAppService(_chat_backend, storage.repository, locks=storage.locks) + else: + _fastgpt_client = AsyncChatClient( + api_key=Config.FASTGPT_API_KEY, base_url=Config.FASTGPT_BASE_URL, + timeout=FASTGPT_TIMEOUT_SECONDS, max_retries=FASTGPT_MAX_RETRIES + 1, + retry_delay=1.0, enable_logging=Config.DEBUG, + ) + await _fastgpt_client.__aenter__() + _chat_backend = FastGPTBackend(_fastgpt_client) + yield + finally: + _chat_backend = None + _langgraph_graph = None + _app_service = None + if llm is not None and hasattr(llm, 'aclose'): + await llm.aclose() + if _fastgpt_client: + await _fastgpt_client.__aexit__(None, None, None) + _fastgpt_client = None async def delete_chat_session(thread_id: str) -> None: - """删除图 checkpoint;缺少清理能力或存储出错必须报告失败。""" - if _langgraph_graph is None: - raise RuntimeError("LangGraph backend not initialized") - checkpointer = getattr(_langgraph_graph, "checkpointer", None) - async_delete = getattr(checkpointer, "adelete_thread", None) - if callable(async_delete): - await async_delete(thread_id) - return - sync_delete = getattr(checkpointer, "delete_thread", None) - if callable(sync_delete): - sync_delete(thread_id) - return - raise RuntimeError("LangGraph checkpointer does not support thread deletion") + await delete_checkpoint(_langgraph_graph, thread_id) def get_fastgpt_client() -> AsyncChatClient: - """Get the initialized FastGPT client.""" if _fastgpt_client is None: - raise RuntimeError("FastGPT client not initialized") + raise RuntimeError('FastGPT client not initialized') return _fastgpt_client def get_app_service() -> AccidentAppService: - """Get the initialized application service(langgraph 模式)。""" if _app_service is None: - raise RuntimeError("Application service not initialized") + raise RuntimeError('Application service not initialized') return _app_service def get_chat_backend() -> ChatBackend: - """Get the backend-neutral chat service.""" if _chat_backend is None: - raise RuntimeError("Chat backend not initialized") + raise RuntimeError('Chat backend not initialized') return _chat_backend diff --git a/src/serve.py b/src/serve.py new file mode 100644 index 0000000..5f09955 --- /dev/null +++ b/src/serve.py @@ -0,0 +1,23 @@ +"""Portable single-worker entry point: python -m src.serve --port 8000.""" +import argparse +import asyncio +import os + +import uvicorn + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--host', default='127.0.0.1') + parser.add_argument('--port', default=8000, type=int) + args = parser.parse_args() + # psycopg 不支持 Windows ProactorEventLoop。直接运行 Server.serve + # 使用已创建的 Selector loop,不被 uvicorn 默认 loop factory 覆盖。 + factory = asyncio.SelectorEventLoop if os.name == 'nt' else None + config = uvicorn.Config('src.main:app', host=args.host, port=args.port, loop='asyncio') + with asyncio.Runner(loop_factory=factory) as runner: + runner.run(uvicorn.Server(config).serve()) + + +if __name__ == '__main__': + main() diff --git a/src/services/app_service.py b/src/services/app_service.py index ff68778..0d22def 100644 --- a/src/services/app_service.py +++ b/src/services/app_service.py @@ -1,65 +1,41 @@ -"""事故处理应用服务(作业 6-1):四业务接口共享同一份业务状态。 +"""四接口应用服务:同会话串行,持久化模式按已提交快照恢复。""" -职责边界(对应计划作业 6): -- set_info / get_info / delete_session **不调用 LLM、不创建辅助对话** - (对照旧 FastGPT 实现的“辅助对话 + 删记录”hack); -- chat 每轮从 repository 读最新业务字段注入图(set_info 写入后 - 下一轮 chat 必须可见),图产出字段差量后写回 repository; -- chat / stream / set_info / get_info / delete_session 都在同会话锁内。 - 锁的引用计数包含等待者,取消和异常都会归还引用;没有使用者时回收。 - 不同会话独立执行,同会话 set_info 不会被在途聊天的旧结果覆盖。 -- 未知 key 兼容策略:get 对未知 key 返回 JSON 空串 '""'(现状兼容); - set 对未知 key 拒绝写入(白名单保护,差异见兼容清单); -- includeInputInfo 继续被 endpoint 接受,但本层不再使用 - ——新后端无需触发辅助对话。 +import hashlib +import json +from contextlib import aclosing, asynccontextmanager, suppress +from dataclasses import dataclass, replace -endpoint 只负责校验、协议(SSE / 响应模型)与依赖注入。 -""" - -from contextlib import aclosing -from dataclasses import replace - -from ..backends.chat import ChatBackend, ChatInput, ChatResult, FormUpdate +from ..backends.chat import ChatBackendConflictError, ChatInput, ChatResult, FormUpdate, TextDelta from .fields_registry import ( - FIELD_REGISTRY, - GROUP_KEYS, - BUSINESS_TO_FORM_KEY, - FieldGroup, - business_patch_from_form, - business_value, - canonical_key, - parse_field_value, - to_external_string, + FIELD_REGISTRY, GROUP_KEYS, BUSINESS_TO_FORM_KEY, FieldGroup, + business_patch_from_form, business_value, canonical_key, + parse_field_value, to_external_string, ) -from .repository import CheckpointNotFoundError, SessionRepository +from .repository import CheckpointNotFoundError from .session_locks import SessionLocks -# set_info 对未知/受保护 key 的拒绝码(与现有“入参不规范”文案对齐) -SET_INFO_INVALID_KEY_MSG = "入参不规范:未知或受保护的字段 key" +SET_INFO_INVALID_KEY_MSG = '入参不规范:未知或受保护的字段 key' + + +@dataclass +class _Turn: + injected: dict + ticket: object = None + committed: bool = False class AccidentAppService: - """四个业务接口的应用服务(langgraph 后端)。""" - - def __init__(self, backend: ChatBackend, repository: SessionRepository): + def __init__(self, backend, repository, *, locks=None): self._backend = backend self._repository = repository - self._locks = SessionLocks() + self._locks = locks if locks is not None else SessionLocks() + self._durable = hasattr(repository, 'begin_turn') - # ---- chat:图每轮读取最新业务状态,产出差量写回 ---- - - def _injected_fields(self, session_id: str) -> dict: - """repository 业务字段 -> 图输入的分组注入。 - - acd 业务键经 BUSINESS_TO_FORM_KEY 映射回图表单键 - (无表单来源的 bjrjs/ywmtc 不注入);人员业务键与图的 - person_fields 同构,原样注入。 - """ - business = self._repository.load(session_id) + async def _injected_fields(self, session_id): + business = await self._repository.load(session_id) if not business: return {} - injected: dict = {"accident_fields": {}, "person_fields": {}} - # 注入同义键的一致视图,覆盖 checkpoint 里的旧值。 + injected = {'accident_fields': {}, 'person_fields': {}} for key, spec in FIELD_REGISTRY.items(): value = business_value(business, key) if value is None: @@ -67,141 +43,129 @@ class AccidentAppService: if spec.group is FieldGroup.ACD: form_key = BUSINESS_TO_FORM_KEY.get(key) if form_key is not None: - injected["accident_fields"][form_key] = value + injected['accident_fields'][form_key] = value else: - injected["person_fields"][key] = value + injected['person_fields'][key] = value return injected - def _writeback_patch(self, session_id: str, patch: dict) -> None: - """图产出的差量(表单键/人员键)经映射落 repository 业务键。""" + async def _writeback_patch(self, session_id, patch): business_patch = business_patch_from_form(patch) if business_patch: - self._repository.apply_update(session_id, business_patch) + await self._repository.apply_update(session_id, business_patch) - async def chat(self, chat_input: ChatInput) -> ChatResult: - """非流式一轮对话:注入最新业务字段 -> 执行图 -> 写回字段差量。""" - async with self._locks.hold(chat_input.session_id): - return await self._chat_locked(chat_input) - - async def _chat_locked(self, chat_input: ChatInput) -> ChatResult: - injected = self._injected_fields(chat_input.session_id) - committed = False - - def commit(patch): - nonlocal committed - self._writeback_patch(chat_input.session_id, patch) - committed = True - - result = await self._backend.complete( - replace(chat_input, injected_fields=injected or None, on_commit=commit) - ) - if not committed: - patch = result.committed_patch - if patch is None: - patch = result.form_update - if isinstance(patch, dict): - commit(patch) - return replace( - result, form_update=result.form_update if chat_input.need_form_update else {}, - ) - - async def stream(self, chat_input: ChatInput): - """流式代理:字段注入同 chat();FormUpdate 事件捕获写回后透传。""" - async with self._locks.hold(chat_input.session_id): - async with aclosing(self._stream_locked(chat_input)) as events: - async for event in events: - yield event - - async def _stream_locked(self, chat_input: ChatInput): - injected = self._injected_fields(chat_input.session_id) - committed = False - - def commit(patch): - nonlocal committed - self._writeback_patch(chat_input.session_id, patch) - committed = True - - result_input = replace(chat_input, injected_fields=injected or None, on_commit=commit) - async with aclosing(self._backend.stream(result_input)) as events: - async for event in events: - if isinstance(event, FormUpdate): - if not committed and isinstance(event.data, dict): - commit(event.data) - if not chat_input.need_form_update: - continue - yield event - - # ---- set_info / get_info:零 LLM、零辅助对话 ---- - - async def set_info(self, session_id: str, key: str, value: str) -> tuple[str, str]: - async with self._locks.hold(session_id): - return self._set_info(session_id, key, value) - - def _set_info(self, session_id: str, key: str, value: str) -> tuple[str, str]: - """白名单校验后写入 repository。返回 (code, msg)。""" - spec = FIELD_REGISTRY.get(key) - if spec is None or not spec.writable: - return "500", SET_INFO_INVALID_KEY_MSG + @asynccontextmanager + async def _turn(self, request): + ticket = None + turn = None try: - parsed = parse_field_value(spec, value) - except ValueError: - return "500", "入参不规范:字段值无法解析" - self._repository.apply_update(session_id, {canonical_key(key): parsed}) - return "200", "" + if self._durable: + payload = {'text': request.text, 'client_mode': request.client_mode, + 'need_form_update': request.need_form_update} + digest = hashlib.sha256(json.dumps(payload, sort_keys=True, ensure_ascii=False).encode()).hexdigest() + ticket = await self._repository.begin_turn(request.session_id, request.request_key, digest) + if ticket.replay is None: + # 官方 checkpoint 可能先写成功,业务事务却未提交。 + # 丢弃该执行进度;始终从业务表的提交快照恢复。 + await self._backend.reset_checkpoint(request.session_id) + elif request.request_key: + raise ChatBackendConflictError('Idempotency-Key requires persistent storage') + injected = dict(ticket.graph_state) if ticket else {} + injected.update(await self._injected_fields(request.session_id)) + if ticket: + injected['state_version'] = ticket.version + turn = _Turn(injected, ticket) + yield turn + except BaseException: + if ticket is not None and ticket.replay is None and not (turn and turn.committed): + # 清理失败时保留 pending;下次持锁访问会标记 aborted 并恢复快照。 + with suppress(Exception): + await self._repository.abort_turn(ticket) + raise - async def get_info(self, session_id: str, key: str) -> str: + async def _commit(self, request, turn, patch, state): + if turn.ticket: + if state is None: + raise RuntimeError('Persistent turns require a complete graph state') + await self._repository.commit_turn(turn.ticket, business_patch_from_form(patch), state) + else: + await self._writeback_patch(request.session_id, patch) + turn.committed = True + + @staticmethod + def _replay(request, state): + patch = state.get('turn_patch') or {} + return ChatResult(content=state['response_text'], status_code=state.get('stage_code'), + form_update=patch if request.need_form_update else {}, committed_patch=patch) + + async def chat(self, request: ChatInput): + async with self._locks.hold(request.session_id): + async with self._turn(request) as turn: + if turn.ticket and turn.ticket.replay is not None: + return self._replay(request, turn.ticket.replay) + async def commit(patch, *, state=None): + await self._commit(request, turn, patch, state) + result = await self._backend.complete( + replace(request, injected_fields=turn.injected or None, on_commit=commit)) + if not turn.committed: + patch = result.committed_patch if result.committed_patch is not None else result.form_update + await commit(patch if isinstance(patch, dict) else {}) + return replace(result, form_update=result.form_update if request.need_form_update else {}) + + async def stream(self, request: ChatInput): + async with self._locks.hold(request.session_id): + async with self._turn(request) as turn: + if turn.ticket and turn.ticket.replay is not None: + result = self._replay(request, turn.ticket.replay) + yield TextDelta(result.content) + if result.form_update: + yield FormUpdate(result.form_update) + return + async def commit(patch, *, state=None): + await self._commit(request, turn, patch, state) + forwarded = replace(request, injected_fields=turn.injected or None, on_commit=commit) + async with aclosing(self._backend.stream(forwarded)) as events: + async for event in events: + if isinstance(event, FormUpdate): + if not turn.committed and isinstance(event.data, dict): + await commit(event.data) + if not request.need_form_update: + continue + yield event + + async def set_info(self, session_id, key, value): async with self._locks.hold(session_id): - return self._get_info(session_id, key) + spec = FIELD_REGISTRY.get(key) + if spec is None or not spec.writable: + return '500', SET_INFO_INVALID_KEY_MSG + try: + parsed = parse_field_value(spec, value) + except ValueError: + return '500', '入参不规范:字段值无法解析' + await self._repository.apply_update(session_id, {canonical_key(key): parsed}) + return '200', '' - def _get_info(self, session_id: str, key: str) -> str: - """按分组或单键读出,返回 JSON 字符串(兼容现有 /get_info 契约)。""" - import json + async def get_info(self, session_id, key): + async with self._locks.hold(session_id): + business = await self._repository.load(session_id) + def group_value(group): + return {k: to_external_string(business_value(business, k)) for k in GROUP_KEYS[group]} + if key == 'all': + value = {group.value: group_value(group) for group in FieldGroup} + else: + try: + value = group_value(FieldGroup(key)) + except ValueError: + value = to_external_string(business_value(business, key)) if key in FIELD_REGISTRY else '' + return json.dumps(value, ensure_ascii=False) - business = self._repository.load(session_id) - if key == "all": - return json.dumps( - { - group.value: { - k: to_external_string(business_value(business, k)) - for k in GROUP_KEYS[group] - } - for group in FieldGroup - }, - ensure_ascii=False, - ) - try: - group = FieldGroup(key) - except ValueError: - group = None - if group is not None: - return json.dumps( - {k: to_external_string(business_value(business, k)) for k in GROUP_KEYS[group]}, - ensure_ascii=False, - ) - # 单键:未知 key 返回 JSON 空串(兼容现状);已知 key 返回其外部表示 - if key not in FIELD_REGISTRY: - return json.dumps("", ensure_ascii=False) - return json.dumps(to_external_string(business_value(business, key)), ensure_ascii=False) - - # ---- delete_session:业务状态 + 图 checkpoint 一起清理 ---- - - async def delete_session(self, session_id: str, drop_checkpoint) -> None: - """删除会话全部数据。 - - drop_checkpoint:由 core 层注入的 checkpoint 清理函数 - (图生命周期归 lifespan 管理,本层不持有图引用)。 - 与会话锁互斥:删除不与在途 chat/set/get 交错。 - 释放锁引用时保留等待者使用的锁,最后一个使用者退出后回收。 - checkpoint 清理成功(或明确不存在)后才删除业务字段。 - 其他异常向上传播,由 endpoint 返回失败,保留业务字段供重试。 - """ + async def delete_session(self, session_id, drop_checkpoint): async with self._locks.hold(session_id): if drop_checkpoint is not None: try: await drop_checkpoint(session_id) except CheckpointNotFoundError: pass - self._repository.delete(session_id) + await self._repository.delete(session_id) -__all__ = ["AccidentAppService", "SET_INFO_INVALID_KEY_MSG"] +__all__ = ['AccidentAppService', 'SET_INFO_INVALID_KEY_MSG'] diff --git a/src/services/repository.py b/src/services/repository.py index 6b6ebb8..2663922 100644 --- a/src/services/repository.py +++ b/src/services/repository.py @@ -1,55 +1,39 @@ -"""会话业务状态 repository(作业 6-1/6-2)。 - -抽象出 repository 接口,使应用服务不依赖具体存储; -作业 6 用内存实现跑通四接口,作业 7 再替换为 PostgreSQL 实现 -(对话 checkpoint 已由 LangGraph checkpointer 承担,本层只管 -业务字段——单一事实来源,图每轮读取最新值)。 - -语义约定: -- 业务状态按外部业务键(hphm1、ywrysw...)扁平存储,值为内部强类型; -- 不存在的会话视为空状态(读取返回 {}),与首轮 checkpoint 无字段一致; -- delete 幂等:重复删除固定成功,不抛异常(行为进测试)。 -""" +"""异步业务字段存储接口;字段值保留 bool/int/str 的区别。""" from typing import Protocol class CheckpointNotFoundError(Exception): - """存储适配器明确确认线程不存在;不能用一般存储异常代替。""" + """存储适配器明确确认线程不存在。""" class SessionRepository(Protocol): - """业务字段存储接口(作业 7 由 PostgreSQL 实现替换)。""" - - def load(self, session_id: str) -> dict: - """读取会话业务字段;无记录时返回空 dict。""" + async def load(self, session_id: str) -> dict: + """读取业务字段;不存在返回空 dict。""" ... - def apply_update(self, session_id: str, patch: dict) -> dict: - """合并差量并返回合并后的完整业务字段。""" + async def apply_update(self, session_id: str, patch: dict) -> dict: + """提交差量后返回完整业务字段。""" ... - def delete(self, session_id: str) -> None: - """删除会话业务状态;幂等,重复删除不抛异常。""" + async def delete(self, session_id: str) -> None: + """幂等删除;真实存储错误必须传播。""" ... class InMemorySessionRepository: - """内存实现:字典存储,仅用于单进程开发与测试。""" - - def __init__(self) -> None: + def __init__(self): self._store: dict[str, dict] = {} - def load(self, session_id: str) -> dict: + async def load(self, session_id: str) -> dict: return dict(self._store.get(session_id) or {}) - def apply_update(self, session_id: str, patch: dict) -> dict: + async def apply_update(self, session_id: str, patch: dict) -> dict: state = self._store.setdefault(session_id, {}) state.update(patch) return dict(state) - def delete(self, session_id: str) -> None: - # 幂等:pop 的缺省值吞掉不存在的情况,重复删除行为固定为成功 + async def delete(self, session_id: str) -> None: self._store.pop(session_id, None) diff --git a/src/storage/__init__.py b/src/storage/__init__.py new file mode 100644 index 0000000..57e7339 --- /dev/null +++ b/src/storage/__init__.py @@ -0,0 +1 @@ +"""数据库适配器:短事务、连接池生命周期与 SQLite 单实例文件锁。""" diff --git a/src/storage/checkpoints.py b/src/storage/checkpoints.py new file mode 100644 index 0000000..841ee4e --- /dev/null +++ b/src/storage/checkpoints.py @@ -0,0 +1,16 @@ +"""图 checkpoint 清理的统一边界;不把缺少清理能力当成功。""" + + +async def delete_checkpoint(graph, session_id): + if graph is None: + raise RuntimeError('LangGraph backend not initialized') + saver = getattr(graph, 'checkpointer', None) + async_delete = getattr(saver, 'adelete_thread', None) + if callable(async_delete): + await async_delete(session_id) + return + sync_delete = getattr(saver, 'delete_thread', None) + if callable(sync_delete): + sync_delete(session_id) + return + raise RuntimeError('LangGraph checkpointer does not support thread deletion') diff --git a/src/storage/database.py b/src/storage/database.py new file mode 100644 index 0000000..89d5829 --- /dev/null +++ b/src/storage/database.py @@ -0,0 +1,93 @@ +import asyncio +import os +from contextlib import asynccontextmanager +from pathlib import Path + + +class SqliteInstanceLock: + """拒绝第二个进程/实例打开同一 SQLite 文件;锁由操作系统随退出释放。""" + + def __init__(self, path): + self.path = Path(str(path) + '.instance.lock') + self.file = None + + def __enter__(self): + self.path.parent.mkdir(parents=True, exist_ok=True) + self.file = self.path.open('a+b') + try: + self.file.seek(0, os.SEEK_END) + if self.file.tell() == 0: + self.file.write(b'0') + self.file.flush() + self.file.seek(0) + if os.name == 'nt': + import msvcrt + msvcrt.locking(self.file.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + fcntl.flock(self.file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as exc: + self.file.close() + self.file = None + raise RuntimeError('SQLite storage already has an active instance; use PostgreSQL for multiple workers') from exc + return self + + def __exit__(self, *_): + if self.file is not None: + self.file.seek(0) + if os.name == 'nt': + import msvcrt + msvcrt.locking(self.file.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + fcntl.flock(self.file.fileno(), fcntl.LOCK_UN) + self.file.close() + self.file = None + + +class Connection: + def __init__(self, raw, postgres=False): + self.raw = raw + self.postgres = postgres + + async def execute(self, sql, params=()): + if self.postgres: + sql = sql.replace('?', '%s') + return await self.raw.execute(sql, params) + + async def one(self, sql, params=()): + cursor = await self.execute(sql, params) + try: + row = await cursor.fetchone() + return dict(row) if row is not None else None + finally: + await cursor.close() + + +class SqliteDatabase: + def __init__(self, connection): + self.connection = connection + self.lock = asyncio.Lock() + + @asynccontextmanager + async def transaction(self): + # 不跨 LLM 调用持有事务;仅保护一次业务提交。 + async with self.lock: + await self.connection.execute('BEGIN IMMEDIATE') + try: + yield Connection(self.connection) + await self.connection.commit() + except BaseException: + await self.connection.rollback() + raise + + +class PostgresDatabase: + def __init__(self, pool): + self.pool = pool + + @asynccontextmanager + async def transaction(self): + async with self.pool.connection() as connection: + async with connection.transaction(): + yield Connection(connection, postgres=True) diff --git a/src/storage/locks.py b/src/storage/locks.py new file mode 100644 index 0000000..b0b8d07 --- /dev/null +++ b/src/storage/locks.py @@ -0,0 +1,61 @@ +import asyncio +import hashlib +from contextlib import asynccontextmanager + +from ..backends.chat import ChatBackendAPIError +from ..services.session_locks import SessionLocks + + +class PostgresSessionLocks: + """本进程锁 + 独立连接上的 session advisory lock;不跨 LLM 持有行事务。""" + + def __init__(self, pool, timeout=30): + self.pool = pool + self.timeout = timeout + self.local = SessionLocks() + + @staticmethod + def key(session_id): + digest = hashlib.blake2b(('znjj-agent:'+session_id).encode(), digest_size=8).digest() + return int.from_bytes(digest, 'big', signed=True) + + async def _release(self, connection, key): + try: + async with asyncio.timeout(5): + await connection.execute('SELECT pg_advisory_unlock(%s)', (key,)) + except Exception: + # 不把仍持锁或失联的连接放回池。 + await connection.close() + + @asynccontextmanager + async def hold(self, session_id): + async with self.local.hold(session_id): + async with self.pool.connection(timeout=self.timeout) as connection: + acquired = False + key = self.key(session_id) + try: + try: + async with asyncio.timeout(self.timeout): + while True: + cursor = await connection.execute('SELECT pg_try_advisory_lock(%s) AS locked', (key,)) + row = await cursor.fetchone() + await cursor.close() + if row['locked']: + acquired = True + break + await asyncio.sleep(0.05) + except TimeoutError as exc: + raise ChatBackendAPIError('Session is busy; retry later') from exc + yield + finally: + if not acquired: + # 查询取消时服务端可能已加锁但客户端尚未收到结果。 + # 关闭此连接,避免把潜在持锁连接放回池。 + await connection.close() + if acquired: + release = asyncio.create_task(self._release(connection, key)) + try: + await asyncio.shield(release) + except asyncio.CancelledError: + await release + raise diff --git a/src/storage/migrate.py b/src/storage/migrate.py new file mode 100644 index 0000000..554682a --- /dev/null +++ b/src/storage/migrate.py @@ -0,0 +1,34 @@ +"""python -m src.storage.migrate --backend sqlite|postgresql""" +import argparse +import asyncio +import os +from dataclasses import replace + +from ..core.config import Config +from .runtime import StorageSettings, open_storage + + +async def migrate(settings): + if settings.kind == 'memory': + raise ValueError('Select --backend sqlite or postgresql') + async with open_storage(replace(settings, auto_migrate=True)): + print(f'Initialized {settings.kind} business schema and LangGraph checkpoint tables.') + + +def main(): + parser = argparse.ArgumentParser(description='Initialize the selected agent storage (no data transfer between backends).') + parser.add_argument('--backend', choices=['sqlite', 'postgresql']) + parser.add_argument('--sqlite-path') + args = parser.parse_args() + settings = StorageSettings.from_config(Config) + if args.backend: + settings = replace(settings, kind=args.backend) + if args.sqlite_path: + settings = replace(settings, sqlite_path=args.sqlite_path) + if os.name == 'nt' and settings.kind == 'postgresql': + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + asyncio.run(migrate(settings)) + + +if __name__ == '__main__': + main() diff --git a/src/storage/repository.py b/src/storage/repository.py new file mode 100644 index 0000000..f8ca02f --- /dev/null +++ b/src/storage/repository.py @@ -0,0 +1,142 @@ +"""SQLite/PostgreSQL 共用的业务表、版本检查与轮次提交日志。""" + +import json +from dataclasses import dataclass +from datetime import datetime, timezone +from uuid import uuid4 + +from ..backends.chat import ChatBackendConflictError + +SCHEMA_VERSION = 1 +DDL = [ + 'CREATE TABLE IF NOT EXISTS agent_schema (version INTEGER PRIMARY KEY)', + '''CREATE TABLE IF NOT EXISTS agent_sessions ( + session_id TEXT PRIMARY KEY, version BIGINT NOT NULL, epoch BIGINT NOT NULL, + fields TEXT NOT NULL, graph_state TEXT NOT NULL, deleted INTEGER NOT NULL, + updated_at TEXT NOT NULL)''', + '''CREATE TABLE IF NOT EXISTS agent_turns ( + turn_id TEXT PRIMARY KEY, session_id TEXT NOT NULL, epoch BIGINT NOT NULL, + request_key TEXT, request_hash TEXT NOT NULL, base_version BIGINT NOT NULL, + status TEXT NOT NULL, result TEXT, created_at TEXT NOT NULL, + UNIQUE(session_id, epoch, request_key))''', + 'CREATE INDEX IF NOT EXISTS agent_turns_session ON agent_turns(session_id)', +] + + +def encode(value): + return json.dumps(value, ensure_ascii=False, separators=(',', ':')) + + +def now(): + return datetime.now(timezone.utc).isoformat() + + +@dataclass +class TurnTicket: + session_id: str + turn_id: str + version: int + epoch: int + graph_state: dict + replay: dict | None = None + + +class SqlSessionRepository: + def __init__(self, database): + self.database = database + + async def migrate(self): + async with self.database.transaction() as conn: + for sql in DDL: + await conn.execute(sql) + await conn.execute('INSERT INTO agent_schema(version) VALUES (?) ON CONFLICT DO NOTHING', (SCHEMA_VERSION,)) + + async def verify(self): + async with self.database.transaction() as conn: + row = await conn.one('SELECT MAX(version) AS version FROM agent_schema') + if not row or row['version'] != SCHEMA_VERSION: + raise RuntimeError('Unsupported agent storage schema; run the storage migration command') + + async def _ensure(self, conn, session_id): + await conn.execute('''INSERT INTO agent_sessions + (session_id,version,epoch,fields,graph_state,deleted,updated_at) + VALUES (?,0,0,'{}','{}',0,?) ON CONFLICT DO NOTHING''', (session_id, now())) + return await conn.one('SELECT * FROM agent_sessions WHERE session_id=?', (session_id,)) + + async def load(self, session_id): + async with self.database.transaction() as conn: + row = await conn.one('SELECT fields FROM agent_sessions WHERE session_id=?', (session_id,)) + return json.loads(row['fields']) if row else {} + + async def apply_update(self, session_id, patch): + async with self.database.transaction() as conn: + row = await self._ensure(conn, session_id) + fields = json.loads(row['fields']) + fields.update(patch) + cursor = await conn.execute('''UPDATE agent_sessions SET fields=?,version=version+1, + deleted=0,updated_at=? WHERE session_id=? AND version=?''', + (encode(fields), now(), session_id, row['version'])) + if cursor.rowcount != 1: + raise ChatBackendConflictError('Session version changed; retry the request') + return fields + + async def delete(self, session_id): + async with self.database.transaction() as conn: + row = await self._ensure(conn, session_id) + cursor = await conn.execute('''UPDATE agent_sessions SET fields='{}',graph_state='{}', + deleted=1,version=version+1,epoch=epoch+1,updated_at=? + WHERE session_id=? AND version=?''', (now(), session_id, row['version'])) + if cursor.rowcount != 1: + raise ChatBackendConflictError('Session version changed during deletion') + await conn.execute('DELETE FROM agent_turns WHERE session_id=?', (session_id,)) + + async def begin_turn(self, session_id, request_key, request_hash): + async with self.database.transaction() as conn: + row = await self._ensure(conn, session_id) + previous = None + if request_key: + previous = await conn.one('''SELECT * FROM agent_turns + WHERE session_id=? AND epoch=? AND request_key=?''', + (session_id, row['epoch'], request_key)) + if previous and previous['request_hash'] != request_hash: + raise ChatBackendConflictError('Idempotency-Key was used with a different request') + if previous and previous['status'] == 'committed': + return TurnTicket(session_id, previous['turn_id'], row['version'], row['epoch'], + json.loads(row['graph_state']), json.loads(previous['result'])) + # 同会话锁已持有:遗留 pending 只能来自中断,下一次从提交快照重建。 + await conn.execute("UPDATE agent_turns SET status='aborted' WHERE session_id=? AND status='pending'", (session_id,)) + turn_id = previous['turn_id'] if previous else uuid4().hex + if previous: + await conn.execute("UPDATE agent_turns SET status='pending',base_version=?,result=NULL WHERE turn_id=?", + (row['version'], turn_id)) + else: + await conn.execute('''INSERT INTO agent_turns + (turn_id,session_id,epoch,request_key,request_hash,base_version,status,result,created_at) + VALUES (?,?,?,?,?,?,'pending',NULL,?)''', + (turn_id, session_id, row['epoch'], request_key, request_hash, row['version'], now())) + return TurnTicket(session_id, turn_id, row['version'], row['epoch'], json.loads(row['graph_state'])) + + async def commit_turn(self, ticket, patch, graph_state): + async with self.database.transaction() as conn: + row = await conn.one('SELECT * FROM agent_sessions WHERE session_id=?', (ticket.session_id,)) + if not row or row['epoch'] != ticket.epoch or row['version'] != ticket.version: + raise ChatBackendConflictError('Session changed before turn commit') + fields = json.loads(row['fields']) + fields.update(patch) + state = dict(graph_state) + state['state_version'] = ticket.version + 1 + state['turn_patch'] = graph_state.get('turn_patch') or {} + cursor = await conn.execute('''UPDATE agent_sessions SET fields=?,graph_state=?,deleted=0, + version=version+1,updated_at=? WHERE session_id=? AND version=? AND epoch=?''', + (encode(fields), encode(state), now(), ticket.session_id, ticket.version, ticket.epoch)) + if cursor.rowcount != 1: + raise ChatBackendConflictError('Session version changed during commit') + result = {key: state.get(key) for key in ('response_text', 'turn_patch', 'stage_code')} + cursor = await conn.execute("UPDATE agent_turns SET status='committed',result=? WHERE turn_id=? AND status='pending'", + (encode(result), ticket.turn_id)) + if cursor.rowcount != 1: + raise ChatBackendConflictError('Turn is no longer pending') + + async def abort_turn(self, ticket): + async with self.database.transaction() as conn: + await conn.execute("UPDATE agent_turns SET status='aborted' WHERE turn_id=? AND status='pending'", (ticket.turn_id,)) diff --git a/src/storage/runtime.py b/src/storage/runtime.py new file mode 100644 index 0000000..857c519 --- /dev/null +++ b/src/storage/runtime.py @@ -0,0 +1,92 @@ +from contextlib import AsyncExitStack, asynccontextmanager +from dataclasses import dataclass +from pathlib import Path + +from langgraph.checkpoint.memory import InMemorySaver + +from ..services.repository import InMemorySessionRepository +from ..services.session_locks import SessionLocks +from .database import PostgresDatabase, SqliteDatabase, SqliteInstanceLock +from .repository import SqlSessionRepository + + +@dataclass(frozen=True) +class StorageSettings: + kind: str = 'memory' + sqlite_path: str = './data/agent.sqlite3' + postgres_dsn: str = '' + auto_migrate: bool = False + pool_size: int = 10 + lock_timeout: float = 30 + + @classmethod + def from_config(cls, config): + return cls(config.AGENT_STORAGE, config.AGENT_SQLITE_PATH, config.AGENT_POSTGRES_DSN, + config.AGENT_DB_AUTO_MIGRATE, config.AGENT_DB_POOL_SIZE, + config.AGENT_DB_LOCK_TIMEOUT_SECONDS) + + +@dataclass +class StorageRuntime: + repository: object + checkpointer: object + locks: object + + +@asynccontextmanager +async def open_storage(settings: StorageSettings): + if settings.kind not in ('memory', 'sqlite', 'postgresql'): + raise ValueError('Unknown AGENT_STORAGE') + async with AsyncExitStack() as stack: + if settings.kind == 'memory': + yield StorageRuntime(InMemorySessionRepository(), InMemorySaver(), SessionLocks()) + return + if settings.kind == 'sqlite': + import aiosqlite + from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver + + if not settings.sqlite_path.strip() or settings.sqlite_path == ':memory:': + raise ValueError('SQLite persistence requires a file path') + path = Path(settings.sqlite_path).resolve() + path.parent.mkdir(parents=True, exist_ok=True) + stack.enter_context(SqliteInstanceLock(path)) + business = await stack.enter_async_context(aiosqlite.connect(path, isolation_level=None)) + business.row_factory = aiosqlite.Row + await business.execute('PRAGMA journal_mode=WAL') + await business.execute('PRAGMA busy_timeout=5000') + checkpoint_connection = await stack.enter_async_context(aiosqlite.connect(path)) + await checkpoint_connection.execute('PRAGMA busy_timeout=5000') + checkpointer = AsyncSqliteSaver(checkpoint_connection) + database = SqliteDatabase(business) + locks = SessionLocks() + else: + from psycopg.rows import dict_row + from psycopg_pool import AsyncConnectionPool + from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver + from .locks import PostgresSessionLocks + + if not settings.postgres_dsn.strip(): + raise ValueError('AGENT_POSTGRES_DSN is required') + async def pool(): + value = AsyncConnectionPool( + settings.postgres_dsn, min_size=1, max_size=settings.pool_size, open=False, + timeout=settings.lock_timeout, + kwargs={'autocommit': True, 'prepare_threshold': 0, 'row_factory': dict_row, 'connect_timeout': 5}, + ) + await stack.enter_async_context(value) + await value.wait(timeout=15) + return value + business_pool = await pool() + checkpoint_pool = await pool() + lock_pool = await pool() + database = PostgresDatabase(business_pool) + checkpointer = AsyncPostgresSaver(checkpoint_pool) + locks = PostgresSessionLocks(lock_pool, settings.lock_timeout) + repository = SqlSessionRepository(database) + if settings.auto_migrate: + await repository.migrate() + await checkpointer.setup() + await repository.verify() + # 启动时同时检查 saver 表,不能仅检查业务表后偷偷用内存 saver。 + await checkpointer.aget_tuple({'configurable': {'thread_id': '__storage_health__'}}) + yield StorageRuntime(repository, checkpointer, locks) diff --git a/test/api/test_assignment6_endpoints.py b/test/api/test_assignment6_endpoints.py index ed7a1b3..5c269ec 100644 --- a/test/api/test_assignment6_endpoints.py +++ b/test/api/test_assignment6_endpoints.py @@ -20,6 +20,8 @@ from src.main import app def client(monkeypatch): """langgraph 后端 + fake LLM 的测试客户端(lifespan 完整初始化)。""" monkeypatch.setattr(Config, "AGENT_BACKEND", "langgraph") + # 通用 API 契约测试不连接用户 .env 中的真实持久化数据库。 + monkeypatch.setattr(Config, "AGENT_STORAGE", "memory") monkeypatch.setattr(Config, "LLM_PROVIDER", "fake") import src.agent.llm as llm_module diff --git a/test/services/test_delete_failure_semantics.py b/test/services/test_delete_failure_semantics.py index 27b89e5..759ccc6 100644 --- a/test/services/test_delete_failure_semantics.py +++ b/test/services/test_delete_failure_semantics.py @@ -59,7 +59,7 @@ async def test_cancelled_checkpoint_cleanup_keeps_business_fields(): @pytest.mark.asyncio async def test_business_delete_failure_is_not_reported_as_success(): class FailingRepository(InMemorySessionRepository): - def delete(self, _): + async def delete(self, _): raise ConnectionError('business storage offline') service = AccidentAppService(SimpleNamespace(), FailingRepository()) await service.set_info('s', 'hphm1', 'test-plate') diff --git a/test/services/test_field_commit_and_retry.py b/test/services/test_field_commit_and_retry.py index 9d9fdff..163c0a9 100644 --- a/test/services/test_field_commit_and_retry.py +++ b/test/services/test_field_commit_and_retry.py @@ -31,8 +31,8 @@ async def test_person_synonyms_share_storage_and_prompt_values(): await service.set_info('s', api, 'new') assert json.loads(await service.get_info('s', api)) == 'new' assert json.loads(await service.get_info('s', native)) == 'new' - assert api not in repo.load('s') - fields = service._injected_fields('s')['person_fields'] + assert api not in await repo.load('s') + fields = (await service._injected_fields('s'))['person_fields'] prompt = render_prompt(SINGLE_CONFIRM, {'person_fields': fields}) assert 'old' not in prompt assert 'new' in prompt diff --git a/test/storage/__init__.py b/test/storage/__init__.py new file mode 100644 index 0000000..b153f2f --- /dev/null +++ b/test/storage/__init__.py @@ -0,0 +1 @@ +"""SQLite/PostgreSQL durability integration tests.""" diff --git a/test/storage/crash_worker.py b/test/storage/crash_worker.py new file mode 100644 index 0000000..35e1d1b --- /dev/null +++ b/test/storage/crash_worker.py @@ -0,0 +1,43 @@ +"""Integration-test child: exit abruptly at a durable turn's commit boundary.""" +import asyncio +import os +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from src.agent.graph import build_graph +from src.backends.chat import ChatInput +from src.backends.langgraph import LangGraphBackend +from src.services.app_service import AccidentAppService +from src.storage.runtime import StorageSettings, open_storage + + +class LLM: + async def complete(self, system, user, *, history=None): + if '【字段提取】' in system: + return '{"has_injury":false}' + return '1002crash-boundary-reply' + + +async def main(): + kind, path, session, boundary = sys.argv[1:] + settings = StorageSettings(kind=kind, sqlite_path=path, + postgres_dsn=os.getenv('AGENT_TEST_POSTGRES_DSN',''), pool_size=2) + async with open_storage(settings) as storage: + service = AccidentAppService(LangGraphBackend(build_graph(storage.checkpointer), llm=LLM()), + storage.repository, locks=storage.locks) + original = storage.repository.commit_turn + async def crash(*args): + if boundary=='before': + os._exit(23) + await original(*args) + os._exit(24) + storage.repository.commit_turn = crash + await service.chat(ChatInput(session,'继续办理',request_key='crash-key')) + + +if __name__ == '__main__': + factory=asyncio.SelectorEventLoop if os.name=='nt' else None + with asyncio.Runner(loop_factory=factory) as runner: + runner.run(main()) diff --git a/test/storage/test_http_persistence.py b/test/storage/test_http_persistence.py new file mode 100644 index 0000000..cea9816 --- /dev/null +++ b/test/storage/test_http_persistence.py @@ -0,0 +1,54 @@ +import asyncio +from contextlib import asynccontextmanager +from dataclasses import replace + +import httpx +import pytest +from fastapi import FastAPI + +from src.api.endpoints import router +from src.core import fastgpt_client as wiring +from src.core.config import Config +from src.storage.runtime import open_storage +from .test_persistence import RecordingLLM, run, settings + + +def test_http_restart_replay_headers_and_switching(settings, monkeypatch): + import src.agent.llm as llm_module + monkeypatch.setattr(Config, 'AGENT_BACKEND', 'langgraph') + monkeypatch.setattr(Config, 'LLM_PROVIDER', 'fake') + monkeypatch.setattr(Config, 'AGENT_STORAGE', settings.kind) + monkeypatch.setattr(Config, 'AGENT_SQLITE_PATH', settings.sqlite_path) + monkeypatch.setattr(Config, 'AGENT_POSTGRES_DSN', settings.postgres_dsn) + monkeypatch.setattr(Config, 'AGENT_DB_POOL_SIZE', settings.pool_size) + monkeypatch.setattr(Config, 'AGENT_DB_AUTO_MIGRATE', True) + monkeypatch.setattr(llm_module, 'build_llm_service', lambda: RecordingLLM()) + app = FastAPI() + app.include_router(router) + @asynccontextmanager + async def client(): + async with wiring.lifespan(app): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as value: + yield value + async def scenario(): + import uuid + payload = {'sessionId': uuid.uuid4().hex, 'timeStamp':'20261022120000'} + request = {**payload, 'text':'新对话'} + async with client() as http: + assert (await http.post('/set_info', json={**payload,'key':'hphm1','value':'http-plate'})).json()['code']=='200' + first = (await http.post('/chat', json=request, headers={'Idempotency-Key':'http-key'})).json() + assert first['nextStageCode']=='1001' + monkeypatch.setattr(Config, 'AGENT_DB_AUTO_MIGRATE', False) + async with client() as http: + assert (await http.post('/chat', json=request, headers={'Idempotency-Key':'http-key'})).json()==first + conflict = await http.post('/chat', json={**request,'text':'different'}, headers={'Idempotency-Key':'http-key'}) + assert conflict.json()['code']=='409' + stream = await http.post('/chat?stream=true', json={**payload,'text':'继续办理','needFormUpdate':False}) + assert 'event: done' in stream.text + assert 'event: formUpdate' not in stream.text + assert '"0"' == (await http.post('/get_info', json={**payload,'key':'jdcsl'})).json()['value'] + assert (await http.request('DELETE','/delete_session',json=payload)).json()['code']=='200' + async with client() as http: + assert (await http.post('/get_info',json={**payload,'key':'hphm1'})).json()['value']=='""' + assert (await http.post('/chat',json=request,headers={'Idempotency-Key':'http-key'})).json()['nextStageCode']=='1001' + run(scenario()) diff --git a/test/storage/test_persistence.py b/test/storage/test_persistence.py new file mode 100644 index 0000000..c7bb228 --- /dev/null +++ b/test/storage/test_persistence.py @@ -0,0 +1,221 @@ +import asyncio +import json +import os +import subprocess +import sys +from pathlib import Path +from contextlib import aclosing, asynccontextmanager +from dataclasses import replace +from uuid import uuid4 + +import pytest + +from src.agent.graph import build_graph +from src.backends.chat import ChatBackendConflictError, ChatInput, FormUpdate +from src.backends.langgraph import LangGraphBackend +from src.services.app_service import AccidentAppService +from src.storage.runtime import StorageSettings, open_storage + + +def run(coroutine): + # psycopg 在 Windows 要求 SelectorEventLoop;Linux 使用标准 runner。 + factory = asyncio.SelectorEventLoop if os.name == 'nt' else None + with asyncio.Runner(loop_factory=factory) as runner: + return runner.run(coroutine) + + +@pytest.fixture(params=['sqlite', 'postgresql']) +def settings(request, tmp_path): + if request.param == 'postgresql': + dsn = os.getenv('AGENT_TEST_POSTGRES_DSN') + if not dsn: + pytest.skip('Set AGENT_TEST_POSTGRES_DSN to an isolated PostgreSQL database') + return StorageSettings(kind='postgresql', postgres_dsn=dsn, auto_migrate=True, pool_size=2) + return StorageSettings(kind='sqlite', sqlite_path=str(tmp_path/'agent.sqlite3'), auto_migrate=True) + + +class RecordingLLM: + def __init__(self): + self.histories = [] + + async def complete(self, system, user, *, history=None): + if '【字段提取】' in system: + return '{"has_injury":false,"vehicle_count":0}' + self.histories.append(history or []) + return '1002已记录' + + async def stream_tokens(self, system, user, *, history=None): + self.histories.append(history or []) + yield '1002' + yield '已记录' + + +@asynccontextmanager +async def service_for(settings, llm=None): + async with open_storage(settings) as storage: + backend = LangGraphBackend(build_graph(storage.checkpointer), llm=llm or RecordingLLM()) + yield AccidentAppService(backend, storage.repository, locks=storage.locks), storage + + +async def session_row(storage, session): + async with storage.repository.database.transaction() as conn: + return await conn.one('SELECT * FROM agent_sessions WHERE session_id=?', (session,)) + + +def test_restart_keeps_fields_phase_and_history(settings): + async def scenario(): + session = uuid4().hex + async with service_for(settings) as (service, storage): + await service.set_info(session, 'hphm1', 'test-plate') + first = await service.chat(ChatInput(session, '新对话')) + assert first.content.startswith('1001') + await service.chat(ChatInput(session, '继续办理')) + values = await storage.repository.load(session) + assert values['ywrysw'] is False + assert type(values['jdcsl']) is int and values['jdcsl'] == 0 + llm = RecordingLLM() + async with service_for(replace(settings, auto_migrate=False), llm) as (service, storage): + assert json.loads(await service.get_info(session, 'hphm1')) == 'test-plate' + result = await service.chat(ChatInput(session, '重启后的新输入')) + assert result.content.startswith('1002') + assert any(m['content'] == '继续办理' for m in llm.histories[0]) + row = await session_row(storage, session) + graph = json.loads(row['graph_state']) + assert graph['turn_count'] == 3 + assert graph['dialogue'][-2]['content'] == '重启后的新输入' + assert graph['state_version'] == row['version'] + run(scenario()) + + +@pytest.mark.parametrize('stream', [False, True]) +def test_idempotency_replay_after_restart_and_conflict(settings, stream): + async def scenario(): + session = uuid4().hex + request = ChatInput(session, '继续办理', request_key='stable-key', need_form_update=True) + async with service_for(settings) as (service, storage): + await service.chat(ChatInput(session, '新对话')) + if stream: + first = [event async for event in service.stream(request)] + assert any(isinstance(event, FormUpdate) for event in first) + else: + first = await service.chat(request) + before = await session_row(storage, session) + llm = RecordingLLM() + async with service_for(settings, llm) as (service, storage): + result = await service.chat(request) + assert result.content == '1002已记录' + assert llm.histories == [] + assert (await session_row(storage, session))['version'] == before['version'] + with pytest.raises(ChatBackendConflictError): + await service.chat(replace(request, text='different')) + run(scenario()) + + +def test_checkpoint_ahead_of_business_commit_is_discarded(settings): + async def scenario(): + session = uuid4().hex + async with service_for(settings) as (service, storage): + await service.chat(ChatInput(session, '新对话')) + original = storage.repository.commit_turn + async def fail(*args): raise ConnectionError('business commit failed') + async def unavailable(*args): raise ConnectionError('journal cleanup failed') + storage.repository.commit_turn = fail + storage.repository.abort_turn = unavailable + with pytest.raises(ConnectionError): + await service.chat(ChatInput(session, '未提交的输入', request_key='retry')) + storage.repository.commit_turn = original + llm = RecordingLLM() + async with service_for(settings, llm) as (service, storage): + await service.chat(ChatInput(session, '未提交的输入', request_key='retry')) + assert all(m['content'] != '未提交的输入' for m in llm.histories[0]) + assert json.loads((await session_row(storage, session))['graph_state'])['turn_count'] == 2 + run(scenario()) + + +def test_committed_business_state_restores_missing_checkpoint(settings): + async def scenario(): + session = uuid4().hex + async with service_for(settings) as (service, storage): + await service.chat(ChatInput(session, '新对话')) + await service.chat(ChatInput(session, '继续办理')) + await storage.checkpointer.adelete_thread(session) + async with service_for(settings) as (service, storage): + reply = await service.chat(ChatInput(session, '再次输入')) + assert reply.content.startswith('1002') + assert json.loads((await session_row(storage, session))['graph_state'])['turn_count'] == 3 + run(scenario()) + + +def test_disconnect_after_commit_can_replay_without_advancing(settings): + async def scenario(): + session = uuid4().hex + request = ChatInput(session, '继续办理', request_key='disconnect') + async with service_for(settings) as (service, storage): + await service.chat(ChatInput(session, '新对话')) + initial = await session_row(storage, session) + async with aclosing(service.stream(request)) as events: + await anext(events) + async with asyncio.timeout(5): + while (await session_row(storage, session))['version'] == initial['version']: + await asyncio.sleep(0.01) + # 此时图及业务提交已完成,但消费者没有读到回合末尾。 + before = await session_row(storage, session) + llm = RecordingLLM() + async with service_for(settings, llm) as (service, storage): + await service.chat(request) + assert llm.histories == [] + assert (await session_row(storage, session))['version'] == before['version'] + run(scenario()) + + +def test_delete_then_restart_and_stale_commit_cannot_revive_data(settings): + async def scenario(): + session = uuid4().hex + async with service_for(settings) as (service, storage): + await service.set_info(session, 'hphm1', 'old-plate') + await service.chat(ChatInput(session, '新对话', request_key='old-key')) + ticket = await storage.repository.begin_turn(session, None, 'pending') + await service.delete_session(session, service._backend.reset_checkpoint) + with pytest.raises(ChatBackendConflictError): + await storage.repository.commit_turn(ticket, {'hphm1':'revived'}, {}) + assert await storage.checkpointer.aget_tuple({'configurable': {'thread_id':session}}) is None + async with service_for(settings) as (service, storage): + assert json.loads(await service.get_info(session, 'hphm1')) == '' + reply = await service.chat(ChatInput(session, '新会话', request_key='old-key')) + assert reply.content.startswith('1001') + assert json.loads((await session_row(storage, session))['graph_state'])['turn_count'] == 1 + run(scenario()) + + +def test_business_version_conflict_rejects_stale_turn(settings): + async def scenario(): + session = uuid4().hex + async with service_for(settings) as (service, storage): + ticket = await storage.repository.begin_turn(session, None, 'pending') + await storage.repository.apply_update(session, {'jdcsl':2}) + with pytest.raises(ChatBackendConflictError): + await storage.repository.commit_turn(ticket, {'jdcsl':1}, {}) + assert (await storage.repository.load(session))['jdcsl'] == 2 + run(scenario()) + + +@pytest.mark.parametrize('boundary', ['before', 'after']) +def test_abrupt_process_exit_at_commit_boundary(settings, boundary): + session = uuid4().hex + async def initialize(): + async with service_for(settings) as (service, storage): + await service.chat(ChatInput(session, '新对话')) + run(initialize()) + worker = Path(__file__).with_name('crash_worker.py') + result = subprocess.run([sys.executable, str(worker), settings.kind, settings.sqlite_path, session, boundary], + capture_output=True, timeout=45) + assert result.returncode == (23 if boundary=='before' else 24), result.stderr.decode(errors='replace') + async def recover(): + llm = RecordingLLM() + async with service_for(replace(settings,auto_migrate=False),llm) as (service,storage): + reply=await service.chat(ChatInput(session,'继续办理',request_key='crash-key')) + assert reply.content.startswith('1002') + assert bool(llm.histories) is (boundary=='before') + row=await session_row(storage,session) + assert json.loads(row['graph_state'])['turn_count']==2 + run(recover()) diff --git a/test/storage/test_storage_config_and_locks.py b/test/storage/test_storage_config_and_locks.py new file mode 100644 index 0000000..279533b --- /dev/null +++ b/test/storage/test_storage_config_and_locks.py @@ -0,0 +1,88 @@ +import asyncio +import os +from dataclasses import replace +from uuid import uuid4 + +import pytest + +from src.core.config import Config +from src.storage.runtime import StorageSettings, open_storage +from .test_persistence import run + + +@pytest.fixture +def postgres_settings(): + dsn = os.getenv('AGENT_TEST_POSTGRES_DSN') + if not dsn: + pytest.skip('Set AGENT_TEST_POSTGRES_DSN to run PostgreSQL integration tests') + return StorageSettings(kind='postgresql',postgres_dsn=dsn,auto_migrate=True,pool_size=2) + + +def test_sqlite_rejects_second_instance_and_releases_guard(tmp_path): + async def scenario(): + config = StorageSettings(kind='sqlite',sqlite_path=str(tmp_path/'single.db'),auto_migrate=True) + async with open_storage(config): + with pytest.raises(RuntimeError, match='active instance'): + async with open_storage(config): pass + async with open_storage(config): pass + run(scenario()) + + +def test_sqlite_requires_migration_and_does_not_fall_back(tmp_path): + async def scenario(): + config = StorageSettings(kind='sqlite',sqlite_path=str(tmp_path/'empty.db')) + with pytest.raises(Exception): + async with open_storage(config): + pytest.fail('Uninitialized durable storage must not start') + async with open_storage(replace(config,auto_migrate=True)): pass + async with open_storage(config) as runtime: + assert runtime.checkpointer.__class__.__name__=='AsyncSqliteSaver' + run(scenario()) + + +def test_postgresql_locks_serialize_independent_application_instances(postgres_settings): + settings = postgres_settings + async def scenario(): + session = uuid4().hex + async with open_storage(settings) as first, open_storage(settings) as second: + entered = asyncio.Event() + async def waiting(): + async with second.locks.hold(session): entered.set() + async with first.locks.hold(session): + task=asyncio.create_task(waiting()) + await asyncio.sleep(0.15) + assert not entered.is_set() + # 同一个锁池中,不同会话仍能执行。 + async with second.locks.hold(session+'-other'): + pass + await asyncio.wait_for(task,3) + assert entered.is_set() + run(scenario()) + + +def test_postgresql_cancelled_lock_holder_releases_server_lock(postgres_settings): + settings = postgres_settings + async def scenario(): + session=uuid4().hex + async with open_storage(settings) as first, open_storage(settings) as second: + ready=asyncio.Event() + async def holding(): + async with first.locks.hold(session): + ready.set() + await asyncio.Future() + task=asyncio.create_task(holding()) + await ready.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): await task + async with asyncio.timeout(3): + async with second.locks.hold(session): pass + run(scenario()) + + +@pytest.mark.parametrize('kind', ['unknown', 'sqlite', 'postgresql']) +def test_invalid_storage_config_is_rejected(monkeypatch,kind): + monkeypatch.setattr(Config,'AGENT_BACKEND','langgraph') + monkeypatch.setattr(Config,'AGENT_STORAGE',kind) + monkeypatch.setattr(Config,'AGENT_SQLITE_PATH',':memory:') + monkeypatch.setattr(Config,'AGENT_POSTGRES_DSN','') + with pytest.raises(ValueError):Config.validate()