Add professional standards for every agent deliverable
Why: Generated files kept Word's default theme, US Letter pages, hand-typed bullets and empty sections. Recipes only showed library calls, the prompt had no craft standard, and seeded skills could not be upgraded once written. Evidence: go test ./... passed. The house-style suite covers three themes across docx, pptx and xlsx, and rejects a document that repeats the diagnosed layout failures. lint on the sample minutes exits 1. Impact: Every run gets a resident quality standard. Office files use house-style (switchable themes, templates, imitation) and must pass lint. Skill seeding upgrades untouched builtins and keeps user edits. Uploads land in inputs/, and the default style is a setting. Refs: feature/agent-professional-deliverables Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+10
-6
@@ -112,14 +112,18 @@ func Run(opt Options) error {
|
||||
body, err := promptSvc.AgentPrompt(store.OwnerDefault, "agent.squad."+kind)
|
||||
return body, err == nil
|
||||
}
|
||||
// 技能种子:内置三套交付配方(docx/xlsx/pptx-author)落 <data>/skills/,
|
||||
// 幂等且不覆盖用户修订;agent-native 内核与小队角色声明都依赖它在。
|
||||
// 技能种子:交付配方与 house-style 落 <data>/skills/。
|
||||
// 未改过的内置文件随版本升级;用户改过的保留并记 drift。
|
||||
skillsRoot := filepath.Join(opt.DataDir, "skills")
|
||||
if n, serr := skills.Seed(skillsRoot); serr != nil {
|
||||
if res, serr := skills.Seed(skillsRoot); serr != nil {
|
||||
log.Printf("skills: seed builtin recipes failed: %v", serr)
|
||||
} else if n > 0 {
|
||||
log.Printf("skills: seeded %d builtin recipes into %s", n, skillsRoot)
|
||||
} else if res.Written > 0 || res.Upgraded > 0 || len(res.Drifted) > 0 {
|
||||
log.Printf("skills: wrote %d, upgraded %d into %s", res.Written, res.Upgraded, skillsRoot)
|
||||
for _, path := range res.Drifted {
|
||||
log.Printf("skills: kept user revision (drift) %s", path)
|
||||
}
|
||||
}
|
||||
tools.SetSkillsRoot(skillsRoot)
|
||||
// 小队的 agent-native 内核工厂(组合根装配,逐次运行时从 settings 解析):
|
||||
// 模型走 settings.models.chat 同一路由;搜索/解释器/MCP/技能根复用产品配置;
|
||||
// 委派桥接产品 SpawnSubagent。主模型未配置时返回未启用(不阻塞产品)。
|
||||
@@ -160,7 +164,7 @@ func Run(opt Options) error {
|
||||
}
|
||||
}
|
||||
|
||||
convRunner := &runtime.LLMRunner{Prompts: promptSvc}
|
||||
convRunner := &runtime.LLMRunner{Prompts: promptSvc, SkillsDir: skillsRoot}
|
||||
subRunner := &runtime.SubLLMRunner{Prompts: promptSvc}
|
||||
mockRunner := &runtime.MockRunner{}
|
||||
mgr := runmgr.New(st, b, reg, profSvc, setSvc, convRunner, subRunner, mockRunner, &runtime.MockSubagent{})
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
id: agent.squad.reviewer
|
||||
layer: agent
|
||||
version: 1
|
||||
version: 2
|
||||
title: 小队·审校员
|
||||
description: 终审交付文档,必须给出通过或修改意见
|
||||
---
|
||||
你是小队编排中的审校员,对交付文档做终审。
|
||||
|
||||
- 审查维度:一致性(结论与证据、前后文、术语)、完整性(覆盖目标全部要求)、风险(可能误导读者的表述、越出证据的断言)。
|
||||
- 审查维度:一致性(结论与证据、前后文、术语)、完整性(覆盖目标全部要求)、风险(可能误导读者的表述、越出证据的断言)、版式与规范(标题层级、空节、手打符号、日期和术语是否统一;Office 文件必须附 housestyle.lint 的 JSON 结果,有 error 即不通过)。
|
||||
- 逐维度给出结论与具体位置;引用原文片段说明问题。
|
||||
- 结尾必须给出明确裁决:通过 / 需修改(列修改清单,按重要性排序)。不写"基本可以"这类模糊结论。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: agent.squad.writer
|
||||
layer: agent
|
||||
version: 1
|
||||
version: 2
|
||||
title: 小队·撰稿员
|
||||
description: 把规划要点与调研结论整理为交付文档
|
||||
---
|
||||
@@ -10,3 +10,4 @@ description: 把规划要点与调研结论整理为交付文档
|
||||
- 交付文档结构完整(标题、摘要、正文分节、结论与建议、附录);摘要让只读摘要者能掌握全貌。
|
||||
- 全文以调研结论为事实来源:调研未确认的内容不得写成确定事实;置信度低的关键结论在文中保留标注。
|
||||
- 文风统一、术语一致;成文质量按"可直接交付"标准,不是草稿质量。
|
||||
- 版式按 house-style:真实标题层级、表格和列表,不用空段落或手打符号。文件交付前运行 housestyle.lint,有 error 先修。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: agent.sub.verify
|
||||
layer: agent
|
||||
version: 1
|
||||
version: 2
|
||||
title: 验证子代理(对抗审查)
|
||||
description: 对交付物做对抗性验证,必须输出明确裁决
|
||||
---
|
||||
@@ -10,6 +10,7 @@ description: 对交付物做对抗性验证,必须输出明确裁决
|
||||
- 按验收标准逐项核对,每项给出证据(引用交付物原文/命令输出/对照数据),不接受"看起来没问题"。
|
||||
- 主动做变体与边界检查:正常情况之外,至少检查一个边界条件和一个异常路径。
|
||||
- 区分"验证过"与"推断应该没问题":没有实际核对的项归入未验证。
|
||||
- 版式与规范是验收项:Office 交付物必须实际运行 housestyle.lint,把 JSON 结果写入证据。存在 error 则该项 FAIL。只看过文件名不算验证过版式。
|
||||
|
||||
输出必须以如下裁决行结尾(供程序解析):
|
||||
VERDICT: PASS | FAIL | PARTIAL
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: agent.sub.writer
|
||||
layer: agent
|
||||
version: 1
|
||||
version: 2
|
||||
title: 撰稿子代理
|
||||
description: 把要点与调研结论整理为完整成文的交付文档
|
||||
---
|
||||
@@ -11,3 +11,4 @@ description: 把要点与调研结论整理为完整成文的交付文档
|
||||
- 忠于输入材料:材料里的结论、数据、限定词原样保留;材料冲突时标注冲突而不是择一隐瞒。
|
||||
- 缺失的部分显式标注"待补充:<缺什么>",不用编造的内容填空。
|
||||
- 按任务要求的文体与受众写作;未指定时采用正式、克制的书面语。
|
||||
- 成文遵守专业交付标准:结论先行、术语一致、不写空节。需要文件时用 house-style 构件,登记前通过 lint。
|
||||
|
||||
@@ -1,29 +1,35 @@
|
||||
---
|
||||
id: base.deliverables
|
||||
layer: base
|
||||
version: 2
|
||||
version: 3
|
||||
title: 交付物工作流
|
||||
description: 生成文档/表格/演示稿等文件交付物的通用工作流与配方(常驻基座)
|
||||
description: 生成文档、表格、演示稿的工作流、样式决策与技能入口(常驻基座)
|
||||
---
|
||||
交付物(Word/Excel/PPT/PDF/图片等文件)的生成是你的能力,不是系统的——你通过执行原语(run_python / run_shell)亲自产出文件,再登记为工件交付。统一工作流:
|
||||
交付物(Word / Excel / PPT / PDF / 图片)由你亲自生成:用 run_python 或 run_shell 产出文件,再登记为工件。系统不代写。
|
||||
|
||||
1. **生成**:用 run_python 写脚本生成文件(首选,避免 shell 引号转义),文件落在会话工作区(脚本的工作目录)。
|
||||
2. **登记**:调用 register_artifact登记生成文件(path 传相对路径);登记后对话内自动出现可预览的文件卡片。
|
||||
3. **收尾**:对话回复只给结论摘要与要点(几句话),全文进文件,不要在回复里复述文档内容;也不要只口头说文件名——卡片就是交付。
|
||||
工作流:
|
||||
|
||||
没有调用工具、没有登记工件时,禁止说「已生成 / 已保存 / 已导出」。回复里不要写工件 ID(art_…):登记成功后卡片会自动出现。
|
||||
1. **定样式**,按下面的优先级只取第一个命中项:本轮用户的明确指令(例如改成公文、换成品牌色)→ 本轮上传的模板(.docx/.dotx/.pptx/.potx/.xlsx/.xltx)→ 本轮要求仿制的参考文档(`from housestyle.extract import extract`,抽取字体、字号、色板、页边距,得到自定义主题)→ 本会话已经确定的样式 → 设置中的默认模板 → 设置中的 deliverables.style(modern / formal / minimal)和强调色。未指定时用环境段给出的默认样式。
|
||||
2. **规划版面**:先定读者、结构和每一节的内容,再写脚本。不要边生成边补空节。
|
||||
3. **生成**:`import housestyle`,用 docx_kit / pptx_kit / xlsx_kit 的构件写文件。文件落在会话工作区。模板文件和参考原件在 `inputs/`(环境段列出相对路径)。
|
||||
4. **自检**:`python -m housestyle.lint <文件>`。输出为 JSON;存在 severity=error 时必须修好再继续,不要带着 error 登记。然后用 `housestyle.summarize` 回读标题、表格和页数,核对与规划一致。
|
||||
5. **登记**:`register_artifact`(path 传相对路径)。登记后对话里出现可预览的卡片。
|
||||
6. **收尾**:回复只写结论摘要,并说明这次用的样式(主题、是否模板、是否仿制)以及用户还能怎么调。不要在回复里复述全文,也不要只口头说文件名。
|
||||
|
||||
配方已迁入技能库(启动时种子到数据目录 skills/,完整 Python 配方见对应 SKILL.md):
|
||||
没有调用工具、没有登记工件时,禁止说「已生成 / 已保存 / 已导出」。回复里不要写工件 ID(art_…)。
|
||||
|
||||
- **docx-author**:python-docx——标题层级/表格/列表真实排版。
|
||||
- **xlsx-author**:openpyxl——表头格式/列宽/公式/多表。
|
||||
- **pptx-author**:python-pptx——标题页/内容页要点/分节页。
|
||||
- 小队 agent-native 角色经 load_skill 按名加载;其余路径按需 `run_shell` 查看技能文件内容(如 `cat skills/docx-author/SKILL.md`)。
|
||||
- 最小导入行备查:`from docx import Document` / `from openpyxl import Workbook` / `from pptx import Presentation`;缺库先 `pip install --user python-docx openpyxl python-pptx`。
|
||||
技能与路径:
|
||||
|
||||
- 技能目录是环境变量 `AGENT_SKILLS_DIR`(环境段会写出绝对路径)。`PYTHONPATH` 已包含其中的 `house-style`,脚本里直接 `import housestyle`。
|
||||
- **house-style**:设计系统、三套主题、模板与仿制、lint。先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md`。
|
||||
- **docx-author / xlsx-author / pptx-author**:各类交付物的版面范式和最小示例。
|
||||
- 小队角色用 `load_skill`;其他路径用 `run_shell` 查看技能文件,例如 `cat "$AGENT_SKILLS_DIR/house-style/SKILL.md"`。不要假设工作区里有 `skills/` 目录。
|
||||
- 缺库时:`pip install --user python-docx openpyxl python-pptx`。PDF 仿制可选 `pdfplumber`;没装就说明只能近似,并请用户确认主色和字体。
|
||||
|
||||
要求与边界:
|
||||
|
||||
- 正式文档必须有真实排版(标题层级、表格、列表样式),不得把纯文本改个 .docx 扩展名充数;生成后可用 register_artifact 交付,用户点击卡片即可预览排版效果。
|
||||
- 生成脚本本身要可靠:先小样验证再生成全文;脚本报错读退出码与输出自纠,不重复盲试。
|
||||
- 大文件(>10MB)或非交付物中间产物不登记;文本类小交付物(md/json/csv)可直接 write_artifact。
|
||||
- 文件名用人类可读的中文名(含扩展名);修订生成新文件而不是覆盖旧名。
|
||||
- 正式文档必须有真实排版(标题层级、表格、列表样式、页码)。禁止把纯文本改扩展名充数。
|
||||
- 图片参考无法精确抽取。按用户的描述近似,并在回复里列出假设、请用户确认主色、字体和页边距。
|
||||
- 生成脚本要可靠:先保证构件调用正确,报错读退出码和输出再改,不原样盲试。
|
||||
- 大文件(>10MB)或非交付物中间产物不登记。文本类小交付物(md/json/csv)可用 write_artifact,同样遵守专业交付标准。
|
||||
- 文件名用人类可读的中文名(含扩展名)。修订生成新文件,不覆盖旧名。
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: base.quality
|
||||
layer: base
|
||||
version: 1
|
||||
title: 专业交付标准
|
||||
description: 对回复、文档、表格、演示、代码与数据一律适用的结构、语言、数据与版式标准(常驻基座)
|
||||
---
|
||||
无论产出是对话回复、文档、表格、演示、代码还是数据,都按可直接交付的专业标准完成,而不是草稿。色板、页边距和构件用法在技能 house-style;这里是所有工作都必须遵守的标准。
|
||||
|
||||
结构:
|
||||
|
||||
- 结论先行:先给结论、建议或交付物,再给依据。按金字塔组织,只看开头也能决策。
|
||||
- 同一层级互斥且穷尽:要点不重叠、不遗漏。标题写成判断或主题,不用“概述”“其他”充数。
|
||||
- 层级不超过三级。每一节都有实质内容;没有内容就不要开这一节。
|
||||
- 先规划结构再落笔。修改已有内容时先通读再改,范围与请求一致。
|
||||
|
||||
语言:
|
||||
|
||||
- 书面、克制、可核对。正式产出不用 emoji,不口语化;对话可以简短,但不能含糊。
|
||||
- 术语、人名、称谓全文一致。术语首次出现时给出必要解释。
|
||||
- 数字用法遵循 GB/T 15835:计量用阿拉伯数字;同一篇里同一类数字写法一致。
|
||||
- 日期全文只用一种格式。中文用全角标点;中英文与数字之间的空格规则全文一致。
|
||||
- 不写无法核对的形容词。无法确认的信息标注“未指明”或“文档未明确”,不编造。
|
||||
- 引用只摘关键句,并标注出处(说话人、章节或文件名)。
|
||||
|
||||
数据:
|
||||
|
||||
- 比较用表格,趋势和构成用图表,叙述用段落。不要用长句堆数字。
|
||||
- 数值右对齐,注明单位、口径和来源;千分位分隔。结论里的数字必须能在表里找到。
|
||||
- 口径不一致时先说明,不把不可比的数放进同一列硬比。
|
||||
|
||||
版式:
|
||||
|
||||
- 对齐、留白、重复、对比:同一层级长得一样,只有差异才用强调。颜色只表达结构或语义。
|
||||
- 用样式、间距和表格表达结构。禁止用空段落、空格或手打符号(•、1.)假装排版。
|
||||
- 正式文件默认 A4、有页码、有真实标题层级和表格样式。主题(现代 / 公文 / 极简)、模板和仿制规则见交付物工作流与 house-style。
|
||||
|
||||
交付前自检,未通过不得声称完成:
|
||||
|
||||
- 完整:要求的部分都在,没有空节。
|
||||
- 准确:人名、时间、数字、引用与材料一致;不确定处已标注。
|
||||
- 一致:术语、日期格式、称谓、口径前后相同。
|
||||
- 版式:Office 文件必须先过 housestyle.lint,存在 error 先修再登记。
|
||||
@@ -1,14 +1,17 @@
|
||||
---
|
||||
id: base.style
|
||||
layer: base
|
||||
version: 1
|
||||
version: 2
|
||||
title: 输出与沟通风格
|
||||
description: 结构化 Markdown、语言跟随、详略取舍
|
||||
description: 对话回复的结构化 Markdown、语言跟随与详略取舍
|
||||
---
|
||||
沟通风格:
|
||||
|
||||
- 使用与用户相同的语言回复;术语首次出现时给出必要的中文解释。
|
||||
- 回复用 Markdown 结构化排版:标题分节、要点列表、表格呈现结构化数据、代码用代码块;避免大段无结构的文字墙。
|
||||
- 详略匹配任务:简单问题直接给答案,不铺陈背景;交付类任务(总结、纪要、报告)内容完整详实,除非用户明确要求压缩,不自行省略。
|
||||
- 结论先行:先给结果或建议,再给依据与过程;过程细节放在可折叠或次要位置。
|
||||
- 引用来源时注明位置(文档段落、章节名、链接);承认局限与未覆盖的部分,不掩饰。
|
||||
- 回复用 Markdown 结构化排版:标题分节、要点列表、表格呈现结构化数据、代码用代码块。避免大段无结构的文字墙。
|
||||
- 标题层级连续,不从一级跳到三级。加粗只标结论或关键词,不整段加粗。
|
||||
- 两项以上的对比用表格,不用长句并列。步骤用有序列表,并列要点用无序列表。
|
||||
- 长代码和长清单放进代码块或交付物;回复里只留读者需要立刻看到的部分。
|
||||
- 详略匹配任务:简单问题直接给答案,不铺陈背景;交付类任务内容完整,除非用户明确要求压缩,不自行省略。
|
||||
- 结论先行:先给结果或建议,再给依据与过程。
|
||||
- 引用来源时注明位置(文档段落、章节名、链接);承认局限与未覆盖的部分。
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
---
|
||||
id: env.runtime
|
||||
layer: env
|
||||
version: 1
|
||||
version: 2
|
||||
title: 运行环境信息
|
||||
description: 动态注入的当前环境事实(占位符随运行填充)
|
||||
---
|
||||
当前运行环境:
|
||||
- 今天是 {{date}}({{weekday}})。涉及"今天/本周/截止日期"的推理以此为准。
|
||||
|
||||
- 今天是 {{date}}({{weekday}})。涉及“今天 / 本周 / 截止日期”的推理以此为准。
|
||||
- 本次会话使用的模型:{{model}}。
|
||||
- 本轮附带文档:{{attachments}}。文档全文已注入上下文(超长时会标注截断)。
|
||||
- 交付物默认样式:{{deliverableStyle}}。本轮的明确指令、上传的模板或参考文档优先于该默认值。
|
||||
- 技能目录:{{skillsDir}}。环境变量 AGENT_SKILLS_DIR 指向这里;Python 已能 `import housestyle`。
|
||||
- 本轮原件(工作区相对路径):{{inputs}}。模板和参考文档用这些路径,不要向用户索取已经上传的文件。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: task.action-items
|
||||
layer: task
|
||||
version: 1
|
||||
version: 2
|
||||
title: 待办提取
|
||||
description: 从文档/对话中提取可跟踪的行动项清单
|
||||
priority: 50
|
||||
@@ -20,3 +20,4 @@ triggers:
|
||||
- 保留来源定位(文档哪一段/对话哪一轮),便于回溯核对。
|
||||
- 相似事项合并,重复事项只留一条并注明多处出现。
|
||||
- 输出可直接入待办系统的干净清单,不加评论性文字。
|
||||
- 用表格而不是手打列表:编号、事项、负责人、期限、来源、状态。状态默认“新”。期限和负责人缺失写“未指明”。正式交付走交付物工作流,lint 无 error 后登记。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: task.agenda
|
||||
layer: task
|
||||
version: 1
|
||||
version: 2
|
||||
title: 会议议程生成
|
||||
description: 依据目标/材料生成会议议程(含时间分配与准备事项)
|
||||
priority: 50
|
||||
@@ -22,3 +22,4 @@ triggers:
|
||||
- 时间分配现实:给讨论和缓冲留余量,不排满;关键决策议题放前面或精力最好的时段。
|
||||
- 依据文档生成时(如上次会议纪要),衔接未决问题与待办,不凭空造议题。
|
||||
- 需要会前准备的事项(材料、数据、预读)明确到人。
|
||||
- 议程用表格:时段、议题、主持或汇报人、预期产出。时间写同一格式,各段之和与会议总时长一致。会前准备另表:事项、负责人。正式交付走交付物工作流,lint 无 error 后登记。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: task.compare
|
||||
layer: task
|
||||
version: 1
|
||||
version: 2
|
||||
title: 对比分析
|
||||
description: 对多个对象(产品/方案/数据集)做多维对比并给结论
|
||||
priority: 55
|
||||
@@ -24,3 +24,4 @@ triggers:
|
||||
- 来源数据来自文档时注明出处;你补充的外部知识标注"文档外"。
|
||||
- 结论分场景给:很少有一个对象全面胜出,写清各自适用条件。
|
||||
- 不回避劣势:每个对象至少一条劣势或局限,全面吹一个踩其余没有决策价值。
|
||||
- 对比矩阵用表格:行是对象或维度,列对齐,表头写清口径。关键差异不要再复制整张表,只展开影响决策的两三条。正式交付走交付物工作流(docx 或 xlsx),lint 无 error 后登记。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: task.doc-minutes
|
||||
layer: task
|
||||
version: 1
|
||||
version: 2
|
||||
title: 会议纪要生成
|
||||
description: 依据上传的会议记录/录音稿生成结构完整的会议纪要
|
||||
priority: 70
|
||||
@@ -26,4 +26,6 @@ triggers:
|
||||
- 区分三件事:讨论过程(谁说了什么)、形成的决议(集体结论)、待办事项(谁在何时前做什么)——不要把发言当决议。
|
||||
- 内容完整详实,除非用户明确要求精简,不要压缩篇幅;原文模糊或矛盾的点在"未决问题"中列出而不是擅自裁决。
|
||||
- 待办事项若原文未指明负责人或期限,写"未指明",不要编造。
|
||||
- 交付形态:按「交付物工作流」自己生成正式 Word 文档——run_python(python-docx 配方)产出「<主题>会议纪要.docx」(标题层级/基本信息表/待办表/决议编号列表都用工件排版表达),再 register_artifact 登记交付;对话内只简述结论,不要在回复里复述全文。
|
||||
- 版面:标题下用键值表写会议信息(时间、地点、主持人、参会人、缺席)。讨论按议题分节,每节含要点、分歧和结论。决议用真编号列表,不要再手写“1.”。待办用表格,列:序号、事项、负责人、期限、状态;原文没有的写“未指明”。发言人用统一称谓(职务或姓名);原文只有“说话人X”时保留并在文末说明。引语只留关键句,并标注是谁说的。
|
||||
- 日期和数字全文一种格式。不要空节、空段落和手打项目符号。
|
||||
- 交付形态:按「交付物工作流」用 housestyle.docx_kit 生成「<主题>会议纪要.docx」,lint 无 error 后再 register_artifact。对话里只简述结论和所用样式,不要复述全文。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: task.report
|
||||
layer: task
|
||||
version: 1
|
||||
version: 2
|
||||
title: 报告撰写
|
||||
description: 把材料组织成正式报告/公文(含执行摘要与附录建议)
|
||||
priority: 55
|
||||
@@ -24,3 +24,5 @@ triggers:
|
||||
- 执行摘要是全文最重要的一段:结论、关键数字、建议动作,让只读摘要的人能做决定。
|
||||
- 结构服务于阅读动线:先结论后论证;每章开头一句话说清本章要点。
|
||||
- 篇幅完整不注水:覆盖必要内容,删掉不改变结论的空话。
|
||||
- 版面:封面(标题、受众、日期)、正文从执行摘要开始。执行摘要用强调块,控制在契约规定的篇幅内。章标题用标题样式,章首一句点明本章要点。数据进表格或附录,不埋在段落里。需要时在正文前放由标题生成的目录说明(页码以文件为准)。
|
||||
- 交付 Word 时走交付物工作流:housestyle.docx_kit 生成,lint 无 error 后登记。回复说明样式,不复述全文。
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
---
|
||||
id: tool.write_artifact
|
||||
layer: tool
|
||||
version: 2
|
||||
version: 3
|
||||
title: write_artifact 工具描述
|
||||
description: 工件写入工具的模型侧说明文案
|
||||
---
|
||||
把**文本类**内容(Markdown/纯文本/JSON/CSV/代码)写入工件,返回工件 ID 与名称。用于文本交付物与需要保存、后续引用的数据。对话内的临时说明不需要写工件。写入时 name 用人类可读的文件名(含扩展名),kind 按内容选 document/code/data;同一交付物修订时写新版本而不是覆盖引用旧名。
|
||||
|
||||
Markdown 交付物同样遵守专业交付标准:标题层级连续、对比用表格、不写空节、术语和日期格式全文一致。它不是把纯文本改一个扩展名。
|
||||
|
||||
分工:本工具只收字符串内容,**不要用它写 .docx/.xlsx/.pptx 等二进制文件**(纯文本改扩展名是无排版的伪文件)。带排版的 Office/PDF/图片交付物由你用 run_python(或 run_shell)生成到工作区后,用 register_artifact 登记交付——配方见交付物工作流(base.deliverables)。
|
||||
|
||||
@@ -14,8 +14,8 @@ func TestDefaultLibraryParses(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(lib) < 46 {
|
||||
t.Errorf("builtin library too small: %d modules (expect >=46 across layers)", len(lib))
|
||||
if len(lib) < 47 {
|
||||
t.Errorf("builtin library too small: %d modules (expect >=47 across layers)", len(lib))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
validLayers := map[string]bool{core.LayerBase: true, core.LayerEnv: true, core.LayerTask: true,
|
||||
@@ -45,7 +45,7 @@ func TestDefaultLibraryParses(t *testing.T) {
|
||||
}
|
||||
}
|
||||
// 关键模块在位:基座五块 + 环境 + 纪要技能 + 通用子代理。
|
||||
for _, id := range []string{"base.identity", "base.behavior", "base.caution", "base.tools", "base.style",
|
||||
for _, id := range []string{"base.identity", "base.behavior", "base.caution", "base.tools", "base.style", "base.quality",
|
||||
"env.runtime", "task.doc-minutes", "agent.sub.general", "aux.session-compact", "tool.tavily_search", "tool.web_search"} {
|
||||
if !seen[id] {
|
||||
t.Errorf("required module missing: %s", id)
|
||||
@@ -72,9 +72,9 @@ func TestResolveSystemDefaultBase(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 默认:base 六块(identity/behavior/caution/style/tools/deliverables)+ env 一块,无技能注入。
|
||||
if len(blocks) != 7 {
|
||||
t.Errorf("blocks = %d, want 7 (6 base + env)", len(blocks))
|
||||
// 默认:base 七块(identity/behavior/caution/style/tools/deliverables/quality)+ env 一块,无技能注入。
|
||||
if len(blocks) != 8 {
|
||||
t.Errorf("blocks = %d, want 8 (7 base + env)", len(blocks))
|
||||
}
|
||||
var envBody string
|
||||
for _, b := range blocks {
|
||||
@@ -95,6 +95,33 @@ func TestResolveSystemDefaultBase(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvDeliverablePlaceholders(t *testing.T) {
|
||||
svc := mustService(t)
|
||||
blocks, _, err := svc.ResolveSystem(core.AgentProfile{}, TriggerCtx{Text: "你好"}, EnvData{
|
||||
Date: "2026-10-01", Weekday: "星期四", Model: "demo",
|
||||
DeliverableStyle: "formal,强调色 1B3A4B",
|
||||
SkillsDir: "/data/skills",
|
||||
Inputs: "inputs/a.docx、inputs/_default-template.docx",
|
||||
}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var envBody string
|
||||
for _, b := range blocks {
|
||||
if b.ModuleID == "env.runtime" {
|
||||
envBody = b.Body
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"formal,强调色 1B3A4B", "/data/skills", "inputs/a.docx", "inputs/_default-template.docx", "2026-10-01"} {
|
||||
if !strings.Contains(envBody, want) {
|
||||
t.Errorf("env missing %q\n%s", want, envBody)
|
||||
}
|
||||
}
|
||||
if strings.Contains(envBody, "{{") {
|
||||
t.Errorf("unresolved placeholder: %s", envBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSystemSkillTriggers(t *testing.T) {
|
||||
svc := mustService(t)
|
||||
cases := []struct {
|
||||
|
||||
@@ -45,10 +45,13 @@ type TriggerCtx struct {
|
||||
|
||||
// EnvData 环境段占位符数据。
|
||||
type EnvData struct {
|
||||
Date string
|
||||
Weekday string
|
||||
Model string
|
||||
Attachments string // 已格式化的附件名列表(空 = "无")
|
||||
Date string
|
||||
Weekday string
|
||||
Model string
|
||||
Attachments string // 已格式化的附件名列表(空 = "无")
|
||||
DeliverableStyle string // 默认交付样式(空 = modern)
|
||||
SkillsDir string // 技能目录绝对路径(空 = "未配置")
|
||||
Inputs string // 本轮原件的工作区相对路径(空 = "无")
|
||||
}
|
||||
|
||||
// Service 提示词库服务:内置(embed)→ 用户(DB)覆盖链;触发匹配与 system 组装。
|
||||
@@ -312,14 +315,26 @@ func (s *Service) ToolPrompt(owner, toolName string) (string, bool, error) {
|
||||
// resolvePlaceholders 填充 {{key}};未知占位符原样保留(可见即信号)。
|
||||
func resolvePlaceholders(body string, env EnvData) string {
|
||||
repl := map[string]string{
|
||||
"date": env.Date,
|
||||
"weekday": env.Weekday,
|
||||
"model": env.Model,
|
||||
"attachments": env.Attachments,
|
||||
"date": env.Date,
|
||||
"weekday": env.Weekday,
|
||||
"model": env.Model,
|
||||
"attachments": env.Attachments,
|
||||
"deliverableStyle": env.DeliverableStyle,
|
||||
"skillsDir": env.SkillsDir,
|
||||
"inputs": env.Inputs,
|
||||
}
|
||||
if repl["attachments"] == "" {
|
||||
repl["attachments"] = "无"
|
||||
}
|
||||
if repl["inputs"] == "" {
|
||||
repl["inputs"] = "无"
|
||||
}
|
||||
if repl["deliverableStyle"] == "" {
|
||||
repl["deliverableStyle"] = "modern"
|
||||
}
|
||||
if repl["skillsDir"] == "" {
|
||||
repl["skillsDir"] = "未配置"
|
||||
}
|
||||
for k, v := range repl {
|
||||
if v == "" {
|
||||
v = "未知" // 日期/星期/模型缺失不裸露占位符
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// WorkspaceFile 是要落到会话工作区 inputs/ 的原件(上传文件或默认模板)。
|
||||
type WorkspaceFile struct {
|
||||
Name string
|
||||
Content []byte
|
||||
}
|
||||
|
||||
// StageWorkspaceInputs 把原件和可选默认模板写到 <root>/<owner>/<conversation>/inputs/。
|
||||
// root 为空时不写(工作区未配置)。返回工作区相对路径。同一批里重名的文件加序号。
|
||||
func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFile, templateName string, template []byte) ([]string, error) {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
dir := filepath.Join(root, owner, conversation, "inputs")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rels []string
|
||||
used := map[string]bool{}
|
||||
for _, file := range files {
|
||||
name, ok := safeInputName(file.Name)
|
||||
if !ok || len(file.Content) == 0 {
|
||||
continue
|
||||
}
|
||||
name = uniqueInputName(name, used)
|
||||
if err := os.WriteFile(filepath.Join(dir, name), file.Content, 0o644); err != nil {
|
||||
return rels, err
|
||||
}
|
||||
used[name] = true
|
||||
rels = append(rels, "inputs/"+name)
|
||||
}
|
||||
if len(template) > 0 {
|
||||
ext := filepath.Ext(templateName)
|
||||
base, ok := safeInputName(templateName)
|
||||
if ok {
|
||||
ext = filepath.Ext(base)
|
||||
}
|
||||
name := "_default-template" + ext
|
||||
if err := os.WriteFile(filepath.Join(dir, name), template, 0o644); err != nil {
|
||||
return rels, err
|
||||
}
|
||||
rels = append(rels, "inputs/"+name)
|
||||
}
|
||||
return rels, nil
|
||||
}
|
||||
|
||||
func (m *Manager) stageInputs(conv core.Conversation, eff core.EffectiveSettings, files []WorkspaceFile) ([]string, error) {
|
||||
var templateName string
|
||||
var template []byte
|
||||
if id := strings.TrimSpace(eff.GetString("deliverables.templateArtifactId", "")); id != "" && m.st != nil {
|
||||
if art, err := m.st.GetArtifact(conv.Owner, id); err == nil {
|
||||
if body, err := m.st.ArtifactContent(conv.Owner, id); err == nil && len(body) > 0 {
|
||||
templateName, template = art.Name, body
|
||||
}
|
||||
}
|
||||
}
|
||||
return StageWorkspaceInputs(m.workspaceRoot, conv.Owner, conv.ID, files, templateName, template)
|
||||
}
|
||||
|
||||
func safeInputName(name string) (string, bool) {
|
||||
base := filepath.Base(strings.TrimSpace(name))
|
||||
if base == "" || base == "." || base == ".." || strings.HasPrefix(base, ".") {
|
||||
return "", false
|
||||
}
|
||||
if strings.ContainsAny(base, `/\`) {
|
||||
return "", false
|
||||
}
|
||||
return base, true
|
||||
}
|
||||
|
||||
func uniqueInputName(name string, used map[string]bool) string {
|
||||
if !used[name] {
|
||||
return name
|
||||
}
|
||||
ext := filepath.Ext(name)
|
||||
stem := strings.TrimSuffix(name, ext)
|
||||
for n := 2; ; n++ {
|
||||
candidate := fmt.Sprintf("%s-%d%s", stem, n, ext)
|
||||
if !used[candidate] {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStageWorkspaceInputs(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
paths, err := StageWorkspaceInputs(root, "alice", "conv1", []WorkspaceFile{
|
||||
{Name: "纪要.docx", Content: []byte("docx")},
|
||||
{Name: "../secret.txt", Content: []byte("no")},
|
||||
{Name: "纪要.docx", Content: []byte("second")},
|
||||
{Name: "", Content: []byte("skip")},
|
||||
}, "品牌模板.pptx", []byte("tpl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"inputs/纪要.docx", "inputs/secret.txt", "inputs/纪要-2.docx", "inputs/_default-template.pptx"}
|
||||
if len(paths) != len(want) {
|
||||
t.Fatalf("paths = %v, want %v", paths, want)
|
||||
}
|
||||
for i, rel := range want {
|
||||
if paths[i] != rel {
|
||||
t.Fatalf("paths[%d] = %q, want %q", i, paths[i], rel)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "alice", "conv1", rel)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "alice", "secret.txt")); err == nil {
|
||||
t.Fatal("path traversal must not escape inputs/")
|
||||
}
|
||||
empty, err := StageWorkspaceInputs("", "alice", "conv1", []WorkspaceFile{{Name: "a.txt", Content: []byte("x")}}, "", nil)
|
||||
if err != nil || empty != nil {
|
||||
t.Fatalf("unconfigured root must no-op, got %v %v", empty, err)
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,7 @@ type runCtx struct {
|
||||
h *runHandle
|
||||
userMessage core.Message
|
||||
attachments []core.ParsedDocument
|
||||
inputPaths []string
|
||||
subTask string
|
||||
subHint string
|
||||
}
|
||||
@@ -134,6 +135,7 @@ func (c *runCtx) History() []core.Message {
|
||||
return hist
|
||||
}
|
||||
func (c *runCtx) Attachments() []core.ParsedDocument { return c.attachments }
|
||||
func (c *runCtx) WorkspaceInputs() []string { return c.inputPaths }
|
||||
func (c *runCtx) Subtask() string { return c.subTask }
|
||||
|
||||
// Tools 模型可见工具清单:Hidden 工具(fs 预留能力)在 runtime.fsTools 未开时过滤
|
||||
@@ -566,7 +568,8 @@ func (t *toolCtx) PeerConversationMessages(conversationID string, limit int) ([]
|
||||
|
||||
// StartConversationRun 为一条已落库的用户消息启动 conversation run。
|
||||
// attachments 为已解析的附件文档(server 层经 ingest 处理)。
|
||||
func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument) (core.Run, error) {
|
||||
// inputs 是同一批附件的原件,落到会话工作区 inputs/,供模板和仿制使用。
|
||||
func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile) (core.Run, error) {
|
||||
// 会话内串行(设计策略落地):在途 run(含 HITL 挂起)未终态前拒绝新 run。
|
||||
m.convMu.Lock()
|
||||
if m.convActive[conv.ID] {
|
||||
@@ -596,7 +599,8 @@ func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core.
|
||||
if eff.GetBool("runtime.mock", false) {
|
||||
runner = m.mock
|
||||
}
|
||||
rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments}
|
||||
inputPaths, _ := m.stageInputs(conv, eff, inputs)
|
||||
rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths}
|
||||
go m.execute(rc, runner)
|
||||
return run, nil
|
||||
}
|
||||
|
||||
+28
-3
@@ -20,6 +20,8 @@ import (
|
||||
type LLMRunner struct {
|
||||
// Prompts 提示词库(nil = 回退画像 SystemPrompt 单段,兼容测试与骨架)。
|
||||
Prompts *prompts.Service
|
||||
// SkillsDir 内置技能目录(<data>/skills)。写入环境段,供模型定位 house-style。
|
||||
SkillsDir string
|
||||
// Stream 提供方边界注入点(nil = 按模型路由构造 agent-native 客户端)。
|
||||
Stream agentllm.StreamFunc
|
||||
// Now 注入时钟(nil = time.Now)。测试用它固定思考与工具耗时。
|
||||
@@ -66,13 +68,20 @@ func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message,
|
||||
}
|
||||
now := time.Now()
|
||||
env := prompts.EnvData{
|
||||
Date: now.Format("2006-01-02"),
|
||||
Weekday: weekdayCN(now),
|
||||
Model: model,
|
||||
Date: now.Format("2006-01-02"),
|
||||
Weekday: weekdayCN(now),
|
||||
Model: model,
|
||||
DeliverableStyle: deliverableStyleLine(rc.Settings()),
|
||||
SkillsDir: l.SkillsDir,
|
||||
}
|
||||
if len(names) > 0 {
|
||||
env.Attachments = strings.Join(names, "、")
|
||||
}
|
||||
if listed, ok := rc.(interface{ WorkspaceInputs() []string }); ok {
|
||||
if paths := listed.WorkspaceInputs(); len(paths) > 0 {
|
||||
env.Inputs = strings.Join(paths, "、")
|
||||
}
|
||||
}
|
||||
prof := rc.Profile()
|
||||
blocks, hits, err := l.Prompts.ResolveSystem(prof, prompts.TriggerCtx{
|
||||
Text: userText, HasAttachments: len(atts) > 0,
|
||||
@@ -103,6 +112,22 @@ func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message,
|
||||
return msgs, obs
|
||||
}
|
||||
|
||||
// deliverableStyleLine 把设置里的默认主题、强调色和模板压成环境段的一句话。
|
||||
func deliverableStyleLine(eff core.EffectiveSettings) string {
|
||||
style := strings.TrimSpace(eff.GetString("deliverables.style", "modern"))
|
||||
if style == "" {
|
||||
style = "modern"
|
||||
}
|
||||
line := style
|
||||
if accent := strings.TrimSpace(eff.GetString("deliverables.accentColor", "")); accent != "" {
|
||||
line += ",强调色 " + accent
|
||||
}
|
||||
if strings.TrimSpace(eff.GetString("deliverables.templateArtifactId", "")) != "" {
|
||||
line += ";已配置默认模板(见 inputs 中的 _default-template)"
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// toolDesc 工具描述:库覆盖(tool.<name>)优先,回落注册表文案。
|
||||
func (l *LLMRunner) toolDesc(owner, name, fallback string) string {
|
||||
if l.Prompts != nil {
|
||||
|
||||
@@ -207,18 +207,18 @@ func TestConversationBusyConflict(t *testing.T) {
|
||||
|
||||
msg := core.Message{ConversationID: convID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "hi"}}}
|
||||
conv, _ := st.GetConversation(store.OwnerDefault, convID)
|
||||
if _, err := mgr.StartConversationRun(conv, msg, nil); err != nil {
|
||||
if _, err := mgr.StartConversationRun(conv, msg, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 第二个 run:409。
|
||||
if _, err := mgr.StartConversationRun(conv, msg, nil); err != runmgr.ErrConversationBusy {
|
||||
if _, err := mgr.StartConversationRun(conv, msg, nil, nil); err != runmgr.ErrConversationBusy {
|
||||
t.Fatalf("second run must be busy, got %v", err)
|
||||
}
|
||||
// 释放第一个 run,槽位归还后可再启动。
|
||||
close(release)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
_, err := mgr.StartConversationRun(conv, msg, nil)
|
||||
_, err := mgr.StartConversationRun(conv, msg, nil, nil)
|
||||
if err == nil {
|
||||
return // 槽位已归还:通过
|
||||
}
|
||||
|
||||
@@ -529,6 +529,7 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
parts = append(parts, core.Part{Type: core.PartText, Text: text})
|
||||
}
|
||||
var parsedDocs []core.ParsedDocument
|
||||
var inputs []runmgr.WorkspaceFile
|
||||
for _, artID := range req.AttachmentIDs {
|
||||
art, err := s.st.GetArtifact(owner, artID)
|
||||
if err != nil {
|
||||
@@ -536,6 +537,9 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
content, _ := s.st.ArtifactContent(owner, artID)
|
||||
if len(content) > 0 {
|
||||
inputs = append(inputs, runmgr.WorkspaceFile{Name: art.Name, Content: content})
|
||||
}
|
||||
doc := ingest.Parse(art.Name, art.MIME, content)
|
||||
parsedDocs = append(parsedDocs, doc)
|
||||
parts = append(parts, core.Part{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind}})
|
||||
@@ -565,7 +569,7 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
run, err := s.mgr.StartConversationRun(conv, msg, parsedDocs)
|
||||
run, err := s.mgr.StartConversationRun(conv, msg, parsedDocs, inputs)
|
||||
writeJSONOr(w, map[string]any{"message": msg, "run": run}, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,12 @@ func SystemDefaults() map[string]any {
|
||||
},
|
||||
"retrieval": map[string]any{"topK": 5},
|
||||
"ui": map[string]any{"theme": "dark", "cardflow": false},
|
||||
"plugins": map[string]any{"dir": "./plugins"},
|
||||
"deliverables": map[string]any{
|
||||
"style": "modern", // modern / formal / minimal
|
||||
"accentColor": "", // 空 = 主题默认主色;#RRGGBB 或 RRGGBB
|
||||
"templateArtifactId": "", // 非空 = 该工件作为默认模板,落到 inputs/_default-template*
|
||||
},
|
||||
"plugins": map[string]any{"dir": "./plugins"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +83,9 @@ func Schema() []core.SettingDesc {
|
||||
{Key: "ui.cardflow", Group: "ui", Label: "长结果进右侧卡片", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "开启后报告/纪要/方案等产物型长内容经 submit_card 进收件箱,对话只留结论摘要与锚点"},
|
||||
{Key: "tools.tavily.apiKeys", Group: "tools", Label: "Tavily API Keys(池)", Type: core.SettingSecret, Default: "", Effect: core.EffectRestart, Description: "逗号分隔;配置后注册 tavily_search/web_search 工具(429/401/403 自动轮换下一把)"},
|
||||
{Key: "tools.tavily.endpoint", Group: "tools", Label: "Tavily 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "空 = 官方 https://api.tavily.com/search;可指向自建代理"},
|
||||
{Key: "deliverables.style", Group: "deliverables", Label: "交付物默认样式", Type: core.SettingEnum, Default: "modern", Options: []string{"modern", "formal", "minimal"}, Effect: core.EffectNewRun, Description: "modern 现代专业 / formal 公文 / minimal 极简。本轮指令、上传模板或参考文档优先于这项"},
|
||||
{Key: "deliverables.accentColor", Group: "deliverables", Label: "交付物强调色", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "6 位十六进制,如 1B3A4B。空 = 使用样式自带主色"},
|
||||
{Key: "deliverables.templateArtifactId", Group: "deliverables", Label: "默认模板工件 ID", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "填写已上传模板的工件 ID。每次运行会复制到会话 inputs/_default-template"},
|
||||
{Key: "plugins.dir", Group: "plugins", Label: "插件目录", Type: core.SettingString, Default: "./plugins", Effect: core.EffectRestart},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package settings
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDeliverableSettingsPresent(t *testing.T) {
|
||||
defaults := SystemDefaults()
|
||||
block, ok := defaults["deliverables"].(map[string]any)
|
||||
if !ok || block["style"] != "modern" || block["accentColor"] != "" || block["templateArtifactId"] != "" {
|
||||
t.Fatalf("defaults = %#v", defaults["deliverables"])
|
||||
}
|
||||
var style, accent, template bool
|
||||
for _, desc := range Schema() {
|
||||
switch desc.Key {
|
||||
case "deliverables.style":
|
||||
style = len(desc.Options) == 3 && desc.Options[0] == "modern"
|
||||
case "deliverables.accentColor":
|
||||
accent = true
|
||||
case "deliverables.templateArtifactId":
|
||||
template = true
|
||||
}
|
||||
}
|
||||
if !style || !accent || !template {
|
||||
t.Fatalf("schema missing deliverable keys: style=%v accent=%v template=%v", style, accent, template)
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,35 @@
|
||||
---
|
||||
description: 用 python-docx 生成带真实排版的 Word(.docx)交付物
|
||||
description: 用 housestyle 生成可直接交付的 Word(.docx)
|
||||
---
|
||||
|
||||
# docx 生成配方(python-docx)
|
||||
# docx 生成配方
|
||||
|
||||
先装库(缺库时):`pip install --user python-docx`。经 run_python 执行,文件落在会话工作区。
|
||||
先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md` 选定主题、模板或仿制结果。缺库时 `pip install --user python-docx`。脚本由 run_python 执行,文件落在会话工作区。
|
||||
|
||||
```python
|
||||
from docx import Document
|
||||
from docx.shared import Pt, RGBColor
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.oxml.ns import qn
|
||||
from housestyle import docx_kit as dx
|
||||
|
||||
doc = Document()
|
||||
# 全局中文字体:等线(西文 Calibri)
|
||||
style = doc.styles['Normal']
|
||||
style.font.name = 'Calibri'
|
||||
style._element.rPr.rFonts.set(qn('w:eastAsia'), '等线')
|
||||
style.font.size = Pt(11)
|
||||
|
||||
t = doc.add_heading('文档标题', level=0)
|
||||
t.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
|
||||
doc.add_heading('一、章节标题', level=1)
|
||||
doc.add_paragraph('正文段落……')
|
||||
|
||||
# 表格(带样式)
|
||||
table = doc.add_table(rows=1, cols=3)
|
||||
table.style = 'Light Grid Accent 1'
|
||||
for i, h in enumerate(['列一', '列二', '列三']):
|
||||
table.rows[0].cells[i].text = h
|
||||
table.add_row().cells[0].text = '数据'
|
||||
|
||||
# 列表
|
||||
doc.add_paragraph('要点', style='List Bullet')
|
||||
doc.add_paragraph('结论', style='List Number')
|
||||
|
||||
doc.save('交付物名.docx')
|
||||
doc = dx.new_document(theme="modern", title="文档标题")
|
||||
dx.add_title(doc, "文档标题", "日期或副标题")
|
||||
dx.add_kv_table(doc, [("项目", "值"), ("日期", "2026年10月1日")])
|
||||
dx.add_heading(doc, "一、结论", 1)
|
||||
dx.add_paragraph(doc, "先写结论。")
|
||||
dx.add_heading(doc, "依据", 2)
|
||||
dx.add_bullets(doc, ["要点一", "要点二"])
|
||||
dx.add_numbers(doc, ["步骤一", "步骤二"])
|
||||
dx.add_table(doc, ["事项", "负责人", "期限"], [["完成需求说明", "未指明", "未指明"]], ["left", "left", "left"])
|
||||
dx.add_callout(doc, "只看这一段也能知道要做什么。", "摘要")
|
||||
dx.save(doc, "交付物.docx")
|
||||
```
|
||||
|
||||
要求:标题层级、表格、列表样式必须真实排版;生成后 register_artifact 登记(path 传相对路径);修订生成新文件名而不是覆盖。
|
||||
然后执行 `python -m housestyle.lint 交付物.docx`。没有 error 再 `register_artifact`。修订用新文件名。
|
||||
|
||||
版面范式:
|
||||
|
||||
- 纪要:标题、会议信息键值表、议题(要点 / 分歧 / 结论)、决议编号、待办表(序号、事项、负责人、期限、状态)、未决问题。
|
||||
- 报告:封面信息、执行摘要强调块、分章、数据表、附录。章首一句说明本章要点。
|
||||
- 方案:背景、目标、方案本身、取舍、风险、下一步。对比用表。
|
||||
- 对比:维度说明、矩阵表、两三条关键差异、分场景结论。
|
||||
- 议程:目标、议程表(时段、议题、负责人、预期产出)、会前准备表。
|
||||
|
||||
列表文本不要再写 “1.” 或 “•”。没有内容的章节不要出现。
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
description: 交付物设计系统。用 housestyle 生成 Word、Excel、PowerPoint,并在登记前自检。
|
||||
---
|
||||
|
||||
# house-style
|
||||
|
||||
正式交付物的版式都从这里来。先选主题或模板,再用对应 kit 的构件,最后跑 lint。不要手写空段落、项目符号或 Word 默认蓝标题。
|
||||
|
||||
## 主题
|
||||
|
||||
| 主题 | 何时用 | 字体 | 主色 |
|
||||
|---|---|---|---|
|
||||
| modern(默认) | 方案、纪要、汇报、内部文档 | 微软雅黑 / Calibri | `#1B3A4B` |
|
||||
| formal | 用户要求公文、红头或 GB/T 9704 风格 | 仿宋_GB2312,标题黑体 | `#C00000` 只作少量强调 |
|
||||
| minimal | 用户要求极简、黑白或品牌点缀 | 等线 / Calibri | `#0B7FFF` |
|
||||
|
||||
页面默认 A4。现代主题页边距上 2.4 / 下 2.2 / 左右 2.6 cm,正文 11 磅、1.15 倍行距。公文主题页边距上 3.7 / 下 3.5 / 左 2.8 / 右 2.6 cm,正文三号、固定 28 磅行距。演示稿默认 16:9。颜色只用来区分标题、表头和强调,不大面积铺色。
|
||||
|
||||
`resolve_theme(theme, accent="#00AA00")` 可只换主色。`accent` 必须是 6 位十六进制。
|
||||
|
||||
## 样式怎么选
|
||||
|
||||
按这个顺序,只取第一个命中项:
|
||||
|
||||
1. 用户本轮说清楚的要求(“改成公文”“主色用品牌绿”)。
|
||||
2. 本轮上传的模板(`.docx` `.dotx` `.pptx` `.potx` `.xlsx` `.xltx`),路径在 `inputs/`。Word 用 `new_document(template=路径)`,它会清空正文,保留纸张、页边距和页眉页脚。
|
||||
3. 用户要求仿照的参考文档:`from housestyle.extract import extract`,再 `extract(路径)` 得到字典,传给 `new_document(抽取结果)`。PDF 在装了 pdfplumber 时抽取字体和页面尺寸,否则说明只能近似。图片不能精确抽取,按描述近似,并请用户确认主色、字体和页边距。
|
||||
4. 本会话前面已经定过的样式。
|
||||
5. 环境段里的默认主题、强调色和 `inputs/_default-template*`。
|
||||
|
||||
## 构件
|
||||
|
||||
```python
|
||||
import housestyle
|
||||
from housestyle import docx_kit as dx
|
||||
from housestyle import pptx_kit as px
|
||||
from housestyle import xlsx_kit as xs
|
||||
|
||||
doc = dx.new_document(theme="modern", title="文档标题") # accent="1B3A4B" 或 template="inputs/模板.docx"
|
||||
dx.add_title(doc, "文档标题", "副标题或日期")
|
||||
dx.add_kv_table(doc, [("字段", "值")])
|
||||
dx.add_heading(doc, "一级标题", 1) # 只到 3 级
|
||||
dx.add_paragraph(doc, "正文。")
|
||||
dx.add_bullets(doc, ["要点"]) # 文本里不要再写 •
|
||||
dx.add_numbers(doc, ["步骤"]) # 文本里不要再写 1.
|
||||
dx.add_table(doc, ["列"], [["值"]], ["left"])
|
||||
dx.add_callout(doc, "给决策者的一段话。", "摘要")
|
||||
dx.add_quote(doc, "关键原话。", "出处")
|
||||
dx.save(doc, "交付物.docx")
|
||||
|
||||
prs = px.new_presentation(theme="modern", title="汇报")
|
||||
px.add_title_slide(prs, "汇报标题", "副标题")
|
||||
px.add_section_slide(prs, "第一部分")
|
||||
px.add_bullets_slide(prs, "一页一个论点", ["最多五条"])
|
||||
px.add_two_column(prs, "对比", "左", ["点"], "右", ["点"])
|
||||
px.add_table_slide(prs, "表", ["列"], [["值"]])
|
||||
px.add_chart_slide(prs, "趋势", ["甲", "乙"], {"系列": [1, 2]})
|
||||
px.save(prs, "交付物.pptx")
|
||||
|
||||
wb = xs.new_workbook(theme="modern", title="数据")
|
||||
xs.add_sheet(wb, "明细", ["名称", "金额"], [["甲", 1200]], ["left", "right"])
|
||||
xs.save(wb, "交付物.xlsx", ["单位:元。口径见源表。"])
|
||||
```
|
||||
|
||||
标题用 `add_heading`,列表用 `add_bullets` / `add_numbers`,数据用 `add_table`。间距由样式控制。不要 `add_paragraph("")`,不要在普通段落开头写 `•`。
|
||||
|
||||
## 自检
|
||||
|
||||
```bash
|
||||
python -m housestyle.lint 交付物.docx
|
||||
python -c "import housestyle; print(housestyle.summarize('交付物.docx'))"
|
||||
```
|
||||
|
||||
lint 打印 JSON。`severity` 为 `error` 时退出码是 1,必须修好再 `register_artifact`。模板生成的文件会把纸张、字体、主题色、页码降为 warning,结构问题(空段、空节、手打符号、跳级)仍然是 error。
|
||||
|
||||
回读结果要和动笔前的结构一致:没有空节,表格列和内容对应,页数或幻灯片数合理。
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Office 交付物的 house style:主题、构件、抽取与自检。
|
||||
|
||||
抽取函数不要在这里再导出:模块名就是 extract,包级同名绑定会挡住
|
||||
`import housestyle.extract`。调用方写 `from housestyle.extract import extract`。
|
||||
"""
|
||||
|
||||
from housestyle.outline import summarize
|
||||
from housestyle.themes import THEMES, Theme, resolve_theme
|
||||
|
||||
__all__ = ["THEMES", "Theme", "resolve_theme", "summarize"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,428 @@
|
||||
"""用主题构件生成 Word。模板模式保留节属性和页眉页脚,只换正文。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from docx import Document
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
|
||||
from docx.oxml import OxmlElement
|
||||
from docx.oxml.ns import qn
|
||||
from docx.shared import Cm, Pt, RGBColor
|
||||
|
||||
from housestyle.themes import Theme, resolve_theme
|
||||
|
||||
|
||||
def new_document(theme: str | Theme | dict = "modern", accent: str | None = None, template: str | None = None, title: str = ""):
|
||||
spec = resolve_theme(theme, accent)
|
||||
if template:
|
||||
doc = Document(template)
|
||||
_clear_body(doc)
|
||||
doc.core_properties.subject = "housestyle-template"
|
||||
else:
|
||||
doc = Document()
|
||||
doc.core_properties.subject = "housestyle"
|
||||
_apply_page(doc, spec)
|
||||
_apply_header_footer(doc, spec, title)
|
||||
doc.core_properties.category = spec.id
|
||||
doc.core_properties.keywords = spec.accent
|
||||
if title:
|
||||
doc.core_properties.title = title
|
||||
if not template:
|
||||
_apply_styles(doc, spec)
|
||||
return doc
|
||||
|
||||
|
||||
def add_title(doc, text: str, subtitle: str = ""):
|
||||
spec = _theme(doc)
|
||||
p = doc.add_paragraph(text, style="Title")
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
if not _templated(doc):
|
||||
_paint(p, spec, size=spec.title_pt, bold=True, color=spec.accent, font=spec.heading_font)
|
||||
if subtitle:
|
||||
sub = doc.add_paragraph(subtitle, style="Normal")
|
||||
sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
if not _templated(doc):
|
||||
_paint(sub, spec, size=spec.body_pt, color=spec.muted, font=spec.font)
|
||||
return p
|
||||
|
||||
|
||||
def add_heading(doc, text: str, level: int = 1):
|
||||
if level not in (1, 2, 3):
|
||||
raise ValueError("heading level must be 1, 2 or 3")
|
||||
spec = _theme(doc)
|
||||
p = doc.add_paragraph(text, style=f"Heading {level}")
|
||||
if not _templated(doc):
|
||||
_paint(
|
||||
p,
|
||||
spec,
|
||||
size=spec.heading_pt[level - 1],
|
||||
bold=True,
|
||||
color=spec.heading_colors[level - 1],
|
||||
font=spec.heading_font,
|
||||
)
|
||||
return p
|
||||
|
||||
|
||||
def add_paragraph(doc, text: str, *, bold: bool = False):
|
||||
spec = _theme(doc)
|
||||
p = doc.add_paragraph(text, style="Normal")
|
||||
if not _templated(doc):
|
||||
_paint(p, spec, size=spec.body_pt, bold=bold, color=spec.text, font=spec.font)
|
||||
elif bold:
|
||||
for run in p.runs:
|
||||
run.bold = True
|
||||
return p
|
||||
|
||||
|
||||
def add_bullets(doc, items: list[str]):
|
||||
spec = _theme(doc)
|
||||
out = []
|
||||
for item in items:
|
||||
p = doc.add_paragraph(item, style="List Bullet")
|
||||
if not _templated(doc):
|
||||
_paint(p, spec, size=spec.body_pt, color=spec.text, font=spec.font)
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def add_numbers(doc, items: list[str]):
|
||||
spec = _theme(doc)
|
||||
out = []
|
||||
for item in items:
|
||||
p = doc.add_paragraph(item, style="List Number")
|
||||
if not _templated(doc):
|
||||
_paint(p, spec, size=spec.body_pt, color=spec.text, font=spec.font)
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def add_kv_table(doc, rows: list[tuple[str, str]]):
|
||||
spec = _theme(doc)
|
||||
table = doc.add_table(rows=len(rows), cols=2)
|
||||
_format_table(table, spec, header=False)
|
||||
width = _content_width(doc, spec)
|
||||
_set_widths(table, [width * 0.28, width * 0.72])
|
||||
for i, (key, value) in enumerate(rows):
|
||||
_write_cell(table.rows[i].cells[0], key, spec, bold=True, fill=spec.accent_soft, align="left")
|
||||
_write_cell(table.rows[i].cells[1], value, spec, align="left")
|
||||
return table
|
||||
|
||||
|
||||
def add_table(doc, headers: list[str], rows: list[list], alignments: list[str] | None = None):
|
||||
spec = _theme(doc)
|
||||
table = doc.add_table(rows=1 + len(rows), cols=len(headers))
|
||||
_format_table(table, spec, header=True)
|
||||
width = _content_width(doc, spec)
|
||||
_set_widths(table, [width / len(headers)] * len(headers))
|
||||
aligns = alignments or ["left"] * len(headers)
|
||||
for i, header in enumerate(headers):
|
||||
align = aligns[i] if i < len(aligns) else "left"
|
||||
_write_cell(
|
||||
table.rows[0].cells[i],
|
||||
str(header),
|
||||
spec,
|
||||
bold=True,
|
||||
color=spec.table_header_fg,
|
||||
fill=spec.table_header_bg,
|
||||
align=align if align != "right" else "center",
|
||||
)
|
||||
_repeat_header(table.rows[0])
|
||||
for r, row in enumerate(rows, start=1):
|
||||
fill = spec.table_alt if r % 2 == 0 else None
|
||||
for c, value in enumerate(row):
|
||||
align = aligns[c] if c < len(aligns) else "left"
|
||||
_write_cell(table.rows[r].cells[c], "" if value is None else str(value), spec, fill=fill, align=align)
|
||||
return table
|
||||
|
||||
|
||||
def add_callout(doc, text: str, label: str = ""):
|
||||
spec = _theme(doc)
|
||||
table = doc.add_table(rows=1, cols=1)
|
||||
_format_table(table, spec, header=False)
|
||||
body = f"{label}{text}" if not label else f"{label}\n{text}"
|
||||
_write_cell(table.cell(0, 0), body, spec, fill=spec.accent_soft, align="left")
|
||||
_set_cell_border(table.cell(0, 0), spec.accent, left=12)
|
||||
return table
|
||||
|
||||
|
||||
def add_quote(doc, text: str, source: str = ""):
|
||||
spec = _theme(doc)
|
||||
p = doc.add_paragraph(style="Normal")
|
||||
p.paragraph_format.left_indent = Cm(0.75)
|
||||
run = p.add_run(text)
|
||||
if not _templated(doc):
|
||||
_font_run(run, spec.font, spec.font_latin, spec.body_pt, False, spec.muted)
|
||||
run.italic = True
|
||||
if source:
|
||||
src = p.add_run(f" — {source}")
|
||||
if not _templated(doc):
|
||||
_font_run(src, spec.font, spec.font_latin, spec.body_pt - 1, False, spec.muted)
|
||||
_set_paragraph_border(p, spec.accent)
|
||||
return p
|
||||
|
||||
|
||||
def add_page_break(doc):
|
||||
doc.add_page_break()
|
||||
|
||||
|
||||
def save(doc, path: str) -> str:
|
||||
_strip_gap_paragraphs(doc)
|
||||
doc.save(path)
|
||||
return path
|
||||
|
||||
|
||||
def _strip_gap_paragraphs(doc) -> None:
|
||||
"""删掉用来充间距的空段。文末、节属性前的那一个空段留给 Word。"""
|
||||
body = doc.element.body
|
||||
paragraphs = [child for child in list(body) if child.tag == qn("w:p")]
|
||||
for child in paragraphs:
|
||||
texts = "".join((node.text or "") for node in child.findall(".//" + qn("w:t")))
|
||||
if texts.strip():
|
||||
continue
|
||||
nxt = child.getnext()
|
||||
if nxt is not None and nxt.tag == qn("w:sectPr"):
|
||||
continue
|
||||
body.remove(child)
|
||||
|
||||
|
||||
def _templated(doc) -> bool:
|
||||
return "housestyle-template" in (doc.core_properties.subject or "")
|
||||
|
||||
|
||||
def _theme(doc) -> Theme:
|
||||
name = doc.core_properties.category or "modern"
|
||||
accent = doc.core_properties.keywords or None
|
||||
if name == "custom":
|
||||
name = "modern"
|
||||
return resolve_theme(name, accent)
|
||||
|
||||
|
||||
def _clear_body(doc) -> None:
|
||||
body = doc.element.body
|
||||
for child in list(body):
|
||||
if child.tag != qn("w:sectPr"):
|
||||
body.remove(child)
|
||||
|
||||
|
||||
def _apply_page(doc, spec: Theme) -> None:
|
||||
for section in doc.sections:
|
||||
section.page_width = Cm(spec.page_width_cm)
|
||||
section.page_height = Cm(spec.page_height_cm)
|
||||
section.top_margin = Cm(spec.margin_top_cm)
|
||||
section.bottom_margin = Cm(spec.margin_bottom_cm)
|
||||
section.left_margin = Cm(spec.margin_left_cm)
|
||||
section.right_margin = Cm(spec.margin_right_cm)
|
||||
|
||||
|
||||
def _apply_styles(doc, spec: Theme) -> None:
|
||||
normal = doc.styles["Normal"]
|
||||
_style_font(normal, spec.font, spec.font_latin, spec.body_pt, spec.text, False)
|
||||
pf = normal.paragraph_format
|
||||
if spec.line_exact_pt:
|
||||
pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY
|
||||
pf.line_spacing = Pt(spec.line_exact_pt)
|
||||
else:
|
||||
pf.line_spacing = spec.line_multiple
|
||||
pf.space_after = Pt(8)
|
||||
pf.space_before = Pt(0)
|
||||
title = doc.styles["Title"]
|
||||
_style_font(title, spec.heading_font, spec.font_latin, spec.title_pt, spec.accent, True)
|
||||
title.paragraph_format.space_after = Pt(6)
|
||||
befores = (18, 14, 10)
|
||||
afters = (6, 4, 2)
|
||||
for level in (1, 2, 3):
|
||||
style = doc.styles[f"Heading {level}"]
|
||||
_style_font(style, spec.heading_font, spec.font_latin, spec.heading_pt[level - 1], spec.heading_colors[level - 1], True)
|
||||
style.paragraph_format.space_before = Pt(befores[level - 1])
|
||||
style.paragraph_format.space_after = Pt(afters[level - 1])
|
||||
|
||||
|
||||
def _apply_header_footer(doc, spec: Theme, title: str) -> None:
|
||||
for section in doc.sections:
|
||||
header = section.header.paragraphs[0]
|
||||
header.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
||||
if title:
|
||||
run = header.add_run(title)
|
||||
_font_run(run, spec.font, spec.font_latin, 9, False, spec.muted)
|
||||
footer = section.footer.paragraphs[0]
|
||||
footer.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
||||
label = footer.add_run("第 ")
|
||||
_font_run(label, spec.font, spec.font_latin, 9, False, spec.muted)
|
||||
_add_field(footer, " PAGE ")
|
||||
suffix = footer.add_run(" 页")
|
||||
_font_run(suffix, spec.font, spec.font_latin, 9, False, spec.muted)
|
||||
|
||||
|
||||
def _add_field(paragraph, instruction: str) -> None:
|
||||
run = paragraph.add_run()
|
||||
begin = OxmlElement("w:fldChar")
|
||||
begin.set(qn("w:fldCharType"), "begin")
|
||||
instr = OxmlElement("w:instrText")
|
||||
instr.set(qn("xml:space"), "preserve")
|
||||
instr.text = instruction
|
||||
end = OxmlElement("w:fldChar")
|
||||
end.set(qn("w:fldCharType"), "end")
|
||||
run._r.append(begin)
|
||||
run._r.append(instr)
|
||||
run._r.append(end)
|
||||
|
||||
|
||||
def _style_font(style, eastasia: str, latin: str, size_pt: float, color: str, bold: bool) -> None:
|
||||
style.font.name = latin
|
||||
style.font.size = Pt(size_pt)
|
||||
style.font.bold = bold
|
||||
style.font.color.rgb = RGBColor.from_string(color)
|
||||
rpr = style.element.get_or_add_rPr()
|
||||
rfonts = rpr.find(qn("w:rFonts"))
|
||||
if rfonts is None:
|
||||
rfonts = OxmlElement("w:rFonts")
|
||||
rpr.append(rfonts)
|
||||
rfonts.set(qn("w:ascii"), latin)
|
||||
rfonts.set(qn("w:hAnsi"), latin)
|
||||
rfonts.set(qn("w:eastAsia"), eastasia)
|
||||
lang = rpr.find(qn("w:lang"))
|
||||
if lang is None:
|
||||
lang = OxmlElement("w:lang")
|
||||
rpr.append(lang)
|
||||
lang.set(qn("w:val"), "en-US")
|
||||
lang.set(qn("w:eastAsia"), "zh-CN")
|
||||
|
||||
|
||||
def _paint(paragraph, spec: Theme, *, size: float, bold: bool = False, color: str, font: str) -> None:
|
||||
for run in paragraph.runs:
|
||||
_font_run(run, font, spec.font_latin, size, bold, color)
|
||||
|
||||
|
||||
def _font_run(run, eastasia: str, latin: str, size_pt: float, bold: bool, color: str) -> None:
|
||||
run.bold = bold
|
||||
run.font.size = Pt(size_pt)
|
||||
run.font.color.rgb = RGBColor.from_string(color)
|
||||
run.font.name = latin
|
||||
rpr = run._element.get_or_add_rPr()
|
||||
rfonts = rpr.find(qn("w:rFonts"))
|
||||
if rfonts is None:
|
||||
rfonts = OxmlElement("w:rFonts")
|
||||
rpr.append(rfonts)
|
||||
rfonts.set(qn("w:ascii"), latin)
|
||||
rfonts.set(qn("w:hAnsi"), latin)
|
||||
rfonts.set(qn("w:eastAsia"), eastasia)
|
||||
|
||||
|
||||
def _format_table(table, spec: Theme, *, header: bool) -> None:
|
||||
tbl = table._tbl
|
||||
tbl_pr = tbl.tblPr
|
||||
if tbl_pr is None:
|
||||
tbl_pr = OxmlElement("w:tblPr")
|
||||
tbl.insert(0, tbl_pr)
|
||||
for child in list(tbl_pr):
|
||||
if child.tag == qn("w:tblStyle"):
|
||||
tbl_pr.remove(child)
|
||||
borders = OxmlElement("w:tblBorders")
|
||||
for edge in ("top", "left", "bottom", "right", "insideH", "insideV"):
|
||||
el = OxmlElement(f"w:{edge}")
|
||||
el.set(qn("w:val"), "single")
|
||||
el.set(qn("w:sz"), "4")
|
||||
el.set(qn("w:space"), "0")
|
||||
el.set(qn("w:color"), spec.line)
|
||||
borders.append(el)
|
||||
existing = tbl_pr.find(qn("w:tblBorders"))
|
||||
if existing is not None:
|
||||
tbl_pr.remove(existing)
|
||||
tbl_pr.append(borders)
|
||||
mar = OxmlElement("w:tblCellMar")
|
||||
for edge, val in (("top", "60"), ("left", "80"), ("bottom", "60"), ("right", "80")):
|
||||
el = OxmlElement(f"w:{edge}")
|
||||
el.set(qn("w:w"), val)
|
||||
el.set(qn("w:type"), "dxa")
|
||||
mar.append(el)
|
||||
old = tbl_pr.find(qn("w:tblCellMar"))
|
||||
if old is not None:
|
||||
tbl_pr.remove(old)
|
||||
tbl_pr.append(mar)
|
||||
|
||||
|
||||
def _set_widths(table, widths) -> None:
|
||||
table.autofit = False
|
||||
tbl = table._tbl
|
||||
grid = tbl.find(qn("w:tblGrid"))
|
||||
if grid is not None:
|
||||
tbl.remove(grid)
|
||||
grid = OxmlElement("w:tblGrid")
|
||||
twips = []
|
||||
for width in widths:
|
||||
tw = str(int(Cm(width).twips))
|
||||
twips.append(tw)
|
||||
col = OxmlElement("w:gridCol")
|
||||
col.set(qn("w:w"), tw)
|
||||
grid.append(col)
|
||||
tbl.tblPr.addnext(grid)
|
||||
for row in table.rows:
|
||||
for idx, cell in enumerate(row.cells):
|
||||
tc_pr = cell._tc.get_or_add_tcPr()
|
||||
tc_w = tc_pr.find(qn("w:tcW"))
|
||||
if tc_w is None:
|
||||
tc_w = OxmlElement("w:tcW")
|
||||
tc_pr.append(tc_w)
|
||||
tc_w.set(qn("w:w"), twips[idx])
|
||||
tc_w.set(qn("w:type"), "dxa")
|
||||
|
||||
|
||||
def _content_width(doc, spec: Theme) -> float:
|
||||
section = doc.sections[0]
|
||||
if section.page_width and section.left_margin and section.right_margin:
|
||||
return section.page_width.cm - section.left_margin.cm - section.right_margin.cm
|
||||
return spec.page_width_cm - spec.margin_left_cm - spec.margin_right_cm
|
||||
|
||||
|
||||
def _write_cell(cell, text: str, spec: Theme, *, bold: bool = False, color: str | None = None, fill: str | None = None, align: str = "left") -> None:
|
||||
cell.text = ""
|
||||
p = cell.paragraphs[0]
|
||||
p.alignment = {"left": WD_ALIGN_PARAGRAPH.LEFT, "center": WD_ALIGN_PARAGRAPH.CENTER, "right": WD_ALIGN_PARAGRAPH.RIGHT}[align]
|
||||
run = p.add_run(text)
|
||||
_font_run(run, spec.font, spec.font_latin, spec.body_pt, bold, color or spec.text)
|
||||
if fill:
|
||||
_shade(cell, fill)
|
||||
|
||||
|
||||
def _shade(cell, fill: str) -> None:
|
||||
tc_pr = cell._tc.get_or_add_tcPr()
|
||||
shd = tc_pr.find(qn("w:shd"))
|
||||
if shd is None:
|
||||
shd = OxmlElement("w:shd")
|
||||
tc_pr.append(shd)
|
||||
shd.set(qn("w:val"), "clear")
|
||||
shd.set(qn("w:color"), "auto")
|
||||
shd.set(qn("w:fill"), fill)
|
||||
|
||||
|
||||
def _repeat_header(row) -> None:
|
||||
tr_pr = row._tr.get_or_add_trPr()
|
||||
mark = OxmlElement("w:tblHeader")
|
||||
mark.set(qn("w:val"), "true")
|
||||
tr_pr.append(mark)
|
||||
|
||||
|
||||
def _set_cell_border(cell, color: str, left: int = 12) -> None:
|
||||
tc_pr = cell._tc.get_or_add_tcPr()
|
||||
borders = tc_pr.find(qn("w:tcBorders"))
|
||||
if borders is None:
|
||||
borders = OxmlElement("w:tcBorders")
|
||||
tc_pr.append(borders)
|
||||
edge = OxmlElement("w:left")
|
||||
edge.set(qn("w:val"), "single")
|
||||
edge.set(qn("w:sz"), str(left))
|
||||
edge.set(qn("w:space"), "0")
|
||||
edge.set(qn("w:color"), color)
|
||||
borders.append(edge)
|
||||
|
||||
|
||||
def _set_paragraph_border(paragraph, color: str) -> None:
|
||||
p_pr = paragraph._p.get_or_add_pPr()
|
||||
borders = OxmlElement("w:pBdr")
|
||||
left = OxmlElement("w:left")
|
||||
left.set(qn("w:val"), "single")
|
||||
left.set(qn("w:sz"), "12")
|
||||
left.set(qn("w:space"), "8")
|
||||
left.set(qn("w:color"), color)
|
||||
borders.append(left)
|
||||
p_pr.append(borders)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""从参考文档抽取可传给 resolve_theme 的主题字典。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from docx import Document
|
||||
from docx.oxml.ns import qn
|
||||
from pptx import Presentation
|
||||
|
||||
try:
|
||||
import pdfplumber
|
||||
except ImportError: # 可选依赖:未安装时 PDF 抽取返回近似说明
|
||||
pdfplumber = None
|
||||
|
||||
|
||||
def extract(path: str) -> dict:
|
||||
ext = Path(path).suffix.lower()
|
||||
if ext in {".docx", ".dotx"}:
|
||||
return extract_docx(path)
|
||||
if ext in {".pptx", ".potx"}:
|
||||
return extract_pptx(path)
|
||||
if ext == ".pdf":
|
||||
return extract_pdf(path)
|
||||
if ext in {".png", ".jpg", ".jpeg", ".webp", ".gif"}:
|
||||
return {
|
||||
"base": "modern",
|
||||
"approximate": True,
|
||||
"note": "图片无法精确抽取版式。请确认主色、字体和页边距后再生成。",
|
||||
}
|
||||
return {"base": "modern", "note": f"不能从 {ext or '该文件'} 抽取版式,已回落 modern。"}
|
||||
|
||||
|
||||
def extract_docx(path: str) -> dict:
|
||||
doc = Document(path)
|
||||
spec: dict = {"base": "modern"}
|
||||
normal = doc.styles["Normal"]
|
||||
rpr = normal.element.find(qn("w:rPr"))
|
||||
if rpr is not None:
|
||||
fonts = rpr.find(qn("w:rFonts"))
|
||||
if fonts is not None and fonts.get(qn("w:eastAsia")):
|
||||
spec["font"] = fonts.get(qn("w:eastAsia"))
|
||||
spec["heading_font"] = spec["font"]
|
||||
if normal.font.size:
|
||||
spec["body_pt"] = normal.font.size.pt
|
||||
try:
|
||||
heading = doc.styles["Heading 1"]
|
||||
if heading.font.color and heading.font.color.rgb:
|
||||
spec["accent"] = str(heading.font.color.rgb)
|
||||
except Exception:
|
||||
pass
|
||||
section = doc.sections[0]
|
||||
if section.page_width and section.page_height:
|
||||
spec["page_width_cm"] = round(section.page_width.cm, 2)
|
||||
spec["page_height_cm"] = round(section.page_height.cm, 2)
|
||||
if section.top_margin:
|
||||
spec["margin_top_cm"] = round(section.top_margin.cm, 2)
|
||||
if section.bottom_margin:
|
||||
spec["margin_bottom_cm"] = round(section.bottom_margin.cm, 2)
|
||||
if section.left_margin:
|
||||
spec["margin_left_cm"] = round(section.left_margin.cm, 2)
|
||||
if section.right_margin:
|
||||
spec["margin_right_cm"] = round(section.right_margin.cm, 2)
|
||||
return spec
|
||||
|
||||
|
||||
def extract_pptx(path: str) -> dict:
|
||||
prs = Presentation(path)
|
||||
spec: dict = {"base": "modern"}
|
||||
if prs.slide_width and prs.slide_height:
|
||||
spec["slide_width_cm"] = round(prs.slide_width.cm, 2)
|
||||
spec["slide_height_cm"] = round(prs.slide_height.cm, 2)
|
||||
for slide in prs.slides:
|
||||
for shape in slide.shapes:
|
||||
if not shape.has_text_frame:
|
||||
continue
|
||||
for paragraph in shape.text_frame.paragraphs:
|
||||
for run in paragraph.runs:
|
||||
if run.font.name:
|
||||
spec["font"] = run.font.name
|
||||
spec["heading_font"] = run.font.name
|
||||
if run.font.color and run.font.color.type is not None:
|
||||
try:
|
||||
spec["accent"] = str(run.font.color.rgb)
|
||||
except Exception:
|
||||
pass
|
||||
if spec.get("font") and spec.get("accent"):
|
||||
return spec
|
||||
return spec
|
||||
|
||||
|
||||
def extract_pdf(path: str) -> dict:
|
||||
if pdfplumber is None:
|
||||
return {
|
||||
"base": "modern",
|
||||
"approximate": True,
|
||||
"note": "未安装 pdfplumber,不能从 PDF 精确抽取字体和字号。请确认主色与字体,或 pip install --user pdfplumber 后重试。",
|
||||
}
|
||||
spec: dict = {"base": "modern", "approximate": False}
|
||||
with pdfplumber.open(path) as pdf:
|
||||
if not pdf.pages:
|
||||
spec["note"] = "PDF 没有页面。"
|
||||
return spec
|
||||
page = pdf.pages[0]
|
||||
spec["page_width_cm"] = round(page.width * 2.54 / 72, 2)
|
||||
spec["page_height_cm"] = round(page.height * 2.54 / 72, 2)
|
||||
chars = page.chars[:40]
|
||||
if chars:
|
||||
spec["font"] = chars[0].get("fontname") or ""
|
||||
size = chars[0].get("size")
|
||||
if size:
|
||||
spec["body_pt"] = round(float(size), 1)
|
||||
if not spec.get("font"):
|
||||
spec["approximate"] = True
|
||||
spec["note"] = "PDF 未暴露可用字体,已按页面尺寸近似。"
|
||||
return spec
|
||||
@@ -0,0 +1,243 @@
|
||||
"""交付前自检。有 error 时进程退出码为 1。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from docx import Document
|
||||
from docx.oxml.ns import qn
|
||||
from openpyxl import load_workbook
|
||||
from pptx import Presentation
|
||||
|
||||
# Word 2007 默认主题色。沿用这些颜色说明标题没有按 house style 设定。
|
||||
_DEFAULT_THEME = {"365F91", "4F81BD", "17365D", "1F497D", "4BACC6", "8064A2", "F79646", "9BBB59", "C0504D"}
|
||||
_TEMPLATE_DOWNGRADE = {"paper-size", "body-font", "theme-color", "page-number", "slide-size"}
|
||||
|
||||
|
||||
def lint_path(path: str) -> list[dict]:
|
||||
ext = Path(path).suffix.lower()
|
||||
if ext == ".docx":
|
||||
return lint_docx(path)
|
||||
if ext == ".pptx":
|
||||
return lint_pptx(path)
|
||||
if ext == ".xlsx":
|
||||
return lint_xlsx(path)
|
||||
return [_issue(path, "warning", "unsupported", f"不检查 {ext or '该类型'}")]
|
||||
|
||||
|
||||
def lint_docx(path: str) -> list[dict]:
|
||||
doc = Document(path)
|
||||
issues = []
|
||||
templated = "housestyle-template" in (doc.core_properties.subject or "")
|
||||
|
||||
def add(rule: str, severity: str, message: str, where: str = ""):
|
||||
if templated and rule in _TEMPLATE_DOWNGRADE and severity == "error":
|
||||
severity = "warning"
|
||||
issues.append(_issue(path, severity, rule, message, where))
|
||||
|
||||
section = doc.sections[0]
|
||||
if section.page_width and section.page_height:
|
||||
if abs(section.page_width.cm - 21.0) > 0.2 or abs(section.page_height.cm - 29.7) > 0.2:
|
||||
add("paper-size", "error", f"纸张不是 A4({section.page_width.cm:.2f} × {section.page_height.cm:.2f} cm)")
|
||||
normal = doc.styles["Normal"]
|
||||
rpr = normal.element.find(qn("w:rPr"))
|
||||
eastasia = ""
|
||||
if rpr is not None:
|
||||
fonts = rpr.find(qn("w:rFonts"))
|
||||
if fonts is not None:
|
||||
eastasia = fonts.get(qn("w:eastAsia")) or ""
|
||||
if not eastasia:
|
||||
add("body-font", "error", "正文样式没有设置中文字体(eastAsia)")
|
||||
for name in ("Title", "Heading 1", "Heading 2", "Heading 3"):
|
||||
try:
|
||||
style = doc.styles[name]
|
||||
except KeyError:
|
||||
continue
|
||||
color = _rgb(style.font)
|
||||
if color in _DEFAULT_THEME:
|
||||
add("theme-color", "error", f"{name} 仍使用 Word 默认主题色 {color}", name)
|
||||
|
||||
paragraphs = list(doc.paragraphs)
|
||||
last = len(paragraphs) - 1
|
||||
for index, paragraph in enumerate(paragraphs):
|
||||
text = paragraph.text.strip()
|
||||
style = paragraph.style.name if paragraph.style is not None else ""
|
||||
level = _heading_level(style)
|
||||
if not text and index != last:
|
||||
add("empty-paragraph", "error", "用空段落充当间距", f"paragraph {index + 1}")
|
||||
if "List" in style:
|
||||
if not text:
|
||||
add("empty-list-item", "error", "空的列表项", style)
|
||||
elif re.match(r"^(\d+[.、)]\s*|[•·▪◦]\s)", text):
|
||||
add("double-number", "error", "列表样式里又手写了编号或符号", text[:40])
|
||||
elif style in {"Normal", "Body Text"} and re.match(r"^[•·▪◦]\s", text):
|
||||
add("manual-bullet", "error", "在正文里手打项目符号", text[:40])
|
||||
for run in paragraph.runs:
|
||||
color = _rgb(run.font)
|
||||
if level and color in _DEFAULT_THEME:
|
||||
add("theme-color", "error", f"标题文字使用 Word 默认主题色 {color}", text[:40])
|
||||
_check_outline(doc, add)
|
||||
|
||||
full = "\n".join(p.text for p in paragraphs)
|
||||
if re.search(r"—\s+—", full) or "。。" in full:
|
||||
add("punctuation", "error", "有破折号被空格拆开,或连续句号")
|
||||
if re.search(r"20\d{2}-\d{2}-\d{2}", full) and re.search(r"20\d{2}年\d{1,2}月", full):
|
||||
add("date-mix", "warning", "同一篇里混用了 ISO 日期和中文日期")
|
||||
|
||||
for table in doc.tables:
|
||||
style_name = table.style.name if table.style is not None else ""
|
||||
if style_name.replace(" ", "").lower() in {"tablegrid", "tablegridlight"} or style_name == "Table Grid":
|
||||
add("table-style", "error", f"表格使用默认样式 {style_name}")
|
||||
|
||||
for section in doc.sections:
|
||||
footer_xml = section.footer._element.xml
|
||||
if "PAGE" not in footer_xml and "页" not in footer_xml:
|
||||
add("page-number", "error", "页脚没有页码")
|
||||
break
|
||||
return issues
|
||||
|
||||
|
||||
def lint_pptx(path: str) -> list[dict]:
|
||||
prs = Presentation(path)
|
||||
issues = []
|
||||
templated = "housestyle-template" in (prs.core_properties.subject or "")
|
||||
|
||||
def add(rule: str, severity: str, message: str, where: str = ""):
|
||||
if templated and rule in _TEMPLATE_DOWNGRADE and severity == "error":
|
||||
severity = "warning"
|
||||
issues.append(_issue(path, severity, rule, message, where))
|
||||
|
||||
if prs.slide_width and prs.slide_height:
|
||||
ratio = prs.slide_width / prs.slide_height
|
||||
if abs(ratio - 16 / 9) > 0.05:
|
||||
add("slide-size", "error", f"不是 16:9(宽高比 {ratio:.3f})")
|
||||
for index, slide in enumerate(prs.slides, start=1):
|
||||
texts = []
|
||||
for shape in slide.shapes:
|
||||
if not shape.has_text_frame:
|
||||
continue
|
||||
body = shape.text_frame.text.strip()
|
||||
if body:
|
||||
texts.append(body)
|
||||
count = sum(1 for p in shape.text_frame.paragraphs if p.text.strip())
|
||||
if count > 6:
|
||||
add("bullet-overflow", "error", f"一页超过 6 条要点({count})", f"slide {index}")
|
||||
if not texts:
|
||||
add("empty-title", "error", "空白页", f"slide {index}")
|
||||
return issues
|
||||
|
||||
|
||||
def lint_xlsx(path: str) -> list[dict]:
|
||||
wb = load_workbook(path)
|
||||
issues = []
|
||||
for ws in wb.worksheets:
|
||||
if ws.title == "说明":
|
||||
continue
|
||||
if ws.max_row < 1 or ws["A1"].value is None:
|
||||
continue
|
||||
header = ws["A1"]
|
||||
if not header.font or not header.font.bold or not header.fill or header.fill.patternType != "solid":
|
||||
issues.append(_issue(path, "error", "header-style", "表头未加粗或没有底色", ws.title))
|
||||
if not ws.freeze_panes:
|
||||
issues.append(_issue(path, "error", "freeze", "没有冻结表头", ws.title))
|
||||
for col in range(1, ws.max_column + 1):
|
||||
letter = ws.cell(1, col).column_letter
|
||||
width = ws.column_dimensions[letter].width
|
||||
if width is None or abs(width - 8.43) < 0.05:
|
||||
issues.append(_issue(path, "error", "column-width", "列宽仍是默认值", f"{ws.title}!{letter}"))
|
||||
break
|
||||
fitted = ws.page_setup.fitToWidth or ws.page_setup.orientation
|
||||
if not fitted:
|
||||
issues.append(_issue(path, "error", "print-setup", "没有打印设置(方向或缩放)", ws.title))
|
||||
if "说明" not in wb.sheetnames:
|
||||
issues.append(_issue(path, "warning", "notes-sheet", "没有“说明”工作表"))
|
||||
return issues
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = list(sys.argv[1:] if argv is None else argv)
|
||||
if not args:
|
||||
print("usage: python -m housestyle.lint <file>...", file=sys.stderr)
|
||||
return 2
|
||||
issues = []
|
||||
for path in args:
|
||||
issues.extend(lint_path(path))
|
||||
json.dump(issues, sys.stdout, ensure_ascii=False, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
return 1 if any(item["severity"] == "error" for item in issues) else 0
|
||||
|
||||
|
||||
def _issue(path: str, severity: str, rule: str, message: str, where: str = "") -> dict:
|
||||
item = {"file": path, "severity": severity, "rule": rule, "message": message}
|
||||
if where:
|
||||
item["where"] = where
|
||||
return item
|
||||
|
||||
|
||||
def _rgb(font) -> str:
|
||||
if font is None or font.color is None:
|
||||
return ""
|
||||
try:
|
||||
rgb = font.color.rgb
|
||||
except (AttributeError, ValueError):
|
||||
return ""
|
||||
if not rgb:
|
||||
return ""
|
||||
return str(rgb).upper()
|
||||
|
||||
|
||||
def _heading_level(style_name: str) -> int:
|
||||
compact = style_name.replace(" ", "")
|
||||
if not compact.lower().startswith("heading"):
|
||||
return 0
|
||||
digits = compact[7:]
|
||||
if digits.isdigit():
|
||||
return int(digits)
|
||||
return 0
|
||||
|
||||
|
||||
def _check_outline(doc, add) -> None:
|
||||
blocks = []
|
||||
for child in doc.element.body:
|
||||
if child.tag == qn("w:tbl"):
|
||||
blocks.append(("table", 0, ""))
|
||||
continue
|
||||
if child.tag != qn("w:p"):
|
||||
continue
|
||||
texts = "".join((node.text or "") for node in child.findall(".//" + qn("w:t"))).strip()
|
||||
style = ""
|
||||
ppr = child.find(qn("w:pPr"))
|
||||
if ppr is not None:
|
||||
pstyle = ppr.find(qn("w:pStyle"))
|
||||
if pstyle is not None:
|
||||
style = pstyle.get(qn("w:val")) or ""
|
||||
blocks.append(("p", _heading_level(style), texts))
|
||||
seen = 0
|
||||
for index, (kind, level, text) in enumerate(blocks):
|
||||
if kind != "p" or not level:
|
||||
continue
|
||||
if level > seen + 1:
|
||||
add("heading-skip", "error", f"标题从 {seen or '无'} 跳到 {level}", text or style_label(level))
|
||||
seen = level
|
||||
if _block_section_empty(blocks, index):
|
||||
add("empty-section", "error", "标题下没有内容", text or style_label(level))
|
||||
|
||||
|
||||
def style_label(level: int) -> str:
|
||||
return f"Heading {level}"
|
||||
|
||||
|
||||
def _block_section_empty(blocks, index: int) -> bool:
|
||||
for kind, level, text in blocks[index + 1 :]:
|
||||
if kind == "table":
|
||||
return False
|
||||
if text:
|
||||
return level > 0
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,58 @@
|
||||
"""回读交付物结构,供生成后核对,不代替 lint。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from docx import Document
|
||||
from openpyxl import load_workbook
|
||||
from pptx import Presentation
|
||||
|
||||
|
||||
def summarize(path: str) -> str:
|
||||
ext = Path(path).suffix.lower()
|
||||
if ext == ".docx":
|
||||
return _docx(path)
|
||||
if ext == ".pptx":
|
||||
return _pptx(path)
|
||||
if ext == ".xlsx":
|
||||
return _xlsx(path)
|
||||
return f"{path}: 不能回读 {ext or '该类型'}"
|
||||
|
||||
|
||||
def _docx(path: str) -> str:
|
||||
doc = Document(path)
|
||||
lines = [f"docx {path}"]
|
||||
section = doc.sections[0]
|
||||
if section.page_width and section.page_height:
|
||||
lines.append(f"page {section.page_width.cm:.2f} x {section.page_height.cm:.2f} cm")
|
||||
for paragraph in doc.paragraphs:
|
||||
text = paragraph.text.strip()
|
||||
if not text:
|
||||
continue
|
||||
style = paragraph.style.name if paragraph.style is not None else ""
|
||||
if style == "Title" or style.startswith("Heading") or "List" in style:
|
||||
lines.append(f"{style}: {text[:80]}")
|
||||
lines.append(f"tables {len(doc.tables)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _pptx(path: str) -> str:
|
||||
prs = Presentation(path)
|
||||
lines = [f"pptx {path}", f"slides {len(prs.slides)}"]
|
||||
for index, slide in enumerate(prs.slides, start=1):
|
||||
title = ""
|
||||
for shape in slide.shapes:
|
||||
if shape.has_text_frame and shape.text_frame.text.strip():
|
||||
title = shape.text_frame.text.strip().splitlines()[0]
|
||||
break
|
||||
lines.append(f"{index}. {title[:80]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _xlsx(path: str) -> str:
|
||||
wb = load_workbook(path, read_only=True)
|
||||
lines = [f"xlsx {path}"]
|
||||
for ws in wb.worksheets:
|
||||
lines.append(f"{ws.title}: {ws.max_row} rows x {ws.max_column} cols")
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,257 @@
|
||||
"""16:9 演示稿构件。传入模板时复用模板版式,不改页面尺寸。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from lxml import etree
|
||||
from pptx import Presentation
|
||||
from pptx.chart.data import CategoryChartData
|
||||
from pptx.dml.color import RGBColor
|
||||
from pptx.enum.chart import XL_CHART_TYPE
|
||||
from pptx.enum.shapes import MSO_SHAPE
|
||||
from pptx.enum.text import PP_ALIGN
|
||||
from pptx.oxml.ns import qn
|
||||
from pptx.util import Emu, Inches, Pt
|
||||
|
||||
from housestyle.themes import resolve_theme
|
||||
|
||||
_WIDE_W = Inches(13.333333)
|
||||
_WIDE_H = Inches(7.5)
|
||||
|
||||
|
||||
def new_presentation(theme="modern", accent: str | None = None, template: str | None = None, title: str = ""):
|
||||
spec = resolve_theme(theme, accent)
|
||||
if template:
|
||||
prs = Presentation(template)
|
||||
prs.core_properties.subject = "housestyle-template"
|
||||
else:
|
||||
prs = Presentation()
|
||||
prs.slide_width = _WIDE_W
|
||||
prs.slide_height = _WIDE_H
|
||||
prs.core_properties.subject = "housestyle"
|
||||
prs.core_properties.category = spec.id
|
||||
prs.core_properties.keywords = spec.accent
|
||||
if title:
|
||||
prs.core_properties.title = title
|
||||
return prs
|
||||
|
||||
|
||||
def add_title_slide(prs, title: str, subtitle: str = ""):
|
||||
if _templated(prs):
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[0])
|
||||
if slide.shapes.title:
|
||||
slide.shapes.title.text = title
|
||||
if len(slide.placeholders) > 1:
|
||||
slide.placeholders[1].text = subtitle
|
||||
return slide
|
||||
spec = _theme(prs)
|
||||
slide = prs.slides.add_slide(_blank(prs))
|
||||
_bar(slide, spec, 0, 0, prs.slide_width, Inches(0.12))
|
||||
box = slide.shapes.add_textbox(Inches(0.8), Inches(2.2), prs.slide_width - Inches(1.6), Inches(1.6))
|
||||
tf = box.text_frame
|
||||
tf.word_wrap = True
|
||||
p = tf.paragraphs[0]
|
||||
run = p.add_run()
|
||||
run.text = title
|
||||
_run_font(run, spec.heading_font, spec.ppt_title_pt, True, spec.accent)
|
||||
if subtitle:
|
||||
sub = slide.shapes.add_textbox(Inches(0.8), Inches(4.0), prs.slide_width - Inches(1.6), Inches(0.8))
|
||||
sp = sub.text_frame.paragraphs[0]
|
||||
srun = sp.add_run()
|
||||
srun.text = subtitle
|
||||
_run_font(srun, spec.font, 18, False, spec.muted)
|
||||
_number(slide, prs, spec, 1)
|
||||
return slide
|
||||
|
||||
|
||||
def add_section_slide(prs, title: str):
|
||||
if _templated(prs):
|
||||
layout = prs.slide_layouts[min(5, len(prs.slide_layouts) - 1)]
|
||||
slide = prs.slides.add_slide(layout)
|
||||
if slide.shapes.title:
|
||||
slide.shapes.title.text = title
|
||||
return slide
|
||||
spec = _theme(prs)
|
||||
slide = prs.slides.add_slide(_blank(prs))
|
||||
shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Emu(0), Emu(0), prs.slide_width, prs.slide_height)
|
||||
shape.fill.solid()
|
||||
shape.fill.fore_color.rgb = RGBColor.from_string(spec.accent)
|
||||
shape.line.fill.background()
|
||||
box = slide.shapes.add_textbox(Inches(0.8), Inches(2.8), prs.slide_width - Inches(1.6), Inches(1.4))
|
||||
run = box.text_frame.paragraphs[0].add_run()
|
||||
run.text = title
|
||||
_run_font(run, spec.heading_font, spec.ppt_title_pt, True, "FFFFFF")
|
||||
return slide
|
||||
|
||||
|
||||
def add_bullets_slide(prs, title: str, bullets: list[str]):
|
||||
if _templated(prs):
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[min(1, len(prs.slide_layouts) - 1)])
|
||||
if slide.shapes.title:
|
||||
slide.shapes.title.text = title
|
||||
body = slide.placeholders[1].text_frame
|
||||
body.text = bullets[0] if bullets else ""
|
||||
for point in bullets[1:]:
|
||||
p = body.add_paragraph()
|
||||
p.text = point
|
||||
p.level = 0
|
||||
return slide
|
||||
spec = _theme(prs)
|
||||
slide = prs.slides.add_slide(_blank(prs))
|
||||
_title(slide, prs, spec, title)
|
||||
box = slide.shapes.add_textbox(Inches(0.7), Inches(1.5), prs.slide_width - Inches(1.2), Inches(5.2))
|
||||
tf = box.text_frame
|
||||
tf.word_wrap = True
|
||||
for i, point in enumerate(bullets):
|
||||
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
||||
p.level = 0
|
||||
run = p.add_run()
|
||||
run.text = point
|
||||
_run_font(run, spec.font, spec.ppt_body_pt, False, spec.text)
|
||||
p.space_after = Pt(10)
|
||||
_number(slide, prs, spec, len(prs.slides))
|
||||
return slide
|
||||
|
||||
|
||||
def add_two_column(prs, title: str, left_title: str, left_points: list[str], right_title: str, right_points: list[str]):
|
||||
spec = _theme(prs)
|
||||
slide = prs.slides.add_slide(_blank(prs))
|
||||
if not _templated(prs):
|
||||
_title(slide, prs, spec, title)
|
||||
gap = Inches(0.3)
|
||||
width = (prs.slide_width - Inches(1.4) - gap) / 2
|
||||
_column(slide, spec, Inches(0.7), Inches(1.6), width, left_title, left_points)
|
||||
_column(slide, spec, Inches(0.7) + width + gap, Inches(1.6), width, right_title, right_points)
|
||||
_number(slide, prs, spec, len(prs.slides))
|
||||
return slide
|
||||
|
||||
|
||||
def add_table_slide(prs, title: str, headers: list[str], rows: list[list]):
|
||||
spec = _theme(prs)
|
||||
slide = prs.slides.add_slide(_blank(prs))
|
||||
if not _templated(prs):
|
||||
_title(slide, prs, spec, title)
|
||||
table_shape = slide.shapes.add_table(1 + len(rows), len(headers), Inches(0.6), Inches(1.6), prs.slide_width - Inches(1.2), Inches(4.8))
|
||||
table = table_shape.table
|
||||
for i, header in enumerate(headers):
|
||||
cell = table.cell(0, i)
|
||||
cell.text = str(header)
|
||||
_paint_cell(cell, spec, spec.table_header_fg, spec.table_header_bg, True)
|
||||
for r, row in enumerate(rows, start=1):
|
||||
for c, value in enumerate(row):
|
||||
cell = table.cell(r, c)
|
||||
cell.text = "" if value is None else str(value)
|
||||
fill = spec.table_alt if r % 2 == 0 else "FFFFFF"
|
||||
_paint_cell(cell, spec, spec.text, fill, False)
|
||||
_number(slide, prs, spec, len(prs.slides))
|
||||
return slide
|
||||
|
||||
|
||||
def add_chart_slide(prs, title: str, categories: list[str], series: dict[str, list]):
|
||||
spec = _theme(prs)
|
||||
slide = prs.slides.add_slide(_blank(prs))
|
||||
if not _templated(prs):
|
||||
_title(slide, prs, spec, title)
|
||||
data = CategoryChartData()
|
||||
data.categories = categories
|
||||
for name, values in series.items():
|
||||
data.add_series(name, values)
|
||||
slide.shapes.add_chart(
|
||||
XL_CHART_TYPE.COLUMN_CLUSTERED,
|
||||
Inches(0.7),
|
||||
Inches(1.6),
|
||||
prs.slide_width - Inches(1.4),
|
||||
Inches(5.2),
|
||||
data,
|
||||
)
|
||||
_number(slide, prs, spec, len(prs.slides))
|
||||
return slide
|
||||
|
||||
|
||||
def save(prs, path: str) -> str:
|
||||
prs.save(path)
|
||||
return path
|
||||
|
||||
|
||||
def _templated(prs) -> bool:
|
||||
return "housestyle-template" in (prs.core_properties.subject or "")
|
||||
|
||||
|
||||
def _theme(prs):
|
||||
name = prs.core_properties.category or "modern"
|
||||
accent = prs.core_properties.keywords or None
|
||||
if name == "custom":
|
||||
name = "modern"
|
||||
return resolve_theme(name, accent)
|
||||
|
||||
|
||||
def _blank(prs):
|
||||
if len(prs.slide_layouts) > 6:
|
||||
return prs.slide_layouts[6]
|
||||
return prs.slide_layouts[-1]
|
||||
|
||||
|
||||
def _bar(slide, spec, left, top, width, height):
|
||||
shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, left, top, width, height)
|
||||
shape.fill.solid()
|
||||
shape.fill.fore_color.rgb = RGBColor.from_string(spec.accent)
|
||||
shape.line.fill.background()
|
||||
return shape
|
||||
|
||||
|
||||
def _title(slide, prs, spec, title: str) -> None:
|
||||
_bar(slide, spec, 0, 0, prs.slide_width, Inches(0.08))
|
||||
box = slide.shapes.add_textbox(Inches(0.6), Inches(0.35), prs.slide_width - Inches(1.2), Inches(0.8))
|
||||
run = box.text_frame.paragraphs[0].add_run()
|
||||
run.text = title
|
||||
_run_font(run, spec.heading_font, 28, True, spec.accent)
|
||||
|
||||
|
||||
def _column(slide, spec, left, top, width, title: str, points: list[str]) -> None:
|
||||
box = slide.shapes.add_textbox(left, top, width, Inches(5))
|
||||
tf = box.text_frame
|
||||
tf.word_wrap = True
|
||||
head = tf.paragraphs[0]
|
||||
run = head.add_run()
|
||||
run.text = title
|
||||
_run_font(run, spec.heading_font, 18, True, spec.accent)
|
||||
for point in points:
|
||||
p = tf.add_paragraph()
|
||||
pr = p.add_run()
|
||||
pr.text = point
|
||||
_run_font(pr, spec.font, 16, False, spec.text)
|
||||
p.space_before = Pt(8)
|
||||
|
||||
|
||||
def _number(slide, prs, spec, number: int) -> None:
|
||||
box = slide.shapes.add_textbox(prs.slide_width - Inches(1.0), prs.slide_height - Inches(0.45), Inches(0.7), Inches(0.3))
|
||||
p = box.text_frame.paragraphs[0]
|
||||
p.alignment = PP_ALIGN.RIGHT
|
||||
run = p.add_run()
|
||||
run.text = str(number)
|
||||
_run_font(run, spec.font, 12, False, spec.muted)
|
||||
|
||||
|
||||
def _run_font(run, name: str, size: float, bold: bool, color: str) -> None:
|
||||
run.font.name = name
|
||||
run.font.size = Pt(size)
|
||||
run.font.bold = bold
|
||||
run.font.color.rgb = RGBColor.from_string(color)
|
||||
# 东亚字体,避免中文回落到 Calibri。
|
||||
rpr = run._r.get_or_add_rPr()
|
||||
latin = rpr.find(qn("a:latin"))
|
||||
if latin is None:
|
||||
latin = etree.SubElement(rpr, qn("a:latin"))
|
||||
latin.set("typeface", name)
|
||||
ea = rpr.find(qn("a:ea"))
|
||||
if ea is None:
|
||||
ea = etree.SubElement(rpr, qn("a:ea"))
|
||||
ea.set("typeface", name)
|
||||
|
||||
|
||||
def _paint_cell(cell, spec, color: str, fill: str, bold: bool) -> None:
|
||||
cell.fill.solid()
|
||||
cell.fill.fore_color.rgb = RGBColor.from_string(fill)
|
||||
for paragraph in cell.text_frame.paragraphs:
|
||||
paragraph.alignment = PP_ALIGN.CENTER if bold else PP_ALIGN.LEFT
|
||||
for run in paragraph.runs:
|
||||
_run_font(run, spec.font, 14, bold, color)
|
||||
@@ -0,0 +1,166 @@
|
||||
"""三套交付主题。色值为 RRGGBB,不含 #。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
_HEX = re.compile(r"^[0-9A-Fa-f]{6}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Theme:
|
||||
id: str
|
||||
font: str
|
||||
font_latin: str
|
||||
heading_font: str
|
||||
body_pt: float
|
||||
title_pt: float
|
||||
heading_pt: tuple
|
||||
heading_colors: tuple
|
||||
accent: str
|
||||
accent_soft: str
|
||||
text: str
|
||||
muted: str
|
||||
line: str
|
||||
table_header_bg: str
|
||||
table_header_fg: str
|
||||
table_alt: str
|
||||
page_width_cm: float = 21.0
|
||||
page_height_cm: float = 29.7
|
||||
margin_top_cm: float = 2.4
|
||||
margin_bottom_cm: float = 2.2
|
||||
margin_left_cm: float = 2.6
|
||||
margin_right_cm: float = 2.6
|
||||
line_multiple: float = 1.15
|
||||
line_exact_pt: float = 0
|
||||
ppt_title_pt: float = 32
|
||||
ppt_body_pt: float = 18
|
||||
|
||||
|
||||
def _t(**kwargs) -> Theme:
|
||||
return Theme(**kwargs)
|
||||
|
||||
|
||||
THEMES = {
|
||||
"modern": _t(
|
||||
id="modern",
|
||||
font="微软雅黑",
|
||||
font_latin="Calibri",
|
||||
heading_font="微软雅黑",
|
||||
body_pt=11,
|
||||
title_pt=22,
|
||||
heading_pt=(16, 13, 12),
|
||||
heading_colors=("1B3A4B", "1B3A4B", "334155"),
|
||||
accent="1B3A4B",
|
||||
accent_soft="E7EEF2",
|
||||
text="1F2937",
|
||||
muted="64748B",
|
||||
line="D6DEE6",
|
||||
table_header_bg="1B3A4B",
|
||||
table_header_fg="FFFFFF",
|
||||
table_alt="F7F9FB",
|
||||
),
|
||||
"formal": _t(
|
||||
id="formal",
|
||||
font="仿宋_GB2312",
|
||||
font_latin="Times New Roman",
|
||||
heading_font="黑体",
|
||||
body_pt=16,
|
||||
title_pt=22,
|
||||
heading_pt=(16, 16, 16),
|
||||
heading_colors=("000000", "000000", "000000"),
|
||||
accent="C00000",
|
||||
accent_soft="F4F4F4",
|
||||
text="000000",
|
||||
muted="333333",
|
||||
line="BFBFBF",
|
||||
table_header_bg="1A1A1A",
|
||||
table_header_fg="FFFFFF",
|
||||
table_alt="F7F7F7",
|
||||
margin_top_cm=3.7,
|
||||
margin_bottom_cm=3.5,
|
||||
margin_left_cm=2.8,
|
||||
margin_right_cm=2.6,
|
||||
line_multiple=0,
|
||||
line_exact_pt=28,
|
||||
ppt_title_pt=32,
|
||||
ppt_body_pt=20,
|
||||
),
|
||||
"minimal": _t(
|
||||
id="minimal",
|
||||
font="等线",
|
||||
font_latin="Calibri",
|
||||
heading_font="等线",
|
||||
body_pt=11,
|
||||
title_pt=26,
|
||||
heading_pt=(18, 14, 12),
|
||||
heading_colors=("111111", "111111", "444444"),
|
||||
accent="0B7FFF",
|
||||
accent_soft="F3F4F6",
|
||||
text="111111",
|
||||
muted="6B7280",
|
||||
line="E5E7EB",
|
||||
table_header_bg="111111",
|
||||
table_header_fg="FFFFFF",
|
||||
table_alt="FAFAFA",
|
||||
margin_top_cm=2.2,
|
||||
margin_bottom_cm=2.2,
|
||||
margin_left_cm=2.2,
|
||||
margin_right_cm=2.2,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _hex(value: str) -> str:
|
||||
raw = str(value).strip().lstrip("#")
|
||||
if not _HEX.match(raw):
|
||||
raise ValueError(f"color must be RRGGBB, got {value!r}")
|
||||
return raw.upper()
|
||||
|
||||
|
||||
def resolve_theme(theme: str | Theme | dict = "modern", accent: str | None = None) -> Theme:
|
||||
"""theme 可以是主题名、Theme 或 extract() 返回的字典。accent 覆盖主色。"""
|
||||
if isinstance(theme, Theme):
|
||||
spec = theme
|
||||
elif isinstance(theme, dict):
|
||||
name = theme.get("base") or "modern"
|
||||
if name not in THEMES:
|
||||
name = "modern"
|
||||
spec = THEMES[name]
|
||||
spec = _apply_dict(spec, theme)
|
||||
else:
|
||||
if theme not in THEMES:
|
||||
raise ValueError(f"unknown theme {theme!r}; choose modern, formal, minimal")
|
||||
spec = THEMES[theme]
|
||||
if accent:
|
||||
color = _hex(accent)
|
||||
colors = (color, color, spec.heading_colors[2])
|
||||
spec = replace(spec, accent=color, table_header_bg=color, heading_colors=colors)
|
||||
return spec
|
||||
|
||||
|
||||
def _apply_dict(spec: Theme, raw: dict) -> Theme:
|
||||
fields = {}
|
||||
for key in (
|
||||
"font",
|
||||
"font_latin",
|
||||
"heading_font",
|
||||
"body_pt",
|
||||
"title_pt",
|
||||
"page_width_cm",
|
||||
"page_height_cm",
|
||||
"margin_top_cm",
|
||||
"margin_bottom_cm",
|
||||
"margin_left_cm",
|
||||
"margin_right_cm",
|
||||
):
|
||||
if raw.get(key) not in (None, ""):
|
||||
fields[key] = raw[key]
|
||||
if raw.get("accent"):
|
||||
color = _hex(raw["accent"])
|
||||
fields["accent"] = color
|
||||
fields["table_header_bg"] = color
|
||||
fields["heading_colors"] = (color, color, spec.heading_colors[2])
|
||||
fields["id"] = "custom"
|
||||
return replace(spec, **fields)
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Excel 交付物:表头、数字格式、冻结、筛选、列宽、打印和说明页。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.worksheet.page import PageMargins
|
||||
|
||||
from housestyle.themes import resolve_theme
|
||||
|
||||
|
||||
def new_workbook(theme="modern", accent: str | None = None, title: str = ""):
|
||||
spec = resolve_theme(theme, accent)
|
||||
wb = Workbook()
|
||||
wb.properties.title = title or spec.id
|
||||
wb.properties.subject = "housestyle"
|
||||
wb.properties.category = spec.id
|
||||
wb.properties.keywords = spec.accent
|
||||
wb.active.title = "数据"
|
||||
return wb
|
||||
|
||||
|
||||
def add_sheet(wb, title: str, headers: list[str], rows: list[list], alignments: list[str] | None = None):
|
||||
if wb.active.title == "数据" and wb.active.max_row == 1 and wb.active["A1"].value is None and len(wb.sheetnames) == 1:
|
||||
ws = wb.active
|
||||
ws.title = _sheet_title(title)
|
||||
else:
|
||||
ws = wb.create_sheet(_sheet_title(title))
|
||||
spec = _theme(wb)
|
||||
thin = Border(
|
||||
left=Side(style="thin", color=spec.line),
|
||||
right=Side(style="thin", color=spec.line),
|
||||
top=Side(style="thin", color=spec.line),
|
||||
bottom=Side(style="thin", color=spec.line),
|
||||
)
|
||||
header_font = Font(name=spec.font, bold=True, color=spec.table_header_fg, size=11)
|
||||
header_fill = PatternFill("solid", fgColor=spec.table_header_bg)
|
||||
body_font = Font(name=spec.font, color=spec.text, size=11)
|
||||
alt = PatternFill("solid", fgColor=spec.table_alt)
|
||||
aligns = alignments or []
|
||||
for col, name in enumerate(headers, start=1):
|
||||
cell = ws.cell(1, col, name)
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||
cell.border = thin
|
||||
for r, row in enumerate(rows, start=2):
|
||||
for c, value in enumerate(row, start=1):
|
||||
cell = ws.cell(r, c, value)
|
||||
cell.font = body_font
|
||||
cell.border = thin
|
||||
align = aligns[c - 1] if c - 1 < len(aligns) else ("right" if isinstance(value, (int, float)) and not isinstance(value, bool) else "left")
|
||||
cell.alignment = Alignment(horizontal=align, vertical="center")
|
||||
if r % 2 == 0:
|
||||
cell.fill = alt
|
||||
if isinstance(value, float):
|
||||
cell.number_format = '#,##0.00'
|
||||
elif isinstance(value, int) and not isinstance(value, bool):
|
||||
cell.number_format = '#,##0'
|
||||
elif isinstance(value, str) and value.startswith("="):
|
||||
cell.alignment = Alignment(horizontal="right", vertical="center")
|
||||
ws.freeze_panes = "A2"
|
||||
ws.auto_filter.ref = f"A1:{get_column_letter(len(headers))}{1 + len(rows)}"
|
||||
ws.page_setup.orientation = "landscape"
|
||||
ws.page_setup.fitToPage = True
|
||||
ws.page_setup.fitToWidth = 1
|
||||
ws.page_setup.fitToHeight = 0
|
||||
ws.page_setup.paperSize = ws.PAPERSIZE_A4
|
||||
ws.sheet_properties.pageSetUpPr.fitToPage = True
|
||||
ws.page_margins = PageMargins(left=0.6, right=0.6, top=0.6, bottom=0.6)
|
||||
ws.print_title_rows = "1:1"
|
||||
ws.row_dimensions[1].height = 22
|
||||
for col in range(1, len(headers) + 1):
|
||||
letter = get_column_letter(col)
|
||||
width = 12
|
||||
for cell in ws[letter]:
|
||||
if cell.value is None:
|
||||
continue
|
||||
width = max(width, min(36, len(str(cell.value)) + 4))
|
||||
ws.column_dimensions[letter].width = width
|
||||
return ws
|
||||
|
||||
|
||||
def add_notes(wb, lines: list[str]):
|
||||
spec = _theme(wb)
|
||||
if "说明" in wb.sheetnames:
|
||||
ws = wb["说明"]
|
||||
else:
|
||||
ws = wb.create_sheet("说明")
|
||||
ws["A1"] = "说明"
|
||||
ws["A1"].font = Font(name=spec.heading_font, bold=True, size=14, color=spec.accent)
|
||||
for i, line in enumerate(lines, start=3):
|
||||
ws.cell(i, 1, line).font = Font(name=spec.font, size=11, color=spec.text)
|
||||
ws.column_dimensions["A"].width = 80
|
||||
return ws
|
||||
|
||||
|
||||
def save(wb, path: str, notes: list[str] | None = None) -> str:
|
||||
if notes is None:
|
||||
notes = ["口径、单位和数据来源以各列表头及本页说明为准。"]
|
||||
add_notes(wb, notes)
|
||||
if "数据" in wb.sheetnames and wb["数据"].max_row == 1 and wb["数据"]["A1"].value is None:
|
||||
# 占位表没写过内容时删掉,避免多一张空表。
|
||||
if len(wb.sheetnames) > 1:
|
||||
del wb["数据"]
|
||||
wb.active = 0
|
||||
wb.save(path)
|
||||
return path
|
||||
|
||||
|
||||
def _sheet_title(title: str) -> str:
|
||||
cleaned = title.replace("/", " ").replace("\\", " ").replace("*", " ").replace("?", " ").replace("[", " ").replace("]", " ").replace(":", " ")
|
||||
return (cleaned or "数据")[:31]
|
||||
|
||||
|
||||
def _theme(wb):
|
||||
name = wb.properties.category or "modern"
|
||||
accent = wb.properties.keywords or None
|
||||
if name == "custom":
|
||||
name = "modern"
|
||||
return resolve_theme(name, accent)
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,124 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from docx import Document
|
||||
from docx.shared import RGBColor
|
||||
|
||||
from housestyle import docx_kit, pptx_kit, resolve_theme, summarize, xlsx_kit
|
||||
from housestyle.extract import extract
|
||||
from housestyle.lint import lint_docx, lint_pptx, lint_xlsx
|
||||
|
||||
|
||||
def _errors(issues):
|
||||
return [item for item in issues if item["severity"] == "error"]
|
||||
|
||||
|
||||
class ThemeTests(unittest.TestCase):
|
||||
def test_accent_override(self):
|
||||
spec = resolve_theme("modern", "#00aa00")
|
||||
self.assertEqual(spec.accent, "00AA00")
|
||||
self.assertEqual(spec.table_header_bg, "00AA00")
|
||||
|
||||
def test_unknown_theme_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
resolve_theme("neon")
|
||||
|
||||
|
||||
class GeneratedDocumentTests(unittest.TestCase):
|
||||
def test_three_themes_docx_pptx_xlsx(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
for name in ("modern", "formal", "minimal"):
|
||||
doc = docx_kit.new_document(name, title="示例")
|
||||
docx_kit.add_title(doc, "示例标题", "2026年10月1日")
|
||||
docx_kit.add_heading(doc, "要点", 1)
|
||||
docx_kit.add_paragraph(doc, "正文先给结论,再给依据。")
|
||||
docx_kit.add_bullets(doc, ["第一点", "第二点"])
|
||||
docx_kit.add_numbers(doc, ["步骤一", "步骤二"])
|
||||
docx_kit.add_heading(doc, "对照", 2)
|
||||
docx_kit.add_table(doc, ["项", "值"], [["甲", "1"], ["乙", "2"]], ["left", "right"])
|
||||
docx_kit.add_kv_table(doc, [("日期", "2026年10月1日")])
|
||||
docx_kit.add_callout(doc, "只读这段也能决策。", "摘要")
|
||||
docx_kit.add_quote(doc, "关键原话。", "说话人1")
|
||||
docx_path = root / f"{name}.docx"
|
||||
docx_kit.save(doc, docx_path)
|
||||
self.assertEqual(_errors(lint_docx(str(docx_path))), [], name)
|
||||
self.assertIn("Heading 1", summarize(str(docx_path)))
|
||||
|
||||
prs = pptx_kit.new_presentation(name, title="汇报")
|
||||
pptx_kit.add_title_slide(prs, "汇报标题", "2026年10月1日")
|
||||
pptx_kit.add_section_slide(prs, "第一部分")
|
||||
pptx_kit.add_bullets_slide(prs, "结论", ["先说结论", "再用数据", "最后给动作"])
|
||||
pptx_kit.add_two_column(prs, "对比", "方案 A", ["快"], "方案 B", ["稳"])
|
||||
pptx_kit.add_table_slide(prs, "数据", ["项", "值"], [["甲", "1"]])
|
||||
pptx_kit.add_chart_slide(prs, "趋势", ["一季度", "二季度"], {"金额": [1, 2]})
|
||||
pptx_path = root / f"{name}.pptx"
|
||||
pptx_kit.save(prs, pptx_path)
|
||||
self.assertEqual(_errors(lint_pptx(str(pptx_path))), [], name)
|
||||
|
||||
wb = xlsx_kit.new_workbook(name, title="数据")
|
||||
xlsx_kit.add_sheet(wb, "明细", ["名称", "金额"], [["甲", 1200], ["乙", 3.5]], ["left", "right"])
|
||||
xlsx_path = root / f"{name}.xlsx"
|
||||
xlsx_kit.save(wb, xlsx_path, ["金额单位为元。"])
|
||||
self.assertEqual(_errors(lint_xlsx(str(xlsx_path))), [], name)
|
||||
|
||||
def test_extract_roundtrip_and_image_note(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "ref.docx"
|
||||
doc = docx_kit.new_document("modern", title="参考")
|
||||
docx_kit.add_paragraph(doc, "用于抽取。")
|
||||
docx_kit.save(doc, path)
|
||||
spec = extract(str(path))
|
||||
self.assertEqual(spec["font"], "微软雅黑")
|
||||
self.assertAlmostEqual(spec["page_width_cm"], 21.0, delta=0.3)
|
||||
self.assertAlmostEqual(spec["page_height_cm"], 29.7, delta=0.3)
|
||||
custom = docx_kit.new_document(spec, title="仿制")
|
||||
docx_kit.add_heading(custom, "仿制结果", 1)
|
||||
docx_kit.add_paragraph(custom, "沿用参考文档的字体和页边距。")
|
||||
out = Path(tmp) / "copy.docx"
|
||||
docx_kit.save(custom, out)
|
||||
self.assertEqual(_errors(lint_docx(str(out))), [])
|
||||
note = extract(str(Path(tmp) / "missing.png"))
|
||||
# 不存在的图片路径仍按扩展名给出说明,不读文件。
|
||||
self.assertTrue(note.get("approximate"))
|
||||
|
||||
|
||||
class BadDocumentTests(unittest.TestCase):
|
||||
def test_docx_rules_match_the_diagnosis(self):
|
||||
doc = Document()
|
||||
heading = doc.add_heading("一、会议背景", level=1)
|
||||
heading.runs[0].font.color.rgb = RGBColor(0x36, 0x5F, 0x91)
|
||||
doc.add_paragraph("")
|
||||
doc.add_paragraph("• 手打符号")
|
||||
doc.add_paragraph("1. 双重编号", style="List Number")
|
||||
doc.add_paragraph("", style="List Number")
|
||||
doc.add_heading("直接跳到三级", level=3)
|
||||
table = doc.add_table(rows=1, cols=1)
|
||||
table.style = "Table Grid"
|
||||
table.cell(0, 0).text = "格"
|
||||
doc.add_paragraph("判断相关性 — — 没有说完")
|
||||
doc.add_heading("五、未决问题", level=1)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = str(Path(tmp) / "bad.docx")
|
||||
doc.save(path)
|
||||
rules = {item["rule"] for item in _errors(lint_docx(path))}
|
||||
expected = {
|
||||
"paper-size",
|
||||
"body-font",
|
||||
"theme-color",
|
||||
"empty-paragraph",
|
||||
"manual-bullet",
|
||||
"double-number",
|
||||
"empty-list-item",
|
||||
"heading-skip",
|
||||
"empty-section",
|
||||
"table-style",
|
||||
"page-number",
|
||||
"punctuation",
|
||||
}
|
||||
self.assertTrue(expected <= rules, sorted(expected - rules))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,37 +1,24 @@
|
||||
---
|
||||
description: 用 python-pptx 生成标题页与内容页结构的 PowerPoint(.pptx)交付物
|
||||
description: 用 housestyle 生成 16:9 的 PowerPoint(.pptx)
|
||||
---
|
||||
|
||||
# pptx 生成配方(python-pptx)
|
||||
# pptx 生成配方
|
||||
|
||||
先装库(缺库时):`pip install --user python-pptx`。经 run_python 执行,文件落在会话工作区。
|
||||
先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md`。缺库时 `pip install --user python-pptx`。有模板时把路径传给 `new_presentation(template=...)`,版式走模板;否则用 16:9 构件。
|
||||
|
||||
```python
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches, Pt
|
||||
from housestyle import pptx_kit as px
|
||||
|
||||
prs = Presentation()
|
||||
|
||||
# 标题页(版式 0:标题+副标题)
|
||||
cover = prs.slides.add_slide(prs.slide_layouts[0])
|
||||
cover.shapes.title.text = '汇报标题'
|
||||
cover.placeholders[1].text = '副标题 / 日期 / 出品'
|
||||
|
||||
# 内容页(版式 1:标题+正文):标题 + 项目符号列表
|
||||
s = prs.slides.add_slide(prs.slide_layouts[1])
|
||||
s.shapes.title.text = '章节要点'
|
||||
body = s.placeholders[1].text_frame
|
||||
body.text = '第一点:结论先行'
|
||||
for point in ['第二点:数据支撑', '第三点:风险与建议']:
|
||||
p = body.add_paragraph()
|
||||
p.text = point
|
||||
p.level = 0
|
||||
|
||||
# 分节页(版式 5:仅标题)
|
||||
sec = prs.slides.add_slide(prs.slide_layouts[5])
|
||||
sec.shapes.title.text = '第二部分'
|
||||
|
||||
prs.save('交付物名.pptx')
|
||||
prs = px.new_presentation(theme="modern", title="汇报标题")
|
||||
px.add_title_slide(prs, "汇报标题", "副标题 / 日期")
|
||||
px.add_section_slide(prs, "第一部分")
|
||||
px.add_bullets_slide(prs, "这一页只讲一个结论", ["依据一", "依据二", "要做的动作"])
|
||||
px.add_two_column(prs, "两种做法", "现在", ["直接生成全文"], "改为", ["先判断意图", "再渐进更新"])
|
||||
px.add_table_slide(prs, "待办", ["事项", "负责人"], [["写需求说明", "未指明"]])
|
||||
px.add_chart_slide(prs, "进度", ["上周", "本周"], {"完成项": [2, 5]})
|
||||
px.save(prs, "交付物.pptx")
|
||||
```
|
||||
|
||||
要求:每页一个明确论点、要点不超过 5 条;生成后 register_artifact 登记;修订生成新文件名。
|
||||
然后 `python -m housestyle.lint 交付物.pptx`。没有 error 再登记。修订用新文件名。
|
||||
|
||||
规则:一页一个论点,要点不超过 5 条,不把纪要全文贴进幻灯片。标题页、分节页、要点页、对比页、表或图,按叙事需要取用,不堆版式。
|
||||
|
||||
@@ -1,37 +1,27 @@
|
||||
---
|
||||
description: 用 openpyxl 生成带格式与公式的 Excel(.xlsx)交付物
|
||||
description: 用 housestyle 生成带表头、公式和说明页的 Excel(.xlsx)
|
||||
---
|
||||
|
||||
# xlsx 生成配方(openpyxl)
|
||||
# xlsx 生成配方
|
||||
|
||||
先装库(缺库时):`pip install --user openpyxl`。经 run_python 执行,文件落在会话工作区。
|
||||
先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md`。缺库时 `pip install --user openpyxl`。
|
||||
|
||||
```python
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment, PatternFill
|
||||
from housestyle import xlsx_kit as xs
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = '表1'
|
||||
|
||||
# 表头(加粗 + 底色 + 居中)
|
||||
header_font = Font(bold=True, color='FFFFFF')
|
||||
header_fill = PatternFill('solid', fgColor='4472C4')
|
||||
for col, name in enumerate(['列一', '列二', '列三'], start=1):
|
||||
c = ws.cell(row=1, column=col, value=name)
|
||||
c.font = header_font
|
||||
c.fill = header_fill
|
||||
c.alignment = Alignment(horizontal='center')
|
||||
|
||||
ws.append([None, None, None]) # 对齐表头后追加数据行
|
||||
ws.append(['甲', 100, '=B2*2']) # 公式直接写字符串
|
||||
ws.column_dimensions['A'].width = 18
|
||||
|
||||
# 第二个工作表
|
||||
ws2 = wb.create_sheet('说明')
|
||||
ws2['A1'] = '口径与数据来源说明'
|
||||
|
||||
wb.save('交付物名.xlsx')
|
||||
wb = xs.new_workbook(theme="modern", title="数据表")
|
||||
xs.add_sheet(
|
||||
wb,
|
||||
"明细",
|
||||
["名称", "数量", "单价", "金额"],
|
||||
[["甲", 2, 100, "=B2*C2"], ["乙", 1, 80, "=B3*C3"]],
|
||||
["left", "right", "right", "right"],
|
||||
)
|
||||
xs.save(wb, "交付物.xlsx", ["金额 = 数量 × 单价,单位元。来源:本表手工录入。"])
|
||||
```
|
||||
|
||||
要求:表头格式、列宽、公式与多表结构按内容需要使用;生成后 register_artifact 登记;修订生成新文件名。
|
||||
`add_sheet` 会加表头底色、冻结首行、筛选、列宽、A4 横向打印和千分位。`save` 会补一张“说明”表,用来写口径、单位和来源。公式写成以 `=` 开头的字符串。
|
||||
|
||||
然后 `python -m housestyle.lint 交付物.xlsx`。没有 error 再登记。修订用新文件名。
|
||||
|
||||
数值列右对齐。不要把说明文字塞进数据表的空行里。
|
||||
|
||||
+125
-16
@@ -1,53 +1,129 @@
|
||||
// Package skills 内置技能库:go:embed 三套交付物生成配方(docx/xlsx/pptx),
|
||||
// Package skills 内置技能库:go:embed 交付物配方与 house-style 设计系统,
|
||||
// 应用启动时种子到 <data>/skills/,供 agent-native 内核的技能加载器
|
||||
// (SKILL.md + load_skill 渐进披露)与人工查阅使用。
|
||||
// 种子幂等且不覆盖:已存在的 SKILL.md 视为用户修订,原样保留。
|
||||
//
|
||||
// 种子可升级:manifest 记录上次写入的内置哈希。磁盘内容仍等于该哈希,
|
||||
// 或等于升级前发布过的旧配方哈希时,换成当前内置版本。用户改过的文件保留,
|
||||
// 并在结果里标为 drift。
|
||||
package skills
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed recipes
|
||||
//go:embed all:recipes
|
||||
var recipes embed.FS
|
||||
|
||||
// Seed 把内置技能种子到 dir(幂等;已存在的文件不覆盖)。
|
||||
// 返回本次实际写入的文件数。
|
||||
func Seed(dir string) (int, error) {
|
||||
count := 0
|
||||
const manifestName = ".builtin-manifest.json"
|
||||
|
||||
// legacyBuiltinHashes 是引入 manifest 之前发布的三份配方。
|
||||
// 已部署环境没有 manifest,内容仍等于这些哈希时视为未被用户改过,允许升级。
|
||||
var legacyBuiltinHashes = map[string]string{
|
||||
"docx-author/SKILL.md": "8d24de3fc1c7ae441e2163b7414e4e0a10c5140e1733debcccd536dd6be344f1",
|
||||
"pptx-author/SKILL.md": "af002478ee84038b0f90d0859525672d74c1d48794ec1750b680a8e4afb9cf72",
|
||||
"xlsx-author/SKILL.md": "97376d377b1e73a9c7127c829b3c8e0d61f1ba941ca12c0d196b565f8a9e2697",
|
||||
}
|
||||
|
||||
// SeedResult 是一次种子的写入、升级和保留情况。
|
||||
type SeedResult struct {
|
||||
Written int
|
||||
Upgraded int
|
||||
Drifted []string
|
||||
}
|
||||
|
||||
type manifestFile struct {
|
||||
Files map[string]manifestEntry `json:"files"`
|
||||
}
|
||||
|
||||
type manifestEntry struct {
|
||||
SHA256 string `json:"sha256"`
|
||||
Managed bool `json:"managed"`
|
||||
}
|
||||
|
||||
// Seed 把内置技能种子到 dir。返回本次新写入、升级和保留的用户修订。
|
||||
func Seed(dir string) (SeedResult, error) {
|
||||
var res SeedResult
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return res, err
|
||||
}
|
||||
man := loadManifest(filepath.Join(dir, manifestName))
|
||||
if man.Files == nil {
|
||||
man.Files = map[string]manifestEntry{}
|
||||
}
|
||||
err := fs.WalkDir(recipes, "recipes", func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
if d.Name() == "__pycache__" {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(d.Name(), ".pyc") {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel("recipes", path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dst := filepath.Join(dir, filepath.FromSlash(rel))
|
||||
if _, statErr := os.Stat(dst); statErr == nil {
|
||||
return nil // 用户修订过:不覆盖
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
data, err := recipes.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
embedded := hashBytes(data)
|
||||
dst := filepath.Join(dir, filepath.FromSlash(rel))
|
||||
disk, err := os.ReadFile(dst)
|
||||
if os.IsNotExist(err) {
|
||||
if err := writeSeed(dst, data); err != nil {
|
||||
return err
|
||||
}
|
||||
man.Files[rel] = manifestEntry{SHA256: embedded, Managed: true}
|
||||
res.Written++
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(dst, data, 0o644); err != nil {
|
||||
return err
|
||||
current := hashBytes(disk)
|
||||
if current == embedded {
|
||||
man.Files[rel] = manifestEntry{SHA256: embedded, Managed: true}
|
||||
return nil
|
||||
}
|
||||
count++
|
||||
prev, has := man.Files[rel]
|
||||
legacy := legacyBuiltinHashes[rel] == current
|
||||
managedSame := has && prev.Managed && prev.SHA256 == current
|
||||
if managedSame || (!has && legacy) || (has && prev.Managed && legacy) {
|
||||
if err := writeSeed(dst, data); err != nil {
|
||||
return err
|
||||
}
|
||||
man.Files[rel] = manifestEntry{SHA256: embedded, Managed: true}
|
||||
res.Upgraded++
|
||||
return nil
|
||||
}
|
||||
if !has || prev.Managed || prev.SHA256 != current {
|
||||
res.Drifted = append(res.Drifted, rel)
|
||||
}
|
||||
man.Files[rel] = manifestEntry{SHA256: current, Managed: false}
|
||||
return nil
|
||||
})
|
||||
return count, err
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
sort.Strings(res.Drifted)
|
||||
if err := saveManifest(filepath.Join(dir, manifestName), man); err != nil {
|
||||
return res, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Names 返回内置技能名(目录名,排序稳定)。
|
||||
@@ -65,3 +141,36 @@ func Names() []string {
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func writeSeed(dst string, data []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dst, data, 0o644)
|
||||
}
|
||||
|
||||
func hashBytes(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func loadManifest(path string) manifestFile {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return manifestFile{}
|
||||
}
|
||||
var man manifestFile
|
||||
if json.Unmarshal(raw, &man) != nil {
|
||||
return manifestFile{}
|
||||
}
|
||||
return man
|
||||
}
|
||||
|
||||
func saveManifest(path string, man manifestFile) error {
|
||||
raw, err := json.MarshalIndent(man, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
return os.WriteFile(path, raw, 0o644)
|
||||
}
|
||||
|
||||
+137
-12
@@ -1,44 +1,169 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSeedIdempotentAndNoOverwrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
n, err := Seed(dir)
|
||||
if err != nil || n != 3 {
|
||||
t.Fatalf("first seed want 3 files, got %d err=%v", n, err)
|
||||
res, err := Seed(dir)
|
||||
if err != nil || res.Written < 4 {
|
||||
t.Fatalf("first seed want several files, got %+v err=%v", res, err)
|
||||
}
|
||||
for _, name := range []string{"docx-author", "xlsx-author", "pptx-author"} {
|
||||
for _, name := range []string{"docx-author", "xlsx-author", "pptx-author", "house-style"} {
|
||||
data, err := os.ReadFile(filepath.Join(dir, name, "SKILL.md"))
|
||||
if err != nil || len(data) == 0 {
|
||||
t.Fatalf("skill %s missing after seed: %v", name, err)
|
||||
}
|
||||
}
|
||||
// 第二次种子:幂等,不再写入。
|
||||
if n, err := Seed(dir); err != nil || n != 0 {
|
||||
t.Fatalf("second seed must be a no-op, got %d err=%v", n, err)
|
||||
if _, err := os.ReadFile(filepath.Join(dir, "house-style", "housestyle", "__init__.py")); err != nil {
|
||||
t.Fatalf("package init was not seeded: %v", err)
|
||||
}
|
||||
again, err := Seed(dir)
|
||||
if err != nil || again.Written != 0 || again.Upgraded != 0 {
|
||||
t.Fatalf("second seed must be a no-op, got %+v err=%v", again, err)
|
||||
}
|
||||
// 用户修订不被覆盖。
|
||||
custom := filepath.Join(dir, "docx-author", "SKILL.md")
|
||||
if err := os.WriteFile(custom, []byte("用户改过的配方"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ := Seed(dir); n != 0 {
|
||||
t.Fatalf("user-edited skill must not be overwritten, wrote %d", n)
|
||||
drifted, err := Seed(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if drifted.Written != 0 || drifted.Upgraded != 0 {
|
||||
t.Fatalf("user-edited skill must not be overwritten, %+v", drifted)
|
||||
}
|
||||
data, _ := os.ReadFile(custom)
|
||||
if string(data) != "用户改过的配方" {
|
||||
t.Fatal("user edit must survive seeding")
|
||||
}
|
||||
found := false
|
||||
for _, p := range drifted.Drifted {
|
||||
if p == "docx-author/SKILL.md" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("drift not reported: %+v", drifted)
|
||||
}
|
||||
quiet, err := Seed(dir)
|
||||
if err != nil || len(quiet.Drifted) != 0 {
|
||||
t.Fatalf("unchanged user edit should not be reported again, %+v err=%v", quiet, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedUpgradesPreviousManaged(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := Seed(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rel := "docx-author/SKILL.md"
|
||||
dst := filepath.Join(dir, filepath.FromSlash(rel))
|
||||
old := []byte("previous builtin body")
|
||||
if err := os.WriteFile(dst, old, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manPath := filepath.Join(dir, manifestName)
|
||||
raw, err := os.ReadFile(manPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var man manifestFile
|
||||
if err := json.Unmarshal(raw, &man); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
man.Files[rel] = manifestEntry{SHA256: hashBytes(old), Managed: true}
|
||||
encoded, err := json.MarshalIndent(man, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(manPath, encoded, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := Seed(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Upgraded < 1 {
|
||||
t.Fatalf("previous builtin must upgrade, %+v", res)
|
||||
}
|
||||
got, _ := os.ReadFile(dst)
|
||||
if string(got) == string(old) {
|
||||
t.Fatal("upgraded file still has the previous body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedUpgradesLegacyBuiltin(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("caller")
|
||||
}
|
||||
old, err := os.ReadFile(filepath.Join(filepath.Dir(thisFile), "testdata", "legacy-docx-author.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rel := "docx-author/SKILL.md"
|
||||
if hashBytes(old) != legacyBuiltinHashes[rel] {
|
||||
t.Fatalf("testdata hash drifted: %s", hashBytes(old))
|
||||
}
|
||||
dst := filepath.Join(dir, "docx-author", "SKILL.md")
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dst, old, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := Seed(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Upgraded < 1 {
|
||||
t.Fatalf("legacy builtin must upgrade, %+v", res)
|
||||
}
|
||||
got, _ := os.ReadFile(dst)
|
||||
if hashBytes(got) == hashBytes(old) {
|
||||
t.Fatal("legacy file was not replaced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNames(t *testing.T) {
|
||||
names := Names()
|
||||
if len(names) != 3 || names[0] != "docx-author" || names[2] != "xlsx-author" {
|
||||
t.Fatalf("want three sorted builtin skills, got %v", names)
|
||||
if len(names) != 4 || names[0] != "docx-author" || names[1] != "house-style" || names[3] != "xlsx-author" {
|
||||
t.Fatalf("want four sorted builtin skills, got %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbedIncludesPackageInit(t *testing.T) {
|
||||
if _, err := recipes.ReadFile("recipes/house-style/housestyle/__init__.py"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHouseStylePython(t *testing.T) {
|
||||
if _, err := exec.LookPath("python3"); err != nil {
|
||||
t.Skip("python3 not available")
|
||||
}
|
||||
probe := exec.Command("python3", "-c", "import docx, pptx, openpyxl")
|
||||
if err := probe.Run(); err != nil {
|
||||
t.Skip("python-docx, python-pptx or openpyxl missing")
|
||||
}
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("caller")
|
||||
}
|
||||
dir := filepath.Join(filepath.Dir(thisFile), "recipes", "house-style")
|
||||
cmd := exec.Command("python3", "-m", "unittest", "tests.test_housestyle")
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "PYTHONPATH="+dir)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("housestyle tests: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
---
|
||||
description: 用 python-docx 生成带真实排版的 Word(.docx)交付物
|
||||
---
|
||||
|
||||
# docx 生成配方(python-docx)
|
||||
|
||||
先装库(缺库时):`pip install --user python-docx`。经 run_python 执行,文件落在会话工作区。
|
||||
|
||||
```python
|
||||
from docx import Document
|
||||
from docx.shared import Pt, RGBColor
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.oxml.ns import qn
|
||||
|
||||
doc = Document()
|
||||
# 全局中文字体:等线(西文 Calibri)
|
||||
style = doc.styles['Normal']
|
||||
style.font.name = 'Calibri'
|
||||
style._element.rPr.rFonts.set(qn('w:eastAsia'), '等线')
|
||||
style.font.size = Pt(11)
|
||||
|
||||
t = doc.add_heading('文档标题', level=0)
|
||||
t.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
|
||||
doc.add_heading('一、章节标题', level=1)
|
||||
doc.add_paragraph('正文段落……')
|
||||
|
||||
# 表格(带样式)
|
||||
table = doc.add_table(rows=1, cols=3)
|
||||
table.style = 'Light Grid Accent 1'
|
||||
for i, h in enumerate(['列一', '列二', '列三']):
|
||||
table.rows[0].cells[i].text = h
|
||||
table.add_row().cells[0].text = '数据'
|
||||
|
||||
# 列表
|
||||
doc.add_paragraph('要点', style='List Bullet')
|
||||
doc.add_paragraph('结论', style='List Number')
|
||||
|
||||
doc.save('交付物名.docx')
|
||||
```
|
||||
|
||||
要求:标题层级、表格、列表样式必须真实排版;生成后 register_artifact 登记(path 传相对路径);修订生成新文件名而不是覆盖。
|
||||
@@ -38,7 +38,7 @@ func RegisterInterpreterTools(r *Registry) error {
|
||||
}
|
||||
return r.Register(core.ToolDesc{
|
||||
Name: "run_python", Source: core.SourceBuiltin,
|
||||
Description: "执行 Python 脚本(python3,工作目录为会话工作区):生成文档/表格/演示稿/数据处理的首选路径。脚本原样落盘执行;stdout/stderr 合并返回,超限头尾截断。python-docx/openpyxl/python-pptx 缺失时可先 pip install --user 安装。",
|
||||
Description: "执行 Python 脚本(python3,工作目录为会话工作区):生成文档/表格/演示稿/数据处理的首选路径。脚本原样落盘执行;stdout/stderr 合并返回,超限头尾截断。python-docx/openpyxl/python-pptx 缺失时可先 pip install --user 安装。交付物用 import housestyle(技能目录在环境变量 AGENT_SKILLS_DIR,house-style 已在 PYTHONPATH)。",
|
||||
Parameters: json.RawMessage(`{"type":"object","properties":{` +
|
||||
`"script":{"type":"string","description":"完整 Python 脚本(utf-8)"},` +
|
||||
`"timeout_sec":{"type":"integer","description":"超时秒数(默认 60,上限 300)"}}` +
|
||||
@@ -46,8 +46,14 @@ func RegisterInterpreterTools(r *Registry) error {
|
||||
}, execPython)
|
||||
}
|
||||
|
||||
// skillsRoot 由组合根在启动时注入(<data>/skills)。空 = 不注入技能路径。
|
||||
var skillsRoot string
|
||||
|
||||
// SetSkillsRoot 让 run_python / run_shell 能 import housestyle,并看到技能目录。
|
||||
func SetSkillsRoot(dir string) { skillsRoot = dir }
|
||||
|
||||
// execEnv 最小环境:PATH/HOME/LANG/TZ/TERM 继承(pip --user、字体、中文 locale),
|
||||
// 其余变量不带入子进程。
|
||||
// 其余变量不带入子进程。技能根配置后追加 AGENT_SKILLS_DIR 与 PYTHONPATH。
|
||||
func execEnv() []string {
|
||||
var out []string
|
||||
for _, kv := range os.Environ() {
|
||||
@@ -58,7 +64,14 @@ func execEnv() []string {
|
||||
}
|
||||
}
|
||||
}
|
||||
return append(out, "PYTHONDONTWRITEBYTECODE=1")
|
||||
out = append(out, "PYTHONDONTWRITEBYTECODE=1")
|
||||
if skillsRoot != "" {
|
||||
out = append(out,
|
||||
"AGENT_SKILLS_DIR="+skillsRoot,
|
||||
"PYTHONPATH="+filepath.Join(skillsRoot, "house-style"),
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// truncateOutput 头 2/3 + 尾 1/3 保留,中部标记省略。
|
||||
|
||||
@@ -139,6 +139,27 @@ func TestRunPythonWritesZipMagic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecEnvIncludesSkillsRoot(t *testing.T) {
|
||||
prev := skillsRoot
|
||||
t.Cleanup(func() { skillsRoot = prev })
|
||||
SetSkillsRoot(filepath.Join(t.TempDir(), "skills"))
|
||||
var dir, pythonpath string
|
||||
for _, kv := range execEnv() {
|
||||
if strings.HasPrefix(kv, "AGENT_SKILLS_DIR=") {
|
||||
dir = strings.TrimPrefix(kv, "AGENT_SKILLS_DIR=")
|
||||
}
|
||||
if strings.HasPrefix(kv, "PYTHONPATH=") {
|
||||
pythonpath = strings.TrimPrefix(kv, "PYTHONPATH=")
|
||||
}
|
||||
}
|
||||
if dir != skillsRoot {
|
||||
t.Fatalf("AGENT_SKILLS_DIR = %q", dir)
|
||||
}
|
||||
if pythonpath != filepath.Join(skillsRoot, "house-style") {
|
||||
t.Fatalf("PYTHONPATH = %q", pythonpath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpreterExecConfigForKernel(t *testing.T) {
|
||||
cfg := InterpreterExecConfig()
|
||||
if cfg == nil {
|
||||
@@ -153,7 +174,7 @@ func TestInterpreterExecConfigForKernel(t *testing.T) {
|
||||
for _, kv := range cfg.Env {
|
||||
if strings.HasPrefix(kv, "PATH=") || strings.HasPrefix(kv, "HOME=") || strings.HasPrefix(kv, "LANG=") ||
|
||||
strings.HasPrefix(kv, "LC_") || strings.HasPrefix(kv, "TZ=") || strings.HasPrefix(kv, "TERM=") ||
|
||||
kv == "PYTHONDONTWRITEBYTECODE=1" {
|
||||
kv == "PYTHONDONTWRITEBYTECODE=1" || strings.HasPrefix(kv, "AGENT_SKILLS_DIR=") || strings.HasPrefix(kv, "PYTHONPATH=") {
|
||||
continue
|
||||
}
|
||||
t.Fatalf("env whitelist violated: %q", kv)
|
||||
|
||||
Reference in New Issue
Block a user