Compare commits
No commits in common. "f8d11a24913ae6a59ea476b9b04a365739f04bcf" and "4f61b5fc56f98200d41467b9549fe22913ef02f4" have entirely different histories.
f8d11a2491
...
4f61b5fc56
53
DESIGN.md
53
DESIGN.md
|
|
@ -589,59 +589,6 @@ 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)
|
||||
|
|
|
|||
20
PROGRESS.md
20
PROGRESS.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||
|
||||
最后更新:2026-06-18(brief 简报重定位为「重要文献速览」+ 精简到三文件 + bump 0.20.0)
|
||||
最后更新:2026-06-18(新增 brief skill:科研方向简报,三路检索 documents/research/web + 文献计量趋势型简报)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -21,24 +21,6 @@
|
|||
|
||||
## 已完成关键能力
|
||||
|
||||
### 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
11
RUN.md
|
|
@ -44,17 +44,6 @@
|
|||
# 对话整个 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`)。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
**生成科研方向简报(重要文献速览)。**
|
||||
**生成科研方向简报(文献计量趋势型简报)。**
|
||||
|
||||
给定一个研究方向 + 时间窗,从各大相关期刊(**Elsevier 数据库优先**)挑选近期**重要论文**,产出两段式简报:**先一份重要论文列表(每篇带标题/作者/期刊/年月/DOI + 一段简介或摘要概述),再对这批论文做内容总结**。三路取数:research(逐刊精确取最新 Elsevier 论文 + DOI)+ documents(内部材料库取全文)取文献,web search 取政策·标准·产业动向(单列)。**只描述不给建议**——呈现"发了什么、讲了什么",判断留给读者。简报 ≠ 综述论文,要**快、准、客观**,5–20 分钟掌握一个方向近期发了哪些重要论文。
|
||||
给定一个研究方向 + 时间窗,用**三路真实数据**(documents 内部库取全文 / research 取近期 DOI 元数据 / web 取政策·会议·标准动向)产出一份**有判断、可溯源**的简报:热点聚类 + 新兴方法 + 关键进展 + 研究空白 + 产业政策动向。简报 ≠ 综述论文 —— 要**快、准、有取舍**("重要性优先于完整性"),帮决策者 / 课题组 5–20 分钟掌握一个方向近期态势。
|
||||
|
||||
**五阶段**:定题对齐 spec(方向+边界 / 时间窗 / 期刊范围 / 深度 / 数据源 / 语言 / 关注点)→ 三路取数(research+documents 取文献、web 取动向;中→英术语转译 + 跨源去重)→ 列清单(带摘要概述)+ 内容总结 → 引文核验 → 渲染验收。
|
||||
**六阶段**:定题对齐 spec(方向+边界 / 时间窗 / 受众 / 深度 / 源开关 / 语言 / 关注点)→ 三路检索取数(中→英术语转译 + 跨源去重,证据表)→ 趋势分析(3–7 热点簇,对齐后再写)→ 逐段起草 → 引文核验(复用 paper 三层协议)→ 渲染验收。
|
||||
|
||||
**深度三档(按篇数)**:`flash` 10–20 篇 / `standard` 20–40 篇 / `deep` 40–80 篇。
|
||||
**深度三档**:`flash` 快报(1–2 页)/ `standard` 标准(4–6 页)/ `deep` 深度(8+ 页,含机构-地理计量),各配字数 / 簇数 / 引文数预算。
|
||||
|
||||
**何时用**:
|
||||
- ✅ 用户要"简报 / 方向简报 / 最新文献 / 重要论文列表 / 研究动态 / 某方向近期重要论文 / 跟踪某领域最新研究"
|
||||
- ✅ 立项前想快速摸清一个方向近期发了哪些重要论文(产出可喂 proposal / analyze)
|
||||
- ✅ 用户要"简报 / 方向简报 / 研究动态 / 趋势报告 / 调研快报 / 跟踪某领域最新研究 / 某方向近期进展"
|
||||
- ✅ 立项前想快速摸清一个方向近期态势再决定要不要做(产出可喂 proposal / analyze)
|
||||
|
||||
**何时不用**:
|
||||
- ⛔ 只要文献清单 / DOI / PDF → research / documents
|
||||
- ⛔ 要写可投稿的综述论文(几十页、定论)→ paper(review 类型)
|
||||
- ⛔ 要把模糊科学问题拆成子问题 + 路线图 → analyze
|
||||
- ⛔ 要"对本院的建议" / 写本子 → proposal
|
||||
- ⛔ 要写本子 → proposal
|
||||
|
||||
**核心能力**:
|
||||
- **逐刊取重要论文**(`references/journals.md`):各建材子领域主流期刊清单(Elsevier 优先),精确 `publication_name` + `year_gte` 取最新,0 命中降级到 keyword 搜;按重要性(期刊层级 + 主题相关性 + 发现分量)筛、按 publication_date 留最新
|
||||
- **三路分工 + 去重**:research+documents 取文献(同 DOI 一条、documents 全文优先)、web 单列产业政策动向不混论文总结;中文方向→英文术语转译(SCM/LC3 等缩写展开)
|
||||
- **每篇带摘要概述**:列表不只标题,每篇 2–4 句讲研究对象/方法/主要发现,基于 abstract 或全文、不夸张不评判
|
||||
- **引文核验**:存在性 / DOI 真伪(以库返回字段为准)/ 支撑度(摘要概述与原文一致,partial 改概述迁就证据),编造零容忍
|
||||
- **自带 `render_docx.py`**:商务红主题 + 论文列表 `[n]` 作锚点、正文 `[n]`/`[Wn]` 引文上标回链 + DOI/URL 可点击超链接(条目内 DOI 子串也链)+ 化学式下标(CO₂/C₃S...,白名单不误伤 LC3/Ca2+);做 deck 转 ppt
|
||||
- **三路检索分工 + 去重**(`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
|
||||
|
||||
**典型产物**:`<方向>-简报.md`(默认,含 `01_papers` 重要论文列表 + `02_summary` 内容总结)+ `evidence.md`(证据表);可选转 docx / deck。
|
||||
**典型产物**:`<方向>-简报.md`(默认)+ `evidence.md`(证据表)+ `CITATIONS.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) → 改造成本组 / 本人专属版本(术语 / 模板 / 默认值),之后日常任务直接用改造版
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.20.1"
|
||||
__version__ = "0.18.0"
|
||||
|
|
|
|||
|
|
@ -56,10 +56,6 @@ 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
|
||||
|
|
@ -365,7 +361,6 @@ 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)。
|
||||
|
||||
|
|
@ -551,23 +546,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,405 +0,0 @@
|
|||
"""定时任务调度核心(DESIGN §8.5)。
|
||||
|
||||
纯逻辑层:cron→next_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)
|
||||
|
|
@ -21,7 +21,6 @@ from uuid import UUID, uuid4
|
|||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
|
|
@ -172,56 +171,3 @@ 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_at。mode:
|
||||
- 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
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -15,9 +15,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,156 +0,0 @@
|
|||
"""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_bg→LLM→回写 run_status→record_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())
|
||||
|
|
@ -1,108 +1,114 @@
|
|||
---
|
||||
name: brief
|
||||
description: 生成科研方向简报(research direction briefing / 重要文献速览)。给定一个研究方向 + 时间窗,从各大相关期刊(Elsevier 数据库优先)挑选近期重要论文,产出一份「重要论文列表 + 内容总结」的可读简报:先列清单(每篇带标题/作者/期刊/年月/DOI + 一段简介或摘要概述),再对这批论文做客观归纳。可溯源、不编造引文,**只描述不给建议**。当用户要"简报 / 方向简报 / 最新文献 / 重要论文列表 / 研究动态 / 某方向近期重要论文 / 跟踪某领域最新研究"时使用。
|
||||
description: 生成科研方向简报(research direction briefing / 文献计量趋势型简报)。给定一个研究方向 + 时间窗,用三路真实数据(documents 内部库取全文 / research 取近期 DOI 元数据 / web 取政策·会议·标准动向),产出一份热点聚类 + 新兴方法 + 关键进展 + 研究空白 + 产业政策动向的可读简报,每条论断可溯源、不编造引文。当用户要"简报 / 方向简报 / 研究动态 / 趋势报告 / 调研快报 / 某方向近期进展 / 文献综述快讯 / 跟踪某领域最新研究"时使用。
|
||||
---
|
||||
|
||||
# 科研方向简报(重要文献速览)
|
||||
# 科研方向简报
|
||||
|
||||
把"某方向近期发了哪些重要论文、都在讲什么"做成一份**可读、可溯源、客观**的简报。两段式:**先一份重要期刊论文列表(各大相关期刊、Elsevier 数据库优先;每篇带一段简介/摘要概述),再对这批论文做内容总结**。
|
||||
把"某研究方向最近发生了什么"变成一份**可读、可溯源、有判断**的简报。**先定题对齐 → 三路检索取数 → 趋势分析 → 逐段起草 → 引文核验渲染** —— 不要一口气出全文,定题和分析阶段先和用户对齐方向与边界。
|
||||
|
||||
> **只描述、不给建议。** 简报呈现"发了什么、讲了什么",不给"本院应当……/可切入……/建议……"。判断留给读者。
|
||||
>
|
||||
> **"重要"怎么挑**:来自主流期刊(Elsevier 旗舰刊优先)、方向上居中而非边缘、有实质发现。近期论文引用尚少,故主要看**期刊层级 + 主题相关性 + 发现的分量**,不是单纯按引用数。控量靠"重要性 + 时新",不靠主观褒贬。
|
||||
简报 ≠ 综述论文(paper review):综述要全面、深、给定论;简报要**快、准、有取舍**——"重要性优先于完整性",帮决策者 / 课题组 5–20 分钟掌握一个方向近期态势。
|
||||
|
||||
简报 ≠ 综述论文(paper review):综述要全面、深、给定论;简报要**快、准、客观**——5–20 分钟掌握一个方向近期发了哪些重要论文、各讲了什么。
|
||||
进度展示建议:用 `task_progress` 标记「定题对齐 / 三路检索 / 趋势分析 / 逐段起草 / 引文核验 / 渲染」关键阶段。
|
||||
|
||||
## 边界(免得和别的 skill 撞)
|
||||
## 边界(先划清,免得和别的 skill 撞)
|
||||
|
||||
- vs `research`/`documents`:它们**只取文献**;brief 把取回的论文**组织成可读列表 + 客观总结**。
|
||||
- vs `paper`(review):paper 写**可投稿综述**(几十页、定论);brief 出**轻量速览**(几页、客观、不给判断)。
|
||||
- vs `analyze`:analyze 拆**科学问题**;brief 围绕**已定方向**列近期重要论文。
|
||||
- vs `proposal`:proposal 写**本子、给建议**;brief 只列论文 + 客观总结。要"对本院的建议" → 转 proposal。
|
||||
| 与谁区分 | 边界 |
|
||||
|---|---|
|
||||
| vs `research`/`documents` | 它们**只取文献**(候选清单 / 全文);brief 是消费方,把取回的文献**组织成有判断的趋势简报**,引文核验接到它们头上 |
|
||||
| vs `paper`(review 类型) | paper-review 写**可投稿的综述论文**(IMRaD/主题式、几十页、定论);brief 出**轻量趋势简报**(几页、有取舍、面向决策),不投稿 |
|
||||
| vs `analyze` | analyze 把**模糊科学问题**拆成子问题 + 路线图(不查文献);brief 围绕**一个已定方向**摸近期态势(重检索)。两者可互为上下游(先拆问题再摸态势,或先摸态势再拆) |
|
||||
| vs `proposal` | proposal 写**本子**(立项依据);brief 只摸方向近期态势,不写立项依据。要立项 → 把简报喂给 proposal |
|
||||
|
||||
## 资源(路径相对 `load_skill` 头里的 `dir=<绝对路径>`)
|
||||
**何时不用**:只要文献清单 / DOI / PDF → research/documents;要写可投稿综述 → paper(review);要拆科学问题 → analyze;要写本子 → proposal。
|
||||
|
||||
- `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` 跑。
|
||||
## 资源
|
||||
|
||||
产物默认 `.md`;要 docx 用 render_docx.py;要 deck 转 `ppt` skill。
|
||||
下面所有路径相对 **`<skill_dir>`** —— `load_skill` 返回头里的 `[skill=brief, dir=<绝对路径>]`,用这个绝对路径拼脚本/资源,不要假设 cwd。
|
||||
|
||||
## 阶段一:定题对齐(BLOCKING)
|
||||
**先读(always)**:
|
||||
- `<skill_dir>/templates/brief_outline.md` —— 简报骨架 + 按深度(快报/标准/深度)的字数预算与簇数/引文数
|
||||
- `<skill_dir>/references/search_strategy.md` —— 三路检索分工(documents/research/web)+ 跨源去重 + 中文方向→英文术语转译
|
||||
|
||||
写一份 task 级 spec(命名见 system prompt《task 级「宪法」文件命名约定》),填下面字段,**有歧义先反问、不替用户拍板**,写完复述确认再往下:
|
||||
**阶段五必读**:
|
||||
- `<skill_dir>/references/citation_verify.md` —— 引文核验协议(存在性 / 三角印证 / 支撑度,复用 paper 思路,接 documents/research/web)
|
||||
|
||||
1. **方向 + 边界**:具体到子方向(不是"水泥"而是"低碳水泥 SCM");明确纳入/排除
|
||||
2. **时间窗**:默认**近 1 年**(简报是"最新文献",窗口宜短);换算成 `year_gte`(今年见 system prompt)
|
||||
3. **期刊范围**:默认按方向所属子领域取 `journals.md` 主流期刊(Elsevier 优先);用户可增删指定刊
|
||||
4. **深度 / 篇数**:`flash` 10–20 篇 / `standard`(默认)20–40 篇 / `deep` 40–80 篇
|
||||
5. **数据源(默认三路并用)**:research + documents **都是获取文献的主力**(research 按期刊精确取最新 Elsevier 论文 + DOI;documents 取内部材料库全文),web search 取政策·标准·产业动向(**单列、不混进论文总结**)。某一路不可用时降级用其余两路,不整体放弃
|
||||
**模板**:
|
||||
- `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` 快报(1–2 页)/ `standard` 标准(4–6 页)/ `deep` 深度(8+ 页,含机构-地理计量)—— 见 brief_outline.md 预算
|
||||
5. **数据源开关**:documents(内部库,材料类首选)/ research(补 DOI 与近期元数据)/ web(政策·会议·标准·产业动向)—— 默认三路并用,用户可关
|
||||
6. **语言**:中文(默认)/ 英文
|
||||
7. **特殊关注点**(可选):想重点呈现的材料体系 / 方法(仍只描述,不给建议)
|
||||
7. **特殊关注点**:用户特别想知道的(如"重点看 CCUS 与水泥结合""谁在做工业固废路线")—— 写进 spec,分析阶段重点回应
|
||||
|
||||
## 阶段二:三路取数(research + documents 取文献 / web 取动向)
|
||||
写完把 spec 七条**复述给用户确认**,认可后进阶段二。
|
||||
|
||||
**先读 `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。缩写与全称都试。
|
||||
## 阶段二:三路检索取数
|
||||
|
||||
**research(逐刊取最新 Elsevier 论文 + DOI)** —— `run_python`:
|
||||
**先读 `references/search_strategy.md`**(三路分工 + 中→英术语 + 去重)。流程:
|
||||
|
||||
```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` 命中目标刊的;仍空记"该刊本窗口库内无收录"。
|
||||
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)
|
||||
|
||||
**documents(内部材料库取全文,材料类首选)** —— host-side tool `document_search`,中英 query 都行(后端跨语言语义检索);胶凝材料库 `classification_id=1`。取 `md_content` 既做候选也供引文核验抓锚点最顺。
|
||||
收 20–80 条(按深度),**不求穷尽**,够支撑各簇即可。命中 0 条先换同义词/放宽年份,3 次仍空如实告诉用户库未覆盖,**不脑补文献**。
|
||||
|
||||
**web search(取动向)** —— 政策(双碳/碳配额)、标准(新国标/团标)、行业会议、企业产线中试。**单列"其他动向",不混进论文列表与总结**。
|
||||
## 阶段三:趋势分析(和用户对齐结构)BLOCKING-lite
|
||||
|
||||
- 汇成证据表 `<task_dir>/evidence.md`:期刊 | 标题 | 第一作者(机构)| 年-月 | 摘要概述 | DOI | 来源(research/documents/web)。
|
||||
- 跨源去重:同 DOI 一条(documents 全文优先,DOI 记自 research);web 不与论文去重、单列。
|
||||
把证据表**聚成 3–7 个热点簇**(按深度),给用户看簇划分 + 每簇代表文献,认可后再起草。每簇判断:
|
||||
|
||||
> **库时效(必交代)**:research(OpenAlex)约 3 个月索引滞后,"最新"= 库内最新。窗口内 0 篇 → 如实告知库未收录该窗口,可用 web 补更近的非论文动向,**不脑补文献**。
|
||||
- **这个簇在做什么 / 解决什么问题**(一句话主题句,不是关键词堆砌)
|
||||
- **代表性进展**(2–4 篇,带真实引文)
|
||||
- **新兴方法 / 技术**(出现的新表征、新建模、新工艺)
|
||||
- **争议 / 分歧 / 未解**(哪里还没共识)
|
||||
|
||||
## 阶段三:列清单 + 内容总结(写 `<task_dir>/sections/*.md`)
|
||||
横向再扫:**研究空白**(大家都没做的)、**机构-地理格局**(deep 才做,元数据够时:谁在领跑、中国占比)、**产业/政策动向**(来自 web)。
|
||||
|
||||
骨架四段(`flash` 可省 `00`/`03`):
|
||||
> 取舍纪律:一个方向近期可能上百篇,简报只留**改变判断的**。重复验证性工作合并成一句"多篇验证了 X";边缘工作直接不收。宁缺毋滥。
|
||||
|
||||
- **`00_overview.md` 概览**:方向 + 纳入/排除边界 + 时间窗 + 覆盖了哪些期刊 + 收录多少篇。无引文。
|
||||
- **`01_papers.md` 重要论文列表(主体)**:按期刊 `###` 分组,每篇一条,行首 `[n]`(渲染时此段作参考锚点、`[n]` 带 DOI 超链接):
|
||||
```
|
||||
### Cement and Concrete Research(Elsevier)
|
||||
## 阶段四:逐段起草
|
||||
|
||||
[1] <标题>. <第一作者> et al., Cement and Concrete Research, 2026-03. DOI: 10.1016/j.cemconres.2026.xxxxxx
|
||||
按 `brief_outline.md` 骨架写 `<task_dir>/sections/*.md`,**每段一个论断 + 证据**:
|
||||
|
||||
<简介/摘要概述:2–4 句,讲研究对象、方法/表征、主要发现与关键数据 —— 基于 abstract 或全文,不夸张、不评判>
|
||||
```
|
||||
按 `publication_date` 倒序,最新在前。每篇都要有摘要概述,不能只留标题。
|
||||
- **`02_summary.md` 内容总结**:对这批论文**客观归纳**——主题分布、常涉材料体系、常用方法/表征、共同关注点;引具体论文挂 `[n]` 上标(回链到 01)。**只描述"这批论文在讲什么",不给"应当/建议/可切入"**。
|
||||
- **`03_web.md` 其他动向(仅 spec 开 web 时)**:政策/标准/会议/产业,`[W1]` 标来源 + 日期,单列。
|
||||
- TL;DR 要点(5 行内,先给结论)→ 方向概览与边界 → 研究热点聚类(各簇)→ 新兴方法 → 近期标志性进展 → 研究空白与争议 → 产业/政策/标准动向(web,可选)→ 参考文献
|
||||
- 起草时引文用占位 `[CITE-<keyword>]`,阶段五核验后映射真实条目并编号
|
||||
- 数字 / 定量结论必须挂引文;"据报道""有研究表明"这种无源句式禁止
|
||||
|
||||
数字/定量结论必须挂 `[n]`;"据报道""有研究表明"这类无源句式禁止。
|
||||
## 阶段五:引文核验(渲染前必跑)
|
||||
|
||||
## 阶段四:引文核验(渲染前必跑)
|
||||
**先读 `references/citation_verify.md`**,对所有引文逐条核验:存在性(两库/web 命中)→ 三角印证(关键论断 ≥2 源)→ 支撑度(抓原文锚点,partial 就改论断迁就证据)。台账写 `<task_dir>/CITATIONS.md`。
|
||||
|
||||
论文直接来自 research/documents,DOI 以**库返回字段为准**(不沿用记忆、不编造)。逐条核验:
|
||||
**铁律(同 paper)**:status 非 verified 的引文不得进最终稿;不为凑数编造文献;支撑不足改论断不改证据;两库/web 都查不到如实告诉用户。
|
||||
|
||||
1. **存在性**:`search()`/`get_paper(doi)` 或 documents 命中确认真实存在;查不到 → 标 `[未核实]`,告诉用户"找不到来源,请提供 DOI 或删去",**不编造**。
|
||||
2. **支撑度**:摘要概述 / `[n]` 论断要和 abstract(或全文)一致;不一致 → **改概述迁就证据**,不是改证据。
|
||||
3. **web**:记原始 URL + 访问日期 + 发布机构,标"截至 <日期>";不当学术结论引。
|
||||
## 阶段六:渲染验收
|
||||
|
||||
台账可写 `<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)、有没有混进"建议/启示/本院应当"措辞。
|
||||
- 交付一句话说清:覆盖了哪些期刊、收了多少篇、时间窗、哪些刊本窗口库内无收录。
|
||||
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. 交付时一句话说清:覆盖了哪几路源、收了多少条证据、哪些被取舍、哪些点是单源待复核
|
||||
|
||||
## 反模式
|
||||
|
||||
- ❌ **给建议/启示/"本院应当"** —— 只描述论文讲了什么,判断留给读者
|
||||
- ❌ 列表只留标题、没摘要概述 —— 每篇都要 2–4 句简介
|
||||
- ❌ 跳过定题直接检索 / 用中文 keyword 搜英文库 / 期刊名不精确 —— 先定题、转英文术语、用精确 `publication_name`
|
||||
- ❌ web 资讯混进论文列表/总结 —— 单列"其他动向"
|
||||
- ❌ 编造 DOI / "据报道"无源句 —— 查不到就如实说
|
||||
- ❌ 跳过定题直接检索 —— 方向边界没定,检索词发散,收一堆不相关
|
||||
- ❌ 把命中的文献**全部**堆进简报 —— 简报是取舍的艺术,不是清单转储
|
||||
- ❌ web 抓的资讯当学术结论引 —— web 动向单列,学术论断要文献支撑
|
||||
- ❌ 编造 DOI / "据报道"无源句 —— 走 citation_verify,查不到就如实说
|
||||
- ❌ 用中文 keyword 搜英文库 —— 先转专业英文术语(见 search_strategy.md)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
# 引文核验协议(简报版)
|
||||
|
||||
与 `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""国办发〔2025〕x号")
|
||||
- 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 都查不到如实告诉用户,给"提供来源 / 删论断"两个选项
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
# 各建材子领域主流期刊清单(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 方向所属子领域,从上面对应表里取 **3–8 本主流刊**(Elsevier 优先),逐刊拉最新。
|
||||
- 跨子领域的方向(如"低碳水泥固废路线")→ 水泥表 + 绿色表合并取。
|
||||
- 每本刊取最新若干篇后,**按重要性筛**:主题居中、有实质发现的留,边缘/纯验证性的弃(控量见 SKILL.md 篇数预算)。
|
||||
- 主流刊都覆盖到了就够,不必穷举所有刊。哪些刊本窗口库内 0 收录,交付时如实点出。
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
# 三路检索策略(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)",去重在表上做完再起草
|
||||
|
||||
## 收多少 / 收到什么程度
|
||||
|
||||
- 按深度收 20–80 条候选(见 brief_outline 预算),**不求穷尽**——够支撑各簇判断即可
|
||||
- 每簇至少 2 篇代表文献(关键论断 ≥2 源,接 citation_verify 三角印证)
|
||||
- 命中 0 条:换同义词 / 展开缩写 / 放宽年份;3 次仍空 → 如实告诉用户库未覆盖该窗口,**不脑补**
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
"""科研方向简报质量检查 — 渲染前跑一遍。
|
||||
|
||||
检查项:
|
||||
- 结构完整性: 按深度(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()
|
||||
|
|
@ -2,9 +2,8 @@
|
|||
|
||||
相对 paper/render_docx.py 的简报专属增强:
|
||||
- **商务红配色**(主色 #C00000):标题分级染色 + 标题下细色条;TL;DR / 「判断」行做浅红底纹 callout
|
||||
- **引文上标 + 内部超链接**:正文 [1] / [W3] → 上标红色,点击锚到「重要论文列表 / 参考文献」段对应条目
|
||||
- **论文列表 / 参考文献可点击**:标题含「论文列表 / 文献列表 / 参考文献」的段,行首 [n] 条目作锚点;
|
||||
条目内 DOI(整条是 DOI 或末尾 "DOI: 10.xxx")→ https://doi.org/... 蓝色超链接;web 条目里的域名/路径 → https:// 超链接
|
||||
- **引文上标 + 内部超链接**:正文 [1] / [W3] → 上标红色,点击锚到文末参考文献对应条目
|
||||
- **参考文献可点击**:DOI → https://doi.org/... 蓝色超链接;web 条目里的域名/路径 → https:// 超链接
|
||||
- **化学式下标(白名单)**:CO2 / C3S2 / Na2O / SO4 ... → 真实下标,**白名单精确匹配**,不误伤 LC3 / EN 197-5 / 8.5 Mt / 2026
|
||||
|
||||
字体规范同院内其它渲染:中文宋体小四 / 英文 Times New Roman 小四 / 行距 1.5 / 首行缩进 2 字符。
|
||||
|
|
@ -408,7 +407,6 @@ 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)
|
||||
|
||||
|
||||
|
|
@ -430,18 +428,6 @@ 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):
|
||||
|
|
@ -618,10 +604,7 @@ 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)
|
||||
# 只在 H1/H2 重判段类型 —— 让「重要论文列表」段下的 ### 期刊子标题不重置 in_refs,
|
||||
# 子标题下的 [n] 条目才能继续按参考锚点渲染(带 DOI 超链接)
|
||||
if level <= 2:
|
||||
in_refs = ("参考文献" in title) or ("论文列表" in title) or ("文献列表" in title)
|
||||
in_refs = "参考文献" in title
|
||||
expect_meta = (level == 1)
|
||||
if level <= 2:
|
||||
in_tldr = ("要点" in title) or ("TL;DR" in title.upper())
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
# 简报骨架 + 篇幅预算
|
||||
|
||||
简报按深度分三档,骨架同构、详略不同。`sections/` 一节一文件,文件名见各段标注。
|
||||
|
||||
## 篇幅预算(按深度)
|
||||
|
||||
| 深度 | 页数 | 总字数(中) | 热点簇数 | 引文数(学术) | 机构-地理计量 |
|
||||
|---|---|---|---|---|---|
|
||||
| `flash` 快报 | 1–2 | 800–1500 | 2–3 | 8–15 | 不做 |
|
||||
| `standard` 标准 | 4–6 | 2500–4000 | 3–5 | 20–40 | 可选(一句话点格局) |
|
||||
| `deep` 深度 | 8+ | 6000+ | 5–7 | 40–80 | 做(发文量趋势 / 国别机构格局) |
|
||||
|
||||
> 字数是预算不是硬指标;宁可短而准,不要灌水凑页。"重要性优先于完整性"。
|
||||
|
||||
## 骨架(各档同构)
|
||||
|
||||
### `00_tldr.md` — 一句话要点(TL;DR)
|
||||
|
||||
- 5 行内,**先给结论**:这个方向近期最值得知道的 3–5 件事,每行一句带判断
|
||||
- 决策者只读这段也能拿到核心态势
|
||||
- 例:`- LC3(石灰石煅烧黏土水泥)从中试走向标准化,近 1 年多国发布技术规程`
|
||||
|
||||
### `01_overview.md` — 方向概览与边界
|
||||
|
||||
- 这个方向**解决什么问题**(1–2 段)、本简报的**纳入/排除边界**(照搬 spec)
|
||||
- 时间窗 + 数据源覆盖说明(收了哪几路、多少条),让读者知道简报的"视野范围"
|
||||
|
||||
### `02_clusters.md` — 研究热点聚类(主体)
|
||||
|
||||
按 spec 深度分 3–7 簇,每簇一个 `###` 小节:
|
||||
|
||||
```
|
||||
### 簇N:<主题句,写判断不写关键词>
|
||||
- 在做什么 / 解决什么:<1–2 句>
|
||||
- 代表性进展:<2–4 篇,带 [CITE-xx],各一句 takeaway + 关键数字>
|
||||
- 新兴方法/技术:<若有>
|
||||
- 争议/未解:<若有>
|
||||
```
|
||||
|
||||
### `03_methods.md` — 新兴方法 / 技术(可并入 02,deep 单列)
|
||||
|
||||
- 跨簇出现的新表征 / 新建模(如 ML 配比优化)/ 新工艺,各带代表文献
|
||||
|
||||
### `04_progress.md` — 近期标志性进展
|
||||
|
||||
- 近 N 年**改变判断**的标志性成果(里程碑论文 / 中试 / 突破),3–8 条,带引文与数字
|
||||
|
||||
### `05_gaps.md` — 研究空白与争议
|
||||
|
||||
- 大家都没做的(空白)、还没共识的(争议)、方法学局限 —— 这段是简报"有判断"的体现
|
||||
|
||||
### `06_industry.md` — 产业 / 政策 / 标准动向(web,可选)
|
||||
|
||||
- 双碳政策 / 碳配额 / 新国标团标 / 行业会议 / 企业产线中试 —— **单列,标注来源为 web 资讯**
|
||||
- 与学术引文分开计数;时效性内容注明日期
|
||||
|
||||
### `08_references.md` — 参考文献
|
||||
|
||||
- 仅 citation_verify 核验通过(verified / verified-revised / 用户确认)的进清单
|
||||
- 学术引文按文中首次出现顺序编 `[1][2]...`,带 DOI;web 来源另起一段标 URL + 访问日期
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
# 简报 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 快报(1–2页) / standard 标准(4–6页,默认) / 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 如 确认是否纳入碱激发体系>`
|
||||
|
|
@ -1,202 +0,0 @@
|
|||
"""定时任务对话工具(DESIGN §8.5 对话端)。
|
||||
|
||||
create / list / update / cancel —— 对话端的完整 CRUD。host-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']})"
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
"""发邮件(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_python。env:
|
||||
SMTP_HOST SMTP_PORT(默 465) SMTP_USER SMTP_PASSWORD
|
||||
SMTP_FROM(默 SMTP_USER) SMTP_TLS(ssl|starttls|none;默按端口:465→ssl 否则 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()
|
||||
198
web/app.py
198
web/app.py
|
|
@ -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 from_db_path, to_db_path
|
||||
from core.paths import to_db_path
|
||||
from core.storage import (
|
||||
NoSubtaskError,
|
||||
check_no_subtask,
|
||||
session_scope,
|
||||
)
|
||||
from core.storage.models import Message, ScheduledJob, Task, UsageEvent
|
||||
from core.storage.models import Message, Task, UsageEvent
|
||||
from core.storage.utils import ensure_local_task_row
|
||||
|
||||
from .auth import (
|
||||
|
|
@ -349,7 +349,6 @@ 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。
|
||||
|
||||
|
|
@ -373,7 +372,6 @@ 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)
|
||||
|
|
@ -512,12 +510,6 @@ 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 段)。
|
||||
|
|
@ -699,155 +691,6 @@ 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 为空,
|
||||
|
|
@ -939,12 +782,6 @@ 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:
|
||||
|
|
@ -1492,37 +1329,6 @@ 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(),从任务列表隐藏。
|
||||
|
|
|
|||
|
|
@ -292,42 +292,6 @@
|
|||
.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 {
|
||||
|
|
@ -1266,22 +1230,6 @@
|
|||
</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">
|
||||
|
|
@ -1376,10 +1324,6 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -1,163 +0,0 @@
|
|||
// 定时任务 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -8,7 +8,6 @@ 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";
|
||||
|
|
@ -88,7 +87,6 @@ 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; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue