diff --git a/CONTEXT.md b/CONTEXT.md index 731665ada..3c0f4a8bb 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -372,8 +372,12 @@ lines and the entry itself, and any other answer keeps the task on the entry. A targets (readiness kind `route`): a missing, unready or switched-off target disables the row with the reason on it. A route is admitted the way everything else at a boundary is (**Admission**): it declares what its target's pipeline spends (`needs`, from the closed -`ROUTE_REQUIREMENTS` vocabulary) and the lowest tier it may open at (`minTier`), and the -entry checks only what was declared -- a route declaring neither is dispatched as routes +`ROUTE_REQUIREMENTS` vocabulary), a file the dispatch must hand over for the target to have +anything to build on (`needsFile`, a suffix; read off the files the user attached -- a direct +chat's media, or an attachment of the turn that the spawn task names -- and never off the task +text alone, since every deck brief spells the deck's destination like a template), and the +lowest tier it may open at (`minTier`), +and the entry checks only what was declared -- a route declaring none is dispatched as routes were before the gate, since an empty declaration is verbatim pass-through. The readiness probe answers for the *routed target's* lane, reading that product folder's own settings first and falling back to what its launcher would inherit from the host; the host's own diff --git a/agents/README.md b/agents/README.md index bfacd3d3e..b8b22a94c 100644 --- a/agents/README.md +++ b/agents/README.md @@ -56,11 +56,14 @@ What one agent directory carries: `"noteFile": ".md"` beside the manifest instead -- discovery reads that file into `note`, so a requirement long enough to be worth writing stays reviewable as a diff; declaring both is refused. What puts a route under that - gate at all is its own declaration, and the two halves are independent: + gate at all is its own declaration, and the three parts are independent: `"needs": ["image_generation", "image_search"]` names what the target's own - pipeline cannot work without, and `"minTier": "max"` the lowest tier the - route may open at. A route naming neither is dispatched exactly as routes - were before the gate existed -- never probed, never tiered -- because + pipeline cannot work without, `"needsFile": ".pptx"` a file the user attached + that the dispatch hands over (a direct chat's media, or an attachment of the + turn that the spawn task names -- a path in the text alone is not one) for + the route to open, and `"minTier": "max"` the lowest tier the route may open + at. A route naming none is dispatched exactly as routes were before the + gate existed -- never probed, never asked for a file, never tiered -- because `routes` is a general facility and a row routing for reasons of its own must not inherit conditions it never asked for. The names in `needs` come from a closed vocabulary (`ROUTE_REQUIREMENTS`), since the host is what answers diff --git a/agents/raven-design/config.json b/agents/raven-design/config.json index cb8934894..4a2be8802 100644 --- a/agents/raven-design/config.json +++ b/agents/raven-design/config.json @@ -5,6 +5,7 @@ "contextWindowTokens": 262144, "maxToolIterations": 400, "llmCallTimeout": 1800, + "llmRetryAfterOutput": true, "compaction": { "enabled": true, "triggerRatio": 0.8, diff --git a/agents/raven-design/modes/max.json b/agents/raven-design/modes/max.json index b3f028b6e..e75802c75 100644 --- a/agents/raven-design/modes/max.json +++ b/agents/raven-design/modes/max.json @@ -1,7 +1,8 @@ { "agents": { "defaults": { - "maxToolIterations": 400 + "maxToolIterations": 400, + "reasoningEffort": "xhigh" } } } diff --git a/agents/raven-design/run.py b/agents/raven-design/run.py index ac99d68b3..040bc349c 100644 --- a/agents/raven-design/run.py +++ b/agents/raven-design/run.py @@ -44,7 +44,7 @@ ), "max": ( "Max", - "400 tool iterations at the host's full reasoning effort. A full deliverable where the ceiling matters more than the bill.", + "400 tool iterations at xhigh reasoning effort. A full deliverable where the ceiling matters more than the bill.", ), } BASELINE_MODE = "high" @@ -186,13 +186,16 @@ def render_config(source: Path) -> Path: # the engine does not read either. The overlay stays on the entry as the # record of what the mode changed. # - # A mode that names an effort of its own keeps it; the rest inherit the - # host's. Medium is the one that names one: below the top tier this agent - # designs the deck itself instead of handing it to the template lane, and - # the cheapest tier asking for the host's full thinking budget on top of - # that is the combination nobody chose. The other two say nothing, which - # is the same as inheriting -- an unset entry effort reads - # ``agents.defaults.reasoningEffort``, and that is the host's. + # A mode that names an effort of its own keeps it; the baseline inherits + # the host's. This agent designs every deck itself, so what a tier buys is + # how hard it thinks about it: medium asks for low, max for xhigh (the + # rung below the ladder's top -- on the providers this lane runs against, + # ``max`` spends the whole output window on thinking), and high says + # nothing, which is the same as + # inheriting -- an unset entry effort reads ``agents.defaults.reasoningEffort``, + # and that is the host's. The overlay itself does not carry the effort -- + # it is lifted onto the entry so the trunk dispenses it, and a copy in the + # diff the engine reads would be a second place to change it. catalogue = render.mode_catalogue( MODES_DIR, MODE_LABELS, diff --git a/agents/raven-design/skills/deck-to-pptx/SKILL.md b/agents/raven-design/skills/deck-to-pptx/SKILL.md index cf0a20353..d21cbfb18 100644 --- a/agents/raven-design/skills/deck-to-pptx/SKILL.md +++ b/agents/raven-design/skills/deck-to-pptx/SKILL.md @@ -50,6 +50,11 @@ without: at most one call, before any other work, recommending the default. Wher no user to ask (the request came from another agent), take the defaults. Say in the reply which of the four were defaults. +Use what the user handed over as much as you can, before anything the rules below go and +get: their documents and data are the material, their pictures are the pictures. A `.pptx` +they attached is a template, and a deck on a template is the engine's work, not this +skill's: the host routes it there before this skill is read. + ## Rules 1. Invent no number, no source, no person, no place. Search first. What the search does not diff --git a/agents/raven-design/subagent.json b/agents/raven-design/subagent.json index e7a72f01b..20b9c64ca 100644 --- a/agents/raven-design/subagent.json +++ b/agents/raven-design/subagent.json @@ -13,7 +13,7 @@ "image_generation", "image_search" ], - "minTier": "max" + "needsFile": ".pptx" } ], "command": "{PYTHON} {SUBAGENT_DIR}/run.py --acp", diff --git a/plugins-dist/ppt-engine/docs/ppt-raven-design.md b/plugins-dist/ppt-engine/docs/ppt-raven-design.md deleted file mode 100644 index 13b60113b..000000000 --- a/plugins-dist/ppt-engine/docs/ppt-raven-design.md +++ /dev/null @@ -1,662 +0,0 @@ -# Raven-PPT 设计决策(插件时代) - -从 D24 起,ppt 引擎的设计决策记在这里,随 `plugins-dist/ppt-engine` 一起演进。D1–D23 写在 -已退役的 fork 树 `subagents/raven-ppt/Raven-PPT/docs/ppt-raven-design.md` 里——那棵树曾是 -A/B 对照的 A 面,现已整树移出工作区,删除 `subagents/` 的提交是 git 历史锚(字节快照见 -`tests/fixtures/vendored_fork/`);读旧决策走历史锚,写新决策来这里。硬不变量原在 fork 树 -的 `raven/ppt/AGENTS.md`,同在历史锚处。 - -**D24 模板的插画要能整个换掉,换进去的图要能坐在模板自己的底色上。** 模板的节页插画 -有两种画法:一种是 `p:pic`(teal 6/13、gold 7),一种是十几个 freeform 组成的组 -(blue 7)。`replace_picture` 原先只认前者,对后者报 "no image to replace",于是借来的 -页面上留着模板的卡通,作者要么绕开画自己的,要么把图叠在卡通上面。现在 `replace_picture` -接受一个"画出来的"形状(组、组里的成员、或一组零散形状的列表):删掉它,把图放进它的 -外接框、插回它在 z 序里的位置、按 contain 收边。成员代表的是"它外面最大的那个不含文字的 -组"——模板页的内容都在一个组下面,第一版直接爬到顶,把 blue 7 整页内容换成了一张图;`adapt(pictures={n: ...})` 在 n 指向 -画出来的形状时走同一条路,`pictures={(5, 6, 7): ...}` 让几块零散形状一起让位。持有文字 -的形状和占位符不算插画——落在上面的序号仍按数错处理,否则一段正文会被一张图顶掉。 -换进去的图要透明底才坐得住:OpenRouter 上的 gpt-image 系列都不收 `background: transparent` -(回 "Accepted: auto, opaque";现默认的 `gpt-image-2.5-sunburst` 与 `gpt-image-2` 均已实测), -用文字要求"透明背景"它会画一张棋盘格;gpt-image-1 收这个参数但不在我们用的这一系列里。所以 `ppt_generate_image(transparent=true)` 采用 JarvisHub 已验证的做法: -提示词里约束"主体画在 #00ff00 纯绿幕上、主体不用绿色",回来后按 HSV 把绿幕键成 alpha, -再对键出边缘 5px 内的像素去绿溢色。先试过白底加四边泛洪,白色在大多数主体内部都有(衬衫、 -盘子、高光),泛洪到处被挡;绿幕全图逐像素键,被手臂围住的口袋也一并去掉。真实生成一张夜市 -摊位测得 49% 像素键出,深色底和琥珀底上边缘干净、无绿边。键出不足 20% 时把这件事写进回复, -因为那是模型画了场景而不是主体。 -填什么图是模型的决定,前提是它知道哪里是图片位。模板的图片位大多不是 `p:pic`:amber 把每张 -照片画成填了图的圆角矩形(`PictureMisc`),v21 的模型在第 9/10/13 页对着看得见的照片写 -`pictures={2: ...}`,被旧代码以"这页没有图片框"拒绝三次。现在菜单的 `pictures` 把图片填充 -形状也算上,并按 `adapt` 的 `# [n]` 编号列出 `picture_slots`(`[2] 11.9x2.7in photo`、 -`[3] 2.4x2.5in drawing`):照片位、和 ≥1in 且不到页宽九成的无字绘图组;页面渲染的说明行 -把这些位和三种可填的东西(来源里的图、照片、透明底插画)写在图旁边,起手调用带上 -`pictures={...}`。图标(0.73in)和贯穿页宽的波浪带不算位。 - -**D25 对比度门禁认"颜色"不认"桶",模板自己画的低对比是报告不是拒绝,发布过的才算发布。** -三件事都来自 9 月 3 日两条 amber 实跑的收尾。一、v21 封面标题被判 1.0:1 拒绝:底色取的是 -文本框内的众数像素桶,大标题笔画占了半个框、照片底没有任何一桶占多数,众数就是墨色自己; -同类还有 v12_beige 第 15 页和 v13_warm 第 18 页(渲染把 #2F2F2F 画成 #303030,跨了 16 级桶的 -边界,桶测试不认它是墨)和 v13_gold 第 7 页(标题框伸到旁边的夜景照片上,#110D0A 的照片 -像素和墨色同桶,被当成有字的列)。改法:墨的判定改为与声明颜色的逐通道距离,20 级以内不作 -底色候选(除非占裁切区 75% 以上,那是黑字黑底),6 级以内才算"这一列有字";切片内的墨占比 -同样按距离算。二、beige 与 mint 模板把白色序号画在黄色箭头上(1.4–1.9:1),克隆这些页的 -deck 全被拒:模板自己画的形状、比值在 1.3 以上,降为 WARNING 并在消息里说明是设计者的选择; -1.3 以下(同色叠同色)仍拒绝。24 个文件校准:17 条降到 10 条,剩下的全是这类模板装饰警告, -没有新增,v20/v21 的成品 0 条。三、两条 run 都在门禁未放行时用 `exec` 把 `deck/build/deck.pptx` -拷进 `out/` 并回复"已发布",钩子只认"out/ 下比本轮开始时新的合法 pptx",于是替它作了证。 -现在 `publish()` 把交付文件的 sha256 写进 `state/published.json`,钩子只认名单上的文件,名单外 -的新文件被点名为"不是 ppt_build 写的、没过检、不是交付物";`ppt_build` 未发布时的回复明说 -自己拷文件用户拿不到;启动器 exec 拒绝 `cp/mv/rsync … .pptx`。顺带:web 界面只能预览 PDF、 -图片和 HTML,pptx 只是下载项,所以发布时把测量用的渲染 PDF 复制到 pptx 旁边同名,钩子回复 -里一并列出。 - -**D26 没发布 deck 就想结束的一轮,钩子把它退回去。** loop 的规则是"回复里没有工具调用就是最终答案", -不看回复说了什么。9 月 3 日的 web 实跑里,子代理刚做完 ingest 就把一句给自己的备注("append 需要 -实际内容……再重新 ingest")当正文发出,turn 结束、deck 未建,靡主 agent 察觉后重新 spawn 续做;另一轮 -则以连续空回复收尾。子代理自己该兜住第一种:`PptEngineHook.after_iteration` 在模型这一轮不带工具 -调用时看目录而不是看文字——本轮没有 `published.json` 新记录、`deck/` 已经开工、回复里既没有向用户提问 -也没有说"做不到",就返回 `rollback=True` 并注入一条"deck 还没发布,继续做;卡住就说清卡在哪"的用户 -消息让模型重采样,每轮最多两次(loop 自身还有 8 次的回滚上限)。判"是否提问/放弃"用的是一小组 -中英文标记(问号、无法、不能、失败、请提供、cannot、unable、blocked……),宁可放过不可误拦:误拦的 -代价是多花一次模型调用,放过的代价只是回到今天的状态。连续空回复是 provider 的问题,钩子拿不到内容, -仍交给 empty-recovery。 - -**D27 一次 web 实跑的账:124 次工具调用、165 分钟,其中等模型 122 分钟。** 9 月 3 日养老中心 deck -(15 页,glm-5.3-flash)三轮共 124 次调用:第一轮 38 次全是找资料(13 次 web_search、9 次 -ppt_fetch、4 次 ppt_image_search,每次一个模型往返);第二轮 59 次(36 次 edit_file、7 次 -build);第三轮 27 次(16 次 edit_file、8 次 build)。工具本身只花 34 分钟,其中 ppt_build 24 分钟, -而 24 分钟里 20 分钟是第二读者:五轮阅读每轮都顶到 240s 预算,读完 6/15、8/12、8/9、0/3、1/3 页, -最后两轮在发布之后、为一两页小修触发,8 分钟只换来 1 条意见。由此改三处:一、读到 0 页的一轮 -过去不写记录,于是也不计入 900s 的 deck 总预算,改为只记秒数、不标页(`_charge`),瞬时失败仍只 -花几秒,读者太慢则预算自然耗尽;二、已发布过的 deck 再次发布时,读者的触发门槛从 1 页提到 5 页 -(`republished` 由 build 阶段按 `published.json` 判定),首次交付仍读完所有未读页;三、`ppt_fetch` -接受 `urls=[...]`,一次并发抓完、入库一次,把资料阶段的 9 个往返压成 1 个。剩下的大头——每次模型 -调用 5 到 20 分钟、7 次空回复——在 provider 侧,读者可用 `composerModel` 单独换成更快的视觉模型。 - -**D28 第二读者:每页的思考档位、意见台账、按页超时。** 复盘 9 月 3 日那次实跑的五轮阅读:读者的判断 -八成是真缺陷,问题在慢和不闭环。慢的根因不在模型而在传参:litellm 只对它表里标了"会推理"的模型转发 -`reasoning_effort`,`openrouter/z-ai/glm-5.3-flash` 不在表里,参数被丢掉,配置写的 medium 从未到过模型, -GLM 按默认档思考,每页 100 到 350 秒、输出 6700 token。同一 15 页实测:参数写 low 与 medium 无差别 -(38 到 631 秒),走 OpenRouter 自己的请求体 `reasoning.effort: low` 则 13 到 29 秒、460 token,意见质量 -(p14 插画压字、p9 无分数、p4 首圈样式)两档都抓到;`reasoning.enabled: false` 被该端点拒绝("Reasoning is -mandatory"),所以 none 也按 low 发。由此 `ProviderComposer` 增加 `extra_body`(挂在 provider 的浅拷贝上, -不动作者共用的实例),`thinking()` 按 provider 是否走 OpenRouter 决定放请求体还是放参数,插件配置 -`readerEffort`(产品配置设 low,对照测试记 `TRUNK_ONLY_SLICE`)。并发从 8 提到 16,15 页一批读完; -每页再加 120 秒自己的超时(重放时 14 页 13 到 40 秒答完、1 页永不返回,整轮空等到 240 秒)。 -不闭环的根因是意见只活在一条回复里:读者第 12 轮点名 p14 正文被插画盖住,之后三轮无人回应就交付了。 -现在每条意见进 `review/ledger.json`,带 id、页码、kind、原文、首见/末见轮次和当时的渲染指纹;同页再读, -按 kind 加词面重合(中文按二元组)匹配到旧条目则 `times_seen` 加一,没再报的关掉记 `fixed`(并记渲染 -是否变过,区分"修好了"与"读者改口"),未读的页一律不动;作者可以 `ppt_review(dismiss=[{id, reason}])` -逐条给裁决,不带 pages 时只更新台账不读页。每次 build 回复都带 `open_findings` 和一句"仍有 N 条未答", -读者仍然不拦任何发布。读者的提示词加入 brief 里用户禁止的事项(那次实跑三轮都要求删模板插画,与 -用户"沿用模板"相冲);build 触发阅读时把本轮 `slides` 指名的页排在积压页前面(第 14 轮读的是积压的 -p12 而不是刚改的 p4)。在同一本 deck 上重放两个状态(build 7 的渲染、交付版渲染):第一轮 14 页 242 秒 -(被那一页拖满预算,其余 40 秒内答完)开 42 条,第二轮只读变过的 8 页 35 秒,匹配到 11 条重复、关掉 -11 条、新开 12 条,p14 压字、p10 五与六、p15 无联系方式、p9 无分数都在未答清单上,与人工复盘一致。 -低档下读者话多(每页约 3 条,其中 oversized_shape/listed 一类口味项占比高),台账的清单会长,这一点 -还没处理。 - -**D29 同题 A/B:作者也走 low,32 分钟交付;发布后的回复必须报路径。** 9 月 4 日用 9 月 3 日同一条 -题目、同一模板重跑一次,读者 low、台账、按页超时全部在线,作者的 low 通过产品配置 `modelOverrides` -给 glm 加请求体 `reasoning.effort: low`(实验只写在安装目录的配置里,未进仓库)。结果:全程 32 分钟 -(对照 165 分钟),子代理一轮跑完(对照三轮、两次提前结束),模型调用 46 次共 23.7 分钟、中位数 15 秒 -(对照 48 次 127 分钟、中位数 93 秒),空回复 0 次(对照 7 次),三次阅读共 54 秒读 28 页(对照五次 -20 分钟读 23 页),最长一次调用仍有 278 秒、2.5 万字符思考。代价在两处:一、作者查资料少了(9 次 -搜索、1 次抓取、0 张图片,对照 13 次搜索、9 次抓取、4 张照片加一张生成插画),15 页里 8 页保留模板 -的白板人物插画,页面干净但模板味重、论据薄;二、build 失败 6/10(对照 4/15),其中 5 次是同一个 -`_NoneColor.rgb` 错误在逐页 draft 里反复撞上。台账这边,作者一次调用把 40 条全部 dismiss,理由多为 -"模板自带插画、用户要求沿用"和"评审页码错位",两秒内完成——裁决通道被当成清空键,还没有对策。 -交付这边暴露新问题:发布后模型最后一步 `cp` deck 被 exec 策略拦下,它的最终回复就是那句拒绝 -("操作未完成,是否继续?"),带问号,钩子按"向用户提问"放行,主 agent 收到的是一个关于它不知道存在 -的 deck 的问题,用户什么都没拿到。`after_iteration` 现在多一条:本轮已发布而回复没有提到交付文件名, -回滚一次并注入交付路径(含 PDF),要求回复报路径和页面内容;命名了就放行,一轮只提醒一次。主 agent -那边在子代理返回后翻了三次目录就结束、没有给用户任何消息,这是主机侧 loop 的事,另行上报。 - -**D30 模板插画是占位图,不是"沿用模板"的一部分;撤掉 pptx 拷贝禁令。** 用户裁定:任何与页面内容不符的 -插画都要换,"模板自带"不是理由。读者提示词现在对每页都说明模板插画是占位、不切题就报 `figure`;台账的 -dismiss 对 `figure` 条目拒绝只讲归属的理由(模板自带/原有/沿用/保持统一,中英文各一组词),除非理由说了 -画面画的是什么、为何切题,被拒的条目留在 open 并在回复里点名。skill 同步写明替换途径(`ppt_generate_image` -按模板风格生成,或 `ppt_image_search` 找实拍)。另一边,D25 加的 `cp/mv … .pptx` exec 禁令撤销:它本来防 -的是把未过检的 build 拷进 out/ 冒充交付,而这一点已由 `published.json` 的 sha256 名单守住;A/B 实跑里它拦下的 -反而是主 agent 要求的"把文件存到工作目录"这一步,并让子代理以那句拒绝收尾。产品配置里不再有 pptx 拷贝规则, -对照测试的对应 ledger 行删除。 - -**D31 封面与结语的照片:全强度加深色压暗层、文字转浅色;洗白的整页照片报警。** 9 月 7 日上海城市规划那次实跑,封面和结语按 skill 当时的写法 `backdrop(alpha=0.30)`:夜景照片洗到 30% 压在白底上、黑字叠上去,渲染出来是一层灰雾;用户对照 WorkBuddy 的封面(照片全强度、深色压暗、白字)指出不好看。`backdrop()` 的默认改为那种形式:照片 `alpha=1.0`,其上一层主题 `dk1` 色的矩形 `scrim=0.62`(无描边无阴影,`is_panel` 的 0.8 门槛之下,不触发 `covered_shape`),页面上每个文字 run 改为 `lt1`,已是饱和强调色(通道差 ≥70 且最大通道 ≥110)的 run 保留;`scrim=None` 且 `alpha≤0.12` 是纹理形式,给模板自身底色之后的淡纹理用。新增 WARNING 门禁 `washed_backdrop`:覆盖页面 80% 以上、`alphaModFix` 在 12% 与 80% 之间的图片。校准:8 套模板、8 本已交付 deck、WorkBuddy 原稿均 0 条,只有上海 deck 的第 1、13 页各 1 条。用上海 deck 的两张原图按新默认重画封面与结语并渲染核对:标题白字可读,照片保住了夜景层次;同时暴露出原图右下角带媒体水印,洗白时被盖住、全强度下显形,图源质量是另一个问题。skill 表格与正文、`ppt_template` 回复里 0.2/0.35 的洗白建议一并改掉。用户看过压暗版后仍不满意:标题只是压在照片上的纯文字,模板封面自己的构图(胶囊标签、蓝色下划线、箭头、星光、右侧插画)没有被用上。追查作者脚本:它确实 `adapt(prototype(tpl, 1))` 了模板封面,但随后自写 `clear_layout_art` 用 `drop_shape` 删掉版式上的整组装饰(因为洗白照片盖不住版式艺术),再铺照片、再手画四角框和星光。根因有三:`replace_picture` 明说版式上的图形不能换(提示去用 backdrop);`ppt_generate_image` 的提示词规则"真实地点一律摄影"把上海推向照片;skill 的封面段只讲 backdrop。验证过的正确做法:`replace_picture(layout_pictures(slide)[0], image, 'cover')` 把图放进版式上的插画位(blue_minimal 的插画是版式组里的一张光栅图,`layout_pictures` 能找到),构图全部保留;再让 `ppt_generate_image(transparent=true)` 按模板画风(扁平、粗黑描边、#155FFD 蓝、#FDA211 黄、星光)生成上海天际线剪影,135 秒、62% 像素透明,放进插画位后与模板如同一手所画。据此:`drop_shape` 拒绝版式上的形状并指路;`ppt_template` 的 layout_pictures 提示、`ppt_generate_image` 描述、skill 封面段都写明"封面封底保留模板构图,插画位放同款画风的剪影,照片位放照片,整页照片模板才用 backdrop"。另一个副产品:stack3/stack4 重建脚本此前从 fork 树拷模板,8 套与主干清单的 sha256 全部不同(fork 的 blue_minimal 是 13 页含 2 隐藏页,主干版是 15 页),已改为 `fetch_templates.py` 拉注册表正版、fork 仅作兜底。 - -**D32 成品要把模板厂商的署名剥干净,PDF 标题跟着 deck 自己的题目走。** 9 月 9 日用户在 WebUI 里打开夜间经济 deck 的 PDF 预览,阅读器标题栏写的是"iSlide PowerPoint Template"。查了 8 套内置模板和两本已交付 deck:厂商的名字不在任何一页上,却签在文件里五处:`docProps/core.xml` 的 `dc:title`/`dc:creator`、`docProps/app.xml` 的 `Company` 与 `TitlesOfParts`、主题(`a:theme`/`a:fontScheme`/`a:fmtScheme`/`thm15:themeFamily` 的 `name`)、插件写的 `ppt/tags/tag1.xml`(`ISLIDE.GUIDESSETTING`)、版式上一段文字 run(`slideLayout4` 的"iSlide"),部分模板的样例页上还有"Designed by iSlide"。deck 由模板克隆而来,全部继承;LibreOffice 转 PDF 时又拿 `dc:title` 当 PDF 标题,于是用户看到的是厂商名。处理放在引擎里而不是重切模板:`services/publish/provenance.strip_vendor_marks` 在 `BuildStage.run` 拿到成功的构建产物之后、测量之前,就地改写这一个 zip:core 的标题改成 intake 记录的 `topic`(没有就删掉标题元素)、作者与最后修改人改为 Raven、含厂商名的关键词/描述删除、修改时间改为当下;app.xml 的 `Company` 清空、其余含厂商名的字符串改为主题名;主题各 scheme 的 `name` 改为 Raven(配色、字体、版式一律不动);tags 里厂商前缀的记录删掉、别人的保留;`docProps/custom.xml` 里名字或取值含厂商名的自定义属性(`ISLIDE.THEME`)删掉、别人的保留;文字按段落处理并区分归属:layouts/masters 是模板的,含厂商名的段落整段删掉("Designed by iSlide"少了名字也不是谁要的说明,厂商常把名字单独放一个 run,所以按段不按 run);slides/notes 是 deck 的,只删"纯署名"的段落,即除了厂商名或其网址之外一个字词都没有的段落,12 套模板的封面与结尾页上的"www.islide.cc"正是这种,而"iSlide market analysis"乃至"PPT by iSlide"这样短的标题都是 deck 自己要说的话,原样保留(评审两次复现:按 run 删词会把整句删空,按署名惯用词表判断会把短标题删空,所以不设词表);形状名与替代文字里的厂商名去掉。模板自带的两页版权/推广页整页是厂商的话,不在这一步的范围,那是选页的事。放在测量之前,是为了让门禁量到的、staging 摘要指向的、发布出去的、`_pdf_beside` 从评审渲染拷出来的,是同一个已清理的文件。非 zip 的输入原样放回(测试用的占位字节、写坏的构建),这一步是收尾不是门禁,它不做拒绝。用户自带的 iSlide 模板同样受益,这是不重切内置模板的另一个理由;内置模板本身的署名留在注册表里,以后重切时再处理。 - -**D33 生图工具骑在宿主的媒体传输层上,配置只在宿主配一次,画风从页面上看、不从模板名里背。** 9 月 9 日排查发现 `ppt_generate_image` 与宿主 `image_generate` 各写一份 HTTP 传输:引擎那份只会 POST `/images`,插件装配时又没把 `image_config` 传进去,于是地址恒为 OpenRouter、模型恒为 gpt-image-2、代理为空,key 只从 LiteLLM 顺手写进环境的 `OPENROUTER_API_KEY` 兜底读到;`tools.media.image` 无论怎么配都不生效,`.env.example` 里的 `PPT_IMAGE_API_KEY` 因此是误导,宿主 LLM 一换成非 OpenRouter 网关生图就断;也不支持参考图,不记用量,尺寸表在 OpenAI 兼容端点上送的是 gpt-image 不接受的 1536x864 一类。处理:(1) 宿主的 `ServiceLocator` 新增三项授予,`media_config(kind)` 按宿主自己 `image_generate` 的规则实时解析 `tools.media.`(含向 `providers.openrouter` 借 key),`media_proxy`,`web_config()`;`RuntimeHandles` 新增 `usage_recorder`,插件的 `SessionTool.bind_runtime` 把它转给装配。(2) 引擎工具改为宿主 `ImageGenerateTool` 的薄包装:模型分流(gpt-image/seedream/qwen-image/grok-imagine 走 Images API,其余走 chat/completions 的 image modality)、编辑与参考图、用量记账全部沿用宿主;自己只留绿幕抠图、按 model+quality+ratio+参考图+prompt 哈希的缓存、进 deck 素材目录并 ingest。新增 `references` 参数(figure id、源文件名或绝对路径,至多 6 张),比例扩到宿主的八种,quality 与宿主对齐为 low/medium/high,回复里带 `width`/`height`,任何格式的回图都转成 PNG 收进 sources。工具始终装配,但声明 `configured()`(宿主 `plugin_surface.py` 里新立的可选声明),宿主装工具表时每次实时问一遍,无 key 无 model 就当轮 withhold,与宿主自己 `image_generate` 的规则和可逆通道一致;评审复现过在装配时一次判定的写法:先空后配的宿主永远见不到这个工具,先配后删的宿主则一直看见一个每次都失败的工具。缓存键里的 quality 用宿主实际发送的值(Settings 设了就以 Settings 为准,`ImageGenerateTool.effective_quality`),不用 deck 自己的默认值,否则 Settings 从 low 改成 high 后同一请求会被旧的低质量文件顶掉;命中缓存时同样回 `width`/`height`。(3) 尺寸:宿主 `images_api_size` 对 gpt-image 家族在 OpenAI 兼容端点上只送 1536x1024 / 1024x1536 / 1024x1024 三帧之一(按横竖取最近),其它家族沿用像素表,OpenRouter 仍传 `aspect_ratio`;作者按回复里的实际尺寸做 cover-fit 裁切。(4) 配置继承:`product_render.inherit_media_image` 由 raven-design 的 `configure_image_generation` 抽出共用,Raven-PPT 的 run.py 先整块继承宿主 `tools.media.image` 与 `tools.media.proxy` 并设 `selectionConfig` 实时跟随,再叠 `.env` 的 `PPT_IMAGE_API_KEY/API_BASE/MODEL` 作为产品自己的覆盖;插件节里的 `webProxy`、`imageSearch.apiKey` 退为覆盖项,缺省从 `web_config()` 读宿主的 `tools.web`。(5) 过拟合:工具描述与 skill 里"插画模板的插画位放模板画风的透明剪影、蓝黄黑扁平上海天际线"这一支写的是 iSlide 那批模板的形态,用户自带模板未必有插画位。改为看渲染定画风:先看主体(真实地点、人群、产品一律摄影),再看这个位置上模板自己放的是照片还是绘画,用眼睛看到的东西描述画风(扁平矢量、线稿、3D、水彩、配色、描边粗细),并把模板自己的插画作为 `references` 交给模型去匹配;不许点名任何没在页面上看到的模板或风格。通用规则"真实事物摄影、抽象概念随模板"不变。 - -**D34 压缩用宿主的,台账是 deck 自己的:用户原话逐字保留,deck 状态从盘上列出。** 9 月 9 日梳理上下文压缩:主 raven 的 compaction 默认关,Raven-PPT 的 config.json 也没有 compaction 段,一个 20 页 deck 曾涨到单次调用 45 万 token(137 轮 6200 万输入 token),因为没有任何东西在天花板之前压缩。宿主的压缩机制本身够用:阈值 `min(window - reserved, triggerRatio * window)`,保系统前缀和第一条 user,尾部逐字保留,中间由当前模型写交接摘要;skill(`always: true`)在系统前缀里,永远不会被压掉。所以产品只开它(`compaction.enabled: true, triggerRatio: 0.85`),阈值、图片字节预算、尾部长度全部沿用宿主默认,不另拍数字。deck 与 coding 的差别只在两处,都由插件的 `before_iteration` hook 在每次压缩后以 `append_note` 补一条"deck 台账"解决:(1) 用户的原话是 deck 的需求,不能被摘要改写。在真实轨迹(self_evolving,314 条消息、估 26.7 万 token)上回放宿主压缩,glm-5.3-flash 写的摘要丢掉了页数上限、简体中文、简洁风格、两条 ask_user 答复和源文件路径,只剩封面反馈和输出路径;台账从 session 记录里逐字抄每条 user 消息和每条 ask_user 问答(去掉 untrusted 包裹),上限 24k 字符并在超出时声明。(2) deck 的工作状态已经在盘上:brief(含 ask_user 确认写进去的 notes)、模板、素材数、figure 目录、outline 每页的 claim 与 layout、第二读者未关闭的 findings(含 seen 次数,最多列 30 条)、已发布数、上次拒绝原因,台账直接列出并写明路径,让模型去读文件而不是凭记忆重建。台账首行带所答摘要的摘要值,同一条摘要只补一次,再压缩一次(宿主把新摘要插在保留尾部之前,旧台账可能还留在尾部)就按新摘要值再补一次;检测在每次迭代都做,包括第一次调用溢出后以 iteration 1 重试的那次。用户原话有两处来源:宿主交给 hook 的 session 记录只到上一 turn,所以当前 turn 的进站消息在 before_user_inbound 时、ask_user 答复在每次迭代从窗口里,先记进 deck 目录下的 `.deck-words.json`,台账再把记录与这份日志去重合并。宿主不动:摘要提示词、图片窗口、tool 结果都保持主 agent 的做法。 - -**D35 三档强度按实测定:medium 全低思考,high 封顶十次整本 build 三轮阅读,max 不封顶;四类门禁噪音不再重复,读者的回复必须落在自己那一页。** 9 月 9 日逐 build 读了六条 deck 轨迹(34/13/8/3/20/20 页)的门禁与第二读者意见。有效捕获(文字互压、盒外溢出、分隔线压字、表格被裁、孤行)都在前几次 build 修好;有效修复最晚落在第 10 次整本 build 与第 3 轮阅读之内(自进化 deck 第 20 次 build 后门禁数不再下降,之后 6 次整本 build 11.5 分钟只产出串页误报、口味意见和用户改封面;其余五条 3 轮之后被修好的意见为 0)。据此:产品有了 `modes/medium.json`、`modes/high.json`,源配置即 max;high 的覆盖只写 `buildCap: 10, readingCap: 3`,medium 再加 `reasoningEffort: low`(D29 实测 32 分钟对 165 分钟);默认档 high。档位经 hook 把 `mode_overlay` 写进 deck 目录的 `.deck-mode.json`,`ppt_build` 每次读:整本 build 计数达上限时 blocking 也放行发布(回复里点名 released_at_cap 和留下的意见),阅读达上限时不再读。max 不写文件,两处都无上限。噪音四类进 `services/gates/quiet.py`:占位页(did_not_draw)只报失败本身,其余连带条目丢弃(每页曾多出 2-4 条);`repeated_layout` 在 outline 给了相同 prototype 或 layout 的相邻页上免报(15 篇论文页统一格式是用户确认的,曾报 17 次);`prototype_kept`("N% 的形状落在模板页位置")每页只说一次(曾 26 次、0 次被响应、交付时仍在);brief 里写了留白/白底时 `excessive_whitespace` 与 `sparse_container` 不报(曾 38 次要求"填满")。读者侧:`type`/`alignment`/`listed` 只在本轮回复里出现、不进台账(曾 26/19/22 条改完换个说法再报);草稿页数与 outline 不等时不给 plan、不产 `claim`(12 页草稿曾被拿去对 34 页大纲);回复里新增 `headline`,与该页 PDF 文字对不上就丢弃这一页的阅读(14 条 claim 意见描述的是别的页;离线重放同一 deck、同样 16 路并发未复现错位,先以这条检查兜底)。 - -**D36 深色模板从用户上传里切出来,缺的版式向其他模板借页。** 内置八套全是浅色,选模板的提示词还写着不要选深色,所以「深色科技风」这类请求从前无处可去。两套用户上传的深色 16:9 模板按同一条流水线切成成品:删隐藏的厂商广告页、按 D32 剥署名、把 37 张参考页全部克隆到深色母版上实测渲染后挑出它们缺的角色(图表页、大数字行、S 形路线图、前后对比、chevron 流程)。丢掉的候选按实测理由分两类:白字钉在 run 上压浅填充的(深底改不了 ink,判读不出来)和位图不跟色的。选模板提示词改成「问到深色、黑色、夜间或科技调性时才取深色标签,否则留在浅色里」。两页模板自带的中英双层标题在克隆后触发 word_collision(模板自己的渲染在阈值内的 17% 与 15%,克隆后放大到 53% 与 45.7%,放大的原因另查):先删英文层,再把序号与卡片图标整体上移 0.20in——这一版被自己的渲染推翻,图标被移出卡片压到横幅图上、序号贴住卡片上边框,模板原本给的 0.165in 边距没了。最终落地的是另一处:碰撞来自标题框底对齐,标题一折行就保住末行往上长,正撞进序号行;标题框改为顶对齐,折行往下长进模板本来就留的空档,正文框随之下移 0.133in 并等量缩高,五个 bullet 的 autofit 统一。用门禁自己的提取器量:序号墨迹止于 334.85pt、标题首行起于 340.08pt,两行标题止于 390.11pt、正文起于 395.31pt,两处净距都是 5.2pt,此前是 12.97pt 的重叠(占序号框 51%,过 40% 的 blocking 线),正文末行到卡片内下沿仍有 6.3pt。另有 gold_panel 模板五页上各有一个内容只是一个 12pt 句点的文本框:页面上看不见(渲染前后差 16 个像素/921,600),但提取器看见一个 3pt 的框落在正文行里、报到 98% 重叠,克隆过这些页的 deck 一律被拒;清掉 run 保留段落后该模板从四页 17 对 blocking 归零,十套模板再无同类。 - -**D37 用户点名的交付路径由引擎自己写,说一次、记一辈子,发布一次、抄一次。** 用户的请求经常连地方一起说:"把 .pptx 输出到 `~/deliverables/` 下、叫 `ravenx-intro.pptx`,回我绝对路径和页数"。此前发布步只写 `out/.pptx`(加同名 PDF),`published.json` 只记这一处;模型要满足请求只能在 build 之后自己 `cp`(`agents/raven-ppt` 的 exec 不设围栏),而 `after_send` 只认 `out/` 下且 sha256 在名单上的文件,于是唯一给出了用户所要路径的那条回复被追加一句"No verifiable deck was published"——请求只能靠侥幸达成,验收还与之矛盾。改法沿 palette.json 的先例(说一次、随 deck 保存):`ppt_build` 多一个 `deliver_to` 参数,接受绝对的 `.pptx` 路径或以 `/` 结尾的目录(目录取 `out/` 里的 deck 文件名),校验四件不需要判断的事——绝对路径、`.pptx` 后缀、目录可建(当场 mkdir)、不在引擎自己的 `deck/`(未过检的构建)与 `out/`(发布步已经在写)之内——通过后写入 `deck/state/delivery.json`,再说一次即覆盖,从不删用户的文件。校验放在三道顺序检查之前:在缺 outline 而被拒的那次 build 上说的目的地照样保留,作者补完 outline 再 build 不必重说。之后每次发布(首次交付和用户改完后的每次修订)都先照旧写 `out/`,再由 `services/publish/deliver.deliver` 把同一份字节原子地抄到目的地(临时文件 + `os.replace`,抄前抄后各核一次 sha256),并以 `role: "delivery"` 记进 `published.json`——同一个 digest、两条路径,后续回合由此知道交付到了哪。被拒的 build 走不到这一步,所以目的地上永远是最后一份过检的 deck,不会是半成品;抄写失败(目录变成了文件、无权限)不是拒绝——`out/` 已经发布——而是 `delivery_failed`,作为回复的第一句话让作者转告用户或换路径。回复这边:`ppt_build` 的 `next_step` 第一句改为"tell the user in these terms: the deck is at <绝对路径> and has N slides",被拒或 draft 时报 `destination_unchanged`(目的地仍是上一份)。验收这边:钩子在回合开始的快照里把记录上的交付路径连同 `out/` 一起取 mtime,`verified_deck` 把它们当作与 `out/` 同等条件的候选(本回合写过、能开、在名单上),整路径先于文件名匹配,因为目录形式下交付文件与 `out/` 同名;`out/` 与记录上的交付路径之外一概不看:验收的判据只有 digest 在 `published.json` 上这一条,不去回复正文里扫路径。中途有过一版 `foreign_decks`,从正文里扫 `.pptx` 路径、区分"字节是已发布 deck 的"与"未过检构建的",再由 `after_iteration` 回滚注入、`after_send` 追加纠正;评审指出它会把回复里作为素材提到的源文件读成目的地,于是纠正让模型把交付写到用户自己的演示文件上(`os.replace` 原子覆盖),而它自己的 docstring 就承认带空格的路径扫不到——模型能判断的事交给固定程序去猜,覆盖不全是必然,所以整段删掉而不是收窄。作者那边本来就有一句在说这件事:`ppt_build` 的描述写着"A copy you make yourself with exec is not recorded and not the deliverable",交付就是 `deliver_to` 记下来的那份。PDF 预览只在同名 `.pdf` 空着时才抄到 deck 旁边:那条路径原先是在 `as_destination` 通过之后才从目的地派生的,因此没经过任何检查,而 `_replace_with` 无条件替换——交付 `intro.pptx` 到用户目录时,他自己的 `intro.pdf` 就被换掉了,`Delivered.preview` 还把它报成本 deck 的预览。覆盖用户点名的 `.pptx` 是他要的;旁边那个 `.pdf` 是引擎自作主张的便利,而记录只能证明交付的 `.pptx` 是我们写的,证明不了某个既存 `.pdf` 是,所以本模块自己立的规矩说了算:"nothing here ever deletes a file the user has."。两条路径改为同处派生并判定(`destination.sidecar_for` → `Destination(deck, sidecar, free)`),判据是名字空不空而不是内容像不像(看起来像我们写的仍然无法证明是我们写的,嗅探内容去猜正是这里拒绝做的判断);名字被占时不写,预览留在发布步已经放好的 `out/` 里,`Delivered.preview` 保持 None(它只指本次交付真正写下的文件),`preview_kept_back` 经 `delivered_pdf_kept_back` 进入回复让模型转告用户。这第二次写入也补进了模型读得到的地方——`deliver_to` 的描述与 skill 的交付段——因为设计记录与 changelog 不是模型站的地方。任务文本末尾的 staging 段、`ppt_build` 描述、skill 的收尾段都写明这条路;prompts/ 下的 AGENTS.md 与 TOOLS.md 被启动器测试逐字节钉在 fork 快照上,不动。未说目的地的 deck 行为完全不变。 - -**D38 已发布的 deck 站着时,不再把「编译 deck 并给 MEDIA 行」追加给每一轮。** 插件的 before_user_inbound 改写每条用户消息,而 materials.describe 的两个分支都以同一句收尾:把 deck 编译到 out/ 并在最终回复里给出 MEDIA 行。交付之后用户说「就这样」的那一轮也带着这句,于是模型又去发布一次。现在 describe 在 published.json 里已有这个 deck 时把结尾那句换成一段陈述:deck 的路径、改它就要再 build 并发布、MEDIA 行只来自发布。判据只有「发布记录里有」这一条——别人拷进 out/ 的副本不算发布——不去判断这一轮是不是 deck 工作:那是模型读用户消息的事。中途有过一版把「本轮是否暂存了新材料」也当条件,评审指出暂存账是整个会话的,第一轮给了源文件的普通 deck 到第二轮账上仍非空,于是该版对最常见的路径无效;改成按本轮新增判也只是把固定程序猜意图的范围缩小,所以条件整个去掉。材料清单与这段并存而不互斥:清单说的是文件夹里有什么,这段说的是记录上有什么,一轮里可以都需要。但"没点名素材就自己去采集(web_search / ppt_image_search / web_fetch / ppt_fetch)"那段不并存:它是写给还没有素材的 deck 的,记录上已有 deck 的那一轮它不只是多余而是过时,所以 deck 站着且本轮没暂存新素材时只发这段陈述——依据仍是发布记录这同一个事实,不是对这一轮意图的判断。没有 deck 站着时,两个分支的文字一字不动。另两个分支的文字一字未改,因为它们正是 fork 被对齐的那两种输入。这同一个事实也带进了回合末的守卫。`after_iteration` 原先只知道「本回合有没有发布」,于是被告知 deck 站着、也没拿到编译指令的那一轮,普通的「就这样,谢谢」照样被回滚两次并收到 "continue: build ... and publish",只有两次上限放它过去——评审逐相地复现了这条生命周期,而这与这段陈述的用意正相反。开局记录上有没有 deck,经 `deck_stood` 从 before_user_inbound (以及跳过入站相的那种回合的 before_iteration)带到 after_iteration,两处读的是同一个 `_standing_decks`;开局有 deck 站着、本回合又什么都没 build,就没有什么是这条回复漏发的,守卫放它结束。「本回合没 build」这一问原先是拿 `out/` 下有没有记录之外的构建来答的,而那个前提是错的:`BuildStage.run` 在有 blocking 判据时于 `stage()`/`publish()` 之前就返回,草稿从头到尾不发布,脚本崩掉的那次两者都到不了——三条路都真的 build 过,却都把 `out/` 原样留在那里。于是一轮改稿的 build 被拒之后,守卫读着空的 `out/` 说「本轮没 build」并放它结束,正是这条守卫本来要挡的那种提前收场:被拒的判据没人去修,run 就停在这里。现在判据是开局就取的一份构建痕迹——`deck/build/deck.pptx`、`deck/build/build.py`、以及 `review/build_failures/` 的条目数——回合末再取一次比对,动过就是 build 过。这三样各对一条路:候选盖住被拒与草稿,程序盖住只改脚本没 build 的那一轮,失败目录盖住脚本崩掉的那次。取不到开局那份痕迹的回合不给豁免:宁可守卫多响一次,也不豁免一个说不清的回合。判据仍是记录与文件夹,不是对这一轮意图的判断。真的没有活干的那一轮照旧能结束——没 build 就是痕迹没动,「就这样,谢谢」还是一次就过。两次上限是逃生口而不是答案,所以没有去调它。 - -**D39 生图不做缓存:每次调用都真生成,同名覆盖。** `ppt_generate_image` 原来按 model、有效 quality、比例、参考图摘要和提示词算摘要做文件名,文件在就直接返回并标 `cached: true`,一次请求都不发。而这个工具存在的用途恰恰是「这张不是我要的,再来一张」,而那次请求带的提示词与第一次一字不差,于是把用户刚否掉的那张原样还给他。缓存分支已删,回复里不再有 `cached` 字段。文件名仍按同一组输入算,这是有意的:页面已经引用了这个文件名,重新生成覆盖它,新图不改脚本就上页。代价是模型若在循环里连发同一提示词会连着付费,这一类由宿主的重复调用断路器兜。构建脚本不调这个工具,所以整本重建不会重新生图。 - -**D45 换字不换框,装不下的那一刻就说,判据只在测量噪声之外开口。**(编号跳到 45:主干到 D36,D40-D44 占在未合的分支上。) 9 月 10 日 medium 档一条 14 页实跑走通了「克隆模板页 + replace_text」这条路:14 页全部取模板原型,`adapt` / `fill` / `add_slide` 零次,空页与残留模板文字这两类缺陷彻底消失,换来的是一类新的——`replace_text` 原样保留原型的几何,比被替换的那句更长的文字无处可去。逐页读渲染图确认了五种表现:折行破坏一行卡片的共同基线(p9 的「Legacy pressure」折成两行,整块标题被顶高)、autofit 降一档字号破坏一行卡片的统一字号(p2、p13)、正文越过安全区压到模板亮色渐变上(p3/p4/p6/p9/p13 的 1、3、5 号卡)、挤住下一个元素(p11)、直接压住邻居(p12 的标题被来源行压住),以及最坏的一种:**框底对齐时文字向上长出画布**——p14 的标题在 72pt 下长到 4 行,"Buy the" 被幻灯片上边缘裁掉,压住 kicker 行和它的分隔线(重建那一版渲染核对过)。门禁事后全数报到了(p14 一条 BLOCKING `word_collision`,加 `overset_copy`×8、`clipped_copy`×4、`wrapped_label`×4、`type_drift`、`type_floor`、`box_overflow`、`rule_strike`),但都在终点线上:整本写完又渲染完,作者要一次回答 14 页。 - -选的缝是 `replace_text` 自己,因为只有它同时握着两半:框,和模板放在这个框里的那句话——下一行就没有了。判据因此不是「按盒子高度算出来能放几行」,而是「盒子放得下的行数,与模板设计者实际放进去的行数,取大」:前者是拿一个假定的行高系数做的算术,后者是事实,两者相左时事实赢。这条结构让「把模板自己的文字写回模板自己的框」按构造必然沉默,而不是靠某个恰好成立的阈值。说话的时机是程序结束(`atexit` 注册的 `_say_what_did_not_fit`),不是写入的那一刻:页面在文字进去时还没写完,那条实跑的封面写完三行标题之后才把 run 设成 44pt,按写入时生效的 72pt 去量报的是「首行被上边缘裁掉」,而渲染图上这一页完好;真正发运的是程序末尾的文字、字号和框(作者随后还可能 `place` 把框放大)。延后之后这条误报消失,另有一个副产品:警告落在 stderr 末尾,而 `ppt_build` 回复里的 `warnings` 取 stderr 的最后 3000 字符,留下的正好是它们。 - -只在测量噪声之外开口。宽度测量器(`assets/text_metrics`,FreeType 与导出方言取大)从不把字符串量得比任何一方会画的更窄,这对跑在成品文件上的门禁是对的方向,也意味着这里每个读数都是上界。量了两遍:10 套内置模板自身的渲染里,182 个「整句就是一行、继承链上没有 autofit」的框,overshoot 中位 1.13、99 分位 1.27;一本已交付 deck 上 9 条未被缩放的英文行是 1.10 到 1.30。所以新文字按 `MEASURED_WIDTH_OVERSHOOT = 1.35` 加宽后的列去折行,只有活过这一步的溢出才出声——`wrapped_labels` 从另一头得到过同一个结论,把预测留作候选、交给渲染裁决,而这里在渲染之前。autofit 读形状自己的 `bodyPr` 而不读继承链,anchor 读继承链,两条都在同一个文件上量到:`black_circuit_tech_launch` 的卡片标签把 `normAutofit` 写在形状上,实跑里 20pt 的位置回来是 17pt;它的结语标题只从版式继承 `normAutofit`,4 行替换被按满 72pt 画出来并裁掉,所以继承来的 autofit 不是缩放,在这里读继承链会对丢了一行的那一页承诺一次缩放。反过来 `anchor="b"` 写在版式的标题占位符上、形状答 `None`:只读形状,p14 报的会是「往下跑」,而它实际是往上长出画布。缩多少不预测——第一版按 shows/needs 算出「接近 10pt」,渲染给的是 17pt。 - -校准与够不着的。10 套模板 211 页 2084 个文本框,每个框把自己的文字写回自己:0 条。这条实跑的 6 个脚本状态逐个回放(从第一次整本 build 到交付版):9 / 9 / 8 / 5 / 3 / 2 条,随作者改短而收敛;第一个状态的 9 条逐条对上了实跑自己在渲染侧报的东西(p7、p9 两条、p10、p13 的 `type_drift`,p12 的两条 `type_drift` 与一条 `overset_copy`,p14),0 误报;交付版的 2 条正是渲染里唯一两个降了档的标签(17pt 与 17.4pt),同一行另外三个标签不报。够不着两类,都记在这里而不是调阈值去凑:一、p11 那两个正文框在 14pt 下需要 4 行、盒子放 3 行,33% 的溢出,而测量器在这一页自己的渲染行上就偏高 16% 到 25%,单边界限无法把它和噪声分开——把 overshoot 降到 1.20 才够得到 p11,代价是交付版从 2 条涨到 8 条,其中 6 条渲染显示本来放得下。二、p12 标题被来源行压住不是盒子容量问题,是标题画出来的横向范围撞上作者自己新加的一个框,且标题框不声明字号(`overset_copy` 同样跳过它)。这两类仍归渲染侧门禁。 - -**D47 生图完全继承宿主配置,deck 侧不许自带 quality 默认值。** 用户裁定:"生图继承主 Raven 的配置,宿主配了什么模型就用什么,deck 的档位不区分生图模型;宿主什么都没配就用 gpt-image-2.5。"模型继承本来就对:`media_gen.py` 从宿主 `tools.media.image` 取模型,只在 Settings 没有模型时才落到 `default_model = "openai/gpt-image-2.5-sunburst"`。档位也确认没有钉:`agents/raven-ppt/config.json` 与 `modes/high.json`、`modes/medium.json`、`subagent.json` 里没有任何 image/media/quality 键(`config.json` 里出现的 `image_generate` 是 `disabledTools` 名单,不是钉),所以这一侧没有要撤的东西。**分歧只在 quality**:`tools/generate_image.py` 的 schema 声明 `"default": "high"`、签名写 `quality: str = "high"`、`_one` 又用 `or "high"` 兜一次,于是 deck 路径永远给 `effective_quality` 传一个非空值,那条「没人指定就交给供应商默认」的分支从 deck 里根本走不到。同一个默认模型,宿主路径不发 quality、deck 路径发 `quality=high`。改法是把这三处的默认值去掉(未指定即 `None`),解析权交回宿主:Settings 有就用 Settings 的,没有则 `gpt-image-2` 给 `medium`、其余 gpt-image 家族给供应商默认;调用方显式点名仍然照发。 - -**quality 对 2.5 到底有没有意义——实测而非推断。** 结论是**有**,所以正确的修法是继承而不是不发。三条证据:(一)OpenRouter 的 `/api/v1/images` 对 `quality` 做的是**与模型无关**的网关校验,`quality:"banana"` 对 `openai/gpt-image-2.5-sunburst`、`openai/gpt-image-2`、`black-forest-labs/flux-1.1-pro`、`google/gemini-3-pro-image` 返回的是**同一条** ZodError(`auto|low|medium|high|xhigh|max`),所以 `supported_parameters` 与这条报文都不能用来判断某个模型认不认。(二)**区分性探针**:`quality:"xhigh"` 打 `openai/gpt-image-1` 得 400,报文是上游自己的话——`Provider rejections: OpenAI: quality: not supported. Accepted: auto, low, medium, high`;同一个值打 `openai/gpt-image-2.5-sunburst` 返回 200 并出图。也就是说 quality 确实穿透到上游、由上游按模型校验,而 sunburst 的档位集合比 gpt-image-1 宽。(三)OpenAI 自己的模型卡(`developers.openai.com/api/docs/models/gpt-image-2.5-sunburst`)写明支持 `low, medium, high, xhigh, max, auto`。**顺带记一条没有改的缺口**:宿主与 deck 两边的枚举都是 `low|medium|high`,所以 2.5 的 `xhigh` 与 `max` 目前无法从任何一侧发出去;两边一致,不构成本条要修的分歧,要放宽应当先动宿主那一份。 - -**D49 溢出改按「文字落在哪里」判:往回长并落到东西上的拒绝,往下长的照旧只报告;丢页的 build 不吃 build 上限。** 9 月 10 日一条 medium 实跑(`day_medium`,deck `20260910_065556_6f0e9e`)暴露两处,都在同一条链上。 - -**一、`overset_copy` 报了十次,一次没被响应。** 十次整本 build 共回灌 33 条溢出告警,第 2 页那三条一字不差报了十次;同一条 run 里 `repeated_prototype` 以 BLOCKING 报出,作者 21 秒内照四条点名的替代方案全改了。唯一差别是严重级。先核实了前提:`overset_copy` 并没有漏掉第 8 页——把那次 build 的暂存文件喂给它,第 8 页出 5 条 finding,包括作者告警里那句 `'CATL–HyperStrong: 60 GWh'`。之所以看着像没报,是因为 `state/blocking.json` 只记 BLOCKING,第 8 页的 `kinds` 于是是空的。**它真正瞎掉的地方是别处**:`_size_pt` 只读 run 上写死的字号,而克隆页什么都不声明,`spilled_copy` 早就为同一个坑改成经 `page_design`/`inherited_size` 解析;`_growth`(新)同样经 `template/house.title_anchor` 解析垂直锚点,因为克隆页也不声明锚点。python-pptx 默认模板的标题占位符正是这种:run 无字号、继承母版 44pt、锚点继承为 `ctr`,旧代码整框跳过。 - -**判据按方向分,不按超出量分。** 用户的原话是「不只是溢出,字被挪到别处了」,几何也是这么说:交付文件里第 3/4/5/8/9 页的标题都是 0.14in 处一个 0.98in 的框、锚点经继承是 `b`,一行时贴着框底看着正常,第二行是往**上**加的。所以: - -- **往下长**(顶对齐)——仍是 WARNING。理由不变,是 D2 的原话:修法是从邻居手里抢高度或砍一行,那正是硬拒不许迫使的交换,`box_overflow` 也一直是报告。十套内置模板自己的 42 条溢出全是这一类。 -- **自己缩字号**(`normAutofit`)——不报位移。渲染器把字缩小、什么都没挪,尺寸由 `type_drift` 读。这一条同时让新判据永远不会去要那个「缩下去又撞字号下限」的交换。 -- **往回长**(中对齐、底对齐)——新增 BLOCKING `displaced_copy`,且**只在落到了东西上时**:出画布,或压到同一列里另一个文本框的正文上。它逐条过了 D2 给 `word_collision` 定的那道例外测试:不是口味(渲染可证——那一页四张卡的标签各把自己第一行压在下面那句话上,`A.pdf` 第 8 页量得 `'CATL–HyperStrong'` 的墨迹从 1.480in 起、自己的框在 1.68in,第三行落进 2.34in 起的正文框,pymupdf 直接把两者并成同一个文本块);不能靠缩字号解决(会缩的框根本不在报告范围内);它要的动作不花代价(改锚点到顶,或 `place()`,框、文字、邻居一个不动);且十次告警实证不收敛。这不是 D11 否决的「按文件里的框两两比重叠」:全宽标题框和角上页码框共享 6% 宽度、且不在任何生长方向上,无论怎么摆都不报;报的是**位移出去的那条墨迹带**对上另一个框,且要求同列共享过半(`DISPLACED_IN_COLUMN`,和 `box_overflow` 对渲染行问的同一个问题同一个答案)。 - -**放墨迹的模型是实测的,不是沿用容量模型。** `LINE_HEIGHT_FACTOR=1.3` 是容量模型,故意往少算行数偏,用它放墨迹会把字推得比渲染器更远——对拒绝是错的方向。经 LibreOffice 渲 24 例(6 种字号、最多 18 行)标定:行进给是 `1.2 × size`,首行墨迹约 `1.0 × size`,于是 `size × (1 + 1.2 × (lines−1))` 在行数一致的 19 例上与实测墨迹高度**差 0.002in 以内**(`RENDERED_LINE_ADVANCE`)。行数用渲染器自己的宽度数(把 `RENDER_DRIFT_HEADROOM` 的 3% 让回去),因为留量是给「装不装得下」用的,带进放置会多算行。超过约十行 LibreOffice 改为裁掉不画,模型这时偏大——但被裁掉的字读者同样看不到,那是 `clipped_copy` 的判据,不改本条结论。顺带一条实测推翻了先写的代码:`spAutoFit` 不是第三种情况,LibreOffice 无视它、照锚点排(同一个 2.4x0.56in/20pt 框,带与不带 `spAutoFit` 三行墨迹落在同一个 y)。 - -**覆盖面与刻意留下的洞,都给数。** 十套模板共 2084 个带文字的框,其中 **815 个没有任何 run 写字号**——这就是旧代码整框跳过的那 815 个,现在全部由继承链解出,「没有字号可读」的洞归零(finding 的 `detail.inherited_size` 说明这一条的字号是继承来的)。仍然不看的两类各有主:202 个 `word_wrap` 关掉的归 `spilled_copy` 与 `wrapped_label`;1380 个短于 `MIN_COPY_CHARS`(24 字符)的归 `wrapped_label`,模块注释原本就写着「短于此即标签,`wrapped_labels` 有它自己的余量」。**这第二个洞是真漏**:同一页第 8 页那两个圆里的 `'Sodium-ion'`(10 字符)与 `'Long-duration (LDES)'`(20 字符)在渲染里同样压到左边那段正文上,本条判据看不见它们。留在 `wrapped_label` 名下,不在本条范围。余下 502 个框按适配度量。 - -**潜在暴露量。** 独立的容量测量(1116 个可判读的框)给出锚点分布 top 65% / ctr 25% / bottom 10%,即**往回长的中对齐是更大的一类**(276 个)。其中 382 个的几何撑不住一句 40 字符的英文标题——但 245 个是 `normAutofit`、本条不报,所以真实暴露是 **137 个框**(134 spill + 3 spAutoFit):`replace_text` 往这些框里写一句英文标题,就是第 8 页那个缺陷。锚点来源也值得记:模板自己的 1116 个框里 111/116 个底对齐写在形状上,而**克隆出来的 deck 页什么都不声明**——交付文件那五页标题的 `anchor` 属性是空的、`b` 是从版式/母版解出来的,这正是只读 slide 会瞎掉的原因。 - -**升级前的标定,逐模板给数。** 十套内置模板把自己的原文写回自己的框:42 条 `overset_copy`、**0 条 `displaced_copy`**(amber 0、beige 0、black_circuit 7、blue_minimal 0、gold_panel 31、green_aurora 3、mint_memphis 0、red_chinese 0、teal 0、warm_bauhaus 1,方向全是往下或自己缩)。25 份已交付/已发布 deck:只有 `A.pptx` 命中,第 8 页 3 条,正是这次被点名的那一页。全证据树 382 个 pptx:19 个命中、共 44 条,除那一份外全是失败 build 的中间暂存文件——含 72pt 标题跑到画布上方 3.15–3.83in 的两例,正是 `house.py` 的注释早就记下、诊断过又只报警的那次复现。反例同样是实测定的:另一本已交付 deck 第 9 页三个中对齐胶囊也往回长 0.10in,但没落到任何东西上、渲出来完全可读,所以不报——「往回长」本身不是缺陷,落到东西上才是。 - -**二、发布那次 build 的回复没有任何人读。** 最后一次 build(07:33:56,`failure-005`)多报 5 条溢出、并两次 `NameError: name 'raise_type' is not defined` 丢掉第 10、11 页;`llm_input_cache.json` 停在第 53 行,喂回模型的 build 编号只到 10。cap 在 build 10 触发后发布、deck 阶段结束,这份回复写进盘就丢了。而 `builds.json` 记着 `whole: 14`——cap 之后每次 build 都发布,作者一路建到 12 页,机制照旧。 - -**改法在计数处,不在写入处。** `stages/build.py` 到 cap 时把 blocking 清空、照发布,这一点保留:用户拿到 deck 比拿不到好。改两处:(1) **丢了页的 build 不吃整本上限**。`tools/build.py` 本来就不数「没产出 deck 的脚本」——注释原话是「它花掉的预算会让第一份真正存在的 deck 在一次 build 之后就被放行」——丢了第 10、11 页的 build 对那两页是同一件事,于是改记在自己的小额度上(`services/tier.count_lost_build`,`CRASH_REPRIEVES = 2`:一次给那个确定性的修复,一次给另一种崩溃;连续第三次说明作者没在收敛)。总量因此封在 cap + 2,崩溃买不到 build。(2) **发布了但丢了页的 build 不许读起来像收工**。原先那句「已交付,没有任何东西拒绝它」是对一个 stand-in 页的错答案;现在改为点名丢掉的页、指到该页 `page_failed` finding 里的 traceback,并说清这次 build 算不算数。**最后一次许可的 build 也崩了会怎样**:照样发布,回复明说额度已尽、这次算数、现在交付的就是用户留下的那一份——run 永远以一份 deck 收尾,既不会不交付,也不会无上限。 - -**顺带记下、本条没有修的**:同一份交付文件封面上两个 6.68x0.40in 的文本框整个挂在画布下沿之外 1.11in(模板自带、清页时没碰过)。框本身出画布是 `off_page` 的判据(WARNING),不是本条的;本条对「框自己已经在画布外」的情形明确让路,否则 black_circuit 第 10 页那四个 11.48in 处的框会被重复报。 - -**顺带修的第三处:发布记录与交付文件谁该跟着谁。** 用户报「publication record 经常不再描述它指名的文件」。逐字查证:`day_medium` 现在是**一致的**(`published.json` 两条都是 `6c6b3cc…`,与 `out/deck.pptx` 相符),我先前读到的 `a371f23…` 是运行中途的状态、被后来的一次发布正确取代了。真正不一致的是 `tier_high`:记录写 `dc285b0…`,`out/deck.pptx` 是 `fc45ab6…`。**根因不是记录过期,是成品被改了。** `deck/build/deck.pptx` 与 `deck/state/staging/candidate.pptx` 六分钟后仍然都是 `dc285b0…`,也就是说没有任何东西被拷来拷去;轨迹里 08:14:18 那条 exec 写着 `python3 - <<'EOF' # Apply the same fix to the published deck (out/deck.pptx) in place`——作者就地改写了已交付的文件,08:14:48 又把这份没过任何门禁的文件 `cp` 成对外的名字。记录一直是对的,只是**写完就再没有人拿它对过那个文件**。 - -所以「记录每次发布重写,还是把成品在写记录的那一刻冻住」这个二选一,答案是**前者,且成品侧要能被查出来**:冻住成品与「用户必须拿到 deck」相冲突——到 cap 之后每次 build 都发布,本条前面加的崩溃额度又让这类 build 更多,记录只能跟着文件走。四处改动:(1) `publish` 在 `os.replace` 之后**回读落地文件**算 sha 并记录它,而不是记那份拷贝的摘要,两者不符即拒绝——记录「这个 sha 就是这个路径上的文件」于是由读那个文件来保证。(2) `record_published` 改为原子写(临时文件 + `os.replace`),写不成时抛 `PublishRefusedError` 而不是让裸 `OSError` 穿过只捕获前者的调用方,留下「文件在、记录还是上一版」——正是实测到的那种状态。(3) 新增 `tampered_delivery` / `unrecorded_deliveries` / `delivery_report`:把记录与文件重新对一次。`stages/build.py` 在**发布之前**问(之后就没有可比的东西了),答案经 `data["delivery_changed"]` 进 `ppt_build` 回复的第一条指令,点明这次改动没过门禁、修法是改画那页的程序而不是改交付文件。它只报告、绝不拦发布:对一份未经测量的交付,正确的答案就是把经过测量的那份盖上去,而调用方正要这么做。(4) `blocking.json` 增加 `deck` 字段记下被测量文件的 sha256(`regress.record` 的新参数,`_load` 忽略未知键所以向后兼容),于是「门禁、发布记录、交付文件说的是同一堆字节」变成可以离盘核对的事实。 - -**下游那个已经兑现不了的承诺,点名。** `plugin/materials.unpublished_decks` 与 `verified_deck` 只按 `published_digests` 的 sha 认人,`plugin/hook.py` 据此在回合结束时发 `COPY_NUDGE`——原话是「模型在一次被拒的 build 之后把自己的构建拷进了 out/」。就地改写的那种情形它同样会命中,但说的话是错的:什么都没被拷贝,被改的是成品本身,而两者要的动作相反(一个是去正常发布,一个是把改动放回程序)。新增 `TAMPERED_NUDGE`,`before_send` 与 `after_send` 两处都先问 `unrecorded_deliveries`,命中就说就地改写、否则才说拷贝。 - -**验证方式与第 8 页一样是实跑。** 真实 `BuildStage` 走 cap 为 2 的 medium 档、四次 build(每次产出字节不同的真 pptx):每次发布后三处记录一致;第 2 次在 cap 上发布后模拟作者就地改写 `out/deck.pptx`,三处立刻**不一致**;第 3 次(丢页、且是本条新增的「过了 cap 还发布」那条路径)把过门禁的 deck 写回去、`delivery_changed` 点名那次改写,三处恢复一致;第 4 次仍然一致;**运行结束时一致**。 - -**D50 克隆页上要画东西,先得有一个"清空这块地方"的操作;作者自己扫是必然扫漏的。** 9 月 10 日一条 -medium 档实跑交付了 10 页,第 6、7 页严重压字。它的程序(`failure-005/build.py:231`)克隆了 -green_aurora 第 4 页,`replace_text` 换词之后自己写了两个 sweep 想把地方腾出来,再往里画折线图和卡片: -`_sweep_text` 按"文字里有汉字"删,`_sweep_art` 按"尺寸大于 1.0x0.7in"删。**两个都不接受区域**,这是 -全部病根——手上没有"清空这个框"的说法,它只能按内容类型扫整页,于是两个方向同时失效:非汉字的 -`01`–`05` 序号全留(`_sweep_text` 按语言判),十个箭头 freeform 每个 1.27x0.68in 或 0.64x2.97in、 -两个维度各差一点点全留(`_sweep_art` 按尺寸判且没有包含性测试),而连接符水平的高 0.00in、垂直的 -宽 0.00in,**两个 sweep 都无条件放过**。两张图就画在这些东西上面。另有两处一并记下:`_sweep_art` 读的是 -`sh.width`/`sh.top` 原始值而不是 `page_position`,组内形状的坐标空间不是页面坐标空间(本仓已有的老账); -而作者最终"修好"这两页的办法是**彻底放弃克隆**,改成 `content_page()`=`add_slide(LAYOUT)` 从零画 -(交付版 `deck/build/build.py:238`)——压字没了,模板设计也没了,正是克隆这条路存在的理由。 -据此在 `services/template/compose.py` 补三个东西,都是给作者的(随 `ppt_template.py` 投影出去): -`page_box(shape)` 返回 `PageBox(x0,y0,x1,y1)`,即 `page_position` 加上被组缩放过的尺寸(模板上一个 -声明 6.80x2.64in 的图表实际画成 6.31x4.01in,按声明尺寸写的判据会整整差一半高);`shapes_in(container, -box, share=0.5)` 是 `shape_near` 的复数形式——`shape_at` 按序号、`shape_saying` 按文字,按位置找的复数 -形式一直缺着(运行期计数:这条程序 `drop_shape` 30 次、`shape_saying` 33 次、`shape_at` 0 次,它知道 -`drop_shape`,缺的是找法);`clear_region(container, box, share=0.5, keep=())` 就是那个配对,对 -`shapes_in` 找到的每个形状调 `drop_shape`,**并把删掉的和留下的都说出来**(返回值 `Cleared` 是删除 -清单的 tuple,另带 `left_standing`;还压过区域的形状同时进一条 warning,落到 build 回复的 `warnings` -字段)。判据是两边的:形状自身有 `share`(默认一半)落在框里才删;但若**框**有 90% 以上落在这个形状里 -而形状自身不到 90%,那它是框所在的那张卡,保留——模板把图表画在卡里,卡才是克隆这一页的理由。 -阈值是量出来的不是拍的:在 black_circuit 第 20/26/29 页图表区域和 green_aurora 第 4 页正文区域共六个 -框上,框内该删的东西自身占比 0.58–1.00,属于页面其余部分的 0.05–0.42,**中间 0.42–0.58 一片空白**, -所以一半落在空档里;卡在四个框上是 0.97–1.00 的框占比配 0.42–0.69 的自身占比,图表则两边都 1.00, -两个读数正好把卡和图表分开。零面积形状(连接符)按其唯一活着的维度算一维占比,否则任何面积比都是 0。 -`share=0` 是另一个问题——"什么压在这条带子上",此时不排除容器(skill 里判断克隆页页脚带子是否被占用 -就是这一句)。校准结果:六个框里**该留的一个没删**(四张卡、页面级波浪装饰、角上的细线、以及 green -第 4 页那条作者要 `replace_text` 的副标题 0.34 都留住了);**该删的漏了一个**——green 第 4 页 -`[24] '智能教育改变学习模式'` 自身占比 0.42,默认留下,`left_standing` 点名、渲染图上看得见, -`share=0.4` 就带走(`share=0.3` 会连副标题一起带走,所以报告比调低阈值更重要)。实跑复核:把 -failure-005 的第 6 页整块拿来,只把两行 sweep 换成一句 `clear_region`,走 script backend 跑同一模板, -箭头与序号全部消失、两张图干净(渲染图对照 before/after)。报告还顺手暴露第二个缺陷: -`replace_text(s6, "自动化与人工智能", ...)` 前缀匹配到了箭头组里的正文标签 -`自动化与人工智能助力产业升级` 而不是标题,所以清单里出现 `[12] text 'System prices fell 31% in…'` -——作者自己的标题文字被当作区域内容删掉了,这正是"要让意外看得见"的价值。顺带修掉 skill 里 -一处引用了作者根本 import 不到的 `page_box(s)` 的示例(那个名字当时只存在于引擎读端 -`services/measure/geometry.py`),现在两处 `page_box` 都按角点返回,同名不同义的坑关掉。 -另需说明:`adapt` 与 `swap_icon` 在那条实跑的引擎里确实不可 import(它的 `ppt_template.py` 95 个 -顶层 def、无 `adapt`),但**在主干上实测两个都在**(探针 `probe_imports.py`:provision 出 build 目录后 -子进程 import,`ppt_template.adapt` True、`ppt_icons.swap_icon` True),所以主干的指引可以点名它们。 -最后,图表那条建议("版式可以克隆,图表要用 ppt_charts 重画并放回同一位置")在 !628 的 -`fix/ppt_chart_pages_are_redrawn` 里,不在主干;本条只改主干上那句 `_CLONE_INSTEAD` 与 -`ppt_template` 克隆分支的 ask,让它们点名 `clear_region`。!628 落地后,那条图表句需要补一句 -"先 `clear_region(slide, page_box(...))` 再画"——一处修改,记在这里。 - -**D54 一个形状的全部文字就是这个键时,它先拿到替换;只提升精确命中,前缀契约一个字不改。** 9 月 10 日交付的一本 deck 里,第 6 页的标题还写着模板的 `自动化与人工智能`。作者写的是 `replace_text(s6, "自动化与人工智能", "System prices fell 31% in one year to $117/kWh")`,落点却是箭头组里一个正文标签,它的文字是 `自动化与人工智能助力产业升级`——作者要的那个字符串是它的前缀。没有报错也没有警告:作者要了一次替换,得到了一次替换,丢掉的是这一页的标题。根因是顺序而不是判据。`_pick` 取第一个命中,`_all_shapes` 按 XML 文档顺序(即 z-order,从后往前)走并把组就地展开,而这一页的 spTree 只有两个顶层子节点:一个 7 成员的组(展开后 25 个文本框)和最后那个标题占位符 `标题 1`。标题正因为画在最上层而排在最后,于是它永远排在组里每一个成员之后——模板"内容放组里、标题压在上面"这种常见画法,恰好让最可能被指的形状永远垫底。既不是阅读顺序也不是有意为之。改法只有一条:某个形状去掉首尾空白后的全部文字**等于**这个键时,它立即胜出,不看位置;没有精确命中时,仍然是原来的"第一个命中"。资格判定(`wanted in text` 子串)一个字没动,所以今天能匹配的调用没有一个会变成匹配不上,`KeyError` 里那段列出全页文案的提示也照旧。校准:把盘上 146 个真实构建程序里的 1979 处字符串抄写路径调用(`texts` / `replace_text` / `pictures` / `drop` / `keep`,211 个不同的键)拿到 10 套内置模板的全部 211 页上重放,7212 次能解析的命中里落点变化 6 处,每一处都是同一条规则:`自动化与人工智能`(green_aurora 第 4 页,本例)、`内部管理`(3 页,原先落在句中的 `本季度,管理层对内部管理流程进行了全面梳理。`)、`数字化转型`(green_aurora 第 5 页,原先落在 `升级网络和数据中心,为数字化转型打下坚实基础。`)、以及 blue_minimal 第 14 页一个整体文字恰好等于键的占位框。再把出事那一版程序原样跑两遍(只换 `_pick` 一个函数),48 次抄写调用里变了 2 次、46 次不动:第 232 行的标题从 (9.59, 3.28) 的 `Text1` 移回 (0.72, 0.14) 的 `标题 1`,另外第 258 行的 `数字化转型` 同样从一句正文移回标题——**同一个缺陷在这一个程序里静默命中了两页**,此前只发现一页。整本渲染逐页比像素:12 页里只有第 6、7 页变化(4570 和 5235 个像素,就是补上的那行标题),其余 10 页完全一致。这个程序自己的 `_sweep_text` 会删掉一切含 CJK 的形状,所以实际交付出去的症状是标题**整行消失**(作者的英文标题被塞进箭头标签、标签又被 `_sweep_art` 清掉,真标题因为还留着中文被当成模板残留删除),而不是模板文字留在页面上;两种症状同一个根因。 -**没有加警告,也没有加拒绝。** 按同一次重放量过"残余歧义"——胜出者只是部分命中、同时还有别的文案不同的候选也命中——7212 次里 28 处,绝大多数是 `单击此处添加文本` 这类模板自带的重复占位文案,作者指的就是"那个占位框",选哪一个都无所谓,报出来就是 28 行没人看的噪音(同一次实跑里 33 条建议类警告被逐字忽略是实证)。硬拒绝更不行:它会打断今天正常工作的调用,而且一处 `raise` 会连带停掉后面所有页(硬不变量第 3 条"测量回灌,不硬拒",第 6 条"工具不给死胡同")。真正剩下的、文案确实不同的只有 `智能制造`(2 页)和 `本季度`(2 页)4 处,留着不动。顺带量过但**没有采纳**的一支:让前缀命中优先于句中命中,只会多改 1 处(`本季度` 在 amber_wave 第 2 页从句中的 `对本季度行业整体工作成果的全面概述` 移到前缀的 `本季度工作中取得的成绩及成功案例分享`);再加上"前缀里取剩余最短"则会多改 12 处,全落在上面那类重复占位文案上,买不到任何东西却会静默改变今天能用的调用,所以两者都不做,需要时另立决策单独校准。 -**给下一个改这里顺序的人:这套代码里的"第一个"等于"最靠后"。** `_all_shapes` 走的是 XML 文档顺序,也就是 z-order 从后往前,并把组就地展开;画在最上层的形状排在最后。所以任何"取第一个命中"的写法,都会在标题压在内容组之上的模板里,最后才轮到那个最可能被指的形状。要改这里的取舍,先想清楚拿到的是哪一头。顺带纠正一处主干上的误解:主干的 `_pick` 判据是**子串**(`wanted in text`),不是前缀;前缀(`startswith`)是另一个函数 `shape_saying`。"前缀必须从第一个字符起匹配"那句措辞只存在于未合入的 !628 分支,用它描述主干会得出错的结论。 -**引擎在克隆页上让作者失望一次,作者就会离开模板设计——这一条超出本次修法,但同一本 deck 就是证据。** 出事那一版程序里,第 6、7 页分别克隆模板第 4、5 页,两页的标题都被静默吃掉(第 232、258 行)。最终交付的那一版,第 1、2、3、4、5、12 页仍然 `clone_page`,而第 6 到 11 页——正好从丢标题的那两页开始,往后再没回来——改成了 `content_page()`:`prs.slides.add_slide(LAYOUT)` 加一行手写的 `write(..., size=28, bold=True)` 标题行。半本 deck 因此没有模板版式,交付文件里那两页的标题坐在 `TextBox 2`(y=0.14)而不是 `标题 1`。这正是克隆路径存在的目的所在的反面(D30、`prototype_kept`、`template_underlay` 都在防这件事)。区域清除那条线与回归审计各自独立观察到同一模式;此处只记本 deck 逐页量到的事实,跨 deck 的因果留给那两条线自己举证——按 `.tmp` 里今天的成品程序数,`adapt` 路径与 `clone_page` 路径是并存的两种写法(多本用 4–16 次 `adapt`、0 次 `clone_page`,本 deck 及另两本相反),不足以支持"调用数从若干降到零"的时序结论。 - -**D55 框能装多少,在作者挑页的那一行就说;`holds` 这个词从此只用来数字符。**(编号跳到 55:D46-D54 占在其他分支上。) 9 月 10 日两份独立审计都把同一件事排在缺陷回归的第一位。逐页审计用同一份评分表读了 v12/v13 时代的 80 页与当天的 40 页:每页可见缺陷从 0.29 涨到 1.48,5.1 倍;按读者会先注意到什么排序,头号成因是旧时代把中文写进中文模板的框里、装得下,而当天把英文写进同样的框里、装不下。十套内置模板全是中文作者做的、自带中文示例文案(`green_aurora_tech_trends` 4438 字、`black_circuit_tech_launch` 6091 字),框是按全宽字形画的,于是 `off_slide` 恶化 26 倍、`crowding` 7 倍,另有 35 条 `box_overflow`。容量测量从另一头说同一件事:351 个 18-27pt 的标题级文案框里,**219 个(62%)连 40 个拉丁字符都放不下**(宽松界,全部 376 个标题级框的保守界容量中位数是 19 个字符),正文框只有 4%。所以不是 deck 话太多,是一句普通的英文标题放不进中文模板的标题框,而作者从来没被告知——整份模板回复量到 45851 字符,`chars` 出现 0 次、`characters` 0 次、` lines` 0 次。 - -D45 已经握着反应的那一半(`replace_text` 写完之后在程序末尾说哪个框装不下),这里补的是主动的那一半,落点选在作者读得到的两处既有数字旁边,不新开小节:`template_pages[n]` 的形状计数后面(`services/template/menu.py` 的 `PageEntry.capacity`,在 `_menu` 里算),以及 `house_style.title_row`(`services/template/house.py` 的 `Row.capacity`)。不放进 `_render_label` 的散文里,那是挑页的地方,不是写 `replace_text` 时回头查一个框的地方。每页说两个框:最紧的那个文案框(先坏的那个),和最大的一组同形框(一行卡片里某一张字号掉一档,是看得见的那种坏);两者是同一个框时并成一句。顺带修掉一处歧义——原来的 `holds 12 text, 7 drawn` 数的是形状,而 `holds` 正是作者会读成容量的词,现在形状计数改说 `carries`,`holds` 只用于字符。术语表(`capacity.LEGEND`,795 字符)挂在既有的 `content_pages` 一段末尾,一份回复里只出现一次。 - -四条判据都是测量逼出来的。一、只给区间不给点值:宽度测量器比渲染读宽 1.10 到 1.30(D45 量的),所以两个界直接复用 `MEASURED_WIDTH_OVERSHOOT`,1.00 倍列宽是承诺、1.35 倍是承诺的尽头,单个数字等于在报测量器自己。二、必须说方向:十套模板 1116 个可发声的框,解析后的 anchor 是 top 724(65%)、centre 276(25%)、bottom 116(10%),也就是四分之一的溢出往上下两头长、十分之一往上长出画布;bottom 不是普遍现象(三套模板一个都没有),而**既是 bottom 对齐又装不下 40 个字符的框有 90 个**,危险的方向和紧的几何是重合的。行里 top 不写(65% 且是读者默认假设的方向),centre 只写 `centred`、bottom 写 `bottom-anchored (grows up)`,含义由术语表兜。三、三种结局而不是两种:`normAutofit` 缩字 731、纯溢出 201、**`spAutoFit` 框自己长高 184(16%)**,后者在 `gold_panel_year_end_summary` 上是多数行为(177 个里 109 个,全文件没有一个 `normAutofit`)。`_growth_note` 原先测完 `normAutofit` 就落到 anchor 分支,对这 184 个只说了一半。补法不是新开一个分支而是加一句从句,依据是渲染实测:三个 0.50in 高、画在 3.00in、灌进需要 1.20in 文字的 `spAutoFit` 框,转 PDF 后填充分别是 3.00-4.20(top)、2.30-3.50(bottom)、以自身中心对称(ctr)——框保住自己对齐的那条边、另一边长出去,方向和 anchor 说的完全一致,所以原来的方向句子是对的,缺的是"框自己的填充和描边跟着文字一起走"这件事,而画布边缘对长高了的框并不比对溢出的文字更宽容。**这条结论有两次互相独立的渲染实测**:D49 的作者用自己的探针得到同一个答案(LibreOffice 不理 `spAutoFit`、认 anchor,同一个框带与不带这个元素都把三行文字放在同一个 y 上)。所以此前"`_growth_note` 把这 184 个全预测错了"的说法要在这里更正:它没有预测错方向,只是少了一句从句,那句从句现在加上了——不要再去找第三个分支修这件事。另外记一条行级事实:199 组同形行里有 120 组每个成员都缩字,所以一行卡片里文字长短不齐会以字号不齐的形式回来——这才是"模板设的 20pt 回来是 17pt"的机制,缩字框与不缩字框配对那个说法被数据否掉了(199 组里 0 组混用两种)。四、只替能替的框说话:2084 个带文字的框里,815 个没有任何 run 声明字号、202 个关掉了折行。关折行的一律不说(它的文字在一行上想跑多远跑多远,按行数字符数去限它是答错了题);没声明字号的走继承链,链上答得出的 323 个照说但标明 `24pt inherited`,链上也答不出的 443 个一个字不说。标明而不是默默拿来用,和 D49 把继承链接进 `overset_copy` 是两件事而不是重复:D49 解析链条是为了在事后**报告**一次溢出,这里解析链条是为了在一个字都还没写之前**报出一个容量**,而一个后来发现来自母版的数字就是一句兑现不了的承诺,所以它必须带着自己的出处走。覆盖率因此从 54% 到 69%,而"页面上九个框只替四个说了话"这件事本身也逐页报出来(`3 states no size, 1 does not wrap`),一页全说不了就写 `copy capacity unmeasured:` 加原因——`mint_memphis_thesis_defense` 第 2 页正是这种,四个序号加一个标题关了折行、四条 bullet 全文件不声明字号,留空白会被读成"这页有地方"。 - -每字符宽度不写死常量而是拿测量器自己探:`measurer().width(probe, 96, bold) / 96 / len(probe)`,量出拉丁 0.525、拉丁粗 0.577、CJK 1.060,与测量本身用的常数吻合,且测量器换成导出方言估算器时这个值跟着变。一处例外要记:CJK 一律按常规字重探。全宽字形在任何字重下都走一个 em,FreeType 那一侧也是这么答的(只有一个 CJK 字面、没有粗体伴侣),而旁边的估算器对拿到的每个字符都乘 5% 的拉丁粗体系数,`measurer()` 取两者的大值。把那 5% 收下就会在这件事的起点那个框上跟设计者顶牛:`green_aurora_tech_trends` 第 20 页 2.165in 的框在 20pt 粗体下装了 7 个字,加粗读法只承诺 6 个。 - -校准与实证。设计者自己的文案是基准,不能被顶牛:1116 个框里 **993 个(89%)落在保守界之内、121 个(11%)在两界之间、2 个越过宽松界**,那 2 个是 `gold_panel` 第 2 页 0.031in 宽的装饰尖括号,各带一个字符,按"少于 4 个字符是标记不是文案框"归类,因此**真实文案框 0 条被顶牛**;这条按 `_stated_size` 判定的同一口径写成了测试。继承链救回来的 323 个另测一遍,311 在保守界内、12 在两界之间、0 越界,说明继承来的字号也没有说谎。回到起点那一页:`green_aurora_tech_trends` 第 20/22 页各有四个 2.165x0.464in、20pt、居中对齐、无 autofit 的框,自带 6-7 个字,roster 行现在写 `tightest of 4 alike at 20pt hold 12-17 en / 7-9 zh, centred, spills`,而实跑交付进去的 `'CATL-HyperStrong: 60 GWh'` 是 24 个字符,宽松界的 1.41 倍——一次 build 都没跑就已经说清了。代价:211 页共 22667 字符,均 107 字符一页;最大那套模板整份回复 43794 涨到 49011(+11.9%),比预估的 7% 高,但超出的不是 band 啰嗦:**roster 把结构页列了两遍**(`structural_pages_are` 与 `template_pages` 各一次),那几页的 band 要付两次钱,再加术语表一次性的 795 字符。这处重复早于本次改动,把它收掉能回收超出的大部分,但不在这次改动里做。`_copy_fit` 的算术一个字没动,`shows = max(capacity_lines, took)` 和 1.35 都是原样引用。 - -**D59 区域只认说得清自己是哪种读法的框;四个裸数字拒收,并把两种读法都摆出来。**(编号跳到 59:D56-D58 占在其他分支上。) -D50 给作者的 `clear_region` / `shapes_in` 存在一个缺陷,恰好落在这个能力被造出来要防的那件事上。`_as_region` -(`services/template/compose.py`)收到裸 4-tuple 时要猜它是两个角点还是一个角点加尺寸,判据是 -`third <= left or fourth <= top`——只有末两个数**小于**首两个数时才读成尺寸。**病根就写在它自己的 docstring -里**:那段话先承认两种约定"从数字本身一般推不出来",紧接着断言尺寸总小于它的原点。13.33x7.5in 的画布上多数 -尺寸大于多数原点,所以它当成例外的那一支才是常态。于是 `(1, 1, 4, 3)` 这个尺寸两条判据都不触发,被读成区域 -`(1,1)-(4,3)` 而不是 `(1,1)-(5,4)`,画在 `(4.2, 3.2)` 的形状对 `shapes_in` 完全不可见,`clear_region` 既不删 -它也不在 `left_standing` 里点它的名,作者被告知这块地方是空的,然后把内容画在了模板家具上面——**这正是 -D50 立这个能力要消灭的那一种静默压字**。拿 D50 自己那张实验页复现更难看:作者手上是模板参考页打印出来的尺寸 -`(2.5, 2.5, 7, 3)`(即区域 `(2.5,2.5)-(9.5,5.5)`),读成角点后是 `(2.5,2.5)-(7, 3)`,箭头、序号、连接符一个 -都不在里面,回复是 `cleared (2.5, 2.5)-(7, 3): nothing was in it`,三个形状全留在页上且一个都没被点名。 - -**测试全体站在分支的安全一侧,所以数量再多也照不到。** 套件里每一处调用要么传毫无歧义的角点 -(`(2.5, 2.5, 9.5, 5.5)`,末两个数大于首两个数,落进角点支),要么传 `Box` / `page_box` 对象走 -`hasattr(box, "x0")` 那条路、根本到不了猜的那几行。唯一覆盖猜法的那条测试 -(`test_a_region_is_read_as_corners_or_as_a_corner_and_a_size`)把猜法本身当成契约断言下来: -`_as_region((4.0, 3.0, 2.0, 1.0)) == (4.0, 3.0, 6.0, 4.0)`,注释写"a size, not a corner above its own top" -——它测的正好是那个罕见的、判据恰好成立的方向。所以只在既有测试旁边加用例找不到这个缺陷,必须拿一个 -数字有歧义的裸 tuple 去走那条路。 - -**改法是把猜法整个删掉,而不是换一个更好的猜法。** 四个数里没有任何信号能分开两种读法,所以歧义只能在调用处 -消除——这本来就是 `ppt_layout.Box` 被给了两个具名构造器的理由(`Box.corners` / `Box.at`,后者的尺寸是 -keyword-only,正是为了拒绝位置参数)。现在 `_as_region` 只接受能自证读法的框:`ppt_layout.Box`、 -`page_box(shape)` 返回的 `PageBox`,或任何带 `.x0/.y1` 的对象;四个裸数字一律 `ValueError`,消息把**两种读法 -各自画出来的矩形都摆出来**(`as corners that is the box (1, 1) to (4, 3), as a size the box (1, 1) to (5, 4)`) -再给出可直接抄的下一步(`Box.corners(x0, y0, x1, y1)` / `Box.at(x, y, w=, h=)` / `page_box(shape)`)。措辞与 -`layout.py` 的 `_AT_TAKES_ITS_SIZE_BY_NAME` 刻意一致,因为那是同一个歧义在下一层的同一种处理。 - -**为什么是拒绝而不是回灌一条警告。** 尺寸读法画出的矩形**恒定包含**角点读法画出的矩形(角点支成立即 -`third > left`、`fourth > top`,故 `left + third > third`),所以"另一种读法会多扫到几个形状"对**每一个**裸 -tuple 都成立,分不开对与错,报出来就是纯噪音——D54 已经实证过 33 条建议类警告被逐字忽略,不再重复那个错误。 -硬不变量第 3 条"测量回灌,不硬拒"管的是字号下限、越界、溢出、折行这类**测量**结论,不是参数校验;同一个函数 -此前对 3-tuple 就是 `raise`,`Box.at` 对位置尺寸也是 `raise`,本条与它们同类。第 6 条"工具不给死胡同"由消息 -满足:两种读法加三个具名构造器,作者照抄即可。风险也已经关掉——`backends/script/runner.py` 按页隔离执行, -一页 raise 只是这一页的失败,已画的幻灯片被收回、原地立一页写明错因、**后面的页照旧画**,作者在下一次构建里 -拿到这条消息,正是回灌该走的路。 - -**契约变更的实际覆盖面:没有任何一处作者面向的示例传裸 tuple。** 全仓扫过一遍,能跑的示例调用清一色是 -`Box.corners(0.95, 1.95, 12.55, 6.55)`(SKILL.md)、`page_box(shape_at(slide, 3))`、`page().footer`(一个 -`Box`);其余出现处全是散文里的占位名 `box`。真正授权过尺寸形式的只有两句,而两句都是这个缺陷的文档面:一是 -`shapes_in` 的 docstring 写"or the `(left, top, width, height)` a template reference prints, which is told -apart by its last two numbers being a size rather than a further corner"——它把那条不成立的推断当成保证卖给 -作者;二是 SKILL.md 的词表把 `page_box(shape)` 写成返回 `(left, top, width, height)`,**这一条本身就是错的** -(`page_box` 从 D50 起就返回两个角点,同一份 SKILL.md 1631 行还反过来说它"answers in the two corners a -region is given in"),是顺手修掉的第二个文档缺陷。两句都已改写。指引同时补进作者真正会读到的四个位置,而不是 -只改文档:`_as_region` 的报错本身、`ppt_template` 绑定模板时那条克隆 ask、参考页盖在不可重绘页上的 -`_CLONE_INSTEAD`、以及 `shape_near` 查不到时的 `KeyError`。 - -**同一条缺陷的第三处,`house_style` 的回复,一并修掉而不是记一笔。** 拒绝比静默好,但一条拒绝砸在"照文档做了" -的作者头上是笔可以不付的账,而这正是改前的状态:`brief()` 把 `body_area_in` 印成尺寸 4-tuple -(`(left, top, width, height)`),**紧挨着上一行** `body_area_as_code` 那句从同一个矩形算出来的 -`Box.corners(...)`——同一个矩形的两种读法隔一行并排放着;而真正把作者推下去的是 `ppt_template` 那条"自己画一页" -的 ask,它写着"lay the content out inside **body_area_in** with `ppt_layout` -- Box.columns, .rows, .grid, -plane(), write(), table()",**点名的每一个都只收框不收尺寸**。照做就是 `Box(*body_area_in)` 或 -`clear_region(slide, body_area_in)`,改前静默清错一块地方,改后被拒。这件事这套代码自己早就知道: -`test_the_brief_hands_the_author_a_line_it_can_paste` 的 docstring 就写着"一次实跑照着它和 `body_area_in` -共有的那两个数读完整个调用,然后把一个尺寸写进了自己的框里",`body_area_as_code` 就是当时打的补丁——**补的是 -旁边加一行,没有把那行错的拿掉**。 - -改法:`brief()` 里四个框字段一律改成角点,并把约定写进键名——`title_row_box_corners_in`、 -`subtitle_row_box_corners_in`、`safe_area_corners_in`、`body_area_corners_in`,减法在新的 `_corners()` -里做一次。**为什么是角点而不是"把尺寸留着改个名"或者"干脆不印"**:作者拿这些数字去做的下一件事全是 -`ppt_layout` 调用(`Box.columns` / `.rows` / `.grid` / `plane()` / `write()` / `clear_region`),一个不收 -尺寸;而**拿到框的作者随时能要回尺寸**(`Box.at`、`.w`、`.h`、`.pptx()`),**拿到裸尺寸的作者没有安全的路 -回到框**——这就是 `_as_region` 那条规则在数据侧的同一句话,所以印出去的应该是框。那条 ask 改成先粘 -`body_area_as_code` 再拿 `ppt_layout` 分割。引擎侧不受影响:`house_style` 门禁与 `bands` 读的是 -`house.title.box` 这个属性(仍是尺寸),不读回复里的键,所以门禁行为一个字没变。 - -顺带修掉 SKILL.md 里**五处**还在点旧键名的引用(第 847、1226、1811、2047、2049 行)。**一份自相矛盾的技能文件 -比其中任何一个版本都糟**:作者读到哪一行都会理直气壮,而这份文件此前恰好两边都写过——词表说 `page_box` 返回 -尺寸、1631 行说它"answers in the two corners a region is given in"。所以这次把约定钉进 `_ppt_engine_skill_claims.py` -(新增 12z 段):技能里出现的每个 `*_corners_in` / `*_as_code` 键都必须是 `house.py` 真的印出来的键,且两边都 -不许再出现那四个裸尺寸键。现测 **2218 条断言 0 条失败**;把 `payload["body_area_corners_in"] = _corners(body)` -改回 `payload["body_area_in"] = list(body)` 就得到 2 条点名失败("the skill names body_area_corners_in and the -house brief ships no such key"、"the brief ships body_area_in, a bare size beside a Box.corners line built from -it"),判据是会响的。留一个改前就有的空缺不在本条里补:`subtitle_row_box_corners_in` 技能里一次都没点过 -(旧名 `subtitle_row_box_in` 同样没点过),所以断言只做"技能点到的键必须存在"这一个方向。 - -**D50 的校准逐项复核,一个数都没动。** 六个框按 D50 原样重建:black_circuit 第 20/26/29 页共 5 个图表区域, -加 green_aurora 第 4 页那个实跑正文区域——后者从 `failure-005/build.py` 第 6 页反推出来是 -`Box.corners(0.95, 1.95, 12.55, 6.05)`(左 0.95 右 12.55 是它的图表列与数字卡列,上 1.95 是两个图表标题, -下 6.05 是两个图表自己的底边),记在这里免得下一个复核的人再解一遍。**注意 D50 里的 green 第 4 页出现过两个 -不同的框,别把两组数字混起来**:校准段那个下边是 6.05(只到图表底边),`[24]` 在它里面占 0.42 因而留下并被点名; -而末尾"实跑复核"那次把整条正文带都清了,下边是 6.55(含三张数字卡的底边,`Box.corners(0.95, 1.95, 12.55, -6.55)`,也就是 SKILL.md 里那个示例),`[24]` 在它里面占 1.00 因而被删,删除数是 25。本次复核两个框都跑过: -6.05 删 24 留 2,6.55 删 25 留 1(留下的是作者刚 `replace_text` 写进去的英文副标题 `[26]`,占比 0.34)。判据自己取走的 35 个形状自身占比 -**0.5795 到 1.0000**(D50 的 0.58–1.00),留下的 8 个(卡除外)**0.0475 到 0.4206**(D50 的 0.05–0.42), -两簇之间的空档是 **(0.4206, 0.5795)**,宽 0.1589,默认的 `share=0.5` 落在空档正中(D50 的"中间 0.42–0.58 -一片空白")。卡 4 张,框占比 **0.967–1.000** 配自身占比 **0.421–0.693**(D50 的 0.97–1.00 与 0.42–0.69); -图表 5 个两个读数都 1.00。点名的那个漏删项 `[24] '智能教育改变学习模式'` 自身占比 **0.4206**(D50 的 0.42), -`clear_region` 在该区域删 24 个、`left_standing` 里点它和副标题 `[26]`(自身占比 **0.3365**,D50 的 0.34) -两个名。整套测量在改动前后逐字节相同——把 `compose.py` 单独 stash 回 58358b415 再跑同一个脚本,`diff` 无输出。 -这是应有的结果:改动只碰裸 tuple 的转换,`_inside`、`_share_of_shape`、`_overlap`、`THE_BOX_SITS_IN_IT` -一个字符没改,而校准脚本传的是 `page_box` / 具名构造器那条对象路径。 - -**D37 被清空的页不许发布:`thin_copy` 分成两个判词,`placeholder_copy` 改问页面真正显示的文案。** 9 月 9 日一本 16 页 deck 的第 7、8、9、10、12、13、14、15 页只剩 `adapt` 自己写的标题与副标题,另外 13 到 26 个文本框全是空的。根因是 `adapt` 的契约(`compose.py:1605`,执行在 `:1765-1768`):这一次调用没点名的文案在返回前一律被清空;作者随后手写了一个按模板中文前缀找 shape 的 helper,在已经被清空的页上 58 次命中 0 个 shape,每次只打印一行 print 就正常返回。第 11 页是同一个错误的响亮版本——它走的是引擎的 `shape_saying`,找不到就 raise,于是只有这一页进了 `page_failures.json`。**我们能知道出了问题,纯粹因为作者在八次里有一次用了引擎自己的那个函数。** - -三处改动,都先校准后落地。 - -(一)唯一精确点名这八页的判据是 `thin_copy`,但它是 WARNING,也不在 script 路线的 `blocking_kinds` 里。先按 D35 的规矩校准:10 套内置模板的 211 张示例页、11 本已交付 deck 的 152 页、加上这本没发出去的 deck。直接把 `thin_copy` 升为拦截会在已交付 deck 上误伤一条——`20260906_170229_40d232` 第 12 页 138 字对 150 的散文档,差 12 个字;模板侧 17 条命中里有 6 条落在离下限 1 到 10 个字的地方(最窄的 149/150)。**按房规改问法而不是改阈值**:`thin_copy` 保持 WARNING,新增判词 `emptied_page`——低于下限**并且**空文本框比带文案的框多。这是一个问句而不是一个可调的数:那八页各留 1 到 2 个带字的框对 13 个以上的空框,而模板与已交付 deck 上被下限报到的每一页都至少留了 3 个带字的框。校准结果:已交付 deck 0 条,模板 211 页 3 条(模板示例页本来就是 `thin_copy` 要抓的那种「什么都没说」的页,见 `density.py` 里 COPY_FLOOR_CARDS 的注释),这本 deck 8 条、正是那八页。表格与图表算作页面带的文案,否则一页只有一张表的版式会被判成没写。`emptied_page` 的 severity 是 BLOCKING,并写进 script 路线的 `blocking_kinds`(清空是 `adapt` 的机制,只有这条路线会克隆模板页再用程序填);到 D35 的 buildCap 上限时和其他 blocking 一样放行。 - -(二)`placeholder_copy` 当时是净负值:它挡住这本 deck 靠的是第 16 页一句正当的 `Thank you`,同时放过了第 14 页真正的模板占位文案。两个原因。一是 `_texts_by_page`(`adherence.py`)只读文本框,而第 14 页的中文在图表 XML 的缓存类目和图例里——`graphicFrame` 没有 `has_text_frame`,`adapt` 的清空也进不去,所以那段中文既没被清、也没被查。改为读页面真正显示的文案:文本框、表格单元格、图表(标题、系列名、缓存类目),模板侧与 deck 侧用同一个读法,于是第 14 页命中 `单击此处添加文本` 与 `项目完成率`;报文按持有者给建议,图表那条明说 `texts=` 到不了图表。四个 `第一季度` 仍不报,那是 `PLACEHOLDER_MIN_CHARS`在起作用(4 个字的字符串两本 deck 撞上是常事,讲季度的 deck 本来就会这么说),这条下限没有动。二是误报要有出路,而这条判据是拦截,误报只能靠把正确的页面改坏来解决。两条豁免:**页面自己的计划逐字要过的那句话不算残留**(计划写在画之前、用的是作者自己的词;第 16 页的计划里写着`Close: Thank you.`),以及**只有数字在变的标签不算槽位**(模板的页码本来就跳过,章节页的 `PART 01` 是同一类;`20260906_094529_ee6328` 就是被它的三条 `PART 01/02/03` 拦下来的,`blocking.json` 里留着记录)。校准结果:11 本已交付 deck 从 3 条误报降到 0 条,这本 deck 从「第 16 页误报 + 第 14 页漏报」变成「第 16 页不报 + 第 14 页 2 条」。 - -(三)坑本身要说在作者站的地方。`fill()` 的注释(`compose.py:2103-2110`)早就把这个失败写得一清二楚,作者照样自己重写了一个 `fill`——所以注释不是答案。作者自己的 stdout 也已经带回了 58 行 miss 提示,同样没有起作用:那些行说的是症状(`found 0 < 1 for 探讨AI在决策中`),没说原因、没说改法,而且埋在 34 KB 回复里的 37 条 finding 之间。座位选两个,都不是注释:**上一次调用的回复**——`emptied_page` 的报文说出这一页有几个空框、几个带字的框、少了多少字,然后说清原因和改法(在同一次 `adapt(...)` 里用 `texts=` / `items=` / `title=`,用不到的形状 `drop=`),并且它是拦截,不是跟着 deck 一起发出去的 warning;**报错**——`shape_saying` 的 KeyError 补上缺的那一句,在作者正做错事的那一刻把静默的一类变成响亮的一类。没有让 `adapt` 去 print:同一条 stdout 里已经有 58 行自己的 print 没人看,再加一行只是把噪音写厚。 - -未验证:改动之后没有重跑一本 deck,只在 10 套模板、11 本已交付 deck 和这本 deck 的成品文件上离线复算了判据。另外三条诊断出来的相邻缺陷没有做:`sparse_container` 在容器全空时直接 skip(越空越不报)、「没标签的图标行」没有判据、SKILL.md 里 `shape_saying` 那一段仍然没说 `adapt` 刚清过文案。 - -**D38 模板页只留一扇门,而且是抄写那一条:`clone_page` + `replace_text`。** 用户原话:"这个工具和路线选择就是我们要做的事——只留最好的那一条,把它做到位;为什么要让模型去选?做完美它。" 9 月 9 日 23 条实跑记录摆出来的事实:进模板页有两条路,`adapt()`(克隆并填,**没点名的文案一律清空**)和 `clone_page()`+`replace_text()`(克隆并保留模板原话)。两条路的危险恰好相反,而安静的那条是 `adapt`:填不上就是一页空白(D37 之前只有一条 WARNING 看得见),而漏掉的 `replace_text` 留下的是模板中文,`placeholder_copy` 会拦。最快最干净的两次交付都走 clone+replace(v16:0 次 `adapt`、3 次 `clone_page`、13 次 `replace_text`、1 次构建失败、77 分钟交付;v19:0/14/71,13 页全部保住原型几何),靠 `adapt` 的每次带 3 到 9 次构建失败,四次没交付的里有三次是纯 `adapt`。但 `adapt` 有一件别人做不了的事:`items=` 填重复单元并**删掉多余的**(8 槽页给 6 条就剩 6 个),23 次里 13 次用到。**先做了一次错误的选择,再由实跑推翻。** 第一版结论是「保留 `adapt`,把它的坑填掉」,理由是那次实跑没有丢过文案、两次拒绝也都照着报文改对了。这个判据太窄:deck 还是难看,而最难看的那一页坏在**被保留的那条路自己依赖的东西**上——`adapt(items=)` 经 `units()` 找重复单元,而 green_aurora 第 5 页的三张卡 `units()` 一个都认不出来(8 个带字的框,0 个落在 run 里),于是作者的标题和正文被写进了图标徽章里两个 0.83in 的空框。**`replace_text` 不走 run 探测**:它按形状此刻显示的文字取形状,同一页上 `replace_text(s, "数据驱动的决策制定", "Heavy forgings")` 直接命中那张卡。实测:那 7 个块用 `replace_text` **7/7 全部命中**,`placeholder_copy` 从 7 条降到 1 条(剩下那条是我没配对的第八个小标签,判据照报)。历史记录方向一致——v16(77 分钟、1 次构建失败、交付)与 v19(13/13 页保住原型几何)都是 clone+replace,靠 `adapt` 的每次带 3 到 9 次构建失败。用户原话:"这个工具就是不行,你没修好——走抄写那条路。"**所以教的路线改为 `clone_page` + `replace_text`,`adapt` 不再教。** - -(一)**清空撤销(这一条随路线切换而变成免费的)。** 没点名的框现在保留模板原话,D37 的 `placeholder_copy`(BLOCKING,这条分支上还读表格单元格与图表文字)把「漏填」从静默变成点名拒绝。`keep=` 从此就是默认行为,只留作「断言这个键还在」;`items=` 给短列表时尾部形状留模板文案而不是清空。按 D37 的规矩重新校准,并且**换了一种校准法**:静态复算成品字节不会有任何变化(文件已经生成完了),真正的前后对比是**把记录下来的 build.py 重新跑一遍**,只把 `ppt_template.py` 换成两棵树各自的引擎。18 本 deck、280 页、140 次 `adapt` 调用:`emptied_page` 0→0,`placeholder_copy` 0→2。两条都指名道姓,一条是这次改动的**理由**,一条是它的**代价**。理由:`20260906_185054_0c8815` 第 5 页——那页已经交付出去了,13 个文本框里有 2 个是空的,其中一个原本写着 `单击添加小标题`,作者的 `SITES` 循环点名了 9 个形状、漏了它,清空把这个漏掉变成了一个谁也看不出来的空标题框。改完之后同一个脚本跑出来的同一页被 `placeholder_copy` 点名拦下。**这不是推论,是一本已交付 deck 的实测。**代价:`20260906_170229_7a12f0` 第 2 页的 `Agenda`——模板目录页的英文副标,deck 正当继承,判据没有豁免口,是误报。量级是 18 本 deck / 280 页里 1 条,落在模板自己拥有的固定标签上,作者的出路是 `texts={"Agenda": "议程"}` 用自己的词,或者 `drop=`。按用户的话不去调它,记在这里。10 套模板 211 页、11 本已交付 deck 152 页、7 本 ppt_runs deck 140 页的静态判据数前后完全一致。**换到抄写路线之后这一条不再需要引擎改动**:`clone_page` 本来就原样保留模板的话,漏掉一句就是页面上明摆着的一句模板中文,`placeholder_copy` 照样点名。实测确认:克隆第 5 页只改一句、其余不动,`placeholder_copy` 报 7 条。也就是说 D37/D38 「漏填必须响亮」的论证在切换之后**由路线本身承担**,而不是靠把 `adapt` 改一遍换来的。`adapt` 的清空撤销留在代码里(它仍可调用),但已不是任何教学路径的一部分。(**已由 D41 推翻**:整个函数已删除。) - -(二)**元数不匹配的报错要能照着改。** 最新一次实跑的三次构建崩溃全是 `adapt` 拒绝元数:原型 6 给 4 单元页 7 条、原型 11 给 5 条各 3 值而单元只有 2 个文本形状、原型 10 给 4 单元页 5 条。而 `compose.py` 里那句 "N values were given" 之后打印的是 `frames`——**原型自己的形状**,不是调用者传的东西;拿 `['a','b','c']` 去探,读回来的是模板中文。两处报文改成描述这次调用:页面有几个单元、一个单元有几个文本形状、来了几条 entry、每条多长、每条的值是什么(截断到 24 字符,最多列 12 条)。`add_unit` 那条无网格的拒绝原样保留(它已经写清了每个单元的盒子和出路),只在 `fill` 里接住并把 entry 清单接上去。**保留 raise**:按页隔离已经在主干上,一次 raise 只损失一页,那页变成 `page_failed` 占位并拦住发布,既响亮又有边界;改成静默部分填充更糟——7 条给 4 槽的作者手里有内容没地方放,悄悄丢掉三条比点名拒绝坏。 - -(三)**作者读到的地方只留一个调用,就是抄写那一条。** 教的是 `s = clone_page(prs, prototype(tpl, N))`,然后每一句 `replace_text(s, "模板此刻写着的话", "你要写的话")`,多余的槽用 `remove_unit`、单个形状用 `drop_shape`、图用 `replace_picture`。**关键的一句此前从来没写出来过**:`replace_text` 的第一个参数就是 `ppt_template(pages=[N])` 在每个形状上方印出来的那串文字——读回页面得到的就是一串可以直接粘的键。这句话现在写在用到它的地方(回复里、skill 的 §8、helper 表)。`adapt` 从 skill、`ppt_template` 回复、`ppt_outline` 建议、`decompile` 提示、`density`/`adherence` 的报文里全部退出(14 处),仍可调用但不再被教。`tests/_ppt_engine_skill_claims.py` 的三条断言反过来钉:skill 里不出现 `adapt(` 调用、印着 `clone_page(prs, prototype(` 至少三次、并且要出现按词取形状的 `replace_text(s, ...)`;`tools/template.py` 里不出现 `adapt(prs`、必须出现 `clone_page(prs, prototype(`。 - -**放弃的唯一能力是 `items=` 删多余槽,替代品是 `remove_unit` 并且要教得明确。** 实测:8 槽的目录页克隆后 `for spare in max(units(s), key=len)[6:]: remove_unit(spare)`,剩下的槽会重新排布(原型 2 从 3 个到 2 个、原型 8 从 4 个到 3 个都验过)。这一段连代码例子写进了回复和 skill,因为它是这次交换里唯一少掉的东西,不能变成一个没人说的洞。写 `""` 不是删除的写法——它清空文字、编号气泡照样发出去。 - -(四)**逐条对照两条路做过的事**,把 clone+replace 三本记录(v16:3 次 clone / 13 次 replace_text;v19:14 / 71;stack5 的 8 页 deck:8 / 约 68)的每个序列拆开数,得到 56 项互不重复的能力:`adapt` 改完后**完整表达 25 项、部分表达 5 项、到不了 25 项**(其中 14 项是克隆之后的调整、10 项是真缺口、1 项是作者的判断而不是 API 的事)。顺手补上的两个高频缺口:`texts=`/`title=`/`subtitle=` 此前 `str()` 掉值,是唯一不能写 `Run` 列表的参数(`items=` 一直可以),三本脚本 20 处调用因此要在 `adapt` 之后再走一遍 `replace_text`;`pictures=` 此前只收 `(image, box, fit)`,`alpha`/`trim`/`anchor`/`zoom` 到不了,v19 有 5 处为此绕开。到不了的 25 项要分两堆看。一堆是**克隆之后的调整**——挪位、改尺寸、关自动缩放、定垂直锚点、`raise_type`、图标替换(`swap_icon` 明说是 `adapt` 够不着的那一个),共 14 项(含版式层换图,见下),`adapt` 返回 slide 之后用 `shape_at`/`place`/`layout_pictures` 做,skill 一直是这么教的,它们不是进模板页的第二条路。版式层图片本来排在缺口第一位,核对代码路径之后**它不是缺口**:`clone_page` 用 `add_slide(_layout_in(presentation, prototype))` 建页,所以 `adapt` 返回的 slide 的 `slide_layout` 就是原型那一版式,而 `adapt` 从头到尾不碰版式,`replace_picture(layout_pictures(s)[0], image, 'cover')` 照 D31 的写法接在 `adapt` 后面照常工作——在 green_aurora 与 amber_wave 两套模板的封面上实测:`layout_pictures` 各返回 1 张(13.33x7.47in / 13.33x3.48in),换图成功,渲染出来标题副标题在位、模板的星点装饰保留、整页底图换成了新图。它归到「克隆之后的调整」那一堆。剩下的**真缺口 10 项**,按出现频次排:几何取形状(「页面上最大的那张图」「按解析后的页面坐标区分同名占位」,v19 有 12 处)、`drop=` 点不到组合(`_all_shapes` 只出叶子)、`layout_pictures` 找不到不是 blipFill 形状的版式画(v19 为此写了两种土办法:按面积扫 XML 里带 blip 的大形状、直接改 `p:bg` 的关系 id)、遮住版式画只影响一页(stack5 用白矩形盖,`drop_shape` 明说拒绝版式形状)、克隆页上新加文本框(`template_underlay` 本来就拦,但 v19 第 11 页确实需要)。这些不是「留给作者自己发现」,是下一批要建的。 - -未验证与未决(本条的台账): - -0. **build 循环没有一个能咬住的边界——三个候选修法,还没有人拍。** 这本 deck 跑了 **14 次 build,对 `buildCap: 10` 计了 0 次**。原因是 `tools/build.py:241` 的 `release = not draft and caps.build_cap is not None and whole >= caps.build_cap`,而 `tier.whole_builds_taken` 只数非 draft 的整本 build(`services/tier.py:76`);这一次 14 次全部带 `draft: true`(1 次整本、13 次按页),deck 目录里连 `.deck-builds` 记录都没生成。**只画草稿的 run,档位的上限压根不生效。** 第二个常量与它复合会更糟:按页隔离分支(`fix/ppt_a_page_that_raises_is_one_page`,尚未进主干)加了 `stages/build.py:90` 的 `HELD_AT_THE_CAP = frozenset({"page_failed"})`,占位页在任何 build 计数下都继续拦发布——它的注释论证「不会死循环」的前提是「作者会把那一块改好,下一次 build 就放行其余」。两条各自都对(带红色「Page 11 did not draw」的 deck 不该当成品发出去;run 必须有界),合起来则是:只要有一页没画出来,发布永远被拒,除了 `maxToolIterations` 没有东西结束这个 run。**本次实跑没有证到这一条**——它的占位页最后全修好了(`page_failures.json` 已消失,第 9、10、12 页 `kinds: []`),所以这里记为风险而不是本次的成因。本次真正没收敛的是第三件事:模型把 `ppt_build({"slides": [9, 10, 12]})` **一模一样连发了六次**,而那三页早已干净,真正还被拦的第 7、8、11 页一次都没再碰;agent 循环没有重复调用断路器,只有 `maxToolIterations=600` 会停。修法是一个还没做的决定,三个候选并列在此,不由「哪个分支后合」来定:(a) 到上限就带着占位页交付,回复里点名说这一页没画出来;(b) 到上限就以「明确失败」结束 run,而不是继续循环;(c) 继续拦,把 `maxToolIterations` 当作边界。另外无论选哪个,draft 不计数这件事都要单独修,否则上限对只画草稿的 run 永远不生效。 - -1. **`units()` 的 run 探测——下一批要做的第一件事,不在本分支。** 它在 flip 之前就坏,flip 之后一样坏,flip 只是把它的后果从静默变成拦截,所以不算本次改动造成的。给接手的人留下起点:对这本 deck 到 build 7 仍被拦的四个原型,「带字的文本框里落在某个 run 内」的比例是——原型 27 **15/17**、原型 25 **9/14**、原型 21 **12/15**、原型 5 **0/8**。原型 5 是最坏的一档,整页内容都在一个 11.95x4.17in 顶层组合里,`units()` 只认出三个 0.83in 图标徽章(`arrangement=irregular`),三列卡片(标题 2.37x0.51in + 正文 3.20x1.57in)一个都没进 run;`fill(max(runs, key=len), items)` 于是把标题和正文写进了徽章里那两个空框,渲染成一列一个字符往下掉。从这一档开始改最省事。 -2. **这本 deck 没有收敛到可发布,运行到 14 次 build 时人为停掉。**下面是它量到的东西,而不是那次没等到的发布。 - - 收敛的部分:BLOCKING **38 → 18**,`placeholder_copy` **23 → 14**(页面从第 3、5、7、8、9、11 收到只剩第 7、8、11),`unreadable` **5 → 0**,`word_collision` 9 → 4(只剩第 11 页),`template_underlay` 4 页 → 0(这一条是本次判据改动的功劳,不是作者改的),占位页 3 → 0(第 1、2、12 页最后都画出来了)。 - - 没收敛的部分:失败页走的是 3 → 1 → 3 → 0,中间那次回升的两页(第 9、10 页)是作者在修第 12 页的过程中**新写坏的**——`AttributeError: 'list' object has no attribute 'left'` 和往一张没有图片框的页上传 `pictures={1: ...}`。也就是说「一轮修一页、顺手弄坏两页」在这条路线上是会发生的。 - - 第 12 页值得单独记一笔:**十一次 build 里它一直在猜 `感谢观看`**,而那一页写的是 `Thank you`;`adapt` 的拒绝每一次都把这页的三个形状连原文列了出来,作者每一次都没照着改,直到很后面才换对。**报错送到作者手上、而且内容完整正确,并不等于作者会读它。**这条和 D37(三)的结论方向一致——座位选对了不代表人会坐——但比那一条更悲观:这里的报文已经把答案原样印出来了。 - - 还有一条与 flip 无关的既有问题:第 7、8、11 页留下的 14 条 `placeholder_copy` 里,第 11 页那一批是上面条目 1 的 run 探测缺口(`items=` 到不了那三张卡),作者要么逐条 `texts=` 点名七个块、要么换原型,两条路它都没走。 -3. **`Agenda` 那条误报没有出路。** 判据没有「模板自己拥有的固定标签」这个豁免口,作者只能改词或 `drop=`。规模 18 本 deck / 280 页里 1 条。 -4. **英文文案进中文模板的盒子。** 第 3 页 `Sodium fast (Natrium)` 被右对齐挤到序号徽章底下,是模板的对齐加作者的字数;`spilled_copy`/`clipped_copy`/`word_collision` 都报到了,属于既有的适配问题,不是本次改动的。 - -**D38 补记:实跑之后。** 9 月 9 日在自己的 RAVEN_HOME(18860 端口,三个分支叠在一起:64k 输出上限 `2e1a2840`、空页判据 `7610ea02`、本分支)上跑了一本 12 页英文 deck,绑定 `green_aurora_tech_trends.pptx`(sha256 `24080aa77538`,对齐深色模板分支的钉)。作者写了 **11 次 `adapt`、0 次 `clone_page`、1 次 `replace_text`**,`ppt_template` 回复里也确实一个原语都没提。**两条删除条件都没有出现**:没有任何一页把给进去的文案丢掉;第一次 build 里 `adapt` 拒了 3 页(第 1、2、12 页),全部按页隔离成红色 `page_failed` 占位,其中第 1 页(猜模板占位串)和第 2 页(**新的元数报文**:4 条 entry 各 3 值对上 2 个文本形状,报文把作者自己的四条 entry 逐条引回去)在第 3 次 build 就照着报文改对了,一轮都没白费。所以 `adapt` **保留**。(**这半句已由 D41 推翻**:`adapt` 已从代码里删除,不再是可调用的 API。上面这段实跑记录仍然成立,改变的是结论。) - -第一次 build 的门禁:**23 条 BLOCKING `placeholder_copy`**(第 3、5、7、8、9、11 页),全是 `adapt` 没点名的框;旧默认下这 23 个框会被静默清空、门禁全绿。 - -但这本 deck 的页面还有两处**与本次改动无关**的缺陷,最初被我读成「按设计工作」,是错的,在此更正: - -(一)**原型 5(第 11 页):引擎选错了 run。** `units()` 在这一页只找到一个 run,就是三个 0.83x0.83in 的绿色图标徽章(`arrangement=irregular`),每个徽章组里两个**空**文本框(0.83in 与约 0.35in)。作者两个值全写进了这两个小空框——渲染出来是一列一个字符竖着掉下去。真正的内容卡(标题 2.37x0.51in `数据驱动的决策制定` + 正文 3.20x1.57in)**根本不在任何 run 里**,整页内容都在一个 11.95x4.17in 的顶层组合内,`units()` 没把三列卡片认成重复单元。所以作者在 `items=` 里怎么写都到不了卡片。**责任在引擎(run 探测),不在作者的列表顺序,也不在这次的清空撤销。**按房规:这是另一次改动、要自己的校准,不在本分支修。缺口的形状量过了:对这本 deck 到 build 7 仍被拦的四个原型,数一下「带字的文本框里有多少落在某个 run 内」——原型 27 是 15/17、原型 25 是 9/14、原型 21 是 12/15、**原型 5 是 0/8**。也就是说 `items=` 在前三个原型上够用(漏的是标题、副标和一张落单的卡,`texts=`/`drop=` 点得到),而原型 5 上这一页说的每一句话都在 run 之外,只能放弃 `items=` 改用 `texts=` 逐条点名七个块。清空撤销把这件事从「静默清空」变成「拒绝并把那七句话原文列出来」,代价是作者要多写一页的键;run 探测修好之后这一页才回到 `items=` 一句话。附带一条,**这条是本次改动造成的,所以本次改完**:`template_underlay` 在第 11 页也报了,但作者一个文本框都没加,报文说的「把新文本框铺在上面」对这一页是错的解释。根因是这条判据只问两件事——页面几何是模板的、页面上还留着模板的文案——而在清空还在的时候第二个条件在 `adapt` 路线上永远不成立,所以它只见过手搭的那一种情形;清空撤销之后,一个单纯的漏填就同时满足两条。**按它自己的说法补上第三个条件**:页面上得真有一块文案站在模板没有形状的位置(`_added_text_boxes`,用的还是 `template_adherence` 本来那套「克隆是精确的,所以模板画的块回到模板的坐标」的读法,只收带字的形状)。选「让判据说到做到」而不是「让它在原因已被点名时闭嘴」,因为这样报文和判据就一致了,不用再靠 quiet.py 记住一条例外。校准:10 套模板自比之下 13–30 页/套全部归零(那本来就是把模板当 deck 比的假阳性),11 本已交付 deck 与 7 本 ppt_runs deck 前后都是 0,这本 deck 从 [7, 8, 9, 11] 与 [7, 8, 11] 归零。真阳性没丢:`tests/test_ppt_engine_measure_adherence.py` 新增一条把两半都钉住——手搭那种(clone_page + 四个自己的文本框 + 从不 replace)照报,`blocks_added` 记下 4;同一个原型只是漏填、一个框都没加的,不报 underlay 而由 `placeholder_copy` 逐条点名。此前**没有任何测试**断言过这条判据会在真阳性上触发,所以它本来可以被静默改瞎。 - -(二)**原型 8(第 3 页):配对是对的,坏的是盒子装不下。** 逐框核对:`01`–`04` 保住了自己的序号(短列表遇到序号框就复述位置的规则如实生效),标题值进了 3.09x0.71in 的标题框,正文值进了 3.88x0.70in 的正文框,四个单元全部配对正确。渲染里 `Sodium fast (Natrium)` 只显示 `dium fast (Natrium)`、`So` 被压在序号徽章下面,是因为标题框**继承模板的右对齐**(`align=RIGHT`,四个单元一律如此,作者传的是裸字符串),英文比 3.09in 宽就往左溢出、钻到 (0.71,2.22) 的徽章底下;`Molten salt & micro` 差一点、`High-temp gas (HTGRs)` 折成两行。**02 的两行右对齐是模板的对齐加作者的字数,不是作者设的。**页面顶部压在极光上的灰色矩形也不是作者画的:原型 8 自己的非文本形状只有 (7.84,3.26) 4.73x3.26in 那个图片位,灰块来自 `Title Only` 版式,正是这本 deck 报出 `over_layout_art`(3 条)与 `layout_picture` 的那个东西。 - -**D48 漏填要按"是不是一句话"点名,而不是按字数:`placeholder_copy` 去掉 5 字下限,模板留下的记号折成每页一条 WARNING。** 用户原话:"就改成模板所有文字吧,没被替换的都报出来。" 9 月 10 日的两处实测漏报都正好四个字符:stack6 那本 deck 第 14 页图表里的 `第一季度`/`第二季度`/`第三季度`/`第四季度`,和 one-door 那本 12 页英文 deck 第 10 页的标题 `工作感悟`。挡住它们的是 `adherence.py` 的 `PLACEHOLDER_MIN_CHARS = 5`,注释写的理由对拉丁文成立、对中文不成立——四个字符在英文里是页码,在中文里是一整个词。**判据改问形状而不是长度**:含任一 CJK 字符,或两个以上拉丁词,就是"一句话",仍然 BLOCKING(`工作感悟`、`第一季度`、`Presenter name`、`单击此处添加文本` 全在这一档,与长度无关);其余的数字、单个符号、孤立短词是"记号",按 D35 的规矩**每页折成一条 WARNING**(新判据 `placeholder_marks`,并进 `quiet.py` 的 `SAID_ONCE_KINDS`,同一页只说一次)——`prototype_kept` 一次跑批报 26 次零响应还带着交付,一页十二条记号就是同一个东西。纯数字、`PART 01` 这类只有数字在变的标签、URL 仍在更上游整条跳过,所以进到分档这一步的记号本来就不多:全部 30 个测量面上折叠最深的一页只有 2 条(gold_panel 第 2 页的 `<` 与 `>`),折叠是保险而不是当下的减量。 - -按 D37 的规矩静态校准,同一个脚本换两棵树跑(`.tmp/unreplaced/calibrate.py`):10 套模板 211 页 BLOCKING 1180→1300、WARNING 0→17;11 本已交付 deck 152 页 0→4/0→0;7 本 ppt_runs deck 140 页 0→4/0→0;两本出问题的实跑构建 28 页 16→22/0→0。**两处漏报都抓到了**:stack6 第 14 页 2→6,one-door 第 7、8、11 页之外新增第 10 页的 `工作感悟`。 - -**代价是一条,而且是 D37 那条误报的中文孪生:`目录`。** 18 本已交付 deck 里有 8 本第 2 页因此被拦(`20260907_031357_c53bc2`、`20260907_062434_2349f9`、`20260907_071215_488328`、`20260906_094529_ee6328`、`v13_blue_plugin`、`v13_red_plugin`、`v13_warm_plugin`、`v16_amber_fast`),全部是模板目录页自己的标题,deck 正当继承。同一条判据反而**把 `Agenda` 降成了 WARNING**(单个拉丁词),也就是说同一类东西的中英两半现在待遇相反:模板结构页自己的角色标签,中文拦、英文放。按用户"遇到就报不要调"的要求,这里只记录不加豁免口;要处理的话,可走的口子是"模板结构页自己的角色标签"(引擎已经有 `menu()`/`roles()` 知道哪页是目录页),而不是把长度下限换个数字再放回来。另有三条被降级的:`#REF!`(图表里的坏引用,3 处)与 `forefront`(2 处),单个拉丁词,从 BLOCKING 变 WARNING。 - - -**同一次里补掉的另一个洞:`boxless_copy`。** one-door 那本 deck 第 12 页渲染出来只有模板底图和页脚,`blocking.json` 里一条都没有;用 python-pptx 打开,整页文案(`Investment Takeaways A 2030s infrastructure story at technology-risk prices`)都在一个文本形状里,声明的框是 `x=0.72in, y=0, w=11.88in, h=0`——**零高度**。四个候选原因(出画布、零尺寸、与底色同色、被底图盖住)里只有一个成立,另外三个已有判据覆盖(`off_page`、`unreadable`、`covered_shape`),所以**答案是一条判据不是四条**:带文案的形状声明了 0 宽或 0 高。放在 `measure/layout.py`(那里本来就是"只看声明几何就能判的事"),BLOCKING,Tier.DECLARED(**BLOCKING 这半句已由 D52 推翻**:已降为 WARNING,下面这段测量与成因仍成立)。校准:30 个测量面 531 页只命中这一页,零误报。为什么以前没人报——`emptied_page`/`thin_copy` 数字符,字都在;`placeholder_copy` 找模板残留,没有;`off_page` 量的是框和画布边的关系,这个框在画布里;`unreadable` 要一个有面积的框才能取底色。顺带查了成因:拿同一个 `adapt(prototype(tpl,28), title=..., texts={...})` 复跑,模板的框(t=1475013、h=2095500)原样保留,**不是引擎干的**;deck 里那个框的 0.72in/11.88in 正是作者脚本自己的 `SAFE_L`/`SAFE_W` 常量,是作者的程序动过这个框。 - -**D40 结构页自己的角色标签不算漏填:判据问这一页"是什么",而不是把字数下限换个名字放回来。** D48 把 `placeholder_copy` 从字数改成"是不是一句话",代价记在它自己那一段:18 本已交付 deck 里 8 本第 2 页因 `目录` 被拦(`20260907_031357_c53bc2`、`20260907_062434_2349f9`、`20260907_071215_488328`、`20260906_094529_ee6328`、`v13_blue_plugin`、`v13_red_plugin`、`v13_warm_plugin`、`v16_amber_fast`),全是模板目录页自己的标题,deck 正当继承并且正当保留;同一条判据把同一个东西的英文半边 `Agenda` 降成 WARNING(单个拉丁词),于是中英两半待遇相反、拦的是中文那半。D48 已经写明可走的口子是"模板结构页自己的角色标签",这一条就是把它落地。 - -**豁免的形状:这一页扮演的角色的名字,只在扮演这个角色的那一页上。** 三处约束都是为了不让它变宽。(一)**页面的角色从计划克隆的那张模板页读**,不从建成的页面读:`_role_labels`(`adherence.py`)拿 `PagePlan.prototype` 去查 `roles(menu(模板))`,和 `density._furniture` 读的是同一对东西。问建成的页面会绕回自己——`menu` 认出目录页靠的正是这条判据要不要放过的那个字符串,那样一页就会"因为写着 `目录` 所以可以写 `目录`"。没声明 prototype 的页面没有角色,也就没有豁免。(二)**只放过角色自己的名字,不是这一页上的所有字符串**:`role_named`(`menu.py`)要求整个字符串就是那个词,而不是含有那个词。`_role` 判一整页时"含有"就够(`单击此处添加章节标题` 那一页确实是章节页),在判据里"含有"是个洞:填章节标题的指令说出了角色名,它仍然是没填的槽;`Trends` 里含有 `end`。(三)**词表是 `menu.py` 自己那份**(`_AGENDA_WORDS`/`_SECTION_WORDS`/`_CLOSING_WORDS`,封面不进——`_COVER_LAYOUTS` 是版式名,封面上的字是 deck 自己的题目),所以没有第二份清单要维护,也没有任何字数或词数参与判断。 - -按 D37 的规矩静态校准,同一个脚本换两棵树跑(`.tmp/unreplaced/dump.py` 逐条、`calibrate.py` 计数):**一共只少了 18 条,一条都没有多出来。** 10 套模板 211 页 BLOCKING 1300→1293、WARNING 17→14——少掉的是 7 条第 2 页的 `目录`(amber_wave、black_circuit、blue_minimal、green_aurora、red_chinese、teal、warm_bauhaus)和 3 条第 2 页的 `Agenda` 记号(blue_minimal、red_chinese、teal);**中英两半在同时带着两半的那三套模板上一起消失,这就是对称**。11 本已交付 deck 152 页 4→0,7 本 ppt_runs deck 140 页 4→0,正是上面点名的那 8 本,8 本的计划都写着 `prototype=2`、模板的目录页也都是第 2 页。**D48 的三处真阳性一条没丢**:stack6 第 14 页图表里的四个 `第一季度` 系列、one_door 第 10 页的 `工作感悟`、第 8 页的 `决策优化` 前后各 4 次命中不变,两本实跑构建 28 页 22→22。反向的窄性也钉在测试里:同一个 `目录` 站在内容页正文里照拦(`adherence` 那条新测试的第 2 页),目录页上第二行 `单击添加小标题` 也照拦。 - -**残留一条,是 `menu` 认角色的既有缺口,不是这次的豁免造成的**:beige_geometric 与 mint_memphis 的第 2 页确实是目录页(都带 `Agenda` 和四条章节名),但 `roles(menu())` 认不出来——`_heading` 取文档顺序第一行,那两页第一行是首个条目的序号 `01`,`_role` 于是既没看到目录词、又因为 13/9 个文本块不落进章节页那条兜底,角色为空。所以这两页的 `Agenda` 仍是一条 WARNING 记号。它是 WARNING 不是 BLOCKING,18 本已交付 deck 没有一本绑这两套模板,而修它要动 `menu` 的角色识别——那会同时改变"哪些页算示例页"(`test_ppt_engine_default_templates` 按 `role` 为空挑示例页)与 `template_adherence`/`density._furniture` 的读数,是另一次改动、要自己的校准。记在这里。 - -**D41 `adapt`彻底删除,不再是可调用的 API:只留抄写那一条门。** 用户原话:"everything goes the clone route, so `adapt` should not survive as a callable."D38 当时的落点是「教学路线改成 `clone_page` + `replace_text`,`adapt` 不再教但仍可调用」,并明确写着「所以 `adapt` 保留」。这一条推翻那半句。理由是 D38 自己记下的事实在「不教但留着」下并没有消失:两条路的默认行为恰好相反(`adapt` 清空没点名的文案,`clone_page` 原样保留),一本实跑 deck 正是各走一半、彼此假设对方的默认,交出八页空白;而模型手里的旧参考、旧脚本、旧回忆随时会把第二扇门重新打开——**不教一条仍可调用的路,等于把它留给会照旧参考写代码的那一次**。所以判据从「文档不许印 `adapt(` 调用」上移到「投影出去的 `ppt_template` 不许定义 `adapt`」(`tests/_ppt_engine_skill_claims.py` 9.1 节)。作者拿到的 `ppt_template.py` 就是 `compose.py` 自己的源码(`helper_source()`),所以删在包里就是删在每个 build 目录里。 - -**删了什么、留了什么。** 删掉公开的 `adapt()`(`compose.py` 1589-1775,187 行)以及只有它用得到的私有件:`_heading_rows`(连同 `_slot`、`_largest_row`、`_TITLE_SLOTS`、`_BLOCK_GAP`、`_BLOCK_LEFT`)、`_picture_spec`、`_advice`;连同下面搬进 `replace_picture` 的那道检查一起算,`compose.py` 从 2689 行降到 2340 行。**共用的机件一件没动**:`_pick`(`replace_text` 与 `fill` 都在用)、`_describe`、`_head`、`_all_shapes` 全部保留,`clone_page`、`replace_text`、`replace_picture`、`drop_shape`、`units`、`fill`、`add_unit`、`remove_unit`、`arrangement`、`boxes`、`place` 一个不少。判定不是靠眼看:按 AST 数每个模块级私有名在整份源码里的载入次数,删完之后只剩 `_BLOCK_GAP`/`_BLOCK_LEFT` 无人引用,随之删掉;`typing.Any` 的最后两处用法在 `adapt` 体内,import 也一并去掉。 - -**丢掉的能力记在这里,没有替代品。** `title=`/`subtitle=` 按角色找页面的标题行与副标题行——先读模板自己给的占位符类型,再按几何兜底(标题下 8% 页高、左边缘 1% 页宽之内算同一个标题块,再不行取标题列里唯一比别人都大的那一行)。这套读法在当时的 12 套模板 197 张示例页上标题 197/197、副标题 180/197 命中,现在没有任何公开调用能拿到它,随之删掉 6 条测试(`test_adapt_writes_the_heading_rows_by_role`、`test_a_page_with_no_heading_row_says_so`、`test_a_cover_heading_is_found_where_the_template_centred_it`、`test_a_section_divider_subtitle_is_the_line_under_its_title`、`test_a_presenter_row_above_the_title_is_neither_heading`、`test_a_subtitle_stranded_mid_page_is_found_by_its_type_size`)。抄写路线上作者按模板此刻说的话点名标题(`replace_text(s, "单击此处添加页面标题", "...")`),不需要知道是哪个形状,所以这不是当下的缺口;要是以后需要,它应当作为自己的一扇门重新开,而不是随 `adapt` 一起回来。引擎内部另有一套(`menu.py` 的 `_heading`/`roles`),判角色的地方用的是那一套,不受影响。 - -**真正花时间的不是那个函数,是它的参数拼写留在作者读得到的地方。** `adapt` 没了,`texts=`/`items=`/`drop=`/`keep=`/`pictures=` 这些只属于它的键还写在 SKILL.md(11 处,含判据对照表里「`pictures={n: ...}` a figure in, or `drop=[n]`」那一行)、门禁报文(`adherence.py` 的 `template_picture`/`layout_picture`/`placeholder_copy` 建议、`figures.py` 的拉伸提示)、`ppt_template` 的回复(`tools/template.py` 三处)、`menu.py` 的图标提示、`compose.py` 与 `stages/build.py` 的注释里。**报文教一个不存在的参数,比没有报文更糟**:作者照着写会得到 TypeError,而报文本来是它唯一的出路。全部改写成现存调用(`replace_picture(shape_at(s, n), ...)`、`drop_shape`、`replace_text`)。历史叙述(density.py、gates/registry.py、adherence.py 里记「当年那本 deck 为什么空」的注释)保留事实但改成「已删除的那条路线」,不再用反引号指一个函数名。 - -**实跑验证,不是只跑单测。** 绑 `blue_minimal_general_analysis.pptx`,只用 `clone_page` + `replace_text` 写一份 build.py,走 `run_script`(脚本后端,非 inline):克隆原型 1、2、5 三页,按各页此刻说的话逐行点名 4+18+10=32 句,**32 句全部写进去、模板一句都没留下**(`ok=True`,3 页);build 目录里投影出来的 `ppt_template.py` 不含 `def adapt`;把同一份脚本的第一页改回 `adapt(prs, prototype(tpl, 1), title=...)` 再跑,构建失败于 `ImportError: cannot import name 'adapt' from 'ppt_template'`。也就是说这不是「不教了」,是真的调不到了。 - -**顺带撞上一条钉:`TOOLS.md` 成为第一份与 fork 有意分叉的携带提示词。** `raven_ppt/prompts/TOOLS.md` 不是停在包里的字节,`hook.py` 的 `seed_identity` 会把它写到 agent home 的 `TOOLS.md`——宿主 context builder 只读那三个座位之一——而它里面写着「`prototype` and `adapt` to clone a page and fill it」。`tests/test_agents_ppt_launcher.py` 有一条 `test_the_carried_identity_prompts_are_the_forks_byte_for_byte`,把 `SOUL.md`/`AGENTS.md`/`TOOLS.md` 三份都钉成与冻结的 fork 夹具逐字节相等,于是「修活的提示词」和「保住字节相等」直接冲突。**改夹具是不行的**:那份夹具就是 fork 说了什么的基线,改了它这条测试就什么也不证明了。所以 `TOOLS.md` 从字节相等那一条里退出(`SOUL.md`/`AGENTS.md` 仍然钉着),换成钉分叉本身的理由:fork 那份里有 `adapt`、我们这份里没有、我们这份有 `clone_page` 与 `replace_text`。这是产品第一次在携带提示词上有意分叉,记在这里而不是让它悄悄发生。 - -**删的过程里揪出一件必须搬而不能丢的机件:图片替换的种类检查。** `adapt` 在解析 `pictures=` 的键时会先问一句 `_blip_fill(shape) is not None or _is_drawing(shape)`——落在文本框、占位符或者带正文的面板上就拒绝并把整页列出来。`replace_picture` 自己**从来没有这道检查**(实测确认:把它指向组内的一个文本框、或一块带正文的矩形面板,它不报错,直接把那个 shape 换成图片;`_is_drawing` 这个判据当时只剩 `menu._picture_slots` 在用)。也就是说 `adapt` 一删,这道检查就从唯一的公开写入路径上消失了,而序号正是它存在的理由——两个模型都曾把「页面第二个形状」当成图片位,实际图片是第三个。所以把它搬进 `replace_picture`(`_refuse_what_is_not_a_picture`,多 shape 与单 shape 两条路都过),报文照 `adapt` 原来的样子把整页逐个形状列出来。**只有一件真的没了**:`adapt` 对「整页只有一张图时,落错的序号读成那张图」的宽容——那是键解析的行为,`shape_at(s, 2)` 现在就是第 2 个形状,不猜。对应那条测试随之删除。 - -**测试面的账。** `tests/test_ppt_engine_template.py` 里 39 个用到 `adapt` 的测试:17 个 `items=` 改成 `clone_page` + `fill(max(units(s), key=len), items)`(拒绝报文本来就是 `fill`/`add_unit` 自己发的,一字未改仍然命中),字符串键改 `replace_text(s, old, new)`、整数键改 `replace_text(shape_at(s, n), ...)`,`pictures=` 的三种拼法改成 `replace_picture` 自己的关键字(`box=`、`alpha=`、`trim=`,元组键改成 shape 列表),`title=` 改成点名页上第一个带字的框;6 个表头测试与只服务它们的 `_written_slots` 删除;3 个图片种类测试靠上面那道搬过来的检查转成 `replace_picture` 自己的拒绝。文件从 109 个测试到 103 个,`104 passed`(含参数化)。另有两句 `_advice` 的引导没了替代(「这一页根本没有图片框」「你把 '' 传给了 N 个键」),前者的 shape 列表那半句由新报文承担。 - -**D42 换字不换框,装不下的那一刻就说,判据只在测量噪声之外开口。** 9 月 10 日 medium 档一条 14 页实跑走通了「克隆模板页 + replace_text」这条路:14 页全部取模板原型,`adapt` / `fill` / `add_slide` 零次,空页与残留模板文字这两类缺陷彻底消失,换来的是一类新的——`replace_text` 原样保留原型的几何,比被替换的那句更长的文字无处可去。逐页读渲染图确认了五种表现:折行破坏一行卡片的共同基线(p9 的「Legacy pressure」折成两行,整块标题被顶高)、autofit 降一档字号破坏一行卡片的统一字号(p2、p13)、正文越过安全区压到模板亮色渐变上(p3/p4/p6/p9/p13 的 1、3、5 号卡)、挤住下一个元素(p11)、直接压住邻居(p12 的标题被来源行压住),以及最坏的一种:**框底对齐时文字向上长出画布**——p14 的标题在 72pt 下长到 4 行,"Buy the" 被幻灯片上边缘裁掉,压住 kicker 行和它的分隔线(重建那一版渲染核对过)。门禁事后全数报到了(p14 一条 BLOCKING `word_collision`,加 `overset_copy`×8、`clipped_copy`×4、`wrapped_label`×4、`type_drift`、`type_floor`、`box_overflow`、`rule_strike`),但都在终点线上:整本写完又渲染完,作者要一次回答 14 页。 - -选的缝是 `replace_text` 自己,因为只有它同时握着两半:框,和模板放在这个框里的那句话——下一行就没有了。判据因此不是「按盒子高度算出来能放几行」,而是「盒子放得下的行数,与模板设计者实际放进去的行数,取大」:前者是拿一个假定的行高系数做的算术,后者是事实,两者相左时事实赢。这条结构让「把模板自己的文字写回模板自己的框」按构造必然沉默,而不是靠某个恰好成立的阈值。说话的时机是程序结束(`atexit` 注册的 `_say_what_did_not_fit`),不是写入的那一刻:页面在文字进去时还没写完,那条实跑的封面写完三行标题之后才把 run 设成 44pt,按写入时生效的 72pt 去量报的是「首行被上边缘裁掉」,而渲染图上这一页完好;真正发运的是程序末尾的文字、字号和框(作者随后还可能 `place` 把框放大)。延后之后这条误报消失,另有一个副产品:警告落在 stderr 末尾,而 `ppt_build` 回复里的 `warnings` 取 stderr 的最后 3000 字符,留下的正好是它们。 - -只在测量噪声之外开口。宽度测量器(`assets/text_metrics`,FreeType 与导出方言取大)从不把字符串量得比任何一方会画的更窄,这对跑在成品文件上的门禁是对的方向,也意味着这里每个读数都是上界。量了两遍:10 套内置模板自身的渲染里,182 个「整句就是一行、继承链上没有 autofit」的框,overshoot 中位 1.13、99 分位 1.27;一本已交付 deck 上 9 条未被缩放的英文行是 1.10 到 1.30。所以新文字按 `MEASURED_WIDTH_OVERSHOOT = 1.35` 加宽后的列去折行,只有活过这一步的溢出才出声——`wrapped_labels` 从另一头得到过同一个结论,把预测留作候选、交给渲染裁决,而这里在渲染之前。autofit 读形状自己的 `bodyPr` 而不读继承链,anchor 读继承链,两条都在同一个文件上量到:`black_circuit_tech_launch` 的卡片标签把 `normAutofit` 写在形状上,实跑里 20pt 的位置回来是 17pt;它的结语标题只从版式继承 `normAutofit`,4 行替换被按满 72pt 画出来并裁掉,所以继承来的 autofit 不是缩放,在这里读继承链会对丢了一行的那一页承诺一次缩放。反过来 `anchor="b"` 写在版式的标题占位符上、形状答 `None`:只读形状,p14 报的会是「往下跑」,而它实际是往上长出画布。缩多少不预测——第一版按 shows/needs 算出「接近 10pt」,渲染给的是 17pt。 - -校准与够不着的。10 套模板 211 页 2084 个文本框,每个框把自己的文字写回自己:0 条。这条实跑的 6 个脚本状态逐个回放(从第一次整本 build 到交付版):9 / 9 / 8 / 5 / 3 / 2 条,随作者改短而收敛;第一个状态的 9 条逐条对上了实跑自己在渲染侧报的东西(p7、p9 两条、p10、p13 的 `type_drift`,p12 的两条 `type_drift` 与一条 `overset_copy`,p14),0 误报;交付版的 2 条正是渲染里唯一两个降了档的标签(17pt 与 17.4pt),同一行另外三个标签不报。够不着两类,都记在这里而不是调阈值去凑:一、p11 那两个正文框在 14pt 下需要 4 行、盒子放 3 行,33% 的溢出,而测量器在这一页自己的渲染行上就偏高 16% 到 25%,单边界限无法把它和噪声分开——把 overshoot 降到 1.20 才够得到 p11,代价是交付版从 2 条涨到 8 条,其中 6 条渲染显示本来放得下。二、p12 标题被来源行压住不是盒子容量问题,是标题画出来的横向范围撞上作者自己新加的一个框,且标题框不声明字号(`overset_copy` 同样跳过它)。这两类仍归渲染侧门禁。 - -**D43 `ppt_template` 的正文被传输层整段吃掉;一个原型起大半本 deck 的判据先降为报警,又按实跑改回拒收。** 9 月 10 日那条 medium 档 14 页实跑走的是 clone_page + replace_text 这条新路线,路线本身满分:14 页全部取自模板原型,adapt/fill/add_slide 零调用,没有空页、没有残留模板文案。缺陷是单调——渲染出来看,14 页里 8 页是同一个五边形卡片行(p3/p4/p6/p7/p9/p10/p12/p13),另有 3 页共用一个照片版式,只有封面、目录、结语是不同的。模板不缺料:`black_circuit_tech_launch.pptx` 34 页,引擎报 `example_pages: 34`,其中 7 页是结构页,余下 27 页都是内容原型,同一条回复还另外提供了 36 页可借的参考页。作者只用了 1、2、5、9、34 五个原型,借页为 0。 - -**根因是回复没送到,不是判据没说。** `tools/_return.py` 返回 `ToolResult(model_text=<那份 JSON 名录>, blocks=<34 条单行说明 + 34 张渲染图>)`,而 `raven/agent/loop/organ_glue.py::_route_result_images` 在「模型能看图、但传输层不能把图片放进 `role="tool"` 消息」这一支(所有 OpenAI 风格 Chat Completions 端点都是,本次 OpenRouter 正是)返回 `image_placeholder_text(blocks)`——**`model_text` 整个被丢掉**。`image_placeholder_text` 的正文只用 `blocks` 里的 text 块拼,它自己的 docstring 却写着「Keeps the tool's own text」。作者收到的 `ppt_template` 回复因此只有 36 行:起止标记加 34 条单行页面说明。实测那段文本里 `27` 只出现 1 次(且是页号),`borrow`/`bundled`/`reference page` 各 0 次,`canvas_in`/`theme_colours`/`next_step` 全部 0 次。也就是说「本模板有 27 个内容原型」「另有 36 页可借」这两件事从未进入作者的上下文;`ppt_outline` 的 `borrowed` 字段说明还在让它去看 `borrowable_pages`,指向一个从未送达的键。同一个 bug 还吃掉了每次读回的 `pages_not_read`(四次读回有四次只返回 2 页,作者不知道少了哪些),以及全部 11 次非 draft build 回复里的 JSON——`equal_card_habit`/`symmetry_habit`/`layout_variety` 每次都发了、字节一致,但它们 `page: None`(`tools/build.py` 只给有页号的 finding 配文本块),正文又被丢掉,所以作者一次都没看见。修法:`image_placeholder_text` 新增 `tool_text` 形参,organ_glue 两处调用把 `model_text` 传进去,与 blocks 里已有的同串去重。这条修在宿主侧(`raven/`),记在这里是因为它的受害者是这条路线。 - -**第二层:大纲那条 ask 送到了,但只是 ask。** `_reused_prototypes` 早就存在,而且在这条实跑里响了两次、两次都进了作者的上下文(它不带图,所以没被上面那个 bug 吃掉):1760 字符、逐页点名 9 个重复页各自该换的未用原型。两次 `ppt_outline` 都 `recorded: true`,第一次 8 页压在原型 6,第二次换成 5——它读了、动了手,改的是「重复哪一个」而不是「重复几次」。作者写下的原话是 "But it's a warning, not blocking." 与 "That's optional guidance"。 - -**`_repeated_prototype` 的判据,BLOCKING。** 三个条件同时成立才说:一个原型起的内容页**严格过半**(`PROTOTYPE_SHARE = 1/2`)、**至少 4 页**(`PROTOTYPE_FLOOR = SERIES_RUN + 1`,因为 `SERIES_RUN` 已判定「连着三页建得一样」更可能是有意做的对比系列)、并且**这套模板自己未被使用的示例页**能接住那些重复页(`_matched_examples` 非空)。第三条是保护伞:只有两个正文版式的模板,重复是模板造成的,此时借页仍然只是邀请。报文不给类别给页号,逐页写明该换成哪一页、各自的构成与图片槽占比。 - -**severity 反复过一次,两次的依据都记在这里。** 先降为 WARNING,两条理由:一,**有用户就是要统一风格**,如果用户点名要重复某个版式,拒收会让这条 run 永远因为「照做了」而交付不出去,那是陷阱不是门禁;二,当时的实测像是说它根本不是那根杠杆——传输层修好之后,同一份 brief、同一套模板的两条实跑,high 档大纲自己就出了 **14 页 10 个不同原型**(最常用的起 2 页)、max 档 **14 页 14 个不同原型**(零重复),对照修之前那条 medium 档的「5 个原型起 14 页、最常用起 8 页」,判据一次都没响。据此的结论是:真正把版式打开的是「作者终于看见模板有什么」,这条判据只是兜底。 - -**推翻它的是后来三条实跑**,都跑在判据仍是 BLOCKING 的树上,brief 里不含任何路线引导(引导已被剥掉并 grep 验证过)。A 跑:大纲把原型 22 压在 **9 个内容页里的 5 个**上,模板 22 个内容示例有 18 个没用,被拒;拒收逐页点名了替代页,**21 秒后**作者带着 **4/4** 条被点名的替代回来,落在被点名的那几页上,不同原型数从 **7 升到 11(共 12 页)**。C 跑:大纲把一个原型压在 **10 个内容页的全部 10 个**上,27 个内容示例有 26 个没碰——比这条记录当初写的 8/14 还糟,被拒;**63 秒后**通过,**12 页 12 个不同原型**(原来是 1)。C 跑值得单记一笔:它取了被点名的 8 个里的 7 个,却**把每一个都重新配到了另一页**,还自己加了三个拒收从没提过的原型——它把拒收当成「模板有料」的证据,而不是一张照抄的清单。三条实跑里走到大纲的有 3 条,判据响了 2 条,两条都在一分钟内改了。 - -对照本条前面记的那次:同一件事当年只是 ask 的时候,作者「改的是重复哪一个,而不是重复几次」;变成拒收之后,它两次改的都是重复几次。所以降级时那条「它不是杠杆」的推论是错的——它成立只是因为那两条实跑本来就没到会触发的程度(2/11 与 1/11),拿没触发的样本证明判据无用,是把沉默当成了证据。第一条理由(有人就是要统一风格)**没有被证据推翻,是被权衡掉的**:三条 brief 没有一条要求统一风格,而「一本 deck 从头到尾是同一页换字」的代价是量到过的,被困住的 run 目前还只是设想。代价照实记:这是大纲阶段的拒收,`buildCap` 那套「封顶照发」不管它(那是 build 侧的),所以真有用户点名要一个版式重复到过半时,作者除了改计划没有别的出路——这是这次反转已知且接受的成本,不是没想到。 - -**标定(判据没动;两次 severity 反复都没有改变哪些情形会响)**:那条 14 页实跑 8/11 内容页(73%)→ 拒;两条 16 页实跑最多 2/14(15%)→ 不响;一条 12 页实跑 10 页用 10 个原型 → 不响;live high 档 2/11(18%)与 live max 档 1/11(9%)→ 都不响。把 amber 裁成「封面 + 2 个正文版式 + 结语」实测:5 页 deck 的 3 个内容页全压一个原型 → 不响(未到 4 页下限),6 页 deck 的 4 个内容页 → 拒。富模板上 2、3 个内容页压一个原型 → 不响。 - -顺带修掉淹掉那条 ask 的噪音:`_slot_facts` 原来逐个罗列图片槽,一个有 15 个等大缩略图的模板页会把「2.3x2.6in, 6% of the page and」重复 15 遍(约 700 字符,正好挤在必须被读到的那条 ask 里);现在相同尺寸合并计数(`4 of 5.4x2.0in, 11% of the page each`)。 - -两处**不改**,各有理由。一,`prototype_kept` 不是「表扬重复」:读代码它只在 `share < KEPT_ITS_PROTOTYPE` 即页面**偏离**了自己声明的原型时才发,实跑里那 3 条说的是 3 页没建在承诺的原型上,与单调无关。二,`equal_card_habit`/`symmetry_habit` 进不了 draft,是 `grading.py` 的 `WHOLE_DECK_ONLY` 明文决定的(「draft 三页里三页是卡片行,说明不了整本会怎样」);这条判据落在大纲阶段,比任何一次 build 都早,所以那 10 次 draft 的沉默不需要靠放宽 draft 来解决。 - -借页顺手复核了一遍(新路线上此前没测过:旧的 252 组一档只 clone 不写字、一档走 `adapt`)。现在是 9 个出借模板 54 页参考页、10 个宿主、去掉自借 486 对,逐对 `clone_page` + `replace_text`:**486/486 通过,0 抛异常,0 存盘失败**,主题色与母版在浅→深、深→浅、深→深、浅→浅四个方向都正确继承。唯一实质缺陷是洗白:用引擎自己的 `contrast_findings` 量,**89/486(18.3%)**的页有文字低于 2.0:1,而 `_pale_accent_note` 只对 warm_bauhaus 一套响(28/49),对 black_circuit(17/46)、red_chinese(11/54)、mint_memphis(10/52)、green_aurora(8/44)、gold_panel(7/44)全都不响——这条待办没做。参考页上的图 27 张里 18 张是 `blipFill`、9 张是 `p:pic`,两种 `replace_picture` 都能改(它走 `_blip_fill`),所以「借来的图还是原模板配色」是占位图没换,不是借页缺陷。 - -未验证与已知缺口:C 跑那条 brief 把所有图都内联给了、也没要求做检索,所以「brief 越薄是不是越容易出一个原型压全本的计划」这件事两条实跑答不了——A 跑是做了检索的,照样走到 5/9,**不要把 C 跑的计划归因于它的 brief**。另外这次反转只改了 severity,`tools/outline.py` 里 `_repeated_prototype` 的 docstring 仍然在论证「是报警不是拒收」,与它下面那行 `Severity.BLOCKING` 自相矛盾;`tests/test_ppt_engine_outline_checks.py` 里钉 severity 的两条(第 1232 行钉 `"warning"`、`test_a_uniform_plan_is_still_recorded` 钉「没有 blocking」)也仍钉着降级后的形状,它们在没有内置模板的检出上会 `pytest.skip`,所以看着是绿的——实测把 628 树的这段代码配上模板跑,两条都会红。还有 `tools/outline.py:440` 那行「响了就不附 `_reused_prototypes`」是降级时为了避免重复而加的,现在 blocking 会让 `_asks` 提前返回,它变成了双保险,留着无害但不再是必需。 - -**D44 一页 raise 只该赔一页;封顶要照发有问题的 deck,但发的是画出来的页,不是占位板;封顶本身在 draft 那一侧根本没被碰到。** 9 月 9 日那条 16 页实跑(`raven_home_stack6` 的 `20260909_132114_9f5065`)在 `deck/review/build_failures/` 下留了四份记录,四份的 `failure.json` 都写 `kind: "page"`——`backends/script/runner.py` 的分块隔离本来就生效,没有一次是整本崩。把这四份脚本连同当时的 build 目录、图与模板照原样重放(`.tmp/page_isolation/replay.py`),开分块与关分块各跑一次:failure-001 八页里第 4、8 页 raise,隔离后 8 页(两页占位),不隔离退出码 1 只剩 4 页;failure-002 第 8 页 raise,8 页对 8 页但退出码 1;failure-003 第 3 页 raise,8 页对 3 页;failure-004 十六页里第 11 页 raise,16 页对 11 页。退出码非 0 时 `run_script` 丢弃 staging,所以“不隔离”那一栏实际交付 0 页、整轮作废。占位落位也核对过:16 页里第 11 页写“Page 11 did not draw”,前后十五页标题都对得上。四次失败相隔 4m53s、21m37s、5m48s,脚本本身跑完 16 页只要约 1.5 秒——那 37 分钟买的是“改一页、整本重跑一轮阅读”,不是丢页;一页赔全本这件事隔离已经解决了。这一条记的是它之后剩下的三件事。 一,封顶照发,但不发占位板。曾一度加过 `HELD_AT_THE_CAP = {"page_failed"}` 把占位页留住不发,用户否掉了,理由成立:封顶存在的意义正是“允许把有问题的 deck 发出去”,否则要封顶做什么——它是拿质量换速度,至少把已经写出来的页摆上去。用质量论据去改造唯一一个专门用来停止讲质量论据的机制,方向就是错的。所以封顶时 `page_failed` 与其余 blocking 一并放行;但“已经写出来的页”不包括引擎自己的红板。新增 `services/publish/trim.py`:`without_pages` 把占位页从一份副本里删掉(sldIdLst 与 rel 两半都删,只删一半 PowerPoint 会要求修复),`pdf_without_pages` 用 `pypdfium2` 把同样的页从预览 PDF 里删掉,删不掉就不给预览——预览比它旁边的文件多一页,预览的就是一本谁都没有的 deck。build 目录那本 16 页原样保留:它是下次编辑的修复基准、每条 finding 页号的计数对象、第二读者读的东西,为对齐交付而给它重编号会动摇整条路线唯一的映射。拿实跑那本真 deck 端到端验过:pptx 16→15 页、预览 PDF 16→15 页、交付件里再无 “did not draw”、build 目录仍是 16 页、`published.json` 记 15。代价是交付件比 finding 的页号短一页,缺口之后的页号低一位,所以回复里新增 `pages_left_out` 逐字说明这件事;不说,读起来就是一本本来就 15 页的 deck。`without_pages` 抛异常时退回原样发(带红板也强过没有 deck),并打 warning。 二,脚本中途存过一次盘,兜底存盘就不再补写。runner 的 `finally` 原本以“PPT_OUTPUT 上没有文件”为条件补存,而按页长出来的脚本会留着它短的时候那句 `prs.save`——failure-004 的脚本第 274 行就是(8 页时代的收尾)。于是文件从第 8 页起就存在,兜底不再动手;画到第 11 页才崩的那一轮盘上留下 8 页,而失败目录那份 `deck.pptx.building` 正是回复让作者“打开看看程序走到哪”的东西,它少说了三页。改成按 `finished` 判:只有脚本没跑到自己最后那句 save 才补存,跑完的 build 一个字节都不再写(有测试钉住,因为补存现在会盖掉已有文件)。同一份 failure-004 重放,关分块那栏从 8 页变成 11 页。补存也改为优先取“页真正加进去的那一本”(`slides_seen[0].part.presentation`),脚本常同时开着 deck 和模板两个 Presentation,扫命名空间可能盖错对象。另外两处“替一页兜底反而赔掉全本”的路收窄:`_drop_last` 的 count 对切片长度做上下限(count 大于页数时 `[-count:]` 会取整张列表、把画好的页一起删掉),`drop_rel` 撞到已移除的关系不再外抛;`_stand_in` 先 `add_slide` 占位再尽力写字,写字失败只记 stderr——这段跑在 `except` 里,它抛出来就等于一页的占位赔掉后面所有页(有测试钉住)。 三,那条实跑里封顶一次都没触发,原因在 draft 这一侧,不在上面任何一条。`.deck-mode.json` 是 `{"mode":"high","buildCap":10,"readingCap":3}`,而 `deck/state/builds.json` 根本不存在——`count_whole_build` 从未被调用过,`whole_builds_taken` 恒为 0,`release` 永远是 False。`tools/build.py` 的 `whole = tier.whole_builds_taken(deck) + (0 if draft else 1)` 与 `if not draft: count_whole_build(...)` 只数非 draft 的调用。四条实跑的 `ppt_build` 计数:stack5 17 次成品 build,发布了;medium 1 次成品,没发;stack6 5 次 draft、0 次成品,没发;one_door 13 次 draft、0 次成品,没发。凡是没离开 draft 的实跑,一次都没发布过。而 draft 并不便宜:`BuildStage.run` 里 measure(也就是渲染)在 `if draft:` 之前就跑完,`_DRAFT_EXEMPT` 只免掉 `page_budget`/`page_mapping`/`unseen_page` 三条,所以一次 draft 的成本约等于一次整本 build 减去发布这一步,却对封顶计 0。决定要不要被评判的是作者自己,而作者正是门禁存在的对象;更糟的是,唯一那句“draft 不交付”写在 `elif not blocking and "pptx_path" in payload:` 里面,而 draft 永远没有 `pptx_path`(stage 在发布前就返回),那句话在它唯一该出现的路径上从来没出现过;钉它的那条测试用的假 stage 无论 draft 与否都返回路径,所以测试一直是绿的。两处都修了:判据放宽成 `("pptx_path" in payload or draft)`,另外每次 draft 成功都单独给一句 `draft_does_not_deliver`(措辞见 `tools/build.py`,钉在 `test_ppt_engine_build_tool.py`)。用户拍了要建,并且不要第二个预算——一个数字比两个好讲。又一条实跑坐实了它是空转:今天一条 medium 档实跑 7 次 build 全是 `draft: true`、计 0、没有台账;连同 one_door 的 13/13 和 stack6 的 5/5,作者自己是不会走 `draft: false` 这条路的。所以 draft 记一次整本 build:`tools/build.py` 的 `whole = tier.whole_builds_taken(deck) + 1` 与 `release = caps.build_cap is not None and whole >= caps.build_cap` 都去掉 `not draft` 那一半,`count_whole_build` 也不再只数非 draft 的调用(仍然只数产出了 deck 的那次——没产出 deck 的一次不是一次 build);`stages/build.py` 的 `if draft:` 改成 `if draft and not release:`,封顶那一次 draft 走发布路径,`_DRAFT_EXEMPT` 免掉的三条照常量出来、照常随封顶放行。依据是 draft 本来就要花一次整本 build 的钱:`measure`(即渲染)在 `if draft:` 之前就跑完,`_DRAFT_EXEMPT` 只免 `page_budget`/`page_mapping`/`unseen_page` 三条。回复里每次都报 `build_budget`(无封顶的档位报“第 N 次、本档不封顶”),因为实测的失败形态就是作者看不见预算、于是当它无穷来花。两处附带修正:封顶那次 draft 已经交付,所以 `draft_does_not_deliver` 加了 `"pptx_path" not in payload` 的条件;“已交付且无 blocking”那条 next step 的三元判据由 `not draft` 改为 `"pptx_path" in payload`,否则一本刚交付的 deck 会被描述成草稿。实跑序列验过(`.tmp/page_isolation/cap_sequence.py`,真 tool/stage/runner,只 stub measure):buildCap 3、脚本第 2 页 raise,连发三次 `draft: true`——第 1、2 次 `build_budget` 报 1/3、2/3 且不发布,第 3 次报 3/3,`released_at_cap` 与 `pages_left_out` 同时出现,交付文件 2 页(“The company”“What is next”,红板已剥掉),build 目录仍 3 页,`published.json` 记 2 页。两条老测试跟着改:钉“drafts count nothing”的那条现在钉相反的规则,钉“draft 会被告知第二读者”的那条改用真实形状(假 stage 无论 draft 与否都返回 `pptx_path`,正是它让那段不可达的文本一直显得可达)。 - -**D45 一台机器同时开几个 LibreOffice 是机器的事,产品配置里那个固定的 2 撤掉。** 9 月 9 日复盘一次 110 分钟的 16 页实跑时量的:同一台 32 核机器上把 7 套模板(各 34 页、图多)转 PDF,1 路 87.0s、2 路 67.8s、4 路 53.5s、7 路 50.3s,四种宽度都是 7/7 成功,而且**单次转换的最慢耗时几乎不动**(50.0 / 52.8 / 49.7 / 50.3s)——每次调用本来就是独立进程加独立 profile(见 `office.py` 的 profile 锁那段),彼此不抢任何东西,所以加宽只买墙上时间,不让任何一次变慢。而此前每台机器都只开 2 路:fork 的 `tools.ppt.renderConcurrency` 写死 2,产品的 `agents/raven-ppt/config.json` 又照抄了一遍,于是**改代码默认根本没用,那个显式的钉子才是真正生效的值**。据此:新增 `services/render/office.default_concurrency()`,取核数的一半、下限 2(最窄的机器也得有重叠)、上限 8(实测到 7 为止,8 份 profile 已是两个多 GB);`DeckViews.concurrency` 与 `EngineConfig.render_concurrency` 都改为读它,`build_ppt_tools` 的入参改成 `None` 即跟着机器;产品配置里 `renderConcurrency` 那一行删掉,运维自己写的值仍然优先、仍然只接受 1..8(越界照旧报错不静默夹取)。launcher 的对照测试里为此加了 `TRUNK_DROPPED_SLICE` 台账行:这是第一条「产品故意不再写」的 fork 键,与 `TRUNK_ONLY_SLICE`(产品新增的键)成对。这条只省渲染的墙上时间(一次模板拼图约 18s),不解释那 110 分钟里的大头,页级隔离与大纲耗时是另两条。 - -**D46 借页洗白的警告改问"这套配色能不能承住借来的字",不再问"accent1 上白字读不读得出"。** -9 月 10 日把 54 张参考页克隆进 10 套内置模板、486 对全部渲染后用 `measure.contrast` 读了一遍:89 页 -(18.3%)带有低于 2.0:1 的正文,10 套宿主每一套都有。而 `tools/outline._pale_accent_note` 背后的 -`theme.white_on_accent` 只问一件事——accent1 对纯白是否低于 2.0——十套里只有 warm_bauhaus 的 accent1 -(#F5AF55,白字 1.88:1)低于它,于是那条警告只对 1/10 宿主响过,漏掉 89 页里的 65 页,包括实跑真正在用 -的深色模板 black_circuit 的 17 页(它的 accent1 是 2.33:1 过关,accent2 是 1.91:1、accent2 的 60/40 -淡色是 1.5:1)。两个函数为什么会分家:`contrast_findings` 的底色取自渲染图上文字框内最差的那一档像素, -所以它对任何模板、任何叠放都有答案;`white_on_accent` 只读主题里的一个色位对一个固定墨色,问的其实是 -"这是不是 warm_bauhaus"。 -改的是问法,不是阈值。借页的标签色钉在 run 上(89 页里 68 处是 #FFFFFF、6 处是 #F8F8F8 字面色,其余 -15 处是 scheme 色),卡片填充却跟着落地的 deck 换色,所以判据变成"这套配色里,参考页真正写过的(标签色, -卡片色)组合有哪些读不出来"。组合不是颜色的笛卡尔积:`defaults.BORROWED_LABEL_PAIRS` 钉的是把 54 张页 -逐个走一遍、每个标签跟它所在的那张卡片(自身填充,或包住它 90% 以上的最小填充形状)配对得到的 29 组, -按出现张数排序;每组带 `lumMod`/`lumOff`/`alpha`,因为 89 页里有 22 页落在色位的淡色而不是色位本身, -而 15% 的卡片根本不是那个色位、是底色掺了一点它。`theme.unreadable_grounds` 只做算术:把两半在绑定模板 -的调色板里解析(字面色不变、scheme 色换掉),淡色按 lumMod/lumOff 算,半透明的按 deck 自己的 bg1 合成, -并且同时问"淡色本身"和"合成后"两端——LibreOffice 画填充 alpha 比 PowerPoint 重,单给一个答案就是我们的 -答案而不是文件的。三条路都试过并按 486 对打了分:只认同一形状上的配对(19 组)覆盖 89 页里的 61 页、 -误报 20 组;同页出现即配对(68 组)覆盖 82 页但误报 163 组、每套模板 18–30 行,把真正要说的那两组挤出 -回复;按几何包含配对(29 组)覆盖 77 页、误报 26 组、每套 5–10 行,取的是这一条。合计:命中从 24/89 -(1/10 宿主)到 77/89(10/10 宿主),warm_bauhaus 28/28、black_circuit 15/17。 -"宿主自己的页不能响"由归属而不是比值兜住,和门禁同一条事实:54 张参考页里只有 green_aurora 第 4 页 -(1.80:1)和 mint_memphis 第 6 页(1.78:1)在自己的配色下低于 2.0,两页 `measure.contrast` 都判 -WARNING 而不是拒绝(形状落在模板自己页面的位置上),而借页清单本来就是 `reference_pages(except_stem= -host)`,绑定模板自己的页从不在其中。剩下 12 页漏报里 8 页(含 black_circuit 借 warm 第 8/10 页那两页, -白正文压在没换色的 #FFFFFF 字面卡上,1.0:1)的卡片是画在文字后面而不是围着文字的形状,任何配对表都碰不到, -要碰只能在回复时解析整叠填充,也就是重造渲染器——那正是 `measure.contrast` 去读渲染图的理由。 - -**D52 没有建立"对所有情形都兼容"的判据不许拦截:`boxless_copy`、`literal_escape`、`house_style` 三条降为 WARNING。** 用户定的通用规则:"不要加没有确立与所有情形兼容性的门禁。" 起因是 `boxless_copy`(D48 那半句已就地标注推翻):它的校准是 **30 个测量面 531 页、1 条命中、0 误报**——一个正例撑不起一次拒绝,因为一条正例只说明这个判据在它被写出来的那一页上是对的,完全没有说明它在读错的那些页上会做什么;而且那唯一一页的框是**作者脚本自己的 `SAFE_L`/`SAFE_W` 常量**改的(D48 已实测复跑确认引擎原样保留模板的 2.29in 框),所以连"引擎会产出这个形状"都还没有证据。判词、报文、Tier.DECLARED 全部保留,只有拒绝这一层撤掉;另外给它单独写了一条 `_ASK`,因为这是唯一一条**渲染图上看不见**的意见(文案根本没画出来),落到通用的"consider N boxless_copy finding(s)"作者对着图看不出要修什么。 - -**顺着把 DISPATCH 里其余 10 条 BLOCKING 全部按校准面复核了一遍。** 结论分三档: - -| kind | 校准面 | 真阳 | 误报 | 支持拒绝? | -| --- | --- | --- | --- | --- | -| `unreadable` | 11 页 → 12 套模板 → 8 套模板+4 本 deck → 24 个文件 → 8 本 deck 约 160 页,阈值按实测改过两次(3:1 → 2.0 + 模板自绘 1.3 档) | 多次 | 7 类,逐类查清并收口 | 支持,全表最扎实的一条 | -| `placeholder_copy` | 10 套模板 211 页、11 本已交付 deck 152 页、7 本 ppt_runs deck 140 页、2 本实跑 28 页;D40 修完少 18 条、一条没多 | 3 条留存 | 已交付 4→0、ppt_runs 4→0 | 支持 | -| `template_underlay` | 同上四个面;加第三条件后 10 套模板各 13–30 条 → 0,已交付与 ppt_runs 0→0 | 1 条(手搭那种构造,现有测试) | 0 | 支持 | -| `emptied_page` | 10 套模板 211 页、11 本已交付 deck 152 页、加出问题那本 | 8 条,正是那八页 | 已交付 0 | 支持 | -| `word_collision` | 只有 1 本人工搭的已验收 deck 作负对照 | 极多:D2 三本交付带字压字、D8 一本 25 对连着八次构建没动、D11、D36、D38 9→4、D42 p14 | 1 类(gold_panel 五页 12pt 句点,四页 17 对,克隆过这些页的 deck 一律被拒)——**是在模板里修掉的,不是在判词里** | 支持(靠实跑真阳记录,不是靠前置扫描)。残留风险:用户自带的模板里再出现同样形状还会误拒 | -| `page_budget` | D3b 七次实跑 932 条里从未触发;无页面/模板扫描 | 0 | 0 | 支持。它是全表唯一**判词里没有本包常量**的一条:范围就是用户给的数,没有可校偏的阈值。已交付会误拒的那条路(`ppt_prepare` 用 10–20 默认值补全 brief)在本树不存在——`prepare.py` 三个字段没说全就不写 brief | -| `language` | 同上,从未触发;`_CJK_PRESENT=0.15`/`_CJK_DOMINANT=0.5`/200 字下限**三个数全靠讲道理,没有语料** | 0 | 0 | 暂留。带宽拉得很开,找不出可信的误报构造;但拒绝路径上确实有三个未校准的本包常量,记在这里 | -| `citation` | D3b 那七次实跑里从未触发,而且**当时它触发不了**:图录 loader 读 `figures` 键、写端写的是 `assets`,取回来永远是空,空图录 → 没有页面"显示了图" → 判词恒静默。无任何语料 | 0 | 0(在一个它无法触发的面上,不算精度证据) | 暂留。判词要求同一页**既**贴了哈希对得上的图**又**引了对不上的编号,两个"没证据"方向都豁免,误报面本身极窄 | -| `literal_escape` | **无**。D3b 七次实跑 932 条里 0 次触发;写它所依据的两页都是**人读出来的**,其中 `_x000B_` 那页还是已交付才发现(当时的判词漏了它);`_xNNNN_` 那一半至今没有测试 | 0 次判词命中 | 0,且从未扫描 | **不支持 → 降为 WARNING** | -| `house_style` | **无**。从未有过触发记录;唯一那条"正常情形"测试是 `house_style_findings(built, built)`——**拿 deck 和自己比**,恒真,不可能发现误报 | 0 次判词命中(它要防的缺陷是判词存在**之前**发生过两次) | 0,且不可测 | **不支持 → 降为 WARNING** | - -`citation` 与 `language` 这两条**没降**,理由要说清楚:它们在 `_PROVENANCE`/`_AGREED` 里,是三条路线共同的 `blocking_kinds`,还各有一条专门断言这个保证的测试(`test_provenance_refuses_publication_on_every_route`、`test_what_was_agreed_refuses_a_deck_on_every_route`)。改它们不是改 severity,是拆掉路线契约并删掉那两条保证测试——超出"只动 severity"的范围,留给用户裁。要把三条降级的升回去,各自需要的证据:`boxless_copy` 需要至少一条**不是作者常量造成的**命中,即引擎自己的某条路径会产出零尺寸框;`literal_escape` 需要在标准四个面(10 套模板 211 页、18 本已交付 deck、ppt_runs、实跑构建)上扫一遍确认正常文案 0 条,外加一次判词自己抓到的记录;`house_style` 需要一条**真绑模板、真建 deck** 的负对照(不是 `f(built, built)`),加上把"作者自行重刷主题"与"根本没打开模板"分开的判法。 - -**顺手修掉两处报文与实际不符**(这一类是本轮真正的债):(一)`prompts/AGENTS.md` 那段"build 会拒什么"的枚举里,`covered_shape`("a shape covering another's content")**本来就早已是 WARNING**,报文却一直说它拒;连同本轮降级的主题与转义一并改掉,并补上原先漏掉的 `emptied_page`。(二)`repeated_prototype`(D43 加的,BLOCKING,在 `tools/outline.py`)从来没写进 SKILL.md 第 12 节,`tests/test_ppt_engine_skill.py` 因此在本分支上**一直是红的**;把它写进"大纲阶段就拒"那张表。三条降级涉及的其他报文:SKILL.md 第 12 节三条从 **Refused** 移到 **Reported** 表,`grading.py` 的 `BLOCKING_CHECKS`、`profiles/registry.py` 里 script 路线的 `house_style`(路线的 fatal 名单是同一个决定的另一半,留着就等于 severity 不说的拒绝还在)。 - -**降级会不会让意见变隐形,逐条查过。** `_asks()` 没有提前返回,两个循环都跑,降级只是把该条从"resolve"挪到"consider";`quiet.py` 的四条静音规则都与 severity 无关;`_return.grouped()` 不截断,每条 finding 带 severity 原样进 `for_you`。反方向反而更亮:`_first_reading()` 在有 blocking 时返回 None,所以降级之后第二读者会去读这本 deck。唯一的真减量在 `checks_for()`——`_ALWAYS = BLOCKING_CHECKS | 覆盖类`,`literal_escape` 是 Tier.COPY,降级后它不再进 `"nothing"` 与 `"prelude"` 两个批次,而这两个批次的定义都是"没有一页的文案变过";`house_style` 是 Tier.DECLARED,`"prelude"` 仍然带它(改模板绑定正是这一档)。交付那一次(`"delivery"`)跑全部 43 条,所以两条在发布前必定被量到。 - -**D51 模板里的原生图表不能靠克隆带过来:版式可以克隆,图表要用 `ppt_charts` 按自己的数据重画、放回同一位置。** -9 月 10 日一条实跑冻在写程序那一步 73 分钟、约 2.5 美元:288 次逐字节相同的 `exec`、0 次 `ppt_build`、 -`build.py` 从未开始,shell 起在最后一条 `ppt_template` 回复的同一秒。大纲第 7 页取了 prototype 20, -`ppt_template` 报 page 20 `cannot_be_redrawn: ["a chart", ...]`,`next_step` 说"这些页持有 python-pptx -写不出的形状,克隆它们再逐行 replace_text"。作者照做,拿到一个填不进数据的图表,于是去 shell 里手量模板 -坐标轴的字号,打算把图表重建出来。这条建议是全部选项里最坏的一条,证据没有歧义:写端根本没有图表支持 -(`replace_data` / `chart_data` 全仓 0 命中,`has_chart` / `chartSpace` 的出现全在读端判据 -`services/measure/{adherence,density,rendered,content}.py` 与 `decompile.py`);`replace_text` 到不了图表文字 -(它在 `chartSpace` 部件里,不在 text frame 里),早一条实跑的克隆图表就留着模板的 `第一季度` 标签;而且读端 -也没有任何判据读图表内容,这样交付出去的页面不会被拒。反过来图表恰恰是这一页上作者能画得比模板更好的 -那一个元素——`ppt_charts` 4020 行、23 种形式,我们交付过的 deck 里从来没有原生图表,图表一直是作者自己画的。 -不是回归:`"a chart"` 进 `unredrawable` 是 `services/template/decompile.py` 2026-08-20 就有的, -`origin/main` 与各分支逐字节相同,只是从没被走到过——`black_circuit_tech_launch` 的原生图表只在第 20、26、29 页, -在作者看不到模板清单的年代它复用的 prototype 是 {1,2,5,9,34},老的成功实跑里 `cannot_be_redrawn` 出现 0 次。 -把选择面放宽,才第一次让这三页一直坏着的页可达。据此:图表从 `unredrawable` 里拿出来,单成一类 -(`PageSource.redraw_yourself`,装每个图表的框,写法与本文件其他框一致 `Inches(x), Inches(y), Inches(w), Inches(h)`); -页头的话按"克隆之外的例外"接在克隆那句后面而不是另起一条,因为两条并列会被读成两个指令,而克隆那句正好以 -"replace its text and pictures"结尾——图表恰恰不听这一句;图表那一行形状注释给出位置句柄 -`drop_shape(shape_near(slide, x, y))` 与 `Box.at(x, y, w=, h=)`,用位置而不是 `# [n]` 序号,因为 `adapt` 的 -`drop=` 和短 `items=` 都会在作者拿到 slide 之前删形状、之后的序号全部前移,而 `shape_near` 找不到会报错。 -这些页仍然作为 prototype 提供——它的排布正是作者要的——`menu` 的 `PageEntry.charts`、每张渲染图旁边那行 -`_render_label` 和 `ppt_template` 的 `charts_to_draw` / `next_step` 都带上这条指令,选页那一刻就说清楚。 -唯一没有覆盖到的口子:`ppt_outline` 的 `_content_examples` 调 `menu()` 不传这两个字典(整本 decompile 一遍 -34 页要 2.37 秒,而这套模板 34 页全部命中 `unwritable`,"clone only"在这里没有区分度),所以只在大纲里 -写下 `prototype: 20` 而从不读回这一页的作者,要到 build 出来才看见图表。顺带两处:`ppt_outline` 与 -`ppt_template` 克隆分支的 `next_step` 现在点名 `ppt_build`(那条"then write the program"没有挂任何工具, -就是这次跑进 shell 的那句);`ppt_theme` 的 `THEMES` 换成带 `__missing__` 的 dict,打错的 theme id 报的 -KeyError 会说出现有条目,而不是一个空的 KeyError——历史上一条实跑就是从这里去改 `themes.json` 的。 - -**D53 目标是交付一本 deck;任何预算都只能是"到点交付已有的",不能是"到点杀掉"。** (编号取 53:D24–D52 在若干未合入分支上各有认领,跳到 53 以避开冲突。)一次以全新 clone 装 `origin/main` 的审计翻出三个数字,并把它们归为"跑得太久"这一类风险:`agents/raven-ppt/config.json` 的 `agents.defaults` 里 `maxToolIterations: 600`、`llmErrorRetryDelays` 为 `[30, 60, 120, 240, 300, 300, 300, 300]`(合计 1650 秒)、`llmRetryAfterOutput: true`(三项均已逐字核对属实);审计据此估算一条持续失败的调用链可占住约 4.4 小时(这一步是审计的推算,本条未复核);以及全局既没有费用上限也没有墙钟上限(同样核对属实)。这一条决策不改上面任何一个值,改的是下一个人看到它们时该往哪个方向动手。 - -**为什么不动。** 用户裁定:**目标是产出一本 deck。** 一个在 deck 产出之前就中止整轮的预算,比一轮跑得太久更糟——太久的那一轮至少留下了文件,被杀掉的那一轮什么都没有,而用户等待的成本已经付过了。所以"给 deck 跑批加个超时/费用上限"这个听起来显然正确的修法,如果实现成 kill switch,就是把一个可交付的慢结果换成一个不可交付的快失败,这是净损失。三个数字里没有一个是当前已知的故障源:600 次迭代从未被真实轨迹触及(实测最长一条在 137 轮),1650 秒的重试阶梯是为"LLM 错误不能结束一个 run"这条更早的判断专门铺的,砍短它会让一次网关抖动重新变成一次交付失败。 - -**如果将来真要加时限,形状必须与档位封顶一致。** D35 定的三档强度(medium/high/max)是这个形状的先例:`buildCap` 与 `readingCap` 到达上限时,`ppt_build` 不是中止,而是**即使还有 blocking 意见也放行发布**,在回复里点名 `released_at_cap` 并列出留下的意见。这是"到点交付已有的":上限管的是"还要不要继续打磨",不是"这一轮还算不算数"。任何墙钟或费用上限都必须照抄这个语义——到点时走一次发布路径,把当前最好的草稿连同"因预算到点而提前交付"的说明一起交出去,而不是抛异常、不是终止 agent 循环、不是让 deck 目录里躺着一个没人拿得到的中间产物。判据是可检验的:预算到点之后,用户手里必须多出一个文件。 - -**读数留在这里,供下一个人核对而不必重跑:** 三项都在 `agents/raven-ppt/config.json` 的 `agents.defaults` 下(`raven/config/schema.py` 里 `max_tool_iterations` 的框架缺省是 40,600 是 deck 产品自己抬上去的);全局费用上限与墙钟上限均不存在。维持原样是本次的决定,不是遗漏。 - -**D56 克隆页上的字号有两个作者看不见的数:标题继承的字号,和一行卡片各自缩到多少。两条判据都读渲染、都只报告。** 9 月 10 日一次逐页复盘把 v12/v13 时代的 80 页与当天的 40 页放在同一把尺子下:可见缺陷从每页 0.29 条涨到 1.48 条,`type_inconsistency` 翻了三倍,`tier_high`(14 页)上 8 页有这一类。两处机制查清了,都不是作者选错了数,而是它没被告知那个数。 - -**一、标题与它下面那行同一个字号,长的那行赢了眼睛。** `tier_high` 第 5 页:克隆来的 TITLE 占位符 11.88x0.98in @(0.72,0.14) 自己不声明字号,经版式/母版 `titleStyle sz=2800` 解析为 28pt;模板那一页第二个标题槽(名字也叫 `Title`,11.88x0.98in @(0.72,1.62))声明 28pt。PDF 里两条 span 都是 28.01pt,标题 37 字符、说明 57 字符,读者先读到说明。**这个 28pt 不是作者写的**:模板 black_circuit 第 21 页那个框本身就写着 28pt,作者只往里写了一句话;green_aurora 第 17/18 页与 black_circuit 第 20/21 页是同一套 iSlide 版式,模板自己的渲染就已经是「角上一行小标题 + 居中加粗一行大字」,缺陷是连页克隆进来的。 - -**证据指向「告知」而不是「拒绝」,而且是量出来的。** 9 本 deck 共 132 页,凡是标题字号由继承而来的标题行 49 条,其中 5 条撞成同号;凡是作者自己声明了标题字号的标题行 34 条,撞成同号 **0 条**。同一次实跑里对照最干净:`day_medium` 第 1-5、12 页仍在克隆,第 6-11 页改走 `content_page()` 自己写 `write(..., size=28, bold=True)` 的标题行——这 6 页作者每一页都把下面那行给了 15pt,一次没撞;撞的是仍在克隆的第 3、4 页。**作者从不撞它看得见的数**,撞的全是它看不见的。所以新判据 `outranked_title` 是 WARNING,消息第一件事就是说出那个数(「标题框自己不声明字号——它经版式与母版解析为 28pt,所以你的程序里没有任何一行给你看过说明所匹配的那个数」)。D2 的四问只过前三问:渲染可证(两条 span 都是 28.01pt)、缩字号答不了(要求的是往下走一档,不是挤进字号下限)、要的动作不花代价(改一个 `size=`,框、文字、邻居都不动);第四问「警告级实证不收敛」**没有证据**——这条判据此前不存在,一次警告都没发过。按 D49 给 `displaced_copy` 立的那个标准,四问不全过就不升级。 - -**只报「同号」,不报「更大」。** 十套模板自己的页面上有四页刻意把标题下面那行设得更大:54pt 的「662+」统计、72pt 的章节序号、40pt 的「04」、28pt 页标题下 38.8pt 的引用金句——每一条都是设计手法。没人会刻意选「和上面那行一模一样」,克隆页上更是选不了。逐页复盘从另一头说的是同一件事:老 deck 有扁平的层级,但从来没有说明比标题大的。 - -**二、一行卡片里的字号互相打架,任何按盒子的读法都看不见。** 同一份交付文件第 3 页:五张卡的小标题同为 2.04x0.66in、同声明 20pt、同带 `normAutofit`,渲染出来是 16.8 / 16.8 / 16.8 / **20.0** / 16.8pt——'Aging fleet' 11 字符一行装下不缩,其余四个折行各缩一档。**每个盒子都在正常工作**,缺陷是这一行。据此新增 `row_type_drift`(WARNING)。 - -**它不是缺一个输入,是缺一个基准。** 先核实了前提:`type_drift` 早就在做同族比较,在这份 deck 上报了 9 条,第 3、4 页都在内。它错的是基准——拿**声明字号**在**全 deck** 范围比:第 4 页一行五个全部渲染成 17.4pt(读者看不出任何问题)它报了五条;第 3 页它把 16.8pt 的那四个称为异常、把孤零零的 20.0pt 当成本行的标准。所以缺的是「这一行自己的离散度」这个问题,不是缺一个字段。 - -**同理,D49 的 `detail.growth == "shrinks"` 用不上。** 自己数了一遍十套模板:402 个层级行、1418 个盒子,270 行全员会缩、125 行全员不缩、**7 行混合**(此前记的「199 行里 0 行混合」分母偏小)。混合极少正说明这个标志在行内近乎恒定,行级上不携带任何区分信息;有区分力的是**各自缩了多少**,而只有渲染知道。判据因此只读渲染的 span,`fit.py` 一行没碰。 - -**同族分组用 `units()`,层级用槽位。** `units()` 就是 `fill()` 写入时走的那套分组,所以报出来的行正是作者当成一行来处理的那一行;行内的「层级」是同一个 run 里同框同声明字号的那一组,即 `type_drift` 的分组键从整本收窄到一页一行——收窄本身就是要点。护栏一条:autofit 只会缩,所以渲染字号大于自身声明字号的成员不可能出自这个框,是 `slots` 无处安放的、压在它上面的 span(某交付页一个 14pt 正文框读出 30pt,来自旁边的数字牌,整行离散度虚报到 2.14)。 - -**标定,逐组给数。** 门槛按十套模板自己的原文写回自己的框来定:**327 个带渲染的层级行**,离散度中位数 1.000、95 分位 1.000、99 分位 1.045;`ROW_DRIFT = 1.10` 之上只有 2 行,都在 green_aurora(第 10 页四个卡标题 16.2 / 16.2 / 12.2 / 13.3pt,第 11 页五个 18 / 18 / 18 / 15.1 / 14.2pt)。两页都渲图看过,肉眼确认后两三张确实小一档——**是已发货模板里的真阳性,不是要调走的噪音**。`outranked_title` 在同一批模板上报 4 条,全是上面那两套 iSlide 版式的四页,同样渲图确认。误报 0 条:数字牌、章节序号、那个只有一个句点的框、引用金句、目录页的英文对照全部沉默——靠三条问法而不是靠调阈值(标题必须渲染在 `BODY_PT` 之上,否则那是一页没有标题的页;下面那行至少要占标题宽度的 0.8,否则那是标题旁边的标签;两者必须**同号**)。 - -**交付件上的结果。** `tier_high`(14 页):`row_type_drift` 10 条落在第 2、3、4、5、6、8、9、14 页,`outranked_title` 1 条在第 5 页——**8 页触发**,与逐页复盘数出来的 8/14 一致。`medium2` 第 12 页 1 条(20 / 20 / 20 / 17.4 / 17pt),`A_grid` 与两条 `day_medium` 各 1-2 条 `outranked_title`。**上一时代的四本 deck(v12_amber、v13_blue、v13_red、v13_warm,共 80 页)两条判据都是 0 条。** - -**建议按「这个数是谁写的」分两种。** 判据要不要报与此无关,但**要求作者做什么**与此有关:说明框落在模板自己的坐标上(`prototypes`,与 `type_scale` 同一个入参和同一条理由),那个字号就是模板的,原型自己也撞,让作者「别再选 28pt」是在说一个它没做过的选择——消息改为「在这里显式声明小一档,标题别动」。作者自己画的框则是两个它都看得见的数,消息是「你画了这个框也选了这个字号,往下走一档」。全证据树里命中的 5 条**全部**是模板的框;作者自建那一支目前只有单测覆盖,没有实跑样本。 - -**顺带一条噪音规则。** `type_drift` 与 `row_type_drift` 在同一页同一槽位同时开口时(某 14 页交付件上 19 条里有 3 条如此),两者的建议互相矛盾——「把这些框给到一个字号需要的高度」正是行级判据点名说「只修好那一张卡、整行照旧不齐」的动作。`quiet.py` 因此新增第五条规则:共享的槽位归行级判据,行级判据看不见的那 6 个槽位仍归 `type_drift`。 - -**D56 补记 两条判据各缺一个身份标识,评审在 !642 上都复现了。** 一、`outranked_title` 只比宽度和字号,没有验证"下面那行"真的在下面、也真的更长。`_heading_rows` 的第一分支按模板的命名取 SUBTITLE,模板把它放哪就是哪——标题上方的角标也算,那是刻意的。于是 y=0.5in 的一个满宽两字"Q3"压在 y=2in 的 24 字标题上、两者同为 44pt 时,判据报了 `outranked_title`,并在同一句里说这个"Q3"是"下面那行"、又是"更长的那行(2 字符对 24 字符)"。自相矛盾的修复建议比误报更贵:它教作者别再读判据,而这条线的活儿本来就是为了修那个毛病。补的两条护栏都是判据消息里已经在声称的关系,不是新阈值:`under.y0 > title.y0`(`_heading_rows` 在所有读几何的分支里用的就是这个关系,只有按命名取的那一支绕过它)与 `below.chars > above.chars`。SKILL.md 与本条原文早都写着"下面那行更长",代码没查。九本 deck 重测:标题行普查不动(标题字号继承的 49 条里 5 条撞号,作者自己声明的 34 条里 0 条),`outranked_title` 从 5 条降到 3 条。掉的两条是 `day_medium` 与 `day_medium2` 的第 4 页(同一页出现在两次跑批里):83 字符标题压 51 字符说明,标题本来就是更长的那行,"说明赢了眼睛"这个机制在那两页上不成立。十套模板一侧 4 条不变。 - -二、行级分组键 `(width, height, stated)` 认不出这是哪一个槽位。一张卡的小标题和正文都是 3x0.6in、都声明 20pt 是常态而不是特例:两卡一行、两个小标题稳定渲染 20pt、两个正文稳定渲染 14pt,四个盒子并成一个层级、报出 1.429 的 `row_type_drift`,而两行各自一点都没漂。同一个二元组还被交给 `quiet._slot_key`,于是一页上真实的行级发现会顺手压掉另一个同尺寸层级的 `type_drift`——实测里行级只看过 20pt 那个层级,14pt 那个层级的 `type_drift` 一起被吞了。分组键现在带上槽位在单元内的位次,位次按页面坐标 (y, x) 排。**没有用 `_reading_order`**:它为了让"序号在标题左边、先读到"而把形状并成行带,行带随形状加入而生长,因此成员取决于扫描顺序,而扫描顺序取自 `shape.top`——组内形状的 `shape.top` 写在各自组的坐标系里,同一 run 的两个单元因此会给出不同的位次。实测拿它当身份:`tier_high` 第 8 页三卡被读成 2+1(序号在一个单元里存在最前、在另两个里存在最后),`green_aurora` 第 11 页五卡被读成 3+2(之字形时间轴上,`_reading_order` 在五个单元中的三个里先读到图标框、另两个里后读到)。压制身份改为两条判据都记下的盒子本身:行级的层级是"单元里的某个槽位",`type_drift` 的分组是"整本里重复的某个形状",彼此的键对方都用不上,盒子是唯一的共同说法;并且要求 `type_drift` 点到的**每一个**盒子都已被行级覆盖才让它闭嘴,剩下的那些没人说过话。重测:九本 deck 的 11 条 `row_type_drift` 逐条一致(页码、离散度、成员数全部不变,`tier_high` 仍是 8/14 页触发,上一时代四本 80 页仍为 0);十套模板带渲染的层级行 327 → 332,多出来的五条是原先被并掉的层级被拆开,中位数 1.000、95 分位 1.000、99 分位 1.045、最大 1.328 全部不变,超线的仍然只有 green_aurora 第 10 页(4 份)与第 11 页(5 份)。`ROW_DRIFT`、`HEADING_TIE`、`HEADING_ROW_WIDTH` 一个都没动。 - -三、**位次要取在结构槽位上,不是取在"现在有字的那些框"上**——补记二的第一版就是后者,评审在同一个 MR 上复现出了回归,而且方向更坏:从误报变成了**漏报**。`adapt` 会清空它没被告知的每一个框,所以一行卡片里某张卡没写的可选槽位会空着留在页面上,而旁边那张卡写了。实测两卡一行、正文之上一个可选小标签、只有第二张卡给了标签:第一张卡的正文排到第 1 位、第二张卡的正文排到第 2 位,`row_findings` 一条都不报——一行真的 20pt 对 14pt 的漂移彻底沉默。误报只是吵,漏报是判据不存在。 - -**所以先定位次、再丢掉空的和量不到的。** "结构槽位"的定义是:**这个 run 在某个单元里往这个尺寸的框里写了字,那么这个尺寸的框在每个单元里都是槽位。** 页面上问不到原型(`adapt` 已经把没说到的框清空了,图标容器和作者无话可说的正文槽位到这里长得一模一样),能分辨的只有 run 自己:某个尺寸在任一单元里有字,它就是槽位,它在另一个单元里的空是那个单元弃用了一个可选项;某个尺寸在整个 run 的每个单元里都空,它是家具(图标容器、间隔条、分隔线),从来就没有位次。这是把 `fill` 自己的规则("旁边的框有字,所以这个空框是间隔")从单元内读到 run 上——兄弟单元把同尺寸填过,比"旁边有字"是更硬的证据,而且正好覆盖 `fill` 看不见的那一例:可选项是单元的**第一个**槽位,它的缺席会把后面每一个都推移一格。跨单元稳定是因为这个集合按 run 算一次,每个单元都对着同一个集合排位。 - -**三种定义都实测过。** 只数有字的框(补记二第一版):十套模板 332 行、九本 deck 130 行,但漏报上面那一例,而且这一例在交付语料里就有——`v13_warm` 第 5 页一个 4.45x0.7in 的槽位,两份拷贝从来没被放在一起比过(离散度 1.000,所以不报,但那不是判据判出来的)。数每一个文本框:模板 333 行,但 `green_aurora` 第 11 页那条真阳性掉了一张卡(之字形时间轴上图标容器在五个单元中的一个里高于标题、另四个里低于标题),报成 4/5。按结构槽位:模板 332 行、分位与最大值全同、报出的仍是第 10(4 份)、第 11(5 份)两行;九本 deck 130 → **131 行**,多出来的正是 `v13_warm` 第 5 页那一对,11 条 `row_type_drift` 逐条不变(页码、离散度、成员数全同,`tier_high` 仍 8/14 页、第 8 页仍 3 成员 1.284,旧四本 80 页仍为 0),`outranked_title` 仍 3 条。阈值一个都没动。 - -**D57 查不到的时候,先说"你是不是想找它",清单留在下面不动。** 两次实跑把同一类失败摆清楚了:run E 七次 build 里六次死在"作者点名了一段这一页没有的字",其中四次是同一个 key 连着四次 build。决定性的一例——作者要 `'数字健康崛起'`,页面第 25 个形状里写的是 `'数字健康兴起'`,一字之差、意思相同;答案就在报错里,坐在 27 个形状的第 25 位,埋在一整屏清单中间。那条报错只说"这一页有什么",从不说"你要的是 X,第 25 个是 Y,是不是它",于是作者把清单读过去、改了别的、再 build。四个程序的 md5 各不相同(304/314/316/316 行),所以这不是死循环:作者每次都在改,只是从来没改那一行——没有任何东西指向它。另外两次失败点名的 `'新技术、新产品及新服务在行业中的应用'` 是逐字存在的,只是在同一个模板的**第 18 页**,而这次 adapt 的是第 16 页。run F 的位置查找是同一个形状:作者问 `(7.39, 2.30)` 附近有没有形状,页面第 3 个形状在 `(7.39, 1.43)`——**x 完全对上**、y 差 0.87in,答案同样躺在位置清单里等人用眼扫。这一条代价不只是 build:run F 20 页里的第 13、18 两页整页死在它上面。**改了五个 raiser**:`adapt` 的 `nothing usable on this page matches`、`replace_text` 三参形式的 `no text on this page matches`、`fill` 的 `no text in this unit matches`、`shape_saying` 的 `no shape on this page starts with`、`shape_near` 的 `no shape within Nin of (x, y)`。每一条都是"提示在前、原清单在后一字不改"——清单是真的什么都不像时唯一能用的东西。**判据按数据定,不从别处搬常量。** 文字侧用 `difflib.SequenceMatcher.ratio()`(比值),不用编辑距离:中文一个字是一个语素、英文一个字母是一次手抖,同样"距离 1"在两边根本不是一回事,所以阈值必须是长度的比值而不是编辑次数。在十个内置模板的 865 条字符串上,把每条按三种方式弄坏(换一个字、删一个字、重写四分之一)来量:阈值 0.55 时"换一个字"1292/1292、"删一个字"1184/1184 全部指对原文,"重写四分之一"1134/1292;真实跑批写过、且在任何模板里都不存在的 421 条正文,**一次都没有得到提示(0/421)**。四个字以下直接关掉而不是调参:三个字的 key 在同一份语料上会给出 121 次提示、其中 10 次指错形状(`'36%'` 指到 `'68%'`),一页的 35%/38%/68%/95% 那一行彼此都是 0.5 分——指错形状比不说话更贵。量过之后**被否掉的两个做法**:滑窗比值(best-window)在同阈值下把误报从 1.97% 抬到 2.29%、正例一分没多;覆盖率(匹配字数/key 长度)在"换一个字"上只有 0.952,不如比值的 0.992。`shape_saying` 是前缀匹配,所以拿 key 去比每个候选的**同长开头**而不是整段(六个字比一整段永远找不到),同样常量下 sub 1270/1292、drop 1155/1186、真实正文 0/421。位置侧用几何,不用任何比值:**最近的那个**(封顶 1.0in)加上**跟你给的两个坐标之一对得上、另一个轴出界的那个**(对齐窗口 0.10in、另一轴封顶 2.0in),两句话各自说明为什么被提出来。必须是两句:run F 第一例里直线距离最近的是 0.72in 外的一张图,作者要的是 0.87in 外、x 完全对上的文本框——只按距离排就一定指错,而"一个轴对上、另一个没对上"恰恰说出了错在哪(行或列数错了一格)而不只是哪个形状。3362 个有位置的形状上量:偏 0.3in 时点中 93.3%(单轴)/91.0%(斜向),偏 3.0in 时 0% 且一半直接沉默;离所有形状 3in 以上的网格点一句都不给(0/47919),并且**任何情况下都不会指向 2.00in 以外的东西**(130638 个探点里 0 次)。**没有改的东西**:命中规则一个字节没动(`_pick` 归 D54,本条的改动全在报错构造那一侧)、`shape_near` 的 0.2in 容差没有放宽("对上这个轴"用的就是调用方给的 tol,`min(0.10, tol)`)、`title`/`subtitle` 这类角色失配不给近似提示(它们指的是角色不是文案,没有可以"差一个字"的字符串,否则一页上写着 "subtitle" 的框会被指出来)、`shape_at`/`prototype` 的下标越界和 `bundled()` 的模板名找不到都没加提示——后者本来就把十个名字一次全印在一句话里,不存在"答案被埋在清单里"这个问题。 - -**D58 一个文本框可以一个段落都没有,写进去的那一刻不能崩;新段落什么都不声明,由文件自己的默认回答。**(编号跳到 58:D37-D57 占在其他分支上。)`replace_text` 的多段写入路径里,第二段起是 `copy.deepcopy(paragraphs[-1]._p)` ——把上一段的属性抄下来,这样新段落拿到的是模板的列表样式而不是一个默认值。这一行对空元组求 `[-1]`,于是**一个 `` 都没有的文本框写进去就是 `IndexError`**。这行在主干上已久,不是今天的改动带进来的。 - -把十套内置模板 211 页上的 3157 个文本框逐个拿自己的文案回写一遍,8 次抛错:`black_circuit_tech_launch` 第 25 页 4 个、`green_aurora_tech_trends` 第 20 页 4 个。八个是同一个形状,两页连 `a16:creationId` 都一样(同一份 iSlide 素材页贴进了两套模板):`roundRect` 自动图形、名字就叫 `Text`、在一个组里、填充 `bg1`、带 `outerShdw`,而它的 `` 从头到尾只有一个 `` 和一个空的 ``——**不是空 `txBody`,也不是只剩 `bodyPr`,是 `bodyPr` 加空 `lstStyle` 而没有 `p`**,四个一模一样,所以四种情形其实是一种。按 `` 的 `bodyPr, lstStyle?, p+`,这本来就是文件不合规。渲染出来看清了它们是什么:这两页都是四张深色卡片,每张卡上有一个橙/绿的胶囊标题和一段正文,而这八个框是**卡片的底板**,标题和正文是压在它上面的另外两个形状。 - -为什么这么小的一处要单独记一条:build 是一个脚本,一处 `raise` 停掉整个 build 并连带遮住它后面的每一页(本仓库有过一本 deck 七次崩溃归因到一处 `raise` 的记录)。这两页都是作者会克隆的普通内容页,`green_aurora` 第 20 页正是今天容量那条线的起点,`black_circuit` 是跑批历史里用得最多的模板。今天 `clear_region` 已经在实跑里被作者主动调用,克隆再改这条路正在被真的走。 - -**选择创建段落而不是跳过这个框。** 作者要求写字,静悄悄什么都不写、页面看上去还是完整的,是这一天里一直在修的那种失败。段落**裸着**加进去:没有 `pPr`,也没有属性的 `run`。这里没有任何比文件自己更可靠的依据,凭空造一个带属性的 run 就是把这个函数的排版当成模板的排版。裸段落继承到的是文件对"一个什么都不声明的形状"的回答:presentation 的 `` 第一级 `sz="1800"` 填 `tx1`,字面与颜色还有形状自己的 ``。**不是**版式或母版的占位符链——这些是组里的普通自动图形,不继承任何占位符。两页各写四句英文文案后渲染,从 PDF 文字层读回:18.0pt、`#FFFFFF`、Arial 的替代字体,压在 `#2F2F2F` 的底板上,旁边模板自己的正文是 14pt。也就是说新段落比这张卡自己的正文大一档,且 `anchor="ctr"` 正落在正文所在的位置:**写进底板而不是写进压在它上面的那个框,得到的是字压字**,那件事由渲染实测的 `word_collision`(BLOCKING)去说,不在这里拦。把这八个框自己的(空)文案回写一遍,两页各 0/1,210,275 个像素变化——没有内容要写时,这条路一个像素都不动。 - -同一个 `paragraphs[-1]` / `runs[0]` 形态在引擎里扫了一遍,可达的只有这一处。`compose.py` 第 1421 行的 `frame.paragraphs[-1]` 在 append 之后;`_as_slide_number`(`assets/layout.py`)已经有 `if not getattr(frame, "paragraphs", None): return` 这条同形的守卫;`layout.py` 另外四处 `frame.paragraphs[0]` 的框都是 `add_textbox` 或表格单元格现造的,python-pptx 必给一个段落;`decompile.py` 里的 `frame.paragraphs[0]` 与 `para.runs[0]` 是**发给作者的参考代码字符串**,只在 `for paragraph in frame.paragraphs` 里且 `frame.text = ...` 之后才吐出,后者本身还带 `if para.runs:`;`measure/rendered.py` 的 `paragraphs[index]` 是本地列表且有 `index >= len` 的 break;容量与版式那批新文件(`capacity.py`、`house.py`、`menu.py`、`measure/fit.py`、`measure/type_size.py`)对段落一律是遍历或 `next(..., None)`,空元组安全。D45 那条判据的底数因此不变、抛错数从 8 变 0:211 页 / 2084 个有字的框 / 0 条容量报告 / 0 次抛错。 - -**D60 对比度门禁多读一种区域:渲染器画出来的每个词自己的框;判据不动一个数。** 9 月 10 日交付的一本 -12 页 deck(绿色深色模板,0 条 BLOCKING 发布)第 12 页把两行白色项目符号压在绿色波浪的亮脊上, -第一行的 `flexibility` 起完全读不出来;同一页的标题「Key takeaways」是同一组配色(白压深底) -却完全正常。先定位机制:`contrast.py` 取的区域是**声明框**(`ink_box`),按墨的列跨度收窄后 -切成宽 `round(裁切高 × 1.5)` 的**满高竖列**——y 方向不切,宽度按**整块**的高算而不是一行的高。 -这本 deck 的项目符号块是 1707×151px(两行加行距),一列就是 226×151px;统计量是 16 级色桶的 -**众数**(不是均值)。亮脊窄,在每一列里都是少数,于是每列众数都回 `#161B1E`,读数 **17.36:1**, -过。把四道守卫(`_SLICE_GROUND_SHARE` / `_SLICE_INK_MAX` / `_SLICE_INK_MIN` / `_second_ground`) -**按 16 种组合逐一关掉**,最差读数仍是 15.69:1——在列这个尺度上守卫不是原因,区域才是。对照样本: -把同一本 deck 第 10 页的脚注移回绿色宽带上(`TextBox 37` 从 5.55in 移到 6.35in 重渲),宽带在 -一列里是**多数**,今天的读法就报 **1.51:1 BLOCKING**——那正是这次实跑里第 10 页被拒、作者随后 -修掉的那一下。所以「宽带报、窄脊不报」的分界不在亮度上,在**取像素的那个框里有多少不是要量的东西**。 - -改法只加区域,不换统计量也不换阈值:`words`(渲染 PDF 的词框,走 `deck.rendered_words`,与 -`word_collision` 等共用那一次读取)作为**额外**的切片交给 `_worst_ground`,与竖列走同一套守卫, -只能把答案变差、不会变静。一个词框里只有这个词的字形和字形后面的底色,别的什么都没有——这正是 -D25 那次「众数取到墨自己」和克隆页 1.09:1 漏判留下的同一条规矩。第 12 页于是在 `flexibility` -上读到 `#01E698`,**1.64:1 BLOCKING**,消息里点名是哪个词;标题仍是 17.33:1 不报。 -`_SLICE_GROUND_SHARE`(一列的众数桶要占 35%)只对**列**问,不对词问:它要挡的是「众数是字形而不是 -底色」,而字形这件事下面两道墨占比守卫已经直接量了;35% 顺带挡掉的是**有纹理的底色**,而渲染波浪的 -亮脊就是一片连续的绿,没有任何一个 16 级桶占到 `flexibility` 那个词框的 15%——被挡掉的恰好是读者 -读不出来的那一处。 - -校准(十套内置模板逐套,前后同一次跑、同一批渲染):**211 页,前 0 BLOCKING / 13 WARNING, -后 0 BLOCKING / 13 WARNING,逐条页码与比值完全相同**(amber 18 页 0/0、beige 23 页 0/2、 -black 34 页 0/3、blue 15 页 0/0、gold 25 页 0/0、green 28 页 0/1、mint 20 页 0/7、red 15 页 0/0、 -teal 15 页 0/0、warm 18 页 0/0);13 条全是 D25 说的那类模板装饰警告,且全部仍由竖列判出 -(`word=None`),即十套模板里没有一个词框比竖列更差。规模上:模板共 1921 个判过的文字块,其中 -**553 块是多行**(29%,都在这个盲区的暴露面里),词框只让 1 块的读数动过(gold 第 1 页标题 -20.29→9.33,离 2.0 很远);那本 deck 的 114 块里动了 2 块(第 12 页 17.36→1.64 越线, -第 1 页副标题 17.44→5.66 不越线)。 - -**试过而否掉的更锐版本,连同它的假阳性一起记下来**,因为它看着更干净:把竖列**换成**词框(而不是 -追加),第 12 页同样报 1.64,但代价是两处。一、beige 第 7 页的白色序号 `01`:声明框是个矩形、里面是个 -三角形,今天的读法把序号判在**框里占多数的页面底色** `#EEDABF` 上(1.36:1),词框判在序号真正 -坐着的三角形 `#D5A260` 上(2.29:1)——今天那条 1.36 是**假阳性**,锐版本把它去掉了(这是个真收益, -但这次不动)。二、mint 第 8 页的 `02`:黄色菱形是渐变,从 `#F2D046`(1.51:1)到 `#FAE48B`(1.27:1), -序号正落在最亮的中段,词框读 **1.28:1**,跌到 `TEMPLATE_OWN_FLOOR = 1.3` 以下,于是模板自己设计 -的那个序号从 WARNING **翻成 BLOCKING**——凡克隆 mint 第 8 页的 deck 都会被拒,正是 D25 修掉的那个 -回归。为定这条线专门造了平底夹具实测(黑色模板上四条纯色面板,两种读法在平底上必然同值): -同色叠同色 **1.00:1**、`#1A1A1A` 压 `#000000`(这门禁当初就是为它写的)**1.21:1**、 -白压 `#FAE388`(mint 自己那个序号)**1.28:1**、白压 `#F2D046` **1.51:1**。也就是说锐版本把 -`TEMPLATE_OWN_FLOOR` 该待的窗口从 0.18 宽(1.21→1.39)压到 **0.07 宽(1.21→1.28)**——**在这个 -窗口里没法安全地重新定这个常数**,而按用户的规矩「常数真要动就停下来单独立案」,所以这次不动它, -锐版本也不采用。要走那条路,得先把「这是设计还是失手」换成不靠亮度比的问法(同色叠同色是**色度** -一样,白压浅黄不是:逐通道最大距离 26 对 74+,separation 约 3 倍,而亮度比只有 1.06 倍), -那是另一次修法。 - -**D62 指引写在路由声明上,不写在闸门里;两条 deck 路线各拿到同一份话。**(编号跳到 62:D61 占在 `feat/ppt_latin_body_type_floor` 上。)9 月 11 日两次实跑(medium / high)把同一件事量清楚了:工具在手边、模型一次都不用。`agents/raven-design/config.json` 的 `disabledTools` 既不含 `image_generate` 也不含 `web_search`,实例里生图 key、搜索 key、jina key 全配齐,而两轮的工具调用统计是 `read_file` 56、`exec` 15、`write_file` 8、`read_skill` 3、`edit_file` 3、`list_dir` 1——**生图 0 次、搜索 0 次**,八页 deck 的内容全靠模型自己写、一张图没有。第三次实跑把生图差在哪也摆清楚了:**差别不在模型也不在后端,在"按下生成那一刻眼前有什么"**。主 agent 那次先 `read_file` 了 logo、又 `read_file` 了 `tmp/preview/slide-01.png`(**封面本身**),再 `image_generate(images=[logo], prompt="...inspired by the reference logo... 一枚很淡的金色无穷环压在右半边... 左侧 55% 保持近乎纯暗,留给标题... 不要任何文字/字母/数字")`,拿到的是那枚 logo 虚化后的金环,用户满意;PPT 子代理那次的上下文里只有 `ppt_brief`、`ppt_template` 和三次 `exec`,然后 `ppt_generate_image(prompt="...a matrix of small warm-gold dots... one refined golden raven bird silhouette...")`,**没传参考图、没看过封面**——而原封面本来就有金点阵和一只金鸟,叠上去成了两只鸟、两层点阵。引擎的顺序是 `prepare → brief → template → 生图 → build`,生图那一刻页面还不存在,所以这不是模型没意识到,是流水线让它没得看。 - -**落点选在 `agents/raven-design/subagent.json` 的 route 条目,不选 `routing.py` 的模块常量。** 上一版把这段话写成 `DELIVERABLE` / `DELIVERABLE_NOTE` 两个模块常量,档位或凭据把 deck 留在 Raven-Design 时拼到任务文本末尾;实测送达了,但**措辞是 deck 专用的、闸门是通用的**——今天只有 Raven-Design 一行声明 `routes`,第二行出现的那天,"This request is a deck" 会被贴到不是 deck 的任务上。新形状是 `{"to": "Raven-PPT", "owes": ".pptx", "note": "..."}`:`owes` 是结构化事实(闸门的日志说出"running here and owing a .pptx"),`note` 是散文,闸门只负责加 `\n\n[host] ` 前缀。两个字段都默认空,**没声明的 route 一个字都不贴**——这是今天之前的行为,也是新加的那条测试钉的东西(未打补丁的树上跑同一条探针,得到的是 `'a deck\n\n[host] This request is a deck...'`,即当时没有"什么都不说"这个选项)。 - -**否掉的两个做法**:一是 `note` 里写 `{owes}` 占位符由代码替换——一句话两个来源,而 `owes` 为空时替换出来的是个洞;改成 `owes` 只做结构化事实,另加一条测试断言散文里确实出现了 `owes` 的值,一致性由测试而不是运行期魔法保证。二是把这段话写进 design-engine 的共享 skill——那 17 个 skill 是**所有**视觉任务共用的,deck 专用的话进去就等于贴到海报和图表任务上,而且用户明确要求不动 `plugins-dist/design-engine/`。 - -**同一份话写两处。** medium/high 走 route 声明(Raven-Design 手上只有 `image_generate`、`web_search`、`web_fetch`,`deep_research`/`video_generate`/`text_to_speech` 被禁,**没有通用图片搜索工具**,找图只能 `web_search` + `web_fetch` 落地);max 走 `ppt-script-authoring/SKILL.md`(§2 加"页面缺的东西是搜出来的不是填出来的",§5 加一节"生成前先看这一页")。两边参数名不同,写的时候分开写:host 的 `image_generate` 用 `images`(本地路径 / http(s) URL / data URI,chat 路由最多 6 张),引擎的 `ppt_generate_image` 用 `references`(最多 6 张,`_references` 里 `[:6]` 截断)。 - -**图标库对 medium/high 那条路是通的,不需要移植数据。** `raven_ppt` 与 `raven_design` 装在同一个 site-packages(本树 venv 实测两者 `__file__` 同在 `plugins-dist/` 下),所以 Raven-Design 的进程 `import raven_ppt.services.assets.icons` 能拿到全部 **1304** 个 Tabler 描边图标。但 `services/assets/script_helpers.py` 里的 `add_icon` 不能直接借:那是**生成给作者 build 目录的模块文本**,从自己所在目录读 `icons.json` / `icon_keywords.json`,当库 import 必然找不到文件。所以 route 声明里给的是"用 `icon_paths` 取几何、自己画 freeform"的一段代码,**从 manifest 里原样抽出来跑过**:`icon_candidates('deadline')→calendar_due`、`('risk')→warning`、`('inventory')` 第 4 位是 `building_warehouse`、`resolve_icon_name('map-pin')→map_pin`,画出的 7 个图标转 PDF 再转 PNG 逐个看过。写这段代码时自己踩了三个坑,都写进了指引:**一条 path 中间的 `M` 是新起一笔**(第一版把它当续笔,`bulb` 的五道光线连成了穿过灯泡的乱线);`convert_to_shape()` 会盖一个 `p:style`,其 `a:effectRef` 指向主题投影,不删掉每一笔都带灰影;**跨度小于笔宽的一笔是上游的圆点**,要填充成方块而不是描边(1304 个里 140 个带这种笔,`alert_triangle` 的叹号点、`info_circle` 的 i 都是)。数据里**没有 `Z` 命令**(M 5022 / L 8705 / C 7259),所以不用处理闭合。搜索按语义走但**一次只问一个具体的词**:`inventory` 能经 tags 摸到 `building_warehouse`,`supply chain`、`throughput` 这种抽象词名字和 tag 都不沾,回空——这条也写进了两侧指引。图标是"标记"不是"插图",尺寸沿用 `menu.py` 已记下的实测(bundled 模板卡片图标 0.73in,cartoon 2.4in 起),也沿用既有那条"别给每个图标垫一个彩色圆底"。 - -**D63 直聊也按档位分流,档位从这次派发的 `mode` 读;6KB 的指引从 JSON 字符串挪进文件。** D62 的闸门只认 `turn_tier_in_force()`,而**直聊根本不开轮次**——`turn_tier_in_force()` 返回 `None`,上一版把它读成"没有档位可排"而放行。后果是:**网页上直接跟 Raven-Design 说一句"给我一本 deck",任何档位都还是整轮转给 Raven-PPT**(凭据闸门仍然生效)。9 月 11 日那次真实轨迹的 lane 名就是 `tui:20260911_025846_09a7ef#Raven-Design/raven-ppt-pptx-76ce5e`:用户以为在跟 Design 说话,`RoutingBackend.run` 把整轮交给了 PPT。也就是说**用户平时用的那个面,medium/high 那条路从来没被走到过**。 - -**档位不是没有,是存在别处。** 两条 lane 携带同一个事实的两个副本:spawn / DAG 节点跑在宿主轮次里,档位是轮次自己的冻结快照(冻结的理由不变:轮中途切档属于下一轮);直聊没有轮次作用域,档位是 manager 为这次派发解析出来的 `mode`——`SubagentManager.resolve_mode` 的答案,即"实例自己的 override,否则会话的常驻档位"。所以 `_tier_in_force(mode)` 的规则是:**有轮次就只认轮次**(`mode` 不是第二意见,一个轮末尾的派发必须按轮开始时的档位回答),**没有轮次才读 `mode`**。`mode` 从 `kwargs.get("mode")` 取而不提升为具名参数:闸门后面每个实现本来就从同一个 dict 拿 `mode`,提升出来会给"调用方根本没传 mode"的后端凭空塞一个。 - -**三种"读不到档位"仍然一律放行,且仍然彼此区分**:轮次开始时没有档位(`""`)、根本没有轮次且这次派发没有 `mode`、以及档位是梯子之外的另一套词。把其中任何一种读成"低于 max",都会让一个从来没配过档位的部署在这条改动上线当天把所有 deck 换一条路。日志里现在会说清楚来源:`but this chat runs at 'medium', below 'max'` 对 `but the turn runs at 'medium', below 'max'`。实测两条都跑过(临时实例 18899,直聊 medium 留本地并送达 6159 字符的提示,直聊 max 打出 `classified for 'Raven-PPT'; routing there`)。 - -**指引改成文件,manifest 只留指针。** `note` 那 6027 字节、74 行的散文塞在 `subagent.json` 的单行 JSON 字符串里,改一个词的 diff 是整行重写,编辑器里不换行,也没法当 markdown 读。现在是 `agents/raven-design/route-raven-ppt.md`,manifest 写 `{"to": "Raven-PPT", "owes": ".pptx", "noteFile": "route-raven-ppt.md"}`,`vendored_agents._read_route_notes` 在扫描 manifest 时(占位符替换之后、模型校验之前)读进 `note`——**下游没有任何东西知道作者用的是哪种写法**。为什么在扫描时读而不是在闸门里读:route 走到 `RoutingBackend` 时 folder 早已不在作用域,而 `_fill_routing` 会把 route 列表整个抄进存储的 config 行。路径限制在 folder 内(`../` 一律拒绝),因为 folder 才是被 `install.py` 拷贝、被 `_install_packaged_tree` 安装、被 wheel(git ls-files)打包的那个单位。同时声明 `note` 和 `noteFile` 由模型校验器拒绝,而不是静悄悄挑一个。**文件不存在时只告警、note 留空,不禁用这一行**:没有 note 的行是"少说了一条要求的 agent",被禁用的行是"agent 没了",前者是更小的失败;而随包漏掉这个文件由一条测试(读 shipped folder)在 CI 挡住,不靠日志。 - -**注意这只改了作者与评审的形态,没有改上下文开销**:note 的价值恰恰在于它挂在任务文本上、lane 每轮都重读(本仓库自己的教训是"文档写了没用,要放在作者站的地方")。真要压上下文,正确的做法是把第 2-5 点搬进 design lane 自己加载的 skill、route note 只留交付物加一个指针——那需要动 `plugins-dist/design-engine/`,本次被明确禁止。 - -**D64 交付物只有 .pptx;测量用的渲染 PDF 留在 out/ 当引擎自己的预览,不再复制到用户目录,回复里也不再提。** 9 月 16 日用户看 max 档交付时两份文件并排、medium 档只有一份,要求统一为只交付 pptx;web 界面自己会把 deck 渲染成 PDF 预览,旁边那份没有读者。`deliver()` 保留 `preview` 参数但 build 阶段传 None,`ppt_build` 的回复不再带 `pdf_path`、`delivered_pdf`、`delivered_pdf_kept_back`。 diff --git a/plugins-dist/ppt-engine/raven_ppt/profiles/registry.py b/plugins-dist/ppt-engine/raven_ppt/profiles/registry.py index ef7c6fb6b..3b57f073b 100644 --- a/plugins-dist/ppt-engine/raven_ppt/profiles/registry.py +++ b/plugins-dist/ppt-engine/raven_ppt/profiles/registry.py @@ -20,8 +20,8 @@ # Fail-closed kind. A claim about the source material that the file itself # settles: a page citing one figure while showing another. Everything else warns -# -- see docs/ppt-raven-design.md D2 for why measurements of the rendered page -# must not refuse publication. +# -- a measurement of the rendered page is the renderer's word, not the file's, +# and must not refuse publication. _PROVENANCE = frozenset({"citation"}) # What the deck was agreed to be. Refusals rather than warnings on every route: a diff --git a/raven/agent/loop/turn_path.py b/raven/agent/loop/turn_path.py index 300a88e5a..e3281065e 100644 --- a/raven/agent/loop/turn_path.py +++ b/raven/agent/loop/turn_path.py @@ -2085,6 +2085,7 @@ async def _tap_episode(_index: int) -> None: if on_episode_start is not None: await on_episode_start(index) + from raven.agent.subagent.attachments import turn_attachments from raven.agent.subagent.mode_tiers import turn_tier async def _attempt(seed: list[dict], attempt: int): @@ -2175,7 +2176,10 @@ def _rerun_pending(final_content: str | None, msgs: list[dict], status: str) -> # The tier this turn dispatches sub-agents at, frozen here for the # same reason the iteration cap is read once: a switch arriving mid-turn # lands on the next turn, not on a sub-agent this one has yet to call. - with turn_tier(self.session_tier(key)): + # And its attachments, for the same reader: a dispatch that names a + # file the user attached is handing it over, one that names any other + # .pptx is not, and only the turn knows which is which. + with turn_tier(self.session_tier(key)), turn_attachments(req.media): final_content, _, all_msgs, outcome = await _attempt(initial_messages, attempt_no) # The conditional rerun. A dead turn has no answer to damage -- "empty # implies wrong" is a scoring rule, so the count of right answers among diff --git a/raven/agent/subagent/attachments.py b/raven/agent/subagent/attachments.py index d1cbc1e6d..26241b5f8 100644 --- a/raven/agent/subagent/attachments.py +++ b/raven/agent/subagent/attachments.py @@ -19,13 +19,43 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Iterable, Iterator, Sequence +from contextlib import contextmanager +from contextvars import ContextVar from pathlib import Path from typing import Any from raven.spine.message import Media ATTACHMENTS_NOTE = "[attachments, by absolute path]" + +_TURN_MEDIA: ContextVar[tuple[Media, ...] | None] = ContextVar("raven_turn_attachments", default=None) + + +@contextmanager +def turn_attachments(media: Sequence[Media] | None) -> Iterator[None]: + """Hold the turn's attachments where a dispatch inside it can read them. + + A spawn and a DAG node receive no ``media``: the dispatching model writes + the paths it wants the lane to have into the task text, from the note the + host put on the user's message. What the turn actually carried is a fact + the host knows and the text does not settle -- a task names the deck's + destination in the same spelling as a template -- so it travels beside + the tier, as a ContextVar over the turn's own call graph, for the routing + entry to read (``RoutingBackend``). + """ + token = _TURN_MEDIA.set(tuple(media or ())) + try: + yield + finally: + _TURN_MEDIA.reset(token) + + +def turn_attachments_in_force() -> tuple[Media, ...]: + """The attachments of the turn this dispatch runs inside; empty outside any turn.""" + return _TURN_MEDIA.get() or () + + UNDELIVERABLE_NOTE = "[attachments were sent but not handed over: this agent cannot read local files]" diff --git a/raven/agent/subagent/backends/routing.py b/raven/agent/subagent/backends/routing.py index 83f65967a..582120d7d 100644 --- a/raven/agent/subagent/backends/routing.py +++ b/raven/agent/subagent/backends/routing.py @@ -27,8 +27,9 @@ and it is also the only thing on this seat that knows *what kind of work it is*: the classifier answering a target is the entry learning that this task is the target's specialty. ``_target_open`` then decides whether that specialty's -own lane may run here -- the tier this dispatch runs at, and whether the -deployment holds the credentials the target's pipeline spends. When it may not, the task stays on the +own lane may run here -- whether the deployment holds the credentials the +target's pipeline spends, whether the dispatch hands over the file the target +builds on, and the tier this dispatch runs at. When it may not, the task stays on the row's own implementation and carries the closed route's own note with it, so the lane that ends up building it is told what the other lane would have produced, and how to produce it here. @@ -47,12 +48,14 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Sequence +import re +from collections.abc import Awaitable, Callable, Iterable, Sequence from pathlib import Path from typing import Any, NamedTuple from loguru import logger +from raven.agent.subagent.attachments import turn_attachments_in_force from raven.agent.subagent.backends.base import optional_keyword from raven.agent.subagent.mode_tiers import turn_tier_in_force from raven.config.schema import TIER_LADDER @@ -79,15 +82,59 @@ HOST_PREFIX = "\n\n[host] " +def handed_files(*texts: str | None, media: Iterable[Any] = ()) -> tuple[str, ...]: + """The files this dispatch hands the implementation, by path. + + Only files the user attached count, and they are read from where the host + holds them rather than from the task text. A direct chat carries them as + ``media`` (``Media`` rows, or plain paths): every one of those is handed + over, since the transport puts them beside the text. A spawn or a DAG node + carries none -- the dispatching model passes a file on by writing its path + into the task -- so there the turn's own attachments are read + (:func:`turn_attachments_in_force`) and one counts when the task names it. + + Read this way rather than by scanning the text for anything that looks like + a file, because the text does not settle the question: every deck brief the + dispatching model writes names the deck's destination in the same spelling + as a template (``.../tmp/tui/history.pptx``), and one that mentions a stray + file in the working directory names that too. Neither was handed over. + Conversely an attachment the user told the model to ignore is not named in + the task, and stays where it is. + """ + given = [path for path in (_path_of(item) for item in media or ()) if path] + if given: + return tuple(given) + handed: list[str] = [] + for item in turn_attachments_in_force(): + path = _path_of(item) + if not path: + continue + # The path or its name, bounded the way a file name is on both sides: + # ``brand.pptx`` in ``brand.pptx.bak`` or in ``new_brand.pptx`` is a + # different file, while ``brand.pptx.`` at the end of a sentence is + # this one -- a dot closes the name unless it opens a longer one. + named = re.compile(r"(? str: + """A ``Media`` row's path, or a plain string path; anything else is not a file.""" + path = getattr(item, "path", item) + return path if isinstance(path, str) else "" + + class RouteTarget(NamedTuple): """One candidate target as the entry holds it. ``name`` and ``description`` are the roster line the classifier picks from; ``owes`` and ``note`` are the declaring row's own words for the case where - the gate keeps the work here, and ``needs`` and ``min_tier`` are what put a - route under the gate at all. A plain ``(name, description, backend)`` still - builds one, with every declared field saying nothing -- which is what a route - that declares nothing means, and such a route is neither probed nor tiered. + the gate keeps the work here, and ``needs``, ``min_tier`` and ``needs_file`` + are what put a route under the gate at all. A plain ``(name, description, + backend)`` still builds one, with every declared field saying nothing -- + which is what a route that declares nothing means, and such a route is + neither probed nor tiered nor asked for a file. """ name: str @@ -97,6 +144,7 @@ class RouteTarget(NamedTuple): note: str = "" needs: tuple[str, ...] = () min_tier: str = "" + needs_file: str = "" def hand_off(self) -> str: """What to append to a task classified for this route that stays here.""" @@ -180,21 +228,23 @@ def call(*args: Any, **kwargs: Any) -> None: return call - def _target_open(self, target: RouteTarget, mode: str | None) -> str: + def _target_open(self, target: RouteTarget, mode: str | None, handed: Sequence[str] = ()) -> str: """``""`` when ``target`` may run this dispatch, else why it may not. - Two conditions, both about the deployment rather than the task, which is - why neither can be asked of the classifier -- and both read off the - *route's own declaration*, so a route that declares neither is returned - unconditionally open. ``routes`` is a general facility: a row routing for - reasons of its own would otherwise be closed by a credential its target - never spends and a tier its target never asked for, having opted into - nothing. + Three conditions, none of them a judgement the classifier could make: + two are about the deployment and the third is a fact about what the + dispatch carries. All three read off the *route's own declaration*, so a + route that declares none is returned unconditionally open. ``routes`` is + a general facility: a row routing for reasons of its own would otherwise + be closed by a credential its target never spends, a file it never asked + for and a tier it never named, having opted into nothing. The credentials come first because they are the flat answer: a target whose pipeline cannot buy a picture builds the same deck at every tier. - The tier comes second, and where it is read from depends on what kind of - dispatch this is; see :meth:`_tier_in_force`. + The file comes second: a target that builds on one the user supplies + has nothing to build on without it, at any tier. The tier comes last, + and where it is read from depends on what kind of dispatch this is; see + :meth:`_tier_in_force`. """ if target.needs and self._target_ready is not None: try: @@ -206,6 +256,10 @@ def _target_open(self, target: RouteTarget, mode: str | None) -> str: ready = True if not ready: return "a credential its lane spends is not configured here" + if target.needs_file: + suffix = target.needs_file.lower() + if not any(name.lower().endswith(suffix) for name in handed): + return f"no {target.needs_file} the user attached was handed over" if not target.min_tier: return "" where, tier = self._tier_in_force(mode) @@ -248,9 +302,19 @@ def _tier_in_force(mode: str | None) -> tuple[str, str]: return "the turn", turn.strip() async def pick( - self, task: str, *, session_key: str | None, instance: str | None, mode: str | None = None + self, + task: str, + *, + session_key: str | None, + instance: str | None, + mode: str | None = None, + handed: Sequence[str] = (), ) -> Picked: - """What runs this task; see the module docstring for the order.""" + """What runs this task; see the module docstring for the order. + + ``handed`` is what the dispatch carries by file name (see + :func:`handed_files`); a route declaring ``needs_file`` opens on it. + """ if instance and (bound := await self._bound(session_key, instance)) is not None: return bound if self._router is not None and self._targets: @@ -262,7 +326,7 @@ async def pick( answer = None for target in self._targets: if answer == target.name: - if closed := self._target_open(target, mode): + if closed := self._target_open(target, mode, handed): logger.info( "{!r}: classified for {!r}, but {}; running here{}", self.name, @@ -317,7 +381,11 @@ async def run( # from the same dict, and lifting it out would hand one to a backend the # caller never passed one to. picked = await self.pick( - authored_task or task, session_key=session_key, instance=instance, mode=kwargs.get("mode") + authored_task or task, + session_key=session_key, + instance=instance, + mode=kwargs.get("mode"), + handed=handed_files(task, authored_task, media=kwargs.get("media") or ()), ) if picked.note: # Both texts, because which one an implementation reads is its own @@ -340,4 +408,4 @@ async def run( ) -__all__ = ["HOST_PREFIX", "Picked", "RouteTarget", "Router", "RoutingBackend", "TargetReady"] +__all__ = ["HOST_PREFIX", "Picked", "RouteTarget", "Router", "RoutingBackend", "TargetReady", "handed_files"] diff --git a/raven/agent/subagent/registry.py b/raven/agent/subagent/registry.py index 0b3c4a5d1..e2d597f09 100644 --- a/raven/agent/subagent/registry.py +++ b/raven/agent/subagent/registry.py @@ -91,9 +91,10 @@ class Route: ``owes`` and ``note`` are what the declaring row says about the case where the gate keeps the work here; see :class:`SubagentRouteConfig` for why they - are the row's words rather than the gate's. ``needs`` and ``min_tier`` are - what subject the route to that gate in the first place, and a route naming - neither is dispatched exactly as routes were before the gate. + are the row's words rather than the gate's. ``needs``, ``min_tier`` and + ``needs_file`` are what subject the route to that gate in the first place, + and a route naming none of them is dispatched exactly as routes were before + the gate. """ to: str @@ -101,6 +102,7 @@ class Route: note: str = "" needs: tuple[str, ...] = () min_tier: str = "" + needs_file: str = "" @dataclass(frozen=True) @@ -203,6 +205,7 @@ def _route_for(declared: Any) -> Route: note=str(read("note", "") or ""), needs=tuple(str(need) for need in declared_needs), min_tier=str(read("min_tier", "") or read("minTier", "") or ""), + needs_file=str(read("needs_file", "") or read("needsFile", "") or ""), ) @@ -357,6 +360,7 @@ def _routing_entry(self, row: AgentRow, rows: dict[str, AgentRow], backends: dic route.note, route.needs, route.min_tier, + route.needs_file, ) ) if not targets: diff --git a/raven/config/schema.py b/raven/config/schema.py index 8e2ce124d..f04d6ee32 100644 --- a/raven/config/schema.py +++ b/raven/config/schema.py @@ -1826,12 +1826,25 @@ class SubagentRouteConfig(Base): work stays here. Empty is every tier, which is what a route that says nothing about cost means.""" + needs_file: str = "" + """A file suffix (``".pptx"``) the dispatch must hand over for this route to open. + + A target that builds on a file the user supplies -- a deck engine on a + template -- is the right lane only where that file is actually there; a + dispatch carrying none keeps the work here, whatever it costs. Read off + the files the user attached, never off the task text alone: a direct chat + hands its attachments over as media, and a spawn or a DAG node hands one + over when the task names an attachment of this turn. Empty is every + dispatch, which is what a route that builds on nothing means.""" + @model_validator(mode="after") def _one_source_for_the_note(self) -> "SubagentRouteConfig": if self.note and self.note_file: raise ValueError("a route declares its note inline or in noteFile, not both") if self.min_tier and self.min_tier not in TIER_LADDER: raise ValueError(f"a route's minTier is one of {TIER_LADDER}, not {self.min_tier!r}") + if self.needs_file and not (self.needs_file.startswith(".") and len(self.needs_file) > 1): + raise ValueError(f"a route's needsFile is a suffix such as '.pptx', not {self.needs_file!r}") return self diff --git a/schemas/subagent.schema.json b/schemas/subagent.schema.json index 826078ba4..c0a16fddf 100644 --- a/schemas/subagent.schema.json +++ b/schemas/subagent.schema.json @@ -56,6 +56,11 @@ "title": "Needs", "type": "array" }, + "needsFile": { + "default": "", + "title": "Needsfile", + "type": "string" + }, "note": { "default": "", "title": "Note", diff --git a/tests/test_agents_design_launcher.py b/tests/test_agents_design_launcher.py index b107e375b..d09ffb301 100644 --- a/tests/test_agents_design_launcher.py +++ b/tests/test_agents_design_launcher.py @@ -10,7 +10,7 @@ import pytest -from raven.config.schema import ROUTE_REQUIREMENTS, TIER_LADDER +from raven.config.schema import ROUTE_REQUIREMENTS REPO = Path(__file__).resolve().parent.parent RUN_PY = REPO / "agents" / "raven-design" / "run.py" @@ -157,16 +157,18 @@ def test_the_roster_row_carries_the_forks_identity_verbatim(): # belongs here is that there is no sixth key -- a field nothing reads would # look like configuration. ((route,)) = ours["routes"] - assert set(route) == {"to", "owes", "noteFile", "needs", "minTier"} + assert set(route) == {"to", "owes", "noteFile", "needs", "needsFile"} assert route["owes"] == ".pptx" # The gate reaches this route because this route asked for it. Both are # pinned here rather than left to the gate's own tests: the gate is generic, # and a manifest that dropped either would close nothing while every test - # about the gate kept passing. + # about the gate kept passing. The template lane opens on a template and + # on nothing else: no tier floor, so a row that reintroduced one would + # send a deck with no template to the engine again. assert route["needs"] == ["image_generation", "image_search"] - assert route["minTier"] == "max" + assert route["needsFile"] == ".pptx" + assert "minTier" not in route assert set(route["needs"]) <= set(ROUTE_REQUIREMENTS) - assert route["minTier"] in TIER_LADDER # The note is prose and lives beside the manifest, which discovery reads # into the route. Its absence from the folder is the one thing that cannot # be checked anywhere else: no note file, no requirement, and the row still @@ -282,14 +284,13 @@ def test_the_render_loads_through_trunks_own_loader(grounded): assert all(mount.always_enabled and mount.path.endswith("skills") for mount in mounts) -def test_only_medium_names_an_effort_and_the_other_tiers_inherit_the_hosts(grounded, tmp_path): - """Design tiers change iteration caps; medium alone pins its reasoning effort. +def test_tiers_climb_a_ladder_of_effort_around_the_hosts(grounded, tmp_path): + """Design tiers keep one iteration cap and climb a ladder of reasoning effort. - Below the top tier this agent designs the deck itself rather than handing it - to the template lane, and the cheapest tier is the one that must not also ask - for the host's full thinking budget. The host here is on ``high`` so an - inherited effort and a pinned one are different strings -- a host already on - ``low`` would let the pin pass by coincidence. + This agent designs every deck itself, so what a tier buys is how hard it + thinks: medium asks for low, max for xhigh, and the + baseline inherits whatever the host is set to -- ``high`` here, so the + ladder reads low / high / xhigh. """ from raven.config.loader import load_config from raven.config.mode_catalogue import build_mode_catalogue @@ -306,7 +307,7 @@ def test_only_medium_names_an_effort_and_the_other_tiers_inherit_the_hosts(groun assert {m: (e["maxToolIterations"], e["reasoningEffort"]) for m, e in modes.items()} == { "medium": (400, "low"), "high": (400, "high"), - "max": (400, "high"), + "max": (400, "xhigh"), } # The overlay carries the cap it changed and not the effort: the effort is # the trunk's own knob, dispensed off the entry, and a copy in the diff the @@ -315,7 +316,7 @@ def test_only_medium_names_an_effort_and_the_other_tiers_inherit_the_hosts(groun # And the trunk reads them as the loop will enforce them. catalogue = build_mode_catalogue(load_config(grounded.render_config(RUN_PY.parent / "config.json"))) assert catalogue.default == "high" - assert (catalogue.get("max").max_iterations, catalogue.get("max").reasoning_effort) == (400, "high") + assert (catalogue.get("max").max_iterations, catalogue.get("max").reasoning_effort) == (400, "xhigh") assert (catalogue.get("high").max_iterations, catalogue.get("high").reasoning_effort) == (400, "high") assert (catalogue.get("medium").max_iterations, catalogue.get("medium").reasoning_effort) == (400, "low") @@ -337,6 +338,8 @@ def test_own_credentials_and_source_model_are_ignored(grounded, tmp_path): assert list(data["providers"]) == ["custom"] assert data["providers"]["custom"]["apiKey"] == "host-key" assert data["agents"]["defaults"]["model"] == "host-model" + # The host's ``low``, not the source's ``max``: the effort is inherited + # with the model, and the source has no say in it. assert data["agents"]["defaults"]["reasoningEffort"] == "low" diff --git a/tests/test_ppt_engine_quiet_and_tier.py b/tests/test_ppt_engine_quiet_and_tier.py index f04240848..b5e489608 100644 --- a/tests/test_ppt_engine_quiet_and_tier.py +++ b/tests/test_ppt_engine_quiet_and_tier.py @@ -1,6 +1,6 @@ """What a build stops repeating, and what a tier caps. -Six recorded deck runs were read build by build (docs/ppt-raven-design.md D35): +Six recorded deck runs were read build by build: four kinds of finding drove the author round in circles without changing the deck, the reader's replies described the wrong page fourteen times, and every useful fix was in by the tenth whole-deck build and the third reading. diff --git a/tests/test_subagent_routing_backend.py b/tests/test_subagent_routing_backend.py index afca3494b..a83a95119 100644 --- a/tests/test_subagent_routing_backend.py +++ b/tests/test_subagent_routing_backend.py @@ -13,11 +13,13 @@ from pathlib import Path from typing import Any -from raven.agent.subagent.backends.routing import HOST_PREFIX, RouteTarget, RoutingBackend +from raven.agent.subagent.attachments import turn_attachments +from raven.agent.subagent.backends.routing import HOST_PREFIX, RouteTarget, RoutingBackend, handed_files from raven.agent.subagent.mode_tiers import turn_tier, turn_tier_in_force from raven.agent.subagent.registry import AgentRegistry from raven.agent.subagent.vendored_agents import _read_route_notes as read_route_notes from raven.config.schema import ThirdPartyAcpSubagentConfig +from raven.spine.message import Media REPO = Path(__file__).resolve().parent.parent @@ -63,10 +65,19 @@ async def __call__(self, menu: list[tuple[str, str]], task: str, default: str) - return self.answer -#: What the shipped deck route declares; the default for an entry under test, -#: so a case about the gate does not have to restate why the gate applies. +#: What the shipped deck route declares (``NEEDS``, ``NEEDS_FILE``) and the tier +#: floor the gate also knows how to read (``MIN_TIER``; the shipped route no +#: longer declares one). The entry under test defaults to the credentials and +#: the floor, so a case about the tier gate does not have to restate why the +#: gate applies; a case about the file gate names ``needs_file`` itself. NEEDS = ("image_generation", "image_search") MIN_TIER = "max" +NEEDS_FILE = ".pptx" +TEMPLATE = "/home/u/.raven/uploads/brand.pptx" + + +def _media(path: str) -> Media: + return Media(path=path, mime="application/octet-stream", kind="file") def _entry( @@ -78,12 +89,13 @@ def _entry( note: str = NOTE, needs: tuple[str, ...] = NEEDS, min_tier: str = MIN_TIER, + needs_file: str = "", ) -> tuple[RoutingBackend, _Backend, _Backend]: design, deck = _Backend("Design"), _Backend("Deck") entry = RoutingBackend( "Design", design, - [RouteTarget("Deck", "builds a .pptx", deck, owes, note, needs, min_tier)], + [RouteTarget("Deck", "builds a .pptx", deck, owes, note, needs, min_tier, needs_file)], instances=instances or _Instances(), target_ready=target_ready, ) @@ -354,7 +366,7 @@ async def test_below_the_top_tier_the_deck_stays_here_and_the_deliverable_travel assert run["authored_task"] == f"a deck of the report{HOST_PREFIX}{NOTE}", tier async def test_the_top_tier_routes_to_the_target_with_the_task_untouched(self) -> None: - """Max is the template lane, and it is told nothing it did not already know.""" + """At the floor the target runs, and it is told nothing it did not already know.""" entry, design, deck = _entry(_Router("Deck")) with turn_tier("max"): @@ -553,6 +565,147 @@ async def test_a_handle_the_target_already_bound_is_still_not_re_gated(self) -> assert router.asked == [] and deck.runs[0]["task"] == "apply the notes" +class TestATemplateOpensTheRoute: + """A target that builds on a file the user supplies opens on that file. + + What counts as handed over is read from where the host holds the turn's + attachments, never from the task text alone: a direct chat carries them as + ``media``; a spawn or a DAG node carries none, so the turn's attachments are + read and one counts when the task names it. The text does not settle it by + itself -- every deck brief names the deck's destination in the same spelling + as a template. + """ + + async def test_without_an_attachment_the_work_stays_here_at_every_tier_and_on_a_chat(self) -> None: + for tier in ("medium", "high", "max"): + entry, design, deck = _entry(_Router("Deck"), min_tier="", needs_file=NEEDS_FILE) + with turn_tier(tier): + assert await _run(entry, "a deck about tennis", authored_task="a tennis deck") == "Design did it" + assert deck.runs == [], tier + assert design.runs[0]["task"] == f"a deck about tennis{HOST_PREFIX}{NOTE}", tier + assert design.runs[0]["authored_task"] == f"a tennis deck{HOST_PREFIX}{NOTE}", tier + entry, design, deck = _entry(_Router("Deck"), min_tier="", needs_file=NEEDS_FILE) + assert await _run(entry, "a deck", instance="h1", mode="max") == "Design did it" + assert deck.runs == [] and design.runs[0]["task"] == f"a deck{HOST_PREFIX}{NOTE}" + + async def test_a_chats_attached_template_opens_the_route_and_travels_with_the_task(self) -> None: + """The direct-chat lane: the file arrives as ``media``, which the gate + reads and the target still receives -- whether or not the text names it.""" + entry, design, deck = _entry(_Router("Deck"), min_tier="", needs_file=NEEDS_FILE) + media = (_media(TEMPLATE),) + assert await _run(entry, "a deck on this", media=media) == "Deck did it" + assert design.runs == [] + assert deck.runs[0]["task"] == "a deck on this" and deck.runs[0]["media"] == media + + async def test_a_turns_attachment_opens_the_route_when_the_task_names_it(self) -> None: + """The spawn and DAG lane: no ``media`` crosses; the dispatching model + passes the file on by writing its path into the task, in whichever of + the two texts it wrote and however it punctuates around it.""" + for kwargs in ( + {"task": f"Build the deck on {TEMPLATE}, 20 pages."}, + {"task": "Build the deck.", "authored_task": f"deck on {TEMPLATE}"}, + {"task": f"template: ({TEMPLATE})"}, + # Full-width punctuation right after the path, as a Chinese request + # and the model writing one both put it. + {"task": f"\u7528 {TEMPLATE}\uff0c\u505a 20 \u9875"}, + {"task": f"\u6a21\u677f\uff1a{TEMPLATE}\u3002"}, + {"task": f"\u6a21\u677f\uff08{TEMPLATE}\uff09"}, + # The name alone, as a model that read the host's relative note writes it. + {"task": f"use the attached {Path(TEMPLATE).name} as the template"}, + {"task": f"\u9644\u4ef6\uff1auploads/{Path(TEMPLATE).name}"}, + # ASCII marks right after the name: the full stop that ends an + # English sentence, an ellipsis, and the rest of the keyboard. + {"task": f"Build the deck on the attached template {TEMPLATE}."}, + {"task": f"template {Path(TEMPLATE).name}..."}, + {"task": f"use {Path(TEMPLATE).name}!"}, + {"task": f'the template is "{Path(TEMPLATE).name}"; 20 pages'}, + ): + entry, design, deck = _entry(_Router("Deck"), min_tier="", needs_file=NEEDS_FILE) + task = kwargs.pop("task") + with turn_tier("medium"), turn_attachments([_media(TEMPLATE)]): + assert await _run(entry, task, **kwargs) == "Deck did it", task + assert design.runs == [] and deck.runs[0]["task"] == task, task + + async def test_a_pptx_in_the_text_that_is_not_an_attachment_does_not_open_it(self) -> None: + """What the dispatching model writes into every deck brief: the deck's + destination, its format, and now and then a stray file it saw in the + working directory. None of them was handed over.""" + briefs = ( + "Save the deck as /home/u/.raven/tmp/tui/tennis_history.pptx, 20 pages.", + "\u6700\u7ec8\u4ea4\u4ed8\u53ef\u7528\u7684 PPT \u6587\u4ef6\uff08.pptx\uff09\uff0c\u4fdd\u5b58\u5230\u5f53\u524d\u5de5\u4f5c\u76ee\u5f55", + "\u5b58\u4e3a /home/u/.raven/tmp/tui/\u7f51\u7403\u5386\u53f2\u4ecb\u7ecd.pptx", + "The working directory already holds an older tennis_history.pptx from yesterday; that is not yours.", + "any *.pptx will do; the format is .pptx", + f"a deck like {TEMPLATE}.bak", + "Ignore the attachment; save the new deck as /home/u/.raven/tmp/tui/new_brand.pptx", + "the old my-brand.pptx and rebrand.pptx in the folder are not yours", + "a brand.pptx-based look, a brand.pptx_copy on disk, and brand.pptxx as a typo", + f"keep {TEMPLATE}.bak, it is last year's", + ) + for brief in briefs: + for attached in ((), (_media(TEMPLATE),)): + entry, design, deck = _entry(_Router("Deck"), min_tier="", needs_file=NEEDS_FILE) + with turn_tier("max"), turn_attachments(list(attached)): + assert await _run(entry, brief) == "Design did it", (brief, attached) + assert deck.runs == [], (brief, attached) + + async def test_an_attachment_the_task_does_not_name_was_not_handed_over(self) -> None: + """The user attached a template and then told the model to ignore it; the + model's brief names no file, and the work stays here.""" + entry, design, deck = _entry(_Router("Deck"), min_tier="", needs_file=NEEDS_FILE) + with turn_tier("max"), turn_attachments([_media(TEMPLATE)]): + assert await _run(entry, "a fresh tennis deck, design it from scratch") == "Design did it" + assert deck.runs == [] + + async def test_only_an_attachment_of_the_declared_kind_counts(self) -> None: + for attached in ( + "/home/u/.raven/uploads/notes.md", + "/home/u/.raven/uploads/brand.pptx.bak", + "/home/u/.raven/uploads/logo.png", + ): + entry, design, deck = _entry(_Router("Deck"), min_tier="", needs_file=NEEDS_FILE) + with turn_tier("max"), turn_attachments([_media(attached)]): + assert await _run(entry, f"a deck from {attached}") == "Design did it", attached + assert deck.runs == [], attached + entry, design, deck = _entry(_Router("Deck"), min_tier="", needs_file=NEEDS_FILE) + with turn_tier("max"), turn_attachments([_media("/home/u/.raven/uploads/notes.md"), _media(TEMPLATE)]): + assert await _run(entry, f"a deck from notes.md on {TEMPLATE}") == "Deck did it" + + async def test_a_chats_media_outranks_the_turns_attachments(self) -> None: + """Both present -- a direct chat is answered inside a host turn in some + surfaces -- and the chat's own ``media`` is what was handed to it.""" + entry, design, deck = _entry(_Router("Deck"), min_tier="", needs_file=NEEDS_FILE) + with turn_tier("max"), turn_attachments([_media(TEMPLATE)]): + assert await _run(entry, f"a deck on {TEMPLATE}", media=(_media("/x/notes.md"),)) == "Design did it" + assert deck.runs == [] + + async def test_the_credentials_close_the_route_with_the_file_in_hand(self) -> None: + """The flat condition comes first: a template lane that cannot buy a + picture is not the lane, however good the template.""" + entry, design, deck = _entry( + _Router("Deck"), target_ready=lambda _t, _n: False, min_tier="", needs_file=NEEDS_FILE + ) + assert await _run(entry, f"a deck on {TEMPLATE}", media=(_media(TEMPLATE),)) == "Design did it" + assert deck.runs == [] and design.runs[0]["task"] == f"a deck on {TEMPLATE}{HOST_PREFIX}{NOTE}" + + async def test_the_file_and_the_floor_both_have_to_hold_when_both_are_declared(self) -> None: + entry, design, deck = _entry(_Router("Deck"), min_tier="high", needs_file=NEEDS_FILE) + with turn_tier("medium"), turn_attachments([_media(TEMPLATE)]): + assert await _run(entry, f"a deck on {TEMPLATE}") == "Design did it" + entry, design, deck = _entry(_Router("Deck"), min_tier="high", needs_file=NEEDS_FILE) + with turn_tier("high"), turn_attachments([_media(TEMPLATE)]): + assert await _run(entry, f"a deck on {TEMPLATE}") == "Deck did it" + + async def test_odd_inputs_do_not_raise(self) -> None: + """A media row without a path, an empty text, a None text, an attachment + that is a plain string: the gate answers, it does not fail the dispatch.""" + entry, design, deck = _entry(_Router("Deck"), min_tier="", needs_file=NEEDS_FILE) + with turn_tier("max"), turn_attachments([_media(""), _media(TEMPLATE)]): + assert await _run(entry, "", authored_task=None) == "Design did it" + assert await _run(entry, "on " + TEMPLATE, authored_task=None) == "Deck did it" + assert handed_files("x", None, media=("/a/b.pptx", object())) == ("/a/b.pptx",) + + class TestTheShippedManifestsRoute: """The real roster lines, through the real entry.""" @@ -575,33 +728,38 @@ def _table() -> AgentRegistry: registry.apply(rows) return registry - async def test_a_deck_request_reaches_the_deck_engine_through_the_classifier(self) -> None: + async def test_a_deck_request_with_a_template_reaches_the_deck_engine_through_the_classifier(self) -> None: entry = self._table().backend("Raven-Design") assert isinstance(entry, RoutingBackend) router = _Router("Raven-PPT") entry.set_router(router) - for task in ( - "Build a 10-slide deck from /data/report.md", + f"Build a 10-slide deck from /data/report.md on {TEMPLATE}", "Make a PPT about Shanghai's city plan", "Turn these notes into a presentation for Friday", "{{ inputs.source }}\nExport the summary as a .pptx", ): - assert (await entry.pick(task, session_key="s1", instance=None))[0] == "Raven-PPT", task + picked = await entry.pick(task, session_key="s1", instance=None, handed=(TEMPLATE,)) + assert picked[0] == "Raven-PPT", task assert len(router.asked) == 4 - async def test_below_the_top_tier_the_shipped_row_keeps_the_deck_and_carries_its_own_words(self) -> None: - """The real rows, through the real entry: the deck does not vanish below - max, it changes which lane designs it and travels with what the manifest - declares -- verbatim, under the host marker, and nothing else.""" + async def test_without_a_template_the_shipped_row_keeps_the_deck_at_every_tier(self) -> None: + """The real rows, through the real entry: a deck with no template is this + row's own at max as much as at medium -- the tier buys thinking, not a + different product -- and it travels with what the manifest declares: + verbatim, under the host marker, and nothing else.""" entry = self._table().backend("Raven-Design") entry.set_router(_Router("Raven-PPT")) + for tier in ("medium", "high", "max"): + with turn_tier(tier): + picked = await entry.pick("Make a PPT about Shanghai's city plan", session_key="s1", instance=None) + assert picked.name == "Raven-Design", tier + assert picked.note == f"{HOST_PREFIX}{_declared_note()}", tier + # And no tier below max keeps it closed once the template is there. with turn_tier("medium"): - picked = await entry.pick("Make a PPT about Shanghai's city plan", session_key="s1", instance=None) - - assert picked.name == "Raven-Design" - assert picked.note == f"{HOST_PREFIX}{_declared_note()}" + picked = await entry.pick("Make a PPT on it", session_key="s1", instance=None, handed=(TEMPLATE,)) + assert picked.name == "Raven-PPT" and picked.note == "" async def test_the_classifier_reads_the_deck_engines_line_and_never_the_entrys_own(self) -> None: registry = self._table() @@ -779,6 +937,25 @@ async def test_the_two_declarations_are_independent(self) -> None: assert await _run(entry, "a deck") == "Deck did it", "min_tier alone must not consult the probe" assert asked == [] + async def test_a_declared_file_is_the_third_declaration_and_independent_of_the_other_two(self) -> None: + """A route asking for a file is not thereby probed, and not thereby + tiered; and a route naming a rung or a credential is not thereby asking + for a file.""" + asked: list[Any] = [] + entry, design, deck = _entry( + _Router("Deck"), + target_ready=lambda t, n: asked.append((t, n)) or False, + needs=(), + min_tier="", + needs_file=NEEDS_FILE, + ) + with turn_tier("medium"), turn_attachments([_media(TEMPLATE)]): + assert await _run(entry, f"a deck on {TEMPLATE}") == "Deck did it" + assert asked == [], "needs_file alone must not consult the probe" + entry, design, deck = _entry(_Router("Deck"), target_ready=lambda _t, _n: True, needs=NEEDS, min_tier="max") + with turn_tier("max"): + assert await _run(entry, "a deck, no file anywhere") == "Deck did it", "no needs_file, no file asked for" + async def test_a_declared_tier_is_a_floor_and_not_the_top_rung(self) -> None: """The rung is read off the declaration, so a route may open at a middle one. Pinned because the rule it replaced was ``== TIER_LADDER[-1]``, and