Compare commits

..

3 Commits

Author SHA1 Message Date
caoqianming f8d11a2491 test(scheduler): 定时任务端到端 smoke + tick 默认 30s→10s + bump 0.20.1
- scripts/smoke_scheduler.py:插一条 next_run=now 的 isolated job,轮询 last_status
  翻 ok/error/skipped,验证守护循环全链路(认领→建 task→_run_agent_bg→LLM→记账)。
  实跑通过:约 15s 内触发,agent 回「早安,今天也加油!」,last_status=ok。
- 守护循环扫描间隔默认 30s→10s(ZCBOT_SCHEDULER_TICK_SECONDS);间隔只决定最坏
  延迟≤1tick,不决定会否漏(claim 取 next_run<=now 的全部)。DESIGN/RUN 同步。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:48:44 +08:00
caoqianming 2b9a7febde feat(skill): brief 重定位为重要文献速览(论文列表+总结,只描述不给建议)+ 精简三文件 + bump 0.20.0
- 重定位:重要论文列表(各大期刊,Elsevier 数据库优先,每篇带简介/摘要概述)+ 内容总结;去掉建议/启示/热点聚类/判断
- 三路取数:research + documents 取文献为主力,web search 取政策·标准·产业动向单列(不混进论文总结)
- 精简 8→3 文件:SKILL.md 自包含(spec 字段/骨架/检索法/核验铁律/渲染说明)+ references/journals.md(各建材子领域主流期刊清单,Elsevier 标注 + 精确 publication_name + 0 命中降级)+ scripts/render_docx.py;删 templates/spec.md、templates/brief_outline.md、references/search_strategy.md、references/citation_verify.md、scripts/quality_check.py
- render_docx.py:论文列表段(标题含"论文列表/文献列表/参考文献")H3 期刊子标题下的 [n] 条目仍作锚点(只在 H1/H2 重判段类型);条目内 DOI 子串(末尾 "DOI: 10.xxx")也做 https://doi.org 超链接;smoke test 验证锚点/回链/外链/化学式下标全在

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:24:31 +08:00
caoqianming 108351864e feat(scheduler): 定时任务 v1 — 对话建/管 + 守护循环执行 + 只读前端 (DESIGN §8.5)
到点把一句自然语言 prompt 喂进 agent 主管线,可跑 skill 出简报 / 发邮件 / 打招呼等。
job 本体 = cron+时区 + prompt + 会话模式;"发邮件"不是字段,是 agent 据 prompt 调
send_email 的动作 → 加任何能力不改 schema。

后端:
- scheduled_jobs 表 + migration 0011(独立加表,公测兼容)
- core/scheduler.py:croniter 算 next_run(时区+vixie OR 语义)、claim+advance 防重复触发、
  失败阈值自停、notify 兜底投递、CRUD 服务层(工具与 REST 共用不漂移)
- 守护循环 _scheduler_loop(lifespan,仿 _disk_scanner 的 plain-asyncio,不引 APScheduler/Celery;
  复用 _run_agent_bg,抢 run 锁、超时协作 cancel、并发上限)
- tools/send_email.py(host-side,SMTP_* 齐才挂)
- /v1/schedules GET/PATCH/DELETE 三端点

对话端 = 完整 CRUD:schedule_create/list/update/cancel 四工具(定时 run 内不挂防自我繁殖)。

前端 = 只读 + 停用/删除:左栏 rail「定时」入口 + crons.js 只读 master-detail modal
(复用 skills modal 范式);建/改故意只走对话,规避 cron 构建器 UX。

会话模式:isolated(默认,每次新建临时 task 省 token)/ persistent(绑 bound_task_id 续上下文)。
env:SMTP_* / ZCBOT_DISABLE_SCHEDULER / ZCBOT_SCHEDULER_TICK_SECONDS / ZCBOT_SCHEDULER_CONCURRENCY。

已验:migration 上库、CRUD 端到端、3 REST + 4 工具注册、crons.js 语法。
待验:起 web 进程跑一轮真实触发 + 邮件 smoke。bump 0.18.0 → 0.19.0。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:42:31 +08:00
25 changed files with 1746 additions and 604 deletions

View File

@ -589,6 +589,59 @@ create index on usage_events (model_profile, created_at);
**搁置(成本不抵当前收益)**:gunicorn 无感换版 / broker 外置 Redis / nginx 蓝绿双实例 —— 留到"单机线程池调到头仍不够"或"换版断流成真实投诉"再议(无感换版需先把 broker 外置共享,分析见 RUN.md §B)。
### 8.5 定时任务 / 计划运行(Scheduled Jobs)(2026-06-18 设计,status=design)
**缺口**:无任何定时触发机制。但有价值的活很多是**时间驱动**而非事件驱动 —— 每日简报、每周综述、定时拉数据存盘、早安提醒。当前必须有人在对话里手动发消息才跑得起来。诉求:**用对话方式创建**"每天 X 点干 Y"的任务,到点自动跑、结果送达。
**业界印证(四源高度收敛)**:OpenClaw `cron-jobs` / Autobot(agent-loop×cron)/ Claude Code routines / geta.team 自建调度器,关键模式一致 —— ① cron 到点**往同一条 agent 主管线注入一条带标记的消息**,不另起执行路径;② 三种会话隔离模式(isolated 默认 / persistent 续上下文 / main 系统事件);③ isolated 运行到期自动 prune;④ 退避重试(transient vs permanent);⑤ per-job 超时;⑥ 投递显式 + runner 兜底(OpenClaw `--announce`);⑦ 5 段 cron + 时区,警惕 dom/dow 同列的 vixie OR 语义坑;⑧ 持久化用 DB,管理三件套(`cron_create/list/delete`);⑨ 对话式自然语言创建即标准做法。
**核心洞察(把方案收口到极简)**:定时任务本体 = `什么时候(cron+时区)` + `做什么(一句自然语言 prompt)` + `跑在哪(会话模式)`。**复用现成 agent 主管线**(`web/app.py:_run_agent_bg`,§3.6 / §7.2 同一条 POST /messages 路径),守护循环只负责"到点把一条带 `[定时任务]` 标记的 prompt 喂进去",**不造第二套跑 agent 的逻辑**。
> **关键解耦:"发邮件"不是一等公民,是 agent 据 prompt 调工具的一个动作。** job 模型只存 prompt,"做什么 / 结果发哪"全在那句话里(发邮件→调 `send_email`;出简报→`load_skill` 落盘;打招呼→回一句话)。好处:未来加任何能力(telegram / webhook / 落盘 / 调 API)**不改 schema**,只要 agent 有对应工具、prompt 说清楚。
**三层投递(没人盯着看 → 结果不能丢)**:
1. **baseline(永远有,零配置)**:定时 run 就是正常 run,结果**必进对应 task 线程**;守护循环跑完给该 task 打**未读/通知标记**,用户下次登录可见。
2. **opt-in 推送(prompt 驱动)**:要发邮件/(将来)telegram → prompt 里说,agent 调工具发。灵活、能写动态正文。
3. **可靠兜底(可选结构化 `notify`)**:某 job 要"必达某邮箱、不靠 AI 记性" → job 带 `notify={channel,to}`,守护循环 run 完**确定性补发**最新产物。不填走第 1 层。
**会话模式(隔离轴,业界核心设计点)**:
- **isolated(默认)**:每次触发新建临时 task,只带 job 的 prompt + skill,**不继承对话历史**。上下文最小 → 省 token(契合 high-turn 烧 token 治理,§8.2 / [[project_high_turn_token_burn_root_causes]]);临时 task 打标签 + 到期自动归档,防 task 列表被每日任务刷屏。
- **persistent(可选)**:job 绑定一个常驻 task(`bound_task_id`),每次往同一线程追加消息,有跨天连续性("和昨天比")。代价:线程越长重发历史越多、token 逐日涨 —— 仅在用户明确要连续性时用。
**数据模型(新表 `scheduled_jobs`,独立加表不碰现有 schema → 公测兼容)**:
`id, user_id, name, prompt, cron, tz(默 Asia/Shanghai), mode(isolated|persistent), bound_task_id(可空), notify(JSONB 可空), enabled, timeout_seconds, next_run_at, last_run_at, last_status, last_error, last_task_id, consecutive_failures, expires_at(可空), created_at, deleted_at`。Alembic 加表 migration;`usage_events` 复用现成记账(可加 `kind="scheduled"` 自由文本区分,无需 migration)。
**守护循环(仿 §8.4 `_disk_scanner`,plain-asyncio)**:lifespan 起一个后台 task,每 ~10s(`ZCBOT_SCHEDULER_TICK_SECONDS`,只决定最坏延迟≤1tick、不决定会否漏 —— claim 取 `next_run<=now` 的全部)扫 `enabled AND next_run_at<=now()`;命中即 `asyncio.create_task(asyncio.to_thread(_run_agent_bg, ...))` 复用现成路径,登记到 `app.state.inflight`(随关停 drain 一起收尾)。与**单活 run 锁**(§7.x `run_status` + `SELECT FOR UPDATE`)交互:isolated 每次新 task 天然无冲突;persistent 若绑定 task 正忙 → 跳过本次 + 记 warn,下一个点再来(不排队堆积)。run 完回写 `last_*` + croniter 算 `next_run_at`
**croniter 选型**:存标准 5 段 cron 串 + 时区,`croniter` 算 `next_run_at`。理由:正确处理 dom/dow 同列的 vixie OR 语义和时区折算(手搓极易踩坑,四源都点名这个坑);纯 Python 小依赖。劣选:只支持"每天/每周 HH:MM"自己用 datetime 算 —— 零依赖但遇复杂周期要返工。
**可靠性(业界补的,纳入设计)**:
- **退避重试**:transient(限流/网络)指数退避重试(60s→120s→300s),成功重置;permanent(prompt 报错/鉴权)直接失败记 `last_error`
- **per-job 超时** `timeout_seconds`:超时复用现成协作式 cancel 信号(§7.x)。
- **无补跑(no catch-up)**:守护进程宕机期间错过的点**跳到下一个**,不补 N 次(同 Claude Code 语义)。
- **防自我繁殖**:定时 run 内**禁用 `schedule_create`**(防任务造任务);并发调度数设上限。
- **expiry 安全界**:`expires_at` + `consecutive_failures` 阈值 → 连续失败 N 次或长期没人管自动停,防僵尸定时任务(同 Claude Code 7 天过期思路)。
**对话端(用户要的"对话方式创建")**:核心是 host-side 工具三件套 `schedule_create / schedule_list / schedule_cancel`(写 `scheduled_jobs`,按 `user_id` 隔离,密钥不进沙箱,沿用 §3.4 typed-tool 范式)。自然语言进、自然语言管("我有哪些定时任务""取消那个简报")—— 即 Claude Code 模式(其定时任务纯工具实现,无配套 skill,证明工具单干可跑通)。
- **工具必须、skill 可选后置**:skill 是 markdown 不能落库,执行器只能是工具;收集字段/`ask_user` 确认这套流程,能力强的模型靠工具自描述 schema 即可走通。故 **v1 纯工具**(schema + 参数描述写好就够),契合 §5 "Less Scaffolding, More Trust" —— 先信模型,跑不好再加脚手架。
- **skill 真正值钱处不是教填参数(schema 够),而是教写好 `job.prompt`**:job 的 prompt 决定未来**每天**那次 run 的质量,用户随口一句直接存会跑得差;好 prompt 要自包含/可重复/产物位置明确/把发哪存哪写死 —— 模型默认不会,值得一份模板+确认纪律(cron 口径翻译、回读人话确认、默认 isolated 并提示 persistent 代价)去教。**v2 按需补**:实测发现 agent 写的 `job.prompt` 质量差 / 确认流程乱再加;且因调度低频,用按需 `load_skill`(§3.5)而非 always-on prompt 块,避免每轮白烧 token(§8.2)。
- 三件套用**三个独立工具**(schema 清晰、对齐 Claude Code `CronCreate/List/Delete`),非单工具带 `action` 参数。
**取舍(不选)**:
- **不引 APScheduler / Celery**:项目刻意用 plain-asyncio 后台循环(§8.4),调度需求是单机低并发,引调度框架/Redis broker 是过度工程。
- **不学 geta 用 JSON 文件持久化**:已有 PG + SQLAlchemy + alembic,加表是自然选择(JSON 文件丢状态、无事务、无按 user 查询)。
- **email 不做成 job 一等字段**:降通用性(见核心解耦);仅留可选 `notify` 兜底。
**风险 / 边界 / 待定**:
- **`send_email` 工具仍要建**(`tools/send_email.py`,host-side,仅当 `SMTP_*` env 存在才挂,沿用 §3.4 "有 key 才注册"),让第 2/3 层能用。**待定:SMTP 发信账号**(企业邮箱/QQ/163/Gmail 应用密码)—— 给真实账号走 env,或先占位走沙箱验证链路。
- **计费归属**:定时 run 计入 job 所属 `user_id` 的 token/配额,`usage_events` 标 `kind="scheduled"` 可审计。
- **错峰抖动**:多用户同设 8 点 → 按 job-id 加确定性偏移防同一秒打爆 LLM provider(单机低并发,列 nice-to-have 不阻塞 v1)。
- **待定小项**:可选 `notify` 字段是否 v1 就上(倾向上,零成本兜底);`expires_at` 默认值。
**改动面(v1)**:1 张新表 + migration、1 守护循环(lifespan)、4 个 schedule 工具(create/list/**update**/cancel)、1 个 send_email 工具、agent_builder 注册 + 定时 run 内工具裁剪。**v2 按需**:薄 skill(教写 `job.prompt`)。**不动** loop / llm / capabilities / 现有 DB schema。
**前端取舍(2026-06-18 定 + 落地):对话端做完整 CRUD,前端只读展示 + 停用/删除。** 前端 SPA 调 `/v1/*` REST、不经 agent → "界面建/改定时任务"必须另开 REST + 表单 + cron 构建器(整套最重的是让科研用户填 cron 的 UX)。既然产品本就是对话式 agent,把建/改/删/查全收到对话(`schedule_*` 工具),**前端退化成只读看板**:`GET /v1/schedules` 列表 + 列表项「停用/删除」两个高频便捷动作(`PATCH`/`DELETE /v1/schedules/{id}`)。好处:cron 构建器 UX 难题直接消失(用户从不在前端填 cron,对 bot 说"每天早九点"由模型翻译);无"前端改了和对话不同步"的状态问题。代价:界面不能新建/编辑(需求低频,且对话更自然)。落地:`web/static/js/crons.js` 只读 master-detail modal(复用 skills modal 范式)+ 左栏 rail「定时」入口;工具与 REST 共用 `core.scheduler` CRUD 服务层不漂移。
---
## 附录:DeepSeek V4 关键事实(2026-04-24)

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-06-18(新增 brief skill:科研方向简报,三路检索 documents/research/web + 文献计量趋势型简报)
最后更新:2026-06-18(brief 简报重定位为「重要文献速览」+ 精简到三文件 + bump 0.20.0)
---
@ -21,6 +21,24 @@
## 已完成关键能力
### 2026-06-18 / brief 简报重定位「重要文献速览」+ 精简三文件(bump 0.20.0)
- 需求漂移收敛:brief 从"热点聚类趋势判断型简报"重定位为**「重要论文列表 + 内容总结」速览型** —— ①只描述不给建议(去掉启示/判断/空白争议);②开头一份重要期刊论文列表(各大相关刊、**Elsevier 数据库优先**),每篇带一段简介/摘要概述;③对这批论文做客观总结即可。
- 数据源:**research + documents 都是取文献主力**(research 逐刊精确取最新 Elsevier 论文 + DOI;documents 取内部材料库全文),web search 取动向**单列**不混进论文总结。
- **精简到三文件**(原 8 文件):`SKILL.md`(自包含:spec 字段/骨架/检索法/核验铁律/渲染说明)+ `references/journals.md`(各建材子领域主流期刊清单,Elsevier 标注 + 精确 publication_name + 0 命中降级)+ `scripts/render_docx.py`。删 `templates/spec.md`、`templates/brief_outline.md`、`references/search_strategy.md`、`references/citation_verify.md`、`scripts/quality_check.py`。
- `render_docx.py` 两处小改并已 smoke test 验证:①「重要论文列表」段(标题含"论文列表/文献列表/参考文献")H3 期刊子标题下的 `[n]` 条目仍作锚点(只在 H1/H2 重判段类型);②条目内 DOI 子串(末尾 "DOI: 10.xxx")也做 https://doi.org 超链接。验证:ref 锚点/内部回链/外部 DOI 链/化学式下标全在。
- 文件:`skills/brief/{SKILL.md,references/journals.md,scripts/render_docx.py}`;`core/__init__.py` 0.19.0→0.20.0;`SKILL_LIST.md`(brief 条目重写,总数仍 17)同步。
### 2026-06-18 / 定时任务 v1(scheduled_jobs,DESIGN §8.5)
- 需求:对话方式建"每天 X 点干 Y"的定时任务(跑 skill 出简报 / 发邮件 / 打招呼皆可)。调研 OpenClaw/Autobot/Claude Code/geta 四源收敛,定方案见 DESIGN §8.5。
- **核心解耦**:job 本体 = `cron+tz + 一句 prompt + 会话模式`;"发邮件"不是字段,是 agent 据 prompt 调 `send_email` 的动作 → 加任何能力不改 schema。
- **不引调度框架**:croniter(唯一新依赖)只当 next_run 计算器(正确处理 dom/dow OR 语义 + 时区);"每 30s 醒来扫到点 job"是 plain-asyncio 守护循环,仿 §8.4 `_disk_scanner`,复用 `_run_agent_bg`,不上 APScheduler/Celery。
- **文件**:`core/storage/models.py` 加 `ScheduledJob` + migration `0011_scheduled_jobs`(独立加表,公测兼容)/ `core/scheduler.py`(cron 数学 + claim+advance 防重复触发 + record_result 失败阈值自停 + notify 兜底投递 + CRUD 服务层 `list/create/update/set_enabled/cancel_job`,工具与 REST 共用)/ `tools/schedule.py`(create/list/**update**/cancel 四件套,薄包装服务层,user_id ctor 注入,定时 run 内不挂防自我繁殖)/ `tools/send_email.py`(host-side,SMTP_* 齐才挂)/ `web/app.py` lifespan `_scheduler_loop` + `_execute_scheduled_job`(认领→抢 run 锁→to_thread 跑→超时协作 cancel→notify→记账)+ `/v1/schedules` GET/PATCH/DELETE 三端点。
- **对话端 = 完整 CRUD**(建/改/删/查都说着办);**前端 = 只读展示 + 停用/删除两个便捷按钮**(左栏 rail「定时」按钮 → `crons.js` 只读 master-detail modal,复用 skills modal 范式;建/改无 REST、故意只走对话,§8.5)。两条路径共用 `core.scheduler` 服务层不漂移。
- **会话模式**:isolated(默认,每次新建临时 task `scheduled-<id8>` 目录,省 token)/ persistent(绑定 bound_task_id 续上下文)。env:`SMTP_*` / `ZCBOT_DISABLE_SCHEDULER` / `ZCBOT_SCHEDULER_TICK_SECONDS` / `ZCBOT_SCHEDULER_CONCURRENCY`(见 RUN)。已验:migration 上库 0011、CRUD 服务层端到端、3 REST 路由 + 4 工具注册、crons.js 语法。bump 0.18.0 → 0.19.0。
- **v2 待做**:对话工具教写好 job.prompt 的薄 skill;退避重试(transient/permanent 区分)目前简化为"到下一 cron 点 + 连失败 5 次自停";真机邮件 smoke + 守护循环定时触发的端到端验证(需起 web 进程跑一轮)。
### 2026-06-18 / brief skill:科研方向简报
- 需求:用户要"水泥/建材方向的科研简报"。联网调研简报类做法——Anthropic 官方 digest skill(办公活动聚合)+ Paper Digest(论文影响力周报)+ 文献计量趋势报告(热点聚类/新兴方法/地理格局)。结论:现有 skill 缺"某方向近期文献 → 有判断的趋势简报"这一环(research/documents 只取文献不组织、paper-review 出可投稿综述、analyze 拆问题不查文献)。

11
RUN.md
View File

@ -44,6 +44,17 @@
# 对话整个 run 期占 1 线程,active_runs 逼近此值即排队(看 journalctl 的 [stats] 行);
# 并发不够再调大(先确认是真并发高、而非单条 run 慢)。
# ZCBOT_RUN_MAX_WORKERS=16
# 定时任务发邮件(send_email tool + 定时任务 notify 兜底投递,DESIGN §8.5):可选。
# 三者齐了才挂 send_email tool(没配的部署 agent 看不到这个工具);密钥只留宿主、不进 sandbox。
# SMTP_HOST=smtp.qq.com
# SMTP_PORT=465 # 默 465;465→SSL,其余→STARTTLS(可用 SMTP_TLS=ssl|starttls|none 覆盖)
# SMTP_USER=you@qq.com
# SMTP_PASSWORD=<授权码/应用专用密码,非登录密码>
# SMTP_FROM=you@qq.com # 可选,默认取 SMTP_USER
# 定时任务守护循环(DESIGN §8.5,随 web 进程起,plain-asyncio 仿 _disk_scanner):
# ZCBOT_DISABLE_SCHEDULER=1 # 可选,整体关掉调度(对照 Claude Code CLAUDE_CODE_DISABLE_CRON)
# ZCBOT_SCHEDULER_TICK_SECONDS=10 # 可选,扫描间隔,默 10s(只决定最坏延迟≤1tick,不影响会否漏)
# ZCBOT_SCHEDULER_CONCURRENCY=4 # 可选,并发跑的定时 run 上限,默 4
```
> litellm 在 import 时副作用加载 .env;入口走 `main.py`,`.env` 自动生效。直跑 `python -c "from core.storage import ..."` 不经 litellm 链路时记得自己 `import litellm` 触发,或手动 `export ZCBOT_DB_URL=...`
- **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里;含 `bcrypt`)。

View File

@ -23,7 +23,7 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` +
| 演示出图 | [plot_pub](#plot_pub) | 出版级 matplotlib 学术图(中文 + viridis + 矢量 + 投稿级复合图设计纪律) |
| 文献检索 | [research](#research) | 查 paper_server(OpenAlex 元数据 + Sci-Hub 下载) |
| 文献检索 | [documents](#documents) | 查内部 7 学科材料知识库(100W+ 论文,跨语言检索;host-side tool 持 key) |
| 文献检索 | [brief](#brief) | 科研方向简报:三路检索(内部库 + research + web)→ 热点聚类趋势简报 |
| 文献检索 | [brief](#brief) | 科研方向简报:三路检索(research + 内部库取文献 / web 取动向)→ 重要论文列表(带摘要概述)+ 内容总结,只描述不给建议 |
| 科研计算 | [pymatgen](#pymatgen) | 晶体结构 / XRD 模拟 / 相图 / Materials Project(host-side tool 持 key) |
| 科研计算 | [stats_ml](#stats_ml) | 配方-性能建模与机器学习(三库分工) |
| 内容生成 | [imagegen](#imagegen) | 豆包 Seedream 5.0 文生图 + 改图 i2i(¥0.22 / 张) |
@ -291,32 +291,32 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S
---
### brief
**生成科研方向简报(文献计量趋势型简报)。**
**生成科研方向简报(重要文献速览)。**
给定一个研究方向 + 时间窗,用**三路真实数据**(documents 内部库取全文 / research 取近期 DOI 元数据 / web 取政策·会议·标准动向)产出一份**有判断、可溯源**的简报:热点聚类 + 新兴方法 + 关键进展 + 研究空白 + 产业政策动向。简报 ≠ 综述论文 —— 要**快、准、有取舍**("重要性优先于完整性"),帮决策者 / 课题组 520 分钟掌握一个方向近期态势
给定一个研究方向 + 时间窗,从各大相关期刊(**Elsevier 数据库优先**)挑选近期**重要论文**,产出两段式简报:**先一份重要论文列表(每篇带标题/作者/期刊/年月/DOI + 一段简介或摘要概述),再对这批论文做内容总结**。三路取数:research(逐刊精确取最新 Elsevier 论文 + DOI)+ documents(内部材料库取全文)取文献,web search 取政策·标准·产业动向(单列)。**只描述不给建议**——呈现"发了什么、讲了什么",判断留给读者。简报 ≠ 综述论文,要**快、准、客观**,520 分钟掌握一个方向近期发了哪些重要论文
**六阶段**:定题对齐 spec(方向+边界 / 时间窗 / 受众 / 深度 / 源开关 / 语言 / 关注点)→ 三路检索取数(中→英术语转译 + 跨源去重,证据表)→ 趋势分析(37 热点簇,对齐后再写)→ 逐段起草 → 引文核验(复用 paper 三层协议)→ 渲染验收。
**五阶段**:定题对齐 spec(方向+边界 / 时间窗 / 期刊范围 / 深度 / 数据源 / 语言 / 关注点)→ 三路取数(research+documents 取文献、web 取动向;中→英术语转译 + 跨源去重)→ 列清单(带摘要概述)+ 内容总结 → 引文核验 → 渲染验收。
**深度三档**:`flash` 快报(12 页)/ `standard` 标准(46 页)/ `deep` 深度(8+ 页,含机构-地理计量),各配字数 / 簇数 / 引文数预算
**深度三档(按篇数)**:`flash` 1020 篇 / `standard` 2040 篇 / `deep` 4080 篇
**何时用**:
- ✅ 用户要"简报 / 方向简报 / 研究动态 / 趋势报告 / 调研快报 / 跟踪某领域最新研究 / 某方向近期进展"
- ✅ 立项前想快速摸清一个方向近期态势再决定要不要做(产出可喂 proposal / analyze)
- ✅ 用户要"简报 / 方向简报 / 最新文献 / 重要论文列表 / 研究动态 / 某方向近期重要论文 / 跟踪某领域最新研究"
- ✅ 立项前想快速摸清一个方向近期发了哪些重要论文(产出可喂 proposal / analyze)
**何时不用**:
- ⛔ 只要文献清单 / DOI / PDF → research / documents
- ⛔ 要写可投稿的综述论文(几十页、定论)→ paper(review 类型)
- ⛔ 要把模糊科学问题拆成子问题 + 路线图 → analyze
- ⛔ 要写本子 → proposal
- ⛔ 要"对本院的建议" / 写本子 → proposal
**核心能力**:
- **三路检索分工 + 去重**(`search_strategy.md`):documents 全文首选、research 补 DOI 卡 year_gte、web 单列产业政策动向不混学术引文计数;中文方向→英文术语转译(SCM/LC3 等缩写展开)
- **趋势分析纪律**:热点聚成簇(主题句写判断不堆关键词)、新兴方法、争议空白、机构-地理格局(deep);取舍优先于穷尽
- **引文核验**(复用 paper 三层协议):存在性 / 三角印证 / 支撑度,编造零容忍;web 来源标 URL + 日期 + 文号
- `quality_check.py`:结构 / 簇数预算 / 过度宣称 / **无源句式**("据报道""有研究表明"无引文)/ 引文交叉核对
- **自带 `render_docx.py`**:商务红主题 + 正文 `[n]`/`[Wn]` 引文上标并锚到文末 + DOI/URL 可点击超链接 + 化学式下标(CO₂/C₃S...,白名单不误伤 LC3/Ca2+)+ TL;DR / 判断 callout 底纹;做 deck 转 ppt
- **逐刊取重要论文**(`references/journals.md`):各建材子领域主流期刊清单(Elsevier 优先),精确 `publication_name` + `year_gte` 取最新,0 命中降级到 keyword 搜;按重要性(期刊层级 + 主题相关性 + 发现分量)筛、按 publication_date 留最新
- **三路分工 + 去重**:research+documents 取文献(同 DOI 一条、documents 全文优先)、web 单列产业政策动向不混论文总结;中文方向→英文术语转译(SCM/LC3 等缩写展开)
- **每篇带摘要概述**:列表不只标题,每篇 24 句讲研究对象/方法/主要发现,基于 abstract 或全文、不夸张不评判
- **引文核验**:存在性 / DOI 真伪(以库返回字段为准)/ 支撑度(摘要概述与原文一致,partial 改概述迁就证据),编造零容忍
- **自带 `render_docx.py`**:商务红主题 + 论文列表 `[n]` 作锚点、正文 `[n]`/`[Wn]` 引文上标回链 + DOI/URL 可点击超链接(条目内 DOI 子串也链)+ 化学式下标(CO₂/C₃S...,白名单不误伤 LC3/Ca2+);做 deck 转 ppt
**典型产物**:`<方向>-简报.md`(默认)+ `evidence.md`(证据表)+ `CITATIONS.md`(引文核验台账);可选转 docx / deck。
**典型产物**:`<方向>-简报.md`(默认,含 `01_papers` 重要论文列表 + `02_summary` 内容总结)+ `evidence.md`(证据表);可选转 docx / deck。
---
@ -528,7 +528,7 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S
- **写本子全流程**:analyze(拆问题) → research / documents(查文献) → stats_ml(算配方-性能模型出预实验数据) → plot_pub(出图) → proposal(写本子) → review(审稿)
- **写专利全流程**:patent(挖点 + 检索 + 起草) → research(查现有技术) → plot_pub(出附图) → review(终审)
- **写标准全流程**:analyze(定标准化对象) → stats_ml(配方-性能 / 精密度试验数据定指标) → research / documents(查国内外现有标准与现状) → standard(起草标准 + 编制说明) → plot_pub(出图) → review(送审前终审)
- **方向简报 → 立项**:brief(三路检索摸方向近期态势) → analyze(把方向拆成子问题 + 路线图) → proposal(写本子) / paper(写综述);简报要做成汇报 → ppt
- **方向简报 → 立项**:brief(三路取数,出重要论文列表 + 内容总结,只描述) → analyze(把方向拆成子问题 + 路线图) → proposal(写本子、给建议) / paper(写综述);简报要做成汇报 → ppt
- **PPT 汇报**:analyze(提炼论点) → research / documents(找数据 + 引文) → plot_pub(出图) → ppt(组装 deck) → imagegen(可选,做封面 / 引子页)
- **晶体计算**:pymatgen(算 XRD / 相图) → plot_pub(出图) → proposal / patent(写到本子 / 交底书里)
- **定制能力**:skill-creator(fork 某内置 skill,如 ppt / proposal) → 改造成本组 / 本人专属版本(术语 / 模板 / 默认值),之后日常任务直接用改造版

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.18.0"
__version__ = "0.20.1"

View File

@ -56,6 +56,10 @@ from tools.task_progress import TaskProgressTool
from tools.ask_user import AskUserTool
from tools.web_fetch import WebFetchTool
from tools.web_search import WebSearchTool
from tools.schedule import (
ScheduleCancelTool, ScheduleCreateTool, ScheduleListTool, ScheduleUpdateTool,
)
from tools.send_email import SendEmailTool, smtp_configured
from core.ark_client import ArkConfig
from core.bocha_client import BochaConfig
@ -361,6 +365,7 @@ def build_agent(
image_variant: str = "",
video_variant: str = "",
cancel_check: Optional[Callable[[], bool]] = None,
scheduled_run: bool = False,
) -> Tuple[AgentLoop, Session, str, TaskState, Path]:
"""返回 (agent, session, task_id_str, task_state, working_dir_path)。
@ -546,6 +551,23 @@ def build_agent(
):
tools[t.name] = t
# 定时任务管理(DESIGN §8.5):增删查三件套。**定时 run 内不挂**(防任务造任务,
# 自我繁殖);仅交互对话里能建/管 job。user_id 由 ctor 注入,不信模型传的 id。
if not scheduled_run:
for t in (
ScheduleCreateTool(uid, base_dir=tool_base, user_root=ur_path),
ScheduleListTool(uid, base_dir=tool_base, user_root=ur_path),
ScheduleUpdateTool(uid, base_dir=tool_base, user_root=ur_path),
ScheduleCancelTool(uid, base_dir=tool_base, user_root=ur_path),
):
tools[t.name] = t
# 发邮件(§8.5 投递):仅当 SMTP_* env 齐了才挂(沿用"有 key 才注册",没配的
# 部署里 agent 看不到一个永远报错的工具)。定时与交互 run 都可用。
if smtp_configured():
se = SendEmailTool(base_dir=tool_base, user_root=ur_path)
tools[se.name] = se
if caps.enable_run_python:
rp = RunPythonTool(base_dir=tool_base, user_root=ur_path)
tools[rp.name] = rp

405
core/scheduler.py Normal file
View File

@ -0,0 +1,405 @@
"""定时任务调度核心(DESIGN §8.5)。
纯逻辑层:cronnext_run 计算due 任务认领跑完记账确定性兜底投递
**不碰 asyncio / broker / _run_agent_bg**(那些 web 专属编排留在 web/app.py
lifespan `_scheduler_loop`,仿 _disk_scanner 调本模块)
为什么 claim 时就推进 next_run_at:守护循环每 ~30s 扫一次,若不在认领时把 job
next_run_at 推到下一个 cron ,run 还没跑完时下一 tick 会把同一 job 重复触发
claim+advance 一把事务做掉 天然防重复触发(at-most-once per slot)
"""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from uuid import UUID, uuid4
from croniter import croniter
from sqlalchemy import select, update
from .storage import session_scope
from .storage.models import ScheduledJob
_MODES = ("isolated", "persistent")
try:
from zoneinfo import ZoneInfo
except ImportError: # pragma: no cover (py<3.9 不支持,本项目 3.11+)
ZoneInfo = None # type: ignore
# 连续失败到这个数自动停(防僵尸定时任务,DESIGN §8.5 expiry 安全界)
FAILURE_DISABLE_THRESHOLD = 5
# 单次 tick 最多认领多少 job(防一批同点任务一次性涌入)
CLAIM_LIMIT = 20
def validate_cron(expr: str) -> None:
"""非法 cron 抛 ValueError(给 schedule_create 工具做入参校验)。"""
expr = (expr or "").strip()
if not expr or not croniter.is_valid(expr):
raise ValueError(f"非法 cron 表达式: {expr!r}(需标准 5 段,如 '0 8 * * *')")
def _tzinfo(tz: str):
if ZoneInfo is None:
return timezone.utc
try:
return ZoneInfo(tz or "Asia/Shanghai")
except Exception:
return ZoneInfo("Asia/Shanghai")
def compute_next_run(cron: str, tz: str, after: Optional[datetime] = None) -> datetime:
"""按墙钟时区算下一个触发点,返回 UTC-aware datetime。
croniter 保留 base tzinfo base 折算到 job 的本地时区再算,
'0 8 * * *' 就是该时区的早上 8 ,而非 UTC 8 (§8.5 时区坑)
"""
tzinfo = _tzinfo(tz)
base = (after or datetime.now(timezone.utc)).astimezone(tzinfo)
nxt = croniter(cron, base).get_next(datetime)
return nxt.astimezone(timezone.utc)
def _snapshot(job: ScheduledJob) -> dict[str, Any]:
"""把 ORM 行拍成普通 dict,脱离 session 给编排层用(避免跨线程 lazy-load)。"""
return {
"job_id": job.job_id,
"user_id": job.user_id,
"name": job.name,
"prompt": job.prompt,
"cron": job.cron,
"tz": job.tz,
"mode": job.mode,
"bound_task_id": job.bound_task_id,
"skill": job.skill or "",
"model_profile": job.model_profile or "",
"notify": job.notify,
"timeout_seconds": job.timeout_seconds or 0,
}
def claim_due_jobs(now: Optional[datetime] = None, limit: int = CLAIM_LIMIT) -> list[dict[str, Any]]:
"""认领到点 job:一把事务 SELECT due + 推进 next_run_at,返回快照列表。
- 到点判据:enabled AND deleted_at IS NULL AND next_run_at <= now
- 过期(expires_at <= now): enabled=False 跳过,不返回(安全界)
- 其余:next_run_at 推到下一个 cron (防重复触发),返回快照交编排层去跑
"""
now = now or datetime.now(timezone.utc)
claimed: list[dict[str, Any]] = []
with session_scope() as s:
rows = s.execute(
select(ScheduledJob)
.where(
ScheduledJob.enabled.is_(True),
ScheduledJob.deleted_at.is_(None),
ScheduledJob.next_run_at <= now,
)
.order_by(ScheduledJob.next_run_at)
.limit(limit)
.with_for_update(skip_locked=True)
).scalars().all()
for job in rows:
if job.expires_at is not None and job.expires_at <= now:
job.enabled = False
job.last_status = "expired"
continue
claimed.append(_snapshot(job))
try:
job.next_run_at = compute_next_run(job.cron, job.tz, after=now)
except Exception:
# cron 莫名失效(理论上 create 时已校验)→ 停掉别让它卡死循环
job.enabled = False
job.last_status = "error"
job.last_error = "cron 计算失败,已自动停用"
return claimed
def record_result(
job_id: UUID,
*,
status: str,
task_id: Optional[UUID],
error: Optional[str] = None,
) -> None:
"""run 跑完(或 skip)后回写 last_*。ok 重置连续失败计数;error 累加,
到阈值自动停用"""
now = datetime.now(timezone.utc)
with session_scope() as s:
job = s.get(ScheduledJob, job_id)
if job is None:
return
job.last_run_at = now
job.last_status = status
job.last_error = error
if task_id is not None:
job.last_task_id = task_id
if status == "ok":
job.run_count = (job.run_count or 0) + 1
job.consecutive_failures = 0
elif status == "error":
job.run_count = (job.run_count or 0) + 1
job.consecutive_failures = (job.consecutive_failures or 0) + 1
if job.consecutive_failures >= FAILURE_DISABLE_THRESHOLD:
job.enabled = False
job.last_error = (
f"连续失败 {job.consecutive_failures} 次,已自动停用。最后错误: {error}"
)
# status == "skipped"(persistent task 正忙)不动计数,下一 cron 点再来
def build_run_message(snapshot: dict[str, Any]) -> str:
"""把 job.prompt 包成一条带标记的用户消息喂进 agent。"""
when = datetime.now(_tzinfo(snapshot.get("tz") or "Asia/Shanghai")).strftime("%Y-%m-%d %H:%M")
name = snapshot.get("name") or "定时任务"
return (
f"[定时任务「{name}」自动触发 · {when}]\n\n"
f"{snapshot['prompt']}"
)
# ───────────── 第 3 层确定性兜底投递(notify) ─────────────
def _newest_artifact(working_dir: Path) -> Optional[Path]:
"""工作目录里最近修改的普通文件(跳过隐藏 / .preview 缓存)。"""
if not working_dir.is_dir():
return None
best: Optional[Path] = None
best_mtime = -1.0
for p in working_dir.rglob("*"):
if not p.is_file():
continue
if any(part.startswith(".") for part in p.relative_to(working_dir).parts):
continue
try:
m = p.stat().st_mtime
except OSError:
continue
if m > best_mtime:
best, best_mtime = p, m
return best
def deliver_notify(
notify: Optional[dict[str, Any]],
*,
job_name: str,
working_dir: Path,
tz: str,
) -> None:
"""job 配了 notify 就确定性补发(不靠 agent 记性)。目前仅 email 通道:
把工作目录最新产物当附件,套固定模板发无产物则发纯文本告知已执行
阻塞 IO(smtplib),由编排层放进 run_in_executor 失败抛异常,编排层吞掉记日志
"""
if not notify or notify.get("channel") != "email":
return
to = notify.get("to")
if not to:
return
from tools.send_email import send_email_smtp # 延迟导入,避免 core→tools 顶层环依赖
when = datetime.now(_tzinfo(tz)).strftime("%Y-%m-%d %H:%M")
artifact = _newest_artifact(working_dir)
if artifact is not None:
subject = f"[定时任务] {job_name} · {when}"
body = f"定时任务「{job_name}」已于 {when} 执行,产物见附件:{artifact.name}"
send_email_smtp(to, subject, body, [artifact])
else:
subject = f"[定时任务] {job_name} · {when}(无产物文件)"
body = f"定时任务「{job_name}」已于 {when} 执行,本次未产生文件产物。"
send_email_smtp(to, subject, body)
# ───────────── CRUD 服务层(对话工具 + REST 端点共用,DESIGN §8.5)─────────────
#
# tools/schedule.py(对话)与 web/app.py 的 /v1/schedules(前端只读+停用/删除)都调
# 这一层,避免两条创建路径逻辑漂移。函数内自管 session、返回可序列化 dict(脱离
# session,跨 web/工具线程安全);校验失败抛 JobError → 工具转 [Error] 行 / REST 转 4xx。
WEEKDAYS_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
class JobError(ValueError):
"""job 校验 / 找不到 —— 工具转 [Error],REST 转 400/404。"""
def describe_cron(cron: str, tz: str) -> str:
"""常见 cron → 人话(给前端/工具回显);不认的样式退回原 cron 串。"""
parts = (cron or "").split()
if len(parts) != 5:
return cron
mi, ho, dom, mon, dow = parts
hhmm = None
if mi.isdigit() and ho.isdigit():
hhmm = f"{int(ho):02d}:{int(mi):02d}"
if hhmm and dom == "*" and mon == "*" and dow == "*":
return f"每天 {hhmm}"
if hhmm and dom == "*" and mon == "*" and dow.isdigit():
wd = int(dow) % 7 # cron 0/7=周日;映射到 WEEKDAYS_CN(0=周一)
idx = 6 if wd == 0 else wd - 1
return f"{WEEKDAYS_CN[idx]} {hhmm}"
if hhmm and dom.isdigit() and mon == "*" and dow == "*":
return f"每月 {int(dom)}{hhmm}"
if mi.startswith("*/") and ho == "*" and dom == "*":
return f"{mi[2:]} 分钟"
if mi.isdigit() and ho.startswith("*/") and dom == "*":
return f"{ho[2:]} 小时(第 {int(mi)} 分)"
return cron
def job_to_dict(job: ScheduledJob) -> dict[str, Any]:
"""ORM 行 → API/工具用的可序列化快照。"""
def _iso(dt: Optional[datetime]) -> Optional[str]:
if dt is None:
return None
return (dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)).isoformat()
return {
"job_id": str(job.job_id),
"short_id": str(job.job_id)[:8],
"name": job.name,
"prompt": job.prompt,
"cron": job.cron,
"schedule_desc": describe_cron(job.cron, job.tz),
"tz": job.tz,
"mode": job.mode,
"skill": job.skill or "",
"model_profile": job.model_profile or "",
"notify": job.notify,
"enabled": job.enabled,
"timeout_seconds": job.timeout_seconds or 0,
"next_run_at": _iso(job.next_run_at),
"last_run_at": _iso(job.last_run_at),
"last_status": job.last_status,
"last_error": job.last_error,
"last_task_id": str(job.last_task_id) if job.last_task_id else None,
"consecutive_failures": job.consecutive_failures or 0,
"run_count": job.run_count or 0,
"expires_at": _iso(job.expires_at),
"created_at": _iso(job.created_at),
}
def _validate_tz(tz: str) -> str:
tz = (tz or "Asia/Shanghai").strip() or "Asia/Shanghai"
if ZoneInfo is not None:
try:
ZoneInfo(tz)
except Exception:
raise JobError(f"未知时区: {tz!r}(用 IANA 名,如 'Asia/Shanghai')")
return tz
def list_jobs(user_id: UUID) -> list[dict[str, Any]]:
with session_scope() as s:
rows = s.execute(
select(ScheduledJob)
.where(ScheduledJob.user_id == user_id, ScheduledJob.deleted_at.is_(None))
.order_by(ScheduledJob.created_at.desc())
).scalars().all()
return [job_to_dict(j) for j in rows]
def create_job(
user_id: UUID,
*,
name: str,
prompt: str,
cron: str,
tz: str = "Asia/Shanghai",
mode: str = "isolated",
skill: str = "",
notify: Optional[dict[str, Any]] = None,
model_profile: str = "",
timeout_seconds: int = 0,
) -> dict[str, Any]:
name = (name or "").strip()
prompt = (prompt or "").strip()
cron = (cron or "").strip()
mode = (mode or "isolated").strip().lower()
if not name:
raise JobError("name 不能为空")
if not prompt:
raise JobError("prompt 不能为空")
if mode not in _MODES:
raise JobError(f"mode 必须是 {_MODES} 之一")
validate_cron(cron)
tz = _validate_tz(tz)
next_run = compute_next_run(cron, tz)
job = ScheduledJob(
job_id=uuid4(), user_id=user_id, name=name, prompt=prompt, cron=cron, tz=tz,
mode=mode, skill=(skill or "").strip(), notify=notify,
model_profile=(model_profile or "").strip(),
timeout_seconds=int(timeout_seconds or 0), next_run_at=next_run,
)
with session_scope() as s:
s.add(job)
s.flush()
return job_to_dict(job)
def _resolve(s, user_id: UUID, id_str: str) -> ScheduledJob:
"""按完整 UUID 或短 id 前缀定位当前用户的 job;0 或多匹配抛 JobError。"""
jid = (id_str or "").strip()
if not jid:
raise JobError("job_id 不能为空")
rows = s.execute(
select(ScheduledJob).where(
ScheduledJob.user_id == user_id, ScheduledJob.deleted_at.is_(None)
)
).scalars().all()
matches = [j for j in rows if str(j.job_id) == jid or str(j.job_id).startswith(jid)]
if not matches:
raise JobError(f"没找到 id 以 {jid!r} 开头的定时任务")
if len(matches) > 1:
ids = ", ".join(str(j.job_id)[:8] for j in matches)
raise JobError(f"id 前缀 {jid!r} 匹配到多个({ids}),请用更长的 id")
return matches[0]
_EDITABLE = {"name", "prompt", "cron", "tz", "mode", "skill", "notify", "model_profile",
"timeout_seconds", "enabled"}
def update_job(user_id: UUID, id_str: str, **fields: Any) -> dict[str, Any]:
"""改 job 的任意可编辑字段;改了 cron/tz 则重算 next_run_at。"""
fields = {k: v for k, v in fields.items() if k in _EDITABLE and v is not None}
if not fields:
raise JobError("没有可更新的字段")
if "mode" in fields:
fields["mode"] = str(fields["mode"]).strip().lower()
if fields["mode"] not in _MODES:
raise JobError(f"mode 必须是 {_MODES} 之一")
if "cron" in fields:
validate_cron(str(fields["cron"]).strip())
fields["cron"] = str(fields["cron"]).strip()
if "tz" in fields:
fields["tz"] = _validate_tz(str(fields["tz"]))
with session_scope() as s:
job = _resolve(s, user_id, id_str)
for k, v in fields.items():
setattr(job, k, v)
if "cron" in fields or "tz" in fields:
job.next_run_at = compute_next_run(job.cron, job.tz)
# 重新启用 / 改了排程 → 清零连续失败计数,给它干净的重新开始
if fields.get("enabled") is True or "cron" in fields:
job.consecutive_failures = 0
s.flush()
return job_to_dict(job)
def set_enabled(user_id: UUID, id_str: str, enabled: bool) -> dict[str, Any]:
return update_job(user_id, id_str, enabled=bool(enabled))
def cancel_job(user_id: UUID, id_str: str) -> dict[str, Any]:
"""软删(deleted_at + enabled=False)。"""
with session_scope() as s:
job = _resolve(s, user_id, id_str)
job.deleted_at = datetime.now(timezone.utc)
job.enabled = False
s.flush()
return job_to_dict(job)

View File

@ -21,6 +21,7 @@ from uuid import UUID, uuid4
from sqlalchemy import (
BigInteger,
Boolean,
DateTime,
ForeignKey,
Integer,
@ -171,3 +172,56 @@ class UserDiskUsage(Base):
)
class ScheduledJob(Base):
"""定时任务(0011,DESIGN §8.5)。
一行 = 一个"到点把 prompt 喂进 agent 主管线"的计划本体 = cron+tz(何时)
+ prompt(做什么)+ mode(跑在哪);"发邮件"不是字段, agent prompt
send_email 的动作 notify(可空 JSONB)"必达某邮箱"留确定性兜底
守护循环(web/app.py lifespan `_scheduler_loop`,仿 _disk_scanner) ~30s
`enabled AND deleted_at IS NULL AND next_run_at<=now()`,命中即复用 _run_agent_bg
run,跑完回写 last_* + croniter next_run_atmode:
- isolated(默认):每次新建临时 task,只带本 job prompt,不继承历史 token
- persistent:绑定 bound_task_id 常驻 task,追加消息有跨天连续性
"""
__tablename__ = "scheduled_jobs"
job_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4)
user_id: Mapped[UUID] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(Text, nullable=False)
prompt: Mapped[str] = mapped_column(Text, nullable=False)
cron: Mapped[str] = mapped_column(Text, nullable=False) # 标准 5 段 cron
tz: Mapped[str] = mapped_column(Text, nullable=False, server_default="Asia/Shanghai")
mode: Mapped[str] = mapped_column(Text, nullable=False, server_default="isolated") # isolated|persistent
# persistent 模式绑定的常驻 task;task 软删/物理删后 SET NULL(下次触发当 isolated 兜底)
bound_task_id: Mapped[Optional[UUID]] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("tasks.task_id", ondelete="SET NULL"), nullable=True
)
skill: Mapped[str] = mapped_column(Text, nullable=False, server_default="") # 可选预载 skill
model_profile: Mapped[str] = mapped_column(Text, nullable=False, server_default="") # 可选模型覆盖
# 第 3 层可靠投递:{"channel":"email","to":"a@b.com"};NULL=不兜底(走 prompt 驱动/线程未读)
notify: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONB, nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true")
timeout_seconds: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") # 0=不限
next_run_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
last_run_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
last_status: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # ok|error|skipped
last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
last_task_id: Mapped[Optional[UUID]] = mapped_column(PG_UUID(as_uuid=True), nullable=True)
consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0")
run_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0")
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
deleted_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)

View File

@ -0,0 +1,70 @@
"""scheduled_jobs 表(定时任务,DESIGN §8.5).
Revision ID: 0011
Revises: 0010
Create Date: 2026-06-18
新增独立表 scheduled_jobs 不碰现有 schema(公测兼容)一行 = 一个"到点把
prompt 喂进 agent 主管线"的计划。守护循环(web/app.py lifespan)按 (enabled,
next_run_at) 索引扫到点 job 触发 DESIGN §8.5 / core/storage/models.py
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID
revision: str = "0011"
down_revision: Union[str, None] = "0010"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"scheduled_jobs",
sa.Column("job_id", PG_UUID(as_uuid=True), primary_key=True),
sa.Column(
"user_id", PG_UUID(as_uuid=True),
sa.ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False,
),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("prompt", sa.Text(), nullable=False),
sa.Column("cron", sa.Text(), nullable=False),
sa.Column("tz", sa.Text(), nullable=False, server_default="Asia/Shanghai"),
sa.Column("mode", sa.Text(), nullable=False, server_default="isolated"),
sa.Column(
"bound_task_id", PG_UUID(as_uuid=True),
sa.ForeignKey("tasks.task_id", ondelete="SET NULL"), nullable=True,
),
sa.Column("skill", sa.Text(), nullable=False, server_default=""),
sa.Column("model_profile", sa.Text(), nullable=False, server_default=""),
sa.Column("notify", JSONB(), nullable=True),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("timeout_seconds", sa.Integer(), nullable=False, server_default="0"),
sa.Column("next_run_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_run_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_status", sa.Text(), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("last_task_id", PG_UUID(as_uuid=True), nullable=True),
sa.Column("consecutive_failures", sa.Integer(), nullable=False, server_default="0"),
sa.Column("run_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
)
# 守护循环 due 扫描热路径:WHERE enabled AND deleted_at IS NULL AND next_run_at<=now()
op.create_index(
"ix_scheduled_jobs_due", "scheduled_jobs", ["enabled", "next_run_at"],
)
# 用户列出自己的 job
op.create_index(
"ix_scheduled_jobs_user", "scheduled_jobs", ["user_id"],
)
def downgrade() -> None:
op.drop_index("ix_scheduled_jobs_user", table_name="scheduled_jobs")
op.drop_index("ix_scheduled_jobs_due", table_name="scheduled_jobs")
op.drop_table("scheduled_jobs")

View File

@ -15,6 +15,9 @@ markitdown[pdf,docx,pptx,xlsx]>=0.0.1
httpx>=0.27.0
html2text>=2024.0
# 定时任务(§8.5 scheduled_jobs):cron 串 → next_run_at 计算,正确处理 dom/dow OR 语义 + 时区
croniter>=2.0
# §7 B 阶段: Storage 落 PG
sqlalchemy>=2.0.0
psycopg[binary]>=3.1.0

156
scripts/smoke_scheduler.py Normal file
View File

@ -0,0 +1,156 @@
"""Smoke: 定时任务守护循环端到端(DESIGN §8.5)。
跑法(**需先在另一个终端起 web 服务** `.venv/Scripts/python.exe main.py web`):
.venv/Scripts/python.exe scripts/smoke_scheduler.py [--email a@b.com]
干什么:
1. 给某用户(默认 DB 第一个 / --email 指定)插一条 isolated 定时任务,
prompt "回一句早安、不调工具",并把 next_run_at 改成现在 让守护循环下一 tick 就认领
2. 轮询 scheduled_jobs.last_status 直到翻成 ok/error/skipped(超时 180s)
3. ok 则打印它新建的 task_id + agent 实际回复片段,证明全链路(认领 task
_run_agent_bgLLM回写 run_statusrecord_result)走通
4. 收尾软删该 job(留下 task 供查看)
**会真的发起一次 LLM 调用**(一句短回复,费用可忽略)不测邮件 notify 投递另行验
"""
from __future__ import annotations
import os
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from uuid import UUID
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
# Windows 控制台默认 GBK,打印中文会乱码 → 强制 stdout UTF-8(只影响本脚本打印)
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
except Exception:
pass
# 读 .env
env_file = ROOT / ".env"
if env_file.exists():
for line in env_file.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
os.environ.setdefault(k.strip(), v.strip())
from sqlalchemy import select, update
from core import scheduler
from core.storage import session_scope
from core.storage.models import Message, ScheduledJob, Task, User
POLL_TIMEOUT = 180 # 秒
POLL_INTERVAL = 3
PROMPT = "请直接回复一句『早安,今天也加油!』。不要调用任何工具,不要创建文件,不要做其它事。"
def _pick_user(email: str | None) -> UUID | None:
with session_scope() as s:
if email:
return s.execute(select(User.user_id).where(User.email == email)).scalar_one_or_none()
return s.execute(select(User.user_id).order_by(User.created_at).limit(1)).scalar_one_or_none()
def _last_assistant_text(task_id: UUID) -> str:
"""取该 task 最后一条 assistant 文本(payload JSONB)。"""
with session_scope() as s:
rows = s.execute(
select(Message.payload).where(Message.task_id == task_id)
.order_by(Message.idx.desc()).limit(20)
).scalars().all()
for payload in rows:
if not isinstance(payload, dict):
continue
if payload.get("role") != "assistant":
continue
c = payload.get("content")
if isinstance(c, str) and c.strip():
return c.strip()
if isinstance(c, list): # 富内容块
for blk in c:
if isinstance(blk, dict) and isinstance(blk.get("text"), str) and blk["text"].strip():
return blk["text"].strip()
return "(未找到 assistant 文本)"
def main() -> int:
email = None
if "--email" in sys.argv:
i = sys.argv.index("--email")
email = sys.argv[i + 1] if i + 1 < len(sys.argv) else None
uid = _pick_user(email)
if uid is None:
print("[FAIL] DB 里没有用户(或 --email 未匹配)。先 main.py user add。")
return 1
print(f"[..] 用户 {str(uid)[:8]} prompt={PROMPT[:24]}...")
# 1) 建 job(cron 随便给个合法值,马上覆盖 next_run_at 为现在)
job = scheduler.create_job(
uid, name="[smoke] 早安测试", prompt=PROMPT, cron="*/5 * * * *", mode="isolated",
)
jid = UUID(job["job_id"])
with session_scope() as s:
s.execute(update(ScheduledJob).where(ScheduledJob.job_id == jid)
.values(next_run_at=datetime.now(timezone.utc)))
print(f"[ok] 已插入 job {job['short_id']},next_run 置为现在,等守护循环认领...")
print(" (若卡住不动 → 确认 web 服务在跑、ZCBOT_DISABLE_SCHEDULER 未设、tick 已过)")
# 2) 轮询 last_status
deadline = time.time() + POLL_TIMEOUT
status = None
last_task_id = None
last_error = None
while time.time() < deadline:
time.sleep(POLL_INTERVAL)
with session_scope() as s:
row = s.execute(
select(ScheduledJob.last_status, ScheduledJob.last_task_id,
ScheduledJob.last_error, ScheduledJob.next_run_at)
.where(ScheduledJob.job_id == jid)
).first()
if row is None:
print("[FAIL] job 不见了(被并发删?)")
return 1
status, last_task_id, last_error = row.last_status, row.last_task_id, row.last_error
waited = int(time.time() - (deadline - POLL_TIMEOUT))
print(f" [{waited:>3}s] last_status={status or '(待触发)'}")
if status in ("ok", "error", "skipped"):
break
# 3) 结果
print("-" * 50)
if status == "ok":
print(f"[PASS] 守护循环已触发并成功。task={str(last_task_id)[:8] if last_task_id else '?'}")
if last_task_id:
print(f" agent 回复: {_last_assistant_text(last_task_id)[:120]}")
rc = 0
elif status == "error":
print(f"[FAIL] 触发了但 run 报错: {last_error}")
rc = 1
elif status == "skipped":
print(f"[WARN] 被跳过(目标 task 正忙?): {last_error}")
rc = 1
else:
print(f"[FAIL] {POLL_TIMEOUT}s 内未触发(last_status 仍为空)。web 服务/调度是否在跑?")
rc = 1
# 4) 收尾:软删 job(留 task)
try:
scheduler.cancel_job(uid, str(jid))
print(f"[..] 已清理 smoke job {job['short_id']}(task 保留可查看)")
except Exception as e:
print(f"[..] 清理 job 失败(可手动删): {e}")
return rc
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,114 +1,108 @@
---
name: brief
description: 生成科研方向简报(research direction briefing / 文献计量趋势型简报)。给定一个研究方向 + 时间窗,用三路真实数据(documents 内部库取全文 / research 取近期 DOI 元数据 / web 取政策·会议·标准动向),产出一份热点聚类 + 新兴方法 + 关键进展 + 研究空白 + 产业政策动向的可读简报,每条论断可溯源、不编造引文。当用户要"简报 / 方向简报 / 研究动态 / 趋势报告 / 调研快报 / 某方向近期进展 / 文献综述快讯 / 跟踪某领域最新研究"时使用。
description: 生成科研方向简报(research direction briefing / 重要文献速览)。给定一个研究方向 + 时间窗,从各大相关期刊(Elsevier 数据库优先)挑选近期重要论文,产出一份「重要论文列表 + 内容总结」的可读简报:先列清单(每篇带标题/作者/期刊/年月/DOI + 一段简介或摘要概述),再对这批论文做客观归纳。可溯源、不编造引文,**只描述不给建议**。当用户要"简报 / 方向简报 / 最新文献 / 重要论文列表 / 研究动态 / 某方向近期重要论文 / 跟踪某领域最新研究"时使用。
---
# 科研方向简报
# 科研方向简报(重要文献速览)
把"某研究方向最近发生了什么"变成一份**可读、可溯源、有判断**的简报。**先定题对齐 → 三路检索取数 → 趋势分析 → 逐段起草 → 引文核验渲染** —— 不要一口气出全文,定题和分析阶段先和用户对齐方向与边界
把"某方向近期发了哪些重要论文、都在讲什么"做成一份**可读、可溯源、客观**的简报。两段式:**先一份重要期刊论文列表(各大相关期刊、Elsevier 数据库优先;每篇带一段简介/摘要概述),再对这批论文做内容总结**
简报 ≠ 综述论文(paper review):综述要全面、深、给定论;简报要**快、准、有取舍**——"重要性优先于完整性",帮决策者 / 课题组 520 分钟掌握一个方向近期态势。
> **只描述、不给建议。** 简报呈现"发了什么、讲了什么",不给"本院应当……/可切入……/建议……"。判断留给读者。
>
> **"重要"怎么挑**:来自主流期刊(Elsevier 旗舰刊优先)、方向上居中而非边缘、有实质发现。近期论文引用尚少,故主要看**期刊层级 + 主题相关性 + 发现的分量**,不是单纯按引用数。控量靠"重要性 + 时新",不靠主观褒贬。
进度展示建议:用 `task_progress` 标记「定题对齐 / 三路检索 / 趋势分析 / 逐段起草 / 引文核验 / 渲染」关键阶段。
简报 ≠ 综述论文(paper review):综述要全面、深、给定论;简报要**快、准、客观**——520 分钟掌握一个方向近期发了哪些重要论文、各讲了什么
## 边界(先划清,免得和别的 skill 撞)
## 边界(免得和别的 skill 撞)
| 与谁区分 | 边界 |
|---|---|
| vs `research`/`documents` | 它们**只取文献**(候选清单 / 全文);brief 是消费方,把取回的文献**组织成有判断的趋势简报**,引文核验接到它们头上 |
| vs `paper`(review 类型) | paper-review 写**可投稿的综述论文**(IMRaD/主题式、几十页、定论);brief 出**轻量趋势简报**(几页、有取舍、面向决策),不投稿 |
| vs `analyze` | analyze 把**模糊科学问题**拆成子问题 + 路线图(不查文献);brief 围绕**一个已定方向**摸近期态势(重检索)。两者可互为上下游(先拆问题再摸态势,或先摸态势再拆) |
| vs `proposal` | proposal 写**本子**(立项依据);brief 只摸方向近期态势,不写立项依据。要立项 → 把简报喂给 proposal |
- vs `research`/`documents`:它们**只取文献**;brief 把取回的论文**组织成可读列表 + 客观总结**。
- vs `paper`(review):paper 写**可投稿综述**(几十页、定论);brief 出**轻量速览**(几页、客观、不给判断)。
- vs `analyze`:analyze 拆**科学问题**;brief 围绕**已定方向**列近期重要论文。
- vs `proposal`:proposal 写**本子、给建议**;brief 只列论文 + 客观总结。要"对本院的建议" → 转 proposal。
**何时不用**:只要文献清单 / DOI / PDF → research/documents;要写可投稿综述 → paper(review);要拆科学问题 → analyze;要写本子 → proposal。
## 资源(路径相对 `load_skill` 头里的 `dir=<绝对路径>`)
## 资源
- `references/journals.md` —— 各建材子领域主流期刊清单(Elsevier 数据库优先)+ 精确 `publication_name` + 0 命中降级法。**阶段二必读**。
- `scripts/render_docx.py` —— md→docx,商务红主题 + 列表 `[n]` 锚点 + 正文 `[n]`/`[Wn]` 引文上标回链 + DOI/URL 可点超链接 + 化学式下标白名单(CO2/C3S/Na2O...,不误伤 LC3/C595/Ca2+)。用 `.venv/Scripts/python.exe` 跑。
下面所有路径相对 **`<skill_dir>`** —— `load_skill` 返回头里的 `[skill=brief, dir=<绝对路径>]`,用这个绝对路径拼脚本/资源,不要假设 cwd
产物默认 `.md`;要 docx 用 render_docx.py;要 deck 转 `ppt` skill
**先读(always)**:
- `<skill_dir>/templates/brief_outline.md` —— 简报骨架 + 按深度(快报/标准/深度)的字数预算与簇数/引文数
- `<skill_dir>/references/search_strategy.md` —— 三路检索分工(documents/research/web)+ 跨源去重 + 中文方向→英文术语转译
## 阶段一:定题对齐(BLOCKING)
**阶段五必读**:
- `<skill_dir>/references/citation_verify.md` —— 引文核验协议(存在性 / 三角印证 / 支撑度,复用 paper 思路,接 documents/research/web)
写一份 task 级 spec(命名见 system prompt《task 级「宪法」文件命名约定》),填下面字段,**有歧义先反问、不替用户拍板**,写完复述确认再往下:
**模板**:
- `templates/spec.md` —— 七条定题对齐固定字段(复制到 task 级 spec 文件)
**脚本**(`.venv/Scripts/python.exe <skill_dir>/scripts/...`):
- `scripts/quality_check.py` —— `--depth {flash,standard,deep}`,结构完整性 / 占位符泄漏 / 过度宣称 / 无源句式 / 引文交叉核对(orphan/uncited/编号连续)
- `scripts/render_docx.py` —— md→docx,**简报专属版式**:商务红主题(`--no-color` 关)+ 正文 `[n]`/`[Wn]` 引文上标并锚到文末 + DOI/URL 可点击超链接 + 化学式下标白名单(CO2/C3S/Na2O...,不误伤 LC3/C595/Ca2+)+ TL;DR / 判断 行做底纹 callout
**产物与渲染**:简报默认产物是 `.md`。要 docx → 本 skill 自带 `render_docx.py`(见上);要做成汇报 deck → 转 `ppt` skill。
## 阶段一:定题对齐(写 spec)BLOCKING
产物:**task 级 spec 文件**,简报的"宪法",后续每阶段前重读。命名按 system prompt 的《task 级「宪法」文件命名约定》:
<task_dir>/<today>-<task_short_id>-<task_name>.spec.md
复制 `templates/spec.md` 填七条,**有歧义先反问,不要替用户拍板**:
1. **方向 + 边界**:具体到子方向(不是"水泥"而是"低碳水泥 SCM");明确**纳入/排除**(如"只看辅助胶凝材料替代,不含碱激发")
2. **时间窗**:默认**近 3 年**;用户说"最新/近期"→ 近 1 年;"这两年"→ 近 2 年。换算成 `year_gte`(今年是 system prompt 给的当前年)
3. **受众**:院领导汇报 / 课题组内部 / 立项前调研 / 对外交流 —— 决定语气与详略
4. **深度**:`flash` 快报(12 页)/ `standard` 标准(46 页)/ `deep` 深度(8+ 页,含机构-地理计量)—— 见 brief_outline.md 预算
5. **数据源开关**:documents(内部库,材料类首选)/ research(补 DOI 与近期元数据)/ web(政策·会议·标准·产业动向)—— 默认三路并用,用户可关
1. **方向 + 边界**:具体到子方向(不是"水泥"而是"低碳水泥 SCM");明确纳入/排除
2. **时间窗**:默认**近 1 年**(简报是"最新文献",窗口宜短);换算成 `year_gte`(今年见 system prompt)
3. **期刊范围**:默认按方向所属子领域取 `journals.md` 主流期刊(Elsevier 优先);用户可增删指定刊
4. **深度 / 篇数**:`flash` 1020 篇 / `standard`(默认)2040 篇 / `deep` 4080 篇
5. **数据源(默认三路并用)**:research + documents **都是获取文献的主力**(research 按期刊精确取最新 Elsevier 论文 + DOI;documents 取内部材料库全文),web search 取政策·标准·产业动向(**单列、不混进论文总结**)。某一路不可用时降级用其余两路,不整体放弃
6. **语言**:中文(默认)/ 英文
7. **特殊关注点**:用户特别想知道的(如"重点看 CCUS 与水泥结合""谁在做工业固废路线")—— 写进 spec,分析阶段重点回应
7. **特殊关注点**(可选):想重点呈现的材料体系 / 方法(仍只描述,不给建议)
写完把 spec 七条**复述给用户确认**,认可后进阶段二。
## 阶段二:三路取数(research + documents 取文献 / web 取动向)
## 阶段二:三路检索取数
**先读 `references/journals.md`**。**中文方向先转专业英文术语**(库主语料英文):低碳水泥→low-carbon cement / clinker substitution;SCM→supplementary cementitious materials / fly ash / GGBFS / calcined clay;LC3→limestone calcined clay cement;碳化养护→CO2 curing / carbonation。缩写与全称都试。
**先读 `references/search_strategy.md`**(三路分工 + 中→英术语 + 去重)。流程:
**research(逐刊取最新 Elsevier 论文 + DOI)** —— `run_python`:
1. **中文方向 → 英文检索词组**:库里主语料是英文,`SCM` 这类要展开(supplementary cementitious materials / fly ash / GGBFS / calcined clay / limestone calcined clay cement / LC3 ...)
2. **documents**(材料类首选):语义检索,中英 query 都行;胶凝材料库(classification_id=1)。取 `md_content` 备引文核验
3. **research**:`search(keyword=英文, year_gte=<>, limit=...)` 拉近期候选 + DOI;`has_pdf`/`is_oa` 按需 filter。看 list 自带 abstract 判切题
4. **web**(可选):政策(双碳、水泥行业碳配额)、标准(新国标/团标)、行业会议、企业中试/产线 —— web 的东西**单独标"产业/政策动向",不混进学术引文计数**
5. 汇成**证据表** `<task_dir>/evidence.md`(仿 lit_matrix):一行一条 = 来源 | 标题 | 年 | 一句话 takeaway | 归属簇 | 引文可用性(documents全文/DOI/web)
```python
from skills.research.paper import search
# 逐刊拉最新:publication_name 精确匹配 + 时间窗;list 自带 abstract,看前 200-400 字判切题与分量
for jname in ["Cement and Concrete Research", "Cement and Concrete Composites",
"Construction and Building Materials", "Journal of Cleaner Production"]:
papers = search(publication_name=jname, year_gte=2025, limit=50)
# 按 publication_date 倒序取最新若干;留重要的(主题居中 + 有实质发现),弃边缘
```
某刊精确名 0 命中 → 换 `keyword=<方向英文术语>` 再搜,从返回里挑 `publication_name` 命中目标刊的;仍空记"该刊本窗口库内无收录"。
收 2080 条(按深度),**不求穷尽**,够支撑各簇即可。命中 0 条先换同义词/放宽年份,3 次仍空如实告诉用户库未覆盖,**不脑补文献**。
**documents(内部材料库取全文,材料类首选)** —— host-side tool `document_search`,中英 query 都行(后端跨语言语义检索);胶凝材料库 `classification_id=1`。取 `md_content` 既做候选也供引文核验抓锚点最顺
## 阶段三:趋势分析(和用户对齐结构)BLOCKING-lite
**web search(取动向)** —— 政策(双碳/碳配额)、标准(新国标/团标)、行业会议、企业产线中试。**单列"其他动向",不混进论文列表与总结**。
把证据表**聚成 37 个热点簇**(按深度),给用户看簇划分 + 每簇代表文献,认可后再起草。每簇判断:
- 汇成证据表 `<task_dir>/evidence.md`:期刊 | 标题 | 第一作者(机构)| 年-月 | 摘要概述 | DOI | 来源(research/documents/web)。
- 跨源去重:同 DOI 一条(documents 全文优先,DOI 记自 research);web 不与论文去重、单列。
- **这个簇在做什么 / 解决什么问题**(一句话主题句,不是关键词堆砌)
- **代表性进展**(24 篇,带真实引文)
- **新兴方法 / 技术**(出现的新表征、新建模、新工艺)
- **争议 / 分歧 / 未解**(哪里还没共识)
> **库时效(必交代)**:research(OpenAlex)约 3 个月索引滞后,"最新"= 库内最新。窗口内 0 篇 → 如实告知库未收录该窗口,可用 web 补更近的非论文动向,**不脑补文献**。
横向再扫:**研究空白**(大家都没做的)、**机构-地理格局**(deep 才做,元数据够时:谁在领跑、中国占比)、**产业/政策动向**(来自 web)。
## 阶段三:列清单 + 内容总结(写 `<task_dir>/sections/*.md`)
> 取舍纪律:一个方向近期可能上百篇,简报只留**改变判断的**。重复验证性工作合并成一句"多篇验证了 X";边缘工作直接不收。宁缺毋滥。
骨架四段(`flash` 可省 `00`/`03`):
## 阶段四:逐段起草
- **`00_overview.md` 概览**:方向 + 纳入/排除边界 + 时间窗 + 覆盖了哪些期刊 + 收录多少篇。无引文。
- **`01_papers.md` 重要论文列表(主体)**:按期刊 `###` 分组,每篇一条,行首 `[n]`(渲染时此段作参考锚点、`[n]` 带 DOI 超链接):
```
### Cement and Concrete Research(Elsevier)
`brief_outline.md` 骨架写 `<task_dir>/sections/*.md`,**每段一个论断 + 证据**:
[1] <标题>. <第一作者> et al., Cement and Concrete Research, 2026-03. DOI: 10.1016/j.cemconres.2026.xxxxxx
- TL;DR 要点(5 行内,先给结论)→ 方向概览与边界 → 研究热点聚类(各簇)→ 新兴方法 → 近期标志性进展 → 研究空白与争议 → 产业/政策/标准动向(web,可选)→ 参考文献
- 起草时引文用占位 `[CITE-<keyword>]`,阶段五核验后映射真实条目并编号
- 数字 / 定量结论必须挂引文;"据报道""有研究表明"这种无源句式禁止
<简介/摘要概述:24 ,讲研究对象方法/表征主要发现与关键数据 基于 abstract 或全文,不夸张不评判>
```
`publication_date` 倒序,最新在前。每篇都要有摘要概述,不能只留标题。
- **`02_summary.md` 内容总结**:对这批论文**客观归纳**——主题分布、常涉材料体系、常用方法/表征、共同关注点;引具体论文挂 `[n]` 上标(回链到 01)。**只描述"这批论文在讲什么",不给"应当/建议/可切入"**。
- **`03_web.md` 其他动向(仅 spec 开 web 时)**:政策/标准/会议/产业,`[W1]` 标来源 + 日期,单列。
## 阶段五:引文核验(渲染前必跑)
数字/定量结论必须挂 `[n]`;"据报道""有研究表明"这类无源句式禁止。
**先读 `references/citation_verify.md`**,对所有引文逐条核验:存在性(两库/web 命中)→ 三角印证(关键论断 ≥2 源)→ 支撑度(抓原文锚点,partial 就改论断迁就证据)。台账写 `<task_dir>/CITATIONS.md`
## 阶段四:引文核验(渲染前必跑)
**铁律(同 paper)**:status 非 verified 的引文不得进最终稿;不为凑数编造文献;支撑不足改论断不改证据;两库/web 都查不到如实告诉用户。
论文直接来自 research/documents,DOI 以**库返回字段为准**(不沿用记忆、不编造)。逐条核验:
## 阶段六:渲染验收
1. **存在性**:`search()`/`get_paper(doi)` 或 documents 命中确认真实存在;查不到 → 标 `[未核实]`,告诉用户"找不到来源,请提供 DOI 或删去",**不编造**。
2. **支撑度**:摘要概述 / `[n]` 论断要和 abstract(或全文)一致;不一致 → **改概述迁就证据**,不是改证据。
3. **web**:记原始 URL + 访问日期 + 发布机构,标"截至 <日期>";不当学术结论引。
1. `quality_check.py --depth <flash|standard|deep>` 跑 sections:结构 / 簇数预算 / 占位符 / 过度宣称 / 无源句式 / 引文交叉核对
2. 用户要 docx → `.venv/Scripts/python.exe <skill_dir>/scripts/render_docx.py <sections_dir> -o <方向>-简报.docx`(商务红 + 引文上标超链接 + 化学式下标;`--no-color` 出黑白);要 deck → 转 ppt skill
4. 交付时一句话说清:覆盖了哪几路源、收了多少条证据、哪些被取舍、哪些点是单源待复核
台账可写 `<task_dir>/CITATIONS.md`。**铁律**:不为凑数编造文献;支撑不足改论断不改证据;查不到如实说。
## 阶段五:渲染验收
- 用户要 docx → `.venv/Scripts/python.exe <dir>/scripts/render_docx.py <sections_dir> -o <方向>-简报.docx`(`--no-color` 出黑白);要 deck → 转 ppt。
- 渲染前自查:`[CITE-]`/`<TODO>` 占位是否清干净、正文 `[n]` 与列表 `[n]` 是否对得上(无 orphan)、有没有混进"建议/启示/本院应当"措辞。
- 交付一句话说清:覆盖了哪些期刊、收了多少篇、时间窗、哪些刊本窗口库内无收录。
## 反模式
- ❌ 跳过定题直接检索 —— 方向边界没定,检索词发散,收一堆不相关
- ❌ 把命中的文献**全部**堆进简报 —— 简报是取舍的艺术,不是清单转储
- ❌ web 抓的资讯当学术结论引 —— web 动向单列,学术论断要文献支撑
- ❌ 编造 DOI / "据报道"无源句 —— 走 citation_verify,查不到就如实说
- ❌ 用中文 keyword 搜英文库 —— 先转专业英文术语(见 search_strategy.md)
- ❌ **给建议/启示/"本院应当"** —— 只描述论文讲了什么,判断留给读者
- ❌ 列表只留标题、没摘要概述 —— 每篇都要 24 句简介
- ❌ 跳过定题直接检索 / 用中文 keyword 搜英文库 / 期刊名不精确 —— 先定题、转英文术语、用精确 `publication_name`
- ❌ web 资讯混进论文列表/总结 —— 单列"其他动向"
- ❌ 编造 DOI / "据报道"无源句 —— 查不到就如实说

View File

@ -1,60 +0,0 @@
# 引文核验协议(简报版)
`paper` skill 的引文三角核验**同一套思路**,这里按简报场景收口。简报虽轻,但**编造引文 / 引而不实**同样致命——决策者会照着简报判断方向,假信息代价更高。
> 协议不是脚本——你(模型)拿 host-side tool 逐条执行。quality_check.py 只做机械的 orphan/uncited/编号核对,真伪与支撑度靠本协议。
## 何时跑
阶段四逐段起草后、阶段六渲染前,对所有 `[CITE-xx]` 占位逐条核验。用户自带的引文也要跑。
## 三层核验(逐条)
### 第 1 层 — 存在性
1. `documents` 语义检索 / `research``search()` / `get_paper(doi)` 确认文献真实存在
2. 命中 → 以**库里返回字段为准**记 DOI/作者/年/期刊,不沿用记忆
3. 两库都查不到 → 标 `[未核实]`,**不得编造**;告诉用户"这条找不到来源,请提供 DOI 或删去该论断"
### 第 2 层 — 三角印证
关键论断(趋势判断、定量结论、"标志性进展")至少 **2 个独立源**一致才稳:
- documents 命中 + research/DOI 一致 → 通过
- 仅单一来源 → 标"单源,谨慎",简报交付时点出"此点单源待复核"
- 来源字段冲突 → 以可验证 DOI 元数据为准
### 第 3 层 — 支撑度
文献存在但**不支撑你写的那句话**是最容易翻车的:
1. 抓 `md_content`(documents)/ `fetch_xml`/`fetch_pdf`(research)
2. 定位 ≤25 词原文锚点 + 段落位置
3. 三档:**support** 通过;**partial/需限定** → 改写论断迁就证据;**not-support/反向** → 删引用或换文献
4. 抓不到全文 → abstract 弱核验,标"仅摘要核验"
## web 来源的核验(简报特有)
web 资讯(政策/标准/产业)**不进学术引文三角**,但同样要可溯源:
- 记**原始 URL + 访问日期 + 发布机构**;优先官方源(政府/标委会/期刊/企业官网),而非二手转载
- 政策 / 标准类:能找到文号 / 标准号就记(如"GB/T xxxxx""国办发2025x号")
- web 信息标注"截至 <日期>",时效性内容明确边界——避免简报过期后误导
## 产出:核验台账 `CITATIONS.md`
```markdown
# 引文核验台账
- [1] <author> <year>, <journal> | exists:✓(documents+DOI) | triangulate:✓ | claim:support "<≤25词锚点>"(§x) | status: verified
- [2] <author> <year> | exists:✓ | claim:partial → 已把"大幅提升"改为"28d 提高约 15%" | status: verified-revised
- [W1] <机构> <标题>, <URL>, 访问 <日期> | 类型:政策动向 | status: web-sourced
- [3] <author> <year> | exists:✗ 两库未命中 | status: 待用户提供
```
## 铁律(同 paper)
- ❌ status 非 verified/verified-revised/用户确认的学术引文不得进最终稿
- ❌ 不为凑数编造"看起来合理"的文献
- ❌ web 资讯当学术结论引(单列动向段,标 URL+日期)
- ✅ 支撑不足**改论断迁就证据**,不是改证据迁就论断
- ✅ 两库/web 都查不到如实告诉用户,给"提供来源 / 删论断"两个选项

View File

@ -0,0 +1,58 @@
# 各建材子领域主流期刊清单(Elsevier 数据库优先)
逐刊取最新论文时用。**绝大多数是 Elsevier**(下表 `E` 标),少数主流非 Elsevier 刊也列上(标出版商),取数时 Elsevier 优先。
> **用法**:`search(publication_name="<下表精确名>", year_gte=<>, limit=50)`,按 `publication_date` 倒序取最新。
> **名字要精确**:OpenAlex 的期刊显示名就是下表这串,带副标题的(如 `Composites Part B: Engineering`)要带全。
> **0 命中降级**:精确名搜不到 → 换 `keyword=<期刊核心词>``keyword=<方向英文术语>` 搜,从返回里挑 `publication_name` 命中该刊的;仍空 → 记"该刊本窗口库内无收录",不脑补。
## 水泥 / 混凝土 / 胶凝材料(本院核心)
| 期刊 | 出版商 | 备注 |
|---|---|---|
| Cement and Concrete Research | E | 领域顶刊,机理与材料 |
| Cement and Concrete Composites | E | 复合胶凝、SCM、耐久 |
| Construction and Building Materials | E | 体量最大,工程材料广谱 |
| Cement | E | 较新 OA 刊,水泥专门 |
| Journal of Building Engineering | E | 建筑工程材料与结构 |
| Materials and Structures | Springer(RILEM) | 非 E 主流,RILEM 旗舰 |
| Cement, Concrete and Aggregates | ASTM | 非 E |
## 绿色 / 低碳 / 固废资源化
| 期刊 | 出版商 | 备注 |
|---|---|---|
| Journal of Cleaner Production | E | 低碳、生命周期、固废 |
| Resources, Conservation and Recycling | E | 工业固废资源化 |
| Journal of Environmental Management | E | 环境与固废处置 |
| Waste Management | E | 固废(矿渣/粉煤灰/赤泥)|
| Journal of CO2 Utilization | E | 碳化养护 / CCUS |
## 陶瓷 / 玻璃 / 耐火
| 期刊 | 出版商 | 备注 |
|---|---|---|
| Ceramics International | E | 陶瓷综合顶刊 |
| Journal of the European Ceramic Society | E | 陶瓷,欧洲旗舰 |
| Journal of Non-Crystalline Solids | E | 玻璃 / 非晶 |
| Journal of the American Ceramic Society | Wiley | 非 E,陶瓷顶刊(JACerS)|
| International Journal of Applied Glass Science | Wiley | 非 E,玻璃 |
## 复合材料 / 新型建材 / 通用材料
| 期刊 | 出版商 | 备注 |
|---|---|---|
| Composites Part B: Engineering | E | 复合材料(纤维增强等)|
| Composites Part A: Applied Science and Manufacturing | E | 复合材料 |
| Materials & Design | E | 材料设计广谱 |
| Journal of Materials Research and Technology | E | 材料制备表征 |
| Materials Today Communications | E | 材料快报 |
| Powder Technology | E | 粉体 / 颗粒 |
| Fuel | E | 燃煤灰渣相关 |
## 取数策略
- 按 spec 方向所属子领域,从上面对应表里取 **38 本主流刊**(Elsevier 优先),逐刊拉最新。
- 跨子领域的方向(如"低碳水泥固废路线")→ 水泥表 + 绿色表合并取。
- 每本刊取最新若干篇后,**按重要性筛**:主题居中、有实质发现的留,边缘/纯验证性的弃(控量见 SKILL.md 篇数预算)。
- 主流刊都覆盖到了就够,不必穷举所有刊。哪些刊本窗口库内 0 收录,交付时如实点出。

View File

@ -1,61 +0,0 @@
# 三路检索策略(documents / research / web)
简报数据底座最多三路并用。**三路分工不同、去重要做、学术与资讯分开计数**。
## 三路分工
| 路 | 拿什么 | 怎么调 | 强项 | 注意 |
|---|---|---|---|---|
| **documents** | 材料学科论文**全文 md**(胶凝材料库 classification_id=1) | host-side tool `document_search`,中英 query 都行(后端跨语言语义检索) | LLM 直接读全文、引文核验抓锚点最顺;材料类首选 | 需宿主配 `DOCUMENT_SEARCH_API_KEY`;只覆盖预收的 7 学科 |
| **research** | OpenAlex 元数据 + DOI + abstract,可拉 PDF/XML | `from skills.research.paper import search, get_paper, fetch_xml`,`run_python` 调 | 补近期文献与 DOI、`year_gte` 卡时间窗、`is_oa`/`has_pdf` filter | keyword **英文为主**;SearchFilter 匹配 title/author 不含 abstract |
| **web** | 政策 / 标准 / 会议 / 产业动向 | WebSearch / WebFetch | 时效性最强,学术库覆盖不到的非论文信息 | **不当学术结论引**;单列"产业/政策动向"段,标来源 + 日期 |
> documents / research 任一不可用(key 没配 / 服务器连不上)时**降级用另一路 + web**,别整体放弃。research 不持 key,通常是降级首选。
## 中文方向 → 英文术语(关键)
库里 95%+ 文献 title 是英文,中文 keyword 命中率很低。**中文方向先转专业英文词组再搜**,缩写要展开:
| 中文方向 | 英文检索词组(同义/展开) |
|---|---|
| 低碳水泥 | low-carbon cement / low-CO2 cement / clinker substitution |
| 辅助胶凝材料 SCM | supplementary cementitious materials / SCM / fly ash / GGBFS / ground granulated blast-furnace slag / calcined clay / silica fume / limestone powder |
| LC3 石灰石煅烧黏土水泥 | limestone calcined clay cement / LC3 / calcined clay cement |
| 水泥窑碳捕集 | cement CCUS / carbon capture cement kiln / oxyfuel cement |
| 工业固废资源化 | industrial solid waste / steel slag / red mud / phosphogypsum in cement |
| 碳化养护 | CO2 curing / carbonation curing / accelerated carbonation |
| 水化机理 | cement hydration / C-S-H / hydration kinetics |
转译策略:用领域标准英文术语;不确定就先英文 keyword 试一次看返回 title 是否相关;多个同义词分别搜一遍合并去重;缩写(SCM/LC3/GGBFS)与全称都搜。
## research 调用范式
```python
from skills.research.paper import search
# 时间窗 = 近1年 → year_gte=<当前年-1>;多个英文同义词分别搜
for kw in ["limestone calcined clay cement", "LC3 cement", "supplementary cementitious materials"]:
papers = search(keyword=kw, year_gte=2025, limit=15)
for p in papers:
# list 已带 abstract,直接看前 200-400 字判切题,不必再 get_paper
print(p["publication_year"], p["title"], p["doi"])
if p["abstract"]:
print(p["abstract"][:300])
```
要全文做引文核验:`has_fulltext_xml=True` 先 `fetch_xml`(结构化,LLM 友好),否则 `fetch_pdf`
## 跨源去重
同一篇 / 同一结论可能三路都出现:
- **同 DOI** → 一条(documents 全文优先,记 DOI 来自 research)
- **同结论不同文献**(多篇验证同一现象)→ 合并成一句"多篇(refN, refN)验证了 X",不逐条铺开
- **web 资讯 vs 学术论文** → 不去重、不混算;web 进"产业/政策动向"段,学术进簇与引文计数
- 证据表 `evidence.md` 一行一条标"来源(documents/research/web)",去重在表上做完再起草
## 收多少 / 收到什么程度
- 按深度收 2080 条候选(见 brief_outline 预算),**不求穷尽**——够支撑各簇判断即可
- 每簇至少 2 篇代表文献(关键论断 ≥2 源,接 citation_verify 三角印证)
- 命中 0 条:换同义词 / 展开缩写 / 放宽年份;3 次仍空 → 如实告诉用户库未覆盖该窗口,**不脑补**

View File

@ -1,270 +0,0 @@
"""科研方向简报质量检查 — 渲染前跑一遍。
检查项:
- 结构完整性: 按深度(flash/standard/deep)必备段落是否齐全
- 占位符泄漏: <TODO> / [CITE-xx] 占位是否还在
- 过度宣称: "国际领先 / 首次 / 颠覆 / unprecedented" 等无证据夸张词(简报要有判断但别吹)
- 无源句式: "据报道 / 有研究表明 / 业内普遍认为" 等不挂引文的论断(简报每条论断要可溯源)
- 引文交叉核对: 文中学术引 [n] 与参考文献清单 [n] 互查(orphan / uncited / 编号连续)
- web 来源计数: [W1].. 单独统计,提醒和学术引文分开
用法:
python quality_check.py <sections_dir> --depth standard
python quality_check.py <sections_dir> --depth flash --strict
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
# 各深度必备段落(stem 前缀匹配;references 单独判)
REQUIRED_SECTIONS: dict[str, list[str]] = {
"flash": ["00_tldr", "02_clusters", "references"],
"standard": ["00_tldr", "01_overview", "02_clusters", "05_gaps", "references"],
"deep": ["00_tldr", "01_overview", "02_clusters", "04_progress", "05_gaps", "references"],
}
# 各深度热点簇数预算(02_clusters 里 '### 簇' 计数)
CLUSTER_BUDGET: dict[str, tuple[int, int]] = {
"flash": (2, 3),
"standard": (3, 5),
"deep": (5, 7),
}
OVERCLAIM_PHRASES = [
"国际领先", "国际一流", "世界领先", "世界一流", "填补空白", "重大突破",
"划时代", "前所未有", "颠覆性", "革命性",
"world-first", "world-leading", "unprecedented", "groundbreaking",
"revolutionary", "state of the art",
]
# 无源句式: 出现这些但同段没有 [n]/[CITE-] 引文 → 论断悬空
UNSOURCED_PHRASES = [
"据报道", "有研究表明", "研究显示", "业内普遍认为", "众所周知",
"大量研究", "普遍认为", "据悉",
]
PLACEHOLDER_PATTERNS = [
r"<TODO[^>]*>",
r"\[CITE-[A-Za-z0-9_\-]+\]",
r"\bXX+\b",
]
_INTEXT_CITE_RE = re.compile(r"\[(\d[\d,\s\-]*)\]") # 学术引 [7] / [7-9] / [7,9]
_REF_ENTRY_RE = re.compile(r"^\s*\[(\d+)\]") # 参考文献条目 [n]
_WEB_CITE_RE = re.compile(r"\[W\d+\]") # web 来源 [W1]
_CITE_TOKEN_RE = re.compile(r"\[(?:\d[\d,\s\-]*|CITE-[A-Za-z0-9_\-]+)\]")
def _is_references_file(stem: str) -> bool:
s = stem.lower()
return "reference" in s or s.endswith("_refs") or "参考文献" in stem or "08_" in s
def check_structure(sections_dir: Path, depth: str) -> list[str]:
required = REQUIRED_SECTIONS.get(depth, [])
existing = {f.stem for f in sections_dir.glob("*.md")}
issues = []
for req in required:
if req == "references":
if not any(_is_references_file(s) for s in existing):
issues.append("缺段落: 参考文献 (08_references)")
continue
if not any(s.startswith(req) for s in existing):
issues.append(f"缺段落: {req}")
return issues
def check_clusters(sections_dir: Path, depth: str) -> list[str]:
lo, hi = CLUSTER_BUDGET.get(depth, (0, 999))
n = 0
for md in sections_dir.glob("*.md"):
if md.stem.startswith("02_clusters"):
n += len(re.findall(r"^#{2,4}\s*簇", md.read_text(encoding="utf-8"), re.MULTILINE))
if n == 0:
return ["02_clusters 里没找到 '### 簇N' 小节 — 热点聚类是简报主体"]
if n < lo:
return [f"热点簇 {n} 个, 少于 {depth} 档预算 {lo}-{hi} (簇太少, 方向覆盖可能不足)"]
if n > hi:
return [f"热点簇 {n} 个, 多于 {depth} 档预算 {lo}-{hi} (簇太多, 考虑合并 — 重要性优先于完整性)"]
return []
def check_phrases(text: str, label: str) -> list[str]:
issues = []
low = text.lower()
for phrase in OVERCLAIM_PHRASES:
if phrase in text or phrase.lower() in low:
issues.append(f"[{label}] 过度宣称: '{phrase}' — 换成可被数据支撑的具体表述")
return issues
def check_unsourced(text: str, label: str) -> list[str]:
"""无源句式: 整段出现却无任何引文标记 → 悬空论断。按段落(空行分隔)判。"""
issues = []
for para in re.split(r"\n\s*\n", text):
if _CITE_TOKEN_RE.search(para) or _WEB_CITE_RE.search(para):
continue # 本段有引文, 放过
for phrase in UNSOURCED_PHRASES:
if phrase in para:
snippet = para.strip().replace("\n", " ")[:40]
issues.append(f"[{label}] 无源论断: '{phrase}' 所在段无引文标记 — 挂 [n] 或删 (\"{snippet}...\")")
break
return issues
def check_placeholders(text: str, label: str) -> list[str]:
issues = []
for pat in PLACEHOLDER_PATTERNS:
for m in re.findall(pat, text):
issues.append(f"[{label}] 占位符未替换: '{m}'")
return issues
def _expand_cite_group(grp: str) -> set[int]:
out: set[int] = set()
for part in grp.split(","):
part = part.strip()
if not part:
continue
if "-" in part:
a, _, b = part.partition("-")
try:
lo, hi = int(a), int(b)
except ValueError:
continue
if 0 < lo <= hi <= 999:
out.update(range(lo, hi + 1))
else:
try:
out.add(int(part))
except ValueError:
continue
return out
def check_citations(sections_dir: Path) -> list[str]:
issues: list[str] = []
cited: set[int] = set()
ref_nums: list[int] = []
web_in_text = 0
web_refs = 0
for md in sorted(sections_dir.glob("*.md")):
text = md.read_text(encoding="utf-8")
if _is_references_file(md.stem):
for ln in text.splitlines():
m = _REF_ENTRY_RE.match(ln)
if m:
ref_nums.append(int(m.group(1)))
if re.match(r"^\s*\[W\d+\]", ln):
web_refs += 1
else:
for grp in _INTEXT_CITE_RE.findall(text):
cited.update(_expand_cite_group(grp))
web_in_text += len(_WEB_CITE_RE.findall(text))
if not ref_nums and not cited:
return ["未发现任何学术引文 (文中 [n] 和参考文献清单都为空) — 简报论断需文献支撑"]
ref_set = set(ref_nums)
orphan = sorted(cited - ref_set)
if orphan:
issues.append(f"orphan cite — 文中引了 {orphan} 但参考文献清单缺对应条目 (编造/漏排, 走 citation_verify)")
uncited = sorted(ref_set - cited)
if uncited:
issues.append(f"uncited ref — 参考文献第 {uncited} 条正文从未引用 (删除或补引)")
dups = sorted({n for n in ref_nums if ref_nums.count(n) > 1})
if dups:
issues.append(f"参考文献编号重复: {dups}")
if ref_set:
gaps = sorted(set(range(1, max(ref_set) + 1)) - ref_set)
if gaps:
issues.append(f"参考文献编号不连续, 缺号: {gaps} (顺序编码制需 1..N 连续)")
if 1 not in ref_set:
issues.append("参考文献编号未从 [1] 起")
# web 来源只提示, 不判错(它们和学术引文分开计数)
if web_in_text and not web_refs:
issues.append(f"文中有 {web_in_text} 处 [W..] web 引用但参考文献无 [W..] 条目 — 补 web 来源(URL+日期)")
return issues
def main() -> None:
ap = argparse.ArgumentParser(description="科研方向简报质量检查")
ap.add_argument("sections_dir", type=Path)
ap.add_argument("--depth", required=True, choices=list(REQUIRED_SECTIONS.keys()))
ap.add_argument("--strict", action="store_true", help="严格模式: 任何问题退出 1")
args = ap.parse_args()
if not args.sections_dir.is_dir():
print(f"[ERR] {args.sections_dir} not a directory", file=sys.stderr)
sys.exit(2)
print(f"\n[简报质量检查] depth={args.depth}\n")
all_issues: list[str] = []
struct = check_structure(args.sections_dir, args.depth)
if struct:
print("[ERR] 结构问题:")
for s in struct:
print(f" - {s}")
all_issues.extend(struct)
else:
print("[OK] 结构完整")
cl = check_clusters(args.sections_dir, args.depth)
if cl:
print("\n[WARN] 热点簇数:")
for s in cl:
print(f" - {s}")
all_issues.extend(cl)
else:
print("[OK] 热点簇数在预算内")
files = sorted(args.sections_dir.glob("*.md"))
print(f"\n{len(files)} 个段落, 逐段扫描 (过度宣称 / 无源论断 / 占位符)...\n")
for f in files:
text = f.read_text(encoding="utf-8")
sub = (check_phrases(text, f.stem)
+ check_unsourced(text, f.stem)
+ check_placeholders(text, f.stem))
if sub:
print(f"[WARN] {f.stem}:")
for s in sub:
print(f" - {s.split('] ', 1)[1] if '] ' in s else s}")
all_issues.extend(sub)
cite_issues = check_citations(args.sections_dir)
if cite_issues:
print("\n[ERR] 引文交叉核对:")
for s in cite_issues:
print(f" - {s}")
all_issues.extend(cite_issues)
else:
print("\n[OK] 学术引文 [n] 与参考文献清单一致 (无 orphan / uncited, 编号连续)")
print("\n" + "=" * 60)
if all_issues:
print(f"[WARN] 共发现 {len(all_issues)} 个问题。")
print("\n建议:")
print(" - 过度宣称 -> 换成数据支撑的具体表述")
print(" - 无源论断 -> 挂 [n] 引文或删该句")
print(" - 占位符未替换 -> 走 citation_verify 把 [CITE-] 映射成真实引文")
print(" - orphan cite -> 大概率编造, 走 citation_verify 三角核验")
print(" - uncited ref -> 删条目或正文补引")
if args.strict:
sys.exit(1)
else:
print("[OK] 未发现问题。")
if __name__ == "__main__":
main()

View File

@ -2,8 +2,9 @@
相对 paper/render_docx.py 的简报专属增强:
- **商务红配色**(主色 #C00000):标题分级染色 + 标题下细色条;TL;DR / 「判断」行做浅红底纹 callout
- **引文上标 + 内部超链接**:正文 [1] / [W3] 上标红色,点击锚到文末参考文献对应条目
- **参考文献可点击**:DOI https://doi.org/... 蓝色超链接;web 条目里的域名/路径 https:// 超链接
- **引文上标 + 内部超链接**:正文 [1] / [W3] 上标红色,点击锚到重要论文列表 / 参考文献段对应条目
- **论文列表 / 参考文献可点击**:标题含论文列表 / 文献列表 / 参考文献的段,行首 [n] 条目作锚点;
条目内 DOI(整条是 DOI 或末尾 "DOI: 10.xxx") https://doi.org/... 蓝色超链接;web 条目里的域名/路径 https:// 超链接
- **化学式下标(白名单)**:CO2 / C3S2 / Na2O / SO4 ... 真实下标,**白名单精确匹配**,不误伤 LC3 / EN 197-5 / 8.5 Mt / 2026
字体规范同院内其它渲染:中文宋体小四 / 英文 Times New Roman 小四 / 行距 1.5 / 首行缩进 2 字符
@ -407,6 +408,7 @@ def add_page_footer(doc: Document, color: bool) -> None:
_REF_RE = re.compile(r"^\[(W?\d+)\]\s+(.+)$")
_DOI_RE = re.compile(r"^10\.\d{4,9}/\S+$")
_DOI_INLINE_RE = re.compile(r"10\.\d{4,9}/\S+") # 条目内 DOI 子串(论文列表条目末尾常带 "DOI: 10.xxx")
_URL_TOKEN_RE = re.compile(r"([a-z0-9][\w.\-]*\.[a-z]{2,}(?:/[^\s]+)?)", re.IGNORECASE)
@ -428,6 +430,18 @@ def add_reference_item(doc: Document, cid: str, value: str, bm_id: int, color: b
if _DOI_RE.match(value):
add_external_link(p, f"https://doi.org/{value}", value, size_pt=10.5)
return
# 论文列表条目:行内含 DOI(如 "<标题>. <作者>, <刊>, 2026-03. DOI: 10.1016/...")
# → 把 DOI 子串做成超链接,前后文正常
m_doi = _DOI_INLINE_RE.search(value)
if m_doi:
doi = m_doi.group(0).rstrip(".,;)")
pre, post = value[:m_doi.start()], value[m_doi.start() + len(doi):]
if pre:
_emit_plain_run(p, pre, size_pt=10.5, cn_font="宋体")
add_external_link(p, f"https://doi.org/{doi}", doi, size_pt=10.5)
if post:
_emit_plain_run(p, post, size_pt=10.5, cn_font="宋体")
return
# web 条目:把第一个像 URL 的 token 变成超链接
m = _URL_TOKEN_RE.search(value)
if m and ("/" in m.group(1) or m.group(1).count(".") >= 1) and " " not in m.group(1):
@ -604,7 +618,10 @@ def render_md_block(doc: Document, md_text: str, ctx: dict) -> None:
if m:
title = m.group(2).strip()
level = min(len(m.group(1)), 3)
in_refs = "参考文献" in title
# 只在 H1/H2 重判段类型 —— 让「重要论文列表」段下的 ### 期刊子标题不重置 in_refs,
# 子标题下的 [n] 条目才能继续按参考锚点渲染(带 DOI 超链接)
if level <= 2:
in_refs = ("参考文献" in title) or ("论文列表" in title) or ("文献列表" in title)
expect_meta = (level == 1)
if level <= 2:
in_tldr = ("要点" in title) or ("TL;DR" in title.upper())

View File

@ -1,60 +0,0 @@
# 简报骨架 + 篇幅预算
简报按深度分三档,骨架同构、详略不同。`sections/` 一节一文件,文件名见各段标注。
## 篇幅预算(按深度)
| 深度 | 页数 | 总字数(中) | 热点簇数 | 引文数(学术) | 机构-地理计量 |
|---|---|---|---|---|---|
| `flash` 快报 | 12 | 8001500 | 23 | 815 | 不做 |
| `standard` 标准 | 46 | 25004000 | 35 | 2040 | 可选(一句话点格局) |
| `deep` 深度 | 8+ | 6000+ | 57 | 4080 | 做(发文量趋势 / 国别机构格局) |
> 字数是预算不是硬指标;宁可短而准,不要灌水凑页。"重要性优先于完整性"。
## 骨架(各档同构)
### `00_tldr.md` — 一句话要点(TL;DR)
- 5 行内,**先给结论**:这个方向近期最值得知道的 35 件事,每行一句带判断
- 决策者只读这段也能拿到核心态势
- 例:`- LC3(石灰石煅烧黏土水泥)从中试走向标准化,近 1 年多国发布技术规程`
### `01_overview.md` — 方向概览与边界
- 这个方向**解决什么问题**(12 段)、本简报的**纳入/排除边界**(照搬 spec)
- 时间窗 + 数据源覆盖说明(收了哪几路、多少条),让读者知道简报的"视野范围"
### `02_clusters.md` — 研究热点聚类(主体)
按 spec 深度分 37 簇,每簇一个 `###` 小节:
```
### 簇N:<主题句,写判断不写关键词>
- 在做什么 / 解决什么:<12 >
- 代表性进展:<24 , [CITE-xx],各一句 takeaway + 关键数字>
- 新兴方法/技术:<若有>
- 争议/未解:<若有>
```
### `03_methods.md` — 新兴方法 / 技术(可并入 02,deep 单列)
- 跨簇出现的新表征 / 新建模(如 ML 配比优化)/ 新工艺,各带代表文献
### `04_progress.md` — 近期标志性进展
- 近 N 年**改变判断**的标志性成果(里程碑论文 / 中试 / 突破),38 条,带引文与数字
### `05_gaps.md` — 研究空白与争议
- 大家都没做的(空白)、还没共识的(争议)、方法学局限 —— 这段是简报"有判断"的体现
### `06_industry.md` — 产业 / 政策 / 标准动向(web,可选)
- 双碳政策 / 碳配额 / 新国标团标 / 行业会议 / 企业产线中试 —— **单列,标注来源为 web 资讯**
- 与学术引文分开计数;时效性内容注明日期
### `08_references.md` — 参考文献
- 仅 citation_verify 核验通过(verified / verified-revised / 用户确认)的进清单
- 学术引文按文中首次出现顺序编 `[1][2]...`,带 DOI;web 来源另起一段标 URL + 访问日期

View File

@ -1,54 +0,0 @@
# 简报 spec(定题对齐"宪法")
> 阶段一产物。写定后不再改,阶段二/三/四每阶段前都要 read。`<TODO>` 是占位符,需用户明确填值,不要硬编。
## 1. 方向 + 边界
- 研究方向(具体到子方向):`<TODO 低碳水泥的辅助胶凝材料(SCM)路线,不是泛泛的"水泥">`
- 纳入范围:`<TODO 粉煤灰/矿渣/煅烧黏土/石灰石粉等 SCM 替代熟料>`
- 排除范围:`<TODO 碱激发/地聚物单独成体系的不收;CCUS 仅在与 SCM 协同时提及>`
## 2. 时间窗
- 口径:`<TODO 1 / 2 / 3 (默认)>`
- 换算 `year_gte`:`<TODO 当前年减窗口, 近1年2025>`(当前年见 system prompt)
## 3. 受众
- `<TODO 院领导汇报 / 课题组内部 / 立项前调研 / 对外交流>`(决定语气与详略)
## 4. 深度
- `<TODO flash 快报(12页) / standard 标准(46页,默认) / deep 深度(8+页,含机构-地理计量)>`
- 对应字数预算 / 簇数 / 引文数见 brief_outline.md
## 5. 数据源开关(默认三路并用)
- documents(内部库,材料类首选):`<TODO /;胶凝材料库 classification_id=1>`
- research(补 DOI 与近期元数据):`<TODO />`
- web(政策·会议·标准·产业动向):`<TODO />`
## 6. 语言
- `<TODO 中文(默认) / 英文>`
## 7. 特殊关注点
> 用户特别想知道的,分析阶段重点回应。
- `<TODO 如 重点看 CCUS 与水泥协同 / 谁在做工业固废路线 / 关注新国标动向>`
## 检索词组(阶段二填,中→英)
> 中文方向转成的英文检索词,库里主语料英文。
```
<TODO 主词 + 同义/展开词,如:
supplementary cementitious materials | SCM | fly ash | GGBFS / ground granulated blast-furnace slag |
calcined clay | limestone calcined clay cement | LC3 | clinker substitution | low-carbon cement>
```
## 待用户提供 / TODO
- [ ] `<TODO 如 确认时间窗口是近1年还是近2年>`
- [ ] `<TODO 如 确认是否纳入碱激发体系>`

202
tools/schedule.py Normal file
View File

@ -0,0 +1,202 @@
"""定时任务对话工具(DESIGN §8.5 对话端)。
create / list / update / cancel 对话端的完整 CRUDhost-side, user_id 隔离
(ctor 注入,不信模型传的 id)增删改查逻辑全在 core.scheduler 服务层,本文件只做
schema + 把结果/JobError 转成给模型看的文本前端只读视图走 /v1/schedules REST,
同样调那一层 两条路径不漂移
定时 run 内这几个工具不注册(防任务造任务,§8.5)
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
from uuid import UUID
from core import scheduler
from core.scheduler import JobError, _tzinfo
from .base import Tool
_MODES = ("isolated", "persistent")
def _fmt_local_iso(iso: Optional[str], tz: str) -> str:
if not iso:
return ""
dt = datetime.fromisoformat(iso)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(_tzinfo(tz)).strftime("%Y-%m-%d %H:%M")
class _UserScopedTool(Tool):
"""带 user_id 的 host-side 工具基类。"""
def __init__(self, user_id: UUID, base_dir=None, user_root=None) -> None:
super().__init__(base_dir=base_dir, user_root=user_root)
self.user_id = user_id
class ScheduleCreateTool(_UserScopedTool):
name = "schedule_create"
description = (
"Create a recurring scheduled task that runs an instruction automatically on a cron "
"schedule. The instruction (`prompt`) is what an agent will be told to do at each fire "
"time — write it self-contained and repeatable (which skill to use, what to produce, "
"where to deliver). To email a result, say so IN the prompt (the run can call send_email), "
"or set `notify_email` for guaranteed delivery of the latest output. ALWAYS confirm the "
"schedule (read back the cron in plain words + the resolved next run time) with the user "
"before relying on it. Times are wall-clock in `tz`."
)
parameters = {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Short human label, e.g. '每日水泥简报'."},
"prompt": {
"type": "string",
"description": "Self-contained instruction to run each time (e.g. '用 brief skill 生成今日简报并存盘').",
},
"cron": {
"type": "string",
"description": "Standard 5-field cron 'min hour dom month dow'. E.g. '0 8 * * *' = daily 08:00; '0 9 * * 1' = Mondays 09:00.",
},
"tz": {"type": "string", "description": "IANA timezone, default 'Asia/Shanghai'."},
"mode": {
"type": "string",
"enum": list(_MODES),
"description": "isolated (default): fresh task each run, cheap, no cross-run memory. persistent: same task thread, keeps continuity (costs more tokens).",
},
"skill": {"type": "string", "description": "Optional skill name to preload for the run."},
"notify_email": {
"type": "string",
"description": "Optional email for guaranteed delivery of the run's latest artifact (deterministic, not dependent on the agent remembering).",
},
"timeout_seconds": {
"type": "integer",
"description": "Optional hard timeout for each run (0 = no limit).",
},
},
"required": ["name", "prompt", "cron"],
}
def execute(
self, name: str, prompt: str, cron: str, tz: str = "Asia/Shanghai",
mode: str = "isolated", skill: str = "", notify_email: str = "",
timeout_seconds: int = 0,
) -> str:
notify = None
if notify_email and notify_email.strip():
notify = {"channel": "email", "to": notify_email.strip()}
try:
job = scheduler.create_job(
self.user_id, name=name, prompt=prompt, cron=cron, tz=tz, mode=mode,
skill=skill, notify=notify, timeout_seconds=timeout_seconds,
)
except JobError as e:
return f"[Error] {e}"
nf = f";产物将发到 {notify['to']}" if notify else ""
return (
f"[ok] 已创建定时任务「{job['name']}」(id={job['short_id']},mode={job['mode']}{nf})。\n"
f"{job['schedule_desc']}(cron={job['cron']} @{job['tz']});"
f"下次触发 {_fmt_local_iso(job['next_run_at'], job['tz'])}"
)
class ScheduleListTool(_UserScopedTool):
name = "schedule_list"
description = (
"List the current user's scheduled tasks with id, schedule, next run time and last "
"status. Use when the user asks what scheduled tasks they have."
)
parameters = {"type": "object", "properties": {}}
def execute(self) -> str:
jobs = scheduler.list_jobs(self.user_id)
if not jobs:
return "(没有定时任务)"
lines = ["定时任务列表:"]
for j in jobs:
flag = "" if j["enabled"] else " [已停用]"
nxt = _fmt_local_iso(j["next_run_at"], j["tz"])
lines.append(
f"- {j['short_id']}{j['name']}{flag} | {j['schedule_desc']} | "
f"{j['mode']} | 下次 {nxt} | 上次 {j['last_status'] or ''}"
)
return "\n".join(lines)
class ScheduleUpdateTool(_UserScopedTool):
name = "schedule_update"
description = (
"Edit an existing scheduled task by id (8-char short id from schedule_list is accepted). "
"Pass ONLY the fields to change. Use to reschedule (cron), rewrite the instruction "
"(prompt), pause/resume (enabled), change delivery (notify_email), etc. Changing cron/tz "
"recomputes the next run time. Confirm the change (read back new schedule) with the user."
)
parameters = {
"type": "object",
"properties": {
"job_id": {"type": "string", "description": "Job id (full UUID or 8-char short id)."},
"name": {"type": "string", "description": "New label."},
"prompt": {"type": "string", "description": "New instruction to run."},
"cron": {"type": "string", "description": "New 5-field cron expression."},
"tz": {"type": "string", "description": "New IANA timezone."},
"mode": {"type": "string", "enum": list(_MODES), "description": "New session mode."},
"skill": {"type": "string", "description": "New preload skill (empty string to clear)."},
"enabled": {"type": "boolean", "description": "true=resume, false=pause."},
"notify_email": {
"type": "string",
"description": "Set guaranteed-delivery email; empty string clears it.",
},
"timeout_seconds": {"type": "integer", "description": "New per-run timeout (0=none)."},
},
"required": ["job_id"],
}
def execute(
self, job_id: str, name: Optional[str] = None, prompt: Optional[str] = None,
cron: Optional[str] = None, tz: Optional[str] = None, mode: Optional[str] = None,
skill: Optional[str] = None, enabled: Optional[bool] = None,
notify_email: Optional[str] = None, timeout_seconds: Optional[int] = None,
) -> str:
fields: dict = {}
for k, v in (("name", name), ("prompt", prompt), ("cron", cron), ("tz", tz),
("mode", mode), ("skill", skill), ("enabled", enabled),
("timeout_seconds", timeout_seconds)):
if v is not None:
fields[k] = v
# notify_email 是便捷字段 → 转 notify dict;传空串 = 清除兜底投递
if notify_email is not None:
fields["notify"] = {"channel": "email", "to": notify_email.strip()} if notify_email.strip() else {}
try:
job = scheduler.update_job(self.user_id, job_id, **fields)
except JobError as e:
return f"[Error] {e}"
state = "启用" if job["enabled"] else "已停用"
return (
f"[ok] 已更新定时任务「{job['name']}」(id={job['short_id']},{state})。\n"
f"{job['schedule_desc']}(cron={job['cron']} @{job['tz']});"
f"下次触发 {_fmt_local_iso(job['next_run_at'], job['tz'])}"
)
class ScheduleCancelTool(_UserScopedTool):
name = "schedule_cancel"
description = (
"Cancel (soft-delete) a scheduled task by id (8-char short id accepted). It stops firing "
"immediately. To merely pause (keep it for later), use schedule_update enabled=false instead."
)
parameters = {
"type": "object",
"properties": {
"job_id": {"type": "string", "description": "Job id (full UUID or 8-char short id)."},
},
"required": ["job_id"],
}
def execute(self, job_id: str) -> str:
try:
job = scheduler.cancel_job(self.user_id, job_id)
except JobError as e:
return f"[Error] {e}"
return f"[ok] 已取消定时任务「{job['name']}」(id={job['short_id']})"

169
tools/send_email.py Normal file
View File

@ -0,0 +1,169 @@
"""发邮件(host-side,DESIGN §8.5 投递第 2/3 层)。
- `send_email_smtp(...)`:纯函数, SMTP_* env 发信SendEmailTool 与定时任务的
确定性兜底投递(core/scheduler.py notify)共用它
- `smtp_configured()`:agent_builder 据此决定挂不挂 tool(沿用"有 key 才注册"范式,
§3.4);没配 SMTP 的部署里 agent 看不到一个永远报错的工具
- `SendEmailTool`:agent (定时或交互)run 里调,附件路径强制落 user_root 内防越界
密钥只在 host 进程读,绝不进沙箱 / run_pythonenv:
SMTP_HOST SMTP_PORT( 465) SMTP_USER SMTP_PASSWORD
SMTP_FROM( SMTP_USER) SMTP_TLS(ssl|starttls|none;默按端口:465ssl 否则 starttls)
"""
from __future__ import annotations
import os
import smtplib
from email.message import EmailMessage
from email.utils import formataddr
from pathlib import Path
from typing import Iterable, Optional
from .base import Tool
_MAX_ATTACH_BYTES = 20 * 1024 * 1024 # 单封附件总上限,防把大产物塞爆 SMTP
_MAX_RECIPIENTS = 10
def smtp_configured() -> bool:
"""最小可发信集合是否齐全。"""
return bool(
os.getenv("SMTP_HOST", "").strip()
and os.getenv("SMTP_USER", "").strip()
and os.getenv("SMTP_PASSWORD", "").strip()
)
def _tls_mode(port: int) -> str:
mode = os.getenv("SMTP_TLS", "").strip().lower()
if mode in ("ssl", "starttls", "none"):
return mode
return "ssl" if port == 465 else "starttls"
def send_email_smtp(
to: Iterable[str] | str,
subject: str,
body: str,
attachments: Optional[Iterable[Path]] = None,
*,
timeout: float = 30.0,
) -> None:
"""同步发一封纯文本邮件(可带附件)。失败抛异常,由调用方决定如何处理。
host-side 调用(SendEmailTool to_thread run 线程里;scheduler
run_in_executor ) smtplib 是阻塞 IO,不要在 asyncio loop 直接 await
"""
if not smtp_configured():
raise RuntimeError("SMTP 未配置(需 SMTP_HOST/SMTP_USER/SMTP_PASSWORD)")
host = os.getenv("SMTP_HOST", "").strip()
port = int(os.getenv("SMTP_PORT", "465").strip() or "465")
user = os.getenv("SMTP_USER", "").strip()
password = os.getenv("SMTP_PASSWORD", "").strip()
sender = os.getenv("SMTP_FROM", "").strip() or user
if isinstance(to, str):
to_list = [to]
else:
to_list = list(to)
to_list = [a.strip() for a in to_list if a and a.strip()]
if not to_list:
raise ValueError("收件人为空")
if len(to_list) > _MAX_RECIPIENTS:
raise ValueError(f"收件人过多(上限 {_MAX_RECIPIENTS})")
msg = EmailMessage()
msg["From"] = formataddr(("zcbot", sender))
msg["To"] = ", ".join(to_list)
msg["Subject"] = subject or "(无主题)"
msg.set_content(body or "")
total = 0
for p in attachments or []:
p = Path(p)
if not p.is_file():
continue
data = p.read_bytes()
total += len(data)
if total > _MAX_ATTACH_BYTES:
raise ValueError(f"附件总大小超过 {_MAX_ATTACH_BYTES // (1024*1024)}MB")
msg.add_attachment(
data, maintype="application", subtype="octet-stream", filename=p.name
)
tls = _tls_mode(port)
if tls == "ssl":
with smtplib.SMTP_SSL(host, port, timeout=timeout) as smtp:
smtp.login(user, password)
smtp.send_message(msg)
else:
with smtplib.SMTP(host, port, timeout=timeout) as smtp:
if tls == "starttls":
smtp.starttls()
smtp.login(user, password)
smtp.send_message(msg)
class SendEmailTool(Tool):
name = "send_email"
description = (
"Send an email (plain text, optional file attachments) via the server's configured "
"SMTP account. Use this when the user asks to email a result/report to someone, or when "
"a scheduled task's instruction says to email its output. Attachments are paths inside "
"the working directory (e.g. 'report.docx' or '<wd>/figures/x.png'). Returns a short "
"confirmation or an [Error] line."
)
parameters = {
"type": "object",
"properties": {
"to": {
"type": "array",
"items": {"type": "string"},
"description": "Recipient email address(es).",
},
"subject": {"type": "string", "description": "Email subject line."},
"body": {"type": "string", "description": "Plain-text email body."},
"attachments": {
"type": "array",
"items": {"type": "string"},
"description": "Optional file paths (relative to working dir) to attach.",
},
},
"required": ["to", "subject", "body"],
}
def execute(
self,
to: list[str] | str,
subject: str,
body: str,
attachments: Optional[list[str]] = None,
) -> str:
if isinstance(to, str):
to = [to]
recipients = [a.strip() for a in (to or []) if isinstance(a, str) and a.strip()]
if not recipients:
return "[Error] to 不能为空"
resolved: list[Path] = []
for raw in attachments or []:
if not isinstance(raw, str) or not raw.strip():
continue
p = self._resolve(raw.strip()).resolve()
# 附件强制落 user_root 内,防 ../ 读到别人/系统文件
if self.user_root is not None:
try:
p.relative_to(self.user_root.resolve())
except ValueError:
return f"[Error] 附件路径越界(必须在工作目录内): {raw}"
if not p.is_file():
return f"[Error] 附件不存在: {raw}"
resolved.append(p)
try:
send_email_smtp(recipients, subject, body, resolved)
except Exception as e:
return f"[Error] 发送失败: {type(e).__name__}: {e}"
n = f"(含 {len(resolved)} 个附件)" if resolved else ""
return f"[ok] 已发送给 {', '.join(recipients)} {n}".strip()

View File

@ -36,13 +36,13 @@ from sqlalchemy import BigInteger, cast, func, select, update
from starlette.background import BackgroundTask
from core import __version__
from core.paths import to_db_path
from core.paths import from_db_path, to_db_path
from core.storage import (
NoSubtaskError,
check_no_subtask,
session_scope,
)
from core.storage.models import Message, Task, UsageEvent
from core.storage.models import Message, ScheduledJob, Task, UsageEvent
from core.storage.utils import ensure_local_task_row
from .auth import (
@ -349,6 +349,7 @@ def _validate_transfer(
def _run_agent_bg(
task_id: UUID, user_id: UUID, user_message: str,
image_variant: str = "", video_variant: str = "",
scheduled: bool = False,
) -> None:
"""工作线程:`build_agent(resume=True)` → 装 WebEventSink + cancel_check → `agent.run` → 写 tasks.run_status。
@ -372,6 +373,7 @@ def _run_agent_bg(
image_variant=image_variant,
video_variant=video_variant,
cancel_check=cancel_check,
scheduled_run=scheduled,
)
agent.sink = WebEventSink(broker, task_id)
agent.run(user_message)
@ -510,6 +512,12 @@ class TaskPatchRequest(BaseModel):
model_profile: Optional[str] = None # 切模型(c 模式 task 层 / A 粒度 — 下条 send 生效)
class SchedulePatchRequest(BaseModel):
# 前端只读视图仅用 enabled(停用/启用);其余字段留着供"对话改不了时"的兜底直改,
# 但前端不暴露编辑表单(建/改走对话,§8.5)。
enabled: Optional[bool] = None
class MessageRequest(BaseModel):
content: str
# 该条消息触发的生图 / 生视频模型 variant key(config/media/doubao.yaml image/video 段)。
@ -691,6 +699,155 @@ def create_app() -> FastAPI:
stats_logger_task = asyncio.create_task(_stats_logger(), name="stats-logger")
# ── 定时任务守护循环(§8.5)── 仿 _disk_scanner 的 plain-asyncio 范式,不引
# APScheduler/Celery。每 ~10s 认领到点 job(claim+advance next_run 防重复触发),
# 复用 _run_agent_bg 起 run,跑完确定性兜底投递 + 回写 last_*。间隔只决定最坏延迟
# (≤1 tick),不决定会不会漏(claim 取 next_run<=now 的全部)。ZCBOT_DISABLE_SCHEDULER=1
# 整体关掉(对照 Claude Code CLAUDE_CODE_DISABLE_CRON)。
scheduler_enabled = os.getenv("ZCBOT_DISABLE_SCHEDULER", "").strip() not in ("1", "true", "yes")
sched_tick = int(os.getenv("ZCBOT_SCHEDULER_TICK_SECONDS", "10") or "10")
sched_sema = asyncio.Semaphore(int(os.getenv("ZCBOT_SCHEDULER_CONCURRENCY", "4") or "4"))
async def _execute_scheduled_job(snap: dict) -> None:
"""认领后跑一个 job:解析目标 task → 抢 run 锁 → _run_agent_bg → 投递 + 记账。"""
from core.agent_builder import (
resolve_workspace, working_dir_from_name, validate_task_name, InvalidTaskName,
)
from core.scheduler import build_run_message, deliver_notify, record_result
from core.storage.utils import ensure_local_task_row
job_id = snap["job_id"]
uid = snap["user_id"]
async with sched_sema:
try:
profile, model_id = _resolve_model_profile(snap.get("model_profile") or "")
ws = resolve_workspace(None, _cfg)
# 目标 task:persistent 用绑定 task(缺则新建并回填);isolated 用稳定 per-job 目录
tid: Optional[UUID] = None
if snap["mode"] == "persistent" and snap.get("bound_task_id"):
tid = snap["bound_task_id"]
# 绑定 task 可能已被删(SET NULL 已处理 None;这里再查实在性)
with session_scope() as s:
exists = s.execute(
select(Task.task_id).where(
Task.task_id == tid, Task.deleted_at.is_(None)
)
).first()
if exists is None:
tid = None
if tid is None:
tid = uuid4()
wd_name = f"scheduled-{str(job_id)[:8]}"
fs_dir = working_dir_from_name(ws, uid, wd_name)
fs_dir.mkdir(parents=True, exist_ok=True)
disp = f"{snap['name']}"
try:
disp = validate_task_name(disp)
except InvalidTaskName:
disp = wd_name # 名字含非法字符 → 退到安全名
ensure_local_task_row(
task_id=tid, name=disp, working_dir=to_db_path(fs_dir),
skill=snap.get("skill") or "", user_id=uid,
model=model_id, model_profile=profile,
description="(定时任务自动创建)",
)
if snap["mode"] == "persistent":
with session_scope() as s:
s.execute(update(ScheduledJob).where(
ScheduledJob.job_id == job_id
).values(bound_task_id=tid))
# 抢 run 锁(同 post_message):busy → 本次跳过,下个 cron 点再来
with session_scope() as s:
row = s.execute(
select(Task.run_status).where(Task.task_id == tid).with_for_update()
).first()
if row is None:
record_result(job_id, status="error", task_id=tid, error="目标 task 不存在")
return
if row.run_status in ("running", "cancelling"):
record_result(job_id, status="skipped", task_id=tid,
error="目标 task 正忙,本次跳过")
print(f"[scheduler] job {str(job_id)[:8]} skipped (task busy)")
return
s.execute(update(Task).where(Task.task_id == tid).values(
run_status="running", run_error=None))
message = build_run_message(snap)
broker.start(tid)
runner = asyncio.create_task(asyncio.to_thread(
_run_agent_bg, tid, uid, message, "", "", True,
))
app.state.inflight[runner] = tid
runner.add_done_callback(lambda t: app.state.inflight.pop(t, None))
timeout = int(snap.get("timeout_seconds") or 0)
if timeout > 0:
done, _pending = await asyncio.wait({runner}, timeout=timeout)
if not done:
broker.request_cancel(tid) # 协作式停;loop 在 chunk 间 poll 到即退
print(f"[scheduler] job {str(job_id)[:8]} timed out ({timeout}s), cancelling")
await runner
else:
await runner
# run 终态:_run_agent_bg 收尾把 run_status 写回 idle(ok)/error
with session_scope() as s:
st = s.execute(
select(Task.run_status, Task.run_error).where(Task.task_id == tid)
).first()
if st is not None and st.run_status == "error":
record_result(job_id, status="error", task_id=tid, error=st.run_error)
print(f"[scheduler] job {str(job_id)[:8]} run error: {st.run_error}")
return
# 第 3 层确定性兜底投递(notify);失败不影响 run 已成功这一事实
if snap.get("notify"):
try:
with session_scope() as s:
wd_db = s.execute(
select(Task.working_dir).where(Task.task_id == tid)
).scalar_one_or_none()
fs_dir = from_db_path(wd_db) if wd_db else ws
await asyncio.get_running_loop().run_in_executor(
None, lambda: deliver_notify(
snap["notify"], job_name=snap["name"],
working_dir=fs_dir, tz=snap["tz"],
)
)
except Exception as e:
print(f"[scheduler] job {str(job_id)[:8]} notify failed: {type(e).__name__}: {e}")
record_result(job_id, status="ok", task_id=tid)
print(f"[scheduler] job {str(job_id)[:8]} '{snap['name']}' done")
except Exception as e:
print(f"[scheduler] job {str(job_id)[:8]} crashed: {type(e).__name__}: {e}")
try:
record_result(job_id, status="error", task_id=None, error=f"{type(e).__name__}: {e}")
except Exception:
pass
async def _scheduler_loop() -> None:
from core.scheduler import claim_due_jobs
loop = asyncio.get_running_loop()
while True:
try:
await asyncio.sleep(sched_tick)
if getattr(app.state, "draining", None) is not None and app.state.draining.is_set():
continue # 关停 drain 期不起新 job
due = await loop.run_in_executor(None, claim_due_jobs)
for snap in due:
asyncio.create_task(_execute_scheduled_job(snap))
if due:
print(f"[scheduler] fired {len(due)} job(s)")
except asyncio.CancelledError:
raise
except Exception as e:
print(f"[scheduler] loop error: {type(e).__name__}: {e}")
scheduler_task = asyncio.create_task(_scheduler_loop(), name="scheduler") if scheduler_enabled else None
if scheduler_enabled:
print(f"[scheduler] enabled (tick={sched_tick}s)")
# Sandbox pool(§7.5):仅当 ZCBOT_SANDBOX_BACKEND=docker 时启用。
# 启动钩子:① init_pool(创建 docker network + pool 实例)② shutdown_all 清
# 前驱孤儿(上次进程留下的 zcbot-sandbox-* 容器,内存 _last_active 为空,
@ -782,6 +939,12 @@ def create_app() -> FastAPI:
await stats_logger_task
except (asyncio.CancelledError, Exception):
pass
if scheduler_task is not None:
scheduler_task.cancel()
try:
await scheduler_task
except (asyncio.CancelledError, Exception):
pass
if sandbox_reaper_task is not None:
sandbox_reaper_task.cancel()
try:
@ -1329,6 +1492,37 @@ def create_app() -> FastAPI:
raise HTTPException(404, f"memory file not found: {filename!r}")
return {"filename": filename, "content": content}
# ───────────── 定时任务(DESIGN §8.5)─────────────
# 前端只读展示 + 停用/删除两个便捷动作;建/改全走对话(schedule_* 工具)。
# 与对话工具共用 core.scheduler 服务层,两条路径不漂移。
@app.get("/v1/schedules", tags=["schedules"])
def list_schedules(user_id: UUID = Depends(require_user)):
"""列当前用户的定时任务(只读)。前端「定时」面板一次拉满。"""
from core import scheduler
return {"results": scheduler.list_jobs(user_id)}
@app.patch("/v1/schedules/{job_id}", tags=["schedules"])
def patch_schedule(
job_id: str, body: SchedulePatchRequest, user_id: UUID = Depends(require_user),
):
"""改定时任务 —— 前端只用来停用/启用(enabled)。其余编辑走对话。"""
from core import scheduler
if body.enabled is None:
raise HTTPException(400, "no fields to update")
try:
return scheduler.set_enabled(user_id, job_id, body.enabled)
except scheduler.JobError as e:
raise HTTPException(404, str(e))
@app.delete("/v1/schedules/{job_id}", status_code=204, tags=["schedules"])
def delete_schedule(job_id: str, user_id: UUID = Depends(require_user)):
"""删定时任务(软删,立即停止触发)。"""
from core import scheduler
try:
scheduler.cancel_job(user_id, job_id)
except scheduler.JobError as e:
raise HTTPException(404, str(e))
@app.delete("/v1/tasks/{task_id}", status_code=204, tags=["tasks"])
def delete_task(task_id: str, user_id: UUID = Depends(require_user)):
"""软删除:置 deleted_at=now(),从任务列表隐藏。

View File

@ -292,6 +292,42 @@
.sk-pane { width: auto; max-height: 26vh; border-right: none; border-bottom: 1px solid var(--border); }
}
/* 定时任务 modal(只读 + 停用/删除,DESIGN §8.5)— 复用 .sk-item/.sk-badge/.sk-empty */
#crons-modal .card {
width: 880px; max-width: 94vw; height: 78vh; max-height: 78vh;
display: flex; flex-direction: column;
}
#crons-modal h3 {
margin: 0; padding: 12px 16px; font-size: 16px;
border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 8px;
}
#crons-modal h3 .spacer { flex: 1; }
#crons-modal h3 svg { opacity: .85; }
#crons-modal .cr-hint { font-size: 11px; font-weight: 400; color: var(--muted); }
#crons-modal .sk-x {
border: none; background: transparent; font-size: 16px;
cursor: pointer; color: var(--muted); padding: 2px 6px;
}
#cr-cols { flex: 1; display: flex; min-height: 0; }
#cr-list { width: 300px; flex-shrink: 0; overflow: auto; padding: 12px; border-right: 1px solid var(--border); }
#cr-detail { flex: 1; min-width: 0; overflow: auto; padding: 16px 20px; }
.cr-sched { font-size: 12px; color: var(--accent); margin-top: 2px; }
.cr-meta { font-size: 11px; color: var(--muted); margin-top: 3px; }
.cr-st { font-size: 10px; font-weight: 500; border-radius: 8px; padding: 0 6px; white-space: nowrap; }
.cr-st.ok { color: #2a8a4a; border: 1px solid #2a8a4a; }
.cr-st.error { color: #c0392b; border: 1px solid #c0392b; }
.cr-st.paused { color: var(--muted); border: 1px solid var(--border); }
.cr-d-row { display: flex; gap: 8px; padding: 7px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
.cr-d-row .k { width: 84px; flex-shrink: 0; color: var(--muted); }
.cr-d-row .v { flex: 1; min-width: 0; word-break: break-word; white-space: pre-wrap; }
.cr-acts { display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
@media (max-width: 760px) {
#crons-modal .card { width: 96vw; height: 88vh; max-height: 88vh; }
#cr-cols { flex-direction: column; }
#cr-list { width: auto; max-height: 30vh; border-right: none; border-bottom: 1px solid var(--border); }
#crons-modal .cr-hint { display: none; }
}
/* ───── 记忆查看 modal(只读两栏;改走对话)───── */
#memory-modal { z-index: 112; }
#memory-modal .card {
@ -1230,6 +1266,22 @@
</div>
</div>
<div id="crons-modal" class="modal">
<div class="card">
<h3>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg>
<span>定时任务</span>
<span class="spacer"></span>
<span class="cr-hint">新建 / 修改请在对话里说,例如「每天早八点把水泥简报发我邮箱」</span>
<button id="cr-close" class="sk-x" title="关闭"></button>
</h3>
<div id="cr-cols">
<div id="cr-list"><div class="muted" style="padding:8px;">加载中…</div></div>
<div id="cr-detail"><div class="sk-empty">← 选一个定时任务查看详情</div></div>
</div>
</div>
</div>
<!-- ───── 记忆查看 modal(只读;改记忆走对话)───── -->
<div id="memory-modal" class="modal">
<div class="card">
@ -1324,6 +1376,10 @@
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path></svg>
<span>记忆</span>
</button>
<button id="hd-crons" title="查看定时任务(建 / 改请在对话里说)">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg>
<span>定时</span>
</button>
</div>
</div>
<div id="split-left" class="splitter" role="separator" aria-orientation="vertical" title="拖拽调整任务栏宽度"></div>

163
web/static/js/crons.js Normal file
View File

@ -0,0 +1,163 @@
// 定时任务 modal:只读两栏 master-detail(左列表 / 右详情),仅「停用/启用」「删除」
// 两个便捷动作;新建 / 修改全走对话(schedule_* 工具,DESIGN §8.5)。
// 左侧 rail 底部「定时」按钮触发。
// 后端:GET /v1/schedules(列表)、PATCH /v1/schedules/{id}{enabled}(停用/启用)、
// DELETE /v1/schedules/{id}(删除)。建/改无 REST —— 故意只读。
import { $ } from "./dom.js";
import { api } from "./api.js";
import { escapeHtml } from "./format.js";
import { selectTask } from "./chat.js";
const PLACEHOLDER = '<div class="sk-empty">← 选一个定时任务查看详情</div>';
let _jobs = [];
function openCronsModal() {
$("crons-modal").classList.add("show");
$("cr-detail").innerHTML = PLACEHOLDER;
renderList();
}
export function closeCronsModal() {
$("crons-modal").classList.remove("show");
}
// UTC ISO → 浏览器本地短时刻(用户浏览器基本就在 CST,直观)。
function ts(iso) {
if (!iso) return "—";
const d = new Date(iso);
if (isNaN(d)) return "—";
return d.toLocaleString("zh-CN", {
month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false,
});
}
function statusBadge(j) {
if (!j.enabled) return '<span class="cr-st paused">已停用</span>';
if (j.last_status === "error") return '<span class="cr-st error">上次失败</span>';
if (j.last_status === "ok") return '<span class="cr-st ok">正常</span>';
return '<span class="cr-st paused">待运行</span>';
}
function itemHtml(j) {
return `<div class="sk-item" data-id="${escapeHtml(j.job_id)}">
<div class="sk-name">${escapeHtml(j.name)} ${statusBadge(j)}</div>
<div class="cr-sched">${escapeHtml(j.schedule_desc || j.cron)}</div>
<div class="cr-meta">下次 ${ts(j.next_run_at)} · 上次 ${ts(j.last_run_at)}</div>
</div>`;
}
async function renderList() {
const list = $("cr-list");
list.innerHTML = '<div class="muted" style="padding:8px;">加载中…</div>';
let data;
try {
data = await api("GET", "/v1/schedules");
} catch (e) {
list.innerHTML = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(e.message)}</div>`;
return;
}
_jobs = data.results || [];
if (!_jobs.length) {
list.innerHTML =
'<div class="muted" style="padding:8px;font-size:12px;">还没有定时任务。' +
'对助手说「每天早上八点用 brief skill 出份简报发我邮箱」即可创建。</div>';
return;
}
const active = _jobs.filter((j) => j.enabled);
const paused = _jobs.filter((j) => !j.enabled);
let html = `<div class="sk-group-title">活跃 (${active.length})</div>`;
html += active.map(itemHtml).join("") ||
'<div class="muted" style="padding:4px 8px;font-size:12px;">(无)</div>';
if (paused.length) {
html += `<div class="sk-group-title" style="margin-top:12px;">已停用 (${paused.length})</div>`;
html += paused.map(itemHtml).join("");
}
list.innerHTML = html;
}
function row(k, v) {
return `<div class="cr-d-row"><span class="k">${k}</span><span class="v">${v}</span></div>`;
}
function selectJob(id, itemEl) {
$("cr-list").querySelectorAll(".sk-item.active").forEach((el) => el.classList.remove("active"));
if (itemEl) itemEl.classList.add("active");
const j = _jobs.find((x) => x.job_id === id);
if (!j) return;
const notify = j.notify && j.notify.to
? `必达邮件 → ${escapeHtml(j.notify.to)}` : "无(结果进任务线程 / 由指令自行投递)";
const modeDesc = j.mode === "persistent" ? "持续(同一任务线程,有连续性)" : "独立(每次新建任务,省 token)";
let rows =
row("排程", `${escapeHtml(j.schedule_desc || "")} <span class="muted">(${escapeHtml(j.cron)} @${escapeHtml(j.tz)})</span>`) +
row("模式", escapeHtml(modeDesc)) +
row("指令", escapeHtml(j.prompt)) +
(j.skill ? row("技能", escapeHtml(j.skill)) : "") +
row("通知", notify) +
row("下次触发", ts(j.next_run_at)) +
row("上次执行", `${ts(j.last_run_at)} · ${escapeHtml(j.last_status || "—")}`);
if (j.last_error) rows += row("上次错误", `<span style="color:#c0392b">${escapeHtml(j.last_error)}</span>`);
rows += row("累计运行", `${j.run_count}` + (j.consecutive_failures ? ` · 连续失败 ${j.consecutive_failures}` : ""));
const openTask = j.last_task_id
? `<button class="small" data-open-task="${escapeHtml(j.last_task_id)}">打开它跑的任务</button>` : "";
$("cr-detail").innerHTML =
`<div class="sk-d-head"><span class="sk-d-name">${escapeHtml(j.name)}</span> ${statusBadge(j)}<span class="spacer"></span></div>` +
rows +
`<div class="cr-acts">
<button class="small" data-toggle="${escapeHtml(j.job_id)}">${j.enabled ? "停用" : "启用"}</button>
<button class="small danger" data-del="${escapeHtml(j.job_id)}">删除</button>
${openTask}
</div>`;
}
// ───── 顶层绑定 ─────
$("hd-crons").onclick = openCronsModal;
$("cr-close").onclick = closeCronsModal;
$("crons-modal").addEventListener("click", (e) => {
if (e.target.id === "crons-modal") closeCronsModal(); // 点遮罩关闭
});
$("cr-list").addEventListener("click", (e) => {
const item = e.target.closest(".sk-item");
if (item) selectJob(item.getAttribute("data-id"), item);
});
$("cr-detail").addEventListener("click", async (e) => {
const toggle = e.target.closest("[data-toggle]");
const del = e.target.closest("[data-del]");
const openTask = e.target.closest("[data-open-task]");
if (openTask) {
closeCronsModal();
selectTask(openTask.getAttribute("data-open-task"));
return;
}
if (toggle) {
const id = toggle.getAttribute("data-toggle");
const j = _jobs.find((x) => x.job_id === id);
toggle.disabled = true;
try {
await api("PATCH", "/v1/schedules/" + encodeURIComponent(id), { enabled: !j.enabled });
await renderList();
selectJob(id);
} catch (err) {
alert("操作失败: " + err.message);
toggle.disabled = false;
}
return;
}
if (del) {
const id = del.getAttribute("data-del");
const j = _jobs.find((x) => x.job_id === id);
if (!confirm(`删除定时任务「${j ? j.name : id}」?不可撤销(停用可保留改用「停用」)。`)) return;
del.disabled = true;
try {
await api("DELETE", "/v1/schedules/" + encodeURIComponent(id));
$("cr-detail").innerHTML = PLACEHOLDER;
await renderList();
} catch (err) {
alert("删除失败: " + err.message);
del.disabled = false;
}
}
});

View File

@ -8,6 +8,7 @@ import { api } from "./api.js";
import { closeChpwModal } from "./auth.js";
import { closeSkillsModal } from "./skills.js";
import { closeMemoryModal } from "./memory.js";
import { closeCronsModal } from "./crons.js";
import { closeFilePreview, closeMiniPreview } from "./preview.js";
import { closeSrcPicker, loadFiles } from "./files.js";
import { loadFolderSuggestions } from "./newtask.js";
@ -87,6 +88,7 @@ document.addEventListener("keydown", (e) => {
if ($("chpw-modal").classList.contains("show")) { closeChpwModal(); return; }
if ($("skills-modal").classList.contains("show")) { closeSkillsModal(); return; }
if ($("memory-modal").classList.contains("show")) { closeMemoryModal(); return; }
if ($("crons-modal").classList.contains("show")) { closeCronsModal(); return; }
if ($("mini-preview-modal").classList.contains("show")) { closeMiniPreview(); return; }
if ($("src-picker-modal").classList.contains("show")) { closeSrcPicker(); return; }
if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; }