diff --git a/backend/app/notify/service.py b/backend/app/notify/service.py index 6ec395f5..462f4643 100644 --- a/backend/app/notify/service.py +++ b/backend/app/notify/service.py @@ -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")}} diff --git a/backend/tests/test_ledger_recall.py b/backend/tests/test_ledger_recall.py index 0052f306..7e7232c7 100644 --- a/backend/tests/test_ledger_recall.py +++ b/backend/tests/test_ledger_recall.py @@ -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