Files
AgentLab/backend/tests/test_notify_events.py
T
ThearchyHelios 77fc0efa10 口径召回后端(W4-a):发布新版本后反查用过旧版的答案、按参数分组在同一份数据上重算两版之差,数真变了才开 recall 待办
为什么:口径升版是被迫处置的事件(02 做法 7、拍板 L6),已交付的答案以前没人追;目录事实被驳回、表结构变了也没有地方标。

- ledger/uses.py(新):反查旧版本被谁用过——比对工件里等价已证明的问数回答(含轮次、对话主人)、W 的正式运行(定时任务、
  应用各取最近 4 期)、固定了它的工作流版本发起的正式运行、固定这一版的应用、导出审计(导出人也是主人);预告、召回、
  版本用量共用这一份
- ledger/recall.py(新):计划按(旧版本、新版本、input_key)去重成组,从已有召回运行重算(幂等、不建任务表);每组一次
  召回运行(origin_kind=recall,探索运行),派生图经 derive.recall_graph(新旧两版取数和口径卡带前缀、召回卡里算两版之差);
  不能重算照实记原因(数据部分不能复用、新版没这张卡、入口参数不齐、图里有人工节点或写入);每次发布最多 recall_limit 组,
  超出的手动接着算;收尾时按渲染的旧值新值判数变没变:变了开 recall 待办(开登录按主人各一条、关登录派给任何人)、推
  ledger.recall,没变在原答案上记 ledger.recalled;待办「按新版重新出具」走 issue.check_target 同一套判法、「知道了」
- 目录事实被驳回或修改(审阅、人工编辑、保存提案、批量审阅之后):冻结目录里看得到这一项的探索运行记 ledger.basis_changed,
  按新目录多出错误级 SQL 检查问题的才开待办;表结构变动消费 source_changes(游标、处理时改了目录的当场交过去),标依据的
  表结构已变,受影响的口径试算一次,算不出来才通知发布人
- 发布钩子在 commit_publish 提交之后排队(自动的几样和调度循环同一个开关),GET /api/workflows/{id}/recall-preview 给发布
  弹窗预告;GET/POST /api/ledger/recall/{wf}/{v}[/continue] 给版本历史看批次、接着算;版本用量加问数回答、导出、召回
- ledger.recall 打开订阅(全局、个人渠道),同一渠道只推一次,消息不放数;新事件 ledger.recalled、ledger.basis_changed
  登记为封存后追加的事件,可验证报告离线核对页认得;E4 每次发布召回了几组、数变了的比例
- runner 收尾时召回运行在后台收尾,启动时接着做没发完的批次
2026-10-16 19:52:03 +08:00

188 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""通知事件的登记表(app/notify/service.EVENTS;总方案 X6)。
本轮新增的六个事件(数据源变化、口径召回、评测退步 / 失败、待办汇总、密钥到期)在第 1 波一次登记好:名字、界面上的叫法、
推给哪几条路都在 EVENTS 一处,前端的叫法在 lib/terms.ts 的 NOTIFY_TEXT 一处。第 2、3 波实现推送的那一块只改自己那一行
的范围,不再去改同一个元组、同一张叫法表(几块并行改同一处必然冲突)。这里守:
- 登记表:六个事件都在,叫法不空;推给哪几条路是空的就是「登记了、还没有推送」——接口不收这种订阅、GET /api/notify/events
照实给空的 scopes,设置页和应用的通知里都不列(勾了收不到就是「看着生效、其实没生效」,个人渠道「实例完成」的老问题);
- 前后端对照:terms.ts 的 eventLabel 和 EVENTS 一一对应、逐字一致;投递记录才有的事件(改派、升级、催办、测试消息)另有一张
叫法表,和服务端 _AS 对上(写法同 test_chat_turn_policy.py,两边一改就对不上);
- 推给哪几条路是真的:_channels 只走事件登记了的路;没登记的事件推不出去,直接报错;
- 订阅的范围不对(全局渠道勾只推个人的事件)拒收,但只核对这次新勾的:以前存下的照旧,开关、改名不会因此 422。
渠道名、地址都是虚构的。
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import delete
from app.core.crypto import encrypt
from app.db.base import SessionLocal
from app.db.models import NotifyChannel
from app.notify import service
TERMS_TS = Path(__file__).resolve().parents[2] / "frontend" / "src" / "lib" / "terms.ts"
URL = "https://hooks.example.com/agentlab/notify-events-5d1c"
#: 本轮登记的六个(总方案 X6)
THIS_ROUND = ("source.changed", "ledger.recall", "eval.regressed", "eval.failed", "task.digest", "key.expiring")
#: 其中已经实现了推送、打开了订阅的(推给哪几条路):定时评测的两个只推全局渠道(第 3 波 W3-c,notify_eval);每天的待办汇总
#: 只推个人渠道;访问密钥快到期,个人密钥推本人、服务账号的推全局渠道(第 3 波 W3-d,notify/digest.py)。其余还没有
PUSHED_THIS_ROUND = {
"eval.regressed": {"global"},
"eval.failed": {"global"},
"task.digest": {"user"},
"key.expiring": {"global", "user"},
# 第 4 波:W4-d 打开 source.changed 时在这里加它推给的几条路
# (两行之间隔一行不动的,两个包各改各的、合并不撞)
# 第 4 波 W4-a:口径召回数真变了的待办推全局渠道和答案主人自己的个人渠道(notify_recall)
"ledger.recall": {"global", "user"},
}
@pytest.fixture
async def client():
from app.main import app
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
async with SessionLocal() as session:
await session.execute(delete(NotifyChannel).where(NotifyChannel.name.like("事件登记-%")))
await session.commit()
# --------------------------------------------------------------------------
# 登记表
# --------------------------------------------------------------------------
def test_this_rounds_events_are_registered_once_and_not_pushed_yet():
for name in THIS_ROUND:
assert name in service.EVENTS, f"{name} 没有登记"
assert service.EVENTS[name].label, name
if name in PUSHED_THIS_ROUND:
assert set(service.EVENTS[name].scopes) == PUSHED_THIS_ROUND[name], name
else:
assert service.EVENTS[name].scopes == (), f"{name} 还没有推送:不该有人订阅得到"
assert len({e.label for e in service.EVENTS.values()}) == len(service.EVENTS), "两个事件叫成了同一句"
assert service.EVENT_LABEL == {k: v.label for k, v in service.EVENTS.items()}
def test_events_that_are_pushed_today_keep_their_audience():
"""现有五个事件推给哪几条路,照 _channels 和各个 notify_* 现在的做法登记(行为不变)。"""
every = {"global", "user", "app"}
assert {k: set(v.scopes) for k, v in service.EVENTS.items() if v.scopes} == {
"task.opened": every, "run.finished": every, "run.failed": every, "action.unknown": every,
# 发给提交人自己(和订阅了它的全局渠道),不归哪个应用
"review.done": {"global", "user"},
**PUSHED_THIS_ROUND,
}
for event in service.EVENTS.values():
assert set(event.scopes) <= every
def _object_block(source: str, name: str) -> str:
"""terms.ts 里 NOTIFY_TEXT 下面名叫 name 的那个对象字面量(到配对的右花括号为止)。"""
head = source.index("export const NOTIFY_TEXT = {")
start = source.index(f"{name}: {{", head) + len(name) + 2
depth, i = 1, start + 1
while depth:
depth += {"{": 1, "}": -1}.get(source[i], 0)
i += 1
return source[start:i]
def _entries(block: str) -> dict[str, str]:
return dict(re.findall(r"'?([a-z]+(?:\.[a-z_]+)?)'?:\s*'([^']*)'", block))
def test_frontend_labels_match_the_registry_word_for_word():
source = TERMS_TS.read_text(encoding="utf-8")
labels = _entries(_object_block(source, "eventLabel"))
assert labels == service.EVENT_LABEL, "设置页的叫法和服务端登记的对不上(一种说法)"
delivery = _entries(_object_block(source, "deliveryEventLabel"))
assert set(delivery) == set(service._AS) | {"test"}, "投递记录里会出现的事件都要有叫法"
assert not set(delivery) & set(labels)
# --------------------------------------------------------------------------
# 接口
# --------------------------------------------------------------------------
async def test_events_endpoint_says_who_receives_each_event(client):
rows = (await client.get("/api/notify/events")).json()
assert [r["event"] for r in rows] == list(service.EVENTS)
by = {r["event"]: r for r in rows}
assert by["eval.regressed"] == {"event": "eval.regressed", "label": "定时评测有退步", "scopes": ["global"]}
assert by["ledger.recall"]["scopes"] == ["global", "user"]
assert by["review.done"]["scopes"] == ["global", "user"]
#: 登记了、还没有推送的演示事件(第 4 波之后本轮的六个都推了,用它守「不收订阅、不列」这条规矩)
UNPUSHED = "demo.unpushed"
async def test_events_not_pushed_yet_cannot_be_subscribed(client, monkeypatch):
monkeypatch.setitem(service.EVENTS, UNPUSHED, service.Event("还没推送的演示事件", ()))
resp = await client.post("/api/notify/channels", json={
"name": "事件登记-评测", "url": URL, "events": ["task.opened", UNPUSHED]})
assert resp.status_code == 422, resp.text
detail = resp.json()["detail"]
assert "还没推送的演示事件" in detail and "还没有推送" in detail
unknown = await client.post("/api/notify/channels", json={"name": "事件登记-未知", "url": URL, "events": ["nope"]})
assert unknown.status_code == 422
assert "还没推送的演示事件" not in unknown.json()["detail"], "可选的里面不列还没有推送的"
assert "待办打开" in unknown.json()["detail"]
async def test_scope_mismatch_is_refused_only_for_newly_ticked_events(client):
refused = await client.post("/api/notify/channels", json={
"name": "事件登记-应用", "scope": "app", "url": URL, "events": ["review.done"]})
assert refused.status_code == 422, refused.text
assert "我提交审核的应用有结果" in refused.json()["detail"] and "应用" in refused.json()["detail"]
# 以前存下的(当时界面对哪种渠道都列这一项):开关、改名照旧,不因此 422
async with SessionLocal() as session:
row = NotifyChannel(name="事件登记-旧", scope="app", url=encrypt(URL), events=["task.opened", "review.done"])
session.add(row)
await session.commit()
channel_id = row.id
toggled = await client.patch(f"/api/notify/channels/{channel_id}", json={"enabled": False})
assert toggled.status_code == 200, toggled.text
assert toggled.json()["events"] == ["task.opened", "review.done"]
again = await client.patch(f"/api/notify/channels/{channel_id}", json={"events": ["review.done", "run.failed"]})
assert again.status_code == 200, again.text
added = await client.patch(f"/api/notify/channels/{channel_id}", json={"events": ["review.done", "eval.failed"]})
assert added.status_code == 422 and "定时评测没有评完" in added.json()["detail"]
# --------------------------------------------------------------------------
# 推送只走登记了的路
# --------------------------------------------------------------------------
async def test_channels_only_walk_the_paths_the_event_is_registered_for(monkeypatch):
monkeypatch.setitem(service.EVENTS, "demo.personal", service.Event("只推个人的演示事件", ("user",)))
monkeypatch.setitem(service.EVENTS, UNPUSHED, service.Event("还没推送的演示事件", ()))
async with SessionLocal() as session:
row = NotifyChannel(name="事件登记-全局", scope="global", url=encrypt(URL),
events=["demo.personal", "task.opened", UNPUSHED])
session.add(row)
await session.commit()
try:
ids = lambda found: [c.id for c in found] # noqa: E731
assert row.id not in ids(await service._channels(session, "demo.personal", None, [])), "全局渠道这条路没登记"
assert row.id in ids(await service._channels(session, "task.opened", None, []))
assert await service._channels(session, UNPUSHED, None, []) == [], "还没有推送的:谁都不推"
with pytest.raises(ValueError, match="没有登记"):
await service._channels(session, "demo.unregistered", None, [])
finally:
await session.delete(row)
await session.commit()