feat(compare-alert): 卡点独立成列(AlertHit.stuck_point + 卡片第5列),reason 去重简化

- AlertHit 加 stuck_point: str | None = None 字段(格式化好的「平台·环节 帧/s」)
- classify_cancelled_fallback reason 简化为「深度放弃」(耗时/步数已在「用时」列,不重复)
- build_hits 加 _fmt_stuck helper;cancelled 卡死 stuck_point=「美团·加菜 110帧/32s」;
  failed stuck_point=环节标签、reason 不再附「卡在 X」
- format_alert_card 列序改为 时间/手机号/用时/失败原因/卡点/版本/trace (7列)
- 同步更新 test_compare_alert_stuck_worker / _fallback / _format / _rules 断言

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
guke
2026-08-05 16:42:11 +08:00
parent e135ba9a84
commit 347c4c7de4
6 changed files with 89 additions and 18 deletions
+11 -3
View File
@@ -81,6 +81,14 @@ def _trace_dir(base: Path, trace_url: str | None) -> Path | None:
return base / name
def _fmt_stuck(sp) -> str:
"""StuckPoint → 「平台·环节 110帧/32s」;stuck_ms 为 None 时省略时长。"""
s = f"{sp.label()} {sp.frames}"
if sp.stuck_ms is not None:
s += f"/{round(sp.stuck_ms / 1000)}s"
return s
def build_hits(
records: list,
*,
@@ -114,8 +122,8 @@ def build_hits(
reads += 1
if res is not None and res.readable:
if res.points:
reason = "卡在 " + "".join(sp.label() for sp in res.points)
hit = make_hit(rec, "T5", reason)
stuck = "".join(_fmt_stuck(sp) for sp in res.points)
hit = _dc_replace(make_hit(rec, "T5", "深度放弃"), stuck_point=stuck)
else:
hit = None # 读到且确认没卡 → 不报
else:
@@ -146,7 +154,7 @@ def build_hits(
sp = trace_stuck.last_step(td)
reads += 1
if sp is not None:
hit = _dc_replace(hit, reason=f"{hit.reason}|卡在 {sp.label()}")
hit = _dc_replace(hit, stuck_point=sp.label())
if hit is not None:
hits.append(hit)
return hits
+2 -4
View File
@@ -30,6 +30,7 @@ class AlertHit:
user_id: int | None
total_ms: int | None = None
step_count: int | None = None
stuck_point: str | None = None
def make_hit(rec: Any, alert_type: str, reason: str) -> AlertHit:
@@ -59,10 +60,7 @@ def classify_cancelled_fallback(
step is not None and step > cancelled_step_threshold
)
if deep:
return make_hit(
rec, "T5",
f"深度放弃·等待 {round((ms or 0) / 1000)}s / {step or 0} 步后退出",
)
return make_hit(rec, "T5", "深度放弃")
return None
+2
View File
@@ -168,6 +168,7 @@ def _build_table_rows(
"phone": phone,
"cost": _cost_cell(h.total_ms, h.step_count),
"reason": h.reason,
"stuck": h.stuck_point or "-",
"ver": h.app_version or "-",
"trace": trace,
})
@@ -179,6 +180,7 @@ _TABLE_COLUMNS = [
{"name": "phone", "display_name": "手机号", "data_type": "text"},
{"name": "cost", "display_name": "用时", "data_type": "text"},
{"name": "reason", "display_name": "失败原因", "data_type": "text"},
{"name": "stuck", "display_name": "卡点", "data_type": "text"},
{"name": "ver", "display_name": "版本", "data_type": "text"},
{"name": "trace", "display_name": "trace", "data_type": "lark_md"},
]
+50 -3
View File
@@ -265,7 +265,7 @@ def test_card_body_markdown_summary():
assert "合计 3 条" in md["content"]
def test_card_has_table_six_columns():
def test_card_has_table_seven_columns():
card = format_alert_card(
_card_hits(2),
window_label="w",
@@ -280,9 +280,15 @@ def test_card_has_table_six_columns():
table = elements[1]
assert table["tag"] == "table"
cols = table["columns"]
assert len(cols) == 6
assert len(cols) == 7
display_names = [c["display_name"] for c in cols]
assert display_names == ["时间", "手机号", "用时", "失败原因", "版本", "trace"]
assert display_names == ["时间", "手机号", "用时", "失败原因", "卡点", "版本", "trace"]
# 「卡点」列在「失败原因」后、「版本」前
names = [c["name"] for c in cols]
reason_idx = names.index("reason")
stuck_idx = names.index("stuck")
ver_idx = names.index("ver")
assert reason_idx < stuck_idx < ver_idx
# trace 列用 lark_md
trace_col = next(c for c in cols if c["name"] == "trace")
assert trace_col["data_type"] == "lark_md"
@@ -523,3 +529,44 @@ def test_card_table_has_page_size_and_header_style():
assert "header_style" in table
assert table["header_style"].get("background_style") == "grey"
assert table["header_style"].get("bold") is True
def test_card_stuck_point_shown_in_row():
"""stuck_point 有值时行中 stuck 列正确显示;无值时显示「-」。"""
import dataclasses
from app.services.compare_alert import make_hit
class _Rec:
trace_id = "sp1"
status = "cancelled"
total_ms = 90000
step_count = 20
fail_reason = None
information = None
app_version = "v1.0"
created_at = datetime(2026, 8, 5, 10, 0)
trace_url = None
user_id = None
hit_with = dataclasses.replace(
make_hit(_Rec(), "T5", "深度放弃"),
stuck_point="美团·加菜 110帧/32s",
)
hit_without = make_hit(_Rec(), "T5", "深度放弃") # stuck_point=None
card_with = format_alert_card(
[hit_with],
window_label="w", phone_map={}, interval_min=5,
max_detail_per_type=20, max_total=50,
)
row_with = card_with["body"]["elements"][1]["rows"][0]
assert row_with["stuck"] == "美团·加菜 110帧/32s"
card_without = format_alert_card(
[hit_without],
window_label="w", phone_map={}, interval_min=5,
max_detail_per_type=20, max_total=50,
)
row_without = card_without["body"]["elements"][1]["rows"][0]
assert row_without["stuck"] == "-"
+1 -1
View File
@@ -58,4 +58,4 @@ def test_reason_texts():
t1_empty = classify_record(_rec(status="failed", fail_reason=None, information=None), **KW)
assert t1_empty.reason == "技术失败·比价过程出错"
t5 = classify_record(_rec(status="cancelled", total_ms=98000, step_count=26), **KW)
assert t5.reason == "深度放弃·等待 98s / 26 步后退出"
assert t5.reason == "深度放弃"
+23 -7
View File
@@ -2,7 +2,8 @@
import json
from pathlib import Path
from app.core.compare_alert_worker import build_hits
from app.core.compare_alert_worker import _fmt_stuck, build_hits
from app.services.trace_stuck import StuckPoint
class _Rec:
@@ -42,7 +43,9 @@ def test_cancelled_stuck_reports_via_trace(tmp_path):
hits = build_hits([rec], work_log_dir=str(tmp_path), **_KW)
assert len(hits) == 1
assert hits[0].alert_type == "T5"
assert "卡在" in hits[0].reason and "美团·加菜" in hits[0].reason
assert hits[0].reason == "深度放弃"
assert "美团·加菜" in hits[0].stuck_point
assert "" in hits[0].stuck_point
def test_cancelled_readable_not_stuck_no_report(tmp_path):
@@ -80,7 +83,8 @@ def test_failed_gets_stuck_point_appended(tmp_path):
hits = build_hits([rec], work_log_dir=str(tmp_path), **_KW)
assert len(hits) == 1
assert hits[0].alert_type == "T2"
assert "卡在 美团·加菜" in hits[0].reason
assert "美团·加菜" in hits[0].stuck_point
assert "卡在" not in hits[0].reason
def test_max_trace_reads_zero_skips_trace(tmp_path):
@@ -107,7 +111,19 @@ def test_shared_reads_budget_across_branches(tmp_path):
kw = {**_KW, "max_trace_reads": 1}
hits = build_hits([cancelled, failed], work_log_dir=str(tmp_path), **kw)
assert len(hits) == 2
# cancelled 消耗了唯一预算 → 报卡死
assert hits[0].alert_type == "T5" and "卡在" in hits[0].reason
# failed 超预算 → 仍报 T2,但不附卡点
assert hits[1].alert_type == "T2" and "卡在" not in hits[1].reason
# cancelled 消耗了唯一预算 → 报卡死,卡点在 stuck_point
assert hits[0].alert_type == "T5" and hits[0].stuck_point is not None
# failed 超预算 → 仍报 T2,但 stuck_point 为 None
assert hits[1].alert_type == "T2" and hits[1].stuck_point is None
# ---- _fmt_stuck 单测 ----
def test_fmt_stuck_with_ms():
sp = StuckPoint(platform="meituan", pipeline_step="add_one_dish", frames=110, stuck_ms=32000)
assert _fmt_stuck(sp) == "美团·加菜 110帧/32s"
def test_fmt_stuck_without_ms():
sp = StuckPoint(platform="meituan", pipeline_step="add_one_dish", frames=110, stuck_ms=None)
assert _fmt_stuck(sp) == "美团·加菜 110帧"