Add professional standards for every agent deliverable #38
@@ -6,3 +6,5 @@
|
||||
web/dist/*
|
||||
!web/dist/.gitkeep
|
||||
data/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
+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"]
|
||||
@@ -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)
|
||||
@@ -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