dsh-mcmp 2.2.3 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,545 @@
1
+ /**
2
+ * dsh-mcmp · 数学建模论文自动化流水线 —— 论文写作主循环模块(Host 侧)
3
+ *
4
+ * 职责:论文写作的主循环流程与循环自身的运行状态,覆盖所有正式工作的循环部分。
5
+ * - 步骤元数据(STEPS:8 步骤 × 19 迭代/轮)与提示词构造:
6
+ * buildPrompt(内含「质疑驱动」环节)、buildFinalizePrompt(兜底定稿);
7
+ * - 子智能体调度:runChild / pickProvider;
8
+ * - 主循环编排:runPipeline —— 按轮 × 迭代逐个启动专家子智能体,
9
+ * 含断点续跑计数(completedIterations)、连续失败熔断、中止处理、
10
+ * S5 识图路径解析与兜底定稿(不论成败都补一次 论文定稿.md);
11
+ * - 循环运行状态(state/logs/recording/active)与对外接口:
12
+ * snapshot(进度快照)、abort(中止)、reset(清空状态与续跑记录)。
13
+ *
14
+ * 通过 createPipeline({ fs, subagents, agents }) 创建实例;程序入口模块(lib/index.js)
15
+ * 负责触发与装配,显示界面模块(lib/client.js 浮窗)经 /mcmp-api 路由访问
16
+ * snapshot/abort/reset 完成显示与交互。
17
+ */
18
+
19
+ export const FLOW_NAME = '数学建模论文流水线v2'
20
+ // 全流水线缺陷台账:跨步骤错误登记、裁决与向上修复的唯一权威文件
21
+ export const LEDGER = '00_缺陷与裁决台账.md'
22
+ export const PER_ROUND = 19
23
+
24
+ // ---------- 步骤元数据(8 步骤 × 19 迭代/轮) ----------
25
+ const STEPS = [
26
+ { key: 'S1', name: '赛题分析与建模方案', phase: 'S1 赛题分析与建模方案', file: '01_赛题分析.md', iters: [
27
+ { name: '赛题精读与拆解', task: '(1) 环境清理:若工作区根目录已存在且含有 00_赛题原文.md 或 01_赛题分析.md(属于上一次运行),先用 shell 命令把整个「数学建模流水线」目录改名为 数学建模流水线_历史归档_YYYYMMDD_HHMMSS(只改名、绝不删除,完整保留历史成果),再重建空目录结构;(2) 通读赛题全文(含附件、表格、图),确认所选题目与全部子问题;(3) 列出问题背景、已知条件、数据说明、待求目标与提交要求(页数、格式、支撑材料);(4) 判断每个子问题的类型(预测/优化/评价/机理/决策等);(5) 圈出关键词与易误解之处(尤其题目附录中的条件定义),给出你的理解与疑问;(6) 将赛题原文完整保存为 00_赛题原文.md;(7) 建立目录结构:代码/、代码/results/、图表/;(8) 检查《缺陷与裁决台账》00_缺陷与裁决台账.md:**若已存在(多轮运行时第二轮起必然存在),直接读取,确认表头与使用规则完整即可,绝不覆盖或删除既有条目**;若不存在,按表头「编号|发现步骤|出错文件与位置|问题描述|证据|修复方案|状态(待修复/已修复/已否决)|修复人(轮次·步骤)|备注」创建,并在文件开头写入使用规则:①任何步骤发现前序步骤(或本步骤既往迭代)的错误,必须登记本台账,并**直接修复出错文件**(用编辑工具、在修改处标注「第R轮·Sx修正:D#编号」),不得只在下游文件里绕开;②修复后检查下游引用处,能同步的立即同步,不能同步的追加台账条目;③全流水线所有步骤以本台账最新裁决为最高权威,冲突时以台账为准;④状态由修复人维护,闭环后保留记录不删除。' },
28
+ { name: '建模方案定稿', task: '(1) 数据特征分析(缺失、异常、量纲、规模)与预处理方案;(2) 逐条编号的合理假设(初版)并说明理由;(3) 每个子问题列出 2-3 种候选建模思路,逐一分析优劣与可行性;(4) 为每个子问题敲定选用的模型与方法并说明理由;(5) 给出总体建模框架(问题→数据→假设→模型→求解→验证→结论);(6) 输出《建模方案定稿》,作为后续所有步骤的权威依据;(7) 列出编程实现清单、图表清单(含技术路线图/架构图)与论文结构大纲。' },
29
+ ] },
30
+ { key: 'S2', name: '模型建立与求解', phase: 'S2 模型建立与求解', file: '02_模型建立与求解.md', iters: [
31
+ { name: '模型建立', task: '(1) 建立全题统一的符号体系(符号表:含义+单位),后续步骤必须沿用;(2) 逐条编号模型假设(在 S1 基础上完善);(3) 对每个子问题建立数学模型:决策变量/目标函数/约束或方程,给出推导过程;(4) 说明模型与赛题要求的对应关系(尤其回应赛题附录中的条件定义);(5) 指出模型适用范围与前提。' },
32
+ { name: '模型求解', task: '(1) 可解析求解的给出完整推导与闭式解;(2) 需数值求解的给出算法原理、步骤与伪代码(最优化/微分方程/统计/启发式),说明复杂度、收敛性与可行性;(3) 对关键公式用手算小例子验证;(4) 明确哪些结果将在 S3 用程序实现,给出期望输出的数值口径(精度、单位)。' },
33
+ { name: '模型验证设计', task: '(1) 设计验证方案:特殊/退化情形、手算对照、交叉验证、独立仿真等;(2) 设计灵敏度分析方案(扰动参数、观察指标、扰动范围);(3) 检查模型假设的合理性,列出可放宽的假设及放宽后的修正方式;(4) 总结模型优点、不足与风险;(5) 把本轮形成的符号、公式、结论固化为 S3 的权威依据;若与 S1 定稿冲突,显式裁决:在《缺陷与裁决台账》登记,并直接修复出错文件(标注修正来源),不得只在本文件写裁决。' },
34
+ ] },
35
+ { key: 'S3', name: '编程实现与数值验证', phase: 'S3 编程实现与数值验证', file: '03_编程实现.md', iters: [
36
+ { name: '核心代码', task: '(1) 按 01/02 权威成果用 Python 实现每个子问题的核心算法,代码写入 代码/ 目录,文件名含步骤与版本(如 s3_model_v1.py),配中文注释;(2) 数据读取与预处理;(3) 确保可直接运行;运行并把关键输出记录到 03_编程实现.md;(4) 若缺依赖先 pip 安装(设 3 分钟时限,失败则改用纯标准库实现);(5) 旧代码一律保留。' },
37
+ { name: '调试与数值验证', task: '(1) 用手算小例子与极端参数对照测试;(2) 修复所有 bug,处理数据缺失/异常;(3) 边界情形测试;(4) 与 02 公式逐项核对,数值不一致必须定位原因并裁决(以程序实测为准):在《缺陷与裁决台账》登记,并直接修复出错的 02/01 文件(标注修正来源);(5) 数值结果落盘到 代码/results/ 的 CSV/JSON;(6) 新版本用 _v2/_v3 后缀另存,绝不删除旧版。' },
38
+ { name: '封装与自测', task: '(1) 整理为函数/类,参数化关键量;(2) 统一输出 代码/results/ 结构化数据(CSV/JSON + 结果索引);(3) 提供 main 入口一键复现全部结果;(4) 编写自测套件(断言:数值口径、概率范围、收支非负、退化情形),全部通过并保留报告;(5) 记录运行环境(语言版本、依赖及版本)与复现步骤;(6) 输出《数值验证报告》:权威数值清单,供图表与论文引用。' },
39
+ ] },
40
+ { key: 'S4', name: '图表与流程图生成', phase: 'S4 图表与流程图生成', file: '04_图表生成.md', iters: [
41
+ { name: '基础图表与结构图', task: '(1) 依据 03 权威 results/ 生成论文所需全部图:结果曲线、对比柱状图、散点+拟合、成本/误差分解图等;(2) 同时生成总体技术路线图与系统/模型架构图(graphviz/mermaid/matplotlib/networkx 或自建引擎,源文件保存 代码/,图片输出 图表/);(3) 优先尝试 pip 安装 matplotlib/PIL(3 分钟时限,失败则沿用/自建纯标准库 PNG 引擎);(4) 每张图:中文标题、轴标签、图例、单位,分辨率不低于 300dpi(或 1800×1200 + pHYs);(5) 布局防重叠:图例自动避让、长标签截断或换行、结论框/副标题预留空间、文本框之间留足间隙;(6) 绘图数据与 results/ 交叉核对(脚本断言),不符即退出;(7) 绘图代码保存 代码/。' },
42
+ { name: '美化统一', task: '(1) 统一字号、配色、线型风格(集中式 STYLE 常量);(2) 修复中文乱码(字体字形探测/回退),禁用易乱码字符(如下标数字、组合音标),统一 U+2212 为 "-";(3) 每张图加副标题(参数/口径说明)、结论框(关键数值+结论)、数据来源行;(4) 补充关键标注(重要数值、结论性说明);(5) 图内符号与论文符号体系一致;(6) 保证每张图脱离正文也能看懂。' },
43
+ { name: '精选定稿', task: '(1) 从全部图中精选论文真正需要的图(结果图+技术路线图+架构图),宁缺毋滥;(2) 确定编号(图1、图2…)与插入章节;(3) 冗余图移入 图表/归档/,不物理删除;(4) 定稿图统一导出到 图表/定稿/;(5) 输出《图表清单》(编号/路径/插入章节/一句话说明)与《程序化自检报告》(尺寸/墨量/数据断言);(6) 为 S5 视觉自检做好准备:列出每张定稿图对应的绘图代码与重渲染命令。' },
44
+ ] },
45
+ { key: 'S5', name: '图表视觉自检与修复', phase: 'S5 图表视觉自检与修复', file: '05_图表自检.md', iters: [
46
+ { name: '视觉自检与问题清单', vision: true, task: '(1) 逐张检查 图表/定稿/ 的全部图片,重点:①文字重叠/相互遮挡;②乱码、豆腐块、缺字;③文字被裁切/越出画布;④标题、轴标签、图例缺失或不完整;⑤标注数值与 代码/results/ 权威数据不一致;⑥曲线/柱体被文字遮挡。若你具备识图能力(视觉能力:可用):用识图工具逐张查看(必要时裁剪放大局部),列出每张图的问题清单(问题描述+图内位置+严重级:A=必须修复/B=应当修复)。若不具备识图能力(视觉能力:不可用):编写并运行 Python 分析脚本——在绘图代码中记录每个文本的包围盒(x,y,w,h)并计算两两交叠面积、文本框越界检测、字形回退(豆腐块)探测、墨量/空白检测,输出每图问题 JSON 报告。(2) 汇总为《图表视觉自检报告》写入 05_图表自检.md:每图问题清单+分级+修复建议;(3) 分析脚本保存 代码/。' },
47
+ { name: '修复与复检', vision: true, task: '(1) 对《图表视觉自检报告》中全部 A/B 级问题逐条实际修复:修改绘图代码(调整布局/字号/标签/图例位置/增加间距/换行/去重),重渲染出新版 PNG(定稿目录覆盖同名或 _v2 另存,旧版移入 归档/);(2) 每条修复登记到《修复验证表》:问题→修复方式(代码改动描述)→复检证据(视觉复核结论或脚本输出:重叠数=0);(3) 修复后再次自检(识图工具或脚本),A 级问题必须清零;若仍保留 B 级,给出取舍理由;(4) 更新 04_图表生成.md 的《图表清单》,注明每张图已通过视觉自检;(5) 禁止只记录不修复:每个问题必须有"修复+复检证据"或"显式弃修理由"。' },
48
+ ] },
49
+ { key: 'S6', name: '论文撰写', phase: 'S6 论文撰写', file: '06_论文.md', iters: [
50
+ { name: '初稿', task: '(1) 依据前 5 步权威成果撰写论文初稿,写入 06_论文.md 新的一节;(2) 标准结构:摘要(单独成页、含关键词,突出方法/结果/创新)、一 问题重述与分析、二 模型假设与符号说明、三 模型建立与求解(分问题、公式编号)、四 模型验证与灵敏度分析、五 模型评价与推广、六 参考文献、附录(程序清单);(3) 正文全部结论必须引用前序成果(图表、代码输出),不得凭空捏造数据;(4) 图表用相对路径引用 图表/定稿/ 的图,编号与《图表清单》一致;(5) 严禁流水线内部痕迹:不得出现"01_赛题分析.md""02 迭代3""03 步骤""流水线"等字样,证据一律表述为"支撑材料/附录";(6) 公式编号连续、符号与 02 一致。' },
51
+ { name: '润色终稿', task: '(1) 以评审视角逐节质疑:摘要是否一页内?公式与符号前后一致、编号连续?图表引用齐全、编号正确?有无"显然/易得"式跳步?灵敏度结论是否量化?参考文献格式规范?(2) 修订后重写一份完整、连续的终稿,写入新的一节(标题注明「终稿」),全文完整、可直接提交——不允许用"修订段落清单"代替完整终稿;(3) 保留初稿内容不删;(4) 全文检索流水线痕迹与内部文档名并清零;(5) 结尾附《终稿质检清单》(逐项 ✓/✗)。' },
52
+ ] },
53
+ { key: 'S7', name: '评审与修复落实', phase: 'S7 评审与修复落实', file: '07_评审修复.md', iters: [
54
+ { name: '合规检查与评审', task: '(1) 按国赛要求逐项检查 06 终稿:摘要单独成页含关键词、无学校/队员/指导教师信息、页数符合要求、图表编号与引用一一对应、公式编号连续、参考文献格式、附录程序清单、结果表数值与 results/ 一致、提交文件命名;(2) 以竞赛评审专家身份按国赛评审标准(摘要约10%、假设合理性、建模科学性、求解正确性、验证充分性、写作规范性、创新性)打分并写评语;(3) 汇总输出《合规检查报告》+《评审报告》+《问题清单》(每项含:级别 A=必须修复/B=应当修复/C=建议、位置、证据、修复建议);(4) 建立《问题跟踪表》(编号/描述/级别/状态=待修复),写入 07_评审修复.md;(5) 《问题清单》与《问题跟踪表》中凡涉及前序步骤文件的条目(模型/公式/数值/图表源头错误),同时登记到《缺陷与裁决台账》(若尚未登记),避免只在 06 论文层面打补丁。' },
55
+ { name: '修复落实', task: '(1) 对《问题清单》中全部 A/B 级问题逐一实际修复,而不是只写处理意见:论文问题 → 直接用编辑工具修改 06_论文.md 终稿正文(修改后终稿仍是完整连续的全文,禁止再追加"修订段落");图表问题 → 修改绘图代码并重渲染、复检;数值问题 → 修改代码重跑,与 results/ 复核一致后再更新论文;源头在 01~05 的问题 → 直接修复源头文件并在《缺陷与裁决台账》登记(标注修正来源),再同步下游引用;(2) 每条修复在《问题跟踪表》中登记:修复方式(文件+改动描述)、证据(命令/复检输出/断言结果)、状态(已修复);无法修复的写明阻塞原因与后续建议(状态:受阻);(3) 修复不得破坏既有正确内容;(4) 同步更新论文中受影响的图表引用与编号。' },
56
+ { name: '修复复核', task: '(1) 重新逐项核验《问题跟踪表》:每项重新检查(重跑自测/断言/视觉复检/文本检索),确认修复真实生效,状态更新为"已验证";(2) 对未闭环项继续修复;(3) 输出《修复验证报告》:问题→状态→验证证据,给出闭环率(A 级必须 100%);(4) 更新评审打分(修复后预估);(5) 为 S8 定稿列出移交清单:论文终稿路径、图表清单、代码入口、复现命令。' },
57
+ ] },
58
+ { key: 'S8', name: '论文定稿与提交包', phase: 'S8 论文定稿与提交包', file: '08_论文定稿.md', iters: [
59
+ { name: '论文定稿与提交包', task: '(1) 无论前序步骤是否全部成功,本步骤必须产出一版完整论文:检查 06_论文.md 是否有干净完整的终稿;若有,以其为底稿做最终校对后写入 论文定稿.md;若终稿缺失/混乱/不完整,则依据 00_赛题原文.md、01~07 全部成果与 代码/results/、图表/定稿/ 从头撰写完整论文,写入 论文定稿.md;(2) 论文定稿.md 必须是可直接提交的完整 Markdown:标题、摘要(含关键词)、问题重述与分析、模型假设与符号说明、模型建立与求解(公式编号)、模型验证与灵敏度分析、模型评价与推广、参考文献、附录,图表用相对路径引用;(3) 全文检索并清除流水线内部痕迹与参赛者信息;(4) 数值只采用 results/ 权威数据或前序已验证结论,缺失时明确标注"待补充"并给出推导,不得编造;(5) 输出《提交材料清单》(论文定稿.md、图表、代码、results/、复现说明)与《最后校对报告》;(6) 本步骤工作记录写入 08_论文定稿.md,最终论文全文写入 工作区根目录的 论文定稿.md;(7) 终检《缺陷与裁决台账》:凡状态仍为「待修复」且影响论文结论的条目,在本步骤内尽量闭环;确实无法闭环的,整理为「遗留缺陷清单」写入 08_论文定稿.md(供使用者参考,不得进入论文正文)。' },
60
+ ] },
61
+ ]
62
+ const FLAT = []
63
+ STEPS.forEach((st, si) => { st.iters.forEach((nm, ii) => { FLAT.push({ s: si, i: ii, name: nm.name }) }) })
64
+
65
+ /**
66
+ * 创建论文写作主循环实例。
67
+ * @param {object} services 宿主服务:{ fs, subagents, agents }
68
+ * @returns 主循环对外接口:{ isRunning, progressPercent, completedIterations, snapshot, trackSession, start, abort, reset }
69
+ */
70
+ export function createPipeline({ fs, subagents, agents }) {
71
+ // ---------- 运行状态 ----------
72
+ function freshState() {
73
+ return {
74
+ status: 'idle', runId: '', rounds: 0, round: 0, total: 0, done: 0,
75
+ phase: '', agentLabel: '', logs: [], files: [], error: '',
76
+ title: '', wsDir: '', problemPath: '', startedAt: 0, endedAt: 0,
77
+ vision: null, visionLive: '',
78
+ }
79
+ }
80
+ let state = freshState()
81
+ let active = null
82
+ let lastSession = null // 最近一次运行所属会话,供「清空状态」追加重置标记
83
+ const recording = new Map()
84
+
85
+ function fmtTime(ts) {
86
+ const d = new Date(ts)
87
+ const p = (n) => (n < 10 ? '0' + n : String(n))
88
+ return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds())
89
+ }
90
+
91
+ function pushLog(m) {
92
+ state.logs.push({ t: fmtTime(Date.now()), m })
93
+ if (state.logs.length > 60) state.logs.splice(0, state.logs.length - 60)
94
+ }
95
+
96
+ function mintRunId() {
97
+ return 'run-' + Date.now().toString(36) + '-' + Math.floor(Math.random() * 1679616).toString(36)
98
+ }
99
+
100
+ function makeController() {
101
+ try {
102
+ if (typeof AbortController === 'function') return new AbortController()
103
+ } catch (err) { /* ignore */ }
104
+ const listeners = []
105
+ const sig = {
106
+ aborted: false,
107
+ reason: undefined,
108
+ addEventListener: (t, fn) => { if (t === 'abort') listeners.push(fn) },
109
+ removeEventListener: () => {},
110
+ dispatchEvent: () => false,
111
+ }
112
+ return {
113
+ signal: sig,
114
+ abort: (reason) => {
115
+ if (sig.aborted) return
116
+ sig.aborted = true
117
+ sig.reason = reason
118
+ for (const fn of listeners) { try { fn() } catch (err) { /* ignore */ } }
119
+ },
120
+ }
121
+ }
122
+
123
+ function pickProvider() {
124
+ try {
125
+ const names = subagents && typeof subagents.list === 'function' ? subagents.list() : []
126
+ if (names && names.length > 0) return names.indexOf('spawn') >= 0 ? 'spawn' : names[0]
127
+ } catch (err) { /* ignore */ }
128
+ return undefined
129
+ }
130
+
131
+ // ---------- 断点续跑:从会话日志(落盘)恢复已完成迭代数(仅识别 v2 流程名) ----------
132
+ function completedIterations(session) {
133
+ if (!session || !Array.isArray(session.events)) return 0
134
+ const runs = []
135
+ let current = null
136
+ for (const ev of session.events) {
137
+ try {
138
+ if (ev.type === 'tool-workflow/mcmp-reset') {
139
+ // 用户点过「清空状态/重置」:此前的所有运行记录作废,只从标记之后开始计数
140
+ runs.length = 0
141
+ current = null
142
+ continue
143
+ }
144
+ if (ev.type === 'tool-workflow/run-start') {
145
+ const d = ev.data || {}
146
+ if (d.name === FLOW_NAME) {
147
+ current = { done: new Set() }
148
+ runs.push(current)
149
+ } else {
150
+ current = null
151
+ }
152
+ } else if (ev.type === 'tool-workflow/agent-end' && current) {
153
+ const d = ev.data || {}
154
+ if (Number.isSafeInteger(d.seq) && d.seq >= 1 && d.outcome === 'completed') current.done.add(d.seq)
155
+ }
156
+ } catch (err) { /* 单条事件解析失败忽略 */ }
157
+ }
158
+ // 一次运行从最小序号 S 起连续完成 C 个迭代,则该运行贡献 = (S-1) + C。
159
+ // 这样多轮/续跑运行(seq 从 20、39…继续编号)也能正确累计,而不是只数 1..19。
160
+ const contribution = (run) => {
161
+ if (!run || run.done.size === 0) return 0
162
+ let S = Infinity
163
+ for (const seq of run.done) if (seq < S) S = seq
164
+ let c = 0
165
+ while (run.done.has(S + c)) c++
166
+ return (S - 1) + c
167
+ }
168
+ const last = runs[runs.length - 1]
169
+ let n = last ? contribution(last) : 0
170
+ if (n === 0) {
171
+ let m = 0
172
+ for (const run of runs) m = Math.max(m, contribution(run))
173
+ n = m
174
+ }
175
+ return n
176
+ }
177
+
178
+ function snapshot() {
179
+ const done = state.done
180
+ const total = state.total
181
+ const pct = total > 0 ? Math.min(100, Math.floor((done * 100) / total)) : 0
182
+ const round = state.rounds > 0 ? Math.min(Math.floor(done / PER_ROUND) + 1, state.rounds) : 0
183
+ const roundStart = (round - 1) * PER_ROUND
184
+ const inRound = Math.max(0, Math.min(done - roundStart, PER_ROUND))
185
+ let cur = null
186
+ if (state.status === 'running' && total > 0 && done < total) {
187
+ const f = FLAT[done % PER_ROUND]
188
+ cur = { stepKey: STEPS[f.s].key, stepName: STEPS[f.s].name, stepIdx: f.s, iterIdx: f.i, iterName: f.name, iterTotal: STEPS[f.s].iters.length }
189
+ }
190
+ const counts = {}
191
+ for (let k = 0; k < inRound && k < FLAT.length; k++) {
192
+ const f = FLAT[k]
193
+ counts[f.s] = (counts[f.s] || 0) + 1
194
+ }
195
+ const steps = STEPS.map((st, si) => ({
196
+ key: st.key, name: st.name, file: st.file, phase: st.phase,
197
+ iterTotal: st.iters.length, iters: st.iters.map((n) => n.name),
198
+ done: counts[si] || 0, active: cur !== null && cur.stepIdx === si,
199
+ }))
200
+ return {
201
+ status: state.status, runId: state.runId, rounds: state.rounds, round, done, total, pct,
202
+ cur, steps, phase: state.phase, agentLabel: state.agentLabel,
203
+ logs: state.logs.slice(-12), files: state.files.slice(0, 80),
204
+ error: state.error, title: state.title, wsDir: state.wsDir, problemPath: state.problemPath,
205
+ startedAt: state.startedAt, endedAt: state.endedAt,
206
+ vision: state.vision, visionLive: state.visionLive,
207
+ }
208
+ }
209
+
210
+ async function refreshFiles() {
211
+ if (!fs || !state.wsDir) return
212
+ try {
213
+ const rootT = await fs.resolve(state.wsDir)
214
+ const rootL = await fs.listDir(rootT)
215
+ const files = []
216
+ for (const e of rootL) {
217
+ if (files.length >= 80) break
218
+ files.push({ name: e.name, type: e.type === 'directory' ? 'dir' : 'file' })
219
+ if (e.type === 'directory') {
220
+ try {
221
+ const sub = await fs.listDir(e.target)
222
+ for (const se of sub) {
223
+ if (files.length >= 80) break
224
+ files.push({ name: e.name + '/' + se.name, type: se.type === 'directory' ? 'dir' : 'file' })
225
+ }
226
+ } catch (err) { /* 子目录读取失败忽略 */ }
227
+ }
228
+ }
229
+ state.files = files
230
+ } catch (err) { /* 目录尚不存在时忽略 */ }
231
+ }
232
+
233
+ function buildPrompt(round, si, ii, s, it, args) {
234
+ const p = []
235
+ p.push('你正在参与「全国大学生数学建模竞赛论文」的多轮自动化撰写流水线,担任本轮「' + s.name + ' · ' + it.name + '」的执行专家。你的目标不是"写完",而是产出**可直接放进竞赛论文、经得起评审质疑**的内容。')
236
+ p.push('')
237
+ p.push('【流水线位置】第 ' + round + '/' + args.rounds + ' 轮 · 步骤 ' + (si + 1) + '/' + STEPS.length + ' ' + s.name + ' · 迭代 ' + (ii + 1) + '/' + s.iters.length + '「' + it.name + '」')
238
+ p.push('【工作区(绝对路径)】' + args.wsDir + ' —— 所有读写都在该目录内,使用绝对路径或相对它的路径。')
239
+ if (args.problem.kind === 'file') {
240
+ p.push('【赛题原文文件】' + args.problem.path + ' —— 先用你的文件工具读取该文件全文,作为唯一赛题依据。')
241
+ } else {
242
+ p.push('【赛题原文(已完整嵌入,请逐字精读)】')
243
+ p.push(args.problem.text)
244
+ }
245
+ p.push('')
246
+ p.push('【必读文件(先全部读完再动手,禁止跳过)】')
247
+ p.push('- ' + LEDGER + ' (全流水线《缺陷与裁决台账》:最高权威。先检查是否有与本步骤相关的「待修复」条目:有则在本迭代内先修复(直接修改出错文件并更新台账状态),再完成本轮任务)')
248
+ p.push('- ' + s.file + ' (本步骤历次迭代成果,本次在其基础上改进)')
249
+ for (let q = 0; q < si; q++) p.push('- ' + STEPS[q].file + ' (前序步骤的权威成果;若其内容与台账最新裁决冲突,以台账为准,并在自己的产出中采用台账裁决后的版本)')
250
+ if (round > 1) {
251
+ p.push('上一轮(第 ' + (round - 1) + ' 轮)成果已保存在上述文件中(标题形如「## 第' + (round - 1) + '轮…」)。先阅读上一轮对应内容,本轮必须在其基础上优化,质量不得低于上一轮。')
252
+ }
253
+ if (ii === 0) {
254
+ p.push('若本步骤文件尚不存在或为空,从零建立本步骤成果。')
255
+ } else {
256
+ p.push('若文件中缺失上一迭代(「第' + round + '轮·迭代' + ii + ' ' + s.iters[ii - 1].name + '」)成果,说明其失败:先补齐该迭代成果,再完成本次迭代。')
257
+ }
258
+ if (it.vision) {
259
+ p.push('')
260
+ p.push('【识图能力自检(强制,回复第一行)】检查你自己的工具列表:若含有 vision_describe / read_image / vision_ocr / vision_ground / vision_crop / vision_detect 等识图工具,回复第一行写「视觉能力:可用(工具:xxx)」并优先用识图工具检查图片;若没有任何识图工具,回复第一行写「视觉能力:不可用」,改走脚本分析路径。')
261
+ if (args.vision) {
262
+ p.push('【宿主侧探测(仅供参考)】系统检测到识图插件: ' + (args.vision.source === 'service' ? '服务 ' + args.vision.key : '工具 ' + (args.vision.tools || []).join(', ')) + '。若你的工具列表中确有其对应的调用方式,请直接调用它完成图片检查。')
263
+ } else {
264
+ p.push('【宿主侧探测(仅供参考)】未检测到独立识图插件;如果你装了识图插件但此处未检出,以你自己的工具列表为准,仍然按"视觉能力:可用"路径执行。')
265
+ }
266
+ }
267
+ p.push('')
268
+ p.push('【工作纪律】')
269
+ p.push('1. 质疑先行:开工前对既有成果提出至少 3 条具体质疑(假设是否成立、是否偏离赛题、推导有无漏洞、数据与结论是否一致、格式是否规范),并逐条给出处理决定(采纳并改进 / 否决并说明理由)。质疑清单必须放在回复最前面。')
270
+ p.push('2. 只改有据之处:经得起质疑的既有内容必须保留,不做无谓重写。')
271
+ p.push('3. 禁止编造:所有数值与结论必须来自赛题、实际计算或前序成果;信息不足时明确写出你的假设并说明理由,不得假装已知。')
272
+ p.push('4. 符号与术语一致:沿用前序步骤的符号体系,新增符号必须先定义。')
273
+ p.push('5. 论文级表达:推导完整、步骤清晰、结论可复现,杜绝"显然""易得"式跳步。')
274
+ p.push('6. 修复闭环:发现的每个问题要么在本迭代内真正修复并给出证据(修改后的文件/复检输出/断言结果),要么明确记录阻塞原因与后续建议;禁止"只把问题写进文档而不修复"。')
275
+ p.push('7. 无流水线痕迹:论文类文件(06_论文.md、08_论文定稿.md、论文定稿.md)不得出现内部文档引用("01_赛题分析.md""02 迭代3""03 步骤""流水线"等),证据一律表述为"支撑材料/附录"。')
276
+ p.push('8. 缺陷闭环(跨步骤):发现前序步骤(或本步骤既往迭代)的错误时,必须:①登记《缺陷与裁决台账》(编号 D#、证据、修复方案);②用编辑工具**直接修复出错文件**,在修改处标注「第R轮·Sx修正:D#编号」;③同步受影响的全部下游引用(论文/图表/代码/结果文件),做不到同步的追加台账条目。禁止只在自己文件里写"前序有误"后绕开。')
277
+ p.push('')
278
+ p.push('【本轮任务】' + it.task)
279
+ p.push('')
280
+ p.push('【交付要求(强制)】')
281
+ p.push('1. 完整成果追加写入 ' + s.file + ',标题「## 第' + round + '轮·迭代' + (ii + 1) + ' ' + it.name + '」;内容完整可复现,不得缩写,严禁删除或覆盖已有内容。')
282
+ p.push('2. 代码保存到 ' + args.wsDir + '/代码/,新版本用 _vN 后缀,旧版本一律保留;图片保存到 ' + args.wsDir + '/图表/,旧图保留。')
283
+ p.push('')
284
+ p.push('【回复结构】开头为「质疑清单:」;随后是本次工作与成果;结尾附不超过 200 字总结:完成内容、关键改进、产出文件清单(含路径)。')
285
+ return p.join('\n')
286
+ }
287
+
288
+ function buildFinalizePrompt(args, reason) {
289
+ const p = []
290
+ p.push('你正在参与「全国大学生数学建模竞赛论文」自动化流水线的**兜底定稿**环节。此前流水线提前结束(原因:' + reason + '),但交付要求是"不论最终结果如何,都必须产出一版完整的论文"。现在由你仅凭现有文件完成这个承诺。')
291
+ p.push('')
292
+ p.push('【工作区(绝对路径)】' + args.wsDir + ' —— 所有读写都在该目录内。')
293
+ if (args.problem.kind === 'file') {
294
+ p.push('【赛题原文文件】' + args.problem.path + ' —— 先用文件工具读取全文。')
295
+ } else {
296
+ p.push('【赛题原文(已完整嵌入)】')
297
+ p.push(args.problem.text)
298
+ }
299
+ p.push('')
300
+ p.push('【现有材料(全部先读,能用的必须用)】')
301
+ p.push('- 00_赛题原文.md、00_缺陷与裁决台账.md(全流水线裁决,最高权威)、01_赛题分析.md、02_模型建立与求解.md、03_编程实现.md、04_图表生成.md、05_图表自检.md、06_论文.md、07_评审修复.md、08_论文定稿.md(存在即读)')
302
+ p.push('- 代码/ 与 代码/results/ 下的全部代码与结构化结果(权威数值只从这里取)')
303
+ p.push('- 图表/定稿/ 下的全部图片(论文插图用相对路径引用)')
304
+ p.push('')
305
+ p.push('【任务(必须完成)】')
306
+ p.push('1. 判断 06_论文.md 中是否已有干净完整的终稿:有 → 以其为底稿做最终校对后写入 论文定稿.md;缺失/混乱/不完整 → 依据上述全部材料从头撰写完整论文,写入 论文定稿.md。')
307
+ p.push('2. 论文定稿.md 必须是可直接提交的完整 Markdown:标题、摘要(单独成页、含关键词)、问题重述与分析、模型假设与符号说明、模型建立与求解(公式编号)、模型验证与灵敏度分析、模型评价与推广、参考文献、附录(程序清单);图表用相对路径引用。')
308
+ p.push('3. 全文不得出现流水线内部痕迹与参赛者信息;数值只采用 results/ 权威数据或前序已验证结论,材料不足处明确标注"待补充"并给出推导,严禁编造。若台账中存在对前序结论的修正裁决,以台账为准。')
309
+ p.push('4. 在 08_论文定稿.md 追加一节「## 兜底定稿」,记录:定稿来源(06 终稿 / 重写)、完整度自评、遗留问题清单(把《缺陷与裁决台账》中仍未闭环的条目列入)。')
310
+ p.push('5. 输出《提交材料清单》与《最后校对报告》(写入 08_论文定稿.md 该节)。')
311
+ p.push('')
312
+ p.push('【工作纪律】质疑先行、禁止编造、无流水线痕迹、论文级表达。回复结尾附不超过 200 字总结。')
313
+ return p.join('\n')
314
+ }
315
+
316
+ function appendRec(runId, session, type, data) {
317
+ const rec = recording.get(runId)
318
+ if (!rec || !rec.ok) return false
319
+ try {
320
+ session.append(type, data)
321
+ return true
322
+ } catch (err) {
323
+ rec.ok = false
324
+ return false
325
+ }
326
+ }
327
+
328
+ function finishRun(runId, stopReason, errorMsg) {
329
+ const rec = recording.get(runId)
330
+ if (rec) {
331
+ if (rec.ok) {
332
+ try { rec.session.append('tool-workflow/run-end', { runId, stopReason }) } catch (err) { /* ignore */ }
333
+ }
334
+ recording.delete(runId)
335
+ }
336
+ if (state.runId === runId) {
337
+ state.status = stopReason
338
+ state.endedAt = Date.now()
339
+ if (stopReason === 'error') state.error = errorMsg || '流水线执行出错'
340
+ if (stopReason === 'cancelled') state.error = '已被用户中止(兜底定稿已尝试生成)'
341
+ state.agentLabel = ''
342
+ }
343
+ refreshFiles()
344
+ }
345
+
346
+ function doAbort(reason) {
347
+ if (!active) return { ok: false, reason: '没有运行中的流水线' }
348
+ try { active.controller.abort(new Error(reason || '用户在进度面板中中止')) } catch (err) { /* ignore */ }
349
+ return { ok: true }
350
+ }
351
+
352
+ // 清空状态 + 重置断点续跑记录:向最近一次运行所属会话追加一条重置标记,
353
+ // 续跑扫描遇到该标记会把之前的运行记录全部作废,下次 /loopbegin 从第 1 个迭代全新开始。
354
+ function resetRecords() {
355
+ if (state.status === 'running') return { ok: false, reason: '流水线运行中,不能重置' }
356
+ if (lastSession) {
357
+ try { lastSession.append('tool-workflow/mcmp-reset', { ts: Date.now() }) } catch (err) { /* 会话不可追加时忽略 */ }
358
+ lastSession = null
359
+ }
360
+ state = freshState()
361
+ active = null
362
+ return { ok: true }
363
+ }
364
+
365
+ function doReset() {
366
+ return resetRecords()
367
+ }
368
+
369
+ // 启动一个子智能体并返回 { ok, text, outcome }
370
+ // agentOptions:可选 { provider, model } —— 强制子任务使用指定模型路由(覆盖父会话的固化选项)
371
+ async function runChild(provider, session, agent, controller, label, promptText, onStarted, agentOptions) {
372
+ let child = null
373
+ try {
374
+ const parent = (agents && agents.get(session.id)) || agent
375
+ child = await subagents.start(provider, {
376
+ label,
377
+ prompt: [{ type: 'text', text: promptText }],
378
+ parent,
379
+ signal: controller.signal,
380
+ ...(agentOptions && agentOptions.provider && agentOptions.model ? { agentOptions } : {}),
381
+ })
382
+ if (onStarted) { try { onStarted(child) } catch (err) { /* ignore */ } }
383
+ const res = await child.result
384
+ const text = (Array.isArray(res.output) ? res.output.filter((b) => b && b.type === 'text').map((b) => b.text).join('') : '')
385
+ const outcome = res.stopReason === 'completed' ? 'completed' : res.stopReason === 'cancelled' ? 'cancelled' : 'failed'
386
+ return { ok: true, started: true, outcome, text: text || '' }
387
+ } catch (err) {
388
+ return { ok: false, started: false, outcome: controller.signal.aborted ? 'cancelled' : 'failed', text: '', err }
389
+ } finally {
390
+ if (child) { try { await child.dispose() } catch (err) { /* ignore */ } }
391
+ }
392
+ }
393
+
394
+ // ---------- Host 侧编排器:从断点继续(不依赖聊天 Agent 存活) ----------
395
+ async function runPipeline(runId, session, agent, args, controller) {
396
+ let failStreak = 0
397
+ let cancelled = false
398
+ let finalDone = false // S8(定稿)是否已成功完成
399
+ let stopReason = 'completed'
400
+ let errorMsg = undefined
401
+ const provider = pickProvider()
402
+ if (!provider) {
403
+ finishRun(runId, 'error', '没有可用的子智能体提供方(当前部署未注册 subagent provider)')
404
+ if (active && active.runId === runId) active = null
405
+ return
406
+ }
407
+ const total = args.rounds * PER_ROUND
408
+ for (let g = args.startIndex; g < total; g++) {
409
+ if (controller.signal.aborted) { cancelled = true; break }
410
+ const r = Math.floor(g / PER_ROUND) + 1
411
+ const f = FLAT[g % PER_ROUND]
412
+ const s = STEPS[f.s]
413
+ const it = s.iters[f.i]
414
+ if (g % PER_ROUND === 0) pushLog('【第 ' + r + '/' + args.rounds + ' 轮开始】')
415
+ const label = 'R' + r + '·S' + (f.s + 1) + ' ' + s.name + ' ' + (f.i + 1) + '/' + s.iters.length + ' ' + it.name
416
+ state.phase = s.phase
417
+ state.agentLabel = label
418
+ pushLog('开始: ' + label)
419
+ let started = false
420
+ let outcome = 'failed'
421
+ let note = ''
422
+ const res = await runChild(provider, session, agent, controller, label, buildPrompt(r, f.s, f.i, s, it, args), (child) => {
423
+ started = true
424
+ appendRec(runId, session, 'tool-workflow/agent-start', { runId, seq: g + 1, label, phase: s.phase, childId: String(child.id) })
425
+ }, args.agentOptions)
426
+ outcome = res.outcome
427
+ note = (res.text || '').trim().slice(0, 300)
428
+ if (res.err) note = '执行异常: ' + String((res.err && res.err.message) || res.err).slice(0, 200)
429
+ state.done = Math.min(g + 1, state.total)
430
+ refreshFiles()
431
+ if (outcome === 'completed') {
432
+ failStreak = 0
433
+ pushLog('完成: ' + label)
434
+ if (s.key === 'S8' && (g + 1) === total) finalDone = true
435
+ // 解析 S5 迭代1 的识图能力自报
436
+ if (s.key === 'S5' && f.i === 0) {
437
+ const m = /视觉能力[::]\s*(可用|不可用)/.exec(res.text || '')
438
+ if (m) {
439
+ state.visionLive = m[1] === '可用' ? 'vision' : 'script'
440
+ pushLog('图表自检路径: ' + (m[1] === '可用' ? '识图工具(视觉自检)' : '脚本分析(无识图工具)'))
441
+ }
442
+ }
443
+ } else {
444
+ failStreak += 1
445
+ pushLog(outcome === 'cancelled' ? '中止: ' : '失败: ' + label + (note ? '(' + note.slice(0, 80) + ')' : ''))
446
+ }
447
+ if (started) appendRec(runId, session, 'tool-workflow/agent-end', { runId, seq: g + 1, outcome })
448
+ if ((g + 1) % PER_ROUND === 0 && !controller.signal.aborted) pushLog('【第 ' + r + '/' + args.rounds + ' 轮完成】')
449
+ if (controller.signal.aborted) { cancelled = true; break }
450
+ if (failStreak >= 3) {
451
+ stopReason = 'error'
452
+ errorMsg = '连续 3 个子任务失败,流水线提前终止。常见原因:模型服务限流(429)/账户余额不足(402)/上下文过长/工作区文件问题;可尝试 /loopbegin --model 提供商/模型 换用其他模型,或 /loopreset 后重来。已有成果全部保留。'
453
+ break
454
+ }
455
+ }
456
+ if (cancelled) { stopReason = 'cancelled'; errorMsg = '已被用户中止' }
457
+ // ---------- 兜底定稿:不论成败,只要 S8 未完成,就补一次论文定稿 ----------
458
+ if (!finalDone) {
459
+ pushLog('正在执行兜底定稿:生成 论文定稿.md(不论前序结果如何)')
460
+ state.phase = 'S8 兜底·论文定稿'
461
+ state.agentLabel = 'S8 兜底·论文定稿'
462
+ const fresh = makeController()
463
+ // 关键:把兜底定稿的控制器登记为当前活跃控制器,
464
+ // 否则面板「中止」与 /loopabort 只会 abort 已失效的主控制器,兜底定稿无法被中止
465
+ if (active && active.runId === runId) active.controller = fresh
466
+ const reason = stopReason === 'cancelled' ? '用户中止' : stopReason === 'error' ? errorMsg : '正常流程已结束但定稿步骤未完成'
467
+ const fres = await runChild(provider, session, agent, fresh, '兜底·论文定稿(不论成败)', buildFinalizePrompt(args, reason), undefined, args.agentOptions)
468
+ if (fres.outcome === 'completed') {
469
+ finalDone = true
470
+ pushLog('兜底定稿完成: 论文定稿.md 已生成')
471
+ } else {
472
+ pushLog('兜底定稿未能完成: ' + (fres.text || '').trim().slice(0, 80) + ' —— 请查看工作区已有成果')
473
+ if (fres.outcome === 'cancelled') {
474
+ stopReason = 'cancelled'
475
+ errorMsg = '已被用户中止(兜底定稿未完成)'
476
+ } else if (stopReason === 'completed') {
477
+ stopReason = 'error'
478
+ errorMsg = '流水线迭代已完成,但兜底定稿生成失败'
479
+ }
480
+ }
481
+ }
482
+ finishRun(runId, stopReason, errorMsg)
483
+ if (active && active.runId === runId) active = null
484
+ }
485
+
486
+ // 启动一次运行:创建 run 记录与工作流卡片、初始化运行状态、后台启动主循环(立即返回)
487
+ function start({ session, agent, args, resumeCount, explicitModel }) {
488
+ // 断点续跑:主循环从该迭代开始(入口模块解析出的续跑计数)
489
+ args.startIndex = resumeCount
490
+ const runId = mintRunId()
491
+ const rec = { session, ok: true }
492
+ recording.set(runId, rec)
493
+ try {
494
+ session.append('tool-workflow/run-start', { runId, name: FLOW_NAME })
495
+ } catch (err) {
496
+ recording.delete(runId)
497
+ }
498
+ state = freshState()
499
+ state.status = 'running'
500
+ state.runId = runId
501
+ state.rounds = args.rounds
502
+ state.total = args.rounds * PER_ROUND
503
+ state.done = resumeCount
504
+ state.round = Math.min(Math.floor(resumeCount / PER_ROUND) + 1, args.rounds)
505
+ state.startedAt = Date.now()
506
+ state.wsDir = args.wsDir
507
+ state.problemPath = args.problemPath
508
+ state.title = args.title
509
+ state.vision = args.vision
510
+ const controller = makeController()
511
+ active = { runId, session, controller }
512
+ pushLog('流水线已启动: ' + args.rounds + ' 轮 × 19 次迭代,输出目录 ' + args.wsDir + (resumeCount > 0 ? '(断点续跑:跳过已完成的 ' + resumeCount + ' 次迭代)' : '') + '(关闭本聊天窗口不影响后台执行)')
513
+ if (args.vision) pushLog('识图能力探测: ' + (args.vision.source === 'service' ? '检测到识图服务 ' + args.vision.key : '检测到识图工具 ' + (args.vision.tools || []).join(',')) + '(S5 将自动调用)')
514
+ else pushLog('识图能力探测:未检测到独立识图插件,S5 将让子智能体自检(无识图则走脚本分析)')
515
+ if (explicitModel) pushLog('子任务模型路由:强制 ' + args.agentOptions.provider + '/' + args.agentOptions.model + '(--model 显式覆盖)')
516
+ else pushLog('子任务模型路由:跟随父对话当前选择 ' + args.agentOptions.provider + '/' + args.agentOptions.model + ' (无法读取时兜底 deepseek-official/deepseek-v4-flash)')
517
+ refreshFiles()
518
+ void runPipeline(runId, session, agent, args, controller).catch((err) => {
519
+ pushLog('流水线异常: ' + String((err && err.message) || err))
520
+ finishRun(runId, 'error', '流水线异常: ' + String((err && err.message) || err))
521
+ if (active && active.runId === runId) active = null
522
+ })
523
+ }
524
+
525
+ return {
526
+ // 是否正在运行(供入口模块做「已有流水线正在运行」校验)
527
+ isRunning: () => state.status === 'running',
528
+ // 当前进度百分比(与启动校验提示的口径一致)
529
+ progressPercent: () => (state.total > 0 ? Math.floor((state.done * 100) / state.total) : 0),
530
+ // 断点续跑:已完成的迭代数(入口模块据此决定起始迭代)
531
+ completedIterations,
532
+ // 进度快照(供 /mcmp-api/state 与 /loopstatus)
533
+ snapshot,
534
+ // 写进度日志(供入口模块在触发失败时记录原因)
535
+ log: pushLog,
536
+ // 记录本次运行所属会话(供 reset 向该会话追加重置标记)
537
+ trackSession: (session) => { lastSession = session },
538
+ // 启动一次运行(状态初始化 + 后台启动主循环)
539
+ start,
540
+ // 中止运行中的流水线(reason 缺省为面板中止)
541
+ abort: doAbort,
542
+ // 清空状态与断点续跑记录
543
+ reset: doReset,
544
+ }
545
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mcmp",
3
- "version": "2.2.3",
3
+ "version": "2.3.0",
4
4
  "description": "DeepSeek Harness 插件:数学建模论文自动化流水线 v2(8 步骤 × 19 迭代 × N 轮:图表视觉自检与修复、评审问题修复闭环、跨步骤缺陷台账与向上修复、论文兜底定稿、质疑驱动、断点续跑、进度面板)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",