Compare commits

..

2 Commits

Author SHA1 Message Date
caoqianming 9646f571f3 feat(diag): 加 glm.pro52 空响应定层探针(交生产跑)(bump 0.58.48)
失败面板 empty 簇(近7天15)主角从 opus48 换成 glm.pro52(11/15):assistant 轮
既无 tool_calls 又无正文,被 loop 当正常收尾静默 done。要判 glm 是①真吐空、
②工具意图漏成正文(narrated)、还是③瞬态。

scripts/diag_glm_empty_probe.py:build_agent(resume=True) 复原真实 system prompt +
全工具 schema + 完整多轮上下文(空响应发生在深层,单条 user 消息复现不了 —— 与
2a1bc25d narrated 探针的关键差别),打 glm.pro52,stream/non-stream 各一发,
三态判定 EMPTY/NARRATED/STRUCTURED。默认锁 task 35744bea(近7天 empty 5 次
最可复现),argv 可换 task/user/档做对照。

本机 import litellm 卡 ~20min,只能交生产机跑:
  .venv/Scripts/python.exe scripts/diag_glm_empty_probe.py
输出写 scripts/_glm_empty_probe_*.txt。定层后再决处置。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 16:10:51 +08:00
caoqianming 795161adcd fix(ppt,tools): svg 质量门末尾出 [GATE FAIL] 汇总行——失败签名从误标"Font issues"变诚实(bump 0.58.47)
工具失败面板 shell/exit 第1簇「Font issues x20」是误标:svg_quality_checker
按设计 exit-1(质量门),但 core/toolfail 取 [exit] 前尾行当签名,而尾行永远是
通用 tip「4. Font issues:…」→ 所有门失败(typography/alignment/spec_lock drift)
全塌成一条"Font issues",误导排查方向。多文件门也没有可靠尾行(最后文件可能只有 WARN)。

修在工具侧(toolfail 保持通用零改动):
- svg_quality_checker.gate_verdict_line():errors>0 时在 main() 末尾(export 之后)
  多打一行确定性汇总 [GATE FAIL] svg_quality_checker: N error file(s) / M total;
  top issues: … 作为最后一行 stdout,toolfail 现有取尾行逻辑自动拾取诚实签名,
  模型也多一行有用汇总。
- _categorize_issue 加 spec_lock/typography 类,避免真错(typography px)落 "Other"。
- 补 GateVerdictLineTests(3):无 error 不出行 / [GATE FAIL] 打头带 top issues /
  是最后一行 stdout。56 测试全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 15:59:09 +08:00
5 changed files with 210 additions and 3 deletions

View File

@ -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-20(文件预览背景撤回毛玻璃、改回半透明压暗遮罩,bump 0.58.46) 最后更新:2026-07-20(加 glm.pro52 空响应定层探针,交生产跑,bump 0.58.48)
--- ---
@ -23,6 +23,8 @@
### 2026-07 ### 2026-07
- **07-20 / 0.58.48 / glm.pro52 空响应定层探针**:失败面板 empty 簇(15)主角从 opus48 换成 `glm.pro52`(11/15)。加 `scripts/diag_glm_empty_probe.py`——`build_agent(resume=True)` 复原真实 system prompt + 全工具 schema + **完整多轮上下文**(空响应发生在深层,单条 user 消息复现不了,这是与 2a1bc25d narrated 探针的关键差别),打 glm.pro52,三态判定 EMPTY(真吐空)/NARRATED(工具意图漏成正文)/STRUCTURED。默认锁 task 35744bea(近7天 empty 5 次最可复现)。本机 import litellm 卡 ~20min,交生产机 `.venv/Scripts/python.exe scripts/diag_glm_empty_probe.py` 跑,输出写 `scripts/_glm_empty_probe_*.txt`。定层后再决处置。
- **07-20 / 0.58.47 / 工具失败面板 6 簇巡检 + svg 质量门签名诚实化**:扫近7天失败聚集逐簇定根因。①**write/edit malformed(45/32)**=100% DeepSeek v4 wire 把并行 tool_call 的 arguments 拼成 `{...}{...}`,salvage(0.58.24)复盘提前到 07-20:命中率稳 ~85%(post-launch salvaged 252/malformed 44)、残差涉事 task 全在跑无终态失败=落非流式重试自愈,**决策保持现状不再写代码**(面板绝对数被上线前 07-13/14 尾巴撑高,要看 salvaged/malformed 比)。②**shell/exit "Font issues"(20)**=`svg_quality_checker` 质量门按设计 exit-1,但 `toolfail._classify``[exit]` 前尾行当签名,而尾行永远是通用 tip「4. Font issues:…」→ 所有门失败(typography/alignment/spec_lock drift)误标成 Font issues 误导排查。修:checker 在 errors>0 时末尾多打确定性 `gate_verdict_line()`「[GATE FAIL] svg_quality_checker: N error file(s) / M total; top issues: …」作为最后一行 stdout(放 main() 里 export 之后),toolfail 现有取尾行逻辑自动拾取诚实签名、toolfail 零改动;`_categorize_issue` 加 `spec_lock/typography` 类避免真错落 "Other"。补 3 测试(GateVerdictLineTests),39+3 全绿。③**run_python 预检(31)**=pysyntax 按设计拦中文正文硬拼进 .py(引号/缩进崩),非平台 bug=提示词治理待办。④**empty(15)**=主角换成 `glm.pro52`(11/15,非旧 opus48 案),需生产跑 narrated 探针定层(本机 import litellm 卡,task e7c2f50b 备用)。⑤**web_fetch(6)**=外部 403 拒抓为主,非平台锅。
- **07-20 / 0.58.45 / glob 绝对路径崩溃(工具失败聚集 #2)**:巡检近7天工具失败聚集,`glob` 排第2(3次/3task/3用户、近24h 仍在发)。根因:`tools/fs.py` GlobTool 直接 `base.glob(pattern)`,模型传绝对 pattern(沙箱 cwd 就是 `/workspace`,自然写 `/workspace/**/*.py`)时 pathlib 抛 `NotImplementedError: Non-relative patterns are unsupported`,整个调用崩返 `[Error executing glob]`。改:绝对 pattern 先 `_split_abs_glob` 拆成「存在的静态前缀目录 + 相对 glob 串」(左起吃掉不含 `*?[` 的组件当 base)再匹配;补首字符 `/`\`\\` 判定(POSIX 前导斜杠在 Windows 宿主 `is_absolute()` 为 False,沙箱是 Linux,两处对齐)。顺手删掉原 `if "**"`/`else` 两分支代码完全相同的死重构。`/sandbox/tools/` 是 Dockerfile `COPY tools/` 拷贝,**需重建沙箱镜像**才生效。 - **07-20 / 0.58.45 / glob 绝对路径崩溃(工具失败聚集 #2)**:巡检近7天工具失败聚集,`glob` 排第2(3次/3task/3用户、近24h 仍在发)。根因:`tools/fs.py` GlobTool 直接 `base.glob(pattern)`,模型传绝对 pattern(沙箱 cwd 就是 `/workspace`,自然写 `/workspace/**/*.py`)时 pathlib 抛 `NotImplementedError: Non-relative patterns are unsupported`,整个调用崩返 `[Error executing glob]`。改:绝对 pattern 先 `_split_abs_glob` 拆成「存在的静态前缀目录 + 相对 glob 串」(左起吃掉不含 `*?[` 的组件当 base)再匹配;补首字符 `/`\`\\` 判定(POSIX 前导斜杠在 Windows 宿主 `is_absolute()` 为 False,沙箱是 Linux,两处对齐)。顺手删掉原 `if "**"`/`else` 两分支代码完全相同的死重构。`/sandbox/tools/` 是 Dockerfile `COPY tools/` 拷贝,**需重建沙箱镜像**才生效。
- **07-20 / 0.58.45 / PPT 页脚漂移治本(工具失败聚集 #1,A+C)**:`shell/exit` 排第1(近7天16条 exit-1、近24h 6条,9task/7用户),实测拆解 15/16 是**逐页手写绝对坐标偏离 `spec_lock.layout_grid` 锁定值 216px 的「想对齐没对齐」**(footer_y 近失 27行为最大头,margin_x/content_top 各7行,content_bottom 溢出12行),字体/spec_lock 只是 WARN 不 fail。关键发现:`executor-base.md §180-185` 早有「HARD—checker-enforced」的 snap 纪律却仍被漂移,加 prose 无用,治本须机械化。**A**:spec_lock_reference 新增 `## footer` 锁定片段——x/y 用 `margin_x`/`footer_y` 预填好、页码 `text-anchor="end"``canvas_wmargin_x`、留 `{PAGE}` 占位,executor 每页**逐字粘贴只换页码**,把 footer_y 这个跨页漂移第一大头从「每页手算」收敛成「一次编写」(executor-base 加对应硬规则,SKILL.md 同步);冒烟验证锁定片段过质检 errors=0、无 footer_y/margin_x 报错。**C**:`svg_quality_checker._ALIGN_TOL` 2→3px(2px 对手写实测过严),同步刷 SKILL/spec_lock_reference/executor-base 的「215px/212px」表述与测试值(margin drift 用例 60/63/66→60/64/68,30 测试全绿)。content_bottom 溢出(装太多项,另一性质)与 grid-snap 后处理(B)本轮未做。 - **07-20 / 0.58.45 / PPT 页脚漂移治本(工具失败聚集 #1,A+C)**:`shell/exit` 排第1(近7天16条 exit-1、近24h 6条,9task/7用户),实测拆解 15/16 是**逐页手写绝对坐标偏离 `spec_lock.layout_grid` 锁定值 216px 的「想对齐没对齐」**(footer_y 近失 27行为最大头,margin_x/content_top 各7行,content_bottom 溢出12行),字体/spec_lock 只是 WARN 不 fail。关键发现:`executor-base.md §180-185` 早有「HARD—checker-enforced」的 snap 纪律却仍被漂移,加 prose 无用,治本须机械化。**A**:spec_lock_reference 新增 `## footer` 锁定片段——x/y 用 `margin_x`/`footer_y` 预填好、页码 `text-anchor="end"``canvas_wmargin_x`、留 `{PAGE}` 占位,executor 每页**逐字粘贴只换页码**,把 footer_y 这个跨页漂移第一大头从「每页手算」收敛成「一次编写」(executor-base 加对应硬规则,SKILL.md 同步);冒烟验证锁定片段过质检 errors=0、无 footer_y/margin_x 报错。**C**:`svg_quality_checker._ALIGN_TOL` 2→3px(2px 对手写实测过严),同步刷 SKILL/spec_lock_reference/executor-base 的「215px/212px」表述与测试值(margin drift 用例 60/63/66→60/64/68,30 测试全绿)。content_bottom 溢出(装太多项,另一性质)与 grid-snap 后处理(B)本轮未做。
- **07-19 / 0.58.43**:文件预览遮罩改整屏、消除"上暗下亮"割裂——旧实现用 `#file-preview-modal { bottom: var(--preview-bottom-inset) }` 把遮罩顶到 chat-form 之上,好让预览时能边看边打字,代价是底部整条(输入区+左栏入口+右栏+状态)全不压暗,与上方压暗区形成一条硬边界的通亮带,很割裂。改为遮罩铺满整屏(`bottom`→`padding-bottom`,仍让居中卡片停在输入区之上不遮挡),底部随整屏统一压暗;仅 chat-form 由新 `body.fp-open``position:relative;z-index:95`(压过遮罩 z90,层叠上下文已核:`#app`/`#pane-mid` 均不建 context)+ 上向阴影,上浮成"悬浮活动条",继续可打字。预览时左栏/右栏/状态点击落到遮罩=关预览(符合"背景不可用"预期)。改 `web/static/dev.html` + `web/static/js/preview.js`(open/close 加/去 `fp-open`)。 - **07-19 / 0.58.43**:文件预览遮罩改整屏、消除"上暗下亮"割裂——旧实现用 `#file-preview-modal { bottom: var(--preview-bottom-inset) }` 把遮罩顶到 chat-form 之上,好让预览时能边看边打字,代价是底部整条(输入区+左栏入口+右栏+状态)全不压暗,与上方压暗区形成一条硬边界的通亮带,很割裂。改为遮罩铺满整屏(`bottom`→`padding-bottom`,仍让居中卡片停在输入区之上不遮挡),底部随整屏统一压暗;仅 chat-form 由新 `body.fp-open``position:relative;z-index:95`(压过遮罩 z90,层叠上下文已核:`#app`/`#pane-mid` 均不建 context)+ 上向阴影,上浮成"悬浮活动条",继续可打字。预览时左栏/右栏/状态点击落到遮罩=关预览(符合"背景不可用"预期)。改 `web/static/dev.html` + `web/static/js/preview.js`(open/close 加/去 `fp-open`)。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.58.46" __version__ = "0.58.48"

View File

@ -0,0 +1,134 @@
"""定层 glm.pro52 空响应(失败面板 kind=empty_response 主角,11/15/近7天)。
背景:assistant 轮既无 tool_calls 又无正文, run loop 当正常收尾静默 done
opus48 tool_use 漏成正文(narrated, diag_narrated_toolcall_2a1bc25d)
是不同现象要判 glm 到底是①真吐空(tc=0 content=0)漏成正文(narrated)
还是③正常(能回结构化 tool_calls,线上那次只是瞬态)
做法:build_agent(resume=True) 复原真实 system prompt + 全套工具 schema + **完整多轮
上下文**(空响应发生在深层,单条 user 消息复现不了 这是与 2a1bc25d 探针的关键差别),
直接打 glm.pro52,stream / non-stream 各打一发只读 DB,不跑 loop 不写库
输出 ASCII(Windows GBK 安全),完整正文写文件
用法(生产机跑,本机 import litellm ~20min):
.venv/Scripts/python.exe scripts/diag_glm_empty_probe.py [task_id] [user_id] [model_profile]
默认 = 35744bea(近7天 empty 5 最可复现)model_profile 缺省用 task 自身档(glm.pro52),
传第三参可换档做对照( deepseek_v4.pro 看是否 glm 独有)
"""
import os
import sys
from pathlib import Path
from uuid import UUID
ROOT = Path(__file__).resolve().parent.parent
env = ROOT / ".env"
if env.exists():
for line in env.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True")
sys.path.insert(0, str(ROOT))
import litellm # noqa: E402
from core.agent_builder import build_agent # noqa: E402
# 默认锁定 35744bea(近7天 empty 5 次、最可复现);可 argv 覆盖。
TASK_ID = sys.argv[1] if len(sys.argv) > 1 else "35744bea-49c0-41c8-86be-8d0c5c05bd32"
USER_ID = UUID(sys.argv[2]) if len(sys.argv) > 2 else UUID("3cc07a22-f667-47f3-9617-e7d8a1a60cf0")
MODEL_OVERRIDE = sys.argv[3] if len(sys.argv) > 3 else None
_tag = (MODEL_OVERRIDE or "self").replace(".", "_").replace("/", "_")
OUT = open(ROOT / "scripts" / f"_glm_empty_probe_{TASK_ID[:8]}_{_tag}.txt", "w", encoding="utf-8")
def w(*a):
print(*a, file=OUT)
agent, session, sid, tstate, wd = build_agent(
user_id=USER_ID, session_id=TASK_ID, resume=True,
model_name=MODEL_OVERRIDE,
)
schemas = agent.executor.schemas()
caps = agent.caps
llm = agent.llm
# 复原 loop 下一轮真会送的完整消息(system + 全部历史)—— 空响应就发生在这个上下文上。
messages = list(session.messages)
sys_txt = next((m["content"] for m in messages if m.get("role") == "system"), "")
roles = [m.get("role") for m in messages]
w("[SETUP]")
w(f" task={TASK_ID} model_id={caps.model_id} profile={caps.family}.{caps.variant}")
w(f" api_base={llm.api_base} temp={caps.optimal_temperature} "
f"parallel_tools={caps.parallel_tools}")
w(f" system_prompt chars={len(sys_txt)} tools={len(schemas)}")
w(f" messages count={len(messages)} last_role={roles[-1] if roles else None}")
w(f" role tail={roles[-6:]}")
w("")
kwargs_base = dict(
model=caps.model_id,
api_base=llm.api_base,
api_key=llm.api_key,
messages=messages,
tools=schemas,
temperature=caps.optimal_temperature,
timeout=300,
)
if caps.parallel_tools:
kwargs_base["parallel_tool_calls"] = True
stdout_lines = []
def probe(tag, stream):
w(f"========== {tag} ==========")
try:
if stream:
chunks = list(litellm.completion(
**kwargs_base, stream=True,
stream_options={"include_usage": True}))
resp = litellm.stream_chunk_builder(chunks)
else:
resp = litellm.completion(**kwargs_base)
except Exception as e:
w(f"[FAIL] {type(e).__name__}: {str(e)[:300]}")
stdout_lines.append(f"{tag}: FAIL {type(e).__name__}")
return
msg = resp.choices[0].message
tc = msg.tool_calls or []
content = msg.content or ""
fr = resp.choices[0].finish_reason
u = resp.usage
w(f" finish_reason={fr}")
w(f" usage prompt={u.prompt_tokens} completion={u.completion_tokens}")
w(f" tool_calls count={len(tc)}")
for i, t in enumerate(tc):
w(f" [{i}] {t.function.name}({(t.function.arguments or '')[:200]})")
w(f" content chars={len(content)}")
w(" content:")
w(content[:2000])
w("")
# 三态判定:EMPTY(真吐空)/ NARRATED(工具意图漏成正文)/ STRUCTURED / plain-text
narrated = (not tc) and ("`" in content) and ("\n- " in content or "No result received" in content)
if tc:
verdict = "STRUCTURED tool_calls"
elif not content.strip():
verdict = "EMPTY (tc=0 content=0) <-- 复现空响应"
elif narrated:
verdict = "NARRATED-as-text"
else:
verdict = "plain-text-no-tools"
stdout_lines.append(f"{tag}: {verdict} (finish={fr}, tc={len(tc)}, content={len(content)}c)")
probe("NON-STREAM", stream=False)
probe("STREAM", stream=True)
OUT.close()
for ln in stdout_lines:
print(ln)
print(f"full -> scripts/_glm_empty_probe_{TASK_ID[:8]}_{_tag}.txt")

View File

@ -20,7 +20,7 @@ import re
import json import json
import html import html
from pathlib import Path from pathlib import Path
from typing import List, Dict, Tuple from typing import List, Dict, Tuple, Optional
from collections import Counter, defaultdict from collections import Counter, defaultdict
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
@ -1095,6 +1095,8 @@ class SVGQualityChecker:
return 'Alignment/grid' return 'Alignment/grid'
elif error_msg.startswith('Geometry:'): elif error_msg.startswith('Geometry:'):
return 'Geometry' return 'Geometry'
elif 'spec_lock' in error_msg or 'typography' in error_msg:
return 'spec_lock/typography'
elif 'font' in error_msg.lower(): elif 'font' in error_msg.lower():
return 'Font issues' return 'Font issues'
else: else:
@ -2325,6 +2327,26 @@ class SVGQualityChecker:
"(<path>/<polygon>/<polyline>/<circle>/<image>) — verify dense content " "(<path>/<polygon>/<polyline>/<circle>/<image>) — verify dense content "
"pages aren't just text + boxes.") "pages aren't just text + boxes.")
def gate_verdict_line(self) -> Optional[str]:
"""One deterministic line summarizing the gate outcome, meant to be the
LAST stdout line on failure.
Failure-cluster tooling (core/toolfail) signatures a non-zero shell exit
by the last content line before ``[exit N]``. Without this, that line is
always the generic trailing tip ``4. Font issues: ...`` so every kind
of quality-gate failure (typography px, alignment drift, spec_lock, ...)
collapses onto one misleading "Font issues" cluster. This gives it a
stable, honest signature instead. ``None`` when there are no errors.
"""
if self.summary['errors'] <= 0:
return None
top = sorted(self.issue_types.items(), key=lambda kv: (-kv[1], kv[0]))[:3]
cats = ", ".join(k for k, _ in top) or "unspecified"
return (
f"[GATE FAIL] svg_quality_checker: {self.summary['errors']} error "
f"file(s) / {self.summary['total']} total; top issues: {cats}"
)
def print_summary(self): def print_summary(self):
"""Print check summary""" """Print check summary"""
print("=" * 80) print("=" * 80)
@ -2576,6 +2598,12 @@ def main() -> None:
output_file = sys.argv[idx + 1] output_file = sys.argv[idx + 1]
checker.export_report(output_file) checker.export_report(output_file)
# Deterministic gate verdict as the LAST stdout line on failure (after any
# --export [REPORT] line) so core/toolfail signatures this cluster honestly.
verdict = checker.gate_verdict_line()
if verdict:
print(verdict)
# Return exit code # Return exit code
if checker.summary['errors'] > 0: if checker.summary['errors'] > 0:
sys.exit(1) sys.exit(1)

View File

@ -331,5 +331,48 @@ class SpecContractTests(unittest.TestCase):
self.assertFalse(any("CJK" in e for e in r["errors"])) self.assertFalse(any("CJK" in e for e in r["errors"]))
class GateVerdictLineTests(unittest.TestCase):
"""gate_verdict_line() 给 core/toolfail 一条稳定诚实的失败签名,
替掉[exit] 前最后一行永远是通用 tip 全塌成 Font issues的误标"""
def _checker(self, body: str):
with TemporaryDirectory() as tmp:
page = _write_page(Path(tmp), "01_cover.svg", body)
checker = SVGQualityChecker()
checker.check_file(str(page))
return checker
def test_none_when_no_errors(self):
# 干净页面(无 error)不出 gate 行 —— exit 0 本就不进失败面板
c = self._checker(_text(60, 100, "干净标题"))
self.assertEqual(c.summary["errors"], 0)
self.assertIsNone(c.gate_verdict_line())
def test_gate_line_on_error(self):
# foreignObject 是 error;gate 行须以 [GATE FAIL] svg_quality_checker 打头,
# 带文件计数与 top issues —— toolfail 归一后是诚实签名而非 "Font issues"
c = self._checker('<foreignObject x="0" y="0" width="9" height="9"><b/></foreignObject>')
self.assertGreater(c.summary["errors"], 0)
line = c.gate_verdict_line()
self.assertIsNotNone(line)
self.assertTrue(line.startswith("[GATE FAIL] svg_quality_checker:"))
self.assertIn("top issues:", line)
self.assertIn("foreignObject", line)
def test_gate_line_is_last_stdout_line(self):
# 契约:gate 行是 print_summary 之后的最后一行 stdout(toolfail 取尾行)
import contextlib
import io
c = self._checker('<foreignObject x="0" y="0" width="9" height="9"><b/></foreignObject>')
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
c.print_summary()
line = c.gate_verdict_line()
if line:
print(line)
last = [ln for ln in buf.getvalue().splitlines() if ln.strip()][-1]
self.assertTrue(last.startswith("[GATE FAIL] svg_quality_checker:"))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()