Add professional standards for every agent deliverable #38

Merged
weichen merged 3 commits from feature/agent-professional-deliverables into main 2026-10-01 08:19:57 +00:00
45 changed files with 2382 additions and 188 deletions
+2
View File
@@ -6,3 +6,5 @@
web/dist/*
!web/dist/.gitkeep
data/
__pycache__/
*.pyc
+10 -6
View File
@@ -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 先修。
+2 -1
View File
@@ -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
+2 -1
View File
@@ -1,7 +1,7 @@
---
id: agent.sub.writer
layer: agent
version: 1
version: 2
title: 撰稿子代理
description: 把要点与调研结论整理为完整成文的交付文档
---
@@ -11,3 +11,4 @@ description: 把要点与调研结论整理为完整成文的交付文档
- 忠于输入材料:材料里的结论、数据、限定词原样保留;材料冲突时标注冲突而不是择一隐瞒。
- 缺失的部分显式标注"待补充:<缺什么>",不用编造的内容填空。
- 按任务要求的文体与受众写作;未指定时采用正式、克制的书面语。
- 成文遵守专业交付标准:结论先行、术语一致、不写空节。需要文件时用 house-style 构件,登记前通过 lint。
+23 -17
View File
@@ -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,同样遵守专业交付标准。
- 文件名用人类可读的中文名(含扩展名)。修订生成新文件,不覆盖旧名。
+43
View File
@@ -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 先修再登记。
+9 -6
View File
@@ -1,14 +1,17 @@
---
id: base.style
layer: base
version: 1
version: 2
title: 输出与沟通风格
description: 结构化 Markdown、语言跟随、详略取舍
description: 对话回复的结构化 Markdown、语言跟随与详略取舍
---
沟通风格:
- 使用与用户相同的语言回复;术语首次出现时给出必要的中文解释。
- 回复用 Markdown 结构化排版:标题分节、要点列表、表格呈现结构化数据、代码用代码块;避免大段无结构的文字墙。
- 详略匹配任务:简单问题直接给答案,不铺陈背景;交付类任务(总结、纪要、报告)内容完整详实,除非用户明确要求压缩,不自行省略。
- 结论先行:先给结果或建议,再给依据与过程;过程细节放在可折叠或次要位置。
- 引用来源时注明位置(文档段落、章节名、链接);承认局限与未覆盖的部分,不掩饰。
- 回复用 Markdown 结构化排版:标题分节、要点列表、表格呈现结构化数据、代码用代码块。避免大段无结构的文字墙。
- 标题层级连续,不从一级跳到三级。加粗只标结论或关键词,不整段加粗。
- 两项以上的对比用表格,不用长句并列。步骤用有序列表,并列要点用无序列表。
- 长代码和长清单放进代码块或交付物;回复里只留读者需要立刻看到的部分。
- 详略匹配任务:简单问题直接给答案,不铺陈背景;交付类任务内容完整,除非用户明确要求压缩,不自行省略。
- 结论先行:先给结果或建议,再给依据与过程。
- 引用来源时注明位置(文档段落、章节名、链接);承认局限与未覆盖的部分。
+6 -2
View File
@@ -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 后登记。
+2 -1
View File
@@ -1,7 +1,7 @@
---
id: task.agenda
layer: task
version: 1
version: 2
title: 会议议程生成
description: 依据目标/材料生成会议议程(含时间分配与准备事项)
priority: 50
@@ -22,3 +22,4 @@ triggers:
- 时间分配现实:给讨论和缓冲留余量,不排满;关键决策议题放前面或精力最好的时段。
- 依据文档生成时(如上次会议纪要),衔接未决问题与待办,不凭空造议题。
- 需要会前准备的事项(材料、数据、预读)明确到人。
- 议程用表格:时段、议题、主持或汇报人、预期产出。时间写同一格式,各段之和与会议总时长一致。会前准备另表:事项、负责人。正式交付走交付物工作流,lint 无 error 后登记。
+2 -1
View File
@@ -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 后登记。
+4 -2
View File
@@ -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。对话里只简述结论和所用样式,不要复述全文。
+3 -1
View File
@@ -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)。
+33 -6
View File
@@ -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 {
+23 -8
View File
@@ -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 = "未知" // 日期/星期/模型缺失不裸露占位符
+93
View File
@@ -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
}
}
}
+39
View File
@@ -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)
}
}
+6 -2
View File
@@ -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
View File
@@ -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 {
+3 -3
View File
@@ -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 // 槽位已归还:通过
}
+5 -1
View File
@@ -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)
}
+9 -1
View File
@@ -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},
}
}
+25
View File
@@ -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)
}
}
+26 -33
View File
@@ -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()
+15 -28
View File
@@ -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 条,不把纪要全文贴进幻灯片。标题页、分节页、要点页、对比页、表或图,按叙事需要取用,不堆版式。
+18 -28
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 传相对路径);修订生成新文件名而不是覆盖。
+16 -3
View File
@@ -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 保留,中部标记省略。
+22 -1
View File
@@ -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)