Compare commits
No commits in common. "346930449afd7aea1183e2c6cc399017e78b9ea1" and "0e02cff6c6cae647cfad25756a6197d1de2c325e" have entirely different histories.
346930449a
...
0e02cff6c6
11
PROGRESS.md
11
PROGRESS.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||||
|
|
||||||
最后更新:2026-07-03(seedream size 面积钳制:修 1920x1080 被 ARK 400 打回,bump 0.37.1)
|
最后更新:2026-07-03(ppt 门体系二轮硬化:逃生口收紧 + 导出自动质检 + svg_final 嵌图修复,bump 0.36.1)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -21,15 +21,6 @@
|
||||||
|
|
||||||
## 已完成关键能力
|
## 已完成关键能力
|
||||||
|
|
||||||
### 2026-07-03 / ppt 反纯文字页+图表落地硬门(7aa49195 二代陶瓷 deck 复盘,bump 0.38.0)
|
|
||||||
0.37 网格锁上线后同题重做(task 7aa49195),对齐/标题/节奏大幅好转,但用户复评两点成立:①**两栏裸文字页 ×4**(S8/S9/S16/S21 同为"图标小标题+下划线+文字堆 ×2 栏"零图形)——该形态无卡片、仅 2 图标,0.37 的 icon-grid/card-grid 指纹完全看不见,单调门盲区;②**全本零数据图表**(素材全是数字:100万→500万条/能耗降10-20%/碳排26%),"历程"类内容也退化成文字列表。另有两硬缺陷:S18 第 5 条描述被页脚裁掉(内容超出内容区)、S19 红色大字直接叠压灰色说明文字。修:**A 指纹加 text-columns 原型**(0 卡片+≤3 图标+≤2 图形基元+左对齐文本聚 ≥2 列)堵盲区,4 页同指纹→error;**B spec 指派图表落空检测**——spec_lock page_charts 指派了图表但该页 <3 图形基元且 <4 卡片→error("图表被退化成文字"),配 executor 硬规则"不许把指派图表降级为文字/大字 KPI";**C CJK 叠压升级 error**——两 run 均 ≥70% CJK(表意字宽 1.0em 估宽近精确)且互叠 ≥50%→error(其余情形保持 warning+渲图过目);**D layout_grid 加可选 content_bottom**——非页脚文本 baseline 越过它→error(S18 类),executor 加"写页前垂直空间预算"纪律;**E 策略层数据图表下限**——素材含 ≥3 组可比数值→全本至少 1-2 页真数据图表,零图表需在 spec 写理由;两栏裸文字列表计入"原型 ≤2 次"上限。测试 +9(30 项)全过,全量 162 过;71 charts 模板 + 中汽研 deck 模板回归零新增噪音。已知边界:S19 类叠压若文字带 rotate/scale transform 仍不可测(子树跳过);数据图表下限是策略纪律,机器只能验"指派了没画",验不了"该指派没指派"。
|
|
||||||
|
|
||||||
### 2026-07-03 / web 直播流式文字按轮次分段(修工具刷屏时文字被推出视口,bump 0.37.2)
|
|
||||||
用户报:web 端一次 run 里工具调用多时,助手文字流式输出「一直在上方」被工具卡越推越高滚出视口,看不到。根因:直播态把整次 run(含几十轮 LLM)全塞进**一张 assistant 卡**——文字全累进顶部单块 `.body`(`ctx.acc` 反复重渲),工具 `tool_call`/`tool_result` 全 `appendChild` 到其下方;而历史态(DB reload)是**每轮 LLM 一条独立 assistant 消息**、天然按轮次穿插。两态结构不一致就是病根。修(方案 A,只动 `chat.js` live-run 路径,历史渲染不动):文字按轮次分段——`ensureTextSeg`/`closeTextSeg` 维护「当前打开的文字段」,每个可见工具/选项卡(非隐形 `task_progress`)先 `closeTextSeg` 关掉当前段(空占位段直接移除避免留「思考中」孤块、有内容段定稿去光标+高亮),之后的新文字在卡片底部另起新段。效果=`文字(轮1)→工具→结果→文字(轮2)→…`,流式文字始终在底部可见,且与历史结构一致(run 结束 reload 无跳变)。rAF 节流改为闭包捕获 seg,防工具关段后错渲。删掉 `ctx.body`/`ctx.pending` 单块模型,改 `ctx.curSeg={el,acc,pending}`;`createLiveAssistantCard`/`renderLiveRunIfVisible`/`sendMessage`/`fetchSse` 收尾同步改。
|
|
||||||
|
|
||||||
### 2026-07-03 / seedream size 面积钳制(修 1920x1080 被 ARK 400 打回,bump 0.37.1)
|
|
||||||
模型自选 16:9 出图(如 `1920x1080`=2,073,600px)触发 ARK 硬门 `image size must be at least 3686400 pixels`(=1920²),整次文生图直接 400 失败。根因:`tools/seedream.py` 把 `size` 原样透传,不校验 ARK 的**面积**约束(卡的是总像素不是单边,故 16:9 最小合规是 2560x1440)。修:tool 内新增 `_normalize_size()`,拿到 `chosen_size` 前先钳进 `[min_pixels, max_pixels]`——面积 `<min` 按 `sqrt(min/area)` 等比放大、两边向上取整到 8 的倍数并复核达标(1920x1080→2560x1440);`>max`(3072²=9,437,184)等比缩小;已合规原样透传(向后兼容)。约束值加到 `config/media/doubao.yaml` seedream_5 档(`min_pixels`/`max_pixels`,旧 yaml 缺键则视为不设该侧、行为不变)。归一化时返回串附 `[note]` 提示 + meta 记 `requested_size`,usage 记账按**真实出图尺寸**。选自动钳而非返错让模型重试:省一轮往返、避免二次错。新增 tests 手验 9 例全落合法区间。
|
|
||||||
|
|
||||||
### 2026-07-03 / ppt 对齐网格锁 + 错位/单调质检(d1285247 陶瓷 deck 复盘,bump 0.37.0)
|
### 2026-07-03 / ppt 对齐网格锁 + 错位/单调质检(d1285247 陶瓷 deck 复盘,bump 0.37.0)
|
||||||
对 d1285247 产物(25 页陶瓷方案 PPTX)逐页几何量测 + PowerPoint COM 渲图目视复盘,三类缺陷:①跨页左基线漂移(0.656–0.75in 七个值)+ 并排块顶差 2–12px 的"想对齐没对齐"(S8/S19/S23);②5 页同为"图标+标题+三行字"卡网格,零流程箭头/零分层图形,单调;③标题语义不兑现("五层架构"画成五条等宽横条、"矩阵"画成卡片格)。根因:executor 手写绝对坐标但 spec_lock 无网格常量可依;质检只查重叠/越界不查对齐;"节奏不雷同"只约束相邻页。修四层:**A spec_lock 新增 `layout_grid` 锁段**(margin_x/content_top/footer_y/gutter,strategist 派生、executor 每页吸附、checker 强制;design_spec_reference §V 同步);**B executor-base §3 网格对齐纪律**(并排卡片同 top 同高等 gutter、打破网格 ≥16px 干净打破、同行文字 ≥0.3em 禁贴字);**C svg_quality_checker 新增 check 14**——兄弟卡片近失对齐(精确几何,2–12px error;底对齐/中心对齐/绘图区内数据柱三类豁免,71 charts 模板回归误报清零)、layout_grid 偏离 2–15px error、行内 gap 不等 warning、无锁存量项目跨页左缘聚类漂移 warning、版式指纹单调门(≥3 页同指纹 warn、≥4 或过半 error;仅对 NN_ 编号 deck 页聚合,模板库静默);**D 策略纪律升级**——同一版式原型整本 ≤2 次 + 标题语义必须被图形兑现(SKILL.md 大纲纪律 + strategist visual-floor GATE)。顺手修 comparison_columns 模板胶囊 5px 错位。新增 tests/test_svg_alignment_check.py 21 项,全量 153 过。已知边界:页面平衡类(底部大空白/重心偏移,S18/S22)误报风险高未进 checker,只进阶段五验收 checklist 眼看;错位 error 会被导出边界自动质检门连带拦截,存量项目重导出若报新 error 属预期(真缺陷)。
|
对 d1285247 产物(25 页陶瓷方案 PPTX)逐页几何量测 + PowerPoint COM 渲图目视复盘,三类缺陷:①跨页左基线漂移(0.656–0.75in 七个值)+ 并排块顶差 2–12px 的"想对齐没对齐"(S8/S19/S23);②5 页同为"图标+标题+三行字"卡网格,零流程箭头/零分层图形,单调;③标题语义不兑现("五层架构"画成五条等宽横条、"矩阵"画成卡片格)。根因:executor 手写绝对坐标但 spec_lock 无网格常量可依;质检只查重叠/越界不查对齐;"节奏不雷同"只约束相邻页。修四层:**A spec_lock 新增 `layout_grid` 锁段**(margin_x/content_top/footer_y/gutter,strategist 派生、executor 每页吸附、checker 强制;design_spec_reference §V 同步);**B executor-base §3 网格对齐纪律**(并排卡片同 top 同高等 gutter、打破网格 ≥16px 干净打破、同行文字 ≥0.3em 禁贴字);**C svg_quality_checker 新增 check 14**——兄弟卡片近失对齐(精确几何,2–12px error;底对齐/中心对齐/绘图区内数据柱三类豁免,71 charts 模板回归误报清零)、layout_grid 偏离 2–15px error、行内 gap 不等 warning、无锁存量项目跨页左缘聚类漂移 warning、版式指纹单调门(≥3 页同指纹 warn、≥4 或过半 error;仅对 NN_ 编号 deck 页聚合,模板库静默);**D 策略纪律升级**——同一版式原型整本 ≤2 次 + 标题语义必须被图形兑现(SKILL.md 大纲纪律 + strategist visual-floor GATE)。顺手修 comparison_columns 模板胶囊 5px 错位。新增 tests/test_svg_alignment_check.py 21 项,全量 153 过。已知边界:页面平衡类(底部大空白/重心偏移,S18/S22)误报风险高未进 checker,只进阶段五验收 checklist 眼看;错位 error 会被导出边界自动质检门连带拦截,存量项目重导出若报新 error 属预期(真缺陷)。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,6 @@ image:
|
||||||
endpoint: /images/generations
|
endpoint: /images/generations
|
||||||
price_cny_per_image: 0.22 # 计费单位:成功输出张数;调价改这里 + 重启
|
price_cny_per_image: 0.22 # 计费单位:成功输出张数;调价改这里 + 重启
|
||||||
default_size: 2048x2048 # 原生最高 3072x3072;2K 兼顾质量/体积
|
default_size: 2048x2048 # 原生最高 3072x3072;2K 兼顾质量/体积
|
||||||
# 输出尺寸面积约束(ARK 硬门):面积 < min_pixels → 400 InvalidParameter。
|
|
||||||
# 模型自选 16:9 之类小尺寸(如 1920x1080=2.07M)会栽,故 tool 侧等比钳到合法区间:
|
|
||||||
# min = 1920² = 3,686,400(16:9 最小合规即 2560x1440);max = 3072² = 9,437,184。
|
|
||||||
min_pixels: 3686400
|
|
||||||
max_pixels: 9437184
|
|
||||||
default_watermark: false # 默认无水印(申报/PPT 场景反需求)
|
default_watermark: false # 默认无水印(申报/PPT 场景反需求)
|
||||||
default_search: false # web search 额外加价 ~¥0.05/张;默认关
|
default_search: false # web search 额外加价 ~¥0.05/张;默认关
|
||||||
request_timeout_s: 60 # 出图慢于此判超时
|
request_timeout_s: 60 # 出图慢于此判超时
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.38.0"
|
__version__ = "0.37.0"
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户
|
||||||
|
|
||||||
**逐页大纲**(写进 design_spec.md §IX,也是 spec_lock 的 page_rhythm/page_layouts 依据):**论断式标题 + 每页标节奏**(`anchor`/`dense`/`breathing`)。三条硬纪律(大纲阶段定死):
|
**逐页大纲**(写进 design_spec.md §IX,也是 spec_lock 的 page_rhythm/page_layouts 依据):**论断式标题 + 每页标节奏**(`anchor`/`dense`/`breathing`)。三条硬纪律(大纲阶段定死):
|
||||||
- **论断标题**:写结论不写主题("渗透率破 60%" 不是 "行业背景");
|
- **论断标题**:写结论不写主题("渗透率破 60%" 不是 "行业背景");
|
||||||
- **节奏不雷同(整本 ≤2 次)**:相邻内容页不同版式,且**同一版式原型全 deck 最多 2 页**(图标卡网格 / 全宽横条列表 / **两栏裸文字列表**(图标小标题+下划线+文字堆 ×2、零图形 —— 一次真实交付里出现了 4 页)尤其;5 页"2×3 图标卡"哪怕文案不同也读作同一张片重复,真实翻车过);第 3 页起换形态(时间轴/分层/象限/流程/hub-spoke/图表)。narrative 真正停顿处插 `breathing`(单概念/金句/大图,**禁多卡网格**);不要为凑节奏造填充页;素材含 ≥3 组可比数值(规模/占比/趋势/阶段目标)→ **全本至少 1-2 页真数据图表**(bar/line/donut/进度条),大字 KPI 是强调不算图表,零数据图表要在 spec 写明理由;
|
- **节奏不雷同(整本 ≤2 次)**:相邻内容页不同版式,且**同一版式原型全 deck 最多 2 页**(图标卡网格 / 全宽横条列表尤其 —— 5 页"2×3 图标卡"哪怕文案不同也读作同一张片重复,真实翻车过);第 3 页起换形态(时间轴/分层/象限/流程/hub-spoke/图表)。narrative 真正停顿处插 `breathing`(单概念/金句/大图,**禁多卡网格**);不要为凑节奏造填充页;
|
||||||
- **内容→版式映射(必须落到 spec,不能整本留空)**:历程→时间轴、循环→闭环、2-4 数字→KPI、并列→网格、单震撼数字→breathing 大字、≥3 数据点→图表(charts/ 模板或自绘);对比→象限/分栏、流程→process_flow、占比→donut、架构→分层、关系→hub_spoke。**标题语义必须被图形兑现**:标题写"架构"就画层块堆叠(不是等宽横条列表)、写"矩阵"就画真象限(不是卡片网格)、写"流程/层级"就有方向/层次 —— "五层架构"画成五条一样的横条是典型名不副实。每个能结构化的内容页都要在 spec_lock 的 `page_charts`/`page_layouts` 落一个视觉处理 —— **内容 deck 不许 page_charts + page_layouts 同时空着**(=啥图都没分配,执行层必堆文字方块)。视觉下限见 strategist.md「GATE — visual floor」;质检会硬卡"全是文字方块"的扁平 deck(见阶段四)。
|
- **内容→版式映射(必须落到 spec,不能整本留空)**:历程→时间轴、循环→闭环、2-4 数字→KPI、并列→网格、单震撼数字→breathing 大字、≥3 数据点→图表(charts/ 模板或自绘);对比→象限/分栏、流程→process_flow、占比→donut、架构→分层、关系→hub_spoke。**标题语义必须被图形兑现**:标题写"架构"就画层块堆叠(不是等宽横条列表)、写"矩阵"就画真象限(不是卡片网格)、写"流程/层级"就有方向/层次 —— "五层架构"画成五条一样的横条是典型名不副实。每个能结构化的内容页都要在 spec_lock 的 `page_charts`/`page_layouts` 落一个视觉处理 —— **内容 deck 不许 page_charts + page_layouts 同时空着**(=啥图都没分配,执行层必堆文字方块)。视觉下限见 strategist.md「GATE — visual floor」;质检会硬卡"全是文字方块"的扁平 deck(见阶段四)。
|
||||||
|
|
||||||
大纲连同 a–h **一起给用户预览,⛔ BLOCKING 等确认整份结构**后再进阶段二(改文字比改 slide 便宜)。
|
大纲连同 a–h **一起给用户预览,⛔ BLOCKING 等确认整份结构**后再进阶段二(改文字比改 slide 便宜)。
|
||||||
|
|
@ -142,7 +142,7 @@ references/visual-styles/<locked-style>.md # 锁定的视觉风格
|
||||||
```
|
```
|
||||||
.venv/Scripts/python.exe <skill_dir>/scripts/svg_quality_checker.py <project_dir>
|
.venv/Scripts/python.exe <skill_dir>/scripts/svg_quality_checker.py <project_dir>
|
||||||
```
|
```
|
||||||
- **任何 `error`(禁用特性 / viewBox 不符 / spec_lock 漂移 / **图标压在文字上、文字基线超出画布、CJK 文字互相叠压**(Geometry 检测,几何精确)/ **兄弟卡片错位 2–12px、偏离 layout_grid 网格、正文越过 content_bottom 侵入页脚区、spec 指派了 page_charts 该页却零图形(图表被退化成文字)**(Alignment 检测,几何精确)/ **锁了图标 inventory 却全 deck 0 图标** / **内容 deck 全是文字方块(≥6 页且零 `<path>`/`<polygon>`/`<polyline>`/`<image>`)** / **≥4 页同版式指纹(单调门,含两栏裸文字列表)** 等)必须改:回阶段三重写该页再跑**,不放过。
|
- **任何 `error`(禁用特性 / viewBox 不符 / spec_lock 漂移 / **图标压在文字上、文字基线超出画布**(Geometry 检测,几何精确)/ **兄弟卡片错位 2–12px、偏离 layout_grid 网格**(Alignment 检测,几何精确)/ **锁了图标 inventory 却全 deck 0 图标** / **内容 deck 全是文字方块(≥6 页且零 `<path>`/`<polygon>`/`<polyline>`/`<image>`)** / **≥4 页同版式指纹(单调门)** 等)必须改:回阶段三重写该页再跑**,不放过。
|
||||||
- `warning`(低分辨率图 / 非 PPT 安全字体等):能顺手改就改,否则知会后放行。**例外:`Geometry:` 开头的文字重叠 warning 不许无视** —— 它给了精确坐标,是"大字压说明 / 同行文字互侵"的高嫌疑点(估宽无法区分擦边与压字,所以只报 warn),阶段五渲图时**必须对着该页该坐标专门看**,压了就返工。
|
- `warning`(低分辨率图 / 非 PPT 安全字体等):能顺手改就改,否则知会后放行。**例外:`Geometry:` 开头的文字重叠 warning 不许无视** —— 它给了精确坐标,是"大字压说明 / 同行文字互侵"的高嫌疑点(估宽无法区分擦边与压字,所以只报 warn),阶段五渲图时**必须对着该页该坐标专门看**,压了就返工。
|
||||||
- 跑 `svg_output/`(不要在 finalize 后跑 —— finalize 改写 SVG 会掩盖源级违规)。
|
- 跑 `svg_output/`(不要在 finalize 后跑 —— finalize 改写 SVG 会掩盖源级违规)。
|
||||||
- ⚠️ **别用 `| head` / `| tail` 截断质检输出**:管道会把脚本的非零退出码换成 `head` 的 0(门形同虚设),`head` 还会截掉打在**最后**的 deck 级门结论(如零图标 `[ERROR]`)。原样跑,读完整输出、认它的退出码。
|
- ⚠️ **别用 `| head` / `| tail` 截断质检输出**:管道会把脚本的非零退出码换成 `head` 的 0(门形同虚设),`head` 还会截掉打在**最后**的 deck 级门结论(如零图标 `[ERROR]`)。原样跑,读完整输出、认它的退出码。
|
||||||
|
|
|
||||||
|
|
@ -182,8 +182,6 @@ Before drawing each page, look up its entry in `page_charts` to decide which cha
|
||||||
- **Sibling cards align exactly**: cards in one row share the same `y` and `height`; cards in one column share the same `x`; gaps in a row all equal `layout_grid.gutter`. Compute one set of constants per grid (`x = margin_x + i * (card_w + gutter)`) instead of placing each card by feel. Deliberate stagger (masonry) offsets by ≥16px.
|
- **Sibling cards align exactly**: cards in one row share the same `y` and `height`; cards in one column share the same `x`; gaps in a row all equal `layout_grid.gutter`. Compute one set of constants per grid (`x = margin_x + i * (card_w + gutter)`) instead of placing each card by feel. Deliberate stagger (masonry) offsets by ≥16px.
|
||||||
- **Two-column layouts resolve the bottom edge**: either both columns end at the same y, or the shorter column is deliberately closed (vertical centering, a filler visual, a closing rule) — never one column dangling far above the other with dead whitespace.
|
- **Two-column layouts resolve the bottom edge**: either both columns end at the same y, or the shorter column is deliberately closed (vertical centering, a filler visual, a closing rule) — never one column dangling far above the other with dead whitespace.
|
||||||
- **No glued glyphs on one line**: adjacent inline elements (arrow + number, numeral + unit, badge + label) keep ≥0.3em horizontal gap. An arrow touching a digit ("→02") reads as a typo.
|
- **No glued glyphs on one line**: adjacent inline elements (arrow + number, numeral + unit, badge + label) keep ≥0.3em horizontal gap. An arrow touching a digit ("→02") reads as a typo.
|
||||||
- **Budget vertical space BEFORE writing the page**: items × row height must fit within `content_top`..`content_bottom`. If N items don't fit, cut items, tighten copy, or change layout — never compress row gaps until the last item's description lands in the footer zone (shipped failure: a 5-item list whose 5th description was clipped by the footer). The checker errors on any text baseline past `content_bottom`.
|
|
||||||
- **Never degrade an assigned chart to text**: when `spec_lock.page_charts` assigns this page a chart, the page MUST draw a real figure (adapt the named `templates/charts/` template). Rendering the numbers as a text list or big KPI type instead of the assigned chart is a checker-level error — quantitative evidence belongs in a figure, and the Strategist already decided this page gets one.
|
|
||||||
- **Template structure**: if templates exist, inherit the visual framework
|
- **Template structure**: if templates exist, inherit the visual framework
|
||||||
- **Main-agent ownership**: SVG generation must run in the main agent (not sub-agents) — pages share upstream context for cross-page visual continuity
|
- **Main-agent ownership**: SVG generation must run in the main agent (not sub-agents) — pages share upstream context for cross-page visual continuity
|
||||||
- **Generation rhythm**: lock global design context first, then generate pages sequentially in one continuous context. No batched groups (e.g., 5 at a time).
|
- **Generation rhythm**: lock global design context first, then generate pages sequentially in one continuous context. No batched groups (e.g., 5 at a time).
|
||||||
|
|
|
||||||
|
|
@ -650,8 +650,7 @@ The most common Strategist failure mode is missing the structural half — treat
|
||||||
> - Every **content page whose shape matches a catalog Pick clause** MUST get a visual treatment — a `page_charts` entry (chart / infographic template), a `page_layouts` structural template, or an explicit §VII custom-diagram plan (`no-template-match` with the figure described). Text-in-boxes is the fallback only for pages that genuinely carry no structurable shape, and you must be able to name why.
|
> - Every **content page whose shape matches a catalog Pick clause** MUST get a visual treatment — a `page_charts` entry (chart / infographic template), a `page_layouts` structural template, or an explicit §VII custom-diagram plan (`no-template-match` with the figure described). Text-in-boxes is the fallback only for pages that genuinely carry no structurable shape, and you must be able to name why.
|
||||||
> - **`spec_lock.md` MUST NOT ship a content deck with `page_charts` empty AND `page_layouts` empty/free-design AND no §VII custom-diagram rows.** That combination means no visual was assigned anywhere — re-scan §IX and map each content page's shape to a figure (comparison→columns/quadrant, process/历程→timeline/process_flow, cycle/循环→concentric/segmented_wheel, share→donut/pie, trend→line/area, ranking→bar, architecture→layered_architecture, relations→hub_spoke/mind_map).
|
> - **`spec_lock.md` MUST NOT ship a content deck with `page_charts` empty AND `page_layouts` empty/free-design AND no §VII custom-diagram rows.** That combination means no visual was assigned anywhere — re-scan §IX and map each content page's shape to a figure (comparison→columns/quadrant, process/历程→timeline/process_flow, cycle/循环→concentric/segmented_wheel, share→donut/pie, trend→line/area, ranking→bar, architecture→layered_architecture, relations→hub_spoke/mind_map).
|
||||||
> - Downstream enforcement: `svg_quality_checker.py` **hard-fails** any deck (≥6 text-heavy pages) whose Executor output has zero `<path>`/`<polygon>`/`<polyline>`/`<image>` deck-wide. Leaving the visual plan empty here guarantees that failure later — assign the figures now, at the spec stage, where it is cheapest to change.
|
> - Downstream enforcement: `svg_quality_checker.py` **hard-fails** any deck (≥6 text-heavy pages) whose Executor output has zero `<path>`/`<polygon>`/`<polyline>`/`<image>` deck-wide. Leaving the visual plan empty here guarantees that failure later — assign the figures now, at the spec stage, where it is cheapest to change.
|
||||||
> - **Archetype cap (anti-monotony)**: the same layout archetype — especially the icon+title+text card grid, the full-width stacked row list, and the **two-column bare text list** (icon header + underline + text stack ×2, zero figures — a shipped deck carried FOUR of these) — may carry at most **2 pages per deck**, adjacent or not. Five "2×3 icon grid" pages read as the same slide repeated even when the copy differs (real shipped failure). When §IX assigns a third page the same shape, rework it into a different visual form via the mapping above. Downstream: the checker warns at 3 same-fingerprint pages and errors at 4.
|
> - **Archetype cap (anti-monotony)**: the same layout archetype — especially the icon+title+text card grid and the full-width stacked row list — may carry at most **2 pages per deck**, adjacent or not. Five "2×3 icon grid" pages read as the same slide repeated even when the copy differs (real shipped failure). When §IX assigns a third page the same shape, rework it into a different visual form via the mapping above. Downstream: the checker warns at 3 same-fingerprint pages and errors at 4.
|
||||||
> - **Data-chart floor**: when the source material carries ≥3 comparable quantities (scale numbers, shares, trends, staged targets — e.g. 100万条→500万条, 能耗降10-20%, 碳排占26%), the deck MUST include at least **1-2 real data charts** (bar / line / donut / progress — from `templates/charts/` or custom §VII). A quantitative deck that ships zero data charts wastes its strongest evidence as prose (real shipped failure); if you deliberately assign none, write the reason into design_spec §VII. Numbers rendered as big KPI type count as emphasis, not as a chart.
|
|
||||||
> - **Title semantics must be honored by the layout**: a page titled 架构/分层 gets stacked layers (NOT equal-width row bars); 矩阵 gets a real 2×2 quadrant (NOT a card grid); 流程 gets a directional flow; 层级 gets a pyramid/tree. Shipping a "五层架构" page drawn as five identical list rows is the canonical semantic mismatch — the title promises a figure the page never draws.
|
> - **Title semantics must be honored by the layout**: a page titled 架构/分层 gets stacked layers (NOT equal-width row bars); 矩阵 gets a real 2×2 quadrant (NOT a card grid); 流程 gets a directional flow; 层级 gets a pyramid/tree. Shipping a "五层架构" page drawn as five identical list rows is the canonical semantic mismatch — the title promises a figure the page never draws.
|
||||||
|
|
||||||
> **Reading is mandatory; the catalog is a starting point, not a copy target.**
|
> **Reading is mandatory; the catalog is a starting point, not a copy target.**
|
||||||
|
|
|
||||||
|
|
@ -1602,12 +1602,10 @@ class SVGQualityChecker:
|
||||||
x0 = x
|
x0 = x
|
||||||
joined = ''.join(t for t, _ in runs)
|
joined = ''.join(t for t, _ in runs)
|
||||||
label = joined if len(joined) <= 12 else joined[:12] + '…'
|
label = joined if len(joined) <= 12 else joined[:12] + '…'
|
||||||
cjk = (sum(1 for ch in joined if self._est_char_w(ch) == 1.0)
|
|
||||||
/ len(joined) if joined else 0.0)
|
|
||||||
return {
|
return {
|
||||||
'x0': x0 + tx, 'y0': y - 0.76 * fs + ty,
|
'x0': x0 + tx, 'y0': y - 0.76 * fs + ty,
|
||||||
'x1': x0 + w + tx, 'y1': y + 0.22 * fs + ty,
|
'x1': x0 + w + tx, 'y1': y + 0.22 * fs + ty,
|
||||||
'fs': fs, 'label': label, 'cjk': cjk,
|
'fs': fs, 'label': label,
|
||||||
'baseline': y + ty, 'anchor_x': x + tx,
|
'baseline': y + ty, 'anchor_x': x + tx,
|
||||||
'exact_left': anchor not in ('middle', 'end'),
|
'exact_left': anchor not in ('middle', 'end'),
|
||||||
}
|
}
|
||||||
|
|
@ -1787,24 +1785,14 @@ class SVGQualityChecker:
|
||||||
min_area = min((a['x1'] - a['x0']) * (a['y1'] - a['y0']),
|
min_area = min((a['x1'] - a['x0']) * (a['y1'] - a['y0']),
|
||||||
(b['x1'] - b['x0']) * (b['y1'] - b['y0']))
|
(b['x1'] - b['x0']) * (b['y1'] - b['y0']))
|
||||||
ratio = (iw * ih) / min_area if min_area > 0 else 0
|
ratio = (iw * ih) / min_area if min_area > 0 else 0
|
||||||
# Text-text overlaps normally cap at WARNING: the width
|
# Text-text overlaps cap at WARNING: the width estimate can't
|
||||||
# estimate can't tell a crash from a deliberate graze
|
# tell a crash from a deliberate graze (quadrant captions,
|
||||||
# (quadrant captions, word clouds, tightly-kerned
|
# word clouds, tightly-kerned numeral+suffix pairs all overlap
|
||||||
# numeral+suffix pairs all overlap estimated boxes
|
# estimated boxes legitimately). The warning carries exact
|
||||||
# legitimately). The warning carries exact coordinates so the
|
# coordinates so the render-acceptance pass knows which spot
|
||||||
# render-acceptance pass knows which spot to eyeball.
|
# to eyeball; icon-on-text and off-canvas below stay errors
|
||||||
# EXCEPTION — CJK-on-CJK deep overlap is an ERROR: ideograph
|
# because their geometry is exact.
|
||||||
# advance is a fixed 1.0em, so for runs that are mostly CJK
|
if ratio >= 0.15 or same_line:
|
||||||
# the estimate is near-exact and a >=50% mutual overlap is a
|
|
||||||
# real crash, not noise (a shipped deck had a red display
|
|
||||||
# line sitting straight on top of its gray caption).
|
|
||||||
if ratio >= 0.5 and a['cjk'] >= 0.7 and b['cjk'] >= 0.7:
|
|
||||||
errors.append(
|
|
||||||
f"CJK text \"{a['label']}\" and \"{b['label']}\" are stacked "
|
|
||||||
f"on top of each other (~{ratio * 100:.0f}% overlap at "
|
|
||||||
f"({max(a['x0'], b['x0']):.0f},{max(a['y0'], b['y0']):.0f})) "
|
|
||||||
f"— CJK width estimation is near-exact; separate them")
|
|
||||||
elif ratio >= 0.15 or same_line:
|
|
||||||
warnings.append(
|
warnings.append(
|
||||||
f"text \"{a['label']}\" and \"{b['label']}\" overlap "
|
f"text \"{a['label']}\" and \"{b['label']}\" overlap "
|
||||||
f"(~{ratio * 100:.0f}% of the smaller run, around "
|
f"(~{ratio * 100:.0f}% of the smaller run, around "
|
||||||
|
|
@ -1919,34 +1907,25 @@ class SVGQualityChecker:
|
||||||
return abs(a - b) <= max(floor, rel * max(a, b))
|
return abs(a - b) <= max(floor, rel * max(a, b))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _clusters(values, tol):
|
def _cluster_values(values, tol):
|
||||||
"""Cluster sorted scalars; returns list of member lists."""
|
|
||||||
groups = []
|
|
||||||
for v in sorted(values):
|
|
||||||
if groups and v - groups[-1][-1] <= tol:
|
|
||||||
groups[-1].append(v)
|
|
||||||
else:
|
|
||||||
groups.append([v])
|
|
||||||
return groups
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _cluster_values(cls, values, tol):
|
|
||||||
"""Cluster sorted scalars; returns list of cluster-center floats."""
|
"""Cluster sorted scalars; returns list of cluster-center floats."""
|
||||||
return [sum(c) / len(c) for c in cls._clusters(values, tol)]
|
centers = []
|
||||||
|
for v in sorted(values):
|
||||||
|
if centers and v - centers[-1][-1] <= tol:
|
||||||
|
centers[-1].append(v)
|
||||||
|
else:
|
||||||
|
centers.append([v])
|
||||||
|
return [sum(c) / len(c) for c in centers]
|
||||||
|
|
||||||
def _layout_fingerprint(self, cards, icons, texts, graphic_count):
|
def _layout_fingerprint(self, cards, icons):
|
||||||
"""Classify the page's dominant layout archetype, or None.
|
"""Classify the page's dominant grid archetype, or None.
|
||||||
|
|
||||||
icon-grid: >=4 icons arranged in a >=2x2 grid (the icon+title+text
|
icon-grid: >=4 icons arranged in a >=2x2 grid (the icon+title+text
|
||||||
card pattern — usually no visible card rect, so icons carry the
|
card pattern — usually no visible card rect, so icons carry the
|
||||||
structure). card-grid: >=4 similar-sized card rects in a grid, or a
|
structure). card-grid: >=4 similar-sized card rects in a grid, or a
|
||||||
>=4-row single-column stack (full-width list rows). text-columns:
|
>=4-row single-column stack (full-width list rows). Pages without a
|
||||||
bare text lists in >=2 columns with no cards and no figure — the
|
dominant grid (covers, chapters, diagrams, timelines) get None and
|
||||||
"icon header + underline + text list" two-column page that reads as
|
never count toward monotony.
|
||||||
wall-of-text (invisible to the two grid archetypes above; a shipped
|
|
||||||
deck carried FOUR of these). Pages without a dominant archetype
|
|
||||||
(covers, chapters, diagrams, timelines) get None and never count
|
|
||||||
toward monotony.
|
|
||||||
"""
|
"""
|
||||||
if len(icons) >= 4:
|
if len(icons) >= 4:
|
||||||
rows = self._cluster_values([i['y0'] for i in icons], self._CLUSTER_TOL)
|
rows = self._cluster_values([i['y0'] for i in icons], self._CLUSTER_TOL)
|
||||||
|
|
@ -1970,14 +1949,6 @@ class SVGQualityChecker:
|
||||||
and (min(len(rows), len(cols)) >= 2
|
and (min(len(rows), len(cols)) >= 2
|
||||||
or (len(cols) == 1 and len(rows) >= 4)):
|
or (len(cols) == 1 and len(rows) >= 4)):
|
||||||
return ('card-grid', len(rows), len(cols))
|
return ('card-grid', len(rows), len(cols))
|
||||||
# Text-columns: no cards, no figure, few icons — the structure is
|
|
||||||
# bare left-anchored text stacked in columns.
|
|
||||||
if len(cards) <= 1 and len(icons) <= 3 and graphic_count <= 2:
|
|
||||||
xs = [t['x0'] for t in texts if t.get('exact_left')]
|
|
||||||
col_sizes = [len(g) for g in self._clusters(xs, self._CLUSTER_TOL)
|
|
||||||
if len(g) >= 3]
|
|
||||||
if len(col_sizes) >= 2 and sum(col_sizes) >= 8:
|
|
||||||
return ('text-columns', len(col_sizes), 0)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _check_alignment(self, content: str, svg_path: Path, result: Dict) -> None:
|
def _check_alignment(self, content: str, svg_path: Path, result: Dict) -> None:
|
||||||
|
|
@ -2155,45 +2126,6 @@ class SVGQualityChecker:
|
||||||
f"{dev:.0f}px off the locked footer_y={footer_y:.0f}")
|
f"{dev:.0f}px off the locked footer_y={footer_y:.0f}")
|
||||||
break
|
break
|
||||||
|
|
||||||
# 3b. Content spilling into the footer zone. With content_bottom
|
|
||||||
# locked, any non-footer text whose baseline lands between
|
|
||||||
# content_bottom and the canvas edge is content that didn't fit
|
|
||||||
# (a shipped deck had a list item's description clipped by the
|
|
||||||
# footer). Footer runs themselves (baseline ~ footer_y) and page
|
|
||||||
# numbers are exempt.
|
|
||||||
content_bottom = self._f(grid.get('content_bottom'))
|
|
||||||
if content_bottom is not None and not structural:
|
|
||||||
for t in texts:
|
|
||||||
bl = t['baseline']
|
|
||||||
if bl <= content_bottom + self._ALIGN_TOL or bl > canvas_h + 1:
|
|
||||||
continue # in-bounds, or already an off-canvas error
|
|
||||||
if footer_y is not None and abs(bl - footer_y) <= 8:
|
|
||||||
continue # the footer line itself
|
|
||||||
errors.append(
|
|
||||||
f"text \"{t['label']}\" baseline y={bl:.0f} spills past the locked "
|
|
||||||
f"content_bottom={content_bottom:.0f} into the footer zone — the "
|
|
||||||
f"content doesn't fit; cut items or rework the layout")
|
|
||||||
break
|
|
||||||
|
|
||||||
# 3c. Spec-assigned figure enforcement: spec_lock's page_charts says
|
|
||||||
# this page carries a chart, but the SVG draws no figure — the
|
|
||||||
# executor degraded an assigned visualization into text (shipped
|
|
||||||
# failure: a whole deck of quantitative content with zero data
|
|
||||||
# charts). Figure evidence: >=3 drawing primitives, or >=4
|
|
||||||
# card-size rects (bar charts are mostly <rect>).
|
|
||||||
graphic_count = len(re.findall(
|
|
||||||
r'<(?:path|polygon|polyline|circle|image)\b', content))
|
|
||||||
m_num = self._PAGE_NUM_RE.match(svg_path.name)
|
|
||||||
if m_num and lock:
|
|
||||||
charts_map = lock.get('page_charts') or {}
|
|
||||||
assigned = (charts_map.get(f'P{int(m_num.group(1)):02d}') or '').strip()
|
|
||||||
if assigned and graphic_count < 3 and len(cards) < 4:
|
|
||||||
errors.append(
|
|
||||||
f"spec_lock page_charts assigns '{assigned}' to this page but the "
|
|
||||||
f"SVG draws no figure ({graphic_count} drawing primitives, "
|
|
||||||
f"{len(cards)} cards) — the assigned chart was degraded to text; "
|
|
||||||
f"draw it (adapt templates/charts/{assigned}.svg)")
|
|
||||||
|
|
||||||
# 4. Deck aggregation: primary left edge (margin-drift fallback when no
|
# 4. Deck aggregation: primary left edge (margin-drift fallback when no
|
||||||
# layout_grid is locked) + layout-archetype fingerprint (monotony).
|
# layout_grid is locked) + layout-archetype fingerprint (monotony).
|
||||||
# Only numbered deck pages (NN_*.svg) participate — a directory of
|
# Only numbered deck pages (NN_*.svg) participate — a directory of
|
||||||
|
|
@ -2205,7 +2137,7 @@ class SVGQualityChecker:
|
||||||
edge_candidates = [x for x in edge_candidates if 0 < x < 0.25 * canvas_w]
|
edge_candidates = [x for x in edge_candidates if 0 < x < 0.25 * canvas_w]
|
||||||
if len(texts) >= 4 and edge_candidates:
|
if len(texts) >= 4 and edge_candidates:
|
||||||
self._page_left_edges[svg_path.name] = min(edge_candidates)
|
self._page_left_edges[svg_path.name] = min(edge_candidates)
|
||||||
fp = self._layout_fingerprint(cards, icons, texts, graphic_count)
|
fp = self._layout_fingerprint(cards, icons)
|
||||||
if fp:
|
if fp:
|
||||||
self._page_fingerprints[svg_path.name] = fp
|
self._page_fingerprints[svg_path.name] = fp
|
||||||
|
|
||||||
|
|
@ -2259,8 +2191,7 @@ class SVGQualityChecker:
|
||||||
by_family[fp[0]].append(name)
|
by_family[fp[0]].append(name)
|
||||||
fp, members = max(by_fp.items(), key=lambda kv: len(kv[1]))
|
fp, members = max(by_fp.items(), key=lambda kv: len(kv[1]))
|
||||||
n = len(members)
|
n = len(members)
|
||||||
label = (f"{fp[1]}-column bare-text-list" if fp[0] == 'text-columns'
|
label = f"{fp[1]}x{fp[2]} {fp[0]}"
|
||||||
else f"{fp[1]}x{fp[2]} {fp[0]}")
|
|
||||||
_fix = ("Rework all but 1-2 of them into a different visual form — "
|
_fix = ("Rework all but 1-2 of them into a different visual form — "
|
||||||
"timeline / layered architecture / quadrant / process flow / "
|
"timeline / layered architecture / quadrant / process flow / "
|
||||||
"hub-spoke / chart (templates/charts/) — per the content->layout "
|
"hub-spoke / chart (templates/charts/) — per the content->layout "
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@
|
||||||
## layout_grid
|
## layout_grid
|
||||||
- margin_x: 60
|
- margin_x: 60
|
||||||
- content_top: 150
|
- content_top: 150
|
||||||
- content_bottom: 650
|
|
||||||
- footer_y: 688
|
- footer_y: 688
|
||||||
- gutter: 24
|
- gutter: 24
|
||||||
|
|
||||||
|
|
@ -23,7 +22,6 @@
|
||||||
>
|
>
|
||||||
> - `margin_x` — left/right content margin. Header title, content blocks, and footer on content pages all share this left edge.
|
> - `margin_x` — left/right content margin. Header title, content blocks, and footer on content pages all share this left edge.
|
||||||
> - `content_top` — top edge of the body content zone on content pages (below the header band). Structural pages (cover / chapter / TOC / ending) are exempt.
|
> - `content_top` — top edge of the body content zone on content pages (below the header band). Structural pages (cover / chapter / TOC / ending) are exempt.
|
||||||
> - `content_bottom` — bottom edge of the body content zone. Any non-footer text baseline below it is content that didn't fit (checker errors — a shipped deck had a list item's description clipped by the footer). The Executor budgets vertical space against this before writing a page: items × row height must fit content_top..content_bottom, else cut items or change layout.
|
|
||||||
> - `footer_y` — footer baseline. Omit if the deck has no footer.
|
> - `footer_y` — footer baseline. Omit if the deck has no footer.
|
||||||
> - `gutter` — gap between side-by-side sibling cards in a row/grid. One value deck-wide.
|
> - `gutter` — gap between side-by-side sibling cards in a row/grid. One value deck-wide.
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -235,100 +235,6 @@ class DeckAggregationTests(unittest.TestCase):
|
||||||
_, out = self._run_deck(pages)
|
_, out = self._run_deck(pages)
|
||||||
self.assertNotIn("Layout monotony", out)
|
self.assertNotIn("Layout monotony", out)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _text_columns_page() -> str:
|
|
||||||
# "图标小标题+文字列表 ×2 栏"零图形页(d1285247 二代产物 S8/S9/S16/S21 形态)
|
|
||||||
body = ""
|
|
||||||
for col_x in (66, 690):
|
|
||||||
for i in range(5):
|
|
||||||
body += _text(col_x, 300 + 40 * i, f"要点第{i}行文字")
|
|
||||||
return body
|
|
||||||
|
|
||||||
def test_four_bare_text_column_pages_is_error(self):
|
|
||||||
pages = {f"{i:02d}_t.svg": self._text_columns_page() for i in range(1, 5)}
|
|
||||||
pages["05_d.svg"] = self._diagram_page(1)
|
|
||||||
pages["06_e.svg"] = self._diagram_page(2)
|
|
||||||
_, out = self._run_deck(pages)
|
|
||||||
self.assertIn("[ERROR] Layout monotony", out)
|
|
||||||
self.assertIn("bare-text-list", out)
|
|
||||||
|
|
||||||
def test_text_columns_with_diagram_not_fingerprinted(self):
|
|
||||||
# 同样的文字栏但页上有真图形(≥3 基元,如时间轴线+节点)→ 不算裸文字页
|
|
||||||
timeline = ('<path d="M 100 250 L 1100 250" stroke="#C00000" fill="none"/>'
|
|
||||||
'<circle cx="300" cy="250" r="6" fill="#C00000"/>'
|
|
||||||
'<circle cx="700" cy="250" r="6" fill="#C00000"/>')
|
|
||||||
pages = {f"{i:02d}_t.svg": self._text_columns_page() + timeline
|
|
||||||
for i in range(1, 5)}
|
|
||||||
pages["05_d.svg"] = self._diagram_page(5)
|
|
||||||
pages["06_e.svg"] = self._diagram_page(6)
|
|
||||||
_, out = self._run_deck(pages)
|
|
||||||
self.assertNotIn("bare-text-list", out)
|
|
||||||
|
|
||||||
|
|
||||||
class SpecContractTests(unittest.TestCase):
|
|
||||||
"""spec 指派图表落空 + content_bottom 越界 + CJK 叠压升级。"""
|
|
||||||
|
|
||||||
def _check(self, body: str, lock: str, name="02_content.svg"):
|
|
||||||
with TemporaryDirectory() as tmp:
|
|
||||||
project = Path(tmp)
|
|
||||||
(project / "spec_lock.md").write_text(lock, encoding="utf-8")
|
|
||||||
page = _write_page(project, name, body)
|
|
||||||
return SVGQualityChecker().check_file(str(page))
|
|
||||||
|
|
||||||
def test_assigned_chart_degraded_to_text_is_error(self):
|
|
||||||
lock = "## page_charts\n- P02: bar_chart\n"
|
|
||||||
body = "".join(_text(66, 200 + 40 * i, f"目标{i}") for i in range(6))
|
|
||||||
r = self._check(body, lock)
|
|
||||||
errs = _alignment_errors(r)
|
|
||||||
self.assertTrue(any("page_charts" in e and "bar_chart" in e for e in errs),
|
|
||||||
errs)
|
|
||||||
|
|
||||||
def test_assigned_chart_with_figure_passes(self):
|
|
||||||
lock = "## page_charts\n- P02: line_chart\n"
|
|
||||||
body = ('<path d="M 100 400 L 400 300 L 700 350" stroke="#C00000" fill="none"/>'
|
|
||||||
'<circle cx="400" cy="300" r="5" fill="#C00000"/>'
|
|
||||||
'<circle cx="700" cy="350" r="5" fill="#C00000"/>')
|
|
||||||
r = self._check(body, lock)
|
|
||||||
self.assertFalse(any("page_charts" in e for e in _alignment_errors(r)))
|
|
||||||
|
|
||||||
def test_unassigned_text_page_no_chart_error(self):
|
|
||||||
lock = "## page_charts\n- P05: bar_chart\n"
|
|
||||||
body = "".join(_text(66, 200 + 40 * i, f"要点{i}") for i in range(6))
|
|
||||||
r = self._check(body, lock)
|
|
||||||
self.assertFalse(any("page_charts" in e for e in _alignment_errors(r)))
|
|
||||||
|
|
||||||
def test_content_spills_past_content_bottom_is_error(self):
|
|
||||||
lock = "## layout_grid\n- content_bottom: 650\n- footer_y: 686\n"
|
|
||||||
body = _text(66, 676, "被裁掉的描述文字") + _text(66, 686, "页脚")
|
|
||||||
r = self._check(body, lock)
|
|
||||||
errs = _alignment_errors(r)
|
|
||||||
self.assertTrue(any("content_bottom" in e for e in errs), errs)
|
|
||||||
|
|
||||||
def test_footer_text_near_footer_y_exempt(self):
|
|
||||||
lock = "## layout_grid\n- content_bottom: 650\n- footer_y: 686\n"
|
|
||||||
body = _text(66, 686, "页脚文字") + _text(1150, 690, "12 / 25")
|
|
||||||
r = self._check(body, lock)
|
|
||||||
self.assertFalse(any("content_bottom" in e for e in _alignment_errors(r)))
|
|
||||||
|
|
||||||
def test_cjk_deep_overlap_is_error(self):
|
|
||||||
with TemporaryDirectory() as tmp:
|
|
||||||
page = _write_page(
|
|
||||||
Path(tmp), "03_content.svg",
|
|
||||||
'<text x="400" y="500" font-size="22" fill="#C00000">中国陶瓷碳指数之都</text>'
|
|
||||||
'<text x="405" y="503" font-size="14" fill="#666666">不仅是千年瓷都更是权威发布地</text>')
|
|
||||||
r = SVGQualityChecker().check_file(str(page))
|
|
||||||
self.assertTrue(any("Geometry:" in e and "CJK" in e for e in r["errors"]),
|
|
||||||
r["errors"])
|
|
||||||
|
|
||||||
def test_latin_overlap_stays_warning(self):
|
|
||||||
with TemporaryDirectory() as tmp:
|
|
||||||
page = _write_page(
|
|
||||||
Path(tmp), "03_content.svg",
|
|
||||||
'<text x="400" y="500" font-size="22" fill="#C00000">Total Revenue 2027</text>'
|
|
||||||
'<text x="405" y="503" font-size="14" fill="#666666">annual growth rate</text>')
|
|
||||||
r = SVGQualityChecker().check_file(str(page))
|
|
||||||
self.assertFalse(any("CJK" in e for e in r["errors"]))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -138,15 +138,7 @@ class SeedreamTool(Tool):
|
||||||
|
|
||||||
cfg = self.cfg
|
cfg = self.cfg
|
||||||
model_id = cfg["model_id"]
|
model_id = cfg["model_id"]
|
||||||
requested_size = size or cfg.get("default_size", "2048x2048")
|
chosen_size = size or cfg.get("default_size", "2048x2048")
|
||||||
# ARK 硬门:输出面积必须落在 [min_pixels, max_pixels],否则 400 InvalidParameter。
|
|
||||||
# 模型自选 16:9 之类小尺寸(1920x1080=2.07M < 3.69M)会被打回,这里等比钳到合法区间,
|
|
||||||
# 静默纠错省一轮往返;已合规的尺寸原样透传。归一化时给用户一行提示。
|
|
||||||
chosen_size, size_note = self._normalize_size(
|
|
||||||
requested_size,
|
|
||||||
min_pixels=int(cfg.get("min_pixels", 0)),
|
|
||||||
max_pixels=int(cfg.get("max_pixels", 0)),
|
|
||||||
)
|
|
||||||
chosen_watermark = bool(cfg.get("default_watermark", False)) if watermark is None else bool(watermark)
|
chosen_watermark = bool(cfg.get("default_watermark", False)) if watermark is None else bool(watermark)
|
||||||
chosen_search = bool(cfg.get("default_search", False)) if search is None else bool(search)
|
chosen_search = bool(cfg.get("default_search", False)) if search is None else bool(search)
|
||||||
timeout_s = float(cfg.get("request_timeout_s", 60))
|
timeout_s = float(cfg.get("request_timeout_s", 60))
|
||||||
|
|
@ -193,7 +185,6 @@ class SeedreamTool(Tool):
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"model_id": model_id,
|
"model_id": model_id,
|
||||||
"size": chosen_size,
|
"size": chosen_size,
|
||||||
"requested_size": requested_size, # 归一化前模型/用户请求的原始尺寸(=chosen_size 表示未钳)
|
|
||||||
"watermark": chosen_watermark,
|
"watermark": chosen_watermark,
|
||||||
"search": chosen_search,
|
"search": chosen_search,
|
||||||
"mode": "i2i" if is_i2i else "t2i",
|
"mode": "i2i" if is_i2i else "t2i",
|
||||||
|
|
@ -228,75 +219,14 @@ class SeedreamTool(Tool):
|
||||||
# 格式严格 key=value · 分隔,parse 用正则 `key=([^·\n]+)` 抓。
|
# 格式严格 key=value · 分隔,parse 用正则 `key=([^·\n]+)` 抓。
|
||||||
mode_seg = " · mode=i2i" if is_i2i else ""
|
mode_seg = " · mode=i2i" if is_i2i else ""
|
||||||
ref_line = f"\nreference={ref_disp[0]}" if is_i2i else ""
|
ref_line = f"\nreference={ref_disp[0]}" if is_i2i else ""
|
||||||
note_line = f"\n{size_note}" if size_note else ""
|
|
||||||
return (
|
return (
|
||||||
f"[seedream] model={model_id} · size={chosen_size} · "
|
f"[seedream] model={model_id} · size={chosen_size} · "
|
||||||
f"cost=¥{cost_cny:.2f} · elapsed={elapsed:.1f}s{mode_seg}\n"
|
f"cost=¥{cost_cny:.2f} · elapsed={elapsed:.1f}s{mode_seg}\n"
|
||||||
f"saved: {disp}{ref_line}\n"
|
f"saved: {disp}{ref_line}\n"
|
||||||
f"prompt={prompt!r}\n"
|
f"prompt={prompt!r}\n"
|
||||||
f"watermark={chosen_watermark} search={chosen_search}{note_line}"
|
f"watermark={chosen_watermark} search={chosen_search}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_size(
|
|
||||||
requested: str, *, min_pixels: int = 0, max_pixels: int = 0
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
"""把请求尺寸钳进 ARK 面积约束 [min_pixels, max_pixels],保持宽高比。
|
|
||||||
|
|
||||||
返回 (chosen_size, note):note 非空表示发生了钳制(用于提示用户 + 记账用真实尺寸)。
|
|
||||||
- 无法解析成 "WxH" / 任一边 <= 0 → 原样返回,不阻塞(交给 API 自己报错,行为不回退)。
|
|
||||||
- min/max 传 0 → 视为不设该侧约束(向后兼容:旧 yaml 无这两个键时不改变行为)。
|
|
||||||
- 面积 < min:按 s=sqrt(min/area) 等比放大,两边向上取整到 8 的倍数,复核达标(不够再 +8)。
|
|
||||||
- 面积 > max:按 s=sqrt(max/area) 等比缩小,两边向下取整到 8 的倍数,复核达标(超了再 -8)。
|
|
||||||
- 已在区间内 → 原样透传,note 为空。
|
|
||||||
"""
|
|
||||||
raw = (requested or "").strip().lower().replace(" ", "")
|
|
||||||
parts = raw.split("x")
|
|
||||||
if len(parts) != 2:
|
|
||||||
return requested, ""
|
|
||||||
try:
|
|
||||||
w, h = int(parts[0]), int(parts[1])
|
|
||||||
except ValueError:
|
|
||||||
return requested, ""
|
|
||||||
if w <= 0 or h <= 0:
|
|
||||||
return requested, ""
|
|
||||||
|
|
||||||
import math
|
|
||||||
|
|
||||||
def _round8(v: float, *, up: bool) -> int:
|
|
||||||
n = math.ceil(v / 8) if up else math.floor(v / 8)
|
|
||||||
return max(8, n * 8)
|
|
||||||
|
|
||||||
area = w * h
|
|
||||||
if min_pixels > 0 and area < min_pixels:
|
|
||||||
s = math.sqrt(min_pixels / area)
|
|
||||||
nw, nh = _round8(w * s, up=True), _round8(h * s, up=True)
|
|
||||||
# 取整可能把面积压回下限之下,补到达标为止(沿较长边加 8,尽量不破坏比例)
|
|
||||||
while nw * nh < min_pixels:
|
|
||||||
if nw >= nh:
|
|
||||||
nh += 8
|
|
||||||
else:
|
|
||||||
nw += 8
|
|
||||||
chosen = f"{nw}x{nh}"
|
|
||||||
return chosen, (
|
|
||||||
f"[note] 请求尺寸 {w}x{h}({area:,}px)低于模型最小面积 {min_pixels:,}px,"
|
|
||||||
f"已等比放大到 {chosen} 出图。"
|
|
||||||
)
|
|
||||||
if max_pixels > 0 and area > max_pixels:
|
|
||||||
s = math.sqrt(max_pixels / area)
|
|
||||||
nw, nh = _round8(w * s, up=False), _round8(h * s, up=False)
|
|
||||||
while nw * nh > max_pixels:
|
|
||||||
if nw >= nh:
|
|
||||||
nw -= 8
|
|
||||||
else:
|
|
||||||
nh -= 8
|
|
||||||
chosen = f"{nw}x{nh}"
|
|
||||||
return chosen, (
|
|
||||||
f"[note] 请求尺寸 {w}x{h}({area:,}px)超过模型最大面积 {max_pixels:,}px,"
|
|
||||||
f"已等比缩小到 {chosen} 出图。"
|
|
||||||
)
|
|
||||||
return requested, ""
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_url(resp: dict) -> tuple[str, str]:
|
def _extract_url(resp: dict) -> tuple[str, str]:
|
||||||
"""ark images/generations 响应解析,容忍几种已知 shape:
|
"""ark images/generations 响应解析,容忍几种已知 shape:
|
||||||
|
|
|
||||||
|
|
@ -770,48 +770,12 @@ function isCurrentTaskStreaming() {
|
||||||
return !!getLiveRun(state.taskId);
|
return !!getLiveRun(state.taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 直播卡片内文字按「轮次」分段:每段一个 .body,工具调用会关闭当前段,之后的新文字
|
|
||||||
// 在卡片底部另起一段 —— 使流式文字与工具卡按时序穿插、最新文字始终贴在底部可见。
|
|
||||||
// 历史渲染天然按消息分段,直播这样分段后两态结构一致,run 结束 reload 无跳变。
|
|
||||||
function ensureTextSeg(run) {
|
|
||||||
if (run.curSeg) return run.curSeg;
|
|
||||||
const el = document.createElement("div");
|
|
||||||
el.className = "body streaming";
|
|
||||||
run.card.appendChild(el);
|
|
||||||
run.curSeg = { el, acc: "", pending: false };
|
|
||||||
return run.curSeg;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭当前文字段:空占位段(还没吐字就来了工具)直接移除,避免留下「思考中」孤块;
|
|
||||||
// 有内容的段落定稿(去光标 + 代码高亮),之后的新文字会另起新段。
|
|
||||||
function closeTextSeg(run) {
|
|
||||||
const seg = run.curSeg;
|
|
||||||
if (!seg) return;
|
|
||||||
if (!seg.acc) {
|
|
||||||
seg.el.remove();
|
|
||||||
} else {
|
|
||||||
seg.el.classList.remove("streaming");
|
|
||||||
highlightIn(seg.el);
|
|
||||||
}
|
|
||||||
run.curSeg = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createLiveAssistantCard(run) {
|
function createLiveAssistantCard(run) {
|
||||||
const card = document.createElement("div");
|
const card = document.createElement("div");
|
||||||
card.className = "msg assistant live-run";
|
card.className = "msg assistant live-run";
|
||||||
card.innerHTML = `<div class="role">助手</div>`;
|
card.innerHTML = `<div class="role">助手</div><div class="body streaming">${run.acc ? renderMd(run.acc) : ""}</div>`;
|
||||||
run.card = card;
|
run.card = card;
|
||||||
run.curSeg = null;
|
run.body = card.querySelector(".body");
|
||||||
if (run.acc) {
|
|
||||||
// 重连:已累积文字作为初始(仍打开的)文字段渲染,后续事件在其后穿插
|
|
||||||
const el = document.createElement("div");
|
|
||||||
el.className = "body streaming";
|
|
||||||
el.innerHTML = renderMd(run.acc);
|
|
||||||
card.appendChild(el);
|
|
||||||
run.curSeg = { el, acc: run.acc, pending: false };
|
|
||||||
} else {
|
|
||||||
ensureTextSeg(run); // 空占位段:首字到达前显示「思考中」
|
|
||||||
}
|
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -822,9 +786,8 @@ function renderLiveRunIfVisible() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const wrap = $("chat-stream");
|
const wrap = $("chat-stream");
|
||||||
// card 已持有全部文字段/工具卡 DOM(切走再切回只需重新挂载,不重渲);
|
|
||||||
// 新建的重连 card 由 createLiveAssistantCard 自行渲染已累积文字。
|
|
||||||
const card = run.card || createLiveAssistantCard(run);
|
const card = run.card || createLiveAssistantCard(run);
|
||||||
|
if (run.body && run.acc) run.body.innerHTML = renderMd(run.acc);
|
||||||
renderTaskProgressDock(run.progressSteps || []);
|
renderTaskProgressDock(run.progressSteps || []);
|
||||||
if (card.parentElement !== wrap) wrap.appendChild(card);
|
if (card.parentElement !== wrap) wrap.appendChild(card);
|
||||||
wrap.scrollTop = wrap.scrollHeight;
|
wrap.scrollTop = wrap.scrollHeight;
|
||||||
|
|
@ -838,10 +801,11 @@ function ensureRunningTaskSubscribed(taskId, url) {
|
||||||
taskId,
|
taskId,
|
||||||
url,
|
url,
|
||||||
acc: "",
|
acc: "",
|
||||||
|
pending: false,
|
||||||
seenRels: new Set(),
|
seenRels: new Set(),
|
||||||
terminal: false,
|
terminal: false,
|
||||||
card: null,
|
card: null,
|
||||||
curSeg: null,
|
body: null,
|
||||||
cancelling: state.taskMeta && state.taskMeta.run_status === "cancelling",
|
cancelling: state.taskMeta && state.taskMeta.run_status === "cancelling",
|
||||||
workingDir: state.taskMeta && state.taskMeta.working_dir,
|
workingDir: state.taskMeta && state.taskMeta.working_dir,
|
||||||
progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)),
|
progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)),
|
||||||
|
|
@ -1385,16 +1349,15 @@ async function sendMessage(overrideText) {
|
||||||
taskId,
|
taskId,
|
||||||
url: r.events_url,
|
url: r.events_url,
|
||||||
acc: "",
|
acc: "",
|
||||||
|
pending: false,
|
||||||
seenRels: new Set(),
|
seenRels: new Set(),
|
||||||
terminal: false,
|
terminal: false,
|
||||||
card: asstCard,
|
card: asstCard,
|
||||||
curSeg: null,
|
body: asstCard.querySelector(".body"),
|
||||||
cancelling: false,
|
cancelling: false,
|
||||||
workingDir: state.taskMeta && state.taskMeta.working_dir,
|
workingDir: state.taskMeta && state.taskMeta.working_dir,
|
||||||
progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)),
|
progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)),
|
||||||
};
|
};
|
||||||
// 预建的空占位 .body 即首个文字段(首字到达前显示「思考中」)
|
|
||||||
run.curSeg = { el: asstCard.querySelector(".body"), acc: "", pending: false };
|
|
||||||
state.liveRuns.set(taskId, run);
|
state.liveRuns.set(taskId, run);
|
||||||
state.streaming = true;
|
state.streaming = true;
|
||||||
setActionMode("streaming");
|
setActionMode("streaming");
|
||||||
|
|
@ -1452,6 +1415,8 @@ function appendRunError(run, msg) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchSse(url, run) {
|
async function fetchSse(url, run) {
|
||||||
|
const body = run.body || (run.card && run.card.querySelector(".body"));
|
||||||
|
run.body = body;
|
||||||
const ctx = run;
|
const ctx = run;
|
||||||
const hint = $("chat-hint");
|
const hint = $("chat-hint");
|
||||||
// 重连:reader 异常 / 自然 EOF 但未收到 done/error 时,GET events 重订阅。
|
// 重连:reader 异常 / 自然 EOF 但未收到 done/error 时,GET events 重订阅。
|
||||||
|
|
@ -1485,15 +1450,13 @@ async function fetchSse(url, run) {
|
||||||
await new Promise(r => setTimeout(r, backoffs[attempt]));
|
await new Promise(r => setTimeout(r, backoffs[attempt]));
|
||||||
attempt++;
|
attempt++;
|
||||||
}
|
}
|
||||||
// 最终定稿 + 代码高亮(流式中不 highlight,省 CPU):定稿当前打开的文字段,
|
// 最终定稿 + 代码高亮(流式中不 highlight,省 CPU)
|
||||||
// 已被工具关闭的历史段在 closeTextSeg 时已定稿 + 高亮。
|
if (ctx.body) {
|
||||||
if (ctx.curSeg && ctx.curSeg.el) {
|
ctx.body.innerHTML = renderMd(ctx.acc);
|
||||||
if (ctx.curSeg.acc) ctx.curSeg.el.innerHTML = renderMd(ctx.curSeg.acc);
|
if (ctx.card) highlightIn(ctx.card);
|
||||||
else ctx.curSeg.el.remove(); // 收尾时仍是空占位段 → 移除
|
|
||||||
}
|
}
|
||||||
if (ctx.card) highlightIn(ctx.card);
|
|
||||||
} finally {
|
} finally {
|
||||||
if (ctx.card) ctx.card.querySelectorAll(".body.streaming").forEach((b) => b.classList.remove("streaming"));
|
if (ctx.body) ctx.body.classList.remove("streaming");
|
||||||
state.liveRuns.delete(ctx.taskId);
|
state.liveRuns.delete(ctx.taskId);
|
||||||
state.streaming = state.liveRuns.size > 0;
|
state.streaming = state.liveRuns.size > 0;
|
||||||
if (state.taskId === ctx.taskId) {
|
if (state.taskId === ctx.taskId) {
|
||||||
|
|
@ -1593,6 +1556,7 @@ function handleSseEvent(ev, asstCard, ctx) {
|
||||||
const t = ev.event;
|
const t = ev.event;
|
||||||
asstCard = asstCard || ctx.card || createLiveAssistantCard(ctx);
|
asstCard = asstCard || ctx.card || createLiveAssistantCard(ctx);
|
||||||
ctx.card = asstCard;
|
ctx.card = asstCard;
|
||||||
|
ctx.body = ctx.body || asstCard.querySelector(".body");
|
||||||
const stream = $("chat-stream");
|
const stream = $("chat-stream");
|
||||||
const visible = state.taskId === ctx.taskId;
|
const visible = state.taskId === ctx.taskId;
|
||||||
// 用户拖到上面看历史时不抢滚动,只在贴底时跟流
|
// 用户拖到上面看历史时不抢滚动,只在贴底时跟流
|
||||||
|
|
@ -1605,15 +1569,12 @@ function handleSseEvent(ev, asstCard, ctx) {
|
||||||
setRunHint(ctx, ctx.lastUsageHint);
|
setRunHint(ctx, ctx.lastUsageHint);
|
||||||
} else if (t === "text" && ev.data && ev.data.delta) {
|
} else if (t === "text" && ev.data && ev.data.delta) {
|
||||||
ctx.acc += ev.data.delta;
|
ctx.acc += ev.data.delta;
|
||||||
const seg = ensureTextSeg(ctx); // 无打开文字段则在卡片底部另起一段
|
// rAF 节流:每帧最多 1 次重渲染,流式 token 高频时不抖
|
||||||
seg.acc += ev.data.delta;
|
if (!ctx.pending) {
|
||||||
// rAF 节流:每帧最多 1 次重渲染,流式 token 高频时不抖。闭包捕获 seg —— 若 rAF
|
ctx.pending = true;
|
||||||
// 触发前来了工具调用把当前段关掉,仍渲染这一段自己的累积文本,不会错渲到别的段。
|
|
||||||
if (!seg.pending) {
|
|
||||||
seg.pending = true;
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
seg.el.innerHTML = renderMd(seg.acc);
|
ctx.body.innerHTML = renderMd(ctx.acc);
|
||||||
seg.pending = false;
|
ctx.pending = false;
|
||||||
if (nearBottom) stream.scrollTop = stream.scrollHeight;
|
if (nearBottom) stream.scrollTop = stream.scrollHeight;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1624,9 +1585,8 @@ function handleSseEvent(ev, asstCard, ctx) {
|
||||||
if (fn === "task_progress") {
|
if (fn === "task_progress") {
|
||||||
ctx.progressSteps = applyProgressAction(ctx.progressSteps || [], args);
|
ctx.progressSteps = applyProgressAction(ctx.progressSteps || [], args);
|
||||||
setTaskProgress(ctx.taskId, ctx.progressSteps);
|
setTaskProgress(ctx.taskId, ctx.progressSteps);
|
||||||
return; // 进度是隐形动作,不落可见卡 → 不打断当前文字段
|
return;
|
||||||
}
|
}
|
||||||
closeTextSeg(ctx); // 关闭当前文字段:工具/选项卡追加到其下方,之后新文字另起底部段
|
|
||||||
if (fn === "ask_user") {
|
if (fn === "ask_user") {
|
||||||
asstCard.appendChild(buildAskUserCard(args, { interactive: true }));
|
asstCard.appendChild(buildAskUserCard(args, { interactive: true }));
|
||||||
if (nearBottom) stream.scrollTop = stream.scrollHeight;
|
if (nearBottom) stream.scrollTop = stream.scrollHeight;
|
||||||
|
|
@ -1654,7 +1614,6 @@ function handleSseEvent(ev, asstCard, ctx) {
|
||||||
const toolName = (ev.data && ev.data.name) || "";
|
const toolName = (ev.data && ev.data.name) || "";
|
||||||
if (toolName === "task_progress") return;
|
if (toolName === "task_progress") return;
|
||||||
if (toolName === "ask_user") return; // 选项卡已在 tool_call 阶段渲染,结果是占位不展示
|
if (toolName === "ask_user") return; // 选项卡已在 tool_call 阶段渲染,结果是占位不展示
|
||||||
closeTextSeg(ctx); // 结果卡追加到当前文字段之下,之后新文字另起底部段
|
|
||||||
const banner = extractMediaBanner(toolName, txtStr);
|
const banner = extractMediaBanner(toolName, txtStr);
|
||||||
const det = document.createElement("details");
|
const det = document.createElement("details");
|
||||||
det.className = "tool-call";
|
det.className = "tool-call";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue