Compare commits
5 Commits
b27cc9cd5b
...
d4aa5ccbec
| Author | SHA1 | Date |
|---|---|---|
|
|
d4aa5ccbec | |
|
|
b5d75d2a7b | |
|
|
700176a0c6 | |
|
|
1646205364 | |
|
|
89062d99b3 |
|
|
@ -57,3 +57,6 @@ col.ps1
|
||||||
# 探测脚本 scripts/probe_clawbot*.py 保留作参考与复测)
|
# 探测脚本 scripts/probe_clawbot*.py 保留作参考与复测)
|
||||||
scripts/clawbot_qr*.png
|
scripts/clawbot_qr*.png
|
||||||
scripts/zcbot_filetest.txt
|
scripts/zcbot_filetest.txt
|
||||||
|
|
||||||
|
# 诊断脚本的使用即弃 dump 输出(diag_*.py 写本地,不入库)
|
||||||
|
scripts/_*.txt
|
||||||
|
|
|
||||||
28
DESIGN.md
28
DESIGN.md
|
|
@ -566,7 +566,7 @@ create index on usage_events (model_profile, created_at);
|
||||||
|
|
||||||
**选型**:Context Editing + Memory/File State + Cache Observability 混合。稳定 system/tools 前缀利于 provider cache;旧 tool result 移除或压缩;关键发现写 task summary / FS,需要时 `read` 重新拉。长上下文保留作少数全局推理的临时能力,非默认每轮成本。
|
**选型**:Context Editing + Memory/File State + Cache Observability 混合。稳定 system/tools 前缀利于 provider cache;旧 tool result 移除或压缩;关键发现写 task summary / FS,需要时 `read` 重新拉。长上下文保留作少数全局推理的临时能力,非默认每轮成本。
|
||||||
|
|
||||||
**落地形态**:`core/context.py` 发送前压缩旧 tool / `load_skill` / assistant tool_call arguments(保 `role/tool_call_id/name` 协议完整),不改持久化历史;**上下文压力门槛**(2026-06-10):总 chars 未逼近上限则完全跳过压缩、原样发,护 DeepSeek 前缀缓存(短任务字节逐轮一致、命中 92-94%)。task summary(旧消息压成一条、区分硬约束/计划/文件路径/关键事实)为第二步,未做。
|
**落地形态**:`core/context.py` 发送前压缩旧 tool / `load_skill` / assistant tool_call arguments(保 `role/tool_call_id/name` 协议完整),不改持久化历史;**上下文压力门槛**(2026-06-10):总 chars 未逼近上限则完全跳过压缩、原样发,护 DeepSeek 前缀缓存(短任务字节逐轮一致、命中 92-94%)。task summary(旧消息压成一条、区分硬约束/计划/文件路径/关键事实)为第二步,未做 —— 已并入 §8.8 Phase 2(对齐 Hermes 结构化摘要)统一推进。channel 常驻会话的无限累积另由 §8.8 软重置分段治理(本节压缩挡不住跨时段累积)。
|
||||||
|
|
||||||
### 8.3 PPTX 前端在线预览(2026-06-09,✅ 已落地 Stage 1)
|
### 8.3 PPTX 前端在线预览(2026-06-09,✅ 已落地 Stage 1)
|
||||||
|
|
||||||
|
|
@ -690,7 +690,7 @@ create index on usage_events (model_profile, created_at);
|
||||||
- **推送择优**:简报这类"必达" → 优先企业微信(无条件);ClawBot 作个人微信触达 + 聊天;两者都绑可多投或按用户偏好。
|
- **推送择优**:简报这类"必达" → 优先企业微信(无条件);ClawBot 作个人微信触达 + 聊天;两者都绑可多投或按用户偏好。
|
||||||
|
|
||||||
**第一期两处已定决策(评审通过)**:
|
**第一期两处已定决策(评审通过)**:
|
||||||
- **入站对话 → 每用户一条 persistent「微信」task**(聊天要连续性;token 增长靠 §8.2 context 压缩;打标签与网页 task 区分)。**两渠道入站都落到这条 task**。
|
- **入站对话 → 每用户一条 persistent「微信」task**(聊天要连续性;token 增长靠 §8.8 channel 长会话治理 = 软重置分段 + §8.2 context 压缩;打标签与网页 task 区分)。**两渠道入站都落到这条 task**。
|
||||||
- **敏感凭据入库一律加密列**(`bot_token`/`latest_context_token`;企业微信 secret 走 env 不入库)——env `ZCBOT_WECHAT_SECRET_KEY` 派生密钥;绝不进沙箱/日志/API 响应(§3.4)。
|
- **敏感凭据入库一律加密列**(`bot_token`/`latest_context_token`;企业微信 secret 走 env 不入库)——env `ZCBOT_WECHAT_SECRET_KEY` 派生密钥;绝不进沙箱/日志/API 响应(§3.4)。
|
||||||
|
|
||||||
**唯一现实卡点 = 微信灰度可用性**:仅**国内个人微信**、需 **8.0.70+** 且功能灰度推送中(设置→插件),**不支持企业微信**(`bot_type=3`)。目标用户没有插件入口就用不了——落地前要先核实目标用户在灰度内。腾讯另保留**限频 / 决定可连哪些 AI / 随时终止**的权力(政策风险)。
|
**唯一现实卡点 = 微信灰度可用性**:仅**国内个人微信**、需 **8.0.70+** 且功能灰度推送中(设置→插件),**不支持企业微信**(`bot_type=3`)。目标用户没有插件入口就用不了——落地前要先核实目标用户在灰度内。腾讯另保留**限频 / 决定可连哪些 AI / 随时终止**的权力(政策风险)。
|
||||||
|
|
@ -756,6 +756,30 @@ create index on usage_events (model_profile, created_at);
|
||||||
|
|
||||||
**渠道 B(企业微信,紧随)改动面**:env `WECOM_CORPID/AGENTID/SECRET`;`tools/wecom_push.py`(access_token 缓存 + `message/send` + `media/upload` + 渠道实现);`send_to_user` / `deliver_notify` 接 wecom 渠道;绑定抽象加 wecom 侧 + migration `0013`;OAuth 起始/回调 2 端点 + 前端"绑定企业微信"。**两渠道共用 `send_to_user` 抽象与绑定层**,故渠道 B 主要是"多一个渠道实现 + 一种绑定方式",不重写主体。
|
**渠道 B(企业微信,紧随)改动面**:env `WECOM_CORPID/AGENTID/SECRET`;`tools/wecom_push.py`(access_token 缓存 + `message/send` + `media/upload` + 渠道实现);`send_to_user` / `deliver_notify` 接 wecom 渠道;绑定抽象加 wecom 侧 + migration `0013`;OAuth 起始/回调 2 端点 + 前端"绑定企业微信"。**两渠道共用 `send_to_user` 抽象与绑定层**,故渠道 B 主要是"多一个渠道实现 + 一种绑定方式",不重写主体。
|
||||||
|
|
||||||
|
### 8.8 channel 长会话上下文治理(2026-06-29,Phase 1 ✅ 落地 / Phase 2-3 design)
|
||||||
|
|
||||||
|
**根因**:微信/企业微信入站对话复用**同一条常驻 chat task**(§8.7,per-user-per-channel 一条,要连续性),`Session.load()` 全量装回每轮 LLM 调用。web 任务"做完即止"故有天然边界,IM 是"用户当常驻助手永远在聊"→ 这条 task 只增不减,越用越贵/慢,终撞 context window。§8.2 的压缩只摘旧 tool 正文、门槛高(可靠上下文 50%)、从不删消息,挡不住 IM 这种无限累积。
|
||||||
|
|
||||||
|
**业界对照(2026-06-29 调研:OpenClaw / Hermes(NousResearch)/ Claude Code)**:三家都是"阈值触发摘要 + 头尾保护 + 旧 tool 输出先剪枝"。Hermes 最清晰:双阈值(agent 内 50% + gateway 85% 兜底)+ 四阶段(剪枝→边界检测 protect 头3+尾N→结构化摘要中段→重组保 tool 配对),摘要**增量更新**且保留 file path/ID/数值原文(mem0 实测:摘要会静默丢精确值/硬约束/决策理由)。OpenClaw/Hermes 另配持久记忆层(sqlite-vec / FTS5 + 跨会话)。**但三家都是单次 coding session,不解"IM 用三个月"的跨时段累积** —— 那是 IM 独有、最高杠杆且零信息损失的「会话分段」,本库自补(Phase 1)。
|
||||||
|
|
||||||
|
**心智:边界而非删除**。沿用 §8.2「禁止把『只保留最近 N 条』当主策略」「保留可追溯原文」——本设计**一条消息都不删**,只移动"喂给模型的窗口起点",全历史留 DB、web `/messages` 不 gate 照旧翻完整记录。
|
||||||
|
|
||||||
|
**Phase 1(✅ 2026-06-29):context_base_idx 软重置**
|
||||||
|
- `tasks.context_base_idx`(migration 0019,NOT NULL DEFAULT 0,additive)= 喂给模型的窗口起点。`Session.load()` 只装 `idx >= base` 的消息进 LLM 上下文。
|
||||||
|
- **关键不变量**:`_db_idx`(append 续号锚点)取 messages **真实总条数**而非加载条数 —— 否则下次 append 复用已存在 idx,撞 `uq_messages_task_idx`/覆盖历史。
|
||||||
|
- 两个触发口(`core/wechat/service.py`,仅入站走、push 不触发):
|
||||||
|
- **自动 gap 分段**(`maybe_gap_reset`):入站时距上次消息超 `config.json` `channel.session_gap_hours`(默 6h,`<=0` 关闭)→ 软重置,`base = 最后一条 user 消息 idx`。**不是失忆墙**:新窗口仍带"上一轮"原文做续聊锚点(用户"接着刚才说"接得上),零额外 LLM 调用、零延迟。
|
||||||
|
- **手动新话题**(`reset_channel_context(hard=True)`):用户发「新话题/新会话/`/new`/清空上下文」→ `base = 总数`,彻底从零(回执提示已归档)。
|
||||||
|
- 二者本质同一操作(推进 base)的被动/主动两口:被动断开要续上(软)、主动换题要干净(硬)。
|
||||||
|
- `clear_messages`(web 端清空)全删消息后 `base` 归 0(idx 从 0 重起,否则窗口起点悬空)。存量 task / web 普通任务 base 恒 0 = 喂全量,行为不变(对外契约友好)。
|
||||||
|
- **不选「每次 gap 开新 chat_task_id」**:会堆 `wechat-xxx-2/-3…` 文件夹(`working_dir_from_name` slug 写死)+ web 一堆 task 卡片;软重置零新文件夹/零新 task。**不选「kind='boundary' 标记消息」**:要混进消息流处理 tool 配对 + "别喂模型",列是纯元数据零侵入。
|
||||||
|
|
||||||
|
**Phase 2(design):阈值结构化摘要(补全 Hermes 阶段③)**。现 `core/context.py` 只做剪枝(旧 tool 截 2000 字)+ 尾部保护,缺"中段轮做 LLM 结构化摘要"。补:到门槛时把「base 之后、头 N 条之后、最近 keep_recent 之前」压成固定模板(目标/约束偏好/进展/待办),增量更新而非重写,保留 path/ID/数值原文。门槛接 Hermes 双层(50% + 85% 兜底,`_COMPACT_CONTEXT_RATIO`)。工程坑(mem0 列):辅助模型返非 JSON 降级回原文、tool 配对别被切断(复用 `_repair_dangling_tool_calls`)。**A′(分段)砍跨话题累积,B(摘要)兜单段超长,两者正交**。
|
||||||
|
|
||||||
|
**Phase 3(design):持久检索(解"问很久以前的精确内容")**。软边界拿"跨边界精确回忆"换成本——梗概不够时(问上个月让查的具体数据),上 OpenClaw sqlite-vec / Hermes FTS5:新消息进来先语义/全文检索本 task 历史,命中原文注入当前窗口。工程最重,待 Phase 1/2 跑稳、确认确有此类需求再做(数据没删,随时能补)。
|
||||||
|
|
||||||
|
**落地次序**:Phase 1 上线观察 token 曲线 → 再定 Phase 2 门槛/是否做 → Phase 3 视真实"长期精确回忆"需求。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 附录:DeepSeek V4 关键事实(2026-04-24)
|
## 附录:DeepSeek V4 关键事实(2026-04-24)
|
||||||
|
|
|
||||||
23
PROGRESS.md
23
PROGRESS.md
|
|
@ -21,6 +21,29 @@
|
||||||
|
|
||||||
## 已完成关键能力
|
## 已完成关键能力
|
||||||
|
|
||||||
|
### 2026-06-29 / system prompt 加通用 context 纪律铁律(bump 0.32.5)
|
||||||
|
|
||||||
|
- 承上:反复 dump 全文 abstract 烧 2.5M token 不是 brief 专属,任何 skill 让弱模型处理一批长文本都可能踩。故在 system prompt 单一事实源 `prompts/system/general_v1.md` 的「工作原则」段、紧挨「少来回」加一条全局铁律:大段 `run_python`/`shell` 输出会进对话历史每轮重发,中间数据落文件、只 read 用得上的片段、别整批重复打印。
|
||||||
|
- 与既有规则互补:行 7(源码落 .py 文件)管代码、行 42(少来回)管轮数、这条管「大块数据输出」。brief skill 里的场景化版本(0.32.3)保留做细化。
|
||||||
|
|
||||||
|
### 2026-06-29 / 定时任务默认单次超时 0→1800s(bump 0.32.4)
|
||||||
|
|
||||||
|
- 承上:超时此前默认 0(不限),配合"超时被吞成 ok"的旧 bug,一个跑飞的 job 能无限拖。改默认有限值 1800s(30min):新建 job 不指定 `timeout_seconds` 时给 1800,`0` 仍保留为"不限"逃生口。
|
||||||
|
- 单一事实源 `core/scheduler.DEFAULT_TIMEOUT_SECONDS=1800`,`create_job` 与 `tools/schedule.py`(agent 建 job 的工具)默认都引它;tool JSON schema 描述同步注明"default 1800 / 0=no limit / 重活可调大"。`create_job` 里 `int(timeout_seconds or 0)` 保留显式 0=不限语义。
|
||||||
|
- 存量:把线上 job `e621c8a6`「每日水泥科研简报」的 `timeout_seconds` 由 600 手动改为 1800(直接 SQL UPDATE,未动其它 job)。
|
||||||
|
|
||||||
|
### 2026-06-29 / brief skill 加 context 纪律,堵反复 dump abstract 烧 token(bump 0.32.3)
|
||||||
|
|
||||||
|
- 承上条同一 job 复盘:agent 把同一批 38 篇全文英文 abstract 用 `run_python`/`print` **反复灌进上下文**(实测 dump ≥3 次),工具输出每轮重发 → 48 次 LLM 调用累计输入 **2.5M tokens**(输出仅 28K),既慢又贵,还顶满 600s 超时。根因:brief skill 虽已要求把证据落 `evidence.md` 文件,但没明令"别反复 print 进上下文",弱模型(deepseek-v4-flash)规律不足就放飞。
|
||||||
|
- 修:`skills/brief/SKILL.md` 三处加指示文——阶段二「context 纪律」(落文件、按需 read、别整批重打)、阶段三「一次成稿别重复 dump + 按期刊分批写」、反模式加一条。纯指示文,frontmatter/description 不变 → SKILL_LIST 无需更新。
|
||||||
|
- 仍存的更大杠杆(未做):框架层对超大 `run_python` stdout 在上下文里做截断/省略,根治"工具输出滚雪球",但改动面大、有风险,留待单议。
|
||||||
|
|
||||||
|
### 2026-06-29 / 修定时任务超时被误记成 ok(bump 0.32.2)
|
||||||
|
|
||||||
|
- 实测 bug:定时 job(isolated)跑满 `timeout_seconds` 被调度器协作式 cancel 后,`_run_agent_bg` 对 ok/cancelled 都把 `run_status` 收回 `idle`(二者 DB 不可区分),而 `_execute_scheduled_job` 收尾只判 `run_status=="error"`,于是超时中断被落成 `last_status="ok"` —— 掩盖"跑到一半没写 sections / 没推送",且不计连续失败、不触发兜底。复盘 job `e621c8a6`「每日水泥科研简报」:`timeout_seconds=600`,task 创建→`last_run_at` 正好 600.0s,最后一条 agent 消息停在"按期刊分组打印 38 篇摘要"(还在取数阶段),`last_status` 却是 ok。
|
||||||
|
- 修:`web/app.py` `_execute_scheduled_job` 在超时分支置 `timed_out` 标志,run 收尾后若 `timed_out` → `record_result(status="error", ...)` 并直接返回(不投递半成品 notify)。复用既有 error 语义:计入 `consecutive_failures`、到阈值自动停用、前端 crons.js 显示「上次失败」。不动 `_run_agent_bg` 的 idle-on-cancel 共享语义(HTTP cancel/drain 也用)。
|
||||||
|
- 配套:该 job 真正的诱因是 600s 超时对"7 刊 38 篇带中文摘要重写 + 渲 docx"太短,需用户把 `timeout_seconds` 调大(或 0=不限)。诊断脚本 `scripts/diag_sched_e621.py`。
|
||||||
|
|
||||||
### 2026-06-29 / channel 长会话上下文软重置(Phase 1,bump 0.32.0)
|
### 2026-06-29 / channel 长会话上下文软重置(Phase 1,bump 0.32.0)
|
||||||
|
|
||||||
- 问题:微信/企业微信复用同一常驻 chat_task,`Session.load` 全量喂模型 → 越用越贵/慢,终撞 context window。业界(OpenClaw/Hermes)做法:阈值摘要 + 会话分段 + 持久记忆;IM 场景独有的「会话分段」最高杠杆且零信息损失。
|
- 问题:微信/企业微信复用同一常驻 chat_task,`Session.load` 全量喂模型 → 越用越贵/慢,终撞 context window。业界(OpenClaw/Hermes)做法:阈值摘要 + 会话分段 + 持久记忆;IM 场景独有的「会话分段」最高杠杆且零信息损失。
|
||||||
|
|
|
||||||
1
RUN.md
1
RUN.md
|
|
@ -756,6 +756,7 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
|
||||||
| `kill -HUP <pid>` 后 `/openapi.json` 没新接口 | uvicorn **不响应 SIGHUP**(没装 handler,落 Python 默认终止;Windows 上信号本身无效)。Ubuntu 上用 `systemctl restart zcbot`,或 unit 加 `--reload` 让 uvicorn 监听文件自动重起(见"部署"段)。验证:`curl -s http://127.0.0.1:8765/openapi.json \| python3 -c 'import sys,json;print([p for p in json.load(sys.stdin)["paths"] if "auth" in p])'` |
|
| `kill -HUP <pid>` 后 `/openapi.json` 没新接口 | uvicorn **不响应 SIGHUP**(没装 handler,落 Python 默认终止;Windows 上信号本身无效)。Ubuntu 上用 `systemctl restart zcbot`,或 unit 加 `--reload` 让 uvicorn 监听文件自动重起(见"部署"段)。验证:`curl -s http://127.0.0.1:8765/openapi.json \| python3 -c 'import sys,json;print([p for p in json.load(sys.stdin)["paths"] if "auth" in p])'` |
|
||||||
| `systemctl restart zcbot` 要等几十秒才退 | 正常 —— 优雅 drain 在等在跑的 run 收尾(`shutdown.drain_timeout` 默 30s),没在跑 run 时秒退。journal 出现 `[shutdown] draining N in-flight run(s)` 即正常。真急(不在乎杀掉在跑 run):`systemctl kill -s KILL zcbot` |
|
| `systemctl restart zcbot` 要等几十秒才退 | 正常 —— 优雅 drain 在等在跑的 run 收尾(`shutdown.drain_timeout` 默 30s),没在跑 run 时秒退。journal 出现 `[shutdown] draining N in-flight run(s)` 即正常。真急(不在乎杀掉在跑 run):`systemctl kill -s KILL zcbot` |
|
||||||
| 部署后在跑的对话被标 `error: server restarted before run finished` | 该 run 在 drain 期内没收尾、cancel 也没在 `cancel_grace` 内退,被 SIGKILL 后下次启动 reaper 标的。多半是 run 卡在不 poll cancel 的长动作(如单次超长 docker exec)或 `TimeoutStopSec` 配得比 drain 预算还小被提前 SIGKILL。先核对 unit `TimeoutStopSec > drain_timeout + cancel_grace`;真有超长 run 把 `drain_timeout` 调大 |
|
| 部署后在跑的对话被标 `error: server restarted before run finished` | 该 run 在 drain 期内没收尾、cancel 也没在 `cancel_grace` 内退,被 SIGKILL 后下次启动 reaper 标的。多半是 run 卡在不 poll cancel 的长动作(如单次超长 docker exec)或 `TimeoutStopSec` 配得比 drain 预算还小被提前 SIGKILL。先核对 unit `TimeoutStopSec > drain_timeout + cancel_grace`;真有超长 run 把 `drain_timeout` 调大 |
|
||||||
|
| 定时任务「跑到一半没推送」/ crons 页显示「上次失败: 运行超过超时上限 Ns 未完成」 | job 跑满 `timeout_seconds` 被协作式中断(还没写完 / 没推送)。**0.32.2 起超时记 error**(此前误记 ok 看不出来),计入连续失败、到阈值自动停用。**0.32.4 起新建 job 默认超时 1800s**(此前默认 0=不限;`DEFAULT_TIMEOUT_SECONDS`),`0` 仍可显式设"不限"。处置:报告类重活(多刊检索+渲 docx)若仍不够,把该 job `timeout_seconds` 再调大或设 0;被自动停用的重新 enable。诊断单个 job 用 `scripts/diag_sched_e621.py <job_id 前缀>` |
|
||||||
| `POST /v1/files/rename` 返 409 `folder has active run(s)` | 顶层目录被某 running/cancelling 的 task 占用;先 cancel 等流式 done 再 rename |
|
| `POST /v1/files/rename` 返 409 `folder has active run(s)` | 顶层目录被某 running/cancelling 的 task 占用;先 cancel 等流式 done 再 rename |
|
||||||
| `POST /v1/files/rename` 返 409 `... 前缀嵌套` | 改名后会与其他 task 的 working_dir 形成嵌套;换不冲突的 new_name |
|
| `POST /v1/files/rename` 返 409 `... 前缀嵌套` | 改名后会与其他 task 的 working_dir 形成嵌套;换不冲突的 new_name |
|
||||||
| `POST /v1/files/upload` 返 413 `已达磁盘配额上限` | per-user 5GB(yaml `quotas.disk_bytes_per_user`)。让用户在 dev SPA 右侧文件栏删旧产物 / 大文件,或改 yaml 升配重启 web |
|
| `POST /v1/files/upload` 返 413 `已达磁盘配额上限` | per-user 5GB(yaml `quotas.disk_bytes_per_user`)。让用户在 dev SPA 右侧文件栏删旧产物 / 大文件,或改 yaml 升配重启 web |
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.32.0"
|
__version__ = "0.32.5"
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ except ImportError: # pragma: no cover (py<3.9 不支持,本项目 3.11+)
|
||||||
FAILURE_DISABLE_THRESHOLD = 5
|
FAILURE_DISABLE_THRESHOLD = 5
|
||||||
# 单次 tick 最多认领多少 job(防一批同点任务一次性涌入)
|
# 单次 tick 最多认领多少 job(防一批同点任务一次性涌入)
|
||||||
CLAIM_LIMIT = 20
|
CLAIM_LIMIT = 20
|
||||||
|
# 新建 job 不指定时的默认单次超时(秒)。0=不限;给个有限默认防"跑到一半被
|
||||||
|
# 无限拖着 / 静默吞成 ok"。报告类重活(多刊检索+渲 docx)按经验 30min 够用。
|
||||||
|
DEFAULT_TIMEOUT_SECONDS = 1800
|
||||||
|
|
||||||
|
|
||||||
def validate_cron(expr: str) -> None:
|
def validate_cron(expr: str) -> None:
|
||||||
|
|
@ -340,7 +343,7 @@ def create_job(
|
||||||
skill: str = "",
|
skill: str = "",
|
||||||
notify: Optional[dict[str, Any]] = None,
|
notify: Optional[dict[str, Any]] = None,
|
||||||
model_profile: str = "",
|
model_profile: str = "",
|
||||||
timeout_seconds: int = 0,
|
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
name = (name or "").strip()
|
name = (name or "").strip()
|
||||||
prompt = (prompt or "").strip()
|
prompt = (prompt or "").strip()
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@
|
||||||
- 工具结果带 `[Error ...]` 时,先想清楚原因再重试,不要盲目重复同一调用
|
- 工具结果带 `[Error ...]` 时,先想清楚原因再重试,不要盲目重复同一调用
|
||||||
- 不臆造 API、文献、数据 —— 不知道就 read 源码 / 让用户提供 / 明说不知道
|
- 不臆造 API、文献、数据 —— 不知道就 read 源码 / 让用户提供 / 明说不知道
|
||||||
- 少来回:多个**互相独立、不依赖中间结果**的操作(建多页产物、批量改文件、生成整份 deck/文档)合到一个脚本或一轮(并发多 tool call)里做,别一步一个 tool call —— 每轮来回都重发整段上下文,轮数是 token 体量的线性乘数;但**下一步输入要看上一步结果**时(探索性检索、按报错改、需用户确认方向)就老实分步,别硬批
|
- 少来回:多个**互相独立、不依赖中间结果**的操作(建多页产物、批量改文件、生成整份 deck/文档)合到一个脚本或一轮(并发多 tool call)里做,别一步一个 tool call —— 每轮来回都重发整段上下文,轮数是 token 体量的线性乘数;但**下一步输入要看上一步结果**时(探索性检索、按报错改、需用户确认方向)就老实分步,别硬批
|
||||||
|
- 大块输出别反复灌进上下文:`run_python`/`shell` 打印的大段结果(整批文献摘要、长文件全文、大 JSON)会进对话历史并**每轮重发**,同一批数据 print 两三次上下文就滚雪球。中间数据**落文件**(如 `<task_dir>/scripts/data.json`、`evidence.md`),之后**只 `read` 用得上的片段**,别为"再看一眼"把整批重新打印 —— 既烧 token 又可能撑爆窗口 / 拖到超时被掐断
|
||||||
|
|
||||||
## 路径
|
## 路径
|
||||||
默认工作目录见系统消息末尾,相对路径都基于它。
|
默认工作目录见系统消息末尾,相对路径都基于它。
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
"""diag: 查 scheduled-e621c8a6 这个 job 为何执行到一半没推送(ASCII only, GBK safe)."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
env = Path(__file__).resolve().parent.parent / ".env"
|
||||||
|
for line in env.read_text(encoding="utf-8").splitlines():
|
||||||
|
if line.strip().startswith("ZCBOT_DB_URL="):
|
||||||
|
os.environ["ZCBOT_DB_URL"] = line.split("=", 1)[1].strip()
|
||||||
|
from sqlalchemy import create_engine, text # noqa: E402
|
||||||
|
import builtins # noqa: E402
|
||||||
|
|
||||||
|
_out = open(Path(__file__).resolve().parent / "_sched_e621.txt", "w", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def print(*a, **k): # noqa: A001
|
||||||
|
builtins.print(*a, **k, file=_out)
|
||||||
|
|
||||||
|
|
||||||
|
PREFIX = sys.argv[1] if len(sys.argv) > 1 else "e621c8a6"
|
||||||
|
engine = create_engine(os.environ["ZCBOT_DB_URL"])
|
||||||
|
|
||||||
|
|
||||||
|
def s(x, n=2000):
|
||||||
|
t = str(x if x is not None else "")
|
||||||
|
return t if len(t) <= n else t[:n] + f"...[+{len(t)-n}]"
|
||||||
|
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
job = conn.execute(text(
|
||||||
|
"select job_id,user_id,name,mode,cron,tz,enabled,notify,timeout_seconds,"
|
||||||
|
"next_run_at,last_run_at,last_status,last_error,last_task_id,"
|
||||||
|
"consecutive_failures,run_count,bound_task_id,created_at,deleted_at "
|
||||||
|
"from scheduled_jobs where cast(job_id as text) like :p"),
|
||||||
|
{"p": PREFIX + "%"}).fetchall()
|
||||||
|
print(f"[JOBS matched '{PREFIX}'] {len(job)}")
|
||||||
|
for j in job:
|
||||||
|
print("-" * 60)
|
||||||
|
print(f"job_id={j[0]} name={j[2]!r}")
|
||||||
|
print(f" mode={j[3]} cron={j[4]!r} tz={j[5]} enabled={j[6]} timeout={j[8]}")
|
||||||
|
print(f" notify={j[7]}")
|
||||||
|
print(f" next_run_at={j[9]} last_run_at={j[10]}")
|
||||||
|
print(f" last_status={j[11]} consecutive_failures={j[14]} run_count={j[15]}")
|
||||||
|
print(f" last_task_id={j[13]} bound_task_id={j[16]}")
|
||||||
|
print(f" deleted_at={j[18]} created_at={j[17]}")
|
||||||
|
if j[12]:
|
||||||
|
print(f" last_error: {s(j[12], 1500)}")
|
||||||
|
|
||||||
|
if not job:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
j = job[0]
|
||||||
|
uid = j[1]
|
||||||
|
last_tid = j[13]
|
||||||
|
|
||||||
|
# 找该 job 关联的所有 task(scheduled_job_id 回填 + last_task_id)
|
||||||
|
tasks = conn.execute(text(
|
||||||
|
"select task_id,name,status,run_status,run_error,tokens_prompt,tokens_completion,"
|
||||||
|
"created_at,updated_at,scheduled_job_id from tasks "
|
||||||
|
"where scheduled_job_id = :jid order by created_at"),
|
||||||
|
{"jid": str(j[0])}).fetchall()
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f"[TASKS with scheduled_job_id={str(j[0])[:8]}] {len(tasks)}")
|
||||||
|
for t in tasks:
|
||||||
|
print(f" task={t[0]} name={t[1]!r} status={t[2]} run={t[3]} "
|
||||||
|
f"tok={t[5]}/{t[6]} created={t[7]} updated={t[8]}")
|
||||||
|
if t[4]:
|
||||||
|
print(f" run_error: {s(t[4], 1500)}")
|
||||||
|
|
||||||
|
# dump last_task_id 的消息(执行到哪一步)
|
||||||
|
tid = last_tid or (tasks[-1][0] if tasks else None)
|
||||||
|
if tid is None:
|
||||||
|
print("\n[no task to dump]")
|
||||||
|
sys.exit(0)
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f"[DUMP messages of task {tid}]")
|
||||||
|
msgs = conn.execute(text(
|
||||||
|
"select idx,payload,tokens_in,tokens_out,created_at from messages "
|
||||||
|
"where task_id=:t order by idx"), {"t": str(tid)}).fetchall()
|
||||||
|
print(f"messages: {len(msgs)}\n")
|
||||||
|
for idx, p, ti, to, cat in msgs:
|
||||||
|
role = p.get("role")
|
||||||
|
head = f"[{idx}] {role} tok={ti}/{to} at={cat}"
|
||||||
|
print(head)
|
||||||
|
content = p.get("content")
|
||||||
|
if content:
|
||||||
|
print(" content:", s(content, 1500))
|
||||||
|
for tc in p.get("tool_calls") or []:
|
||||||
|
fn = tc.get("function") or {}
|
||||||
|
print(f" CALL {fn.get('name')}({s(fn.get('arguments'), 800)})")
|
||||||
|
if role == "tool":
|
||||||
|
print(f" TOOL[{p.get('name')}]:", s(content, 1200))
|
||||||
|
print()
|
||||||
|
|
@ -62,6 +62,8 @@ for jname in ["Cement and Concrete Research", "Cement and Concrete Composites",
|
||||||
- 汇成证据表 `<task_dir>/evidence.md`:期刊 | 标题 | 第一作者(机构)| 年-月 | 摘要概述 | DOI | 来源(research/documents/web)。
|
- 汇成证据表 `<task_dir>/evidence.md`:期刊 | 标题 | 第一作者(机构)| 年-月 | 摘要概述 | DOI | 来源(research/documents/web)。
|
||||||
- 跨源去重:同 DOI 一条(documents 全文优先,DOI 记自 research);web 不与论文去重、单列。
|
- 跨源去重:同 DOI 一条(documents 全文优先,DOI 记自 research);web 不与论文去重、单列。
|
||||||
|
|
||||||
|
> **context 纪律(省时省钱,务必遵守)**:检索结果(尤其全文 abstract)**落进 `evidence.md` / `selected_papers.json` 文件**,**不要在对话里反复 `run_python`/`print` 把整批 abstract 灌进上下文**。工具输出会永久留在 context 并每轮重发——同一批摘要 dump 三次,context 就滚成雪球(实测一次简报因此累计烧 2.5M 输入 token、跑满超时被掐断)。需要看某几篇时按需 `read` 文件片段,看完即弃,别整批重打。
|
||||||
|
|
||||||
> **窗口内 0 篇**:如实告知库内该窗口暂无收录(可能该刊本窗口尚未发文),可用 web 补更近的非论文动向,**不脑补文献**。
|
> **窗口内 0 篇**:如实告知库内该窗口暂无收录(可能该刊本窗口尚未发文),可用 web 补更近的非论文动向,**不脑补文献**。
|
||||||
|
|
||||||
## 阶段三:列清单 + 内容总结(写 `<task_dir>/sections/*.md`)
|
## 阶段三:列清单 + 内容总结(写 `<task_dir>/sections/*.md`)
|
||||||
|
|
@ -78,6 +80,8 @@ for jname in ["Cement and Concrete Research", "Cement and Concrete Composites",
|
||||||
<简介/摘要概述:2–4 句,讲研究对象、方法/表征、主要发现与关键数据 —— 基于 abstract 或全文,不夸张、不评判>
|
<简介/摘要概述:2–4 句,讲研究对象、方法/表征、主要发现与关键数据 —— 基于 abstract 或全文,不夸张、不评判>
|
||||||
```
|
```
|
||||||
按 `publication_date` 倒序,最新在前。每篇都要有摘要概述,不能只留标题。
|
按 `publication_date` 倒序,最新在前。每篇都要有摘要概述,不能只留标题。
|
||||||
|
|
||||||
|
> **一次成稿,别重复 dump**:中文概述基于 `evidence.md` / `selected_papers.json` **一遍生成写入**,生成后**不要再把英文 abstract 重新 `print` 进上下文**(它已在文件里)。论文多时按期刊**分批写**(每个 `###` 期刊段一次 `write`/`edit`),避免单次超长输出拖慢——而不是先把全批 abstract 全打印出来再憋一个巨型 write。
|
||||||
- **`02_summary.md` 内容总结**:对这批论文**客观归纳**——主题分布、常涉材料体系、常用方法/表征、共同关注点;引具体论文挂 `[n]` 上标(回链到 01)。**只描述"这批论文在讲什么",不给"应当/建议/可切入"**。
|
- **`02_summary.md` 内容总结**:对这批论文**客观归纳**——主题分布、常涉材料体系、常用方法/表征、共同关注点;引具体论文挂 `[n]` 上标(回链到 01)。**只描述"这批论文在讲什么",不给"应当/建议/可切入"**。
|
||||||
- **`03_web.md` 其他动向(仅 spec 开 web 时)**:政策/标准/会议/产业,`[W1]` 标来源 + 日期,单列。
|
- **`03_web.md` 其他动向(仅 spec 开 web 时)**:政策/标准/会议/产业,`[W1]` 标来源 + 日期,单列。
|
||||||
|
|
||||||
|
|
@ -107,3 +111,4 @@ for jname in ["Cement and Concrete Research", "Cement and Concrete Composites",
|
||||||
- ❌ 跳过定题直接检索 / 用中文 keyword 搜英文库 / 期刊名不精确 —— 先定题、转英文术语、用精确 `publication_name`
|
- ❌ 跳过定题直接检索 / 用中文 keyword 搜英文库 / 期刊名不精确 —— 先定题、转英文术语、用精确 `publication_name`
|
||||||
- ❌ web 资讯混进论文列表/总结 —— 单列"其他动向"
|
- ❌ web 资讯混进论文列表/总结 —— 单列"其他动向"
|
||||||
- ❌ 编造 DOI / "据报道"无源句 —— 查不到就如实说
|
- ❌ 编造 DOI / "据报道"无源句 —— 查不到就如实说
|
||||||
|
- ❌ 反复 `run_python`/`print` 把整批全文 abstract 灌进上下文 —— 落文件、按需读;同批摘要 dump 多次会让 context 滚雪球(实测一次简报累计烧 2.5M token、跑满超时被掐断没推送出去)
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,11 @@ class ScheduleCreateTool(_UserScopedTool):
|
||||||
},
|
},
|
||||||
"timeout_seconds": {
|
"timeout_seconds": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Optional hard timeout for each run (0 = no limit).",
|
"description": (
|
||||||
|
"Optional hard timeout for each run in seconds (default 1800 = 30min; "
|
||||||
|
"0 = no limit). Raise it for heavy report jobs (multi-journal search + "
|
||||||
|
"docx render) that legitimately need longer."
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": ["name", "prompt", "cron"],
|
"required": ["name", "prompt", "cron"],
|
||||||
|
|
@ -82,7 +86,7 @@ class ScheduleCreateTool(_UserScopedTool):
|
||||||
def execute(
|
def execute(
|
||||||
self, name: str, prompt: str, cron: str, tz: str = "Asia/Shanghai",
|
self, name: str, prompt: str, cron: str, tz: str = "Asia/Shanghai",
|
||||||
mode: str = "isolated", skill: str = "", notify_email: str = "",
|
mode: str = "isolated", skill: str = "", notify_email: str = "",
|
||||||
timeout_seconds: int = 0,
|
timeout_seconds: int = scheduler.DEFAULT_TIMEOUT_SECONDS,
|
||||||
) -> str:
|
) -> str:
|
||||||
notify = None
|
notify = None
|
||||||
if notify_email and notify_email.strip():
|
if notify_email and notify_email.strip():
|
||||||
|
|
|
||||||
12
web/app.py
12
web/app.py
|
|
@ -921,15 +921,27 @@ def create_app() -> FastAPI:
|
||||||
runner.add_done_callback(lambda t: app.state.inflight.pop(t, None))
|
runner.add_done_callback(lambda t: app.state.inflight.pop(t, None))
|
||||||
|
|
||||||
timeout = int(snap.get("timeout_seconds") or 0)
|
timeout = int(snap.get("timeout_seconds") or 0)
|
||||||
|
timed_out = False
|
||||||
if timeout > 0:
|
if timeout > 0:
|
||||||
done, _pending = await asyncio.wait({runner}, timeout=timeout)
|
done, _pending = await asyncio.wait({runner}, timeout=timeout)
|
||||||
if not done:
|
if not done:
|
||||||
|
timed_out = True
|
||||||
broker.request_cancel(tid) # 协作式停;loop 在 chunk 间 poll 到即退
|
broker.request_cancel(tid) # 协作式停;loop 在 chunk 间 poll 到即退
|
||||||
print(f"[scheduler] job {str(job_id)[:8]} timed out ({timeout}s), cancelling")
|
print(f"[scheduler] job {str(job_id)[:8]} timed out ({timeout}s), cancelling")
|
||||||
await runner
|
await runner
|
||||||
else:
|
else:
|
||||||
await runner
|
await runner
|
||||||
|
|
||||||
|
# 超时被掐断:_run_agent_bg 对 ok/cancelled 都把 run_status 收回 idle
|
||||||
|
# (二者在 DB 里不可区分),只有这里知道本次是 timeout 中断的。必须记为
|
||||||
|
# error —— 否则会误判成 ok(掩盖"跑到一半没推送"),且不计入连续失败/不触发
|
||||||
|
# 兜底。半成品不投递 notify,直接收尾返回。
|
||||||
|
if timed_out:
|
||||||
|
record_result(job_id, status="error", task_id=tid,
|
||||||
|
error=f"运行超过超时上限 {timeout}s 未完成,已中断(本次未推送)")
|
||||||
|
print(f"[scheduler] job {str(job_id)[:8]} recorded as timeout-error")
|
||||||
|
return
|
||||||
|
|
||||||
# run 终态:_run_agent_bg 收尾把 run_status 写回 idle(ok)/error
|
# run 终态:_run_agent_bg 收尾把 run_status 写回 idle(ok)/error
|
||||||
with session_scope() as s:
|
with session_scope() as s:
|
||||||
st = s.execute(
|
st = s.execute(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue