fix(ai): 折叠态只显示一句话,那句话得说清是哪个指标

精简版的卡片正文就是第一条 highlight,标题不渲染。于是趋势页折叠起来是
「395 天内由 4376.0分 到 4905.0分(改善)」——什么的 4376 分?身体成分、
运动详情同样。

每个 builder 的第一条 detail 现在都自带主语,并加了不变量测试:detail 必须
包含它自己的 title(纯汇总性标题除外)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-09-01 15:41:28 +08:00
parent 6dd070ec9b
commit 2488956f36
3 changed files with 32 additions and 5 deletions

View File

@@ -495,6 +495,10 @@ def rule_scope_insight(context):
return {
# The first highlight leads and is then dropped from the list: showing
# it in both places printed the same sentence twice.
#
# This is why every builder's first `detail` has to read on its own —
# here the `title` beside it is not rendered, and "395 天内由 4376 到
# 4905改善" without naming 耐力分 is a sentence about nothing.
"headline": highlights[0]["detail"] if highlights else None,
"points": [dict(h) for h in highlights[1:6]],
"actions": [],

View File

@@ -280,7 +280,7 @@ def build_trends(user_id, subject=None):
highlights = [{
"title": t["label"],
"detail": (
f"{t['days']} 天内由 {t['firstMean']}{t['unit']}"
f"{t['label']} {t['days']} 天内由 {t['firstMean']}{t['unit']}"
f"{t['lastMean']}{t['unit']}{t['direction']}"
f"斜率约 {t['slopePer30d']}{t['unit']}/30 天。"
),
@@ -332,7 +332,7 @@ def build_daily(user_id, subject=None):
"detail": f"{d['label']} {d['value']}{d['unit']},偏离基线 "
f"{abs(d['z']):.1f} 个标准差。",
} for d in notable[:4]] or [
{"title": date, "detail": "这一天各项指标都在常态范围内。"}
{"title": date, "detail": f"{date} 各项指标都在常态范围内。"}
]
return date, {
@@ -370,7 +370,8 @@ def build_body(user_id, subject=None):
continue
highlights.append({
"title": label,
"detail": f"{first['date']} {a}{unit}{last['date']} {b}{unit}"
"detail": f"{label} {first['date']} {a}{unit}"
f"{last['date']}{b}{unit}"
f"{'+' if b >= a else ''}{round(b - a, 1)}{unit})。",
})
elif series:
@@ -522,9 +523,11 @@ def build_activity(user_id, subject=None):
total_zone = sum(z.get("seconds") or 0 for z in zones)
minutes = round((base.get("duration") or summary.get("duration") or 0) / 60)
sport = (base.get("activity_type") or (detail or {}).get("activityType")
or "运动")
highlights = [{
"title": base.get("activity_type") or (detail or {}).get("activityType") or "运动",
"detail": f"{minutes} 分钟"
"title": sport,
"detail": f"{sport} {minutes} 分钟"
+ (f"{round((base.get('distance') or 0) / 1000, 2)} km"
if base.get("distance") else "")
+ (f",平均心率 {base.get('heart_rate_average')}"

View File

@@ -1053,3 +1053,23 @@ class TestSupersede:
statuses = {r["subject"]: r["status"] for r in analysis_svc.query_all(
"SELECT subject, status FROM ai_jobs WHERE kind = 'trends'")}
assert statuses.get("2026-08-01") == "running"
class TestHighlightsReadAlone:
"""The first highlight becomes the collapsed card's whole text, with no
title beside it — so it has to name what it is about."""
def test_every_first_highlight_names_its_subject(self, month):
for name in scopes.SCOPES:
built = scopes.build(month["id"], name)
if not built:
continue
first = built[1]["highlights"][0]
title = first["title"]
# Titles that are already summaries rather than names of a metric.
if title in ("整体", "当前预测") or title.startswith(""):
continue
assert title in first["detail"], (
f"{name}: 折叠态只显示 detail但它没提到 {title!r}: "
f"{first['detail']!r}"
)