口径召回的通知按种类分开写答案的条数

验收:ledger.recall 消息里写「答案:2 个」,实际是 1 个问数回答加 1 个正式运行。notify_recall 拿 _uses_of 的长度直接
拼成「N 个」,不分种类。

改法:notify/service 加 _answers_text,按用法的种类数(ledger.uses.KINDS 的顺序、KIND_TEXT 的叫法,同版本用量和召回
批次的说法)写成「问数回答 1 个、正式运行 1 个」;全局渠道按这一批汇总、个人渠道只数他的,两处都用它。只写条数,照旧
不放旧值、新值、差值和原答案的值。

测试(test_ledger_recall):新增一条:同一周一个问数回答、一次正式运行,升版召回之后全局渠道收到的「答案」一行是
「问数回答 1 个、正式运行 1 个」,消息里没有数;原来两条按「1 个」「2 个」断言的改成带种类的写法。去掉修复回跑挂
3 条。
This commit is contained in:
ThearchyHelios
2026-10-17 09:15:07 +08:00
parent 8f79098b6f
commit 590bfc9a59
2 changed files with 68 additions and 9 deletions
+19 -6
View File
@@ -342,6 +342,18 @@ async def notify_task(task_id: str, event: str, *, users: list[str], exclude: fr
task_id=task_id)
def _answers_text(uses: list[dict[str, Any]]) -> str:
"""召回消息里「答案」那一行:按种类分开写条数(「问数回答 1 个、正式运行 1 个」,种类的叫法同版本用量 ledger.uses.KIND_TEXT),
只写条数、不放数。以前合成一个「2 个」,一个问数回答加一次正式运行读起来像两个问数回答。一个都没有写「0 个」。"""
from collections import Counter
from app.ledger.uses import KIND_TEXT, KINDS
counts = Counter(str(u.get("kind") or "") for u in uses)
order = [k for k in KINDS if counts.get(k)] + sorted(k for k in counts if k not in KINDS)
return "、".join(f"{KIND_TEXT.get(k, k or '答案')} {counts[k]} 个" for k in order) or "0 个"
async def notify_recall(task_id: str, users: list[str]) -> int:
"""口径召回的待办开出来了(app/ledger/recall):口径升版、数真变了的那一种推 ledger.recall——订阅了它的全局渠道、答案主人
自己的个人渠道;然后照别的待办推 task.opened(已经推过 ledger.recall 的渠道不再推,同一个渠道只推一次)。目录、表结构变了
@@ -352,8 +364,9 @@ async def notify_recall(task_id: str, users: list[str]) -> int:
答案、待办按这一批汇总,链到待办箱(各人在那里看到派给自己的那条);别的几条连 task.opened 也不再推全局渠道。个人渠道
照旧每个主人各一条,只数他的答案、链到他的那条待办。
消息照模块说明的规矩(X6):口径名(搭建时起的工作流名)、新旧版本号、几个答案(几条待办)、召回编号、去处理的链接;不放
旧口径的值、新口径的值、差值和原答案的值。返回推了几个渠道。"""
消息照模块说明的规矩(X6):口径名(搭建时起的工作流名)、新旧版本号、几个答案(按种类分开写条数:问数回答几个、正式运行
几个……,见 _answers_text;几条待办)、召回编号、去处理的链接;不放旧口径的值、新口径的值、差值和原答案的值。返回推了几个
渠道。"""
from app.ledger import recall
try:
@@ -387,13 +400,13 @@ async def notify_recall(task_id: str, users: list[str]) -> int:
head = [("状态", "待处理"), ("口径", f"「{name[:80]}」"),
("版本", f"v{origin.get('from_version')} → v{origin.get('to_version')}")]
tail = [("召回", f"#{task.run_id[:6]}")]
mine = len(recall._uses_of(origin, recall._owner_of_key(key)))
everyone = len(recall._uses_of(origin, ("anyone", "")))
mine = _answers_text(recall._uses_of(origin, recall._owner_of_key(key)))
everyone = _answers_text(recall._uses_of(origin, ("anyone", "")))
def message(answers: int, tasks: int, link: tuple[str, str] | None) -> dict[str, Any]:
def message(answers: str, tasks: int, link: tuple[str, str] | None) -> dict[str, Any]:
return {"event": event, "status": "pending", "ok": False,
"title": recall.CHANGED_TITLE.format(name=name[:80] or "口径", version=origin.get("to_version")),
"lines": [*head, ("答案", f"{answers} 个"), *([("待办", f"{tasks} 条")] if tasks > 1 else []), *tail],
"lines": [*head, ("答案", answers), *([("待办", f"{tasks} 条")] if tasks > 1 else []), *tail],
"links": [link] if base and link else [],
"subject": {"task_id": task_id, "run_id": task.run_id, "workflow_id": origin.get("workflow_id")}}
+49 -3
View File
@@ -464,11 +464,57 @@ async def test_recall_notification_has_no_numbers(monkeypatch):
text = mine[0]["title"] + "".join(f"{k}{v}" for k, v in mine[0]["lines"])
for number in ("170.5", "208.5", "38.0", "38.00"):
assert number not in text, f"消息里不放数:{number}"
assert dict(mine[0]["lines"]) == {"状态": "待处理", "口径": "「周销售」", "版本": "v1 → v2", "答案": "1 个",
assert dict(mine[0]["lines"]) == {"状态": "待处理", "口径": "「周销售」", "版本": "v1 → v2", "答案": "问数回答 1 个",
"召回": dict(mine[0]["lines"])["召回"]}
assert "周销售" in mine[0]["title"] and "v2" in mine[0]["title"]
async def test_recall_message_counts_answers_by_kind(monkeypatch):
"""召回消息按种类分开写条数:一个问数回答、一次正式运行写「问数回答 1 个、正式运行 1 个」(下一轮验收:以前写「答案:2 个」,
读的人以为是两个问数回答)。只写条数,照旧不放数。"""
from app.core.crypto import encrypt
from app.db.models import NotifyChannel
from app.engine.launch import launch_run
from app.notify import service
sent: list[dict] = []
async def capture(channel, message):
sent.append({"channel": channel.name, **message})
from app.notify.core import Outcome
return Outcome(True, 200, 1, None)
monkeypatch.setattr("app.notify.send", capture)
async with SessionLocal() as session:
row = NotifyChannel(name="召回-按种类", scope="global", url=encrypt("https://hooks.example.com/recall-kinds"),
events=["ledger.recall"])
session.add(row)
await session.commit()
channel_id = row.id
try:
wf = await S.publish()
await answer(monkeypatch)
S.script(monkeypatch, "本周销售额 [[m:gmv]]。")
async with SessionLocal() as session:
formal = await launch_run(session, workflow_id=wf, run_class="formal", version=1, input_payload=WEEK1,
actor="analyst")
assert (await S.wait(formal.id)).status == "succeeded"
await publish_v2(wf)
await run_recall(wf)
await service.drain()
finally:
async with SessionLocal() as session:
await session.delete(await session.get(NotifyChannel, channel_id))
await session.commit()
mine = [m for m in sent if m["channel"] == "召回-按种类"]
assert mine, sent
answers = [v for m in mine for k, v in m["lines"] if k == "答案"]
assert "问数回答 1 个、正式运行 1 个" in answers, answers
for number in ("170.5", "208.5", "38.0"):
assert all(number not in v for m in mine for _, v in m["lines"]), "消息里不放数"
async def test_recall_pushes_the_shared_channel_once_per_recall_run(monkeypatch):
"""开登录时一组答案有几个主人就开几条待办,但全局渠道(团队群)按召回运行只收一条(答案、待办都按这次召回汇总,链到
待办箱);订阅了 task.opened 的全局渠道也不再按每条待办各推一次。每个主人自己的个人渠道各收他那一条。"""
@@ -522,12 +568,12 @@ async def test_recall_pushes_the_shared_channel_once_per_recall_run(monkeypatch)
team = [m for m in sent if m["channel"] == "团队-召回"]
assert [m["event"] for m in team] == ["ledger.recall"], [m["event"] for m in team]
lines = dict(team[0]["lines"])
assert lines["答案"] == "2 个" and lines["待办"] == "2 条", lines
assert lines["答案"] == "问数回答 2 个" and lines["待办"] == "2 条", lines
assert [label for label, _ in team[0]["links"]] == ["去待办箱"] or team[0]["links"] == []
assert [m["event"] for m in sent if m["channel"] == "团队-待办"] == ["task.opened"], "只订阅了新待办的团队渠道也只收一条"
for name in ("analyst_a", "analyst_b"):
mine = [m for m in sent if m["channel"] == f"个人-{name}"]
assert [m["event"] for m in mine] == ["ledger.recall"] and dict(mine[0]["lines"])["答案"] == "1 个", mine
assert [m["event"] for m in mine] == ["ledger.recall"] and dict(mine[0]["lines"])["答案"] == "问数回答 1 个", mine
# 账本接口里的召回待办只列看的这个人手上的:第一个答案的主人看得到自己那条,另一个主人看不到
from app.api.deps import Principal