diff --git a/internal/app/app.go b/internal/app/app.go index 17fc315..2261cd7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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)落 /skills/, - // 幂等且不覆盖用户修订;agent-native 内核与小队角色声明都依赖它在。 + // 技能种子:交付配方与 house-style 落 /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{}) diff --git a/internal/prompts/library/agent-squad-reviewer.md b/internal/prompts/library/agent-squad-reviewer.md index b67b03a..8c5e829 100644 --- a/internal/prompts/library/agent-squad-reviewer.md +++ b/internal/prompts/library/agent-squad-reviewer.md @@ -1,12 +1,12 @@ --- id: agent.squad.reviewer layer: agent -version: 1 +version: 2 title: 小队·审校员 description: 终审交付文档,必须给出通过或修改意见 --- 你是小队编排中的审校员,对交付文档做终审。 -- 审查维度:一致性(结论与证据、前后文、术语)、完整性(覆盖目标全部要求)、风险(可能误导读者的表述、越出证据的断言)。 +- 审查维度:一致性(结论与证据、前后文、术语)、完整性(覆盖目标全部要求)、风险(可能误导读者的表述、越出证据的断言)、版式与规范(标题层级、空节、手打符号、日期和术语是否统一;Office 文件必须附 housestyle.lint 的 JSON 结果,有 error 即不通过)。 - 逐维度给出结论与具体位置;引用原文片段说明问题。 - 结尾必须给出明确裁决:通过 / 需修改(列修改清单,按重要性排序)。不写"基本可以"这类模糊结论。 diff --git a/internal/prompts/library/agent-squad-writer.md b/internal/prompts/library/agent-squad-writer.md index 42b9d36..38fdd0b 100644 --- a/internal/prompts/library/agent-squad-writer.md +++ b/internal/prompts/library/agent-squad-writer.md @@ -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 先修。 diff --git a/internal/prompts/library/agent-sub-verify.md b/internal/prompts/library/agent-sub-verify.md index 5f475af..c382769 100644 --- a/internal/prompts/library/agent-sub-verify.md +++ b/internal/prompts/library/agent-sub-verify.md @@ -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 diff --git a/internal/prompts/library/agent-sub-writer.md b/internal/prompts/library/agent-sub-writer.md index 57fc105..364ea10 100644 --- a/internal/prompts/library/agent-sub-writer.md +++ b/internal/prompts/library/agent-sub-writer.md @@ -1,7 +1,7 @@ --- id: agent.sub.writer layer: agent -version: 1 +version: 2 title: 撰稿子代理 description: 把要点与调研结论整理为完整成文的交付文档 --- @@ -11,3 +11,4 @@ description: 把要点与调研结论整理为完整成文的交付文档 - 忠于输入材料:材料里的结论、数据、限定词原样保留;材料冲突时标注冲突而不是择一隐瞒。 - 缺失的部分显式标注"待补充:<缺什么>",不用编造的内容填空。 - 按任务要求的文体与受众写作;未指定时采用正式、克制的书面语。 +- 成文遵守专业交付标准:结论先行、术语一致、不写空节。需要文件时用 house-style 构件,登记前通过 lint。 diff --git a/internal/prompts/library/base-deliverables.md b/internal/prompts/library/base-deliverables.md index a3d76e3..bdf5339 100644 --- a/internal/prompts/library/base-deliverables.md +++ b/internal/prompts/library/base-deliverables.md @@ -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,同样遵守专业交付标准。 +- 文件名用人类可读的中文名(含扩展名)。修订生成新文件,不覆盖旧名。 diff --git a/internal/prompts/library/base-quality.md b/internal/prompts/library/base-quality.md new file mode 100644 index 0000000..b160d36 --- /dev/null +++ b/internal/prompts/library/base-quality.md @@ -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 先修再登记。 diff --git a/internal/prompts/library/base-style.md b/internal/prompts/library/base-style.md index f95856a..dcbc88e 100644 --- a/internal/prompts/library/base-style.md +++ b/internal/prompts/library/base-style.md @@ -1,14 +1,17 @@ --- id: base.style layer: base -version: 1 +version: 2 title: 输出与沟通风格 -description: 结构化 Markdown、语言跟随、详略取舍 +description: 对话回复的结构化 Markdown、语言跟随与详略取舍 --- 沟通风格: - 使用与用户相同的语言回复;术语首次出现时给出必要的中文解释。 -- 回复用 Markdown 结构化排版:标题分节、要点列表、表格呈现结构化数据、代码用代码块;避免大段无结构的文字墙。 -- 详略匹配任务:简单问题直接给答案,不铺陈背景;交付类任务(总结、纪要、报告)内容完整详实,除非用户明确要求压缩,不自行省略。 -- 结论先行:先给结果或建议,再给依据与过程;过程细节放在可折叠或次要位置。 -- 引用来源时注明位置(文档段落、章节名、链接);承认局限与未覆盖的部分,不掩饰。 +- 回复用 Markdown 结构化排版:标题分节、要点列表、表格呈现结构化数据、代码用代码块。避免大段无结构的文字墙。 +- 标题层级连续,不从一级跳到三级。加粗只标结论或关键词,不整段加粗。 +- 两项以上的对比用表格,不用长句并列。步骤用有序列表,并列要点用无序列表。 +- 长代码和长清单放进代码块或交付物;回复里只留读者需要立刻看到的部分。 +- 详略匹配任务:简单问题直接给答案,不铺陈背景;交付类任务内容完整,除非用户明确要求压缩,不自行省略。 +- 结论先行:先给结果或建议,再给依据与过程。 +- 引用来源时注明位置(文档段落、章节名、链接);承认局限与未覆盖的部分。 diff --git a/internal/prompts/library/env-runtime.md b/internal/prompts/library/env-runtime.md index 1d0756e..7db6685 100644 --- a/internal/prompts/library/env-runtime.md +++ b/internal/prompts/library/env-runtime.md @@ -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}}。模板和参考文档用这些路径,不要向用户索取已经上传的文件。 diff --git a/internal/prompts/library/task-action-items.md b/internal/prompts/library/task-action-items.md index 8899948..6d88142 100644 --- a/internal/prompts/library/task-action-items.md +++ b/internal/prompts/library/task-action-items.md @@ -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 后登记。 diff --git a/internal/prompts/library/task-agenda.md b/internal/prompts/library/task-agenda.md index 587ba83..d2b752a 100644 --- a/internal/prompts/library/task-agenda.md +++ b/internal/prompts/library/task-agenda.md @@ -1,7 +1,7 @@ --- id: task.agenda layer: task -version: 1 +version: 2 title: 会议议程生成 description: 依据目标/材料生成会议议程(含时间分配与准备事项) priority: 50 @@ -22,3 +22,4 @@ triggers: - 时间分配现实:给讨论和缓冲留余量,不排满;关键决策议题放前面或精力最好的时段。 - 依据文档生成时(如上次会议纪要),衔接未决问题与待办,不凭空造议题。 - 需要会前准备的事项(材料、数据、预读)明确到人。 +- 议程用表格:时段、议题、主持或汇报人、预期产出。时间写同一格式,各段之和与会议总时长一致。会前准备另表:事项、负责人。正式交付走交付物工作流,lint 无 error 后登记。 diff --git a/internal/prompts/library/task-compare.md b/internal/prompts/library/task-compare.md index 3c6a6ff..ee434fb 100644 --- a/internal/prompts/library/task-compare.md +++ b/internal/prompts/library/task-compare.md @@ -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 后登记。 diff --git a/internal/prompts/library/task-doc-minutes.md b/internal/prompts/library/task-doc-minutes.md index 9ec0198..1530c5f 100644 --- a/internal/prompts/library/task-doc-minutes.md +++ b/internal/prompts/library/task-doc-minutes.md @@ -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。对话里只简述结论和所用样式,不要复述全文。 diff --git a/internal/prompts/library/task-report.md b/internal/prompts/library/task-report.md index e37ac77..773000c 100644 --- a/internal/prompts/library/task-report.md +++ b/internal/prompts/library/task-report.md @@ -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 后登记。回复说明样式,不复述全文。 diff --git a/internal/prompts/library/tool-write-artifact.md b/internal/prompts/library/tool-write-artifact.md index 4ee36a2..dbbcc0f 100644 --- a/internal/prompts/library/tool-write-artifact.md +++ b/internal/prompts/library/tool-write-artifact.md @@ -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)。 diff --git a/internal/prompts/prompts_test.go b/internal/prompts/prompts_test.go index 7ba366b..8e3aa6b 100644 --- a/internal/prompts/prompts_test.go +++ b/internal/prompts/prompts_test.go @@ -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 { diff --git a/internal/prompts/service.go b/internal/prompts/service.go index 8546409..34b1d70 100644 --- a/internal/prompts/service.go +++ b/internal/prompts/service.go @@ -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 = "未知" // 日期/星期/模型缺失不裸露占位符 diff --git a/internal/runmgr/inputs.go b/internal/runmgr/inputs.go new file mode 100644 index 0000000..f18b011 --- /dev/null +++ b/internal/runmgr/inputs.go @@ -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 把原件和可选默认模板写到 ///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 + } + } +} diff --git a/internal/runmgr/inputs_test.go b/internal/runmgr/inputs_test.go new file mode 100644 index 0000000..03fc917 --- /dev/null +++ b/internal/runmgr/inputs_test.go @@ -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) + } +} diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index 318a8ee..6e64ec2 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -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 } diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index ab7dabd..e21fa8b 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -20,6 +20,8 @@ import ( type LLMRunner struct { // Prompts 提示词库(nil = 回退画像 SystemPrompt 单段,兼容测试与骨架)。 Prompts *prompts.Service + // SkillsDir 内置技能目录(/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.)优先,回落注册表文案。 func (l *LLMRunner) toolDesc(owner, name, fallback string) string { if l.Prompts != nil { diff --git a/internal/server/auth_e2e_test.go b/internal/server/auth_e2e_test.go index ed7c477..42cdfdf 100644 --- a/internal/server/auth_e2e_test.go +++ b/internal/server/auth_e2e_test.go @@ -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 // 槽位已归还:通过 } diff --git a/internal/server/server.go b/internal/server/server.go index 6db9dea..4c0258c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) } diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 2bf47d8..0976177 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -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}, } } diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go new file mode 100644 index 0000000..446d501 --- /dev/null +++ b/internal/settings/settings_test.go @@ -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) + } +} diff --git a/internal/skills/recipes/docx-author/SKILL.md b/internal/skills/recipes/docx-author/SKILL.md index 3d435f3..4cdafff 100644 --- a/internal/skills/recipes/docx-author/SKILL.md +++ b/internal/skills/recipes/docx-author/SKILL.md @@ -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.” 或 “•”。没有内容的章节不要出现。 diff --git a/internal/skills/recipes/house-style/SKILL.md b/internal/skills/recipes/house-style/SKILL.md new file mode 100644 index 0000000..963ca72 --- /dev/null +++ b/internal/skills/recipes/house-style/SKILL.md @@ -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。 + +回读结果要和动笔前的结构一致:没有空节,表格列和内容对应,页数或幻灯片数合理。 diff --git a/internal/skills/recipes/house-style/housestyle/__init__.py b/internal/skills/recipes/house-style/housestyle/__init__.py new file mode 100644 index 0000000..dc0c395 --- /dev/null +++ b/internal/skills/recipes/house-style/housestyle/__init__.py @@ -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"] diff --git a/internal/skills/recipes/house-style/housestyle/__pycache__/__init__.cpython-314.pyc b/internal/skills/recipes/house-style/housestyle/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..550e20f Binary files /dev/null and b/internal/skills/recipes/house-style/housestyle/__pycache__/__init__.cpython-314.pyc differ diff --git a/internal/skills/recipes/house-style/housestyle/__pycache__/docx_kit.cpython-314.pyc b/internal/skills/recipes/house-style/housestyle/__pycache__/docx_kit.cpython-314.pyc new file mode 100644 index 0000000..e1ca01c Binary files /dev/null and b/internal/skills/recipes/house-style/housestyle/__pycache__/docx_kit.cpython-314.pyc differ diff --git a/internal/skills/recipes/house-style/housestyle/__pycache__/extract.cpython-314.pyc b/internal/skills/recipes/house-style/housestyle/__pycache__/extract.cpython-314.pyc new file mode 100644 index 0000000..87a3fdd Binary files /dev/null and b/internal/skills/recipes/house-style/housestyle/__pycache__/extract.cpython-314.pyc differ diff --git a/internal/skills/recipes/house-style/housestyle/__pycache__/lint.cpython-314.pyc b/internal/skills/recipes/house-style/housestyle/__pycache__/lint.cpython-314.pyc new file mode 100644 index 0000000..b67bb8f Binary files /dev/null and b/internal/skills/recipes/house-style/housestyle/__pycache__/lint.cpython-314.pyc differ diff --git a/internal/skills/recipes/house-style/housestyle/__pycache__/outline.cpython-314.pyc b/internal/skills/recipes/house-style/housestyle/__pycache__/outline.cpython-314.pyc new file mode 100644 index 0000000..6098eaf Binary files /dev/null and b/internal/skills/recipes/house-style/housestyle/__pycache__/outline.cpython-314.pyc differ diff --git a/internal/skills/recipes/house-style/housestyle/__pycache__/pptx_kit.cpython-314.pyc b/internal/skills/recipes/house-style/housestyle/__pycache__/pptx_kit.cpython-314.pyc new file mode 100644 index 0000000..ac473f1 Binary files /dev/null and b/internal/skills/recipes/house-style/housestyle/__pycache__/pptx_kit.cpython-314.pyc differ diff --git a/internal/skills/recipes/house-style/housestyle/__pycache__/themes.cpython-314.pyc b/internal/skills/recipes/house-style/housestyle/__pycache__/themes.cpython-314.pyc new file mode 100644 index 0000000..819e95e Binary files /dev/null and b/internal/skills/recipes/house-style/housestyle/__pycache__/themes.cpython-314.pyc differ diff --git a/internal/skills/recipes/house-style/housestyle/__pycache__/xlsx_kit.cpython-314.pyc b/internal/skills/recipes/house-style/housestyle/__pycache__/xlsx_kit.cpython-314.pyc new file mode 100644 index 0000000..8390ba9 Binary files /dev/null and b/internal/skills/recipes/house-style/housestyle/__pycache__/xlsx_kit.cpython-314.pyc differ diff --git a/internal/skills/recipes/house-style/housestyle/docx_kit.py b/internal/skills/recipes/house-style/housestyle/docx_kit.py new file mode 100644 index 0000000..0a02bd8 --- /dev/null +++ b/internal/skills/recipes/house-style/housestyle/docx_kit.py @@ -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) + diff --git a/internal/skills/recipes/house-style/housestyle/extract.py b/internal/skills/recipes/house-style/housestyle/extract.py new file mode 100644 index 0000000..bd9808b --- /dev/null +++ b/internal/skills/recipes/house-style/housestyle/extract.py @@ -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 diff --git a/internal/skills/recipes/house-style/housestyle/lint.py b/internal/skills/recipes/house-style/housestyle/lint.py new file mode 100644 index 0000000..804c71a --- /dev/null +++ b/internal/skills/recipes/house-style/housestyle/lint.py @@ -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=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()) diff --git a/internal/skills/recipes/house-style/housestyle/outline.py b/internal/skills/recipes/house-style/housestyle/outline.py new file mode 100644 index 0000000..5f56bcc --- /dev/null +++ b/internal/skills/recipes/house-style/housestyle/outline.py @@ -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) diff --git a/internal/skills/recipes/house-style/housestyle/pptx_kit.py b/internal/skills/recipes/house-style/housestyle/pptx_kit.py new file mode 100644 index 0000000..62853fb --- /dev/null +++ b/internal/skills/recipes/house-style/housestyle/pptx_kit.py @@ -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) diff --git a/internal/skills/recipes/house-style/housestyle/themes.py b/internal/skills/recipes/house-style/housestyle/themes.py new file mode 100644 index 0000000..522fa52 --- /dev/null +++ b/internal/skills/recipes/house-style/housestyle/themes.py @@ -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) diff --git a/internal/skills/recipes/house-style/housestyle/xlsx_kit.py b/internal/skills/recipes/house-style/housestyle/xlsx_kit.py new file mode 100644 index 0000000..5eff046 --- /dev/null +++ b/internal/skills/recipes/house-style/housestyle/xlsx_kit.py @@ -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) diff --git a/internal/skills/recipes/house-style/tests/__init__.py b/internal/skills/recipes/house-style/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/internal/skills/recipes/house-style/tests/__pycache__/__init__.cpython-314.pyc b/internal/skills/recipes/house-style/tests/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..7446ef8 Binary files /dev/null and b/internal/skills/recipes/house-style/tests/__pycache__/__init__.cpython-314.pyc differ diff --git a/internal/skills/recipes/house-style/tests/__pycache__/test_housestyle.cpython-314.pyc b/internal/skills/recipes/house-style/tests/__pycache__/test_housestyle.cpython-314.pyc new file mode 100644 index 0000000..abdedfb Binary files /dev/null and b/internal/skills/recipes/house-style/tests/__pycache__/test_housestyle.cpython-314.pyc differ diff --git a/internal/skills/recipes/house-style/tests/test_housestyle.py b/internal/skills/recipes/house-style/tests/test_housestyle.py new file mode 100644 index 0000000..07624b3 --- /dev/null +++ b/internal/skills/recipes/house-style/tests/test_housestyle.py @@ -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() diff --git a/internal/skills/recipes/pptx-author/SKILL.md b/internal/skills/recipes/pptx-author/SKILL.md index f9aa089..7593279 100644 --- a/internal/skills/recipes/pptx-author/SKILL.md +++ b/internal/skills/recipes/pptx-author/SKILL.md @@ -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 条,不把纪要全文贴进幻灯片。标题页、分节页、要点页、对比页、表或图,按叙事需要取用,不堆版式。 diff --git a/internal/skills/recipes/xlsx-author/SKILL.md b/internal/skills/recipes/xlsx-author/SKILL.md index 7535269..e6ac495 100644 --- a/internal/skills/recipes/xlsx-author/SKILL.md +++ b/internal/skills/recipes/xlsx-author/SKILL.md @@ -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 再登记。修订用新文件名。 + +数值列右对齐。不要把说明文字塞进数据表的空行里。 diff --git a/internal/skills/skills.go b/internal/skills/skills.go index 5fd4894..001025b 100644 --- a/internal/skills/skills.go +++ b/internal/skills/skills.go @@ -1,53 +1,129 @@ -// Package skills 内置技能库:go:embed 三套交付物生成配方(docx/xlsx/pptx), +// Package skills 内置技能库:go:embed 交付物配方与 house-style 设计系统, // 应用启动时种子到 /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) +} diff --git a/internal/skills/skills_test.go b/internal/skills/skills_test.go index ee7d0e3..0e389ff 100644 --- a/internal/skills/skills_test.go +++ b/internal/skills/skills_test.go @@ -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) } } diff --git a/internal/skills/testdata/legacy-docx-author.md b/internal/skills/testdata/legacy-docx-author.md new file mode 100644 index 0000000..3d435f3 --- /dev/null +++ b/internal/skills/testdata/legacy-docx-author.md @@ -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 传相对路径);修订生成新文件名而不是覆盖。 diff --git a/internal/tools/exec_interpreter.go b/internal/tools/exec_interpreter.go index ed0f98e..c79e3df 100644 --- a/internal/tools/exec_interpreter.go +++ b/internal/tools/exec_interpreter.go @@ -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 由组合根在启动时注入(/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 保留,中部标记省略。 diff --git a/internal/tools/exec_interpreter_test.go b/internal/tools/exec_interpreter_test.go index 5bde205..5f0946b 100644 --- a/internal/tools/exec_interpreter_test.go +++ b/internal/tools/exec_interpreter_test.go @@ -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)