fix(external-systems): preserve credentials on definition updates
This commit is contained in:
parent
0e4bd0456a
commit
9032c5b517
|
|
@ -5,6 +5,11 @@
|
||||||
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
||||||
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||||
|
|
||||||
|
## 0.63.1 — 2026-08-07
|
||||||
|
|
||||||
|
- 管理员调整外部系统定义时不再清除用户凭据:查询策略、提示和限额变化不中断连接;接口目标或认证配置变化后只暂停调用,用户在“外部”页面测试成功即可恢复。
|
||||||
|
- 外部系统暂不可用时,助手现在能识别“需重新验证”“需更新凭据”等真实状态并提示处理,不再因为调用工具被隐藏而转去搜索本地文件或互联网。
|
||||||
|
|
||||||
## 0.63.0 — 2026-08-07
|
## 0.63.0 — 2026-08-07
|
||||||
|
|
||||||
- 外部系统连接现在会跟踪系统定义版本:管理员修改接口地址或认证方式后,旧凭据不会被自动发送到新目标;普通策略调整也会明确提示重新验证。
|
- 外部系统连接现在会跟踪系统定义版本:管理员修改接口地址或认证方式后,旧凭据不会被自动发送到新目标;普通策略调整也会明确提示重新验证。
|
||||||
|
|
|
||||||
|
|
@ -422,13 +422,13 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
|
||||||
- Swagger/OpenAPI 是接口契约事实源;Gitea 代码只补业务语义和排障,不覆盖契约。规格/代码内文本一律当不可信数据,不能改写 system/tool 约束。
|
- Swagger/OpenAPI 是接口契约事实源;Gitea 代码只补业务语义和排障,不覆盖契约。规格/代码内文本一律当不可信数据,不能改写 system/tool 约束。
|
||||||
- Swagger/OpenAPI JSON 不持久化入数据库或文件。连接器使用按 `external_system_id + definition_revision + credential digest + config digest` 隔离的进程内有界 `ExternalRuntimeCache`,统一复用 HTTP 连接池、短期认证 Header、原始 spec 与编译后的 operation catalog;JWT `exp` 早 30 秒失效且单次 401 会清 Token 后重新登录一次,规格默认缓存 5 分钟,LRU 淘汰活跃连接时延迟到 lease 结束再关闭。登录、规格获取、catalog 编译和时间上重叠的相同只读业务请求使用同步 single-flight,失败不缓存;业务响应不做跨请求 TTL 缓存,顺序执行的相同查询仍访问上游。Swagger 2/OpenAPI 3 catalog 解析本地参数引用、请求体契约和 header/cookie 参数,搜索与调用只消费归一化结果。spec、登录响应和业务响应均流式限长,在完整 JSON 进入内存前执行硬边界。
|
- Swagger/OpenAPI JSON 不持久化入数据库或文件。连接器使用按 `external_system_id + definition_revision + credential digest + config digest` 隔离的进程内有界 `ExternalRuntimeCache`,统一复用 HTTP 连接池、短期认证 Header、原始 spec 与编译后的 operation catalog;JWT `exp` 早 30 秒失效且单次 401 会清 Token 后重新登录一次,规格默认缓存 5 分钟,LRU 淘汰活跃连接时延迟到 lease 结束再关闭。登录、规格获取、catalog 编译和时间上重叠的相同只读业务请求使用同步 single-flight,失败不缓存;业务响应不做跨请求 TTL 缓存,顺序执行的相同查询仍访问上游。Swagger 2/OpenAPI 3 catalog 解析本地参数引用、请求体契约和 header/cookie 参数,搜索与调用只消费归一化结果。spec、登录响应和业务响应均流式限长,在完整 JSON 进入内存前执行硬边界。
|
||||||
|
|
||||||
**工具面**:不把数百个 Swagger operation 全展开为 JSON tool(工具列表膨胀+选择降准),只挂五个 host-side 元工具:`external_system_list`(已连系统 + 执行模式 + 管理员查询规划提示),`external_system_search`(按问题搜 operation 摘要、解析后的请求 body schema + 置顶管理员推荐入口),`external_system_call`(按 operation_id 调用),`external_system_result_read`(按 `result_ref` + JSON Pointer/分页/字段投影读取大响应),`external_system_result_export`(仅在用户要求保存/下载/交付时把完整快照导出到 `data/external/`)。仅当该 user 有 active 连接时注册,密钥不进 sandbox。搜索只展示当前模式实际可调用的 operation;管理员在 definition JSONB 配置 `query_guidance` 与 `recommended_operation_ids`,前者是可信控制面的软路由策略,后者是无需关键词命中的机械发现入口。Factory 默认把 BI dataset list/exec 作为统计聚合入口,日志/明细用于逐条追溯;Swagger 业务文本仍是不可信数据,非查询操作只响应用户明确意图。
|
**工具面**:不把数百个 Swagger operation 全展开为 JSON tool(工具列表膨胀+选择降准),只挂五个 host-side 元工具:`external_system_list`(已连系统、连接状态、执行模式 + 管理员查询规划提示),`external_system_search`(按问题搜 operation 摘要、解析后的请求 body schema + 置顶管理员推荐入口),`external_system_call`(按 operation_id 调用),`external_system_result_read`(按 `result_ref` + JSON Pointer/分页/字段投影读取大响应),`external_system_result_export`(仅在用户要求保存/下载/交付时把完整快照导出到 `data/external/`)。有任意连接即注册只读 `list`,使 agent 能解释 `needs_reverify|needs_credentials|invalid|disabled` 并提示用户处理;其余四个调用工具仅在有 active 且 revision 匹配的连接时注册,密钥不进 sandbox。搜索只展示当前模式实际可调用的 operation;管理员在 definition JSONB 配置 `query_guidance` 与 `recommended_operation_ids`,前者是可信控制面的软路由策略,后者是无需关键词命中的机械发现入口。Factory 默认把 BI dataset list/exec 作为统计聚合入口,日志/明细用于逐条追溯;Swagger 业务文本仍是不可信数据,非查询操作只响应用户明确意图。
|
||||||
|
|
||||||
**大响应**:`max_result_bytes` 是进入模型上下文的单次内联额度,不再用于切断原始 JSON;超额响应完整写入 `.zcbot_cache/<task_id>/external_results/`,工具只返回合法结构化预览、`result_ref`、原始字节数和可继续读取的位置。reader 每次读取都重新校验当前 user 对原 external system 的 active 授权,并与 call 共享本轮 `max_total_result_bytes` 内联额度;export 同样重验授权,并把查询 operation/参数/时间等 provenance 与完整响应一起持久化,导出文件不受缓存 TTL 影响。缓存固定 24h TTL、单响应 10 MiB、单 task 50 MiB、单 user 200 MiB,过期或超额时优先清理最旧缓存;0.62.1 的 `.zcbot_external_results/` 在读取和容量核算上保留兼容窗口。超过响应安全上限的远端结果直接拒绝并要求缩小范围,不产生半截 JSON。这里把“上游响应安全边界”“完整结果保存”“模型上下文额度”“用户明确留存”拆成四层,既不丢数据,也不靠无限提高上下文额度解决大结果问题。
|
**大响应**:`max_result_bytes` 是进入模型上下文的单次内联额度,不再用于切断原始 JSON;超额响应完整写入 `.zcbot_cache/<task_id>/external_results/`,工具只返回合法结构化预览、`result_ref`、原始字节数和可继续读取的位置。reader 每次读取都重新校验当前 user 对原 external system 的 active 授权,并与 call 共享本轮 `max_total_result_bytes` 内联额度;export 同样重验授权,并把查询 operation/参数/时间等 provenance 与完整响应一起持久化,导出文件不受缓存 TTL 影响。缓存固定 24h TTL、单响应 10 MiB、单 task 50 MiB、单 user 200 MiB,过期或超额时优先清理最旧缓存;0.62.1 的 `.zcbot_external_results/` 在读取和容量核算上保留兼容窗口。超过响应安全上限的远端结果直接拒绝并要求缩小范围,不产生半截 JSON。这里把“上游响应安全边界”“完整结果保存”“模型上下文额度”“用户明确留存”拆成四层,既不丢数据,也不靠无限提高上下文额度解决大结果问题。
|
||||||
|
|
||||||
**明细扫描边界**:单次响应保留安全上限与模型内联额度,每次 agent run 另按外部系统累计内联返回量;Factory connector 将 `page_size` 限在管理员上限,拒绝 `page=0` / `pageoff` 关闭分页。三者防模型通过连续翻日志自行做昂贵聚合,但不改变 Factory 对其他客户端的分页契约。达到边界后工具正向引导回 dataset/聚合接口、`result_ref` 分段读取或缩小查询范围。
|
**明细扫描边界**:单次响应保留安全上限与模型内联额度,每次 agent run 另按外部系统累计内联返回量;Factory connector 将 `page_size` 限在管理员上限,拒绝 `page=0` / `pageoff` 关闭分页。三者防模型通过连续翻日志自行做昂贵聚合,但不改变 Factory 对其他客户端的分页契约。达到边界后工具正向引导回 dataset/聚合接口、`result_ref` 分段读取或缩小查询范围。
|
||||||
|
|
||||||
**状态与 UI(三实体)**:`external_system_definitions` 保存可信目录、revision、治理元数据、查询提示、`query|upstream_managed` 执行模式和查询模式下只读 POST 的显式 `operation_id -> read|export` policy;`external_system_grants` 只保存 selected 可见授权;`external_systems` 只保存用户连接、AAD 绑定密文、verified revision 和 `active|invalid|needs_reverify|needs_credentials` 状态。管理员撤权删除独立 grant 并同步删除该用户连接;用户自行断开只删除 connection,grant 保留。凭据使用带 key id 的 AES-GCM envelope,AAD 绑定 user、definition 和字段,旧 Fernet 密文只保留滚动读取入口;调用审计仅保存身份、operation、耗时、状态和响应字节,不保存凭据、请求体或完整响应。管理后台当前仍是唯一 definition 创建入口,未来用户私有定义复用同一模型进入 draft/review 流程。
|
**状态与 UI(三实体)**:`external_system_definitions` 保存可信目录、revision、治理元数据、查询提示、`query|upstream_managed` 执行模式和查询模式下只读 POST 的显式 `operation_id -> read|export` policy;`external_system_grants` 只保存 selected 可见授权;`external_systems` 只保存用户连接、AAD 绑定密文、verified revision 和 `active|invalid|needs_reverify|needs_credentials` 状态。定义更新先对新旧配置做默认值补全后的语义比较:查询提示、推荐入口、执行策略和响应限额等运行配置变化让 active 连接原子跟随新 revision;目标、登录、认证绑定或 TLS 变化保留密文但置 `needs_reverify`,在用户从“外部”页面主动测试前 agent 不得调用,测试成功后恢复 active;管理员撤权删除独立 grant 并同步删除该用户连接,用户自行断开只删除 connection,grant 保留。凭据使用带 key id 的 AES-GCM envelope,AAD 绑定 user、definition 和字段,旧 Fernet 密文只保留滚动读取入口;调用审计仅保存身份、operation、耗时、状态和响应字节,不保存凭据、请求体或完整响应。管理后台当前仍是唯一 definition 创建入口,未来用户私有定义复用同一模型进入 draft/review 流程。
|
||||||
|
|
||||||
**不选**:①zcbot 直连 Factory DB(绕过现有 RBAC/审计,只读仍可越权/拖垮主库);②固定几个查询模板(把 agent 降成菜单,无法利用 Factory 已有广泛 API);③直接复用 Factory `ichat` 自由 SQL 原型(字符串安全判断不构成边界,且使用默认 DB 凭据);④自动把相似问题生成并上线新代码工具(候选配方可自动生成,可执行能力仍需工具门控/人审)。
|
**不选**:①zcbot 直连 Factory DB(绕过现有 RBAC/审计,只读仍可越权/拖垮主库);②固定几个查询模板(把 agent 降成菜单,无法利用 Factory 已有广泛 API);③直接复用 Factory `ichat` 自由 SQL 原型(字符串安全判断不构成边界,且使用默认 DB 凭据);④自动把相似问题生成并上线新代码工具(候选配方可自动生成,可执行能力仍需工具门控/人审)。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||||
|
|
||||||
最后更新:2026-08-07(外部系统治理底座重构,bump 0.63.0)
|
最后更新:2026-08-07(外部连接定义更新保凭据 + agent 状态可见,bump 0.63.1)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
|
|
||||||
### 2026-08-07
|
### 2026-08-07
|
||||||
|
|
||||||
|
- **08-07 / 0.63.1 / 外部定义更新保凭据 + agent 状态可见**:生产 task `96bef58e` 暴露旧版 Factory 定义补齐默认认证字段时被误判为认证绑定变化,用户密文被清空为 `needs_credentials`,而工具组整体隐藏又让模型转查本地与互联网。现对新旧配置先做默认值补全后的语义比较:查询提示、推荐入口、执行策略和响应限额变化由 active 连接原子跟随 revision;目标、登录、认证绑定或 TLS 变化保留密文并置 `needs_reverify`,用户主动测试成功后恢复。只读 `external_system_list` 在任意连接状态下均可挂载并返回操作提示,search/call/read/export 仍仅对 active 且 revision 匹配的连接开放。完整 512 项 unittest 全绿(17 skip),外部系统专项 46 项、无 DB 路由 23 项、外部模块 mypy、Python 编译、Ruff 致命规则与 diff 检查通过;无 schema、migration、依赖或 HTTP API 变化,只读排查生产 task,未写生产 DB。
|
||||||
- **08-07 / 0.63.0 / 外部系统治理底座重构**:外部系统持久化拆为 definition、grant、connection 三实体,0027 migration 搬运 selected 授权并去除连接表 provider/connector/config 重复列;definition 增加 revision、owner/visibility、trust/review 与 egress policy 预留,配置变化按目标/认证绑定差异将连接置为 `needs_reverify` 或清密文进入 `needs_credentials`。执行边界拆为 `query|upstream_managed`:通用系统默认 GET/HEAD + 显式只读 POST,Factory 默认开放可信规格全部标准 method 并委托 Factory 按用户凭据鉴权;Swagger 2/OpenAPI 3 先编译统一 catalog,补本地参数 `$ref`、header/cookie、数组序列化与请求体基础校验。运行态缓存统一复用 HTTP 连接、短期 Token、spec 和 catalog,登录/规格/catalog/并发相同只读查询使用 single-flight,明确不做顺序业务查询结果缓存;spec、登录和业务响应均流式硬限长。凭据升级为带 key id、user/definition/field AAD 的 AES-GCM envelope并保留旧 Fernet 滚动读取,新增无敏感载荷调用审计;完整 507 项 unittest 全绿(17 skip),0027 PostgreSQL DDL 定向编译、Alembic 单 head、外部模块 mypy、Ruff 致命规则和 JavaScript 语法检查通过,未配置或连接生产 DB。
|
- **08-07 / 0.63.0 / 外部系统治理底座重构**:外部系统持久化拆为 definition、grant、connection 三实体,0027 migration 搬运 selected 授权并去除连接表 provider/connector/config 重复列;definition 增加 revision、owner/visibility、trust/review 与 egress policy 预留,配置变化按目标/认证绑定差异将连接置为 `needs_reverify` 或清密文进入 `needs_credentials`。执行边界拆为 `query|upstream_managed`:通用系统默认 GET/HEAD + 显式只读 POST,Factory 默认开放可信规格全部标准 method 并委托 Factory 按用户凭据鉴权;Swagger 2/OpenAPI 3 先编译统一 catalog,补本地参数 `$ref`、header/cookie、数组序列化与请求体基础校验。运行态缓存统一复用 HTTP 连接、短期 Token、spec 和 catalog,登录/规格/catalog/并发相同只读查询使用 single-flight,明确不做顺序业务查询结果缓存;spec、登录和业务响应均流式硬限长。凭据升级为带 key id、user/definition/field AAD 的 AES-GCM envelope并保留旧 Fernet 滚动读取,新增无敏感载荷调用审计;完整 507 项 unittest 全绿(17 skip),0027 PostgreSQL DDL 定向编译、Alembic 单 head、外部模块 mypy、Ruff 致命规则和 JavaScript 语法检查通过,未配置或连接生产 DB。
|
||||||
|
|
||||||
### 2026-08-06
|
### 2026-08-06
|
||||||
|
|
|
||||||
4
RUN.md
4
RUN.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`。
|
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`。
|
||||||
|
|
||||||
最后更新:2026-08-05(外部系统支持通用 OpenAPI 配置与动态认证凭据)
|
最后更新:2026-08-07(外部系统定义更新保留凭据并按语义重验)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -150,7 +150,7 @@
|
||||||
- **未绑定成员发消息 → 回绑定指引**(不再静默):聊天优先布局下新员工第一动作就是打字,回调对未绑定成员的 text/图片/文件消息每条回一句"先去控制台绑定"(事件不回)。未绑定成员点菜单「工作台」则落在绑定提示页(不自动建号)。
|
- **未绑定成员发消息 → 回绑定指引**(不再静默):聊天优先布局下新员工第一动作就是打字,回调对未绑定成员的 text/图片/文件消息每条回一句"先去控制台绑定"(事件不回)。未绑定成员点菜单「工作台」则落在绑定提示页(不自动建号)。
|
||||||
- **channel 长会话上下文(微信/企业微信通用,0019)**:常驻会话不再无限膨胀。① **自动分段**——入站时距上次消息超过 `config.json` 的 `channel.session_gap_hours`(默 **6** 小时,设 `<=0` 关闭)→ 软重置:只把「最后一条 user 消息起」喂模型(保留上一轮做续聊锚点),之前的历史仍全留 DB,网页端照旧翻完整记录;② **手动新话题**——用户在微信/企业微信里直接发「新话题 / 新会话 / `/new` / 清空上下文」→ 硬重置,彻底从零(回执提示已归档)。两者都**不删任何消息**,只移动「喂给模型的窗口起点」`tasks.context_base_idx`。网页端「清空对话」(`POST /v1/tasks/{id}/clear`)仍整清并把 base 归 0。需 `main.py db upgrade head` 带上 `0019`。
|
- **channel 长会话上下文(微信/企业微信通用,0019)**:常驻会话不再无限膨胀。① **自动分段**——入站时距上次消息超过 `config.json` 的 `channel.session_gap_hours`(默 **6** 小时,设 `<=0` 关闭)→ 软重置:只把「最后一条 user 消息起」喂模型(保留上一轮做续聊锚点),之前的历史仍全留 DB,网页端照旧翻完整记录;② **手动新话题**——用户在微信/企业微信里直接发「新话题 / 新会话 / `/new` / 清空上下文」→ 硬重置,彻底从零(回执提示已归档)。两者都**不删任何消息**,只移动「喂给模型的窗口起点」`tasks.context_base_idx`。网页端「清空对话」(`POST /v1/tasks/{id}/clear`)仍整清并把 base 归 0。需 `main.py db upgrade head` 带上 `0019`。
|
||||||
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。
|
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。
|
||||||
- **OpenAPI 外部系统**:① `.env` 配置独立的 `ZCBOT_CREDENTIAL_MASTER_KEY`,可选 `ZCBOT_CREDENTIAL_KEY_ID` 标识当前密钥;轮换时把旧 key 以 JSON 对象放入 `ZCBOT_CREDENTIAL_PREVIOUS_KEYS`,待用户凭据完成重写后再移除。② 执行 `main.py db upgrade head`,0027 会把既有 selected 授权迁入独立 grants,不连接或清理业务库数据。③ admin 进入管理后台「外部系统」,选择 Factory MES preset 或通用 OpenAPI,配置同源的可信 Base URL / Swagger URL、认证方式、执行模式、推荐入口和查询规划提示,再选择“全部用户”或指定用户。Factory 默认“上游托管”:可信规格声明的全部标准 HTTP method 均可调用,由 Factory 按当前用户凭据最终鉴权;通用系统默认“查询模式”:GET/HEAD 默认可查,POST 只有加入只读清单才开放。④ 普通用户点击左栏 **「外部」**,页面按定义动态显示所需凭据;定义目标或认证变化后必须重新填写凭据,其他策略变化需重新测试连接。通用类型支持“用户名密码换取 Token”“API Key”“Bearer Token”;Swagger JSON 只在进程内按定义和用户有界缓存 5 分钟,spec、登录和业务响应都在流式下载时限长,普通用户和模型不能传任意 URL。
|
- **OpenAPI 外部系统**:① `.env` 配置独立的 `ZCBOT_CREDENTIAL_MASTER_KEY`,可选 `ZCBOT_CREDENTIAL_KEY_ID` 标识当前密钥;轮换时把旧 key 以 JSON 对象放入 `ZCBOT_CREDENTIAL_PREVIOUS_KEYS`,待用户凭据完成重写后再移除。② 执行 `main.py db upgrade head`,0027 会把既有 selected 授权迁入独立 grants,不连接或清理业务库数据。③ admin 进入管理后台「外部系统」,选择 Factory MES preset 或通用 OpenAPI,配置同源的可信 Base URL / Swagger URL、认证方式、执行模式、推荐入口和查询规划提示,再选择“全部用户”或指定用户。Factory 默认“上游托管”:可信规格声明的全部标准 HTTP method 均可调用,由 Factory 按当前用户凭据最终鉴权;通用系统默认“查询模式”:GET/HEAD 默认可查,POST 只有加入只读清单才开放。④ 普通用户点击左栏 **「外部」**,页面按定义动态显示所需凭据;定义目标、登录、认证绑定或 TLS 变化后会保留加密凭据并暂停 agent 调用,用户点击“测试连接”成功后恢复,查询提示、执行策略和响应限额等运行配置变化不中断连接。通用类型支持“用户名密码换取 Token”“API Key”“Bearer Token”;Swagger JSON 只在进程内按定义和用户有界缓存 5 分钟,spec、登录和业务响应都在流式下载时限长,普通用户和模型不能传任意 URL。
|
||||||
- **测试库(可选,`ZCBOT_TEST_DB_URL`)**:DB 级单测(`tests/test_usage_report.py` / `tests/test_scheduler.py` / `tests/test_web_routes_db.py`)**只认这个显式变量、绝不回退 `.env` 的 `ZCBOT_DB_URL`**——后者可能经隧道指向生产库,测试插入的到点 job 会被生产实例调度守护真跑一次(2026-07-23 实锤)。未设则这几组自动 skip。一键起库(docker,端口 5433 避开本地 5432):
|
- **测试库(可选,`ZCBOT_TEST_DB_URL`)**:DB 级单测(`tests/test_usage_report.py` / `tests/test_scheduler.py` / `tests/test_web_routes_db.py`)**只认这个显式变量、绝不回退 `.env` 的 `ZCBOT_DB_URL`**——后者可能经隧道指向生产库,测试插入的到点 job 会被生产实例调度守护真跑一次(2026-07-23 实锤)。未设则这几组自动 skip。一键起库(docker,端口 5433 避开本地 5432):
|
||||||
```bash
|
```bash
|
||||||
docker run -d --name zcbot-test-pg -e POSTGRES_PASSWORD=zcbot_test \
|
docker run -d --name zcbot-test-pg -e POSTGRES_PASSWORD=zcbot_test \
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.63.0"
|
__version__ = "0.63.1"
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
@ -30,6 +30,22 @@ class ExternalSystemError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_REVERIFY_KEYS = frozenset(
|
||||||
|
{
|
||||||
|
"base_url",
|
||||||
|
"openapi_url",
|
||||||
|
"login_path",
|
||||||
|
"auth_type",
|
||||||
|
"username_field",
|
||||||
|
"password_field",
|
||||||
|
"token_field",
|
||||||
|
"auth_header_name",
|
||||||
|
"auth_header_template",
|
||||||
|
"verify_tls",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _runtime_config(provider: str, data: dict[str, Any]) -> OpenApiConfig:
|
def _runtime_config(provider: str, data: dict[str, Any]) -> OpenApiConfig:
|
||||||
try:
|
try:
|
||||||
return OpenApiConfig.from_mapping(merged_config(provider, data))
|
return OpenApiConfig.from_mapping(merged_config(provider, data))
|
||||||
|
|
@ -57,6 +73,28 @@ def _normalized_config(provider: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_definition_config_change(
|
||||||
|
provider: str,
|
||||||
|
old_data: dict[str, Any],
|
||||||
|
new_data: dict[str, Any],
|
||||||
|
) -> tuple[dict[str, Any], dict[str, Any], frozenset[str], str]:
|
||||||
|
"""按运行语义比较配置,避免旧 JSON 缺省字段被误判成认证变化。"""
|
||||||
|
old_config = _normalized_config(provider, old_data)
|
||||||
|
new_config = _normalized_config(provider, new_data)
|
||||||
|
changed = frozenset(
|
||||||
|
key
|
||||||
|
for key in old_config.keys() | new_config.keys()
|
||||||
|
if old_config.get(key) != new_config.get(key)
|
||||||
|
)
|
||||||
|
if not changed:
|
||||||
|
impact = "none"
|
||||||
|
elif changed & _REVERIFY_KEYS:
|
||||||
|
impact = "reverify"
|
||||||
|
else:
|
||||||
|
impact = "runtime"
|
||||||
|
return old_config, new_config, changed, impact
|
||||||
|
|
||||||
|
|
||||||
def _definition_view(
|
def _definition_view(
|
||||||
row: ExternalSystemDefinition, *, include_config: bool
|
row: ExternalSystemDefinition, *, include_config: bool
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
|
@ -92,18 +130,29 @@ def _validate_visibility(visibility: str) -> str:
|
||||||
return mode
|
return mode
|
||||||
|
|
||||||
|
|
||||||
def _invalidate_connections_for_revision(
|
def _mark_connections_for_reverify(connections: Sequence[ExternalSystem]) -> None:
|
||||||
connections: Sequence[ExternalSystem], *, credential_binding_changed: bool
|
"""保留密文凭据,但在用户主动测试前阻止 agent 使用变更后的目标。"""
|
||||||
) -> None:
|
|
||||||
for connection in connections:
|
for connection in connections:
|
||||||
connection.last_verified_at = None
|
connection.last_verified_at = None
|
||||||
connection.last_error = "系统定义已更新,请重新验证连接"
|
connection.last_error = "系统定义已更新,请重新验证连接"
|
||||||
if credential_binding_changed:
|
connection.status = (
|
||||||
connection.credentials = {}
|
"needs_reverify" if connection.credentials else "needs_credentials"
|
||||||
connection.credential_hint = "***"
|
)
|
||||||
connection.status = "needs_credentials"
|
|
||||||
|
|
||||||
|
def _advance_active_connections(
|
||||||
|
connections: Sequence[ExternalSystem], *, revision: int
|
||||||
|
) -> None:
|
||||||
|
"""仅运行策略变化不影响连接有效性,active 连接原子跟随新 revision。"""
|
||||||
|
for connection in connections:
|
||||||
|
if connection.status != "active":
|
||||||
|
continue
|
||||||
|
if connection.credentials:
|
||||||
|
connection.verified_revision = revision
|
||||||
else:
|
else:
|
||||||
connection.status = "needs_reverify"
|
connection.status = "needs_credentials"
|
||||||
|
connection.last_verified_at = None
|
||||||
|
connection.last_error = "连接凭据缺失,请重新填写凭据"
|
||||||
|
|
||||||
|
|
||||||
def _selected_user_ids(s: Any, definition_id: UUID) -> list[str]:
|
def _selected_user_ids(s: Any, definition_id: UUID) -> list[str]:
|
||||||
|
|
@ -315,23 +364,24 @@ def update_external_system_definition(
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise ExternalSystemError("external system definition not found")
|
raise ExternalSystemError("external system definition not found")
|
||||||
old_config = row.config or {}
|
_, new_config, _, impact = (
|
||||||
new_config = _normalized_config(row.provider, config)
|
_classify_definition_config_change(
|
||||||
config_changed = old_config != new_config
|
row.provider,
|
||||||
credential_binding_keys = {
|
row.config or {},
|
||||||
"base_url",
|
config,
|
||||||
"openapi_url",
|
)
|
||||||
"login_path",
|
)
|
||||||
"auth_type",
|
config_changed = impact != "none"
|
||||||
"username_field",
|
connections = (
|
||||||
"password_field",
|
s.execute(
|
||||||
"token_field",
|
select(ExternalSystem).where(
|
||||||
"auth_header_name",
|
ExternalSystem.definition_id == definition_id
|
||||||
"auth_header_template",
|
)
|
||||||
}
|
)
|
||||||
binding_changed = any(
|
.scalars()
|
||||||
old_config.get(key) != new_config.get(key)
|
.all()
|
||||||
for key in credential_binding_keys
|
if config_changed
|
||||||
|
else []
|
||||||
)
|
)
|
||||||
row.name = name
|
row.name = name
|
||||||
row.config = new_config
|
row.config = new_config
|
||||||
|
|
@ -339,19 +389,10 @@ def update_external_system_definition(
|
||||||
row.visibility = _validate_visibility(visibility)
|
row.visibility = _validate_visibility(visibility)
|
||||||
if config_changed:
|
if config_changed:
|
||||||
row.revision += 1
|
row.revision += 1
|
||||||
connections = (
|
if impact == "runtime":
|
||||||
s.execute(
|
_advance_active_connections(connections, revision=row.revision)
|
||||||
select(ExternalSystem).where(
|
else:
|
||||||
ExternalSystem.definition_id == definition_id
|
_mark_connections_for_reverify(connections)
|
||||||
)
|
|
||||||
)
|
|
||||||
.scalars()
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
_invalidate_connections_for_revision(
|
|
||||||
connections,
|
|
||||||
credential_binding_changed=binding_changed,
|
|
||||||
)
|
|
||||||
if row.visibility == "selected":
|
if row.visibility == "selected":
|
||||||
_sync_selected_users(
|
_sync_selected_users(
|
||||||
s, row, selected_user_ids or [], granted_by=row.created_by
|
s, row, selected_user_ids or [], granted_by=row.created_by
|
||||||
|
|
@ -771,6 +812,22 @@ def external_system_tools_available(user_id: UUID) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def external_system_status_available(user_id: UUID) -> bool:
|
||||||
|
"""只读状态工具不需要解密凭据;有连接即向 agent 暴露状态。"""
|
||||||
|
try:
|
||||||
|
with session_scope() as s:
|
||||||
|
return (
|
||||||
|
s.execute(
|
||||||
|
select(ExternalSystem.external_system_id)
|
||||||
|
.where(ExternalSystem.user_id == user_id)
|
||||||
|
.limit(1)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def record_external_system_audit(
|
def record_external_system_audit(
|
||||||
*,
|
*,
|
||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
|
|
|
||||||
|
|
@ -155,12 +155,14 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]:
|
||||||
MaterialsProjectGetEntriesTool(working_dir=ctx.working_dir_path, **base),
|
MaterialsProjectGetEntriesTool(working_dir=ctx.working_dir_path, **base),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def _external_system_status() -> list:
|
||||||
|
return [ExternalSystemListTool(ctx.uid, **base)]
|
||||||
|
|
||||||
def _external_systems() -> list:
|
def _external_systems() -> list:
|
||||||
from core.external_systems.service import record_external_system_audit
|
from core.external_systems.service import record_external_system_audit
|
||||||
|
|
||||||
result_budget: dict[str, int] = {}
|
result_budget: dict[str, int] = {}
|
||||||
return [
|
return [
|
||||||
ExternalSystemListTool(ctx.uid, **base),
|
|
||||||
ExternalSystemSearchTool(ctx.uid, **base),
|
ExternalSystemSearchTool(ctx.uid, **base),
|
||||||
ExternalSystemCallTool(
|
ExternalSystemCallTool(
|
||||||
ctx.uid,
|
ctx.uid,
|
||||||
|
|
@ -282,6 +284,11 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]:
|
||||||
# key 绝不进 run_python / 沙箱。
|
# key 绝不进 run_python / 沙箱。
|
||||||
("document_search", _env_set("DOCUMENT_SEARCH_API_KEY"), _document_search),
|
("document_search", _env_set("DOCUMENT_SEARCH_API_KEY"), _document_search),
|
||||||
("materials_project", _env_set("MP_API_KEY"), _materials_project),
|
("materials_project", _env_set("MP_API_KEY"), _materials_project),
|
||||||
|
(
|
||||||
|
"external_system_status",
|
||||||
|
lambda: _external_system_status_available(ctx.uid),
|
||||||
|
_external_system_status,
|
||||||
|
),
|
||||||
("external_systems", lambda: _external_systems_available(ctx.uid), _external_systems),
|
("external_systems", lambda: _external_systems_available(ctx.uid), _external_systems),
|
||||||
("load_skill", lambda: bool(ctx.skills.skills), _load_skill),
|
("load_skill", lambda: bool(ctx.skills.skills), _load_skill),
|
||||||
("skill_authoring", lambda: True, _skill_authoring),
|
("skill_authoring", lambda: True, _skill_authoring),
|
||||||
|
|
@ -313,3 +320,13 @@ def _external_systems_available(user_id: UUID) -> bool:
|
||||||
return external_system_tools_available(user_id)
|
return external_system_tools_available(user_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _external_system_status_available(user_id: UUID) -> bool:
|
||||||
|
"""连接失效时仍挂只读状态入口,让模型解释真实阻塞原因。"""
|
||||||
|
try:
|
||||||
|
from core.external_systems.service import external_system_status_available
|
||||||
|
|
||||||
|
return external_system_status_available(user_id)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
|
||||||
|
|
@ -80,8 +80,8 @@ class ExternalCredentialCryptoTests(unittest.TestCase):
|
||||||
|
|
||||||
|
|
||||||
class ExternalConnectionRevisionTests(unittest.TestCase):
|
class ExternalConnectionRevisionTests(unittest.TestCase):
|
||||||
def test_non_binding_definition_change_keeps_credentials_for_reverify(self):
|
def test_sensitive_definition_change_keeps_credentials_for_reverify(self):
|
||||||
from core.external_systems.service import _invalidate_connections_for_revision
|
from core.external_systems.service import _mark_connections_for_reverify
|
||||||
|
|
||||||
connection = SimpleNamespace(
|
connection = SimpleNamespace(
|
||||||
credentials={"token": "ciphertext"},
|
credentials={"token": "ciphertext"},
|
||||||
|
|
@ -90,30 +90,107 @@ class ExternalConnectionRevisionTests(unittest.TestCase):
|
||||||
last_verified_at="old",
|
last_verified_at="old",
|
||||||
last_error=None,
|
last_error=None,
|
||||||
)
|
)
|
||||||
_invalidate_connections_for_revision(
|
_mark_connections_for_reverify([connection])
|
||||||
[connection], credential_binding_changed=False
|
|
||||||
)
|
|
||||||
self.assertEqual(connection.credentials, {"token": "ciphertext"})
|
self.assertEqual(connection.credentials, {"token": "ciphertext"})
|
||||||
self.assertEqual(connection.status, "needs_reverify")
|
self.assertEqual(connection.status, "needs_reverify")
|
||||||
self.assertIsNone(connection.last_verified_at)
|
self.assertIsNone(connection.last_verified_at)
|
||||||
|
|
||||||
def test_binding_definition_change_clears_credentials(self):
|
def test_missing_credentials_remains_needs_credentials(self):
|
||||||
from core.external_systems.service import _invalidate_connections_for_revision
|
from core.external_systems.service import _mark_connections_for_reverify
|
||||||
|
|
||||||
connection = SimpleNamespace(
|
connection = SimpleNamespace(
|
||||||
credentials={"token": "ciphertext"},
|
credentials={},
|
||||||
credential_hint="ab***z",
|
credential_hint="***",
|
||||||
status="active",
|
status="active",
|
||||||
last_verified_at="old",
|
last_verified_at="old",
|
||||||
last_error=None,
|
last_error=None,
|
||||||
)
|
)
|
||||||
_invalidate_connections_for_revision(
|
_mark_connections_for_reverify([connection])
|
||||||
[connection], credential_binding_changed=True
|
|
||||||
)
|
|
||||||
self.assertEqual(connection.credentials, {})
|
self.assertEqual(connection.credentials, {})
|
||||||
self.assertEqual(connection.credential_hint, "***")
|
|
||||||
self.assertEqual(connection.status, "needs_credentials")
|
self.assertEqual(connection.status, "needs_credentials")
|
||||||
|
|
||||||
|
def test_legacy_missing_auth_defaults_are_semantically_unchanged(self):
|
||||||
|
from core.external_systems.service import _classify_definition_config_change
|
||||||
|
|
||||||
|
legacy = {
|
||||||
|
"base_url": "https://factory.invalid",
|
||||||
|
"openapi_url": "https://factory.invalid/swagger.json",
|
||||||
|
"login_path": "/api/auth/token/",
|
||||||
|
}
|
||||||
|
materialized = {
|
||||||
|
**legacy,
|
||||||
|
"auth_type": "password_jwt",
|
||||||
|
"username_field": "username",
|
||||||
|
"password_field": "password",
|
||||||
|
"token_field": "access",
|
||||||
|
"auth_header_name": "Authorization",
|
||||||
|
"auth_header_template": "Bearer {token}",
|
||||||
|
}
|
||||||
|
_, _, changed, impact = _classify_definition_config_change(
|
||||||
|
"factory_mes", legacy, materialized
|
||||||
|
)
|
||||||
|
self.assertEqual(changed, frozenset())
|
||||||
|
self.assertEqual(impact, "none")
|
||||||
|
|
||||||
|
def test_query_guidance_change_is_runtime_only(self):
|
||||||
|
from core.external_systems.service import _classify_definition_config_change
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"base_url": "https://factory.invalid",
|
||||||
|
"openapi_url": "https://factory.invalid/swagger.json",
|
||||||
|
}
|
||||||
|
_, _, changed, impact = _classify_definition_config_change(
|
||||||
|
"factory_mes",
|
||||||
|
config,
|
||||||
|
{**config, "query_guidance": "先查数据集目录"},
|
||||||
|
)
|
||||||
|
self.assertEqual(changed, frozenset({"query_guidance"}))
|
||||||
|
self.assertEqual(impact, "runtime")
|
||||||
|
|
||||||
|
def test_target_change_requires_reverify_without_credential_reset(self):
|
||||||
|
from core.external_systems.service import _classify_definition_config_change
|
||||||
|
|
||||||
|
old = {
|
||||||
|
"base_url": "https://factory.invalid",
|
||||||
|
"openapi_url": "https://factory.invalid/swagger.json",
|
||||||
|
}
|
||||||
|
new = {
|
||||||
|
"base_url": "https://factory-new.invalid",
|
||||||
|
"openapi_url": "https://factory-new.invalid/swagger.json",
|
||||||
|
}
|
||||||
|
_, _, changed, impact = _classify_definition_config_change(
|
||||||
|
"factory_mes", old, new
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
changed, frozenset({"base_url", "openapi_url"})
|
||||||
|
)
|
||||||
|
self.assertEqual(impact, "reverify")
|
||||||
|
|
||||||
|
def test_runtime_change_advances_only_active_connections(self):
|
||||||
|
from core.external_systems.service import _advance_active_connections
|
||||||
|
|
||||||
|
active = SimpleNamespace(
|
||||||
|
credentials={"token": "ciphertext"},
|
||||||
|
status="active",
|
||||||
|
verified_revision=1,
|
||||||
|
)
|
||||||
|
invalid = SimpleNamespace(
|
||||||
|
credentials={"token": "ciphertext"},
|
||||||
|
status="invalid",
|
||||||
|
verified_revision=1,
|
||||||
|
)
|
||||||
|
missing = SimpleNamespace(
|
||||||
|
credentials={},
|
||||||
|
status="active",
|
||||||
|
verified_revision=1,
|
||||||
|
last_verified_at="old",
|
||||||
|
last_error=None,
|
||||||
|
)
|
||||||
|
_advance_active_connections([active, invalid, missing], revision=2)
|
||||||
|
self.assertEqual(active.verified_revision, 2)
|
||||||
|
self.assertEqual(invalid.verified_revision, 1)
|
||||||
|
self.assertEqual(missing.status, "needs_credentials")
|
||||||
|
|
||||||
|
|
||||||
def _cfg(*, allowed=frozenset(), recommended=(), operation_mode="query"):
|
def _cfg(*, allowed=frozenset(), recommended=(), operation_mode="query"):
|
||||||
from core.external_systems.factory import FactoryMesConfig
|
from core.external_systems.factory import FactoryMesConfig
|
||||||
|
|
@ -1055,6 +1132,58 @@ class FactoryOpenApiConnectorTests(unittest.TestCase):
|
||||||
|
|
||||||
|
|
||||||
class ExternalSystemToolSafetyTests(unittest.TestCase):
|
class ExternalSystemToolSafetyTests(unittest.TestCase):
|
||||||
|
def test_status_tool_remains_registered_without_callable_connection(self):
|
||||||
|
from core.tool_registry import ToolContext, build_tools
|
||||||
|
|
||||||
|
uid = uuid.uuid4()
|
||||||
|
task_id = uuid.uuid4()
|
||||||
|
with (
|
||||||
|
tempfile.TemporaryDirectory() as tmp,
|
||||||
|
patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{"DOCUMENT_SEARCH_API_KEY": "", "MP_API_KEY": ""},
|
||||||
|
clear=False,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"core.tool_registry._external_system_status_available",
|
||||||
|
return_value=True,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"core.tool_registry._external_systems_available",
|
||||||
|
return_value=False,
|
||||||
|
),
|
||||||
|
patch("core.tool_registry.smtp_configured", return_value=False),
|
||||||
|
patch("core.tool_registry.wechat_push_available", return_value=False),
|
||||||
|
patch("core.tool_registry.lfasr_configured", return_value=False),
|
||||||
|
patch("core.tool_registry.BochaConfig.load", return_value=None),
|
||||||
|
):
|
||||||
|
root = Path(tmp)
|
||||||
|
tools = build_tools(
|
||||||
|
ToolContext(
|
||||||
|
tool_base=root,
|
||||||
|
ur_path=root,
|
||||||
|
working_dir_path=root,
|
||||||
|
task_id=task_id,
|
||||||
|
uid=uid,
|
||||||
|
cfg={},
|
||||||
|
caps=SimpleNamespace(enable_run_python=False),
|
||||||
|
skills=SimpleNamespace(skills={}),
|
||||||
|
cancel_check=None,
|
||||||
|
scheduled_run=True,
|
||||||
|
deferred_actions=SimpleNamespace(),
|
||||||
|
ark_cfg=None,
|
||||||
|
img_provider="",
|
||||||
|
img_key="",
|
||||||
|
img_cfg=None,
|
||||||
|
img_provider_cfg=None,
|
||||||
|
video_variant="",
|
||||||
|
office_to_pdf_available=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIn("external_system_list", tools)
|
||||||
|
self.assertNotIn("external_system_search", tools)
|
||||||
|
self.assertNotIn("external_system_call", tools)
|
||||||
|
|
||||||
def test_tools_are_scoped_to_constructor_user(self):
|
def test_tools_are_scoped_to_constructor_user(self):
|
||||||
from tools.external_systems import ExternalSystemListTool
|
from tools.external_systems import ExternalSystemListTool
|
||||||
|
|
||||||
|
|
@ -1066,12 +1195,20 @@ class ExternalSystemToolSafetyTests(unittest.TestCase):
|
||||||
"external_system_id": str(uuid.uuid4()),
|
"external_system_id": str(uuid.uuid4()),
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"username_masked": "me***r",
|
"username_masked": "me***r",
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
"external_system_id": str(uuid.uuid4()),
|
||||||
|
"status": "needs_reverify",
|
||||||
|
"username_masked": "me***r",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
) as listed:
|
) as listed:
|
||||||
output = ExternalSystemListTool(uid).execute()
|
output = ExternalSystemListTool(uid).execute()
|
||||||
listed.assert_called_once_with(uid)
|
listed.assert_called_once_with(uid)
|
||||||
self.assertNotIn("password", output.lower())
|
self.assertNotIn("password", output.lower())
|
||||||
|
payload = json.loads(output)
|
||||||
|
self.assertEqual(len(payload["systems"]), 2)
|
||||||
|
self.assertIn("测试连接", payload["systems"][1]["status_guidance"])
|
||||||
|
|
||||||
def test_search_returns_admin_guidance_with_recommended_operations(self):
|
def test_search_returns_admin_guidance_with_recommended_operations(self):
|
||||||
from tools.external_systems import ExternalSystemSearchTool
|
from tools.external_systems import ExternalSystemSearchTool
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,9 @@ def _row_and_client(user_id: UUID, raw_system_id: str):
|
||||||
class ExternalSystemListTool(Tool):
|
class ExternalSystemListTool(Tool):
|
||||||
name = "external_system_list"
|
name = "external_system_list"
|
||||||
description = (
|
description = (
|
||||||
"列出当前用户已连接的外部系统。返回 system_id、执行模式、管理员配置的查询规划提示"
|
"列出当前用户已连接的外部系统及状态。返回 system_id、active/需重新验证/"
|
||||||
"和推荐 operationId;使用外部系统前先调用并遵循对应提示。凭据永不返回。"
|
"需更新凭据等状态、执行模式和查询规划提示;使用外部系统前先调用。"
|
||||||
|
"连接不可用时应明确告知用户处理方式,不要改查互联网。凭据永不返回。"
|
||||||
)
|
)
|
||||||
parameters = {"type": "object", "properties": {}}
|
parameters = {"type": "object", "properties": {}}
|
||||||
|
|
||||||
|
|
@ -55,8 +56,17 @@ class ExternalSystemListTool(Tool):
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
|
|
||||||
def execute(self, **kwargs) -> str:
|
def execute(self, **kwargs) -> str:
|
||||||
|
systems = list_external_systems(self.user_id)
|
||||||
|
guidance = {
|
||||||
|
"active": "连接可用,可继续 search/call",
|
||||||
|
"needs_reverify": "系统定义已更新,需要用户在“外部”页面测试连接",
|
||||||
|
"needs_credentials": "连接凭据缺失,需要用户在“外部”页面重新填写凭据",
|
||||||
|
"invalid": "连接验证失败,需要用户在“外部”页面检查或更新凭据",
|
||||||
|
"disabled": "系统定义已停用,请联系管理员",
|
||||||
|
}
|
||||||
systems = [
|
systems = [
|
||||||
x for x in list_external_systems(self.user_id) if x["status"] == "active"
|
{**system, "status_guidance": guidance.get(system["status"], "连接不可用")}
|
||||||
|
for system in systems
|
||||||
]
|
]
|
||||||
return _json({"systems": systems})
|
return _json({"systems": systems})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue