From 6533f0fa887425e761ccd3df814eaedc1bcabc46 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 1 Oct 2026 08:16:58 +0000 Subject: [PATCH 1/3] Add professional standards for every agent deliverable Why: Generated files kept Word's default theme, US Letter pages, hand-typed bullets and empty sections. Recipes only showed library calls, the prompt had no craft standard, and seeded skills could not be upgraded once written. Evidence: go test ./... passed. The house-style suite covers three themes across docx, pptx and xlsx, and rejects a document that repeats the diagnosed layout failures. lint on the sample minutes exits 1. Impact: Every run gets a resident quality standard. Office files use house-style (switchable themes, templates, imitation) and must pass lint. Skill seeding upgrades untouched builtins and keeps user edits. Uploads land in inputs/, and the default style is a setting. Refs: feature/agent-professional-deliverables Co-authored-by: Cursor --- internal/app/app.go | 16 +- .../prompts/library/agent-squad-reviewer.md | 4 +- .../prompts/library/agent-squad-writer.md | 3 +- internal/prompts/library/agent-sub-verify.md | 3 +- internal/prompts/library/agent-sub-writer.md | 3 +- internal/prompts/library/base-deliverables.md | 40 +- internal/prompts/library/base-quality.md | 43 ++ internal/prompts/library/base-style.md | 15 +- internal/prompts/library/env-runtime.md | 8 +- internal/prompts/library/task-action-items.md | 3 +- internal/prompts/library/task-agenda.md | 3 +- internal/prompts/library/task-compare.md | 3 +- internal/prompts/library/task-doc-minutes.md | 6 +- internal/prompts/library/task-report.md | 4 +- .../prompts/library/tool-write-artifact.md | 4 +- internal/prompts/prompts_test.go | 39 +- internal/prompts/service.go | 31 +- internal/runmgr/inputs.go | 93 ++++ internal/runmgr/inputs_test.go | 39 ++ internal/runmgr/runmgr.go | 8 +- internal/runtime/llm.go | 31 +- internal/server/auth_e2e_test.go | 6 +- internal/server/server.go | 6 +- internal/settings/settings.go | 10 +- internal/settings/settings_test.go | 25 + internal/skills/recipes/docx-author/SKILL.md | 59 ++- internal/skills/recipes/house-style/SKILL.md | 76 ++++ .../house-style/housestyle/__init__.py | 10 + .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 719 bytes .../__pycache__/docx_kit.cpython-314.pyc | Bin 0 -> 31489 bytes .../__pycache__/extract.cpython-314.pyc | Bin 0 -> 7943 bytes .../__pycache__/lint.cpython-314.pyc | Bin 0 -> 18387 bytes .../__pycache__/outline.cpython-314.pyc | Bin 0 -> 4529 bytes .../__pycache__/pptx_kit.cpython-314.pyc | Bin 0 -> 20007 bytes .../__pycache__/themes.cpython-314.pyc | Bin 0 -> 6262 bytes .../__pycache__/xlsx_kit.cpython-314.pyc | Bin 0 -> 9179 bytes .../house-style/housestyle/docx_kit.py | 428 ++++++++++++++++++ .../recipes/house-style/housestyle/extract.py | 116 +++++ .../recipes/house-style/housestyle/lint.py | 243 ++++++++++ .../recipes/house-style/housestyle/outline.py | 58 +++ .../house-style/housestyle/pptx_kit.py | 257 +++++++++++ .../recipes/house-style/housestyle/themes.py | 166 +++++++ .../house-style/housestyle/xlsx_kit.py | 122 +++++ .../recipes/house-style/tests/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 251 bytes .../test_housestyle.cpython-314.pyc | Bin 0 -> 10340 bytes .../house-style/tests/test_housestyle.py | 124 +++++ internal/skills/recipes/pptx-author/SKILL.md | 43 +- internal/skills/recipes/xlsx-author/SKILL.md | 46 +- internal/skills/skills.go | 141 +++++- internal/skills/skills_test.go | 149 +++++- .../skills/testdata/legacy-docx-author.md | 42 ++ internal/tools/exec_interpreter.go | 19 +- internal/tools/exec_interpreter_test.go | 23 +- 54 files changed, 2380 insertions(+), 188 deletions(-) create mode 100644 internal/prompts/library/base-quality.md create mode 100644 internal/runmgr/inputs.go create mode 100644 internal/runmgr/inputs_test.go create mode 100644 internal/settings/settings_test.go create mode 100644 internal/skills/recipes/house-style/SKILL.md create mode 100644 internal/skills/recipes/house-style/housestyle/__init__.py create mode 100644 internal/skills/recipes/house-style/housestyle/__pycache__/__init__.cpython-314.pyc create mode 100644 internal/skills/recipes/house-style/housestyle/__pycache__/docx_kit.cpython-314.pyc create mode 100644 internal/skills/recipes/house-style/housestyle/__pycache__/extract.cpython-314.pyc create mode 100644 internal/skills/recipes/house-style/housestyle/__pycache__/lint.cpython-314.pyc create mode 100644 internal/skills/recipes/house-style/housestyle/__pycache__/outline.cpython-314.pyc create mode 100644 internal/skills/recipes/house-style/housestyle/__pycache__/pptx_kit.cpython-314.pyc create mode 100644 internal/skills/recipes/house-style/housestyle/__pycache__/themes.cpython-314.pyc create mode 100644 internal/skills/recipes/house-style/housestyle/__pycache__/xlsx_kit.cpython-314.pyc create mode 100644 internal/skills/recipes/house-style/housestyle/docx_kit.py create mode 100644 internal/skills/recipes/house-style/housestyle/extract.py create mode 100644 internal/skills/recipes/house-style/housestyle/lint.py create mode 100644 internal/skills/recipes/house-style/housestyle/outline.py create mode 100644 internal/skills/recipes/house-style/housestyle/pptx_kit.py create mode 100644 internal/skills/recipes/house-style/housestyle/themes.py create mode 100644 internal/skills/recipes/house-style/housestyle/xlsx_kit.py create mode 100644 internal/skills/recipes/house-style/tests/__init__.py create mode 100644 internal/skills/recipes/house-style/tests/__pycache__/__init__.cpython-314.pyc create mode 100644 internal/skills/recipes/house-style/tests/__pycache__/test_housestyle.cpython-314.pyc create mode 100644 internal/skills/recipes/house-style/tests/test_housestyle.py create mode 100644 internal/skills/testdata/legacy-docx-author.md 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 0000000000000000000000000000000000000000..550e20fe2f36f6f70cb8f72b7fd1736c16a51a71 GIT binary patch literal 719 zcmZuv&rcIU7@cjm6jy7ai5D+4@zj`Q6OU+2Oe84>B`KU;mhD%&us@oaDOgVhkw()B zCW=7B1b-F}QZEK6)xW{(UQFyxTTV!bHxJHkYeI~3_~y;L_q}~HvlmatB*e$zxi3?1 z1caLY;_nFcz|kcRiYSFBx`k50h>#4?(14FaBjKb-#bks=l6|yq01cv)I4aO+DtrMY zBKhCEaWs=LbclPquf6(e`@vRweIcH8@&w|97ECzUTlaSA`!83U#ktmt1+V_e2M-%; zX~o-l)G57hy(%_~^ODrvW2HvxY0cZIbl%Rf=he>Fwf$0=Emqj}9xLr~vsQJJZ9HSk z6;}J$THTJr9f~!b9_*D_`F?x%9a}DQvR!}7KCF9OUf%Q?OVWg4U_-f(332 z198k5P^rJXnGkW9UlqdTu;lA7BhOMf0~*a^khOs0 z(hLw{IJRcWX)ui$z}ln)o|gZah@9u;XIKz~!;mP5zfK_`@f{6)Lqk8L%kxoulGA?x DV>20I literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e1ca01c3b112f414dd6930ed421f2049aa9f7409 GIT binary patch literal 31489 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ac473f188a9827ad7ef631aff6a5fb3c3c7896fe GIT binary patch literal 20007 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 bphtaZ28GQ-3vACG<^d%8vKkm2>GuBzq?sx= literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..819e95e95bdf02b3d0f65d504477f1d4c068bb25 GIT binary patch literal 6262 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

?xQwfykoR{3nD51SVhO*^|j!|V+(KxE* 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 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 0000000000000000000000000000000000000000..7446ef8e7ce42e57d128f8cbef56c60167e7124a GIT binary patch literal 251 zcmXv}%S{6@6tszg5K_3M0!|1SH-I7`apn*TSUI~B)`{0%`MpiFw_NCf7HEP(a-{>( z0MC5p@MfN7^gg#&vr8p-oquc!f0ptO{U=W6A`WV)uGNixdRO^O`cpBFZ?;Vx>>j>u z5pAE-1~rp?ctK?IJQAZbkJy<#n>8E{8OHs15?zDHr|5!d5d03=tpdn)sTm5dT)jnX ztdEQ=DN9=)0@;k(_u{u{AQ<8?z%od-R+W#QtLm;Fj~Dm9a~63jhn82Uh_8v(I!{D> E0}&QUng9R* literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..abdedfb30f8c589841a38efef0ae973dc3e04eea GIT binary patch literal 10340 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 azc--Ey{k7J+5N`uw_f_2j?ynAo%>$|@Jjyx literal 0 HcmV?d00001 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) -- 2.54.0 From fd8feb5ab6bc6fbff3e667413a0ef72cf8314ccc Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 1 Oct 2026 08:17:16 +0000 Subject: [PATCH 2/3] Stop tracking Python bytecode from house-style tests Why: The first commit picked up __pycache__ created while running the house-style suite. Bytecode is not part of the skill and should not be seeded or reviewed. Evidence: git rm of the cached pyc files; .gitignore now ignores __pycache__ and *.pyc. Impact: The skill tree contains only source. Seed already skips __pycache__ directories. Refs: feature/agent-professional-deliverables Co-authored-by: Cursor --- .../__pycache__/__init__.cpython-314.pyc | Bin 719 -> 0 bytes .../__pycache__/docx_kit.cpython-314.pyc | Bin 31489 -> 0 bytes .../__pycache__/extract.cpython-314.pyc | Bin 7943 -> 0 bytes .../housestyle/__pycache__/lint.cpython-314.pyc | Bin 18387 -> 0 bytes .../__pycache__/outline.cpython-314.pyc | Bin 4529 -> 0 bytes .../__pycache__/pptx_kit.cpython-314.pyc | Bin 20007 -> 0 bytes .../__pycache__/themes.cpython-314.pyc | Bin 6262 -> 0 bytes .../__pycache__/xlsx_kit.cpython-314.pyc | Bin 9179 -> 0 bytes .../tests/__pycache__/__init__.cpython-314.pyc | Bin 251 -> 0 bytes .../__pycache__/test_housestyle.cpython-314.pyc | Bin 10340 -> 0 bytes 10 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 internal/skills/recipes/house-style/housestyle/__pycache__/__init__.cpython-314.pyc delete mode 100644 internal/skills/recipes/house-style/housestyle/__pycache__/docx_kit.cpython-314.pyc delete mode 100644 internal/skills/recipes/house-style/housestyle/__pycache__/extract.cpython-314.pyc delete mode 100644 internal/skills/recipes/house-style/housestyle/__pycache__/lint.cpython-314.pyc delete mode 100644 internal/skills/recipes/house-style/housestyle/__pycache__/outline.cpython-314.pyc delete mode 100644 internal/skills/recipes/house-style/housestyle/__pycache__/pptx_kit.cpython-314.pyc delete mode 100644 internal/skills/recipes/house-style/housestyle/__pycache__/themes.cpython-314.pyc delete mode 100644 internal/skills/recipes/house-style/housestyle/__pycache__/xlsx_kit.cpython-314.pyc delete mode 100644 internal/skills/recipes/house-style/tests/__pycache__/__init__.cpython-314.pyc delete mode 100644 internal/skills/recipes/house-style/tests/__pycache__/test_housestyle.cpython-314.pyc 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 deleted file mode 100644 index 550e20fe2f36f6f70cb8f72b7fd1736c16a51a71..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 719 zcmZuv&rcIU7@cjm6jy7ai5D+4@zj`Q6OU+2Oe84>B`KU;mhD%&us@oaDOgVhkw()B zCW=7B1b-F}QZEK6)xW{(UQFyxTTV!bHxJHkYeI~3_~y;L_q}~HvlmatB*e$zxi3?1 z1caLY;_nFcz|kcRiYSFBx`k50h>#4?(14FaBjKb-#bks=l6|yq01cv)I4aO+DtrMY zBKhCEaWs=LbclPquf6(e`@vRweIcH8@&w|97ECzUTlaSA`!83U#ktmt1+V_e2M-%; zX~o-l)G57hy(%_~^ODrvW2HvxY0cZIbl%Rf=he>Fwf$0=Emqj}9xLr~vsQJJZ9HSk z6;}J$THTJr9f~!b9_*D_`F?x%9a}DQvR!}7KCF9OUf%Q?OVWg4U_-f(332 z198k5P^rJXnGkW9UlqdTu;lA7BhOMf0~*a^khOs0 z(hLw{IJRcWX)ui$z}ln)o|gZah@9u;XIKz~!;mP5zfK_`@f{6)Lqk8L%kxoulGA?x DV>20I 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 deleted file mode 100644 index e1ca01c3b112f414dd6930ed421f2049aa9f7409..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 31489 zcmeHwdvF}bdFSlz?CxM+co1)pz!Fb_4?ui~Z;1~OAixI%Ey0B-QiMTZ2|@yaiP0iI-WX-n49l1^ak#88|>bnLU}x~r31NzPa=z}>8oQtDz+X5CeV49&2j zy1Lx&>zSPyfCB=OT=^?qz&AZT-90_s{XP5Z-W4vVjYIfx*3X|D-NbSKNH@tKND;&B z#YT=Bp8Z zJ}YAg=HopDxL+*a7vO#g?hEDn#h#)ZF1WIIpP&8 zzSOe}@k$mi_AEzy1&fz>RwBNN#Y;U^h*z_Cndbq-SF?DzXARTc;3JN|L-`RiksuD|&q;HN(u`R#{qe)jE)zkBKVPv8IE z^}+W({m(DoxbzpFy)g{E{>t~S|H<3eUw;v0D-8*ITTf4Kur1it z+Y`X8u&cMd@1(CMSZPdTwCrl#v9DowV{6k6&yL-m9ZmI#RHW@|Xsm5L(6pnbp>a3W z@W`2y-L>65R?3htp6aPIC5$yE6UL@sLh$U~S<~CyE22Jg^9f{1I7MHexBIlOHAuIO z?FM`y{Zk;!FyZ$0GDHSBFL%_q@db|SK-yyNQNsy?$KWw~Odj52#=pg5t>)27!VTaN zmoT#m2QmUd(fy2@y$simuJ&NU($?ONkNVsuJ!|ak@g;VZy`9 z9KOhMyjMKg77S3UiW6^EpXfd5tM2!8wV&|yRQtu=Cw=X~K=qDJ)VS)v*#PQUwaa&U zRex~RabH`oPxP&7W6zNFxGxYu*S2-9>hN`Uo%V@s$GUxi>XW@4eRMt6*8Y^Qr=z;7 zC+HKAt2*#hS9f=yTJ*Jd`SJWjZ(qQ-DiA!|?UUkCw4=BEOzTr!!7BgRgrl`p9ztJh ztC))>yYL@a0Wiqj<`E~ z$Q?-z5!Yf^N8OF!=e@=XDav(J(hwa%2`PAu>*NxC^AW0x1+O`o3Ub7G%}ti%6ZP7o zv||4nwZ3(72EW;3n6KOsd$P8c=44xoxFb$A)oWTOx3q}!@=erZv%(zjkV!X3r7~s} z!R;KS7%QOMUX#*a>C|5>59;=S(tfpnI~}Zl^OEn>ewo({Brtxyh|Lx@Lslts+omQ~ zIz?2%iS*?Oduw~QuT5+{*4uG5k=ouX`dTqv{XQ|+c@;u zWXd-ud~F>*vGsUwFJ^7R*wv8`+JQ%%z2aFhjVc$O@}2Dmc?yV&=+-HhV@(TGTEtXp z2IUl~52Z|e!0&5Mm@u^iG%3mv+-@-k(S)PN*WZck888XF$f{0KsC6xTum3{8XAyd43O)_y(D3P-k zP78}e2gb~kLRGw^;=K)*HVoFp_?!rzGs(LtWy_^4nv_qhj(BOMRP&^;^b`A{X(4O0 zeNu48?XJN+B8}7x+D6lwA(Wf7drsPBtTtjed6B*`!3TY3f?_5`1&j$JRALrxD-B{c zE)tDm-NbO8fns`)4?qOKZgAcr5L$GTE@LKkCpAyOR^&~=KWpCqn1%0NE{U^#lb37K zmO&8;UV{hX%Laui98oY>Z+3fm6~k3bRo{}tkj`p0<5jG!@tX87)ni0DO{Q4^sX7?y zF>R1(4lrM(UY+!=Ilj&aR-=BMd7iH`FYxQCW_{fOw3o(tRzNC^^Q?eWqV%(Z$GkzN zv_#b~i7eo`#M&+!5@v>c&6oKEALttJCHP~#-5m*YJMrcOf4sK`OtO*KUiW2_xEd9U zYY7n9t#l?V60=S?+d4Wx3&gfgvCV%%q;XDIYHAysYdwjSw(hRZ9+CnQw$>#5=|E9` z8`vAzEw!Z8ACyRhgNR61Pba7d9$JYE;aS3bvM=cCkX~yMX)1`z2@pFI%jtCnKNxl2 zu_TdjCy7J~HBG{g8suX*a0wBj5b2{O=e7*)iVOCbkQWj1LZ#u(2_Y{kR1NN!&M1v# ztcYZ+7~Ju~-f25fcx2m4+olEA(5X=KTTfhkVm$Lo^M{9ja`@w5?2)fU9{Jjo&<08q zccsT%MG;p~SQr;3Tt!jWCJ>$6<+0q=k=)fork9=5sd=H2k!N3ecG{6Kx;DJ-y{(tF zjt4&&cz@ty{#Pl#Oqp_c;&#Vi{gyTI!Vp8xJl7!=gL1pABqBC-$hz7}0jGP#~ zMhCYBlAeaBSd%%_8Wd8ok0BG0Fc!SL*8~(%*lsc%bsLWwR%+5$5}A~P2c?>bL(U5K zD}xd`!UGUdB{GDF)*I9-14Z(rce4?4pX>_+-N$_H)hpd=+`XcE?dL+hgcWWPjCj$WT-T2 zaI2Pz5pIb)00J~38s=*!yppRBAHg#=WnM&Nkc(R#qYabRCDX3liM-8G*Om!kiypR( z3FBGg)`{|s4Cyuj=`?s2cR1sYw0L^fg`)FC;jFRrck`ywSKvOLksZq@i)56AePeC! zc1>kGfW%mOaU{L?j?5v4~YjRI;~wUL<1OmgKhrArV06 zz)iZ0tz&l*63HBrsXGe4wP0#!7UH6c6pK!iLraM+ zy67T=$2fC(YxrmZA^&>#jG9r5@NNXgh&FD4$WQQvw_c3y1N3w!_Z!zn0KmzP;$3PC@0ZS zAl(ok)W9w?=P2^1GU30l5~>cU&NIKUio-lLV03*hXS z2O`=DYI6+M{3b1Hh>tr`V~!;e$C6OT*s2M~lBi?jkYR=dvO1jk-r`G($D2Pm^!}l% z%^w~5#i1$3BM^y3Y{RzE)!%a7O5xJ8Z`wFVs-E_;;JGP!=^N)$QCLTiZRYUvBj7)8u@uuL2X&*;a4h*7K8>iNbP-aC?3b-1P>WBPzQC?LK3A~!pu}lm^^xrR%vI17y2eCd}L2en7VyEurf(zTRF|u z+78VU+GuNkS4Z#!ELj1c91kQ?e83bKV`2QYxAu!5!>#SU?ry1(W{L)28D}=*)<7?s zBS>-ueS}26&C;9AQssuOgb6mUg!wedzjE440q9@ox7- z&e}67H?ixM11_E+{vKL?ycHs|$8&{2bi2q`x3m8@C`Xr3#?K|`X<8WPn! zCyC+}5@pelD6d}KD-~-p$5m-tr@UQD#*+0Gam}VJ21OEY+E!EaS9?K8;V zS&96K!(KttPHWOguo2d}4iI5j#m{KT%YK80_uDi?%dAH%XOcoaV(AsMwH_e}g#~X) zvIWgNY>B+Cy^Ytiw|Q+I^Tz(<@>H`8Eya@523c|iA$(dQgh!wi$?sY~+mo%C!A4-V z00{@%pa51fBKPIby!NBUm4t$FILd7VI(5Km3VSBsM4H5`fL5mXotiOnB;Sa7TjhHH z$?KYDjJ~1m&4rDT*RCz2J4P;T4sYsl;NM~_S?{-L#w#`1-j@9s0lO;M?}fBOy5!#T zG6SM@5r?9`u9>sKY>#OL;?Wl4{q*ytDGX#KNu*Nc~84w33~E-t^|z|9tIu|Bts4x7wcMRvlCP zido$19aDJs+m5N+l@mFu{@gUa`^S!{oUN1mLvd$L%(-U5xh7N>+W1cUSjF4jQw3`V z>!z~`L;csXDuz>r4Dls}?<@*?-!6Egb=2~D%6R#Z89XcQNFUiVyl3>(EBj-PqKKm? zY?yKsPdidaE$8-1HYH|5DvxB8Gt*I7B)u$N;12JeELd^AY&`dFs*qv>cgtS(`)tm< zX!OAgo6m0!wSD`cNq*^cMnP!Tm?4(2eAt2pEG-T{`fk?P(s%NQf+OE}=^NvjL;Q5^ z(s0T5T*LK4JMjFS17q25AAYB3wC?qW@#90a(~g{w`7PT;TX^-G&RFh>NbZWU9aFhg z~AN+5ZMHEr44BKsU`M@J2yZ0hO?uTq_s znwi{BwK8>r!h*WNMC>(?nRq|MUI%y+yE9?ES*E}wq&F#Iz0z7Z*c|1Oe6EGoU3BaS za4@i_ltn`==dBU-xzu+hV@Dm}404~zt22U1nd%s2N*HX6IEnuWr6BDRp@QVO0T&4) z8JQuv0zsO@Rq!4zk;VjDy2oO!2%(xSu*|T8jZzH?p&+Yf$5~l{Bq>!|Wn~tJ_O|YB zSSG~RQG!i{KSJbQ2{m*o%%s!GB)*e$`bo*MA>UBTX!B@Y#9lxa-*NFu>6Pq>s_lcj zn813$cHS0R9cqiFxx=O7>tbtbB5P|tUO&Zqruod#gBKn@|9IFCULDOU3kTnO`qI-^ zGN$;gaeJDc*!dOF!4l~06WiyPBVWb$EBN3f+b#gDrQ#B9XLt9(2byJb$d7a21GAF> zizq<-R1{8+B1>XUTcqb&sIEHVxklnk=20av;D@rNs@m}sNe{MF*o#w>M#Yw_5cxNwx*OMHcWXx)kh8_-^VkCWgj7;B{j*_ z+OWWE@0GB`RvuPpZJ$`3aaZa{*Kk*;Je-Z!%G*A)!S#@f97i2)P~(3XH7IU$C@6GG(A4)I6Tegg_gc`_~PM7zH-)@ zF20Ec65Sb#Br;6gyGcfJ>9GW9C@7F~6s-J)MmVFnXxzyCruCTUYkTUlf%P}@`zV+E zKHfzMk-*#d4-oIc${cT%`sf;;d#}pQBM)ngn`NF;W!H)l3EtlpJRym(7SRVcx1M>G z@b^%?LOe}|v|Aj&Xw6N!glgEG)HqqOL3Y+Ksd2PWsZTtCB>Kw?ib{(dh7wME2UWoH z5c4-EcKKU7+x)8cLI9o+ZKr*eMri_%A!~rzQ7(7u4-pp$Xug65x@3|sMEAZ}|6Ki0 z^9zk}d+Olcd%IW}?**>q!1EBu!Y|0S@h8X{SOg&X_Y{yvPeHhpUDTG~ISaBq)qFe7 zgfsM9@`>i!q#Xe{-LGpbNaGF~`>^QKXRLE`+^J$cW~ACuRUZISOFJ>?7IS+GA($B( zjeCnFHO+7G!-WURC#Znm=rQWCFOMmQmUa}zr8eEeYu=`~7p_z@C<{z!ToS(&ron?* zDy&W|lFU~jCrSRNdp*?drgSx5GM3EmH+#*#C5&n5x;AQBs+v8}FLM3QqkcmZ4eYF- zI#a@^@NKErJeCrSK@tA_#x977eO?s$bSQlNTf^`dzW(OBpMLY3H!l47?>>0*v+um{ z+2y~w{-=LI-omfHc>RqZ{Nvzrs*~{WE68x;`L}<2W$?FG{#eQhFXGRBdNJu@JYeeI z>=(a@`q=_|vT+zpZY??M?n9&)L~D?1=IXExekxsWifOD#Y#I=eJHri{@kziD(%U^i z*#}Ks2B0Lp4AhV5FF;^p)dLB>r? z$2#81yO{S@QK%?w>HbbNh)d_+;4>J{y2%GBO7erXE1z{gO>tBKtqUy-0wOgFi-O zkoyO_Yh=stmYBT&5;%nJvdXd2cOH+X@457NsP=%GyOmS|cFa@o_U z{*i}Xdg$*QnQ_;mk(0wG&-KEi((Za`TPW~W|Hb|v7LPstgR04*jg&)hjM#_mqqWzB z{M)Hq#^QhdXA_sP*AO6y?wx{~3jPML z2lSLEr@>HfxW(XTP`chrJZX;O-*CI06@t(5g2|`Od^c=DEwD)$MY0I8Nsc(kAtx3Q z_%8rj&c1~b39xoAMFgDP?hYS_m2HfaZH$#|kCbhXmDNp@)m_~gvG1PXcdMU}lrO$q zchUgO+npaGlfsD1hKgGp!03bQqBaDXSl|I!UrKZJEA=GjH_TC<-ZCrbjRaT@wh}N~ zDWgUn)Tr7GS~8OIIC%ghEb!Nde|{s(g>c#GJqdFm8?+L6C{fv?hx zo)u>B)io*;0#g1ZvNnP|e}F5a@ZuGU{e%D!c=6`|@`y{%*{IGKa7Y52NPu-6;V~QV z85BA_YqaC_e2KDd8s7AVF|_%+u50#km@h~64(|<_uQ>`~9L!j(c5!lic~r={*v)F5 zQ;KH}ZkJ^@QBprdhTJ4@ia#PSAJzPy$fStzR>MZJ0&WaFNH)QZ;bwLxwT?(lGAOO< zKzrw!01*1#qG8pzhZ>rwR}dUlpzgxR?w88$;iTtGK90Uf~E`&G7B@x z5LV9$nlg&5aYmWUSp{&bDWgow8D(7sb)#=Y0&HGFXvXx4 zFUr2K^|h@-*6AfhVLs+w6LGJJE?GNdofPt?3*F(&*wS^8rR$=F4^mQrUM_cWcwMYy zW29tb)V+yvEzv7jv@~2AD_$QdULP&mFl4(DN+{Bd{ zGq{SKwa1l=A}MqcgZmd-C-o6`S#e?&|Ti`p&eq2Wf{7@!N#8_OTr`&HD}|(%Hj+zpb72`-s^1DiWhi zIJD_<6({?;gI(}1OxP*EueI$sZ83C6H^+SN@ba+@4B|udZo+$UKY>O7d@#`kiFH_d z*k-1L(SKapUucy|2e3j;bq2&*%3BQ(pw=YawPYSsMh^)vn%l)h7>MK}j}Jc{5~iF> zu{;eAEX!Ld7gMmaXWON1`?tGzfIy*O%{;ChrN8szrmc9F@97VDxSS)8*Bxl)J>5r-}S6^B8;X^-p=;P8~ zt@!1Ni58fok4#r$<@pC$gS9a}Kf>ov^5t$yDRlA1FF`+Ob6ix~&rdL&uTdth? zi?dS??1-+~nS63hSbjT&%P6>)mC_6a=`-9Wibd?}KD)xT9%j-~1QT}2sK#v9lCfYx zV(&kpOn^uWv0JBMAJ{Fya4jwV?K6y*NY^VcywbhYZiPgn9L+1%?$;m~&&77rxLQNq zdXP$5LedJ*C`>_*_0Dv50%M>aVnLVr@;!u*ENB+jfWpj0ND(HJc|Z!4#YQX^Dx|9A zQQbu|gC5g8nu2ZG2EFA#mBHvQnJwUZE%SV@Mf1HDU7NA`eOAr)o%+Lm-;umzk9sX& zAoAEk)X{GfpJW++LDQz^{;+MTHSC2AiWyspMObc76YKhTz|Vg8L-&Bi-LzwOt(zsd zaco2*HjV!Z4<%Qks}!TW3A3kRcYU+CnG)V0@FoGWgG>BjJ7RQ>0^1>OYo#@;<6XY) z4lRvlbXoj+D)SV9W&;00U=#oxsPFjkt}`-q)_T*5ODPiphN7P!B5MHOh6WH%&%IE1 zzA%=)Jd(bAZ1q(7sv*mC!Kzrn+DO6LAq)0|e_~IIXXIX}I$sseie^;6Q`Rwao$u6@ zva6XN<^Ljo(y?#;>f&kH7hLCEq0`Yc=9dI-T{0+#cfI$>rANjey(nrhRs=QbkZho)zQtNozm8`4$N48(sV#40rd0$<< zz;-|4_z8Pg5A4u=Og;Rf)bQU>fno%(8**s1DJLADP31Xb&^&OtSMeaa!zkbt#z7$r z;yOFftf8P)IXsG(%;=&*zoeW5Rb!pH#snt`1o=no#IN8Pfc;ctW?_P4!q_T`M{tK->nX|-i1;}@XO!XpQW-OQi}?Q#mli}A zU6AancPT3YHdVii2&jT>QOs5lu@yvZOW+^?mppsaQ94)~=Tly6c&_2)W24x)y)?XG zlH3(@Le8k8T>G%#TyJP#l3yNAU;M(JX^ zq`K$oUfloO{?W2YKKFk2CD-`ud!-yQM=@M^fVF&1DTj_DfO8t0tR1eYPU5peBVx81eCgT#LI2 ziL*nGLN-*7C>162bxhS#*wUn1CowV>jwtA)r>iZ67hw|l^xvdsp-^|Yq73O=vqBSf z^{k)}LJLF$#ZsYWQUwZ?JSJNPP1|}Lwr2Ql6(mcjx$xA|gr~EES6PMzfv`3e&3+Ct zs|Ya~4>S##owt-ixFA$2#ChIQYL6?Y4Kcy#{=K3QE~QrluW(PVD7pN?9KE90aPUrH zo?cOGBOa^f`{pa9e4j^9OVRpwWeP1ai*<0v3N(Ih?cdxMXz%Kx$cY_2fv(SO{hJBJ zcL2o)Ed86i+j=@bH}`MGzM}!NuV>Z40|VxP6RT<(;Vn+nN&F=NQba_U%4DRc>7KA3 z7h$`lW$B(yMiAg2-g%6zcO+a`rf%&Onb;;a(fJ@)#+FDmp)MoSB2ih0bJdi<13yJG z0ou*3(iha}7qQq)-evH&q1GnlImsPNqTJW14g!q62&e*-JGR%UBkDH?f>!rsrJ~P5+68W{Ij;tD96|{df78- zJ$E=%LC$PSiX-Y=0_S(W;>)uMiAhK-LhxXp1Y;8p$;eYq9nKVEHY=<&XjuQ^q*Zjr z9x~~cO`(Oehho$d*p7mvF&hm#pfM8+L1U)tn0&pGDcyhEE?p-M9g!n#KB>e^NsNJ-T;D7?s$aErQ8O0o^sYSWmG|YDT622{Sld06{h*ZKX;Gu%0PK1QRJ2}RkUs(7k5?h7C73L5RCFyJt(xWxg`u@|_E zf&Jq$-p4Licd^UFqs`Lw&=V~0eS`rQSOtWE?LFu+DX3#QgYoN(t;+b1C`%D~ayA&d z!V18ob!yTR2YP6bS!i+VmH7|GFZGV9b$xZl&lsawOu>X}6m5iub^;Cn@Hs}f7>i?= z$gq=GoamxV3`J8Z_DiHCgw~|nRvM+H6JRim+uzt}C_0yTzqy4Y$9jqb3|HA(1yrr?cRlm+#HRg-vt&>t;Y<#Y9 zlFyE3=1F@_T6zbq}MzrR;O&)J_=N(d_co*kGBF z=TQ{@0yfz7)Wb{JEAMiJWOV=tfvVs#4mN@d*qdz^ZvJuf}Pm=BD)IH>p=m>lDH{!<-?^Q%cb`v{El5 z?EAk<*`MpNseL17eD%qQw~~iT zS{c3Bxc&h|yD67(BSp)RDpmp@PKCwg6n7-}R(u2;i0P9=B8Aw^(*sjnP|uN#DRbn* zjFS~ez-D|ArvEb(FDF368_h+P766M_pBhDQZd}rVajs8iP z;L?*YK{dr`N8KF(!V~H2E!t>|AaTo6R6PMk>t9Ff$!wQ8vV3^C#Ow|YAJQmmhnU4Y zsYtCJJx-1~@S;2Z`eLmY-PUOCvTOEbaYqK7s1Yi==5S+sZARhmEgZD5s;H}SaQAQ0 z>fu7^%nhvxb%ea30%(Op#<*Y`u?$b;E3^!d|TK34(!%x4lCe#xxSQSmJ8my07Q-5tO zhzqU}=dg411ib+ICs`7q73P!Ku_tOP!%0tBE!YfwpB9&$JB#fpLZRB4W#^s=9mE;8 z$^KgNEf>yt%6`mn)5h7;M^+54fI?gt&0ZF@Rid}8w&$NswgaEV^k8a-zQvLl9g_(b zBSH*TZ(WwoD_fX@X$yuvu!vQ4m&-9MzeRwywcs+7yCaW!RQNtLfez94vd!xB2walW zTS{f_qY4d`auU>AN=VWNk`H8BW7OMAtSTd*&!kbappg_zI3Eo=lR=|J;`=HORTue= zDEv?5e1 zlMJ?F2ad#@5*Fsm8W5|g2A*0fZlPA3rIN;Af5HT2EWX0(>FPKmzDhB3upKN6r)#&j z_RD-pVo4gl6hKCHoCl*z#GJsFGV=y&U)Zm*C&l3R^sA3V?WN4%_iH%kYwUc}_UzH8 zqxKR!bGQa;f0LdA-x_DyYl~j^Ge-tq z9)O)GuQdnt zMF1|r0MrqGCW>N^Dm!v&1)75;-Ri`j$io+~#*b93k z!I1AJyN;Qq`+J#-!r6w{u!At+#k0dKFC5yNK;>qI3i-YRA`Tr|p=UEv^0dl%NM4&2 zlsx<8JUZ{v_5RU)qh3z!xyWdzP8;ScRYLnLl*Kgl1CXlYY(L|Dx^BMO93V^v@%N|` zCt~~x7coNM?+E+`AYp7jDNfK`oIrxWG=Q`PC+Rv47A&39!)C^dxC2hR(nq?7yF;sA z>4i_6)up)Vg*sz}tMNBcxO%)Zwtf%(Cf4tXr)I}emqt>T#!^cosioomSjFZ@#pYPW zLy?MyrcxieYK?7gj%;s^ZEuNeZ<$JMnGjmAM>u9*60t8K*Yvd!_u47@+VKw57g@h& z%D!iUmwqllvQ?d=Hj>?wN%=E&#?cZlLmc8(_d^`EK&;ySjUKzlQB6zvPRahioT|?x zprX|px_y+uS_0_=)V3_#EPRHLt{$an5x{D|?qq-vHhtp~hUEsA@gTc=yoX&Te7)>? zljF7NfZ`$oS%-R`g+tHlNrFI68ogNosoArVaC>N-X8)U$ zhzGP{qaHz17eu^fh1_7y!3L(R0=H<~Vmvt7oc`fn^i8~o%~$b{DE2%6xsI`Uc9l|S z*b@A{+PY?O6W!k-u$jOc1enQ`X0_^Gj9(52oWLQGSYsrfE!D-QE1RiolB)A_2W4pn zNUkrkRTx>t?4ySS*xWpi2+%$)V{q3C4YWfwbvQL-m=f{_cf=RvO_HnFnn=N#@%qV! zn_>^QL>_LLIP|s2W5L+5fyl9eiHxT)QNM23X~ZWisxi{f2FyH#f_DzJU)XehQ>f#u z-iy6s!5=+y`I#%tv6_PuH3y|Lq9=LpCssTC#z2rXLlY-B{yk>c4U z7~FIiw0kr_$CtZ9@a)3nUJglW6hlGs=ps_2H=*=?EQAJ`!UB{rMWj~*Z75^1piLxz z1&xz~3Da5;z?08ssTd|b?<$P~sCb6X@YtNMl%g(ay~bc72D;4-ns+H8IS%KMxjD>H z1|XD`r0l?njz-DcWM;qBBQ8Z(CwP*zY3ZL%M#Zw^Lr4@KCZNjQL}^sv8oN~)0HuM+@*6wiKB|ELn+^SV1{9Gby_lmk;wY6oqa?cwWIyYxhoiQolF8s{l2t3B zu40l^B?!h2N%f!G8^W*XELBs^p4%T<&z8KCo>XVf?F*G&BikX3@B?W-cI(AWCj(4)4%=0naeyMq`IYmP=S4mp5%J0xz)FlJegrN0mge0Xm z!Q4X&P@vMWs@9-f~*Y-uHRN}Q=q!N)^V)BX)jk(R@bAcpO&uiyf0 zNo3j*%z01E+|oQu`3bO2-HHg!n$(fq!@FaSl8B>3I;<8N(AT5(GH5`GhXeUMl(Q+3 z^5VYd_Kjxh`oz&hW6|Sqi7<=~lBrazBd~Ca_!>=-rR`_4t5o+VVTw%&79)j(VQuUFr4-wUY6DZZ9DIR#6auQXY0Ud}Weq}0c40}= z>mjA3GGR_(h}AAH2sDyCpZ2yg3={0GqP!AfijQaZtyT!%x1E5!xTkTOM_-V2aN3&rWA737w2) ztc26NOt)Lenhtb56Scc%qwBIszC=Y}%elwjSQKjhZr+U4lygsnYUuaU6hx<#T1M&Z zw6;CE?kk;0HNzypdWj+V!cqJC)QkD7pXe}ppfRh9n9N&Wt;LBY@&Ldtxl6l|X%q|8@JAIIh^rH^CtQla?;I8O@Ln~mfj|IQ9k zJWQBDOL8jmC5)@rFa%5(HZt@ho@Z?J&+uyW&%yD`lA9OJEF5@7%Ulfi2o>cYMuLS< zTDJ-#O(ejk)DR*-J$vTJ*5R!&dwIlO9JrDJ9c$xA&FrK182H^?Ycxh7$4rC(QUZN=}lwI1)I zA27u)i4rwW@bpWg(ho+uC|X75=2y|7L`jZiUyiVZz z1l}d^Ljpe_u!X<|0$`8Qu>ovfr?iLYE~Q;3K(dRZg`T2VfB>@=G2M|#$HSDy2yU2S zSp-NgX44@dY?Xf0^sUdC0xZ4fX+t%#H$tKfg`bYnfEPI~vN~fKP!FW2eOD4{`v!(Cb(tAD1ff0e{bLV|2z`PJ@j%NN>a!m~W*pg(fQ}+N zb%5$o2#PYCR~%6G&;m{J$^)t%wV)<>`T*0T5i}&PI-u>*3A!?5!adXn^n%{b6d=2% z0C|i;hFuGJ9m!|f^^iA^e3m@}@|h%Ww5uzS1%1n)MlLl%C8!Me2zoO*w8f3um1g9o zYO(^mLCHJszOgd(%G&sMSKmCdI``w%@4dG&_2$ad!krtlci+8W;aJ}1d69Jp!|Vua zx%am)cY3`(!6~?XULK0H%_$5+s_FC%1V&h|0C~l? zy$~C0zzr%0>EQtiq~m`a@VF_V_?ZPV4yV6cKDJsxa z)jb7}Nwgm%NlISeBsIqh0nRJYF86?N6@f7IppP4I3OtrjE?sIG_KmPjCs_BuFzapd zbG~EjfWSBH83KJ8zCOka>_|f=`(nchp<$490x!0~N!oyPkmY&!IH#w<#d_Q?vYfNu z!}3idK34#z{my~otk>1#_6jTqR89PGx5vXbaqNKG5ADOg0M9n?!kCAZ^KzCQ6*%XB z(C8nN3=T(Z3|WVR%La|{KVJ&bIC_9GDG&82iKi>pNgCWNmp=mvLvF<};k-!4i8em&&Q{p93llf59 z=<*GWo}?T7u0e_Jck-;HhAD$7f)(qBX&rUL`e2VpG^}1Wa0IxmTzvoT-Xk6aja2r_;VyB z)bUuSN(%=^Ad;9E5r-#*EZ&Qd8Ar>y%n9N9wW7`x)|o_|HLSBnbQRqF_tZkQ3T;Y@MNv zx^+Zrb?Z8mkvrWJ9GurJHjB+&q2{ioy3n@nP`5i&c`T$q9-@zPIiTNHKY}<7bmuZ5 zs-d_nNaS;d3}O5O**denT0XX;W0Qu*^<_4T*guK$Ote;7+XoaZ6i%+_yX1tXfSG~gaplg z@)g#2b9#<8ylqp$QAGfRhe%Q%#wMuer0T>D&c>b8K+^5=3j9gki5;wy7xwUO zr$i4zm1G!Zoi4X`$U#a>zt1)1@CzrkJ#w{WtE6@g41ldFW%$7^b)0a!gki_Ph?Gf+ z!>oH~Sit3s5hpj~_BsTg9~X_WV!uxid?Tbotj5C*k`7t1G8f}elh4sad19sVfb1Ty z{6R21==Qoej82krh!waxoTGX7Nmf$$1xYpF^Y}PP$qn^$_<)zRU84i6AKwZj4G+dP zz8*-r4ecd;lIoI*^98&vNdY?Ru3jui~D%5tej6SGXff&s1k?0}^9 zlHmuH{G6o10}Edh8)u1$tAv5P(jo^LzOhZl;fTQZGqCTm9g{QN^8H3ITyhG7)uHT) z@y=+5aq63s-wbMQXIRI(qdMbM<7A_#GlzBNOZ~G27mr7DwZzbQ<#g1TJEJ+Lxs(%p z?qX@gSUsVN>Ww$`C9`=~isp)bzHQzUscRRl9XG8VQIq9z+l98D*yjzAEnkb6{%)dk zI)Cy|w4!QW{nHL8l!gsp17)0f={qlpOac5tOhL5Ra=Gq8omgBOF0K`e8^Xm66ZFIj zlUeHul`12elQ&a)t~U6Ow{vPHn5aHys$;T4)SKZK(wk?iuGG)fi{&lh@)ohY6@H=e z)@8G0w%|(1T#0CH4qKZ=>-Mm9d&Jxd@(U*qud7j3&Xi};6Wn^%H@-j0WQt4?{6b7o za7ZlO3cpb4)@93<*&(sAHC)*$ReB2yA#N|r0C=L^J|UE!KtV$Gg#&7KdoMk=~6%d#*w z8$rop_=T9_rx0!u8B>TcEtgl#Gh%f|xVl5E-W{&q{Xur5d>`gn8rGCW^;zQw|9any z40-nvrOJ>rhIJ*R56R8#H`^Kys?l$)TOj_3sfYOYYW=}Hk;*(j=obcAc%0- zgsH+LnJSsQ$L&foE3)Kr!mc1_&(*_Maxko@CYMt~BI%R5NrvP;+#XE37Za&TJrC)y z6Z~OP=CG4@2;eYq406s97VthP?{{*}AABNlRa>~KZE0)7x-*Vu7(|p}b!)h~Rjh6g zS7RV72Ox}rNcN_g;`7B|666)n^qudU=!)hRin&$r3*}aQY%GkL%SCfT*xVqRTW_0N zr?pYj=F7V-?V8>fEhwHDJvTaYYUb4Rsc2R0mC?D;E2rj81?gb#1w*uH+rsG8(S=i2 zamCSaVSSwb<(ZeKU!Jd6sJ>dg(0H{`tlb%|-FdV2z`8cmV@{VF?yFs}>8YZWUC`cP{i??OFVWSikF5{jO+P#btd^e?JeE z)ZSO3(zg2y$}j&^iE>KTEy!TRV5vox8UV{8>&GP)0LvolLmr=cA8oGRU#9t8nSOtR z{&)2h#D8mm>;tI@fGorS3DNYxI!=NvQu-o5;x!`!B(VxH4>=6V-pZJh8v84Ww^A-M zO$tA?L7VQjZ%y*gwo?u&pa>`(8y;8%9377V(jgc^@Re%72P+@AR6rR}fR70NT0mbM z$`JTy({f3>L3y6iIJ@rZcoFrhTnIiXGJw2n44tWS2nI?z-Lv8nT`4j&l5TGjUYZ0~{Jpo{hK3>gQt4}dd7gfjYXSo98HjcqzE=&&VM&88 zgcw#NB!e4%bl||vg#z0lBHS+hZ1sn~ym#puJcr%=!F)nHaL}*59+R@N_+KjvOKbmq z?#_)HAhAXZSKP#aNVLz-dLPT+YMCYHfasg$3u9|oKn|=|{b^A0u{ldAd2SuRldNDxHRO@8xbhoHC-PD_+ zrRAX3x564zw7>?r-bn+w$vviL;@rNJ+ zXr-u+QP&D8UqOW{sOXQ#^hab~rFq_udsU$WiIbtP~q~Giw!Il9e{={qcVL zTwTdFB1ya6TkD-=pL6cL=bU>!_St9e-`-bCvN8=E!qxm=9Gbg;Lykdu9yE34hOL^rERY1j3bwJHhO+bTGwL`mI7tpaX>K*#+8G#Iz*X%HC zHwKI>uiatVo*BqwdEJhz?dE`aE?33zb9)p43sM`>Y+hf$@fihNw8e{1$mi$rIk?VETo>?GT;;K{g?v8p1uQ>{FGPM8%NOxQ z$QQGGF<*lGY?d!MFq@yV_EC;=WpP{ycVJE_cObu%TbY=xm6#(6T?$@V#+8=^SL5Qw zJFnfi5Skc#{?_pyP5z+&5B<+fo*A^c1;H!WCWn81>%Et6j-0sN-#>BuotxpW-8lb_ zZ8^%6E8@o1-d=B@HQ@2~`WtYqwzmelkm_r^?ZF;*Zvc7aW4(woyS=S0=Mk@PxXtT5 zj2kAq;P#^gtK3e#O7x7Q^;F^VBO5aPoP%pouKha3gL@_1ZUuUdzVj+xy$mBbtGTU2 zvAEiYH^)`}fDqRT?m$rJ?K5vsE>^>|%DO50l754(F^%RIdSw}4X1{D(c=-Tq~QyWQi%^Db}D@2>C% zj&-}Gyp-)m`AXlhxXI~E%&Xh!6tdA>EB^g+5cPAPa(YFR;x3Daw%uWAXs2}j5lV1u zE-6DnkAF6o|A|aL*DTL5W>uyHJI&vWVAssJflHZ!dGe33v+^*ZPAU9iF3cO}F=mTZm`SYB}i*i7dS(k!5evIz4`W&|R(+Xkc-b z`)I&VQ@zk8Sm=h-0FkXQ8`o@!A48^}yKcxD2%K0PGZaS*#W6!^#84VFl=W{L*BJ&n zzy8#9b79DO;%QMo=ZkM#H{}ei49yDf8r^qE72C{48kawF*VfU5;X+U`mOCDNXeSfPR-1OyjKF73TMxBEZ6_k15$t(0Zs4-HK)%Ok$#W;CJ`Jz%X&oB( zHa1?(7t}Y)_2ks2bLiZVUaO-HNfi&mzWPB0m?OKSjWgwILM z5WmL4)$QOopT77$v*plFo2`sy;zukrX4q-Z&Iod!e1=9tvrT?TJ4fQj^~g3-PjY#E zqu#{v)uZld;>l00X&%-zpD%D^V#IO-NT;59I0XwG8MF$n zT2?RBCb&p~HfEunBD^`l4d-&ZI-2Eya(0phX9TQbvBSJFAtT7Wbmb;nEJ2I29cF&c zR2rJn{%S`Czn1k1a{_7Mi{3W(bKhpe+e+{MHmt_l83Vv2=)VT^pPiy>NA~B_wZkHh zG(pkxIL?ut)|cT)g4X$Y4x)4A{rgv6CsBQ^qW+Nt{XZ!8w<$-SWvpbG{UP~&D&^{a;D7c##DJZhc#T_NVc9F8BVAYj$i1=VXKqk9}i%T7p3^e7q(q~Z`-a# zGuAqNuNJ4Nnd-+9ekos(>IeA4W_d)Z6l?3k+U7cPKWC4oeRkhHYIRuc;P5j8U&$-v z6|!T8%?vVO^Xq6nJ8`aJ&sq6pB;h8gs3}1!RhZ%B4lCQe$&;+UL!yZE5(dV(8RI0$ z0yH!;*tgHT!*TYV_ulj6@zsvJbd_;trx7Q(}2rdXJhRy`y*Q#Yn?S;X=-?w-%?W>*S7k6 z&|igO6gsODXbyx06fLBP7)V^plpDXK+hoW~>+^K?h3F}!wkO*DV&HT^a= zyPc?quxfMXcVD9LFPl17Q257(Hh*UDwffvbh2PWX7U;YTlJs};o%0hH!=&bg-n4C5 z`NxX`eX7dpjv%d)?VpBiL69+LYw!8v#lg6?fv|$^KFyK!f?ep-BdPQD2K;@7BkSF* z{=gQ$r}eXpHm~bgMF;K&t5J3GCqI~c@%Uu;+{DF`lRteG5X!ALesS~NH*TE&3GPq) zWcbFrFW{web-d;?W1!33f5X*EsL66OZHd(q5o^; z2b%qhzqV3;zoI2rpp@y>QT`f>}hig!Nq7tYC)QT+sB`qI5{vm zcw*w4FWq?On>R1MIC1jaJG*wtGX6g!h&iZTDsNwDtx8uCxWayMg79 z2s&|QM4Df;i$*60UqDB1zWj#NT#y=@3?IiV$y0s*IgBd|?MWO2^+>jXRHpcdM2C6c zWBptsSBH*0mTkwKnc*ncuYvpuiX!=y=gPQMDv#osY)a;vMFT~Aa$SurIJ601nupLqK%JXyZ9`pL;N1ATfIxOqj7=P2VQ zLE6X@L(knf-+%MXN5r?FCk;WS!fRgiT+AELfQ)wb%C7iTOO_r5+}U<>d!ti`oQmHQe?CD>d$U{{kzgO zL;=8-XB9#zN|&qSnnq8c+ufJ3-QDT}6l7bT%2ubcD>f2g2sqEU&oZ`Rr`sB_xa?*; zgY~l$U5;lZv)!J~u0Z)hfmSJQZubgqC!j*`p@7HjkL&!wHbNNboN8x)X&k$=)9pOs zaRIqx18{b^sg1a*wap(_w)X&YB8(7~-QD05y@9y8!vnrdygjb!bfd9)_pw^SY2#|A z(AkFP;70K*XH9M0mc5TOIvcmw?y8NeX}iEcT;1E+<3>|m(kwZ&}cps4L#boTc8Q2_X9`(dXy7ywzL+99u}7ae!E z3Mivxvx5N$Zk+w?ZlQo)1SHmrm&Wx@d0eWaJ>BJn38W05nm}q2=1iKZxVD$E#4d%-YTrN5G#89Qts4xV~8Q4r*!+IJw zu)#ao*bP!Qo#>meoZdlP-cJm-!p#Sg_6*G3lC#unF$i|a4vL@bZ=@Ay1N?i)XfTY#^x-?pEzgvbz|1Rk(jwOVlIuD=SR%*!-1>jB~jzj5&xL6`mOf! z55(5*imcxiTW^o7w~Kq4uC8y2u6|V9-!itk<$aTR$Q0@n%O5=daBM?EWJ5!2!|uq2 z-6FqNe9$2}_KEx3V*7g|`+H;iAB*gNOca9RzQ@JKzbZcPXyER3OfCjOm+xXW2ZU zzN1uS_hOToyGi7*LNdTK=Z_toh2KZ9^Hu=2PaFW&Rb*{L485 zn(LN1pvkN`0bcjvOqT_(L zv_(AV65U-_4|=Xy4t=cStn=;`bF-^Ip2ul&hgOWKOa9$xKDlmiU4+iBsI@9;til}X zGL9d6=Gah4C=k=lzoMHzq93gpTeOx|c2{VPIDgNWzA;fq%zj|R8q-x?(N(@xeQw=P z*NG1`f)HaX_Fq}CpDOGOWr$^sWBR@So^2g2I$eZSDX)C9_-yf&^35QC{ipWF@)lgl zTX3muz%Xviiy3D}jI%@S;p(VyVa&KRVq6+ER*KcT$Bethy-u;+du^{bK_%kiZI^al z+8{2nUo-8#tK)J?um?eA8%}Q+(2eJn47Z$WiRCSgEk8^XDR%cQ)J}*&Sbr#Li!my^#9qG zyGN~zs*QWBnrN;9F%wl}afOZ+9C=^PH1_3yc@m@jq|A|mQ%tx#B7)_S5Gs%4b3pkV z^ah~3q1E^~puFM#3Mg*`ut}i20ie9`%YgEvr%-S?n0lr7Tu8nqQHFGY83778>8LZp zQUDcl@Tr=UR}K}QGZmDS6);{+0K5xgWde%R@LFDn(t1GJ8P%$ks+}@<;mv6P{uTvq zSd#$c01Ny&0d3Y+as*DNgTqnkI{*&=Kc|Di<#|d#0yv>jj)cA$!45cbW=IEPuS^v3 z=^c8xZ5L!Rj9Y6bm2yUK=;b+@%cUSXGNIq6AEXb=2x&mE3`a(bmKi-jqa|GZ-zI18KO}&4X>g_SW_?Ro$&_O0|PTRjk zM}INaA(T;DLlljl47DU6I&v^PvJO5^5#XXr@-egiUH? zx$-vg{svf)HzO6=DcXW)s%|NOd2tD?a{BZnwaO={3hf)FRTk`ylx+;P{$}AX7yf4H zFPHwN@)gB*716SdfA$lBK2ut)7%@~U`plC4UEu|;)&Z+`kTx&WJLprle*TSH?;W?T zwoQKT)muOO+RbO)COAdQrrg$Nlt79AsaZ@{C4k|jcTrEMM-!bTzj+LQ|9expMbP0| zuv4@fQQXK{Na#c+=|WP9guPU#jS5Mx@gwa5%mWpqtJUu$&D+@_kj`RA<)_RVsi$xd z3HC7cpxl&DHhh9#A}CHNDImyjS0VKZD7n^zr~ zSAEq|4S5q{YjEiC=bwri=Y*|e#swsdFyVg7r3IH&mx?cM6CbdTnRZ__ zk8X(-t{yZ&fU-bd9nUKoZa&>SP&=Mm7|VSCe`4+f?^_DlWmzP*ES9?{lDjCDTN%l% zjOJDi)IhEsx8x0*PMJcDW0rZ>tp$;S-QiWSvK5iC6|u67SIRa<3wB4XyV0iEI`r7L zjwL1A>Rp%Jmz%`KCb8;KvAIob?-JWQ;=V)IEQjxAbNTZ=&EYJ0gTaJ6HDVln{Ji^o zlent(nyK#YEN<4CJ4H;MN(m_c4+W)f&_c?BQXvUSh)7UELV^;KR6kIb1z6rBf$i67 zlCaYG-;mz|^D9Uk>4};wf$dURhXaG*{%B!lNciDM2NWILs~qdTAe7(g}E<5oCtu+R5ILwv3%N%8Zb#2ZSAcQ>PPml4Y1>G<}(L zLJzNGHdGvYByia=i!(!Wo_xoV@GZjprJ3b>v2>J6BMiws?fmlZWFp{~duKCo6Bb$& zct26%j4<6cEZeFe{CyJ?q(Pn8Gb-Q`ZE56^>1?79DOGmcz4aX0X;d`t-V)5)0lkFH zmuv?tIN%?WRyfXJ6e`i(-;kj7CDzNpJV(;37eqUggFM1i_pTf)-by-PF{*`fuTrTU z$N9`K#q$C?jO<+ZsY?j>j4(|EMo&xNi%|a`Uvhkz5Hi0cJ=QAgL5~t14W(h}jBenP zY7x>;Tzkj@x4kZ&ui0fB)@KCnZ!9(4-dBXP~+t=&PlaDd7|is*I` zjtk5Ypl^UYL>rF*B_{cVS={0#X;PfN)?P?AkV|@D%QR49(geiS?QYc0l&%2GQX}!~ z#HGsvq?o)@QI*XUlbjM@GnqCm?Hir1H`g@PQ((+QvmDwxkQFiW7 z&36h1wBzQylTVy{V&IAJ!oOeg>JrF8_!H++sl)!{er$le-3Yomm*RhL4P9iJK3KZLV0`p-9`IXwl&@Q#Y{6lTV*~df@5lCH3NZ zKxur;{%FMhXtd~W#!UOJ=N5*tLt9_j_0q19Emun_uH{w^Y#Yxj7;ZY%6xtnDz2J=H zRY&rw2kOSH`7!IFh;`9O{#9#L%(^;aUCkt-XP7ZTV=;HQU;8Oa5@? zsmwDS;l`0QqYYxse(_*i>|j^qV3+9hh@L~@;m5>7f_T6mof8B#im3 zLg(Xs7Us4MQ^Wz=S8Wa6UbjF(R9uDq{CP?+^D+&iq@PfC0<#Q?dRXfz7R__-u*?X| z^K>KCVU%S-hZx6?;-mj$^f^^f>ww~jLo6wtr8};ti%Gi_jZ@rG9VvHZ#(+}})4Xy@ ztE76(2)s(hdGPrGn6D%#;y}ki4v|o4Pohq#ic`=dmCU;36lP0I4jmKCNn9zN=v0aW zcuLR6o;D-!>K6E7tVtZcz->~qlcsb!M@e3r>Q!@6n0ipLTfLKWF;NdL3p!vx$`rO! z2N5zUWJ$sTW&lQ5{jO|w_^Eeg!r5s?E%`liP3c2|7HHTlFy5ADG^`h%!|)m!`W6St zZDhU8cP!B5?X^|(*i!B*m1MNFZ2;ePZ>+4W1V%tS++A0$jGMr3@p*u=)!**%z%v3C zC(!B!w3!}2Sc@k~0Kq~40zb`v7@qUnIc|oX8OzTA0hQ#5K}URpv>%khR$Ro@WX``A z7AWXx_4KA-p(G;kBh+EGEQSc=ZhO0Kv?f#pE6RxOnswQQx<3nbDA5Q#*-5Rd{Kx#l z1hGIQtH^W|%6ZW=IzMV$(_iy}(R^K> zDQ2zzb?$G9epz%?|H%7h3p-POXR*RClW-sLgw-#2ujv=wRlz`XL#;o)=UU zZD6^m-g!A2QHr6+91x@`F_bFS|NDN$l5}eVtf(aa>1A3XO2!e{3Cz5kq(z^PrcypQ z5?@r)2QFX87=MDA{xz&8pK@x2 zT``7PjSPKt1s#gM3{g7pZH_~=Obv}go!oM$39r^#UQI%`LxT}&gkK<=c3ceV)JlV2 zK!s+E`&YQTpN4MbTowh`}?;?DP#?rEXd1(7rn?leX>LLWG@vs+^VOliaNnuE zD+T56o3mjy*5r(5<(xb;c<5yBVDEtHQ!QsQ;|XDB{u66YZXDbgH5QBN;y-B3ZU@Zn!O)RStIzW6sHk1|K@HDP~*{ zF)j#uV#cZ~#wvD3S%>)N_rqaAmwEiDuRRsh6ODs4sfoXi6=cyf}Xq zXNs$t6#YeX^sh7y62yo#6)K#Nohc5hrBvz+bwCaVWEgikjksSDPQNtYVP9on7u^rp zoFXJAMQQe}pp|hzr9DXja(Cd#oFmg41l{F0XVRi4Q(8{a4nqnV_p9H9b`|%Q zCc(Z1UoWM;j!Nx$s6qph`P-Lq1n(4n>@ztGyn)uU6BGip%P4b>gs}}kMAP}{NGUVS z1AZyt8Wm6&ukvn+fkk zhD0W+oIfF>nBSOq<87QIPqTg_lBAt1;Ze$e7g1alfD1Q?)DrY}nC|Jwzq7TwTQc+l z3Fm};6tUB$jY=3L2gUBzHh5!Noo)DN;jlB&6DR9ed8QyxofTAE%L)JrZ13`PyMz!u zaM1&m53ZD2A8ReH_PgD^lIc@@m^@6sO64{q@)N&Xmvc3iF5JGR;pEoh2bnqrm%5zB$7#VMODvvNtEgHPeZ zFBeC%77VDyjakob8qX^ne)QC%a6>G5xi^|u0T|Alcg0*fUO49!!%K$Os>7W>craSH zELONmEL=5OJ)}t}j6?o24I`_fCCf)wiTUed`t|T{%b9nlgfnHrk!|6!QSG&bYa;r2 z1Dc3=-jF)f2vuTe#d8n7;+Dn~D*evdF=H`VF0;bAk@cglb3LOy=iQ^uOWQB+z5K9P z&fM7?KGESDYw?JOywMilnCUTCNC(u{jdR1=n6YxqSV;{qhrKPAR>rm+5Vsxp?SpW_ zi`E~wW@`D!%ssI7ULiq^6~PAma!z8hY!GJe?UwsQZ)JU&C-sRAX9hk}zmn6TB^?{l z2$X9iqmZJI+$%&SyGNV5pKDS~Kbm+290j>LpmywdD}w=63Bx9shzm8z7{#j~OeYoX z)FJ1<51!D^W(4sQDAI>pJieR4azBR`H88$Z*C>riBm4)Aferc&^s@%kLu@mwd2UlYw-E5m;7a%ogqELLcg0k4 z7l--syKsV(W&#f2P6B(J&W#ypE3RF>X2t4NHAw4Lu3Li?S9R-_$F(cBZrQeNHPX7Z z%eUhG+N#wnw^YZq>$Yy)x@uiqyRB+f)k@s2TeEKU%GDbgQ>ESst0@|!XeUMMDB4I- zJ)-itah;QX!olf`8=TILApOV(esP0HRJcr0Cq=(T6xY!YHFSI0q~C&2)9*e=j{YXf zR(jzhUfJuHJoEK%`tS7}JK8N-hScPfBRRUXP)_GmkLHOTsal=_%v+ zhbTw0&8D3ujI1-%Vd*DIYL7x|A<>?Z7F(sbeuMNwBpZbc>;l>dewqTPt_sEb+=dBm z-XA#Q@3@TLafaV>g%jMY-*d&k=jQ#MoBhAETu!&5KP#SpK;&{i(b7+ie3DN;%_7=PAt5Ga%mETF2{a)o{Xs6i^HVheMqsp_xrXfz|S_rS-KzPFj5~`DHcq8y8l{fPo@HHx5 z%bS6(Re1|lo3@z;0mUORjL^!a;p zspZt4?q#klU;Yd`ue^Kr>c{uaeqj# zWIQZHWZ-K$Nh>AxiqS2$!VRnmx1Y_z~lN2li|40n{bXY6j}IB7 z^6}k*#w-~t9V;B<&LZzF+MX(3EZpqsmd5WacEuv^YGiUOm`2V51GFCGoX(nz3 z`93>X=z~A}O*3kH9GCIn@`d|htVKGMqeQS&9cmZDnq-= zCb=TZQ`PmmRp4HcLyo6w!7!`O^-OuDi!Hfb3%H0I?WKb1$m{PB5@S4!8*ik7+<4&? zRCY|(!dS5IC0Y#x<77!AB)1ogHkQ7D+L{eCYbio)_5Kt5*;tLB3C&@lh zmJ&gXgdriK=(ULz=14RcX)_c74YmWo2SG(Art$>hHdvpy0}9Ci#a6pY8a~wH z5Ed#vY=UTlptm-)^Hc|*9Ku`b^||q^xoLK=U|F*enh&Kr-;K_`Q7~BYh6ea$4Grn( z{H9L$WjA#ejI|5qd2=eB{zbmom1}n8o4az&U5nk>rk+LVN>9H3V6OjQzW-3J|4^1c zobB^uJ#S^}e|6LN>q5Ql%=qc?Ia9%ETR1U)B5!r%td1q?Pv)4vTVJ?sURQ8-UbbGc z-f#}2OlPd`S@U&UZq#kLzIKi+)YPTkxLLC?9nZJ8axJb!Q@*9|MoZt7sq5^uXu-T@ zVPt+JJ(%vvv}Q&xIxf7G*`ICr*)4PDqgt|l%R>ucxj&BlZY0(7VRu?OpZp+s_LXeo z_UxJ+H<|WAjdfx7{O;7@rJ9XdX5$J*mVz)|zKW4{V16vOc|NRmf~GQ7bBNVYAXY<# zSPj+6IzUz>yjpb~+;dRFxlx46dh`gaG<25|V(H zxRck#SU@O@Q56JIzXgm0JgSDYW!!;vHj({co*-8RY5!AvtvB z*U)A=D&VA82#^$UNyKv(JE16pR!o${ZWLpK5x)RQnmV1r$}0G>+DvHGPXX3?)PsfU zBwqo8fVDm)FL6z)5G$SdM7r30x$jcn$8Tr1_GFuTv*z8igLiD3GmcNUeY9=ywY=@c zc{5-~9Rf&w>+e$vX#f&Vjsh@TPOHU~A6X zoH?5_Z)?xl+Vi&VoUMDwwkvOYHD`Nu$+q{)&aZ5PDfV_<{UZZe-}=x`;ojD{r5gA5|>Md9#a zECmEXF53QH>@|daGhX;g;gErk;^Bzq6^s_j>BNNQ6m&w85SG#SwNQ-_TEs%VHa{?6 z`6jUZjC|9yV|H(4w60qSzZ0ILiy=8ppErJBOeHgGGkZUM^P@L2FI}9uLVr>FS?%K6 zUkcg!kz2+Ck95S^^i7q3cd*T>7`*tT@_H4{>z#_@LmHm62>6NtAL^mtg!T|)Ft;kE zL{Uh0V#jAf+L17bLmIwdYNw_hsw=CzQeQBtxoM9w<4f8*{AG~`VT`sCg33c3MNzlO lzGc$7Ol-@f;cL?RHEDjLhX?eN_3)T^(hA}6`^J*({tH;BhJ^qC 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 deleted file mode 100644 index ac473f188a9827ad7ef631aff6a5fb3c3c7896fe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20007 zcmd6PdvsgJdFKUi@dgO+B|gLlNRX5$QKV#1Z&?&YQX=)BVTb}O2_^)R;6nld_yW{3 zNoiMIgOyUsi5k(d6GLmIq0_Wxj@3kNoLV{Q?z(5&Q^EiR^e(+_PW)K4f2)q}j zyWck#_g)aZ6eXuUr*p`ex%0YnXXg99-+c28*5(*>6gX?)U!A0CDC#%(z&iyOkv%vo zqo{GpPKBvn$}a1Yb;`r?N{q|96rFUKCUHfVvQrgSkvQF@?$m@eB(ChzcIv`95?6KU zI}Kq2iL1MeojKtg64!K@I?Z7-iEF!ZJM+SMB(Ceq?<@!xbQXpSJ6D8PkTiW)QD)udo+EyqLr*?IjQ|C2@;=CB(}}yvkk<@d^^JwpT*jLgH3?RS^}gu~&m%Yqx@5 zXRm?WT9UiUUI+12B);0d8shaNUTW8b%@E#M0<5H~(|(B0PAYVUFP9JO^o zg7(niXWWN7TgijQW^=b5?Aq528S)qW^$J$eF*?Soy8Qja-ViIdg|P{EWx#jZ>kbcl zN4-P;bnL<#Kl;&{-9uz*sfyj*{W#6(sissAhs7y-04o+2ri1F0Z@oxS1CUlmJu4fQ z*=2UQT><~JUD>37K~&w9Ls3@c>Fo0k3c5w3qSp`MjZ~(ZIkMhJBv?Q(pf-(}+Lp zWuR13=+ubM7iwa>{Ubrh9}bL#yz4^Yb3QK@=c2)2_^kWXNVqY0jy1U5>G|}!-Aplb zxe)$C)!>a&Us5?T(|z=&kKM!IgEb_W9T+0151>B3 zE(0HfR5c&B%a(hGgSMIZ(qgzoEiGRHY8CKtF zFZpDe$<(eB7Nt&1tT!?_@CpXUk~Q@Q7_S?aR?y3YM}Uyjp|L((k*wP7>}v0DJ6V0m zH!|RLpBWhl5A&&=tbt1%_Kpk2t>SGbPZE1v~`@ zl`MD*>EY5mrAZExMKidV$Urc-Kv)foK4BjWerCOlSpfmZImnn0iX>hgW|V@5WLYB1 zy^mg0aSwy(5P9NyCy)n}MIlXk_X+eflN=osPiVrD`wlKS2W3l>Dby64O&}v72}|Pq zWw~Ce0sEMC{5ygQhr%Y1C7(-CHUc8VXN(E`SxHDB|8eX2h2FSKe2ef_3F75@)hpY2 z6}Ug}JRmizRLNdh=GTaCaDZ%Z;c6yM97|wrk3D`aobasre9wXk7ZL<<&gNc#Owca! z<}BxeG2)zE#@B65k3%K3#F&@!b;K~E9f1L7q$&8em+b}3mu$$G*^HKi{AQ#Za6u(7 zMGyknE~v6A9V)*tdk&QiMN*bqxY~khhuT(~RRkUd(V%e_i*xAW7aHZfXj{Fw7O?O& zi&LRjw5?%rdUKjWVAMt7_76QQFv!LR~_II8IVUC4a&^okIv+x*Wu5;)fIp%uj zTFEiLX=_|OioJPuHH`22#W~{mx;FB;&h-wuSH^z>u6N+Dn!A`ibgSYI{`uKkf7SB& zcV9(!w`GWn$abew{!dHK5Z91-_ul$aR_EqL=K!lBqAtX0Jp%)75@(H^^z(Vn1;)Z5 zC^75-fR&bLVei>6tMGaKtN{>Vzjrv`8}Kqvii4#P_W3;iQ!MT7^DvABUdx~i2P6&c zW97cUSyqP;_aNgL^|D%&qwX_|C&=o89>y~Sv0)I^Se_a4vs(1sg8_e-H4J+_10(*S z^by@A0c&k3Tn*cT=FKn96|RbYSQ;Xzgw zWEJ>61xP7Y9twi+hQ|V;u8;ikz=%)k=&Z~2;SYQ6Vmwqp0 z&cCL6MVB$Z%-La zllv$3Ck>XE!E$58wBpT`al^Xtj(J)WDZJvD%AIO^$@9xT%^6y`#T%_p1#fFU&oCEaB>#?H`Q0hT^*6@wSxK zFj+BCF}^32Uo^hw;=zb=ORR3ot%6(O_bttdGc3rrgQ% z6X!2|EkdUX$}Vf?HMv)6-q60PjXo6}n;v-kCNK@7vmj7 z2Lw@+L1d{tRFe!SQIUmpmPI&waIK(JMTC=!2nW2Vl>}TzGNB2=3DS}CAWD{jHkX!$ zHZB3$^eSCyXp=Z;G0rn!fCU-ijo;T~LWQ6lT95)f0@ml>v|!JJFiAl7VhJ0<_bkUt zuK^w=J!U<>(5pv+7@?H-TAM`Q9}Qxb_!PU!1$#w(* zDYnByAOZ@dR>~%^EFW`i;bS7W%t#YJg!HIqloa4&Ce>u*F9wAwfO}i{w~L`|yBOzV z3n8HGX+B*H`EL)Zokim6ceU^(oMGE;J}!nZgUFAvoLy;G@hc|{IMjEMgD{9@h6agK z?Pi+b>2vPsf3@_ms=dV3S2H+=3{DWM7#;C5oAGf2dQYK;UFBpl&If}-z-$9AjYJT0 zFi4rK*51+9)7ri7UmiCQ+MoWk4q#-6S<{w~oV5_)Ln4ReDLJvob*^OQ+dN@4w8WBgZDh?en zdoh;)2_=C^%sx!m4_*j6kNbUe9BRlV7z^|dpyo>Bh7(W-8xMUW3peBzBy+1{xz#r| zMql``b-F*<*b-}OiLcpxd&AtC_PDiUE_ZK4wGdu7;^v0sF~#HIg_I$eLn75NL-mbq zN$cjAb@O|QTdhA={k7tD>#tNFDE{8`H>TO6uH?~?*wK-=;pBKniq^io|Hb`Dx*$du zBT>y28uPy!cF#u87eUD2%@%BGDNq z8z&l*I%`a4jkeC|>XN#~n65FdTmMKDayCJCE+PLx*m7dm0+FTUKj=_tqVG^rKCv4DD_1!kVjG|xQTc_jIRI=>OI48F zps0Ds(9x)2PUK>nd`xPlMUWxZ7iYv($=7wRcfOFh50wOQReWhNbXobhT@aEPX@X#c z9@p?C#W16!lJC_55i7PX=nnW^1~m?i&;l2Lb*5sbUu|0?5yLx7w&&}<6F23dH zTL^kr?Tux!eH?f8}Bk&cwP zAZf0SnXBXGnn-J^u=MiY)QX}=>mTjAuclU1-P2I!!pBl+=pvi0gcG#&t3rk>CB}R~ z`P9&zg1QM!M3%BtMW4N)itL#@G;wIE>W-modQY-pXRKjoykQq)=jBIam({5p)8xp+ z$fZ-DiO4U$tU*4&CSJ%eXn92u|%?jfCzO8iHi+Tf}SAx)xALxmY6@DJ@1uS>XU9Q-nD*nNPj)2H~9m611*d{!Px) z87&Dr(~LAhygI+-P&B8%9QN_cwBXR<+TtDbhL1uh+4@z7GNW}tj5>o3Ri>^)3V`rq zFOH-`jYo!QZXa)9?C@4*2tAZ@^?Hs;iyR-N4<`kD7&D+}0T0x^u-O|M^%IUu#6i7I z+_*7gSnxD@XV5!~9?mt#iWNhkhE|f(;1H4=p4f7GIBo}*L-f990{lC^0cFUte;Xor zX1kmgR$q9ff3oEdTjn*O*qv%lXjaYZ%vbV>rW90}?g{tnr=$I^U6?bjnLa#cTtCw~ zXV`?=TToWMz9Oo4ZRL%@>BDcHn%Q*wRD!l=q({f*N*bm^b0truHK&EMM?kmPm)3JW z4wFFwBuoYY*dv$>Sd7?J#AZaD&0N%|qKESUj+xL*;7sVej%TYmpD4sQ-2dPE+Q zhdcy4Jo$xSdV~bG&XYurXV6fTcLI;R3?Ct_C4Hhp%QWSnC>7Q#!WD2}c_D0p#9UlQ z=oaw|o3k}zr7I;Nvx%#l>BHtqpjn{g^t%_(rI;^r0)Y}UbXj7WP!6~h0NHFO9`%Ya zJ(DkGe1zc6ktTsFixZ-7Rtntu{QNUaf{_?TTOg5LuN(%@F?ucB9GS8Yi{TG$SR(|vv~ z7(_0ZDo#4Cb_^%N| z*nVn)rmRts(ixlV+KdqZT#8%D1HdCO6gNdqn}g0+kCGcpzUP^%9r(JA!22|F|4j>F z>C7;6b%;av`MZ0-mP{E6gYg_Q29yzRzh)1C#1&N+3 z&*=pJ1LiT{u`2Er%**&7LyctM3@htrm8U(vF)!nUte84;r*PGT7SOY)08i+qs-~Wen<^vpd_mEa>e~l!n=r4*ztSG1la}Tfs0{~_ zoA<{y@Bg6vj^go&cXCW&Vz$Q^h^Q zAj%gAXyE`+6yOdOFQQ=^Ac{$3=vMwaq&HJbFmzgk<{VNVfoAISmSNZA@aQU(WR)Xe zff8bM+<^$La&jKT@$!DOp=J<$vF5gC4jwtw?QT1Gd3l<>ejmyZT6!NMUyZ%a>&xQ$3NXu9VY&EB zs<`3}!>a~hR|YUm&=@976Q(Kpj;0p)P06Z%gu|1AFCI+Nr7^m6UQ?LV)WtM)xKRjQ z(x-!zMvs+VuZ!ltwsuZaDmJLRlcj^6@Gxi2sbaif z!0li5NaIJ)yl|u;mznQV;9Y6Ehrv`Ed0K4o03G8xw>sT98%Vfp=MeVMpiY!oskdZk zKu?D}r@i%ZZcRj?P6)wUT^*TZ<_5%xN4k{d&+YE7HDZora zEOV_9H4l0}#|%{D10PoFA{&$XvKa7Ib$L{lP*1>eD zxPu{1N{t8xH!{f+oHVZjJR|i+fC;Ot6!RwJz%>l~ z9_9#o*d4}!9`0|L=g`B6V2+}Pw80!h?>Krc^qvQ=UQJYTM2v&c2I#C=MK};-z7Bb~ zk3uyy{OH$3~VWw;=1MTo0~tTPMg z$dYna+KPa=4z*YsuVF1KN&W_k3X)bSd-^^FUKAuP1k>&0i6aAH0Ye2`1f+Z4A!HzO z1}7Y6g9b(>MbDxop$Jz+hzUF(lhEJ==xhkNvRp-xyX{jq2Qo6Z?hq^XF~)dD{L&BcB!pHZ<$@?kV|RIcm@LU z8TH~CW*xk1=t9At@B8)nn=Q=m;}d6cb*F_WQix4fPdA(}lErU<0vuE(WViz#vy7WI zVcKr=TG2x?tXFX)w;B@=C~#+}`!Kd2yzEO%j1P0rBP;Kl5CQJ|M25!UcMP}s-w(VS zNZ1^+&SP=g@wr{c6VLa*M zhFg_!T1JSIxrw7t2;QR($6}B#?8%L?%KH>}GuC?;+&V{s4|+%<*K1^Iyh8>`NOYEv z=n_~5N@P2y6?S+65%ex50=z{cL8?IwSgvUN0-FH)2|-qf?^s9{LA+N1>d0)z%eA1+ zc}(1&i;6_&bxHf1Jm`T;ifBaAaHwD(4?-iThyE31zc+&@4GzO2duDY0Lb7Kuse4Gh zW;>uoDakDMOti)6)$okRVekv~O9w!}p>oxZw>W6#S?GJu-tKx6Co~DQA_m|{_Gygm zLXQ|FO3*EXB93W84<`VwFA~FpJ}=rn*5`VV!j z`SiroQ@L~cm4J11Id~u8Qlo^?oFq@BU`MkI)eu+nUds??*fih5 z>D10cI`9H>1~0Hc5+}{8F(wLf{47>!@7mYd1FAFM$dG>&F4}W^q5zAE%z|)-c1YuK z^=iyth2AgFBMbm(os;x9Q3O$^&mK5!TL+|)6?+vT*;Kyqhb`AzfX>_AIX-9IjrK(L z*dBZ0$nja%iTDxE+#XM&e`NOLXuRK_2%erjb3Put5Z8ST=p2+m}P1_=rnD22#)&uZIW{P+G?$&@SIgiV`6- z^iE>u`bZkaTecEtKs>G$xg6g!+@}v#5ksF+z86x|4PT>_Y$vVqAp3p6QkHLtf**ka zRb{#i?2?!3pRnx_`Ug1eEA&rnhwmTay=D7nmmw-Sul8(*pZpjBMVBUgF6bQuTgLOW zXS>$}yoqoVLKz4+m|vmypU}ev$?fa^GsXa&0eEmTY|fB3><%z)RQNMC2Cr_w%?n&Q z46I*@)g2z|>+r!(D8MyOMaUb5Ezf?;B<~QL$424MG{}JICiab$dp#i(P6U|n7cIDL z&2C60l!ldnSrrtclK0}F`GOT!!r$&3Z{r@t^pdJ%$+{0q)=hikCCyy+%iS+_!##$7 zge^$j1Y9VnjOi-lx+>sVaLuxFqVuYKO8f2S;|43J5Y&d3zCLd*Oqwkpnk~`%8-3Fy zZ;r;z%>YwCv)}b%SLEbWO@gk(tCPhO#aFAZIKI6mt}TZKQlZ^WNdfjWrBvz*ip0QMut&^X9JqhLih zkW07<4mWX9-0YCaCFbLFWI4buf+-;I4mgd>cJN-{TR~L@I`K&HuihNz9ubEeC`+4q zxYD~3qPS9nk!jfz5SKP_MrbJ;=MEmiEO|K8X5%_N4y{+#*{qEBd zZ9=m$m0xyk*VSE-ws`}X=)JM+)ooGOZrm|!exRTVsvjt+73;~;h3(pp;Pu_z+)0Uy z3mU-e&^-|7ce~+oy?byBf7u9rVhGtT=q}HW`Zz8}b_}F!0Q%7gQ-WLcl)EuTW*0dP8DTQxtY#+ zj!&uTPpK6jQRN>|>pr5aA5rxmQFSD;@?)wtM%8{wt^GAvR+hwdE63IUkhZZ@f2o2W zcKWg!en#fuR=9Bea5Y@3dbk}7lOL9Xf#Jh7#DjCmhdMln`>jSPTlHJBLRR%#gA%gM bphtaZ28GQ-3vACG<^d%8vKkm2>GuBzq?sx= 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 deleted file mode 100644 index 819e95e95bdf02b3d0f65d504477f1d4c068bb25..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6262 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

?xQwfykoR{3nD51SVhO*^|j!|V+(KxE* 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 E0}&QUng9R* 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 deleted file mode 100644 index abdedfb30f8c589841a38efef0ae973dc3e04eea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10340 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 azc--Ey{k7J+5N`uw_f_2j?ynAo%>$|@Jjyx -- 2.54.0 From 19518db6cf749cb1366e8adea06e0ca4f36cf46e Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 1 Oct 2026 08:17:33 +0000 Subject: [PATCH 3/3] Ignore Python bytecode in the backend tree Why: House-style tests write __pycache__ next to the skill source. Those files must stay out of later commits, not only be deleted once. Evidence: .gitignore lists __pycache__/ and *.pyc. Impact: Local test runs no longer show bytecode as untracked files. Refs: feature/agent-professional-deliverables Co-authored-by: Cursor --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index bc83e52..ac916b8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ web/dist/* !web/dist/.gitkeep data/ +__pycache__/ +*.pyc -- 2.54.0