feat(compare-alert): 记录级报警规则纯函数(T1/T2/T5/T6)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
"""比价失败报警规则:一条记录 → 命中的 AlertHit(或 None)。
|
||||
|
||||
纯函数、不碰 DB(阈值/关键词由调用方从 config 传入),便于单测与调阈值。判定顺序保证四类互斥:
|
||||
failed → fail_reason 空(且 information 非业务)=T1 / 含未识别=T6 / 含超时词=T2 / 其余业务不报;
|
||||
cancelled 且耗时或步数超阈值=T5(深度放弃);success/running 不报。
|
||||
口径依据见 docs/superpowers/specs/2026-08-04-compare-fail-alert-design.md 第 3 节。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
ALERT_TYPE_LABELS: dict[str, str] = {
|
||||
"T1": "系统技术失败",
|
||||
"T6": "商品识别失败",
|
||||
"T2": "启动/超时失败",
|
||||
"T5": "深度放弃(cancelled)",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AlertHit:
|
||||
trace_id: str
|
||||
alert_type: str
|
||||
reason: str
|
||||
app_version: str | None
|
||||
|
||||
|
||||
def _hit(rec: Any, alert_type: str, reason: str) -> AlertHit:
|
||||
return AlertHit(
|
||||
trace_id=rec.trace_id,
|
||||
alert_type=alert_type,
|
||||
reason=reason,
|
||||
app_version=getattr(rec, "app_version", None),
|
||||
)
|
||||
|
||||
|
||||
def classify_record(
|
||||
rec: Any,
|
||||
*,
|
||||
cancelled_ms_threshold: int,
|
||||
cancelled_step_threshold: int,
|
||||
timeout_keywords: tuple[str, ...],
|
||||
unrecognized_keywords: tuple[str, ...],
|
||||
biz_exclude_keywords: tuple[str, ...],
|
||||
) -> AlertHit | None:
|
||||
"""判定单条记录是否触发报警。rec 需有 status/fail_reason/information/total_ms/step_count/
|
||||
trace_id/app_version 属性(ComparisonRecord 或等价对象)。"""
|
||||
status = rec.status
|
||||
if status == "failed":
|
||||
fail_reason = rec.fail_reason
|
||||
if fail_reason is None:
|
||||
info = (rec.information or "").strip()
|
||||
if info and any(w in info for w in biz_exclude_keywords):
|
||||
return None
|
||||
return _hit(rec, "T1", f"技术失败·{info[:80] or '比价过程出错'}")
|
||||
if any(w in fail_reason for w in unrecognized_keywords):
|
||||
return _hit(rec, "T6", f"识别失败·{fail_reason[:80]}")
|
||||
if any(w in fail_reason for w in timeout_keywords):
|
||||
return _hit(rec, "T2", fail_reason[:80])
|
||||
return None
|
||||
if status == "cancelled":
|
||||
ms = rec.total_ms
|
||||
step = rec.step_count
|
||||
deep = (ms is not None and ms > cancelled_ms_threshold) or (
|
||||
step is not None and step > cancelled_step_threshold
|
||||
)
|
||||
if deep:
|
||||
return _hit(
|
||||
rec, "T5",
|
||||
f"深度放弃·等待 {round((ms or 0) / 1000)}s / {step or 0} 步后退出",
|
||||
)
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def classify_batch(records: list, **kwargs) -> list[AlertHit]:
|
||||
"""批量分类,过滤掉 None。"""
|
||||
hits = [classify_record(r, **kwargs) for r in records]
|
||||
return [h for h in hits if h is not None]
|
||||
@@ -0,0 +1,61 @@
|
||||
"""比价报警规则分类:记录 → AlertHit | None(纯函数,不碰 DB)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.compare_alert import classify_record
|
||||
|
||||
KW = dict(
|
||||
cancelled_ms_threshold=90000,
|
||||
cancelled_step_threshold=30,
|
||||
timeout_keywords=("超时", "启动", "加载"),
|
||||
unrecognized_keywords=("未识别",),
|
||||
biz_exclude_keywords=("未找到", "打烊", "起送", "门店", "店内", "不配送", "这些菜", "未入驻", "休息"),
|
||||
)
|
||||
|
||||
|
||||
def _rec(**kw):
|
||||
base = dict(
|
||||
status="failed", fail_reason=None, information=None,
|
||||
total_ms=None, step_count=None, trace_id="t", app_version=None, business_type="food",
|
||||
)
|
||||
base.update(kw)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("rec", "expected_type"),
|
||||
[
|
||||
(_rec(status="failed", fail_reason=None, information="比价过程出错,请稍后重试"), "T1"),
|
||||
(_rec(status="failed", fail_reason=None, information=None), "T1"),
|
||||
(_rec(status="failed", fail_reason=None, information="美团外卖门店已打烊,无法比价"), None),
|
||||
(_rec(status="failed", fail_reason="未识别到商品"), "T6"),
|
||||
(_rec(status="failed", fail_reason="启动淘宝超时, 请稍后重试"), "T2"),
|
||||
(_rec(status="failed", fail_reason="淘宝闪购店内未找到这些菜品"), None),
|
||||
(_rec(status="cancelled", total_ms=98000, step_count=10), "T5"),
|
||||
(_rec(status="cancelled", total_ms=20000, step_count=31), "T5"),
|
||||
(_rec(status="cancelled", total_ms=20000, step_count=5), None),
|
||||
(_rec(status="cancelled", total_ms=None, step_count=None), None),
|
||||
(_rec(status="success"), None),
|
||||
(_rec(status="running"), None),
|
||||
],
|
||||
)
|
||||
def test_classify_record_type(rec, expected_type):
|
||||
hit = classify_record(rec, **KW)
|
||||
assert (hit.alert_type if hit else None) == expected_type
|
||||
|
||||
|
||||
def test_t5_boundary_exclusive():
|
||||
assert classify_record(_rec(status="cancelled", total_ms=90000, step_count=30), **KW) is None
|
||||
assert classify_record(_rec(status="cancelled", total_ms=90001, step_count=30), **KW).alert_type == "T5"
|
||||
|
||||
|
||||
def test_reason_texts():
|
||||
t1 = classify_record(_rec(status="failed", fail_reason=None, information=" 比价过程出错 "), **KW)
|
||||
assert t1.reason == "技术失败·比价过程出错"
|
||||
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 步后退出"
|
||||
Reference in New Issue
Block a user