diff --git a/PROGRESS.md b/PROGRESS.md index 2033a2a..3bcc2bb 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -23,6 +23,7 @@ ### 2026-07 +- **07-13 / 0.58.14**:**toolfail 聚集加时间分布,活跃/已安静一眼分**(0.58.11 的可观测性补强):此前聚集只有全窗口累计 count+last_at——修掉的问题旧记录仍挂满 7 天窗口,和"还在发生"的没视觉区别,只能人肉盯"最近时间"是否前进。`core/toolfail.py` 聚合时加 `daily`(从 now 往回按 24h 分桶,旧→新,非日历日)/ `count_24h`(=尾桶)/ `first_at`,排序改"近 24h 活跃在前(count_24h 降序),安静的按 count 降序沉底";兼补 naive timestamp 的 tzinfo 兜底。admin 页表格加"近24h"(>0 红)和"趋势"列(unicode block mini 条形图,悬浮看逐桶数值),近24h=0 的行整体淡化;告警邮件每条带"近24h N 次/已安静"。API 只加字段不删,向后兼容。冒烟:伪造行 patch session_scope 全绿(分桶/排序/naive ts/邮件格式)。修复部署后次日看板:对应行变灰+趋势尾格归零 = 修好;仍红 = 没修好。 - **07-13 / 0.58.13**:**look_at_image 引导口径改"慢、谨慎调"**:tool description 原写 "Cheap (usually < ¥0.01)"、system prompt 媒体段写"每次很便宜"——都在鼓励随手调,而实际痛点是慢(一次几十秒)。两处(`tools/look_at_image.py` description + `core/agent_builder.py` `_MEDIA_LOOK_SEG`)改为强调 SLOW / 谨慎调用 / 只在确实需要图内容时调 / 想问的一次在 `question` 里问全、绝不对同一张图反复看;"何时不调"加"图内容对当前任务可有可无"。纯 prompt 调整,无行为代码变化。 - **07-13 / 0.58.12**:**沙箱镜像补 `file` 命令**(toolfail 巡检首批产出):7 天 13 任务 9 用户撞 `bash: file: command not found`——模型爱用 `file` 验产物真实类型,slim 基底不带。Dockerfile 加独立小 apt 层(pip/chromium 大层之后,不打穿 cache)。同批评估过 olefile/cairosvg **决定不装**:量小(各 2-3 任务)、模型容器内 pip install 可自愈(样例实证),且 cairosvg 进共享 requirements 会坑 Windows host(cairo DLL);待巡检趋势说话。 - **07-13 / 0.58.11**:**工具失败聚集巡检**(反 9dcae061 "挂 90 天无人知"的结构性缺口):新模块 `core/toolfail.py`——扫近 7 天 messages 里 role=tool 的错误结果(`[Error` 开头 / `command timed out` / 尾部 `[exit 非0]`),按 (工具名+归一化签名:路径/数字/uuid 抹平) 聚合,同签名 ≥5 次且跨 ≥2 task 判聚集(单 task 内试错自愈不触发)。三个出口:① web/app.py `_toolfail_scanner` 周期任务(仿 _disk_scanner,每天跑,签名进程内去重)→ 发 `ZCBOT_DEVELOPER_EMAIL`(复用 SMTP_*,未配则日志);② `GET /v1/admin/tool-failures`;③ admin 页新"工具失败"表(低阈值 3 次/1 task 看全量,悬浮看样例)。生产 DB 冒烟:一次扫出 21 类真聚集(沙箱缺 `file` 命令 x13 跨 13 task、web_fetch Network unreachable x7、mmdc 超时批次等)。env:`ZCBOT_DEVELOPER_EMAIL` / `ZCBOT_TOOLFAIL_SCAN_INTERVAL`(RUN.md 环境段已记)。无新表无 migration;重启后同签名重推一次(聚集还在=问题还在,可接受)。 diff --git a/core/__init__.py b/core/__init__.py index c7f30b5..cbbab48 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.58.13" +__version__ = "0.58.14" diff --git a/core/toolfail.py b/core/toolfail.py index 13ae935..7d05e0f 100644 --- a/core/toolfail.py +++ b/core/toolfail.py @@ -16,6 +16,7 @@ """ from __future__ import annotations +import math import re from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional, Tuple @@ -70,13 +71,20 @@ def scan_tool_failures( min_count: int = 5, min_tasks: int = 2, ) -> List[Dict[str, Any]]: - """扫近 `days` 天的 tool 错误消息,返回超阈值的聚集(count 降序)。 + """扫近 `days` 天的 tool 错误消息,返回超阈值的聚集。 阈值语义:同签名 >= min_count 次 且 跨 >= min_tasks 个 task —— 单 task 内 模型试错几次就自愈的正常噪音不触发;跨 task 复现的才是平台性问题。 + + 时间分布:每个聚集带 `daily`(从 now 往回按 24h 分桶的次数,旧→新, + 非日历日)和 `count_24h`(= daily 尾桶)—— 修复部署后看尾桶是否归零, + 区分「还在发生」和「窗口内的存量记录」。排序:近 24h 活跃的在前 + (count_24h 降序),其后按 count 降序。 同步阻塞(DB 查询),asyncio 调用方放 to_thread/executor。 """ - cutoff = datetime.now(timezone.utc) - timedelta(days=days) + now = datetime.now(timezone.utc) + cutoff = now - timedelta(days=days) + n_buckets = max(1, math.ceil(days)) rows = [] with session_scope() as s: rows = s.execute( @@ -102,6 +110,8 @@ def scan_tool_failures( if hit is None: continue kind, sig_line = hit + # DB 列若是 naive timestamp(存 UTC),补 tzinfo 才能和 now 做减法 + ts = created_at if created_at.tzinfo else created_at.replace(tzinfo=timezone.utc) key = (tool_name or "?", _normalize(sig_line)) c = agg.get(key) if c is None: @@ -112,14 +122,21 @@ def scan_tool_failures( "count": 0, "tasks": set(), "users": set(), - "last_at": created_at, + "first_at": ts, + "last_at": ts, "sample": content[:300], + "daily": [0] * n_buckets, } c["count"] += 1 c["tasks"].add(task_id) c["users"].add(user_id) - if created_at > c["last_at"]: - c["last_at"] = created_at + # 分桶:距 now 每满 24h 退一桶,尾桶 = 近 24h(时钟漂移/边界值 clamp 进首尾桶) + age_days = int((now - ts).total_seconds() // 86400) + c["daily"][n_buckets - 1 - min(n_buckets - 1, max(0, age_days))] += 1 + if ts < c["first_at"]: + c["first_at"] = ts + if ts > c["last_at"]: + c["last_at"] = ts c["sample"] = content[:300] out = [] @@ -131,22 +148,28 @@ def scan_tool_failures( "signature": c["signature"], "kind": c["kind"], "count": c["count"], + "count_24h": c["daily"][-1], + "daily": c["daily"], "task_count": len(c["tasks"]), "user_count": len(c["users"]), + "first_at": c["first_at"].isoformat(), "last_at": c["last_at"].isoformat(), "sample": c["sample"], }) - out.sort(key=lambda x: x["count"], reverse=True) + # 活跃的(近 24h 还在发生)排前面,已安静的沉底 —— 面板/邮件都先看还在烧的 + out.sort(key=lambda x: (x["count_24h"], x["count"]), reverse=True) return out def format_alert(clusters: List[Dict[str, Any]], days: float) -> str: """聚集列表 → 告警邮件正文(纯文本)。""" - lines = [f"近 {days:g} 天内检出 {len(clusters)} 类工具失败聚集:", ""] + lines = [f"近 {days:g} 天内检出 {len(clusters)} 类工具失败聚集(近 24h 活跃的在前):", ""] for c in clusters: + n24 = c.get("count_24h", 0) lines.append( f"- [{c['tool']}/{c['kind']}] x{c['count']}" - f"(task {c['task_count']} 个 / 用户 {c['user_count']} 人," + f"(近24h {n24} 次{',已安静' if not n24 else ''}," + f"task {c['task_count']} 个 / 用户 {c['user_count']} 人," f"最近 {c['last_at']})" ) lines.append(f" 签名: {c['signature']}") diff --git a/web/static/admin.html b/web/static/admin.html index bb4d61d..2355dfe 100644 --- a/web/static/admin.html +++ b/web/static/admin.html @@ -96,6 +96,9 @@ th:first-child, td:first-child { text-align: left; } th { color: var(--muted); font-weight: 600; } td.num { font-family: var(--mono); } + td.num.hot { color: var(--danger); font-weight: 700; } + td.trend { font-family: var(--mono); letter-spacing: 1px; color: var(--accent); } + tr.quiet td { opacity: .45; } td.email { font-family: var(--mono); max-width: 220px; overflow: hidden; text-overflow: ellipsis; } .bar-cell { position: relative; } tfoot .total-row td { border-top: 2px solid var(--border); border-bottom: none; font-weight: 700; } diff --git a/web/static/js/admin.js b/web/static/js/admin.js index ff03a27..60dd1ee 100644 --- a/web/static/js/admin.js +++ b/web/static/js/admin.js @@ -290,21 +290,32 @@ function renderStorage(d) { // 工具失败聚集(近7天,低阈值看全量;巡检邮件走 core/toolfail 高阈值)。 // 签名已在后端归一(路径/数字抹平),悬浮 title 给最近一次的原始样例。 +// 后端已按活跃度排序(近24h 有发生的在前);近24h=0 的行淡化 —— 修复部署后 +// 看对应行是否变灰 + 趋势尾格归零,即知修没修好(旧记录会挂满窗口,行不消失)。 +function trendBar(daily) { + if (!daily || !daily.length) return ""; + const max = Math.max(...daily, 1); + const blocks = "▁▂▃▄▅▆▇█"; + return daily.map(v => v === 0 ? "·" + : blocks[Math.min(blocks.length - 1, Math.ceil(v / max * blocks.length) - 1)]).join(""); +} function renderToolFailures(d) { const rows = d.clusters || []; - const body = rows.map(c => `` + const body = rows.map(c => `` + `${escapeHtml(c.tool)}` + `${escapeHtml(c.kind)}` + `${escapeHtml(c.signature)}` + `${c.count}` + + `${c.count_24h || 0}` + + `${trendBar(c.daily)}` + `${c.task_count}` + `${c.user_count}` + `${c.last_at ? fmtTime(c.last_at) : "—"}` + ``).join("") - || `近 ${d.days || 7} 天无失败聚集`; - $("s-toolfail").innerHTML = `

工具失败聚集(近 ${d.days || 7} 天,同签名 ≥3 次)

` + || `近 ${d.days || 7} 天无失败聚集`; + $("s-toolfail").innerHTML = `

工具失败聚集(近 ${d.days || 7} 天,同签名 ≥3 次;近 24h 活跃在前,灰行 = 已安静)

` + `
` - + `` + + `` + `${body}
工具类型签名(悬浮看样例)次数任务数用户数最近
工具类型签名(悬浮看样例)次数近24h趋势任务数用户数最近
`; }