diff --git a/.gitignore b/.gitignore index 59519db0..64c459a5 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ skills/5writing/templates/**/missfont.log # LaTeX 模板编译生成的 PDF(保留 huaweibei 的 logo.pdf 和 title.pdf 模板资源) skills/5writing/templates/**/main.pdf +__pycache__/ +*.py[cod] diff --git a/README.md b/README.md index 8a9eef88..c4adcc89 100644 --- a/README.md +++ b/README.md @@ -96,11 +96,14 @@ MathModelAgent SKILL —— 直接在 Harness 中驱动的数学建模自动化 完全开源免费,可接入任何模型。 **🧠 端到端自动化** -从问题分析、建模、编码、绘图到论文排版和验收,一条 `/1start-mathmodel` 命令全自动完成,中间阶段自动串联,无需人工干预。 +从问题分析、建模、编码、绘图到论文排版和验收,一条 `/1start-mathmodel` 命令自动串联;国赛任务还会根据全过程证据自动生成 AI 使用详情。 **📄 17 套 Typst 论文模板** 内置中英文主流赛事模板(国赛、华数杯、华为杯、MCM/ICM 等),自动匹配赛事类型,生成排版精良、可直接提交的 PDF 论文。 +**🧾 国赛 AI 使用详情** +国赛任务从开始阶段持续记录真实 AI 使用情况;完整论文验收后自动归纳采纳、迭代修正与实际核验,并隐藏核心建模细节,同时生成 `paper/main.pdf` 和名称准确的 `supporting_materials/AI 工具使用详情.pdf`,缺少任一文件都不会报告最终完成。 + **📐 内置建模知识库** 包含完整的建模规范、模型选择决策树(AHP、TOPSIS、ARIMA、GA 等)、常见易错模式和 MCM/ICM 评分标准,每个阶段自动参考,降低模型幻觉。 @@ -148,6 +151,7 @@ codex: $start-mathmodel 完成这个数学建模任务 ``` /doctor: 检查环境配置 /typst-author: typst 知识 +/7ai-disclosure: 持续记录 AI 使用并生成“AI 工具使用详情.pdf” ``` diff --git a/README_EN.md b/README_EN.md index c4afa62c..213d150f 100644 --- a/README_EN.md +++ b/README_EN.md @@ -26,6 +26,8 @@ The desktop app bundles Claude Code and the full set of MathModelAgent SKILLS. No Python / Node.js / Redis installation and no manual SKILL setup — install it, add one model API key, and start modeling. +For CUMCM tasks, the workflow records actual AI use throughout the run, automatically summarizes evidence-backed adoption, revisions, and checks without exposing core modeling details, and requires both `paper/main.pdf` and `supporting_materials/AI 工具使用详情.pdf` before reporting final completion. + | OS | File | |----|------| | macOS (Apple Silicon) | `mathmodel--arm64.dmg` | @@ -62,6 +64,7 @@ Automatically generate an award-level modeling paper - Local Interpreter: Based on Jupyter, code saved as notebook for easy editing - Cloud Code Interpreter: [E2B](https://e2b.dev/) and [daytona](https://app.daytona.io/) - 📝 Generate a well-formatted paper +- 🧾 Generate the required CUMCM AI-usage disclosure PDF from confirmed run records - 🤝 Multi-agents: modeling expert, coding expert, paper expert, etc. - 🔄 Multi-LLMs: Different models for each agent - 🤖 Support for all models: [litellm](https://docs.litellm.ai/docs/providers) diff --git a/skills/1start-mathmodel/SKILL.md b/skills/1start-mathmodel/SKILL.md index ce215597..eb60f72c 100644 --- a/skills/1start-mathmodel/SKILL.md +++ b/skills/1start-mathmodel/SKILL.md @@ -1,7 +1,7 @@ --- name: 1start-mathmodel -description: "数学建模竞赛工作流入口。用于启动完整建模流程:询问用户偏好,生成 plan.md 和 todo.md,并按阶段调用赛题分析、建模、代码与图表、流程图、论文撰写、验证验收等 skills。" -allowed-tools: Bash(*), Read, Write, Edit, Grep, Glob, Agent, WebSearch, WebFetch +description: "数学建模竞赛工作流入口。用于启动完整建模流程:询问用户偏好,生成 plan.md 和 todo.md,并按阶段调用赛题分析、建模、代码与图表、流程图、论文撰写、验证验收及 AI 使用披露等 skills。" +allowed-tools: Bash(*), Read, Write, Edit, Grep, Glob, Agent, AskUserQuestion, WebSearch, WebFetch --- # 数学建模工作流 @@ -12,12 +12,20 @@ allowed-tools: Bash(*), Read, Write, Edit, Grep, Glob, Agent, WebSearch, WebFetc 如需领域判断,读取 `../_references/math_modeling_norms.md`。该文件只提供数学建模基本规范和防错知识,不改变本 skill 的阶段顺序和产出约定。 +## 国赛识别与强制披露 + +- 用户选择 `CHINA`、国赛、CUMCM 或全国大学生数学建模竞赛时,立即将任务标记为国赛,并强制加载 `7ai-disclosure`。 +- 明确为其他赛事时,不生成国赛专用的 `AI 工具使用详情.pdf`。 +- 无法判断赛事时,在开始阶段询问一次赛事类型,不得等到论文完成后再判断。 +- 桌面 Agent 正在参与论文生成时,视为实际使用了 AI,不得走“全程未使用 AI”分支。 + ## 必须产出 在当前工作目录中创建或更新以下文件: - `plan.md`:整体流程方案、建模方向、阶段顺序、预期产物和风险控制。 - `todo.md`:具体待办事项列表,记录每个阶段的任务和状态。 +- 国赛任务的 `reports/AI_USAGE_LOG.md`:赛中持续维护并在终检时自动归纳的 AI 工具使用过程记录。 ## 工作流 @@ -57,6 +65,7 @@ workflow: 3. 流程与架构图绘制 - `4drawio` 4. 竞赛论文撰写 - `5writing` 5. 验证和验收 - `6verity` +6. AI 使用详情终检与生成 - `7ai-disclosure`(仅国赛强制) ``` ## 项目目录结构 @@ -72,6 +81,7 @@ workflow: │ ├── RESULTS_REPORT.md # 2: 结果报告(3coding-visual) │ ├── DRAWIO_REPORT.md # 3: 非数据图说明(4drawio) │ ├── VERIFY_REPORT.md # 5: 验收报告(6verity) +│ └── AI_USAGE_LOG.md # 国赛全程: AI 使用记录(7ai-disclosure) ├── code/ # 2: 代码(3coding-visual) │ ├── problem1.py │ ├── problem2.py @@ -85,6 +95,9 @@ workflow: ├── paper/ # 4: 论文(5writing) │ ├── main.typ / main.tex # 论文主文件(按用户选择的引擎) │ └── sections/ # 各节文件(.typ 或 .tex) +└── supporting_materials/ # 6: AI 使用支撑材料(确有使用 AI 时) + ├── AI 工具使用详情.tex + └── AI 工具使用详情.pdf ``` 方案必须明确每个阶段由哪个下游 skill 负责,以及该阶段应产出什么文件。 @@ -101,6 +114,7 @@ workflow: - [ ] 3. 流程与架构图绘制 - `4drawio` - [ ] 4. 竞赛论文撰写 - `5writing` - [ ] 5. 验证和验收 - `6verity` +- [ ] 6. AI 使用详情终检与生成 - `7ai-disclosure`(仅国赛强制) ``` 每完成一个阶段,都要更新 `todo.md` 中对应任务的状态。 @@ -116,6 +130,20 @@ workflow: | 流程与架构图绘制 | `4drawio` | 在论文确实需要时,绘制方法流程图、架构图和非数据型概念图。 | `figures/*.drawio`, `figures/*.pdf`, `DRAWIO_REPORT.md` | | 竞赛论文撰写 | `5writing` | 基于分析、建模、代码结果和图表撰写最终竞赛论文,并按章节直接插入图表。 | `paper/` | | 验证和验收 | `6verity` | 检查可复现性、一致性、产物完整性、格式规范和提交就绪状态。 | `VERIFY_REPORT.md` | +| AI 使用详情终检与生成(仅国赛强制) | `7ai-disclosure` | 校验全过程记录,生成并验收赛事要求的 AI 使用支撑材料。 | `AI_USAGE_LOG.md`, `AI 工具使用详情.pdf` | + +## AI 使用记录衔接 + +以下规则只对国赛任务强制执行: + +1. 启动时加载 `7ai-disclosure`,从其模板初始化 `reports/AI_USAGE_LOG.md`。优先根据本次任务的真实模型配置创建工具条目;无法获得版本元数据时写“运行环境未提供具体版本”。 +2. `2analysis-modeling`、`3coding-visual`、`4drawio`、`5writing` 和 `6verity` 每个阶段结束后,立即调用 `7ai-disclosure record`,根据真实对话、模型配置和产物追加记录;不得等到赛后凭记忆补造。 +3. `6verity` 首先完成论文验收并得到 `PAPER_PASS`。此状态只表示论文通过,不能表示整个国赛提交包已经完成。 +4. 随后调用 `7ai-disclosure finalize`。它根据本次会话、文件、运行结果和验收记录自动归纳并脱敏,不为填写详情另行询问队员。 +5. 自动归纳只能陈述有证据的采纳、迭代修正和核验;没有人工修改证据时省略该句,不得补造人工经历。通过最终校验后运行确定性生成脚本。 +6. 只有 `paper/main.pdf` 与名称完全一致的 `supporting_materials/AI 工具使用详情.pdf` 都存在并通过检查,才把 `todo.md` 的最后一步标记完成,并在 `reports/VERIFY_REPORT.md` 写最终 `PASS`。 + +若不是桌面 Agent 参与生成、且队伍能够确认全程未使用任何 AI,才允许不生成空白详情 PDF,并在验收报告中写明确认依据。 ## 阶段边界 diff --git a/skills/6verity/SKILL.md b/skills/6verity/SKILL.md index 1d0f6bf8..69df89c6 100644 --- a/skills/6verity/SKILL.md +++ b/skills/6verity/SKILL.md @@ -1,12 +1,12 @@ --- name: 6verity -description: "数学建模竞赛最终验证和验收阶段,支持 Typst 和 LaTeX 双引擎。用于论文写完后检查章节数量、标题顺序、图表引用、数值一致性、占位符、内部文件泄露、参考文献、代码可复现性、编译和提交就绪状态。" +description: "数学建模竞赛论文验证和验收阶段,支持 Typst 和 LaTeX 双引擎。用于论文写完后检查章节数量、标题顺序、图表引用、数值一致性、占位符、内部文件泄露、参考文献、代码可复现性、编译、AI 支撑材料状态和提交就绪状态。" allowed-tools: Bash(*), Read, Write, Edit, Grep, Glob, Agent, WebSearch, WebFetch --- # 验证和验收(Typst / LaTeX) -本 skill 是完整工作流的最后一关。它不重新建模、不生成新结果、不代替写作阶段重写论文;它负责发现硬错误、修复可直接修复的问题,并输出 `reports/VERIFY_REPORT.md`。 +本 skill 是论文与项目产物的验收门禁。它不重新建模、不生成新结果、不代替写作阶段重写论文;它负责发现硬错误、修复可直接修复的问题,并输出 `reports/VERIFY_REPORT.md`。若项目使用过 AI,随后还必须由 `7ai-disclosure` 完成支撑材料终检与生成。 ## 数学建模规范参考 @@ -16,6 +16,7 @@ allowed-tools: Bash(*), Read, Write, Edit, Grep, Glob, Agent, WebSearch, WebFetc - 本阶段负责:结构验收、文本质量门禁、图表引用检查、结果一致性检查、Typst/LaTeX 编译检查、PDF 视觉检查、提交清单。 - 本阶段不负责:重新设计模型、重新跑大规模实验、重新组织整篇论文。 +- 本阶段只核对 AI 使用日志和支撑材料状态,不补写或猜测 AI 使用记录;自动归纳由 `7ai-disclosure` 根据实际产物和检查证据完成。 - 发现硬错误时,优先做小范围修复;如果需要回到前序阶段,写入 `reports/VERIFY_REPORT.md` 并标记为未通过。 ## 输入 @@ -29,6 +30,7 @@ allowed-tools: Bash(*), Read, Write, Edit, Grep, Glob, Agent, WebSearch, WebFetc 5. 图表目录 6. 可复现代码目录。 7. 编译后的 PDF,或可由入口文件编译得到的输出 PDF。 +8. 国赛任务必须提供 `reports/AI_USAGE_LOG.md`;最终验收还必须提供 `supporting_materials/AI 工具使用详情.tex` 和 `supporting_materials/AI 工具使用详情.pdf`。其他赛事仅在实际存在时检查。 不要假设论文目录一定叫 `paper/`,也不要假设结果文件一定在项目根。若项目使用不同命名,按实际结构传参并在 `reports/VERIFY_REPORT.md` 中说明。 @@ -185,7 +187,17 @@ fi 如果模型没有视觉能力,必须在 `reports/VERIFY_REPORT.md` 中明确写出“未执行视觉检查”的原因,并至少完成 PDF 非空、页数、页面尺寸等可程序化检查。 -### Step 9: 写验收报告 +### Step 9: 检查 AI 使用支撑材料状态 + +- 先读取 `plan.md` 的竞赛类型。`CHINA`、国赛、CUMCM 或全国大学生数学建模竞赛均按国赛处理。 +- 国赛任务必须能够加载 `7ai-disclosure`,并存在 `reports/AI_USAGE_LOG.md`。桌面 Agent 参与论文生成时,不得写“全程未使用 AI”。 +- 首次验收论文时,用 `record` 模式检查日志。论文自身全部通过可以记录为 `PAPER_PASS`,但此状态不是最终提交 `PASS`。 +- `7ai-disclosure` 完成证据驱动的自动归纳、脱敏和 PDF 生成后,再用 `finalize` 模式检查日志,并检查 `supporting_materials/AI 工具使用详情.pdf` 文件名完全一致。 +- 检查该 PDF 非空、可提取文本、页面为 A4、包含规定四部分且无占位符或疑似密钥,并逐页查看是否越界、重叠、裁切或乱码。 +- 国赛任务只有 `paper/main.pdf` 与 `supporting_materials/AI 工具使用详情.pdf` 均通过检查,整体状态才能写 `PASS`。 +- 非国赛任务只有在用户明确要求时才检查该国赛支撑材料。 + +### Step 10: 写验收报告 创建 `reports/VERIFY_REPORT.md`: @@ -193,7 +205,7 @@ fi # 验证和验收报告 ## 结论 -PASS / FAIL +PAPER_PASS / PASS / FAIL ## 检查项 | 检查项 | 结果 | 说明 | @@ -211,10 +223,12 @@ PASS / FAIL ## PDF 视觉检查 +## AI 工具使用支撑材料 + ## 仍需处理的问题 ``` -只有当硬错误都修复、文本门禁通过、核心图表都引用、数值一致、编译通过或明确说明不可编译原因、视觉检查通过或明确说明无法执行原因时,才写 `PASS`。 +论文自身通过、但国赛 AI 支撑材料尚未完成自动归纳或尚未生成时,只能写 `PAPER_PASS`。只有全部硬错误都修复,且国赛要求的两个 PDF 均通过最终检查时,才写 `PASS`。 ## 硬错误标准 @@ -232,6 +246,8 @@ PASS / FAIL - 编译器可用但论文编译失败。 - 编译后的 PDF 为空、缺页、页数异常或页面尺寸异常且无法解释。 - 视觉检查发现正文、表格、图片、公式、页眉页脚、页码等关键元素重叠、裁切、越界或乱码。 +- 日志表明使用过 AI,但最终提交缺少名称完全一致的 `AI 工具使用详情.pdf`。 +- AI 使用日志仍有待确认或未归纳内容、未经证据支持的人工表述、核心建模细节或疑似密钥,却将整体提交状态标记为 `PASS`。 ## 警告标准 diff --git a/skills/7ai-disclosure/SKILL.md b/skills/7ai-disclosure/SKILL.md new file mode 100644 index 00000000..b7df31fa --- /dev/null +++ b/skills/7ai-disclosure/SKILL.md @@ -0,0 +1,72 @@ +--- +name: 7ai-disclosure +description: "全国大学生数学建模竞赛 AI 工具使用过程记录与支撑材料生成。国赛论文工作流启动时自动启用,根据任务证据持续记录并自动归纳工具、用途、提示过程、采纳、修正和核验,在隐藏核心建模细节后生成四部分简版“AI 工具使用详情.pdf”。" +allowed-tools: Bash(*), Read, Write, Edit, Grep, Glob +--- + +# AI 工具使用记录与详情生成 + +本 skill 有 `record` 和 `finalize` 两种模式。国赛任务由 `1start-mathmodel` 强制调用;其他赛事只在用户明确要求时使用。它只整理本次任务中可观察的 AI 使用,不补造记录,也不自动宣称合规。整个披露流程无人值守,不为填写详情另行询问用户。 + +桌面 Agent 正在参与国赛论文生成时,视为实际使用了 AI,不得生成“全程未使用 AI”的空记录。 + +## 输出约束 + +最终 PDF 只包含标题、页眉页码和以下四个一级部分: + +1. 所用 AI 工具名称、版本或型号; +2. 具体使用目的和环节; +3. 主要提示方式与使用过程说明,可附非表格的典型交互示例; +4. 对 AI 输出的采纳、人工修改和核验的主要情况,语言润色除外。 + +第一部分使用“编号、AI 工具名称、版本或型号”三列表格;第二部分使用“工具编号、使用环节、具体使用目的”三列表格。工具编号采用 `TOOL-01`、`TOOL-02` 等,第二至第四部分必须引用对应编号。禁止写入密钥、密码、完整聊天记录、模型私有思维链或无关个人信息。 + +详情 PDF 只写阶段级概括。即使日志或论文中存在,也不得写入赛题条件、原始数据、模型或算法名称、公式、关键假设、参数、数值结果、结论以及足以复原解题方案的连续提示。使用“辅助方案梳理”“辅助代码实现与调试”“辅助结果一致性检查”“辅助论文结构与表达优化”等概括表达。 + +## record:持续记录 + +1. 若 `reports/AI_USAGE_LOG.md` 不存在,从 `templates/AI_USAGE_LOG.md` 复制创建。 +2. 查看当前阶段的真实对话、运行配置、文件差异、运行结果和产物。工具名称与模型优先读取本次任务的真实配置或响应元数据;不可获得时写“运行环境未提供具体版本”,不得根据供应商或界面猜测版本。 +3. 复用已有 `TOOL-xx`;新工具使用下一个连续编号,编号必须从 `TOOL-01` 开始连续递增。 +4. 追加一个连续编号的 `AI-xxx` 记录,写明工具编号、使用环节、具体目的、提示方式、使用过程、采纳情况、迭代修正、核验方法、证据文件和记录状态。 +5. 采纳情况根据内容是否进入最终文件以及后续是否被替换、删除或修正归纳。迭代修正只写文件差异或重新运行能够支持的事实;核验只写实际执行成功并有结果记录的检查。 +6. 只有会话中存在队员明确修改指令时,才可增加“人工修改内容”和“人工修改依据”。没有此类证据时直接省略这两个字段,不写任何“未记录人工修改”的提示。 +7. 仅用于语言润色时,将“是否仅用于语言润色”写为“是”,迭代修正和核验字段可以省略。 +8. 典型交互为可选项;如记录,只保留脱敏的提示、输出和处理结果摘要,不复制全部对话。 +9. 阶段处理中使用“记录中”;阶段证据归纳完成后使用“已归纳”。不得推断队员完成了未留下证据的人工操作。 +10. 每个国赛阶段结束后立即运行记录校验: + +```bash +PYTHON_BIN="$(command -v python3 || command -v python)" +"$PYTHON_BIN" "<本 skill 目录>/scripts/validate_ai_usage_log.py" reports/AI_USAGE_LOG.md --mode record +``` + +## finalize:生成支撑材料 + +1. 自动读取运行时元数据、各阶段报告、最终文件、代码执行结果和 `reports/VERIFY_REPORT.md`,逐条归纳工具版本、采纳、迭代修正和核验情况,不向用户发起确认或补充提问。 +2. 将所有公开描述改写为阶段级概括,删除核心建模细节。没有版本元数据时使用“运行环境未提供具体版本”;没有人工修改证据时删除人工修改字段,不输出否定性说明。 +3. 若缺少核验证据,先执行安全、可复现的轻量检查并保存结果,再填写实际结果;不得把计划中的检查写成已经完成。完成后把记录状态更新为“已归纳”。 +4. 运行最终校验: + +```bash +PYTHON_BIN="$(command -v python3 || command -v python)" +"$PYTHON_BIN" "<本 skill 目录>/scripts/validate_ai_usage_log.py" reports/AI_USAGE_LOG.md --mode finalize +``` + +存在待确认、缺项、无效工具引用、未归纳记录、疑似密钥、未经证据支持的人工表述或核心建模细节时停止生成。 + +5. 最终校验通过后,必须调用确定性生成脚本,不再让模型自由复制或手填模板: + +```bash +"$PYTHON_BIN" "<本 skill 目录>/scripts/generate_ai_usage_pdf.py" \ + reports/AI_USAGE_LOG.md \ + --output-dir supporting_materials +``` + +生成脚本负责日志到四部分模板的映射、LaTeX 转义、XeLaTeX 两遍编译、Tectonic 回退,以及文件名、非空、A4、文本层、规定部分、占位符和疑似密钥检查。典型交互使用正文段落,不使用表格;证据文件和内部状态只用于校验,不写入最终 PDF。 + +6. 必须得到名称完全一致的 `supporting_materials/AI 工具使用详情.pdf`,再逐页检查是否存在越界、重叠、裁切或乱码。 +7. 在 `reports/VERIFY_REPORT.md` 记录自动归纳、日志校验、编译、程序化 PDF 检查和逐页视觉检查结果。 +8. 只有 `paper/main.pdf` 与 `supporting_materials/AI 工具使用详情.pdf` 均通过检查,国赛提交包才能标记最终 `PASS`。 + +仅当本任务并非由桌面 Agent 生成、且队伍确认全程未使用任何 AI 时,不生成空白详情 PDF,并在验收报告中记录确认依据。 diff --git a/skills/7ai-disclosure/scripts/generate_ai_usage_pdf.py b/skills/7ai-disclosure/scripts/generate_ai_usage_pdf.py new file mode 100644 index 00000000..df98a3e1 --- /dev/null +++ b/skills/7ai-disclosure/scripts/generate_ai_usage_pdf.py @@ -0,0 +1,515 @@ +#!/usr/bin/env python3 +"""Generate the CUMCM AI usage disclosure from a validated Markdown log.""" + +from __future__ import annotations + +import argparse +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +from validate_ai_usage_log import ( + NO_HUMAN_RECORD_PHRASES, + PENDING_MARKERS, + SECRET_PATTERNS, + id_sections, + parse_fields, + validate, +) + +TEMPLATE_MARKERS = ( + "%%__TOOLS_ROWS__%%", + "%%__PURPOSE_ROWS__%%", + "%%__PROCESS_BLOCKS__%%", + "%%__ADOPTION_BLOCKS__%%", +) +REQUIRED_PDF_HEADINGS = ( + "一、所用AI工具名称、版本或型号", + "二、具体使用目的和环节", + "三、主要提示方式与使用过程说明", + "四、对AI输出的采纳、人工修改和核验的主要情况", +) + + +class GenerationError(RuntimeError): + """Raised when a disclosure artifact cannot be generated safely.""" + + +@dataclass(frozen=True) +class ToolEntry: + """One AI tool declared in the usage log.""" + + tool_id: str + name: str + version: str + + +@dataclass(frozen=True) +class UsageRecord: + """One stage-level AI usage record.""" + + record_id: str + tool_ids: tuple[str, ...] + stage: str + purpose: str + language_only: bool + prompt_method: str + process: str + adoption: str + iteration: str + verification: str + human_edit: str + + +@dataclass(frozen=True) +class InteractionExample: + """One optional representative interaction example.""" + + example_id: str + record_ids: tuple[str, ...] + prompt_summary: str + output_summary: str + process_result: str + + +@dataclass(frozen=True) +class DisclosureLog: + """Structured data parsed from the Markdown log.""" + + tools: tuple[ToolEntry, ...] + records: tuple[UsageRecord, ...] + examples: tuple[InteractionExample, ...] + + +def _field(fields: dict[str, list[str]], name: str, default: str = "") -> str: + """Return a stable, compact value for a structured Markdown field.""" + values = [value.strip() for value in fields.get(name, []) if value.strip()] + return ";".join(values) if values else default + + +def parse_log(path: Path) -> DisclosureLog: + """Parse a validated AI usage log into structured records. + + Args: + path: Markdown log path. + + Returns: + Structured disclosure data. + """ + text = path.read_text(encoding="utf-8-sig") + tools: list[ToolEntry] = [] + records: list[UsageRecord] = [] + examples: list[InteractionExample] = [] + + for item_id, block in id_sections(text): + fields = parse_fields(block) + if item_id.startswith("TOOL-"): + tools.append( + ToolEntry( + tool_id=item_id, + name=_field(fields, "工具名称"), + version=_field(fields, "版本或型号"), + ) + ) + elif item_id.startswith("AI-"): + tool_ids = tuple( + dict.fromkeys(re.findall(r"TOOL-\d{2}", _field(fields, "工具编号"))) + ) + records.append( + UsageRecord( + record_id=item_id, + tool_ids=tool_ids, + stage=_field(fields, "使用环节"), + purpose=_field(fields, "具体使用目的"), + language_only=_field(fields, "是否仅用于语言润色") == "是", + prompt_method=_field(fields, "主要提示方式"), + process=_field(fields, "使用过程说明"), + adoption=_field(fields, "AI 输出采纳情况"), + iteration=_field(fields, "迭代修正情况"), + verification=_field(fields, "核验方法与结果"), + human_edit=_field(fields, "人工修改内容"), + ) + ) + elif item_id.startswith("EX-"): + record_ids = tuple( + dict.fromkeys(re.findall(r"AI-\d{3}", _field(fields, "对应记录"))) + ) + examples.append( + InteractionExample( + example_id=item_id, + record_ids=record_ids, + prompt_summary=_field(fields, "代表性提示摘要"), + output_summary=_field(fields, "AI 输出摘要"), + process_result=_field(fields, "处理结果"), + ) + ) + + return DisclosureLog(tuple(tools), tuple(records), tuple(examples)) + + +def latex_escape(value: str) -> str: + """Escape untrusted log text for LaTeX text mode. + + Args: + value: Plain text from the automatically summarized usage log. + + Returns: + A single-line LaTeX-safe string. + """ + compact = re.sub(r"\s+", " ", value).strip() + replacements = { + "\\": r"\textbackslash{}", + "{": r"\{", + "}": r"\}", + "$": r"\$", + "&": r"\&", + "#": r"\#", + "_": r"\_", + "%": r"\%", + "~": r"\textasciitilde{}", + "^": r"\textasciicircum{}", + } + return "".join(replacements.get(char, char) for char in compact) + + +def _tools_rows(data: DisclosureLog) -> str: + return "\n".join( + f"{tool.tool_id} & {latex_escape(tool.name)} & " + f"{latex_escape(tool.version)} \\\\" + for tool in data.tools + ) + + +def _purpose_rows(data: DisclosureLog) -> str: + return "\n".join( + f"{latex_escape('、'.join(record.tool_ids))} & " + f"{latex_escape(record.stage)} & {latex_escape(record.purpose)} \\\\" + for record in data.records + ) + + +def _process_blocks(data: DisclosureLog) -> str: + examples_by_record: dict[str, list[InteractionExample]] = {} + for example in data.examples: + for record_id in example.record_ids: + examples_by_record.setdefault(record_id, []).append(example) + + blocks: list[str] = [] + for record in data.records: + tool_ids = "、".join(record.tool_ids) + lines = [ + rf"\recordheading{{{record.record_id}}}{{{latex_escape(tool_ids)}}}", + rf"\textbf{{主要提示方式:}}{latex_escape(record.prompt_method)}。", + "", + rf"\textbf{{使用过程:}}{latex_escape(record.process)}。", + ] + for example in examples_by_record.get(record.record_id, []): + lines.extend( + [ + "", + rf"\noindent\textbf{{典型交互示例 {example.example_id}}}", + "", + rf"\textbf{{代表性提示摘要:}}{latex_escape(example.prompt_summary)}。", + "", + rf"\textbf{{AI 输出摘要:}}{latex_escape(example.output_summary)}。", + "", + rf"\textbf{{处理结果:}}{latex_escape(example.process_result)}。", + ] + ) + blocks.append("\n".join(lines)) + return "\n\n".join(blocks) + + +def _adoption_blocks(data: DisclosureLog) -> str: + blocks: list[str] = [] + for record in data.records: + if record.language_only: + continue + tool_ids = "、".join(record.tool_ids) + lines = [ + rf"\recordheading{{{record.record_id}}}{{{latex_escape(tool_ids)}}}", + rf"\textbf{{采纳情况:}}{latex_escape(record.adoption)}。", + ] + if record.human_edit: + lines.extend( + ["", rf"\textbf{{人工修改:}}{latex_escape(record.human_edit)}。"] + ) + if record.iteration: + lines.extend( + ["", rf"\textbf{{迭代修正:}}{latex_escape(record.iteration)}。"] + ) + if record.verification: + lines.extend( + ["", rf"\textbf{{核验情况:}}{latex_escape(record.verification)}。"] + ) + blocks.append("\n".join(lines)) + if not blocks: + return "本次记录仅涉及语言表达辅助,第四部分不作展开。" + return "\n\n".join(blocks) + + +def render_tex(log_path: Path, template_path: Path, tex_path: Path) -> Path: + """Render a final TeX source from a validated log. + + Args: + log_path: Finalized Markdown log. + template_path: LaTeX template containing deterministic markers. + tex_path: Destination TeX path. + + Returns: + The written TeX path. + + Raises: + GenerationError: If validation or template rendering fails. + """ + errors = validate(log_path, "finalize") + if errors: + raise GenerationError("日志未通过最终校验:\n- " + "\n- ".join(errors)) + if not template_path.is_file(): + raise GenerationError(f"LaTeX 模板不存在:{template_path}") + + template = template_path.read_text(encoding="utf-8-sig") + for marker in TEMPLATE_MARKERS: + if template.count(marker) != 1: + raise GenerationError(f"模板标记缺失或重复:{marker}") + + data = parse_log(log_path) + rendered = template + replacements = { + TEMPLATE_MARKERS[0]: _tools_rows(data), + TEMPLATE_MARKERS[1]: _purpose_rows(data), + TEMPLATE_MARKERS[2]: _process_blocks(data), + TEMPLATE_MARKERS[3]: _adoption_blocks(data), + } + for marker, content in replacements.items(): + rendered = rendered.replace(marker, content) + + if any(marker in rendered for marker in TEMPLATE_MARKERS): + raise GenerationError("生成后的 TeX 仍含模板标记") + for marker in PENDING_MARKERS: + if marker.casefold() in rendered.casefold(): + raise GenerationError(f"生成后的 TeX 仍含待确认或占位内容:{marker}") + for phrase in NO_HUMAN_RECORD_PHRASES: + if phrase in rendered: + raise GenerationError(f"生成后的 TeX 含禁止的无人工记录提示:{phrase}") + for label, pattern in SECRET_PATTERNS.items(): + if pattern.search(rendered): + raise GenerationError(f"生成后的 TeX 检测到疑似{label}") + + tex_path.parent.mkdir(parents=True, exist_ok=True) + with tex_path.open("w", encoding="utf-8", newline="\n") as output: + output.write(rendered) + return tex_path + + +def _run_compiler(command: list[str], cwd: Path) -> None: + """Run a PDF compiler and raise a compact actionable error on failure.""" + try: + result = subprocess.run( + command, + cwd=cwd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=180, + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise GenerationError(f"PDF 编译超时:{' '.join(command)}") from exc + if result.returncode != 0: + output = (result.stdout + "\n" + result.stderr).strip() + raise GenerationError( + f"PDF 编译失败(退出码 {result.returncode}):\n{output[-4000:]}" + ) + + +def compile_pdf(tex_path: Path, compiler: str = "auto") -> Path: + """Compile the rendered TeX using XeLaTeX or Tectonic. + + Args: + tex_path: Rendered TeX source. + compiler: ``auto``, ``xelatex``, or ``tectonic``. + + Returns: + Compiled PDF path. + """ + candidates = ("xelatex", "tectonic") if compiler == "auto" else (compiler,) + selected = next( + ((name, shutil.which(name)) for name in candidates if shutil.which(name)), + None, + ) + if selected is None: + expected = " 或 ".join(candidates) + raise GenerationError(f"缺少 PDF 编译器:需要 {expected}") + + name, executable = selected + assert executable is not None + if name == "xelatex": + command = [ + executable, + "-interaction=nonstopmode", + "-halt-on-error", + tex_path.name, + ] + _run_compiler(command, tex_path.parent) + _run_compiler(command, tex_path.parent) + else: + _run_compiler([executable, "--keep-logs", tex_path.name], tex_path.parent) + + pdf_path = tex_path.with_suffix(".pdf") + if not pdf_path.is_file() or pdf_path.stat().st_size == 0: + raise GenerationError(f"编译器未生成非空 PDF:{pdf_path}") + return pdf_path + + +def cleanup_auxiliary_files(tex_path: Path) -> None: + """Remove compiler by-products after a fully verified build.""" + names = [ + tex_path.with_suffix(suffix) + for suffix in (".aux", ".log", ".out", ".toc", ".xdv") + ] + names.append(tex_path.with_name(f"{tex_path.stem}.synctex.gz")) + for path in names: + if path.is_file(): + path.unlink() + + +def _normalize_pdf_text(text: str) -> str: + return re.sub(r"\s+", "", text) + + +def inspect_pdf(pdf_path: Path, source_tex: Path | None = None) -> list[str]: + """Check filename, A4 pages, text layer, headings, and sensitive content. + + Some PDF parsers cannot decode CJK ToUnicode maps produced by every TeX + engine. In that case the compiled source verifies the required headings, + while the PDF parser still verifies that a non-empty text layer exists. + """ + errors: list[str] = [] + if pdf_path.name != "AI 工具使用详情.pdf": + errors.append("最终 PDF 文件名必须完全等于“AI 工具使用详情.pdf”") + if not pdf_path.is_file() or pdf_path.stat().st_size < 1024: + return errors + ["最终 PDF 不存在或文件过小"] + if pdf_path.read_bytes()[:5] != b"%PDF-": + errors.append("最终文件不是有效 PDF") + + pages: list[tuple[float, float, str]] = [] + try: + import fitz # type: ignore[import-unresolved] + + with fitz.open(pdf_path) as document: + pages = [ + (float(page.rect.width), float(page.rect.height), page.get_text("text")) + for page in document + ] + except ImportError: + try: + from pypdf import PdfReader # type: ignore[import-unresolved] + + reader = PdfReader(str(pdf_path)) + pages = [ + ( + float(page.mediabox.width), + float(page.mediabox.height), + page.extract_text() or "", + ) + for page in reader.pages + ] + except ImportError: + errors.append("缺少 PyMuPDF 或 pypdf,无法检查页面尺寸和文本层") + return errors + except Exception as exc: # noqa: BLE001 + errors.append(f"PDF 读取失败:{exc}") + return errors + except Exception as exc: # noqa: BLE001 + errors.append(f"PDF 读取失败:{exc}") + return errors + + if not pages: + return errors + ["最终 PDF 没有页面"] + for index, (width, height, _) in enumerate(pages, start=1): + if abs(width - 595.28) > 5 or abs(height - 841.89) > 5: + errors.append( + f"第 {index} 页不是 A4 纵向页面:{width:.1f} x {height:.1f} pt" + ) + + text = "\n".join(page_text for _, _, page_text in pages) + normalized_pdf = _normalize_pdf_text(text) + if not normalized_pdf: + errors.append("最终 PDF 没有可提取文本层") + heading_text = text + if source_tex is not None and source_tex.is_file(): + heading_text = source_tex.read_text(encoding="utf-8-sig") + normalized_headings = _normalize_pdf_text(heading_text) + for heading in REQUIRED_PDF_HEADINGS: + if heading not in normalized_headings: + errors.append(f"最终 PDF 缺少规定部分:{heading}") + for marker in PENDING_MARKERS: + if marker.casefold() in text.casefold(): + errors.append(f"最终 PDF 仍含待确认或占位内容:{marker}") + inspection_text = text + if source_tex is not None and source_tex.is_file(): + inspection_text += "\n" + source_tex.read_text(encoding="utf-8-sig") + for phrase in NO_HUMAN_RECORD_PHRASES: + if phrase in inspection_text: + errors.append(f"最终 PDF 含禁止的无人工记录提示:{phrase}") + for label, pattern in SECRET_PATTERNS.items(): + if pattern.search(text): + errors.append(f"最终 PDF 检测到疑似{label}") + return list(dict.fromkeys(errors)) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="根据自动归纳的 AI 使用日志生成国赛 AI 工具使用详情 PDF" + ) + parser.add_argument("log_path", type=Path, help="reports/AI_USAGE_LOG.md 路径") + parser.add_argument( + "--output-dir", + type=Path, + default=Path("supporting_materials"), + help="输出目录,默认 supporting_materials", + ) + parser.add_argument( + "--template", + type=Path, + default=Path(__file__).resolve().parents[1] + / "templates" + / "AI工具使用详情模板.tex", + ) + parser.add_argument( + "--compiler", + choices=("auto", "xelatex", "tectonic"), + default="auto", + ) + parser.add_argument( + "--no-compile", + action="store_true", + help="只生成 TeX,不编译;仅用于自动测试", + ) + args = parser.parse_args() + + tex_path = args.output_dir / "AI 工具使用详情.tex" + try: + render_tex(args.log_path, args.template, tex_path) + print(f"TEX GENERATED: {tex_path}") + if args.no_compile: + return 0 + pdf_path = compile_pdf(tex_path, args.compiler) + errors = inspect_pdf(pdf_path, tex_path) + if errors: + raise GenerationError("PDF 验收失败:\n- " + "\n- ".join(errors)) + cleanup_auxiliary_files(tex_path) + print(f"PDF GENERATED AND VERIFIED: {pdf_path}") + return 0 + except GenerationError as exc: + print(f"ERROR: {exc}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/7ai-disclosure/scripts/test_ai_disclosure.py b/skills/7ai-disclosure/scripts/test_ai_disclosure.py new file mode 100644 index 00000000..c37a535f --- /dev/null +++ b/skills/7ai-disclosure/scripts/test_ai_disclosure.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Regression tests for the 7ai-disclosure validator and renderer.""" + +from __future__ import annotations + +import tempfile +import unittest +from contextlib import ExitStack +from pathlib import Path +from unittest.mock import patch + +from generate_ai_usage_pdf import GenerationError, compile_pdf, parse_log, render_tex +from validate_ai_usage_log import validate + +FIXTURE_PATH = ( + Path(__file__).resolve().parents[1] / "tests" / "fixtures" / "valid_multi_tool.md" +) +TEMPLATE_LOG_PATH = ( + Path(__file__).resolve().parents[1] / "templates" / "AI_USAGE_LOG.md" +) +VALID_MULTI_TOOL_LOG = FIXTURE_PATH.read_text(encoding="utf-8") + + +class DisclosureGenerationTests(unittest.TestCase): + """Verify stable mapping, safety gates, and LaTeX rendering.""" + + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.addCleanup(self.temp_dir.cleanup) + self.root = Path(self.temp_dir.name) + self.log_path = self.root / "AI_USAGE_LOG.md" + self.log_path.write_text(VALID_MULTI_TOOL_LOG, encoding="utf-8") + self.template = ( + Path(__file__).resolve().parents[1] / "templates" / "AI工具使用详情模板.tex" + ) + + def test_final_log_is_valid_and_ids_map_to_records(self) -> None: + self.assertEqual(validate(self.log_path, "finalize"), []) + data = parse_log(self.log_path) + self.assertEqual([tool.tool_id for tool in data.tools], ["TOOL-01", "TOOL-02"]) + self.assertEqual(data.records[0].tool_ids, ("TOOL-01",)) + self.assertEqual(data.examples[0].record_ids, ("AI-001",)) + + def test_initial_template_passes_record_mode_only(self) -> None: + record_errors = validate(TEMPLATE_LOG_PATH, "record") + final_errors = validate(TEMPLATE_LOG_PATH, "finalize") + self.assertEqual(record_errors, []) + self.assertTrue(any("占位内容" in error for error in final_errors)) + self.assertTrue(any("尚未标记为“已归纳”" in error for error in final_errors)) + + def test_rendered_tex_has_four_sections_and_no_template_markers(self) -> None: + tex_path = self.root / "supporting_materials" / "AI 工具使用详情.tex" + render_tex(self.log_path, self.template, tex_path) + text = tex_path.read_text(encoding="utf-8") + self.assertNotIn("%%__", text) + self.assertNotIn("待队员确认", text) + self.assertIn("MathModelAgent 桌面 Agent", text) + self.assertIn("运行环境未提供具体版本", text) + self.assertIn("AI-001", text) + self.assertIn("EX-001", text) + self.assertIn("迭代修正:", text) + self.assertIn("核验情况:", text) + self.assertNotIn("人工修改:", text) + self.assertNotIn("人工核验:", text) + self.assertNotIn("未记录到额外人工修改", text) + self.assertIn("一、所用 AI 工具名称、版本或型号", text) + self.assertIn("四、对 AI 输出的采纳、人工修改和核验的主要情况", text) + + def test_language_polishing_is_excluded_from_part_four(self) -> None: + tex_path = self.root / "AI 工具使用详情.tex" + render_tex(self.log_path, self.template, tex_path) + part_four = tex_path.read_text(encoding="utf-8").split( + "四、对 AI 输出的采纳、人工修改和核验的主要情况", 1 + )[1] + self.assertIn("AI-001", part_four) + self.assertNotIn("AI-002", part_four) + + def test_in_progress_record_blocks_generation(self) -> None: + pending = VALID_MULTI_TOOL_LOG.replace( + "- 当前状态:已归纳", "- 当前状态:记录中", 1 + ) + self.log_path.write_text(pending, encoding="utf-8") + with self.assertRaises(GenerationError): + render_tex(self.log_path, self.template, self.root / "out.tex") + + def test_core_model_detail_is_rejected(self) -> None: + unsafe = VALID_MULTI_TOOL_LOG.replace( + "检查实现完整性", "检查 ARIMA 参数为 0.8 的实现完整性" + ) + self.log_path.write_text(unsafe, encoding="utf-8") + errors = validate(self.log_path, "finalize") + self.assertTrue(any("核心建模过程" in error for error in errors)) + + def test_optional_evidenced_human_edit_is_rendered(self) -> None: + with_human_edit = VALID_MULTI_TOOL_LOG.replace( + "- 对应证据文件或产物:reports/VERIFY_REPORT.md", + "- 人工修改内容:根据明确反馈调整了章节顺序\n" + "- 人工修改依据:当前会话中的明确修改要求\n" + "- 对应证据文件或产物:reports/VERIFY_REPORT.md", + ) + self.log_path.write_text(with_human_edit, encoding="utf-8") + tex_path = self.root / "AI 工具使用详情.tex" + render_tex(self.log_path, self.template, tex_path) + text = tex_path.read_text(encoding="utf-8") + self.assertIn("人工修改:", text) + self.assertIn("根据明确反馈调整了章节顺序", text) + + def test_forbidden_no_human_record_phrase_is_rejected(self) -> None: + unsafe = VALID_MULTI_TOOL_LOG.replace( + "程序运行发现的问题已修正,并重新生成相关产物", + "未记录到额外人工修改", + ) + self.log_path.write_text(unsafe, encoding="utf-8") + errors = validate(self.log_path, "finalize") + self.assertTrue(any("禁止的无人工记录提示" in error for error in errors)) + + def test_non_contiguous_tool_ids_fail_validation(self) -> None: + invalid = VALID_MULTI_TOOL_LOG.replace("TOOL-02", "TOOL-03") + self.log_path.write_text(invalid, encoding="utf-8") + errors = validate(self.log_path, "record") + self.assertIn("工具编号必须从 1 开始连续递增", errors) + + def test_missing_compilers_fail_with_actionable_error(self) -> None: + tex_path = self.root / "AI 工具使用详情.tex" + tex_path.write_text("test", encoding="utf-8") + with ExitStack() as stack: + stack.enter_context( + patch("generate_ai_usage_pdf.shutil.which", return_value=None) + ) + stack.enter_context( + self.assertRaisesRegex(GenerationError, "缺少 PDF 编译器") + ) + compile_pdf(tex_path) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/7ai-disclosure/scripts/validate_ai_usage_log.py b/skills/7ai-disclosure/scripts/validate_ai_usage_log.py new file mode 100644 index 00000000..f6cb804d --- /dev/null +++ b/skills/7ai-disclosure/scripts/validate_ai_usage_log.py @@ -0,0 +1,384 @@ +#!/usr/bin/env python3 +"""Validate the structured Markdown log used by 7ai-disclosure.""" + +from __future__ import annotations + +import argparse +import re +import sys +import tempfile +from pathlib import Path + +PENDING_MARKERS = ( + "待队员确认", + "请填写", + "TODO", + "PLACEHOLDER", + "示例工具", + "示例模型", + "示例型号", +) +NO_HUMAN_RECORD_PHRASES = ( + "未记录到额外人工修改", + "未记录人工修改", + "无人工修改记录", + "没有人工修改记录", + "未发现人工修改", +) +SECRET_PATTERNS = { + "OpenAI 风格密钥": re.compile(r"\bsk-[A-Za-z0-9_-]{12,}\b"), + "GitHub 令牌": re.compile(r"\b(?:ghp|github_pat)_[A-Za-z0-9_]{12,}\b"), + "Tavily 密钥": re.compile(r"\btvly-[A-Za-z0-9_-]{12,}\b"), + "Bearer 令牌": re.compile(r"\bBearer\s+[A-Za-z0-9._~+/-]{12,}", re.IGNORECASE), + "环境变量密钥": re.compile(r"\b[A-Z][A-Z0-9_]*(?:API_KEY|TOKEN|SECRET)\s*=\s*\S+"), +} +CORE_DETAIL_PATTERNS = { + "公式或变量表达式": re.compile( + r"(?:\\(?:frac|sum|int|begin)\b|\$[^$\n]+\$|\b[A-Za-z]\w*_\d+\b|" + r"\b[A-Za-z][A-Za-z0-9_]*\s*=\s*-?\d)" + ), + "具体数值结果": re.compile( + r"(?:最终结果|最优值|预测值|权重|参数|误差|准确率|目标函数值)" + r"[^。;\n]{0,16}-?\d+(?:\.\d+)?" + ), + "特定模型或算法名称": re.compile( + r"(?:AHP|TOPSIS|ARIMA|LSTM|XGBoost|NSGA(?:-?II)?|" + r"随机森林|层次分析法|熵权法|遗传算法|模拟退火|微分方程|" + r"线性规划|整数规划)", + re.IGNORECASE, + ), + "内部项目路径": re.compile( + r"(?:^|[\s((])(?:code|results|paper|figures|reports)[/\\][^\s,。;))]+", + re.IGNORECASE, + ), +} +HUMAN_CLAIM_PATTERN = re.compile( + r"(?:队员|手工|参赛者|团队成员|人工(?:复算|检查|核验|修改|审阅|调整|确认|验证))" +) + +REQUIRED_HEADINGS = ("AI 工具目录", "AI 使用记录") +REQUIRED_TOOL = ("工具名称", "版本或型号") +REQUIRED_RECORD = ( + "工具编号", + "使用环节", + "具体使用目的", + "是否仅用于语言润色", + "主要提示方式", + "使用过程说明", + "AI 输出采纳情况", + "对应证据文件或产物", + "当前状态", +) +REQUIRED_REVIEW = ("迭代修正情况", "核验方法与结果") +OPTIONAL_HUMAN = ("人工修改内容", "人工修改依据") +REQUIRED_EXAMPLE = ("对应记录", "代表性提示摘要", "AI 输出摘要", "处理结果") +PUBLIC_DISCLOSURE_FIELDS = ( + "使用环节", + "具体使用目的", + "主要提示方式", + "使用过程说明", + "AI 输出采纳情况", + "迭代修正情况", + "核验方法与结果", + "人工修改内容", + "代表性提示摘要", + "AI 输出摘要", + "处理结果", +) +ID_PATTERN = re.compile( + r"^###\s+((?:TOOL-\d{2})|(?:AI-\d{3})|(?:EX-\d{3}))\s*$", re.MULTILINE +) + + +def parse_fields(text: str) -> dict[str, list[str]]: + fields: dict[str, list[str]] = {} + for line in text.splitlines(): + match = re.match(r"^\s*-\s*([^::]+)[::]\s*(.*?)\s*$", line) + if match: + fields.setdefault(match.group(1).strip(), []).append(match.group(2).strip()) + return fields + + +def id_sections(text: str) -> list[tuple[str, str]]: + matches = list(ID_PATTERN.finditer(text)) + result: list[tuple[str, str]] = [] + for index, match in enumerate(matches): + end = matches[index + 1].start() if index + 1 < len(matches) else len(text) + result.append((match.group(1), text[match.end() : end])) + return result + + +def missing_fields( + fields: dict[str, list[str]], required: tuple[str, ...] +) -> list[str]: + return [name for name in required if name not in fields or not any(fields[name])] + + +def is_pending(value: str) -> bool: + return ( + not value.strip() + or any(marker.casefold() in value.casefold() for marker in PENDING_MARKERS) + or bool(re.search(r"【[^】]*】", value)) + ) + + +def expected_ids(prefix: str, count: int, width: int) -> list[str]: + """Build the required contiguous identifiers for one entry type.""" + return [f"{prefix}-{index:0{width}d}" for index in range(1, count + 1)] + + +def validate_public_content( + owner: str, fields: dict[str, list[str]], errors: list[str] +) -> None: + """Reject details or claims that must not enter the public disclosure.""" + for field_name in PUBLIC_DISCLOSURE_FIELDS: + for value in fields.get(field_name, []): + for phrase in NO_HUMAN_RECORD_PHRASES: + if phrase in value: + errors.append( + f"{owner} {field_name} 含禁止的无人工记录提示:{phrase}" + ) + if field_name not in OPTIONAL_HUMAN and HUMAN_CLAIM_PATTERN.search(value): + errors.append( + f"{owner} {field_name} 将过程表述为人工行为;仅有明确会话依据时" + "才可使用人工修改专用字段" + ) + for label, pattern in CORE_DETAIL_PATTERNS.items(): + if pattern.search(value): + errors.append( + f"{owner} {field_name} 含{label},请改为不泄露核心建模过程的阶段概括" + ) + + +def validate(path: Path, mode: str) -> list[str]: + if not path.is_file(): + return [f"日志不存在:{path}"] + + text = path.read_text(encoding="utf-8-sig") + errors: list[str] = [] + + for heading in REQUIRED_HEADINGS: + if not re.search(rf"^##\s+{re.escape(heading)}\s*$", text, flags=re.MULTILINE): + errors.append(f"缺少二级标题:{heading}") + + for label, pattern in SECRET_PATTERNS.items(): + if pattern.search(text): + errors.append(f"检测到疑似{label},请删除或脱敏") + + sections = id_sections(text) + tools = [ + (item_id, block) for item_id, block in sections if item_id.startswith("TOOL-") + ] + records = [ + (item_id, block) for item_id, block in sections if item_id.startswith("AI-") + ] + examples = [ + (item_id, block) for item_id, block in sections if item_id.startswith("EX-") + ] + tool_ids = [item_id for item_id, _ in tools] + record_ids = [item_id for item_id, _ in records] + example_ids = [item_id for item_id, _ in examples] + + if not tools: + errors.append("至少需要一个 TOOL-xx 工具条目") + if not records: + errors.append("至少需要一个 AI-xxx 使用记录") + for label, ids in ( + ("工具", tool_ids), + ("使用记录", record_ids), + ("典型交互", example_ids), + ): + if len(ids) != len(set(ids)): + errors.append(f"{label}编号重复") + for label, ids, prefix, width in ( + ("工具", tool_ids, "TOOL", 2), + ("使用记录", record_ids, "AI", 3), + ("典型交互", example_ids, "EX", 3), + ): + if ids and ids != expected_ids(prefix, len(ids), width): + errors.append(f"{label}编号必须从 1 开始连续递增") + + for tool_id, block in tools: + fields = parse_fields(block) + for name in missing_fields(fields, REQUIRED_TOOL): + errors.append(f"{tool_id} 缺少字段或值:{name}") + + for record_id, block in records: + fields = parse_fields(block) + for name in missing_fields(fields, REQUIRED_RECORD): + errors.append(f"{record_id} 缺少字段或值:{name}") + + references = re.findall(r"TOOL-\d{2}", " ".join(fields.get("工具编号", []))) + if not references: + errors.append(f"{record_id} 未引用 TOOL-xx") + for reference in references: + if reference not in tool_ids: + errors.append(f"{record_id} 引用了不存在的工具:{reference}") + + language_only = " ".join(fields.get("是否仅用于语言润色", [])) + if language_only and language_only not in {"是", "否"}: + errors.append(f"{record_id} “是否仅用于语言润色”只能是“是”或“否”") + if language_only != "是": + for name in missing_fields(fields, REQUIRED_REVIEW): + errors.append(f"{record_id} 非纯语言润色,缺少字段或值:{name}") + + human_edit = " ".join(fields.get("人工修改内容", [])).strip() + human_basis = " ".join(fields.get("人工修改依据", [])).strip() + if bool(human_edit) != bool(human_basis): + errors.append( + f"{record_id} 人工修改内容与人工修改依据必须同时填写或同时省略" + ) + + status = " ".join(fields.get("当前状态", [])) + if status and status not in {"记录中", "已归纳"}: + errors.append(f"{record_id} 当前状态只能是“记录中”或“已归纳”") + validate_public_content(record_id, fields, errors) + + for example_id, block in examples: + fields = parse_fields(block) + for name in missing_fields(fields, REQUIRED_EXAMPLE): + errors.append(f"{example_id} 缺少字段或值:{name}") + references = re.findall(r"AI-\d{3}", " ".join(fields.get("对应记录", []))) + if not references: + errors.append(f"{example_id} 未引用 AI-xxx") + for reference in references: + if reference not in record_ids: + errors.append(f"{example_id} 引用了不存在的记录:{reference}") + validate_public_content(example_id, fields, errors) + + if mode == "finalize": + for field_name, values in parse_fields(text).items(): + for value in values: + if is_pending(value): + errors.append(f"最终生成前仍有待确认或占位内容:{field_name}") + for record_id, block in records: + fields = parse_fields(block) + if " ".join(fields.get("当前状态", [])) != "已归纳": + errors.append(f"{record_id} 尚未标记为“已归纳”") + + return list(dict.fromkeys(errors)) + + +VALID_SAMPLE = """# AI 工具使用过程记录 +## AI 工具目录 +### TOOL-01 +- 工具名称:MathModelAgent 桌面 Agent +- 版本或型号:运行环境未提供具体版本 +## AI 使用记录 +### AI-001 +- 工具编号:TOOL-01 +- 使用环节:代码复核 +- 具体使用目的:检查边界条件 +- 是否仅用于语言润色:否 +- 主要提示方式:提供代码和约束,请列出边界风险 +- 使用过程说明:生成检查清单后与实际产物逐项对照 +- AI 输出采纳情况:经文件对照后选择性纳入最终稿 +- 迭代修正情况:程序运行发现的问题已修正并重新生成相关产物 +- 核验方法与结果:完成代码复现、异常检查和正文结果一致性核对,检查通过 +- 对应证据文件或产物:tests/test_boundary.py +- 当前状态:已归纳 +## 典型交互示例(可选) +### EX-001 +- 对应记录:AI-001 +- 代表性提示摘要:检查边界条件 +- AI 输出摘要:列出三类风险 +- 处理结果:与现有产物对照后,仅保留有运行记录支持的建议 +""" + + +def self_test() -> int: + language_sample = VALID_SAMPLE.replace( + "- 是否仅用于语言润色:否", "- 是否仅用于语言润色:是" + ) + language_sample = language_sample.replace( + "- 迭代修正情况:程序运行发现的问题已修正并重新生成相关产物\n", + "", + ) + language_sample = language_sample.replace( + "- 核验方法与结果:完成代码复现、异常检查和正文结果一致性核对,检查通过\n", + "", + ) + cases = [ + ("valid", VALID_SAMPLE, "finalize", False), + ("language_only", language_sample, "finalize", False), + ( + "pending", + VALID_SAMPLE.replace("- 当前状态:已归纳", "- 当前状态:记录中"), + "finalize", + True, + ), + ( + "bad_ref", + VALID_SAMPLE.replace("- 工具编号:TOOL-01", "- 工具编号:TOOL-99"), + "record", + True, + ), + ( + "secret", + VALID_SAMPLE + "\n- 备注:sk-abcdefghijklmnopqrstuvwxyz\n", + "record", + True, + ), + ( + "missing_review", + VALID_SAMPLE.replace( + "- 核验方法与结果:完成代码复现、异常检查和正文结果一致性核对,检查通过\n", + "", + ), + "record", + True, + ), + ( + "fabricated_human_claim", + VALID_SAMPLE.replace("完成代码复现", "队员手工复算并完成代码复现"), + "record", + True, + ), + ( + "core_detail", + VALID_SAMPLE.replace("检查边界条件", "检查 ARIMA 参数为 0.8 的结果"), + "record", + True, + ), + ( + "no_human_record_phrase", + VALID_SAMPLE.replace("程序运行发现的问题已修正", "未记录到额外人工修改"), + "record", + True, + ), + ("non_contiguous", VALID_SAMPLE.replace("TOOL-01", "TOOL-02"), "record", True), + ] + with tempfile.TemporaryDirectory() as tmpdir: + for name, content, mode, should_fail in cases: + path = Path(tmpdir) / f"{name}.md" + path.write_text(content, encoding="utf-8") + failed = bool(validate(path, mode)) + if failed != should_fail: + print(f"SELF-TEST FAIL: {name}") + return 1 + print("SELF-TEST PASS") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="校验 AI 工具使用过程记录") + parser.add_argument("path", nargs="?", type=Path, help="AI_USAGE_LOG.md 路径") + parser.add_argument("--mode", choices=("record", "finalize"), default="finalize") + parser.add_argument("--self-test", action="store_true") + args = parser.parse_args() + if args.self_test: + return self_test() + if args.path is None: + parser.error("除 --self-test 外,必须提供日志路径") + errors = validate(args.path, args.mode) + if errors: + for error in errors: + print(f"ERROR: {error}") + print(f"VALIDATION FAILED: {len(errors)} error(s)") + return 1 + print(f"VALIDATION PASS ({args.mode})") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git "a/skills/7ai-disclosure/templates/AI \345\267\245\345\205\267\344\275\277\347\224\250\350\257\246\346\203\205.pdf" "b/skills/7ai-disclosure/templates/AI \345\267\245\345\205\267\344\275\277\347\224\250\350\257\246\346\203\205.pdf" new file mode 100644 index 00000000..956e2304 Binary files /dev/null and "b/skills/7ai-disclosure/templates/AI \345\267\245\345\205\267\344\275\277\347\224\250\350\257\246\346\203\205.pdf" differ diff --git a/skills/7ai-disclosure/templates/AI_USAGE_LOG.md b/skills/7ai-disclosure/templates/AI_USAGE_LOG.md new file mode 100644 index 00000000..e46ebbd3 --- /dev/null +++ b/skills/7ai-disclosure/templates/AI_USAGE_LOG.md @@ -0,0 +1,30 @@ +# AI 工具使用过程记录 + +> 本记录由工作流根据会话、运行时元数据、文件产物和检查结果自动维护。只记录实际发生且可由任务证据支持的使用,不写入密钥、完整聊天记录、核心建模细节或模型私有思维链。 + +## AI 工具目录 + +### TOOL-01 + +- 工具名称:MathModelAgent 桌面 Agent +- 版本或型号:运行环境未提供具体版本 + +## AI 使用记录 + +### AI-001 + +- 工具编号:TOOL-01 +- 使用环节:任务启动与全流程编排 +- 具体使用目的:建立任务计划、组织阶段产物并维护检查记录 +- 是否仅用于语言润色:否 +- 主要提示方式:【自动概括本次任务的输入类型和输出要求,不写题目细节】 +- 使用过程说明:【自动概括实际执行阶段,不写模型、公式、参数或结果】 +- AI 输出采纳情况:【根据最终文件和修改记录自动归纳】 +- 迭代修正情况:【根据重新运行和文件修改证据自动归纳】 +- 核验方法与结果:【根据实际检查记录自动归纳】 +- 对应证据文件或产物:plan.md;todo.md +- 当前状态:记录中 + +## 典型交互示例(可选) + + diff --git "a/skills/7ai-disclosure/templates/AI\345\267\245\345\205\267\344\275\277\347\224\250\350\257\246\346\203\205\346\250\241\346\235\277.tex" "b/skills/7ai-disclosure/templates/AI\345\267\245\345\205\267\344\275\277\347\224\250\350\257\246\346\203\205\346\250\241\346\235\277.tex" new file mode 100644 index 00000000..9ec9f32e --- /dev/null +++ "b/skills/7ai-disclosure/templates/AI\345\267\245\345\205\267\344\275\277\347\224\250\350\257\246\346\203\205\346\250\241\346\235\277.tex" @@ -0,0 +1,85 @@ +% !TeX program = xelatex +% 由 generate_ai_usage_pdf.py 根据自动归纳日志填充;不要直接提交本源文件。 +\documentclass[UTF8,a4paper,11pt]{ctexart} +\usepackage[a4paper,top=2.0cm,bottom=2.0cm,left=2.15cm,right=2.15cm]{geometry} +\usepackage{array,booktabs,longtable,fancyhdr,lastpage} +\usepackage[table]{xcolor} +\usepackage[hidelinks]{hyperref} + +\definecolor{accent}{HTML}{315B7D} +\definecolor{sectionbg}{HTML}{EDF3F7} +\definecolor{tablehead}{HTML}{E4EDF3} +\definecolor{rulegray}{HTML}{AAB8C2} +\definecolor{bodytext}{HTML}{263746} +\definecolor{mutedtext}{HTML}{667580} +\newcolumntype{L}[1]{>{\raggedright\arraybackslash}p{#1}} +\newcommand{\sectiontitle}[1]{% + \par\vspace{0.65em}\noindent + \begingroup\setlength{\fboxsep}{7pt}% + \colorbox{sectionbg}{\parbox{\dimexpr\textwidth-2\fboxsep\relax}{% + \textcolor{accent}{\rule{3pt}{1.15em}\hspace{0.65em}\bfseries\zihao{-3}#1}}}% + \endgroup\par\vspace{0.5em}} +\newcommand{\recordheading}[2]{% + \vspace{0.25em}\noindent\textcolor{accent}{\bfseries 使用记录 #1(#2)}\par + \vspace{0.08em}{\color{rulegray}\hrule height 0.35pt}\vspace{0.2em}} +\renewcommand{\arraystretch}{1.3} +\setlength{\parindent}{0pt} +\setlength{\parskip}{0.35em} +\setlength{\LTpre}{0.2em} +\setlength{\LTpost}{0.25em} +\arrayrulecolor{rulegray} + +\pagestyle{fancy} +\setlength{\headheight}{24pt} +\addtolength{\topmargin}{-7pt} +\fancyhf{} +\fancyhead[L]{\textcolor{accent}{\bfseries AI 工具使用详情}} +\fancyfoot[C]{\textcolor{mutedtext}{第 \thepage\ 页,共 \pageref{LastPage} 页}} +\renewcommand{\headrulewidth}{0pt} +\renewcommand{\headrule}{\hbox to\headwidth{\color{accent}\leaders\hrule height 0.6pt\hfill}} +\renewcommand{\footrulewidth}{0pt} + +\begin{document} +\color{bodytext} + +\begin{center} + {\zihao{2}\bfseries\color{accent}AI 工具使用详情}\par + \vspace{0.3em} + {\color{rulegray}\rule{0.28\textwidth}{0.7pt}} +\end{center} + +\sectiontitle{一、所用 AI 工具名称、版本或型号} +\begin{longtable}{@{}L{2.1cm}L{6.2cm}L{7.1cm}@{}} +\toprule +\rowcolor{tablehead}\textcolor{accent}{\textbf{编号}} & \textcolor{accent}{\textbf{AI 工具名称}} & \textcolor{accent}{\textbf{版本或型号}} \\ +\midrule +\endfirsthead +\toprule +\rowcolor{tablehead}\textcolor{accent}{\textbf{编号}} & \textcolor{accent}{\textbf{AI 工具名称}} & \textcolor{accent}{\textbf{版本或型号}} \\ +\midrule +\endhead +%%__TOOLS_ROWS__%% +\bottomrule +\end{longtable} + +\sectiontitle{二、具体使用目的和环节} +\begin{longtable}{@{}L{3.0cm}L{4.2cm}L{8.2cm}@{}} +\toprule +\rowcolor{tablehead}\textcolor{accent}{\textbf{工具编号}} & \textcolor{accent}{\textbf{使用环节}} & \textcolor{accent}{\textbf{具体使用目的}} \\ +\midrule +\endfirsthead +\toprule +\rowcolor{tablehead}\textcolor{accent}{\textbf{工具编号}} & \textcolor{accent}{\textbf{使用环节}} & \textcolor{accent}{\textbf{具体使用目的}} \\ +\midrule +\endhead +%%__PURPOSE_ROWS__%% +\bottomrule +\end{longtable} + +\sectiontitle{三、主要提示方式与使用过程说明} +%%__PROCESS_BLOCKS__%% + +\sectiontitle{四、对 AI 输出的采纳、人工修改和核验的主要情况} +%%__ADOPTION_BLOCKS__%% + +\end{document} diff --git a/skills/7ai-disclosure/tests/fixtures/valid_multi_tool.md b/skills/7ai-disclosure/tests/fixtures/valid_multi_tool.md new file mode 100644 index 00000000..83bbb880 --- /dev/null +++ b/skills/7ai-disclosure/tests/fixtures/valid_multi_tool.md @@ -0,0 +1,50 @@ +# AI 工具使用过程记录 + +## AI 工具目录 + +### TOOL-01 + +- 工具名称:MathModelAgent 桌面 Agent +- 版本或型号:运行环境未提供具体版本 + +### TOOL-02 + +- 工具名称:ChatGPT +- 版本或型号:运行环境未提供具体版本 + +## AI 使用记录 + +### AI-001 + +- 工具编号:TOOL-01 +- 使用环节:方案梳理与实现检查 +- 具体使用目的:辅助梳理任务结构并检查实现完整性 +- 是否仅用于语言润色:否 +- 主要提示方式:提供任务材料类型与交付要求,要求给出可复现的阶段建议 +- 使用过程说明:先形成阶段草稿,再根据程序运行和文件检查结果迭代 +- AI 输出采纳情况:经文件对照后选择性纳入最终稿 +- 迭代修正情况:程序运行发现的问题已修正,并重新生成相关产物 +- 核验方法与结果:完成代码复现、异常检查和正文结果一致性核对,检查通过 +- 对应证据文件或产物:reports/VERIFY_REPORT.md +- 当前状态:已归纳 + +### AI-002 + +- 工具编号:TOOL-02 +- 使用环节:摘要语言润色 +- 具体使用目的:压缩摘要并统一术语 +- 是否仅用于语言润色:是 +- 主要提示方式:提供原摘要并限制字数 +- 使用过程说明:比较修改前后文本,保持原有信息范围不变 +- AI 输出采纳情况:部分采纳措辞 +- 对应证据文件或产物:paper/abstract.tex +- 当前状态:已归纳 + +## 典型交互示例(可选) + +### EX-001 + +- 对应记录:AI-001 +- 代表性提示摘要:检查实现完整性、结果一致性和表达缺漏 +- AI 输出摘要:给出风险项和修订建议摘要 +- 处理结果:与现有产物对照后,仅保留有运行记录支持的建议 diff --git a/skills/doctor/SKILL.md b/skills/doctor/SKILL.md index e8958e18..49ac8b77 100644 --- a/skills/doctor/SKILL.md +++ b/skills/doctor/SKILL.md @@ -16,6 +16,7 @@ allowed-tools: Bash(*), Read, Write | --- | --- | --- | | `typst` | 论文编译(5writing、6verity,Typst 引擎) | `command -v typst` | | `xelatex` | 论文编译(5writing、6verity,LaTeX 引擎,中文模板必需) | `command -v xelatex` | +| `tectonic` | AI 使用详情 PDF 的备用 LaTeX 编译器 | `command -v tectonic` | | `python3` | 数值计算与图表(3coding-visual) | `command -v python3` | | `drawio` / `draw.io` | DrawIO 流程图导出 PDF(4drawio) | `command -v drawio \|\| command -v draw.io` | | `pdftoppm` | PDF 转 PNG 视觉检查(6verity) | `command -v pdftoppm` | @@ -32,6 +33,7 @@ allowed-tools: Bash(*), Read, Write | `matplotlib` | 图表生成 | | `scikit-learn` | 机器学习建模 | | `openpyxl` | 读写 Excel 数据附件 | +| `PyMuPDF`(导入名 `fitz`) | 检查 AI 使用详情 PDF 的页数、A4 尺寸和文本层 | ## 工作流程 @@ -74,6 +76,7 @@ check_cmd() { check_cmd typst check_cmd xelatex +check_cmd tectonic check_cmd python3 || check_cmd python # Windows 上可能是 python command -v drawio >/dev/null 2>&1 || command -v draw.io >/dev/null 2>&1 \ && echo "OK drawio" || echo "MISS drawio" @@ -81,21 +84,27 @@ check_cmd pdftoppm check_cmd mutool check_cmd magick -python3 - <<'PYEOF' +PYTHON_BIN="$(command -v python3 || command -v python)" +if [ -n "$PYTHON_BIN" ]; then +"$PYTHON_BIN" - <<'PYEOF' import importlib -pkgs = ["numpy", "scipy", "pandas", "matplotlib", "sklearn", "openpyxl"] +pkgs = ["numpy", "scipy", "pandas", "matplotlib", "sklearn", "openpyxl", "fitz"] for p in pkgs: try: importlib.import_module(p) import importlib.metadata as meta try: - ver = meta.version(p if p != "sklearn" else "scikit-learn") + dist = {"sklearn": "scikit-learn", "fitz": "PyMuPDF"}.get(p, p) + ver = meta.version(dist) except Exception: ver = "?" print(f"OK {p} ({ver})") except ImportError: print(f"MISS {p}") PYEOF +else + echo "MISS python3/python" +fi ``` ### Step 3:输出检查报告 @@ -112,7 +121,9 @@ PYEOF ... ``` -**必须项:** typst 或 xelatex(至少一个论文编译器)、python3、numpy、pandas、matplotlib +**通用必须项:** typst 或 xelatex(至少一个论文编译器)、python3、numpy、pandas、matplotlib + +**国赛 AI 支撑材料附加必须项:** `7ai-disclosure/SKILL.md` 与其模板、脚本完整,xelatex 或 tectonic 至少一个,PyMuPDF 可导入。 **可选项:** drawio、pdftoppm/mutool/magick 三选一、scipy、scikit-learn、openpyxl ### Step 4:提供安装命令(按平台) @@ -220,6 +231,7 @@ Doctor 检查完成(macOS) 4drawio ⚠ drawio 未安装,PDF 导出将跳过 5writing ✓(typst ✓,xelatex ✓) 6verity ⚠ 无 PDF 转 PNG 工具,视觉检查将跳过 + 7ai-disclosure ✓(python ✓,xelatex ✓,PyMuPDF ✓) ``` ## 注意事项 @@ -228,4 +240,5 @@ Doctor 检查完成(macOS) - Windows 下建议在 PowerShell(管理员)或 Git Bash 中运行,部分命令需要管理员权限。 - Linux 的 `sudo` 命令会请求密码,执行前告知用户。 - drawio 和 PDF 转 PNG 工具缺失不影响核心工作流,仅影响导出质量。 +- 国赛任务缺少 `7ai-disclosure`、xelatex/tectonic 或 PyMuPDF 时,不得报告最终提交包已完成。 - 如平台检测为 unknown,打印所有平台命令供用户手动选择。 diff --git a/skills/skills.sh.json b/skills/skills.sh.json index 58d26a52..b3e7e2ef 100644 --- a/skills/skills.sh.json +++ b/skills/skills.sh.json @@ -10,7 +10,8 @@ "3coding-visual", "4drawio", "5writing", - "6verity" + "6verity", + "7ai-disclosure" ] }, {