合入 W4-a 口径召回:发布新版本后反查用过旧版的答案(问数回答、正式运行、固定它的版本与应用、导出),按参数分组在同一份数据上重算两版之差(差值在召回卡里算),数真变了才开 recall 待办、没变的记「已按 vN 核对,不影响」;目录事实和表结构变动标「依据有变」;版本用量与发布弹窗的召回预告、版本历史里的召回批次与接着算、recall 待办卡;团队渠道一次召回只推一条;账本接口带召回标记,对账结果可以反馈;E4

This commit is contained in:
ThearchyHelios
2026-10-16 22:28:34 +08:00
44 changed files with 5135 additions and 120 deletions
+69 -4
View File
@@ -10,8 +10,13 @@
- POST /api/ledger/answers/{run_id}/issue(answers_router,登录即可,第 3 波):按口径正式出具(app/ledger/issue.py,拍板
L1、L5:参数只从比对工件取,使用者只能按他有权使用的应用固定着的版本出具);
- POST /api/ledger/answers/{run_id}/reconcile(同上):口径对账,点开才跑一次确定性派生的只读图(app/ledger/reconcile.py);
- POST /api/ledger/answers/{run_id}/reconcile/{reconcile_id}/feedback(同上,第 4 波):对账结果上的「说明有用 / 看不懂」
(app/ledger/feedback.py,统计 E2);
- GET /api/ledger/certify-queue、POST /api/ledger/certify-queue/dismiss(router,搭建者,第 3 波):可沉淀的问题、「不是口径」
(app/ledger/certify.py)。这几个新接口都不放给访问密钥(auth/keys.KEY_ROUTES 不登记)。
(app/ledger/certify.py)。
- GET /api/ledger/recall/{workflow_id}/{version}、POST …/continue(router,搭建者,第 4 波):口径升到这一版之后的召回批次——
每组的结论、「另有 M 组未重算」、手动接着算(app/ledger/recall.py)。版本用量(workflow-versions)另加问数回答、导出、召回。
这几个新接口都不放给访问密钥(auth/keys.KEY_ROUTES 不登记)。
"""
from __future__ import annotations
@@ -24,7 +29,7 @@ from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import get_session
from app.db.base import SessionLocal, get_session
from app.db.models import Workflow
from app.db.models_process import ActionRecord
@@ -40,12 +45,18 @@ answers_router = APIRouter(prefix="/api/ledger/answers", tags=["ledger"])
@router.get("/workflow-versions/{workflow_id}/{version}")
async def workflow_version(workflow_id: str, version: int, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
"""一个工作流版本被哪些应用固定、生效记录数、正式运行数。"""
"""一个工作流版本被哪些应用固定、生效记录数、正式运行数;另加口径账本的用量(next-round 02 做法 8):被几次问数回答用过
(answers:比对里等价已证明的探索运行)、导出过几次(exports:这些答案和这一版的正式运行上的报告导出审计)、定时任务几个、
召回(recall:以这一版为新版本召回过几组、其中数变了几组,作为旧版本被召回过几组)。"""
from app.apps.versions import usage
from app.ledger.uses import version_usage
if await session.get(Workflow, workflow_id) is None:
raise HTTPException(404, "工作流不存在,可能已被删除")
return await usage(session, workflow_id, version)
base = await usage(session, workflow_id, version)
extra = await version_usage(session, workflow_id, version)
return {**base, "answers": extra["answers"], "exports": extra["exports"], "schedules": extra["schedules"],
"recall": extra["recall"]}
def effect_row(r: ActionRecord, *, receipt: bool = True) -> dict[str, Any]:
@@ -163,6 +174,60 @@ async def reconcile_answer(run_id: str, payload: ReconcileIn) -> dict[str, Any]:
return {"run_id": run.id, "reconciled": ledger_reconcile.reconciled_view(run)}
class FeedbackIn(BaseModel):
"""对账结果上的反馈:说明有用(useful)还是看不懂(unclear)。"""
verdict: str = Field(min_length=1, max_length=20)
@answers_router.post("/{run_id}/reconcile/{reconcile_id}/feedback")
async def reconcile_feedback(run_id: str, reconcile_id: str, payload: FeedbackIn) -> dict[str, Any]:
"""对账结果上的「说明有用 / 看不懂」(app/ledger/feedback.py,统计 E2):记成对账运行上的封存后事件,同一个人改主意只算
最后一次。交回这次对账现在的反馈 {mine, useful, unclear}。不认的反馈 422;不是这个答案的对账 404;读不了这个答案 403;
对账还没跑完 409。"""
from app.api.coded import CodedHTTPException
from app.api.deps import current_principal
from app.ledger import feedback
try:
out = await feedback.give(run_id, reconcile_id, payload.verdict, principal=current_principal())
except feedback.FeedbackRefused as e:
raise CodedHTTPException(e.status_code, e.message, e.code) from e
return {"run_id": reconcile_id, "feedback": out}
@router.get("/recall/{workflow_id}/{version}")
async def recall_batches(workflow_id: str, version: int, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
"""口径升到这一版之后的召回(app/ledger/recall.batches):每组(旧版本、参数)的结论——数变了 / 没变 / 无法重算:原因 /
重算失败 / 还在算 / 未重算——和「另有 M 组未重算」、能不能接着算。计划从已有的召回运行现算,不另存。"""
from app.ledger import recall
if await session.get(Workflow, workflow_id) is None:
raise HTTPException(404, "工作流不存在,可能已被删除")
return await recall.batches(session, workflow_id, version)
@router.post("/recall/{workflow_id}/{version}/continue")
async def recall_continue(workflow_id: str, version: int, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
"""接着算:把还没重算的组再发起一批(每批 recall_limit 组,上限是 0 时按缺省的组数),交回发起了的召回运行和新的批次
情况。都算过了的、这一版已经不是当前发布版的(之后又发布了更新的版本)回 409,detail 写为什么。"""
from app.api.coded import CodedHTTPException
from app.api.deps import current_principal
from app.ledger import recall
if await session.get(Workflow, workflow_id) is None:
raise HTTPException(404, "工作流不存在,可能已被删除")
current = await recall.batches(session, workflow_id, version)
if not current["can_continue"]:
raise CodedHTTPException(409, current.get("continue_reason") or "没有还没重算的组", recall.REFUSED)
launched = await recall.launch(workflow_id, version, batch="manual", actor=current_principal().username)
async with SessionLocal() as fresh:
view = await recall.batches(fresh, workflow_id, version)
return {"launched": launched, **view}
@router.get("/certify-queue")
async def certify_queue(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""可沉淀的问题(认证队列,app/ledger/certify.py):确定性重复的 SQL 形状、问题聚类、被反复用的答案。每项给代表问题、
+17
View File
@@ -577,9 +577,12 @@ async def commit_publish(session: AsyncSession, workflow: Workflow, snapshot: Wo
workflow.published_by = actor
await session.commit()
# 口径账本(next-round 02 做法 8):从问数据固化出的口径发布了,来源运行在后台按它补比一次(不挡发布)
from app.ledger import recall as ledger_recall
from app.ledger.certify import after_publish
after_publish(workflow.id, workflow.origin)
# 口径召回(next-round 02 做法 7、拍板 L6):用过旧版本的答案在后台按计划排队重算(每次最多 recall_limit 组),失败不影响发布
ledger_recall.after_publish(workflow.id, snapshot.version, actor)
return {
"ok": True,
"level": level,
@@ -590,6 +593,20 @@ async def commit_publish(session: AsyncSession, workflow: Workflow, snapshot: Wo
}
@router.get("/{workflow_id}/recall-preview")
async def recall_preview(workflow_id: str, version: int | None = None,
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""发布弹窗的召回预告(next-round 02 做法 7、8):把 version(缺省是最新保存的那一版)发布成新版本之后,旧版本被多少问数
回答、导出、定时任务、应用用过,预计自动重算几组,超出上限的几组(「另有 M 组未重算」),不能重算的原因各几组。和发布之后
真的召回走同一份计划(ledger/recall.build_plan),规模一致。只读,不发起。"""
from app.ledger import recall as ledger_recall
workflow = await session.get(Workflow, workflow_id)
if not workflow:
raise HTTPException(404, _GONE)
return await ledger_recall.preview(session, workflow_id, int(version or workflow.version))
# --------------------------------------------------------------------------
# 发布前检查与自动修复:两个接口都只读,不改库、不发布
# --------------------------------------------------------------------------
+28 -5
View File
@@ -120,7 +120,6 @@ async def graph_side_effect(session: AsyncSession, graph_json: Any, *, level: st
判断沿用应用检查对流程钉住项的那一套(apps/lint._lint_process):人工节点、写外部系统的能力
(capabilities.registry.writes_externally,和运行时、发布检查同一个判断)、process.* 问题(子工作流先读好,
process.human_in_subgraph 才查得到)。level 是那一版发布时的级别(门禁按它查),没有按 published。"""
from app.capabilities.registry import effective_effect, get_capability, writes_externally
from app.engine.governance import publish_issues
from app.engine.lint_process import load_subgraphs, with_subgraphs
from app.engine.schema import GraphSpec, NodeType
@@ -131,6 +130,34 @@ async def graph_side_effect(session: AsyncSession, graph_json: Any, *, level: st
return "的图无法解析"
if humans := [n.title for n in graph.nodes if n.type == NodeType.HUMAN]:
return f"里有人工节点「{'、'.join(humans[:3])}」"
if reason := await _external_write(session, graph):
return reason
with with_subgraphs(await load_subgraphs(session, graph)):
issues = publish_issues(graph, level=level or "published")
if process := [i for i in issues if (i.code or "").startswith("process.")]:
return f"按流程使用不安全:{process[0].message.rstrip('。')}"
return None
async def graph_external_write(session: AsyncSession, graph_json: Any) -> str | None:
"""一张图里有没有调用工具节点会写外部系统(graph_side_effect 里的那一条):有的交回一句原因,没有返回 None。
口径召回(ledger/recall)只用这一条:召回图是从口径卡往上派生的取数和口径卡,图里别处的人工节点、按流程使用的问题
碰不到它(细案做法 7 不重算的只有缺入口字段、数据部分不能复用、会写外部系统三种);替人整张图重跑的补算、按口径出具
仍然走 graph_side_effect 全套。"""
from app.engine.schema import GraphSpec
try:
graph = GraphSpec.model_validate(graph_json)
except ValueError:
return "的图无法解析"
return await _external_write(session, graph)
async def _external_write(session: AsyncSession, graph: Any) -> str | None:
from app.capabilities.registry import effective_effect, get_capability, writes_externally
from app.engine.schema import NodeType
for node in graph.nodes:
name = node.config.get("tool") if node.type == NodeType.TOOL else None
if not isinstance(name, str) or not name:
@@ -138,10 +165,6 @@ async def graph_side_effect(session: AsyncSession, graph_json: Any, *, level: st
cap = await get_capability(session, name)
if cap is not None and writes_externally(cap.name, effective_effect(cap), cap.endpoint_id):
return f"的「{node.title}」会写外部系统({name})"
with with_subgraphs(await load_subgraphs(session, graph)):
issues = publish_issues(graph, level=level or "published")
if process := [i for i in issues if (i.code or "").startswith("process.")]:
return f"按流程使用不安全:{process[0].message.rstrip('。')}"
return None
+17
View File
@@ -1184,6 +1184,23 @@ def _summary(etype: str, data: Any) -> str:
("none", "没有相关口径"), ("skipped", "没有比完"))
if isinstance(counts.get(k), int) and counts[k]]
return "封存后比对已发布口径" + (f":{'、'.join(parts)}" if parts else "")
if etype == EventType.LEDGER_RECALLED:
# 口径召回(next-round 02 做法 7):只说按哪一版核对、数变没变,不带新旧值和差值
to = data.get("to_version")
head = f"已按 v{to} 核对" if isinstance(to, int) else "口径召回核对"
changed = data.get("changed")
return head + (":数变了" if changed is True else ",不影响" if changed is False else "")
if etype == EventType.LEDGER_BASIS_CHANGED:
# 答案依据的目录事实、表结构变了:只说哪一类、几项,不带表名、列名、取值
kind = {"catalog": "目录事实", "structure": "表结构", "values": "取值", "catalog_edit": "目录"}.get(
str(data.get("kind") or ""), "")
items = data.get("items")
count = len(items) if isinstance(items, list) else 0
return f"依据的{kind or '东西'}有变" + (f"({count} 项)" if count else "")
if etype == EventType.LEDGER_FEEDBACK:
# 对账结果上的反馈(next-round 02 做法 10):只说是哪一种,不带谁给的
verdict = {"useful": "说明有用", "unclear": "看不懂"}.get(str(data.get("verdict") or ""))
return f"反馈:{verdict}" if verdict else "对账说明的反馈"
if etype == EventType.LOG and isinstance(data.get("message"), str):
return _clip(data["message"])
return ""
+2
View File
@@ -65,6 +65,8 @@
'human.requested': '请求人工审批', 'human.resolved': '人工审批结果', 'approval.inquiry': '审批质询',
'issuance': '出具判定', 'caliber.upgrade': '口径卡升版处置', 'catalog.drift': '数据目录变化',
'schema.drift': '表结构变化', 'report.checked': '报告自查', 'evidence.judged': '按需裁判', 'file.extracted': '读入附件',
'ledger.matched': '比对已发布口径', 'ledger.recalled': '口径召回核对', 'ledger.basis_changed': '答案的依据有变',
'ledger.feedback': '对账说明的反馈',
'log': '日志', 'usage': '用量',
}
const T = {
+14
View File
@@ -92,6 +92,20 @@ class EventType(StrEnum):
# algo, counts, trigger}。追加在封存之后(post_seal),是批注不是证据:比对依据的已发布口径在运行之外,不能混进运行
# 自身的封存事实;封存核对照旧一致
LEDGER_MATCHED = "ledger.matched"
# 口径召回(02 做法 7):口径发布新版本后,在同一份数据上把新旧两版各算一次(召回运行,origin_kind=recall)。召回运行
# 结束时在它自己身上记一条结论 {role: recall, changed, workflow_id, from_version, to_version, metrics, answers, tasks};
# 数没变的那几个原答案的运行上各记一条 {role: answer, changed: false, recall_run, workflow_id, from_version,
# to_version},界面上写「已按 vN 核对,不影响」(数变了的开 recall 待办,不在原答案上记)。同 ledger.matched:追加在
# 封存之后(post_seal),是批注不是证据,不进封存范围
LEDGER_RECALLED = "ledger.recalled"
# 答案依据的东西变了(02 做法 7 二、三):kind=catalog 目录里影响数字的一项被驳回或改了(冻结的目录里看得到这一项);
# structure 用到的表或列被删、列的类型变了;values 触及有效记录条件的新码值;catalog_edit 处理变化时改了目录。带数据源、
# 表、哪几项(路径和叫法),按新目录跑 SQL 检查新出现的错误级问题条数。同上,封存之后追加,不进封存范围
LEDGER_BASIS_CHANGED = "ledger.basis_changed"
# 对账结果上的「说明有用 / 看不懂」(02 做法 10 的 E2,ledger/feedback):追加在对账运行(origin_kind=reconcile)上,
# {verdict: useful | unclear, by, verified, source_run}。同一个人改主意再记一条,统计只算他最后一次。同上,封存之后
# 追加,是批注不是证据
LEDGER_FEEDBACK = "ledger.feedback"
# 应用平台(docs/design/app-platform-and-look.md §3.11)
# 运行来源:发起时写进 launch_run 的 notes,在封存范围内(engine/origin.py)
+17 -3
View File
@@ -919,7 +919,17 @@ async def review_entry(session: AsyncSession, source_id: str, table: str, path:
if if_version != current:
raise CatalogConflict(table, current)
notes = review_item(entry.notes if entry else {}, path, action)
return await write_entry(session, source_id, table, notes, if_version=current, actor=actor)
written = await write_entry(session, source_id, table, notes, if_version=current, actor=actor)
after_review(source_id, table, entry.notes if entry else None, written)
return written
def after_review(source_id: str, table: str, before: Mapping[str, Any] | None, written: CatalogEntry) -> None:
"""审阅、人工编辑、保存提案写进目录之后(已提交):影响数字的项被驳回或改了的,口径账本在后台找出依据了它的答案、标
「依据有变」(ledger/recall.after_review,docs/design/next-round/02-口径账本.md 做法 7 二)。不挡这次写入,出错只记日志。"""
from app.ledger import recall
recall.after_review(source_id, table, before, written.notes, written.version)
async def save_human_edit(session: AsyncSession, source_id: str, table: str, submitted: Mapping[str, Any], *,
@@ -939,7 +949,9 @@ async def save_human_edit(session: AsyncSession, source_id: str, table: str, sub
schema_cache = getattr(row, "schema_cache", None)
notes = apply_human_edit(entry.notes if entry else {}, submitted, table=table,
inferable=structure_relation_ids(schema_cache, table))
return await write_entry(session, source_id, table, notes, if_version=current, actor=actor)
written = await write_entry(session, source_id, table, notes, if_version=current, actor=actor)
after_review(source_id, table, entry.notes if entry else None, written)
return written
# ==========================================================================
@@ -1251,7 +1263,9 @@ async def save_patch(session: AsyncSession, source_id: str, table: str, changes:
if if_version != current:
raise CatalogConflict(table, current)
notes = apply_patch(entry.notes if entry else {}, changes, table=table, tables=tables)
return await write_entry(session, source_id, table, notes, if_version=current, actor=actor)
written = await write_entry(session, source_id, table, notes, if_version=current, actor=actor)
after_review(source_id, table, entry.notes if entry else None, written)
return written
# ==========================================================================
+4 -1
View File
@@ -22,7 +22,8 @@
使用次数比运行次数多时两个都写(「被 2 次运行用到,使用 5 次」),和清单、运行列表上的数都对得上。
- **批量审阅**(review_batch,POST /catalog/review/batch):逐表按 if_version 乐观锁,同一张表的几条在一次写入里完成,
一张表冲突、写不进去不影响别的表;路径不对、表不存在逐条报(results 和请求一一对应)。审阅的规则同单项审阅
(catalog.review_item)。
(catalog.review_item)。写进去之后影响数字的项被驳回了的,口径账本在后台标依据了它的答案「依据有变」
(catalog.after_review → ledger/recall,next-round 02 做法 7 二)。
- **被哪些运行用过**(table_runs,GET /catalog/{table}/usage):取法同使用次数(catalog.query_snapshot_tables),按运行
去重,新的在前;total 是运行次数。
- **进度**(review_progress,GET /catalog/review-progress):常用表(使用次数前 TOP_N 张,和起草对话框「使用次数最多的
@@ -379,6 +380,8 @@ async def review_batch(session: AsyncSession, source: Any, items: Iterable[Batch
continue
for i in applied:
results[i] = _result(items[i], ok=True, version=written.version)
# 驳回了影响数字的项:口径账本在后台标依据了它的答案「依据有变」(catalog.after_review,同单项审阅)
catalog.after_review(source.id, key, entry.notes if entry else None, written)
return [r for r in results if r is not None]
+17 -3
View File
@@ -11,7 +11,9 @@
起草(建起草任务)、重新剖析(建剖析任务)、知道了、误报(必须写理由)。每个动作写 resolution,动了目录的记下目录版本,
建了任务的记下任务编号。
- **拉取**(list_changes / affecting):给召回按 seq 游标拉;seq 自增、不复用。触发召回的只有结构类、处理时改了目录的、
以及 rules_touched 的新码值(triggers_recall);纯取值漂移只算数据质量提醒。
以及 rules_touched 的新码值(triggers_recall);纯取值漂移只算数据质量提醒。探查、剖析任务结束时(notify_after_job)
口径账本在后台按游标接着拉(ledger/recall.consume_source_changes);处理变化时改了目录的那一条当场交过去
(ledger/recall.process_change:它记下来的时候游标早已经过去了)。
- **说法**(summary):一条变化给人看的一句(接口、日志用);通知里不放它(不放表名、列名、取值,D9),见
notify/service.notify_source_changed。
@@ -340,10 +342,17 @@ async def notify_after_job(source_id: str, job_id: str) -> int:
from app.notify import service
service.spawn(service.notify_source_changed(source_id))
return n
except Exception: # noqa: BLE001
logger.exception("任务 %s 结束后推送数据源变化的通知时出错", job_id)
return 0
n = 0
try:
# 口径账本的召回(next-round 02 做法 7 三):这次记下的变化里表或列被删、类型变了的,标用过它的答案「依据的表结构已变」
from app.ledger import recall
recall.spawn(recall.consume_source_changes())
except Exception: # noqa: BLE001 - 辅助步骤
logger.exception("任务 %s 结束后排队召回时出错", job_id)
return n
# ==========================================================================
@@ -469,6 +478,11 @@ async def handle(session: AsyncSession, source: Any, row: SourceChange, action:
if version is not None:
out["catalog_version"] = version
out["change"] = change_out(row)
if version is not None and triggers_recall(out["change"]):
# 处理时改了目录:用过这张表的答案「依据有变」(口径账本,后台;游标早已过了这一条,当场交过去)
from app.ledger import recall
recall.spawn(recall.process_change(out["change"]))
return out
+3 -2
View File
@@ -17,9 +17,10 @@ from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, TimestampMixin, UTCDateTime, new_id, utcnow
#: 待办的种类。人工节点三种 + 现有的几种中断 + 动作确认、核实、等外部系统 + 个人应用提交审核(review,
#: docs/design/normal-user-apps.md N7,app/process/tasks.REVIEW_KIND)
#: docs/design/normal-user-apps.md N7,app/process/tasks.REVIEW_KIND)+ 口径召回(recall,docs/design/next-round/02-口径账本.md
#: 做法 7:口径升版后答案的数变了、答案依据的目录或表结构变了;挂在召回运行或答案的运行上,没有 Approval,app/ledger/recall)
TASK_KINDS = ("approve", "input", "edit", "tool", "code", "dual_check", "action_confirm", "reconcile", "signal",
"review")
"review", "recall")
#: pending 等人;resolving 已凑齐、正在恢复运行(条件 UPDATE 抢到的那个请求负责);resolved / cancelled / expired 终态
TASK_STATUSES = ("pending", "resolving", "resolved", "cancelled", "expired")
#: 动作状态。simulated:探索运行里端点没有 test 环境时的演练,不发送
+11 -1
View File
@@ -133,15 +133,20 @@ class RunManager:
# 口径账本:最近几天封存了、一次都没比过的探索运行,在后台补比一次(ledger/match.py)。只读工件和版本表,不查业务库、
# 不调模型;和调度循环同一个开关,拷来的数据目录、测试不补
from app.ledger import match as ledger_match
from app.ledger import recall as ledger_recall
ledger_match.start_backfill()
# 口径召回(ledger/recall.py):最近发布、自动那一批没发完的接着发,结束了还没收尾的召回运行补上收尾。同一个开关
ledger_recall.start_resume()
async def shutdown(self) -> None:
self._closing = True
# 后台的口径比对先停:没做完的不记,下次启动补比会再比
# 后台的口径比对、召回先停:没做完的不记,下次启动补比、接着召回会再做
from app.ledger import match as ledger_match
from app.ledger import recall as ledger_recall
await ledger_match.shutdown()
await ledger_recall.shutdown()
# 只等还没结束的:结束了的没什么可等,而且它可能属于另一个事件循环,
# gather 会直接抛 "future belongs to a different loop"
pending = [t for t in self._tasks.values() if not t.done()]
@@ -1084,6 +1089,11 @@ class RunManager:
if ledger_match.wanted(run_class, origin_kind):
ledger_match.schedule(run_id)
if origin_kind == "recall" and status in ("succeeded", "failed", "cancelled"):
# 口径召回运行结束、封存完(ledger/recall.py):在后台收尾——数变了开 recall 待办,没变的在原答案上记账
from app.ledger import recall as ledger_recall
ledger_recall.schedule_settle(run_id)
async def _seal(self, run_id: str) -> None:
"""对到此为止的全部事件算清单哈希,记下封到了哪一条。"""
+56 -9
View File
@@ -17,16 +17,16 @@ cell(nodes.X, 0, '列')、X 是查库的调用工具节点(look/calibers.card_
拒收、给原因(NotDerivable)。工作流、版本、节点不在了也照实说。
**已实现的**:caliber_probe —— 一份口径的取数 + 口径卡(评测口径题的期望);reconcile —— 口径对账(第 3 波,
reconcile_graph);组合式问数用的 caliber_block(card_id, inputs)(第 3 波)。
reconcile_graph);组合式问数用的 caliber_block(card_id, inputs)(第 3 波);recall —— 口径召回(第 4 波,recall_graph)。
**扩展点**(第 3 波的账本按这里扩,不另写生成器):
- 口径对账(02 做法 6):assemble("reconcile", [ours, caliber_block(pinned)], tail=[对账卡])。ours 是「这次的取数」
(执行过的 SQL 原文,节点 id ours_q1…,build 一个 Block 即可,不经 caliber_block),对账卡是本地 metrics 节点(差值、
相对差,式子引用 cell(nodes.ours_q1, 0, '列') 和口径卡的指标,on_missing=null),出口的契约写 metrics_from。
- 口径召回(02 做法 7):assemble("recall", [caliber_block(old, prefix="old_"), caliber_block(new, prefix="new_")],
tail=[召回卡])。同一张图里放两份口径,取数节点 id 会撞:caliber_block 带 prefix 时把拷出来的节点改名,口径卡的
caliber_from 带上改名映射 nodes={原 id: 新 id},运行时 metrics._pinned_caliber 按语法树改写式子里的 cell(nodes.X…)
和 nodes.X(第 3 波起)。
- 口径召回(02 做法 7,recall_graph):assemble("recall", [caliber_block(old, prefix="old_"), caliber_block(new,
prefix="new_")], tail=[召回卡])。同一张图里放两份口径,取数节点 id 会撞:caliber_block 带 prefix 时把拷出来的节点改名,
口径卡的 caliber_from 带上改名映射 nodes={原 id: 新 id},运行时 metrics._pinned_caliber 按语法树改写式子里的
cell(nodes.X…) 和 nodes.X(第 3 波起)。
- 组合式问数(02 做法 4,ledger/compose.py):不经 assemble(拷进的是问数据那张图),只用 caliber_block(prefix=…,
card_id=…, inputs=…):取数节点改名、模板按助手给的参数渲染成字面 SQL,口径卡带改名映射。
- 新的派生种类登记进 KINDS,入口字段、出口、首尾相接都走 assemble,不在调用方拼节点。
@@ -44,7 +44,7 @@ from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
#: 派生图的种类。caliber_probe、reconcile 已实现;recall 归第 4 波(见模块说明的扩展点)
#: 派生图的种类(见模块说明)
KINDS = ("caliber_probe", "reconcile", "recall")
#: 不能派生的原因的机器码
@@ -380,6 +380,53 @@ def reconcile_graph(pinned: Pinned, ours: Block, tail: Sequence[dict[str, Any]],
contract={"metrics_from": RECONCILE_CARD} if card is not None else None)
#: 召回图里的召回卡(细案 02 做法 7)
RECALL_CARD = "recall"
def recall_prefixes(i: int) -> tuple[str, str]:
"""召回图里第 i 对口径卡(旧版、新版)各自的前缀:第一对 old_ / new_,之后 old2_ / new2_……(同一版里两张卡读同一个
取数节点时,各拷一份也不撞)。"""
n = "" if i == 0 else str(i + 1)
return f"old{n}_", f"new{n}_"
def recall_fields(old: Pinned, new: Pinned, inputs: Mapping[str, Any]) -> list[dict[str, Any]]:
"""召回图的入口:新版的入口字段在前(类型、必填照新版),旧版有、新版没有的接在后面;缺省值都换成这一组的参数。"""
fields: list[dict[str, Any]] = []
seen: set[str] = set()
for f in [*new.entry_fields, *old.entry_fields]:
name = str(f.get("name") or "")
if not name or name in seen:
continue
seen.add(name)
item = copy.deepcopy(dict(f))
if name in inputs:
item["default"] = inputs[name]
fields.append(item)
return fields
def recall_graph(pairs: Sequence[tuple[Pinned, Pinned]], tail: Sequence[dict[str, Any]], *,
inputs: Mapping[str, Any]) -> DerivedGraph:
"""口径召回(细案 02 做法 7):入口(这一组的参数)→ 每一对口径卡:旧版的取数 + 旧版口径卡(caliber_from 固定旧版)、
新版的取数 + 新版口径卡(固定新版),两版的取数节点各带前缀、口径卡的 caliber_from 带改名映射(不撞 id)→ tail(召回卡:
两版之差在这里算,ledger/recall.recall_card)→ 出口(契约 metrics_from 指向召回卡;不加报告、不调模型)。
两版在同一次运行里先后查同一份数据:差值只反映口径的变化(数据随时间的变化另拿原答案当时的值对照着说)。入口字段按
第一对的新版、旧版合起来(recall_fields)。同样的输入永远得到同一张图。"""
if not pairs:
raise ValueError("召回图至少要一对口径卡")
blocks: list[Block] = []
for i, (old, new) in enumerate(pairs):
old_prefix, new_prefix = recall_prefixes(i)
blocks += [caliber_block(old, prefix=old_prefix), caliber_block(new, prefix=new_prefix)]
old, new = pairs[0]
card = next((n for n in tail if n.get("type") == "metrics" and n.get("id") == RECALL_CARD), None)
return assemble("recall", blocks, fields=recall_fields(old, new, inputs), defaults=new.defaults or old.defaults,
tail=tail, contract={"metrics_from": RECALL_CARD} if card is not None else None)
def caliber_probe(pinned: Pinned, *, upgrade_policy: str | None = None) -> DerivedGraph:
"""口径探针:一份口径的取数 + 口径卡(评测口径题的期望,细案 01 做法 8)。节点 id 和原图一样,入口字段照抄。
同样的 pinned 永远得到同一张图(确定性)。"""
@@ -388,6 +435,6 @@ def caliber_probe(pinned: Pinned, *, upgrade_policy: str | None = None) -> Deriv
__all__ = ["ALIGN_ID", "Block", "DerivedGraph", "ENTRY_ID", "EXIT_ID", "GONE", "KINDS", "NOT_CARD", "NOT_REUSABLE",
"NotDerivable", "OURS_ID", "Pinned", "RECONCILE_CARD", "TEMPLATE", "assemble", "caliber_block",
"caliber_probe", "entry_fields", "load_pinned", "ours_block", "reconcile_graph", "reconcile_prefix", "render_inputs",
"template_problem"]
"NotDerivable", "OURS_ID", "Pinned", "RECALL_CARD", "RECONCILE_CARD", "TEMPLATE", "assemble", "caliber_block",
"caliber_probe", "entry_fields", "load_pinned", "ours_block", "recall_fields", "recall_graph",
"recall_prefixes", "reconcile_graph", "reconcile_prefix", "render_inputs", "template_problem"]
+108
View File
@@ -0,0 +1,108 @@
"""对账结果上的「说明有用 / 看不懂」(docs/design/next-round/02-口径账本.md 做法 10 的 E2)。
对账(ledger/reconcile)把这次的答案和一份相关口径在同一份数据上各算一次,结果卡片上写差在哪、差多少。看的人觉得这段
说明有没有帮他看懂,点一下「说明有用」或「看不懂」——这是 E2 里唯一要人给的数,衡量差异说明写得好不好。
- 记在哪:对账运行上的封存后事件 ledger.feedback({verdict, by, verified, source_run},engine/run_append,post_seal)。
运行本身就是记录,不另建反馈表(同统计「全部现算、不另存」);批注不是证据,封存核对照旧一致。
- 谁能给:能读这个答案的人(同发起对账,ledger/reconcile:使用者对自己的答案也能对账);对账要是这个答案的(origin.source_run),
而且已经跑完封存。
- 怎么数:同一个人(开登录时按用户名;关登录时署名是自报的,按署名、没署名的算同一个)改主意再记一条,只算他最后一次。
账本接口 reconciled 每一项带上 {mine, useful, unclear}(feedback_of),统计 E2 数整个窗口(counts)。
"""
from __future__ import annotations
from collections.abc import Collection, Iterable
from typing import Any
from sqlalchemy import select
from app.core.events import EventType
from app.db.base import SessionLocal
from app.db.models import Run, RunEvent
#: 两种反馈
VERDICTS = ("useful", "unclear")
#: 被拒的机器码
REFUSED = "ledger_feedback_refused"
NOT_FOUND = "这次对账不存在,或者不是这个答案的对账"
NO_ACCESS = "你没有权限查看这个答案"
NOT_SEALED = "对账还没运行完,等结果出来再评说明有没有用"
BAD_VERDICT = "反馈只有两种:说明有用、看不懂"
class FeedbackRefused(Exception):
def __init__(self, status_code: int, message: str, code: str = REFUSED) -> None:
super().__init__(message)
self.status_code = status_code
self.message = message
self.code = code
def _who(principal: Any) -> str:
return str(getattr(principal, "username", None) or "")
async def give(answer_id: str, reconcile_id: str, verdict: str, *, principal: Any) -> dict[str, Any]:
"""记一条反馈(见模块说明),交回这次对账现在的反馈 {mine, useful, unclear}。不能记的抛 FeedbackRefused。"""
from app.api.deps import can_read_run
from app.engine.run_append import append_events
from app.ledger.reconcile import ORIGIN as RECONCILE
if verdict not in VERDICTS:
raise FeedbackRefused(422, BAD_VERDICT)
async with SessionLocal() as session:
answer = await session.get(Run, answer_id)
rec = await session.get(Run, reconcile_id)
origin = rec.origin if rec is not None and isinstance(rec.origin, dict) else {}
if answer is None or rec is None or rec.origin_kind != RECONCILE or origin.get("source_run") != answer_id:
raise FeedbackRefused(404, NOT_FOUND)
if getattr(principal, "mode", "off") != "off" and not await can_read_run(principal, answer):
raise FeedbackRefused(403, NO_ACCESS)
data = {"verdict": verdict, "by": _who(principal) or None, "verified": bool(getattr(principal, "verified", False)),
"source_run": answer_id}
if not await append_events(reconcile_id, [(EventType.LEDGER_FEEDBACK, None, data)], require="sealed"):
raise FeedbackRefused(409, NOT_SEALED)
async with SessionLocal() as session:
return (await feedback_of(session, [reconcile_id], principal))[reconcile_id]
async def _latest(session: Any, run_ids: Collection[str]) -> dict[str, dict[str, str]]:
"""{对账运行: {谁: 他最后一次给的}}。"""
out: dict[str, dict[str, str]] = {}
if not run_ids:
return out
rows = (await session.execute(select(RunEvent.run_id, RunEvent.data).where(
RunEvent.run_id.in_(list(run_ids)), RunEvent.type == EventType.LEDGER_FEEDBACK).order_by(RunEvent.seq))).all()
for run_id, data in rows:
if isinstance(data, dict) and data.get("verdict") in VERDICTS:
out.setdefault(run_id, {})[str(data.get("by") or "")] = str(data["verdict"])
return out
async def feedback_of(session: Any, run_ids: Iterable[str], principal: Any) -> dict[str, dict[str, Any]]:
"""每次对账的反馈:{对账运行: {mine: 看的这个人最后一次给的(没给过为 None), useful: 几个人, unclear: 几个人}}。"""
ids = list(dict.fromkeys(run_ids))
latest = await _latest(session, ids)
me = _who(principal)
out: dict[str, dict[str, Any]] = {}
for run_id in ids:
votes = latest.get(run_id, {})
out[run_id] = {"mine": votes.get(me), "useful": sum(v == "useful" for v in votes.values()),
"unclear": sum(v == "unclear" for v in votes.values())}
return out
async def counts(session: Any, run_ids: Collection[str]) -> dict[str, Any]:
"""统计 E2 的反馈(ledger/stats._e2):这些对账运行上,说明有用、看不懂各几个(每人只算最后一次),给过反馈的对账几次,
有用的比率(分母为 0 时为 None)。"""
latest = await _latest(session, run_ids)
useful = sum(v == "useful" for votes in latest.values() for v in votes.values())
unclear = sum(v == "unclear" for votes in latest.values() for v in votes.values())
total = useful + unclear
return {"available": True, "useful": useful, "unclear": unclear, "runs": sum(1 for v in latest.values() if v),
"useful_rate": round(useful / total, 4) if total else None}
__all__ = ["BAD_VERDICT", "NOT_FOUND", "NOT_SEALED", "REFUSED", "VERDICTS", "FeedbackRefused", "counts", "feedback_of",
"give"]
+65 -42
View File
@@ -208,12 +208,7 @@ async def plan_issue(session: Any, run: Run | None, content: dict[str, Any] | No
hide 判一个绑定值是不是这次运行查到过的遮罩值。只读,不发起。"""
from app.api.deps import can_read_run
from app.api.settings import ledger_settings
from app.apps import versions as app_versions
from app.apps.backfill import graph_side_effect
from app.apps.forms import input_schema, validate_form
from app.apps.resolve import input_key
from app.ledger import match as ledger_match
from app.ledger.derive import entry_fields
from app.ledger.labels import caliber_key
plan = Plan(run_id=run.id if run else "", workflow_id=workflow_id, version=int(version),
@@ -237,43 +232,9 @@ async def plan_issue(session: Any, run: Run | None, content: dict[str, Any] | No
plan.binding = dict(whole.get("binding") or {})
if hide is not None and any(hide(v) for v in plan.binding.values()):
return plan.refuse(MASKED_BINDING)
if workflow is None:
return plan.refuse(GONE, 404)
row = await app_versions.version_row(session, workflow_id, int(version))
if row is None:
return plan.refuse(VERSION_GONE.format(name=workflow.name, version=version), 404)
if row.retired_at is not None:
return plan.refuse(RETIRED.format(name=workflow.name, version=version))
if not app_versions.is_published(row, current=workflow.published_version):
return plan.refuse(UNPUBLISHED.format(name=workflow.name, version=version))
plan.level = row.level or plan.level
plan.expect_hash = row.graph_hash
if reason := await graph_side_effect(session, row.graph, level=row.level):
return plan.refuse(SIDE_EFFECT.format(name=workflow.name, version=version, reason=reason))
fields = list(entry_fields(row.graph or {}))
values = {str(f["name"]): f.get("default") for f in fields if f.get("default") not in (None, "")}
values.update({k: v for k, v in plan.binding.items() if v not in (None, "")})
plan.inputs = values
if errors := validate_form(input_schema(fields), values):
return plan.refuse(INPUTS.format(problems=";".join(e["message"] for e in errors)))
# 参数运行时要渲染进口径的 SQL 模板:写在引号里的不能带引号、裸写的只能是数(ledger/compose 同一个判断)
from app.ledger.compose import slot_contexts, value_problem
contexts = slot_contexts([n for n in (row.graph or {}).get("nodes") or [] if n.get("type") == "tool"],
strict=False)
if problem := next((p for k, v in values.items() if (p := value_problem(k, v, contexts.get(k, set())))), None):
return plan.refuse(f"{problem},不代填出具")
if principal.mode != "off" and not principal.at_least("builder"):
plan.granted_by_app = await _granting_app(session, principal, workflow_id, int(version))
if plan.granted_by_app is None:
return plan.refuse(ASK_BUILDER, 403, FORBIDDEN)
key = input_key(values)
plan.existing = next((r.id for r in await issued_runs(session, run.id)
if (r.workflow_id, r.version) == (workflow_id, int(version)) and r.status in _LIVE
and input_key(r.input) == key), None)
if await check_target(session, plan, workflow, principal=principal, source_run=run.id) is None:
return plan
settings = await ledger_settings(session)
if plan.existing is None and await issued_today(session) >= int(settings["issue_daily_limit"]):
return plan.refuse(OVER_LIMIT.format(limit=settings["issue_daily_limit"]), 429, DAILY_LIMIT)
if not settings["auto_issue"]:
plan.auto_reason = AUTO_OFF
elif run.origin_kind != "chat":
@@ -287,6 +248,68 @@ async def plan_issue(session: Any, run: Run | None, content: dict[str, Any] | No
return plan
async def check_target(session: Any, plan: Plan, workflow: Any, *, principal: Any, source_run: str) -> Plan | None:
"""按口径出具的那一版能不能发起(plan_issue 和召回待办的「按新版重新出具」共用,ledger/recall.reissue):那一版已发布、
未退役;图里没有人工节点、不会写外部系统;入口字段的缺省值加上 plan.binding 过表单校验、能渲染进取数模板;使用者只能按
他有权使用的应用固定着的这一版(拍板 L5);同一个来源(source_run)、同一版、同一组参数发起过的交回那一次;当天没超
上限。填好 plan 的 level、expect_hash、inputs、granted_by_app、existing;不能出具的写进 plan.refuse,交回 None。"""
from app.api.settings import ledger_settings
from app.apps import versions as app_versions
from app.apps.backfill import graph_side_effect
from app.apps.forms import input_schema, validate_form
from app.apps.resolve import input_key
from app.ledger.derive import entry_fields
workflow_id, version = plan.workflow_id, plan.version
if workflow is None:
plan.refuse(GONE, 404)
return None
row = await app_versions.version_row(session, workflow_id, int(version))
if row is None:
plan.refuse(VERSION_GONE.format(name=workflow.name, version=version), 404)
return None
if row.retired_at is not None:
plan.refuse(RETIRED.format(name=workflow.name, version=version))
return None
if not app_versions.is_published(row, current=workflow.published_version):
plan.refuse(UNPUBLISHED.format(name=workflow.name, version=version))
return None
plan.level = row.level or plan.level
plan.expect_hash = row.graph_hash
if reason := await graph_side_effect(session, row.graph, level=row.level):
plan.refuse(SIDE_EFFECT.format(name=workflow.name, version=version, reason=reason))
return None
fields = list(entry_fields(row.graph or {}))
values = {str(f["name"]): f.get("default") for f in fields if f.get("default") not in (None, "")}
values.update({k: v for k, v in plan.binding.items() if v not in (None, "")})
plan.inputs = values
if errors := validate_form(input_schema(fields), values):
plan.refuse(INPUTS.format(problems=";".join(e["message"] for e in errors)))
return None
# 参数运行时要渲染进口径的 SQL 模板:写在引号里的不能带引号、裸写的只能是数(ledger/compose 同一个判断)
from app.ledger.compose import slot_contexts, value_problem
contexts = slot_contexts([n for n in (row.graph or {}).get("nodes") or [] if n.get("type") == "tool"],
strict=False)
if problem := next((p for k, v in values.items() if (p := value_problem(k, v, contexts.get(k, set())))), None):
plan.refuse(f"{problem},不代填出具")
return None
if principal.mode != "off" and not principal.at_least("builder"):
plan.granted_by_app = await _granting_app(session, principal, workflow_id, int(version))
if plan.granted_by_app is None:
plan.refuse(ASK_BUILDER, 403, FORBIDDEN)
return None
key = input_key(values)
plan.existing = next((r.id for r in await issued_runs(session, source_run)
if (r.workflow_id, r.version) == (workflow_id, int(version)) and r.status in _LIVE
and input_key(r.input) == key), None)
settings = await ledger_settings(session)
if plan.existing is None and await issued_today(session) >= int(settings["issue_daily_limit"]):
plan.refuse(OVER_LIMIT.format(limit=settings["issue_daily_limit"]), 429, DAILY_LIMIT)
return None
return plan
async def _conversation_of(session: Any, run: Run) -> dict[str, str]:
"""这次运行出自哪段对话的哪一轮:运行来源里封存的(origin_ref,第 2 波);老运行退回 conversation_turns.run_id。"""
from app.db.models import ConversationTurn
@@ -392,4 +415,4 @@ async def auto_issue(run_id: str) -> str | None:
__all__ = ["AUTO_NOT_CHAT", "DAILY_LIMIT", "FORBIDDEN", "ORIGIN", "REFUSED", "IssueRefused", "Plan", "auto_issue",
"issue", "issued_runs", "issued_today", "issued_view", "plan_issue", "plans_for"]
"check_target", "issue", "issued_runs", "issued_today", "issued_view", "plan_issue", "plans_for"]
File diff suppressed because it is too large Load Diff
+53 -9
View File
@@ -11,14 +11,15 @@
的查询里各自的比率;证明等价的绑定是在哪一级配上的;没证明的原因(不可比、参数绑不上、配上了但不等价);不等价的差异类型
分布(time / filter / aggregate / join / other / parse,每份口径的差异每类只计一次);逐个数的标签各几个、整题等价几次。
- E2 对账:对账运行(origin=reconcile,第 3 波起由 ledger/reconcile 发起)发起、完成、失败各几次,对过账的答案几个,其中参数
经人核对过的、对照的指标由人选的各几次;相关口径的差异说明里兜底说明的占比。「说明有用 / 看不懂」的反馈要等对账面板
(第 4 波)记成对账运行上的封存后事件,这之前照实写没有数据。
经人核对过的、对照的指标由人选的各几次;相关口径的差异说明里兜底说明的占比;对账结果上「说明有用 / 看不懂」的反馈
(第 4 波,ledger/feedback:对账运行上的封存后事件 ledger.feedback,每人只算最后一次)各几个、有用的比率。
- E3 重复度:问数据运行里执行过的查询按(数据源、normalize_sql 文本)分组,出现在 ≥ REPEAT_RUNS 次运行、来自 ≥ REPEAT_CONVERSATIONS
段对话的算重复的形状,给出这样的形状几个、有重复形状的运行占几成;问题聚类(app/chat/questions:全站唯一的聚类,只取问题
原文、不含 history、排除系统派生的运行)里 ≥ REPEAT_RUNS 个问题的类占几成。认证队列的转化(第 3 波,ledger/certify):固化成
口径草稿几份(origin.freeze 的工作流)、其中发布了几份、点了「不是口径」几项。
- E4 召回:每个口径版本被用了多少次(比对里等价已证明的运行数、这一版的正式运行数)。每次发布召回了几组、其中数变了的比例要等
召回(第 4 波),这之前照实写没有数据,只给召回运行的次数。
- E4 召回:每个口径版本被用了多少次(比对里等价已证明的运行数、这一版的正式运行数);每次发布(口径升到某一版)召回了几组、
收过尾的几组、其中数变了的几组和比例、重算失败的几组(ledger/recall:召回运行和它收尾时记的 ledger.recalled,第 4 波)。
按召回运行的发起时间筛窗口;表结构变了之后的口径试算不算召回。
"""
from __future__ import annotations
@@ -41,9 +42,6 @@ ASPECT_GROUP = {"time": "time", "time_range": "time", "filter": "filter", "aggre
"group_by": "aggregate", "join": "join", "tables": "join", "parse": "parse"}
DIFF_GROUPS = ("time", "filter", "aggregate", "join", "other", "parse")
STATUSES = ("proven", "composed", "related", "none", "skipped")
#: 没有数据时的说法(这一块的功能还没做)
NOT_YET_RECONCILE = "对账面板的反馈在第 4 波接上,目前没有数据"
NOT_YET_RECALL = "召回在第 4 波接上,目前没有数据"
#: 聚类最多取几个问题(同治理接口的上限)
QUESTION_LIMIT = 500
@@ -154,8 +152,11 @@ async def _e2(session: Any, contents: list[dict[str, Any]], **scope: Any) -> dic
stmt = stmt.where(Run.created_at >= scope["since"])
if scope.get("until") is not None:
stmt = stmt.where(Run.created_at < scope["until"])
from app.ledger import feedback as ledger_feedback
by_status = {s: int(n) for s, n in (await session.execute(stmt.group_by(Run.status))).all()}
origins = [o for (o,) in (await session.execute(stmt.with_only_columns(Run.origin))).all() if isinstance(o, dict)]
reconcile_ids = [r for (r,) in (await session.execute(stmt.with_only_columns(Run.id))).all()]
answers = {o.get("source_run") for o in origins if o.get("source_run")}
explained = fallback = 0
for content in contents:
@@ -170,7 +171,7 @@ async def _e2(session: Any, contents: list[dict[str, Any]], **scope: Any) -> dic
"pairs_chosen": sum(1 for o in origins if o.get("pairs_chosen")),
"explained": explained, "fallback": fallback,
"fallback_rate": _ratio(fallback, explained),
"feedback": {"available": False, "note": NOT_YET_RECONCILE}}
"feedback": await ledger_feedback.counts(session, reconcile_ids)}
#: 查询快照 → (数据源, 形状)。工件按内容寻址、不会变,记下来不会过期
@@ -294,7 +295,50 @@ async def _e4(session: Any, contents: list[dict[str, Any]], **scope: Any) -> dic
usage = [{"caliber": key, "workflow_id": key.rsplit("@", 1)[0], "version": int(key.rsplit("@", 1)[1]),
"proven_runs": proven.get(key, 0), "formal_runs": formal.get(key, 0)}
for key in sorted(set(proven) | set(formal))]
return {"usage": usage, "recall_runs": recalls, "per_publish": {"available": False, "note": NOT_YET_RECALL}}
return {"usage": usage, "recall_runs": recalls, "per_publish": await _per_publish(session, **scope)}
async def _per_publish(session: Any, **scope: Any) -> dict[str, Any]:
"""每次发布召回了几组、其中数变了的比例(E4 下半):按(工作流、新版本)分,读召回运行收尾时记的结论。"""
from app.core.events import EventType
from app.db.models import Run, RunEvent
stmt = select(Run.id, Run.origin).where(Run.origin_kind == "recall")
if scope.get("run_ids") is not None:
stmt = stmt.where(Run.id.in_(list(scope["run_ids"])))
else:
if scope.get("since") is not None:
stmt = stmt.where(Run.created_at >= scope["since"])
if scope.get("until") is not None:
stmt = stmt.where(Run.created_at < scope["until"])
runs = [(rid, o) for rid, o in (await session.execute(stmt)).all()
if isinstance(o, dict) and not o.get("probe") and o.get("workflow_id")]
settled: dict[str, dict[str, Any]] = {}
if runs:
rows = (await session.execute(select(RunEvent.run_id, RunEvent.data).where(
RunEvent.run_id.in_([r for r, _ in runs]), RunEvent.type == EventType.LEDGER_RECALLED)
.order_by(RunEvent.seq))).all()
for rid, data in rows:
if isinstance(data, dict) and data.get("role") == "recall":
settled[rid] = data
by: dict[tuple[str, int], dict[str, int]] = {}
for rid, origin in runs:
key = (str(origin["workflow_id"]), int(origin.get("to_version") or 0))
item = by.setdefault(key, {"groups": 0, "settled": 0, "changed": 0, "failed": 0})
item["groups"] += 1
data = settled.get(rid)
if data is None:
continue
item["settled"] += 1
if data.get("outcome") == "failed":
item["failed"] += 1
elif data.get("changed"):
item["changed"] += 1
items = [{"caliber": ledger_labels.caliber_key(w, v), "workflow_id": w, "version": v, **c,
"changed_rate": _ratio(c["changed"], c["settled"] - c["failed"])}
for (w, v), c in sorted(by.items())]
return {"available": True, "publishes": items, "groups": sum(i["groups"] for i in items),
"changed": sum(i["changed"] for i in items)}
async def compute_stats(session: Any, *, since: datetime | None = None, until: datetime | None = None,
+382
View File
@@ -0,0 +1,382 @@
"""一份口径的旧版本被谁用过(docs/design/next-round/02-口径账本.md 做法 7 一):口径发布新版本之后,召回要追的那些答案。
**为什么单独一个模块。** 召回(ledger/recall)要按参数去重成组、重算,发布弹窗要预告规模,版本历史要写「这一版被多少问数
回答、导出、召回用过」——三处要的是同一份「用过」的清单。各数各的,预告的规模和实际召回的就对不上(验收要求两者一致)。
反查的对象(工作流 W 的旧版本 V,只看 V < 新版本):
- **问数回答**(answer):caliber_match 工件的 meta 里记着 W@V 等价已证明(含助手选用)的探索运行,取这次运行现在这次封存
的最新一份比对(ledger/store.latest:之后补比过、按新口径不再等价的不算),每条等价的查询一份参数(绑定);
- **W@V 的正式运行**:定时任务发起的(schedule,每个定时任务最近 PERIODS 期)、应用发起的(app,每个应用最近 PERIODS 次)、
别处发起的(formal:手动、接口、按口径出具……),参数是那次运行的输入;
- **固定了 W@V 的工作流版本发起的正式运行**(pinned:子工作流或口径卡 caliber_from 固定在 W@V,apps/versions.pinned_upstream
同一个认法),参数是那次运行的输入里 W 的入口有的字段;
- **固定这一版的应用**(apps/versions.pinning_apps):应用发起过的正式运行在上面;还没有运行的只列出来,没有可重算的参数;
- **导出记录**:上面这些运行上的 report.exported 审计(第 2 波),导出人也是答案的主人;
- **问数轮次**:运行 origin 里封存的 conversation_id / turn_id(origin_ref,第 2 波),老运行退回 conversation_turns.run_id。
**答案的主人**(owners,召回待办派给谁):对话的 created_by(没有的退回运行的发起人)、导出人、定时任务的 created_by、应用的
管理者(授权里的 manage,没有就是建应用的人);别处发起的正式运行是它的发起人。只记 (user|group, 名字),组由开待办时展开。
只读应用库:不查业务库、不调模型。系统派生的运行(评测、对账、召回,engine/origin.counted_runs)不算「用过」。数量有上限
(MAX_ANSWERS、MAX_FORMAL),按新的在前取,超出的照实算进 truncated。
"""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Artifact, Run, Workflow, WorkflowVersion
#: 用法的种类。answer 问数回答;schedule 定时任务发起的正式运行;app 应用发起的;formal 别处发起的;pinned 固定了这一版的
#: 别的工作流版本发起的
KINDS = ("answer", "schedule", "app", "formal", "pinned")
#: 每个定时任务、每个应用最近几期(细案「定时任务最近 K 期」)
PERIODS = 4
#: 最多看多少次问数回答、多少次正式运行(各按新的在前)
MAX_ANSWERS = 2000
MAX_FORMAL = 500
#: 种类的叫法(版本用量、召回批次、待办里写「来自问数据」……)
KIND_TEXT = {"answer": "问数回答", "schedule": "定时任务", "app": "应用", "formal": "正式运行",
"pinned": "固定了它的工作流"}
#: 导出审计的动作(api/evidence.py、api/bundle.py 写的)
EXPORTED = "report.exported"
@dataclass
class Use:
"""一次「用过」:哪次运行、旧版本几、参数(原样,没补缺省值)、什么时候、谁是答案的主人。"""
kind: str
run_id: str
version: int
binding: dict[str, Any]
at: datetime | None = None
owners: list[tuple[str, str]] = field(default_factory=list)
conversation_id: str | None = None
turn_id: str | None = None
schedule_id: str | None = None
app_id: str | None = None
#: 问数回答:等价的是哪条查询(查询快照)、口径的哪个取数节点
query: str | None = None
node_id: str | None = None
#: 固定了这一版的是哪个工作流的哪一版(pinned)
via_workflow: str | None = None
via_version: int | None = None
exporters: list[str] = field(default_factory=list)
def add_owner(self, kind: str, name: str | None) -> None:
if name and (kind, name) not in self.owners:
self.owners.append((kind, name))
def describe(self) -> dict[str, Any]:
"""记进召回运行 origin 的那一份(召回结束时据此开待办、在原答案上记账):只放编号、参数的来处和主人。"""
out: dict[str, Any] = {"kind": self.kind, "run_id": self.run_id, "version": self.version,
"owners": [list(o) for o in self.owners]}
for key in ("conversation_id", "turn_id", "schedule_id", "app_id", "query", "node_id", "via_workflow",
"via_version"):
if (value := getattr(self, key)) is not None:
out[key] = value
if self.exporters:
out["exporters"] = list(self.exporters)
if self.at is not None:
out["at"] = self.at.isoformat()
return out
@dataclass
class Found:
"""反查的结果:用法、固定着旧版本的应用({版本: [应用]})、有没有因为上限少看了。"""
uses: list[Use]
apps: dict[int, list[dict[str, Any]]] = field(default_factory=dict)
truncated: bool = False
def versions(self) -> list[int]:
return sorted({u.version for u in self.uses} | set(self.apps))
# ==========================================================================
# 反查
# ==========================================================================
async def _answers(session: AsyncSession, workflow_id: str, below: int | None, only: int | None
) -> tuple[list[Use], bool]:
"""问数回答:比对工件 meta 里等价已证明的那几份口径里有 W@V 的探索运行(见模块说明)。"""
from app.engine.origin import counted_runs
from app.ledger import store
from app.ledger.labels import caliber_key
prefix = f"{workflow_id}@"
rows = (await session.execute(
select(Artifact.run_id, Artifact.meta, Run.finished_at).join(Run, Run.id == Artifact.run_id)
.where(Artifact.kind == store.KIND, Run.status == "succeeded", counted_runs())
.order_by(Run.finished_at.desc()))).all()
wanted: dict[str, datetime | None] = {}
for run_id, meta, at in rows:
keys = [k for k in ((meta or {}).get("calibers") or []) if isinstance(k, str) and k.startswith(prefix)]
if not keys or run_id in wanted:
continue
versions = {int(k.rsplit("@", 1)[1]) for k in keys if k.rsplit("@", 1)[1].isdigit()}
if any((only is None or v == only) and (below is None or v < below) for v in versions):
wanted[run_id] = at
truncated = len(wanted) > MAX_ANSWERS
out: list[Use] = []
for run_id, at in list(wanted.items())[:MAX_ANSWERS]:
recorded = await store.latest(run_id)
content = recorded.content if recorded is not None else None
if not isinstance(content, dict):
continue
seen: set[tuple[int, str]] = set()
for query in content.get("queries") or []:
for cand in query.get("candidates") or []:
if cand.get("status") != "proven" or cand.get("workflow_id") != workflow_id:
continue
version = cand.get("version")
if not isinstance(version, int) or (only is not None and version != only) \
or (below is not None and version >= below):
continue
if cand.get("caliber") != caliber_key(workflow_id, version):
continue
binding = dict(cand.get("binding") or {})
key = (version, _key(binding))
if key in seen:
continue
seen.add(key)
out.append(Use(kind="answer", run_id=run_id, version=version, binding=binding, at=at,
query=query.get("artifact"), node_id=cand.get("node_id")))
await _chat_owners(session, [u for u in out])
return out, truncated
def _key(binding: Mapping[str, Any]) -> str:
from app.apps.resolve import input_key
return input_key(dict(binding))
async def _chat_owners(session: AsyncSession, found: list[Use]) -> None:
"""问数回答的轮次和主人:运行 origin 里封存的对话、轮次(origin_ref),老运行退回 conversation_turns.run_id;主人是
对话的 created_by,没有的是运行的发起人。"""
from app.db.models import Conversation, ConversationTurn
ids = sorted({u.run_id for u in found})
if not ids:
return
runs = {rid: (origin, started) for rid, origin, started in (await session.execute(
select(Run.id, Run.origin, Run.started_by).where(Run.id.in_(ids)))).all()}
turns: dict[str, tuple[str, str]] = {}
for rid, (origin, _) in runs.items():
if isinstance(origin, dict) and origin.get("conversation_id") and origin.get("turn_id"):
turns[rid] = (str(origin["conversation_id"]), str(origin["turn_id"]))
missing = [rid for rid in ids if rid not in turns]
if missing:
for turn_id, conv, rid in (await session.execute(
select(ConversationTurn.id, ConversationTurn.conversation_id, ConversationTurn.run_id)
.where(ConversationTurn.run_id.in_(missing)).order_by(ConversationTurn.created_at))).all():
turns.setdefault(rid, (conv, turn_id))
convs = sorted({c for c, _ in turns.values()})
owners = dict((await session.execute(select(Conversation.id, Conversation.created_by)
.where(Conversation.id.in_(convs)))).all()) if convs else {}
for use in found:
if use.run_id in turns:
use.conversation_id, use.turn_id = turns[use.run_id]
owner = owners.get(use.conversation_id) if use.conversation_id else None
use.add_owner("user", owner or runs.get(use.run_id, (None, None))[1])
async def _app_managers(session: AsyncSession, app_ids: Iterable[str]) -> dict[str, list[tuple[str, str]]]:
"""应用的管理者:授权里的 manage(人或组);没有授权管理者的是建应用的人。"""
from app.db.models_apps import App, AppGrant
ids = sorted({a for a in app_ids if a})
if not ids:
return {}
out: dict[str, list[tuple[str, str]]] = {a: [] for a in ids}
for app_id, kind, name in (await session.execute(
select(AppGrant.app_id, AppGrant.principal_kind, AppGrant.principal)
.where(AppGrant.app_id.in_(ids), AppGrant.role == "manage"))).all():
if kind in ("user", "group") and name and (kind, name) not in out[app_id]:
out[app_id].append((kind, name))
for app_id, created_by in (await session.execute(select(App.id, App.created_by).where(App.id.in_(ids)))).all():
if not out[app_id] and created_by:
out[app_id].append(("user", created_by))
return out
async def _formal(session: AsyncSession, clause: Any, *, kind_of: Any) -> tuple[list[Use], bool]:
"""满足 clause 的已封存、成功的正式运行(新的在前,最多 MAX_FORMAL 次)→ 用法。定时任务、应用各只留最近 PERIODS 期。
kind_of(run) 给出这次运行算哪一种(schedule / app / formal / pinned)和旧版本。"""
from app.db.models import Schedule
from app.engine.origin import counted_runs
rows = list((await session.execute(
select(Run).where(clause, Run.run_class == "formal", Run.status == "succeeded",
Run.manifest_hash.is_not(None), counted_runs())
.order_by(Run.finished_at.desc()).limit(MAX_FORMAL + 1))).scalars())
truncated = len(rows) > MAX_FORMAL
per: dict[tuple[str, str], int] = {}
out: list[Use] = []
for run in rows[:MAX_FORMAL]:
kind, version, via = kind_of(run)
group = ("schedule", run.schedule_id) if run.schedule_id else ("app", run.app_id) if run.app_id else None
if group is not None:
per[group] = per.get(group, 0) + 1
if per[group] > PERIODS:
continue
use = Use(kind=kind, run_id=run.id, version=version, binding=dict(run.input or {}), at=run.finished_at,
schedule_id=run.schedule_id, app_id=run.app_id)
if via is not None:
use.via_workflow, use.via_version = via
out.append(use)
schedules = sorted({u.schedule_id for u in out if u.schedule_id})
makers = dict((await session.execute(select(Schedule.id, Schedule.created_by)
.where(Schedule.id.in_(schedules)))).all()) if schedules else {}
managers = await _app_managers(session, [u.app_id for u in out if u.app_id])
starters = dict((await session.execute(select(Run.id, Run.started_by).where(
Run.id.in_([u.run_id for u in out])))).all()) if out else {}
for use in out:
if use.schedule_id:
use.add_owner("user", makers.get(use.schedule_id) or starters.get(use.run_id))
elif use.app_id:
for owner in managers.get(use.app_id) or []:
use.add_owner(*owner)
else:
use.add_owner("user", starters.get(use.run_id))
return out, truncated
def _pins(graph: Any, workflow_id: str) -> set[int]:
"""一张图里固定了 workflow_id 的哪几版(子工作流、口径卡的 caliber_from;同 apps/versions.pinned_upstream 的认法,
直接读 JSON,不为了这个把整张图解析成 GraphSpec)。"""
out: set[int] = set()
for node in (graph or {}).get("nodes") or [] if isinstance(graph, dict) else []:
if not isinstance(node, dict):
continue
cfg = (node.get("data") or {}).get("config") if isinstance(node.get("data"), dict) else None
if not isinstance(cfg, dict):
continue
if node.get("type") == "subgraph":
wid, version = cfg.get("workflow_id"), cfg.get("workflow_version")
elif node.get("type") == "metrics" and isinstance(cfg.get("caliber_from"), dict):
wid, version = cfg["caliber_from"].get("workflow_id"), cfg["caliber_from"].get("workflow_version")
else:
continue
if wid == workflow_id and str(version or "").isdigit():
out.add(int(version))
return out
async def pinned_versions(session: AsyncSession, workflow_id: str) -> dict[tuple[str, int], set[int]]:
"""别的工作流的已发布版本里固定了 workflow_id 的:{(工作流, 版本): 固定的 W 的版本}。"""
rows = (await session.execute(
select(WorkflowVersion.workflow_id, WorkflowVersion.version, WorkflowVersion.graph)
.where(WorkflowVersion.workflow_id != workflow_id, WorkflowVersion.level.is_not(None)))).all()
return {(wid, int(v)): pins for wid, v, graph in rows if (pins := _pins(graph, workflow_id))}
async def find_uses(session: AsyncSession, workflow_id: str, *, below: int | None = None,
only: int | None = None) -> Found:
"""W 的旧版本被谁用过(见模块说明)。below:只看版本号小于它的(召回:新版本);only:只看这一版(版本用量)。"""
from app.apps.versions import pinning_apps
def version_ok(v: Any) -> bool:
return isinstance(v, int) and (only is None or v == only) and (below is None or v < below)
answers, cut_a = await _answers(session, workflow_id, below, only)
own = Run.workflow_id == workflow_id
if only is not None:
own = and_(own, Run.version == only)
elif below is not None:
own = and_(own, Run.version < below)
formal, cut_f = await _formal(session, own, kind_of=lambda r: (
"schedule" if r.schedule_id else "app" if r.app_id else "formal", int(r.version), None))
pins = await pinned_versions(session, workflow_id)
pinned: list[Use] = []
cut_p = False
targets = {key: sorted(v for v in versions if version_ok(v)) for key, versions in pins.items()}
targets = {key: vs for key, vs in targets.items() if vs}
if targets:
clause = or_(*(and_(Run.workflow_id == wid, Run.version == v) for (wid, v) in targets))
pinned, cut_p = await _formal(session, clause, kind_of=lambda r: (
"pinned", targets[(r.workflow_id, int(r.version))][-1], (r.workflow_id, int(r.version))))
uses = [*answers, *formal, *pinned]
await _exporters(session, uses)
apps: dict[int, list[dict[str, Any]]] = {}
for item in await pinning_apps(session, workflow_id):
if version_ok(item.get("version")):
apps.setdefault(int(item["version"]), []).append(item)
return Found(uses=uses, apps=apps, truncated=cut_a or cut_f or cut_p)
async def _exporters(session: AsyncSession, uses: list[Use]) -> None:
"""这些运行上的报告导出审计:导出人进答案的主人。"""
from app.db.models import AuditEvent
ids = sorted({u.run_id for u in uses})
if not ids:
return
by: dict[str, list[str]] = {}
for rid, actor in (await session.execute(select(AuditEvent.subject_id, AuditEvent.actor).where(
AuditEvent.action == EXPORTED, AuditEvent.subject_kind == "run",
AuditEvent.subject_id.in_(ids)).order_by(AuditEvent.at))).all():
if actor and actor not in by.setdefault(rid, []):
by[rid].append(actor)
for use in uses:
use.exporters = list(by.get(use.run_id) or [])
for actor in use.exporters:
use.add_owner("user", actor)
# ==========================================================================
# 版本用量(版本历史、发布弹窗)
# ==========================================================================
async def exports_count(session: AsyncSession, run_ids: Iterable[str]) -> int:
from app.db.models import AuditEvent
ids = sorted(set(run_ids))
if not ids:
return 0
return int(await session.scalar(select(func.count()).select_from(AuditEvent).where(
AuditEvent.action == EXPORTED, AuditEvent.subject_kind == "run", AuditEvent.subject_id.in_(ids))) or 0)
def summarize(found: Found) -> dict[str, Any]:
"""用法的计数(不含参数和主人):问数回答几次(运行数)、导出几次、定时任务几个、应用几个、各种正式运行几次。"""
answers = {u.run_id for u in found.uses if u.kind == "answer"}
schedules = {u.schedule_id for u in found.uses if u.schedule_id}
apps = {u.app_id for u in found.uses if u.app_id} | {a["app_id"] for items in found.apps.values() for a in items}
return {"answers": len(answers), "schedules": len(schedules), "apps": len(apps),
"formal": len({u.run_id for u in found.uses if u.kind in ("formal", "schedule", "app")}),
"pinned": len({u.run_id for u in found.uses if u.kind == "pinned"}),
"exports": sum(len(u.exporters) for u in {u.run_id: u for u in found.uses}.values()),
"truncated": found.truncated}
async def version_usage(session: AsyncSession, workflow_id: str, version: int) -> dict[str, Any]:
"""一个版本的用法计数(账本接口 GET /api/ledger/workflow-versions/{wf}/{v} 加的那几项):问数回答、导出、召回。
召回分两头:这一版作为新版本召回过几组(recall_runs:以它为新版本发起过的召回运行;changed:其中数变了的)、作为旧版本
被召回过几组(recalled_from)。"""
from app.ledger import recall
found = await find_uses(session, workflow_id, only=version)
out = summarize(found)
out["exports"] = await exports_count(session, [u.run_id for u in found.uses])
out["recall"] = await recall.version_recall_counts(session, workflow_id, version)
return out
async def workflow_name(session: AsyncSession, workflow_id: str) -> str:
return str(await session.scalar(select(Workflow.name).where(Workflow.id == workflow_id)) or "")
__all__ = ["EXPORTED", "Found", "KINDS", "KIND_TEXT", "MAX_ANSWERS", "MAX_FORMAL", "PERIODS", "Use", "exports_count",
"find_uses", "pinned_versions", "summarize", "version_usage", "workflow_name"]
+17 -6
View File
@@ -6,7 +6,11 @@ ledger 键、报告导出的片段面板,三处读同一份比对工件、按
界面层不放逻辑:标签、整题判断、能做的动作都由这里给。actions(第 3 波):整题等价时按那份口径正式出具(能不能、能不能
自动、不能的原因,ledger/issue.plan_issue,按看的这个人的权限判);有相关口径的查询看差多少(口径对账,ledger/reconcile)。
issued 是这次运行按口径出具过的正式运行,reconciled 是对过的账(各自的运行、状态)。
issued 是这次运行按口径出具过的正式运行,reconciled 是对过的账(各自的运行、状态,第 4 波起带上结果上的「说明有用 /
看不懂」:看的这个人给过的、各几个,ledger/feedback)。
recall(第 4 波,ledger/recall.answer_marks):这个答案身上的召回标记——「已按 vN 核对,不影响」(checked)、依据有变
(basis)、看的这个人手上说的是它的 recall 待办(tasks)。几种状态都带:没比对过、不比对的答案照样可能被标依据有变。
"""
from __future__ import annotations
@@ -108,11 +112,16 @@ async def run_view(run_id: str) -> dict[str, Any]:
from app.api.coded import CodedHTTPException
from app.api.evidence import RUN_NOT_FOUND, _sealed
from app.api.deps import current_principal
from app.ledger.recall import answer_marks
async with SessionLocal() as session:
run = await session.get(Run, run_id)
if run is None:
raise CodedHTTPException(404, "运行记录不存在", RUN_NOT_FOUND)
base: dict[str, Any] = {"run_id": run_id, "note": ledger_labels.POST_SEAL_NOTE}
if run is None:
raise CodedHTTPException(404, "运行记录不存在", RUN_NOT_FOUND)
principal = current_principal()
marks = await answer_marks(session, run_id, principal)
base: dict[str, Any] = {"run_id": run_id, "note": ledger_labels.POST_SEAL_NOTE, "recall": marks}
recorded = await store.latest(run_id)
if recorded is None:
why = ledger_match.ineligible(run)
@@ -125,7 +134,7 @@ async def run_view(run_id: str) -> dict[str, Any]:
return {**base, "state": "pending"}
if recorded.content is None:
return {**base, "state": "unreadable", "reason": recorded.problem, **_stamp(recorded)}
from app.api.deps import current_principal
from app.ledger import feedback as ledger_feedback
from app.ledger import issue as ledger_issue
from app.ledger import reconcile as ledger_reconcile
@@ -133,12 +142,14 @@ async def run_view(run_id: str) -> dict[str, Any]:
sealed = await _sealed(run_id)
hider = await _hider(sealed)
names = await _names(_workflow_ids(content))
principal = current_principal()
plans = await ledger_issue.plans_for(run_id, content, principal=principal, hide=hider.hide,
artifact=recorded.artifact, retro=recorded.retro)
async with SessionLocal() as session:
issued = [ledger_issue.issued_view(r) for r in await ledger_issue.issued_runs(session, run_id)]
reconciled = [ledger_reconcile.reconciled_view(r) for r in await ledger_reconcile.reconciled_runs(session, run_id)]
# 对账结果上的「说明有用 / 看不懂」(ledger/feedback):看的这个人给过哪一个、各几个
votes = await ledger_feedback.feedback_of(session, [r["run_id"] for r in reconciled], principal)
reconciled = [{**r, "feedback": votes[r["run_id"]]} for r in reconciled]
return {
**base, "state": "matched", **_stamp(recorded),
"calibers": [{**{k: c.get(k) for k in ("caliber", "workflow_id", "version", "level", "graph_hash")},
+81 -6
View File
@@ -35,7 +35,9 @@ from typing import Any
from sqlalchemy import select
from app.db.base import SessionLocal, utcnow
from app.db.models import App, AppVersion, NotifyChannel, NotifyDelivery, Run, Setting, Task, TaskAssignment, User
from app.db.models import (
App, AppVersion, NotifyChannel, NotifyDelivery, Run, Setting, Task, TaskAssignment, User, Workflow,
)
from app.notify.core import Message, Outcome, host_of
logger = logging.getLogger(__name__)
@@ -69,8 +71,9 @@ EVENTS: dict[str, Event] = {
# ---- 本轮登记的(总方案 X6):scopes 为空的还没有推送;实现推送的那一块只改自己这一行的 scopes,消息照模块说明的规矩 ----
# 数据源有要处理的结构 / 取值变化(03 B3):只推 severity 为 action 的,按源汇总;源名、各级别条数、链接
"source.changed": Event("数据源有变化要处理", ()),
# 口径升版后召回重算,数真变了(02 第 7 项):答案的主人有 recall 待办;口径名、版本、链接,不放新旧值和差值
"ledger.recall": Event("口径升版,答案的数变了", ()),
# 口径升版后召回重算,数真变了(02 第 7 项,notify_recall):答案的主人有 recall 待办;推全局渠道和答案主人自己的个人
# 渠道(召回运行不归哪个应用)。口径名、版本、几个答案、链接,不放新旧值和差值
"ledger.recall": Event("口径升版,答案的数变了", ("global", "user")),
# 定时评测有题从通过变成不通过 / 评测没跑完(01 第 15 项):题集名、通过数、退步题数、链接;不放期望值和答案里的数。
# 只推全局渠道:评测是搭建者们共用的一把尺,不归哪个人(notify_eval)。只推定时发起的(手动发起的那次人自己在看),
# 叫法就照实带「定时」;没评完包括失败、到了上限、止损停下,所以不叫「失败」
@@ -103,7 +106,7 @@ _TASK_HEAD = {"task.opened": "新的待办", "task.reassigned": "待办已改派
"task.reminded": "待办催办"}
_KIND_TEXT = {"approve": "审批", "input": "补充输入", "edit": "编辑稿件", "tool": "工具调用审批", "code": "代码执行审批",
"dual_check": "双盲复算裁决", "action_confirm": "确认写入外部系统", "reconcile": "核实写入结果",
"signal": "等外部系统回调", "review": "审核个人应用"}
"signal": "等外部系统回调", "review": "审核个人应用", "recall": "口径召回"}
_ENV_TEXT = {"prod": "生产环境", "test": "测试环境", "simulated": "演练"}
_background: set[asyncio.Task[Any]] = set()
@@ -302,8 +305,9 @@ async def ensure_assignee_mail(users: list[str]) -> int:
return made
async def notify_task(task_id: str, event: str, *, users: list[str]) -> int:
async def notify_task(task_id: str, event: str, *, users: list[str], exclude: frozenset[str] = frozenset()) -> int:
"""待办的通知(task.opened / reassigned / escalated / reminded)。users 是这次要通知到的人(派给的用户名)。返回推了几个渠道。
exclude 是这一次已经推过别的事件的渠道(口径召回先推了 ledger.recall 的,notify_recall),同一个渠道只推一次。
管理员开了「默认发邮件给待办处理人」时,先给还没有个人邮件渠道的处理人建一个(ensure_assignee_mail),这一条就推得到。"""
try:
@@ -315,7 +319,7 @@ async def notify_task(task_id: str, event: str, *, users: list[str]) -> int:
if task is None:
return 0
run = await session.get(Run, task.run_id)
channels = await _channels(session, event, run, users)
channels = [c for c in await _channels(session, event, run, users) if c.id not in exclude]
if not channels:
return 0
base = await _base_url(session)
@@ -338,6 +342,77 @@ async def notify_task(task_id: str, event: str, *, users: list[str]) -> int:
task_id=task_id)
async def notify_recall(task_id: str, users: list[str]) -> int:
"""口径召回的待办开出来了(app/ledger/recall):口径升版、数真变了的那一种推 ledger.recall——订阅了它的全局渠道、答案主人
自己的个人渠道;然后照别的待办推 task.opened(已经推过 ledger.recall 的渠道不再推,同一个渠道只推一次)。目录、表结构变了
的那两种只推 task.opened。
全局渠道按这一批待办只推一次(同一次召回运行、同一个来由开的那几条,recall.batch_of_key):开登录时一组答案有几个主人就
开几条待办,团队群却只该收到一条——只在这一批里最早开的那条(开出时间、id 排序,几条并发推也认同一条)上推全局渠道,
答案、待办按这一批汇总,链到待办箱(各人在那里看到派给自己的那条);别的几条连 task.opened 也不再推全局渠道。个人渠道
照旧每个主人各一条,只数他的答案、链到他的那条待办。
消息照模块说明的规矩(X6):口径名(搭建时起的工作流名)、新旧版本号、几个答案(几条待办)、召回编号、去处理的链接;不放
旧口径的值、新口径的值、差值和原答案的值。返回推了几个渠道。"""
from app.ledger import recall
try:
await ensure_assignee_mail(users)
except Exception: # noqa: BLE001 - 自动建渠道出错不挡这条待办的通知
logger.exception("给待办 %s 的处理人自动建邮件渠道失败", task_id)
event = "ledger.recall"
async with SessionLocal() as session:
task = await session.get(Task, task_id)
if task is None:
return 0
run = await session.get(Run, task.run_id)
key = task.correlation_key or ""
reason = key.split(":", 1)[0]
batch = recall.batch_of_key(key)
siblings = [t for t, k in (await session.execute(
select(Task.id, Task.correlation_key).where(Task.run_id == task.run_id, Task.kind == recall.TASK_KIND)
.order_by(Task.created_at, Task.id))).all() if recall.batch_of_key(k or "") == batch]
first = not siblings or siblings[0] == task_id
channels = await _channels(session, event, run, users) if reason == "caliber" else []
# 不是这一批最早的那条:全局渠道已经(或正由那一条)推过了,这条只推个人渠道,task.opened 也跳过全局渠道
shared = [] if first else list((await session.execute(select(NotifyChannel.id).where(
NotifyChannel.scope == "global"))).scalars())
channels = [c for c in channels if first or c.scope != "global"]
origin = run.origin if run is not None and isinstance(run.origin, dict) else {}
name = str(await session.scalar(select(Workflow.name).where(Workflow.id == origin.get("workflow_id")))
or "") if origin.get("workflow_id") else ""
base = await _base_url(session) if channels else None
pushed = 0
if channels:
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", "")))
def message(answers: int, 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],
"links": [link] if base and link else [],
"subject": {"task_id": task_id, "run_id": task.run_id, "workflow_id": origin.get("workflow_id")}}
own = ("去处理", f"{base}/apps/inbox/{task_id}")
group = [c for c in channels if c.scope == "global"]
if group:
# 这一批只有这一条(关登录时派给任何人)就链到它;几条的链到待办箱,各人看派给自己的那条
many = len(siblings) > 1
pushed += await _push(group, message(everyone, len(siblings), ("去待办箱", f"{base}/apps?tab=inbox")
if many else own),
subject_kind="task", subject_id=task_id, event=event, run_id=task.run_id,
task_id=task_id)
if personal := [c for c in channels if c.scope != "global"]:
pushed += await _push(personal, message(mine, 1, own), subject_kind="task", subject_id=task_id,
event=event, run_id=task.run_id, task_id=task_id)
return pushed + await notify_task(task_id, "task.opened", users=users,
exclude=frozenset(c.id for c in channels) | frozenset(shared))
async def notify_run(run_id: str, event: str) -> int:
"""应用实例结束、失败(run.finished / run.failed)。只推应用发起的运行。
+88 -1
View File
@@ -15,6 +15,9 @@
时限,但调度不对它们做提醒、升级、超时驳回——试运行不该把待办推到真实的处理人、上级那里。
- 个人应用提交审核(kind=review,N7):没有 Approval、不恢复运行,挂在个人应用最近一次成功的刷新上;派给选定的搭建者
或组。在待办里只能退回,同意要在共享副本上走发布单,发布完自动记为已发布(blueprints/personal.py)。
- 口径召回(kind=recall,docs/design/next-round/02-口径账本.md 做法 7):没有 Approval、不恢复运行。口径升版后数真变了的
挂在召回运行上,派给答案的主人(开登录时每人一条,关登录时任何人);动作「按新版重新出具」(走按口径出具同一套判法)
和「知道了」。答案依据的目录、表结构变了的另两种只有「知道了」。开、收尾、内容都在 app/ledger/recall.py。
"""
from __future__ import annotations
@@ -163,6 +166,29 @@ async def open_review(session: AsyncSession, *, run: Run, app_id: str, blueprint
return task
#: 口径召回的待办(app/ledger/recall.py)。没有 Approval,按 correlation_key(来由:运行[:附加]:派给的种类:名字)认同一条
RECALL_KIND = "recall"
#: recall 待办的两种决定:按新版重新出具、知道了(目录、表结构那两种只有「知道了」)
RECALL_DECISIONS = ("reissue", "ack")
async def open_recall(session: AsyncSession, *, run: Run, title: str, owner: tuple[str, str],
correlation_key: str) -> Task:
"""开一条 recall 待办(挂在 run 上),派给 owner:人、组(在这一刻展开成成员,同改派)、任何人;来源记 recall。调用方
提交,提交后 announce。"""
task = Task(run_id=run.id, node_id=None, app_id=None, app_version=None, kind=RECALL_KIND, title=title[:300],
policy={"kind": "any"}, correlation_key=correlation_key[:200])
session.add(task)
await session.flush()
kind, name = owner
targets = await _expand(session, task, kind, name, RECALL_KIND) if kind in ("user", "group") and name \
else [("anyone", "", RECALL_KIND)]
for k, n, source in dict.fromkeys(targets):
session.add(TaskAssignment(task_id=task.id, principal_kind=k, principal=n, source=source))
await session.flush()
return task
async def review_handler(session: AsyncSession, task: Task, principal: Any) -> bool:
"""这个人是不是这条审核待办的处理人(派给他或他的组、没撤回,同 handles):只有他们能取个人蓝图的共享副本。"""
return handles((await _assignments(session, [task.id])).get(task.id, []), principal)
@@ -327,8 +353,15 @@ async def announce(task_id: str, event: EventType = EventType.TASK_OPENED) -> No
return
from app.notify.service import spawn
users = [a.principal for a in rows.get(task_id, []) if a.principal_kind == "user"]
if task.kind == RECALL_KIND:
# 口径召回:口径升版、数变了的推 ledger.recall,订阅了 task.opened 的渠道另推一条(同一个渠道只推一次)
from app.notify.service import notify_recall
spawn(notify_recall(task_id, users))
return
# 推送放到后台:接收端挂住时超时加重试要几十秒,不能让运行停在「执行中」等它
spawn(_notify(task_id, "task.opened", [a.principal for a in rows.get(task_id, []) if a.principal_kind == "user"]))
spawn(_notify(task_id, "task.opened", users))
# --------------------------------------------------------------------------
@@ -542,6 +575,8 @@ async def respond(task_id: str, principal: Any, *, decision: Any, values: dict[s
return await _respond_reconcile(session, task, principal, decision, values, note)
if task.kind == REVIEW_KIND:
return await _respond_review(session, task, principal, decision, note)
if task.kind == RECALL_KIND:
return await _respond_recall(session, task, principal, decision, note)
if task.status != "pending":
raise TaskRefused(_closed(task.status))
if inquiry_busy(task.run_id):
@@ -669,6 +704,54 @@ async def _respond_review(session: AsyncSession, task: Task, principal: Any, dec
return {"task": await detail(task_id, principal), "resolved": True, "run": await _run(run_id)}
async def _respond_recall(session: AsyncSession, task: Task, principal: Any, decision: Any,
note: str | None) -> dict[str, Any]:
"""recall 待办:「按新版重新出具」(app/ledger/recall.reissue,走按口径出具同一套判法;不能出具照实拒绝,待办留着)或
「知道了」。处理完记成已处理(条件 UPDATE,同 close_review),结论和出具的那次正式运行写进 outcome,另记一条审计:运行
已经封存,事件记不进去。"""
if task.status != "pending":
raise TaskRefused(_closed(task.status))
reason = (task.correlation_key or "").split(":", 1)[0]
allowed = RECALL_DECISIONS if reason == "caliber" else ("ack",)
if decision not in allowed:
raise TaskRefused("这条待办只能「按新版重新出具」或「知道了」" if reason == "caliber"
else "这条待办只能「知道了」", status_code=422, code="task_decision_invalid")
rows = (await _assignments(session, [task.id])).get(task.id, [])
mine_id = _pick(task, rows, principal).id
task_id, run_id = task.id, task.run_id
await session.rollback()
outcome: dict[str, Any] = {"decision": decision, "note": (note or "").strip()[:2000]}
issued: Any = None
if decision == "reissue":
from app.ledger import recall
try:
issued = await recall.reissue(task_id, principal)
except recall.RecallRefused as e:
raise TaskRefused(e.message, status_code=e.status_code, code=e.code) from e
outcome["issued_run"] = issued.id
async with SessionLocal() as session:
now = utcnow()
claimed = await session.execute(
update(Task).where(Task.id == task_id, Task.status == "pending")
.values(status="resolved", resolved_by=principal.username, resolved_at=now, outcome=outcome))
if claimed.rowcount != 1:
await session.rollback()
raise TaskRefused(_closed("resolved"))
await session.execute(update(TaskAssignment).where(TaskAssignment.id == mine_id).values(
status="responded", responded_by=principal.username, responded_at=now, verified=bool(principal.verified),
response={"decision": decision, "note": outcome["note"]}))
await session.execute(update(TaskAssignment).where(TaskAssignment.task_id == task_id,
TaskAssignment.status == "pending")
.values(status="withdrawn"))
session.add(AuditEvent(actor=principal.username, verified=bool(principal.verified),
action=f"ledger.recall.{decision}", subject_kind="task", subject_id=task_id,
data={"run_id": run_id, **({"issued_run": issued.id} if issued is not None else {}),
**key_trace(principal)}))
await session.commit()
return {"task": await detail(task_id, principal), "resolved": True, "run": await _run(run_id)}
async def close_reconcile(session: AsyncSession, action_id: str, principal: Any, outcome: str,
note: str | None) -> str | None:
"""核实之后把这个动作的核实待办收掉(条件 UPDATE,和 tick、别人的回复抢同一行)。返回待办 id,没有返回 None。"""
@@ -1102,6 +1185,10 @@ async def _describe(session: AsyncSession, tasks: list[Task], principal: Any) ->
from app.blueprints.personal import review_payload
payload = await review_payload(session, task)
elif task.kind == RECALL_KIND:
from app.ledger.recall import task_payload
payload = await task_payload(session, task, principal)
run = runs.get(task.run_id)
out.append({
"id": task.id, "approval_id": task.approval_id, "run_id": task.run_id, "node_id": task.node_id,
+3 -1
View File
@@ -304,7 +304,9 @@ async def test_version_detail_has_form_and_usage(client):
assert detail["usage"]["effects"] == 1 and detail["usage"]["formal_runs"] == 1
assert [a["name"] for a in detail["usage"]["apps"]] == ["周报看板"]
ledger = (await client.get(f"/api/ledger/workflow-versions/{wf}/1")).json()
assert ledger == detail["usage"]
# 账本接口在同一份之上另加口径账本的用量(问数回答、导出、定时任务、召回,next-round 02 做法 8)
assert {k: ledger[k] for k in detail["usage"]} == detail["usage"]
assert ledger["answers"] == 0 and ledger["recall"] == {"groups": 0, "changed": 0, "recalled_from": 0}
effects = (await client.get("/api/ledger/effects", params={"workflow_id": wf})).json()["items"]
assert [(e["capability"], e["state"], e["workflow_version"]) for e in effects] == [("erp_pay", "confirmed", 1)]
+4 -2
View File
@@ -233,11 +233,13 @@ async def test_stats_count_what_the_fixtures_did(client, monkeypatch):
assert e1["with_candidates"] == 3 and e1["rates"]["proven"] == round(1 / 3, 4)
assert e1["diff_aspects"]["filter"] == 1 and e1["labels"]["caliber"] == 1 and e1["labels"]["unknown"] == 1
assert e1["whole_equivalent"] == 1
assert body["e2"]["launched"] == 0 and body["e2"]["feedback"]["available"] is False
assert body["e2"]["launched"] == 0 and body["e2"]["feedback"] == {"available": True, "useful": 0, "unclear": 0,
"runs": 0, "useful_rate": None}
assert body["e3"]["chat_runs"] == 0 and body["e3"]["threshold"] == {"runs": 3, "conversations": 2}
usage = {u["caliber"]: u for u in body["e4"]["usage"]}
assert usage[f"{wf}@1"]["proven_runs"] == 1
assert body["e4"]["per_publish"]["available"] is False
# 召回(第 4 波):这一段时间里没有发布新版本,没有召回
assert body["e4"]["per_publish"]["available"] is True and body["e4"]["per_publish"]["groups"] == 0
async def test_stats_take_the_run_ids_the_caller_names(monkeypatch):
+119
View File
@@ -0,0 +1,119 @@
"""对账结果的「说明有用 / 看不懂」(next-round 02 做法 10 的 E2;app/ledger/feedback.py)。
真的跑:虚构店铺库里的问数据答案(算上了退款)和受管口径「周销售」(只算已支付)对一次账,再在结果上给反馈:
- 反馈记成对账运行上的封存后事件 ledger.feedback(批注不是证据,不进封存范围);同一个人改主意只算最后一次;
- 账本接口 reconciled 每一项带上反馈(看的这个人给过的、各几个),界面照着画选中的那一个;
- 统计 E2 数到它(不再写「第 4 波接上」);
- 不认的反馈、不是这个答案的对账、还没跑完的对账照实拒绝。
数据都是编的。
"""
from __future__ import annotations
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.core.events import EventType
from app.db.base import SessionLocal
from app.db.models import RunEvent
from app.engine.runner import run_manager
from app.ledger import match as ledger_match
from app.main import app
from tests import ledger_samples as S
CHECKED = {"start": "2026-09-01", "end": "2026-09-08"}
@pytest.fixture(autouse=True)
async def engine_up(tmp_path):
await run_manager.setup()
path = tmp_path / "shop.db"
await S.fresh_source(path)
yield path
await ledger_match.drain()
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
async def reconciled(client, monkeypatch) -> tuple[str, str]:
"""问一次(算上了退款)、按核对过的参数和「周销售」对一次账,等它跑完。返回 (答案, 对账运行)。"""
wf = await S.publish()
row = await S.explore(monkeypatch, S.WITH_REFUNDS)
body = (await client.get(f"/api/runs/{row.id}/ledger")).json()
[action] = [a for a in body["actions"] if a["kind"] == "reconcile"]
res = await client.post(f"/api/ledger/answers/{row.id}/reconcile",
json={"query": action["query"], "workflow_id": wf, "version": 1, "inputs": CHECKED,
"confirm": True})
assert res.status_code == 201, res.text
done = await S.wait(res.json()["run_id"])
assert done.status == "succeeded", done.error
return row.id, done.id
async def test_feedback_is_a_post_seal_note_counted_by_e2(client, monkeypatch):
answer, rec = await reconciled(client, monkeypatch)
before = (await client.get("/api/ledger/stats")).json()["e2"]["feedback"]
assert before["available"] is True and "第 4 波" not in str(before)
res = await client.post(f"/api/ledger/answers/{answer}/reconcile/{rec}/feedback", json={"verdict": "useful"})
assert res.status_code == 200, res.text
assert res.json() == {"run_id": rec, "feedback": {"mine": "useful", "useful": 1, "unclear": 0}}
async with SessionLocal() as session:
[data] = (await session.execute(select(RunEvent.data).where(
RunEvent.run_id == rec, RunEvent.type == EventType.LEDGER_FEEDBACK))).scalars()
assert data["verdict"] == "useful" and data["post_seal"] is True and data["source_run"] == answer
# 改主意:同一个人只算最后一次
res = await client.post(f"/api/ledger/answers/{answer}/reconcile/{rec}/feedback", json={"verdict": "unclear"})
assert res.json()["feedback"] == {"mine": "unclear", "useful": 0, "unclear": 1}
[item] = (await client.get(f"/api/runs/{answer}/ledger")).json()["reconciled"]
assert item["run_id"] == rec and item["feedback"] == {"mine": "unclear", "useful": 0, "unclear": 1}
after = (await client.get("/api/ledger/stats")).json()["e2"]["feedback"]
assert after["useful"] - before["useful"] == 0 and after["unclear"] - before["unclear"] == 1
assert after["runs"] - before["runs"] == 1 and after["available"] is True
async def test_feedback_refusals(client, monkeypatch):
answer, rec = await reconciled(client, monkeypatch)
bad = await client.post(f"/api/ledger/answers/{answer}/reconcile/{rec}/feedback", json={"verdict": "great"})
assert bad.status_code == 422
other = await S.explore(monkeypatch, S.ELSEWHERE, text="门店 [[v:Q1.r0.n]] 家。")
res = await client.post(f"/api/ledger/answers/{other.id}/reconcile/{rec}/feedback", json={"verdict": "useful"})
assert res.status_code == 404 and res.json()["code"] == "ledger_feedback_refused", "不是这个答案的对账"
res = await client.post(f"/api/ledger/answers/{answer}/reconcile/{answer}/feedback", json={"verdict": "useful"})
assert res.status_code == 404, "答案自己不是对账运行"
# 还没封存的对账(正在跑、刚发起):没有结果可评
from app.db.models import Run
async with SessionLocal() as session:
row = await session.get(Run, rec)
sealed = row.manifest_hash
row.manifest_hash = None
await session.commit()
try:
res = await client.post(f"/api/ledger/answers/{answer}/reconcile/{rec}/feedback", json={"verdict": "useful"})
assert res.status_code == 409 and "还没运行完" in res.json()["detail"], res.text
finally:
async with SessionLocal() as session:
(await session.get(Run, rec)).manifest_hash = sealed
await session.commit()
def test_offline_page_and_summary_know_the_feedback_event():
import re
from pathlib import Path
from app.bundle.build import _summary
script = (Path(__file__).resolve().parents[1] / "app" / "bundle" / "verifier.js").read_text(encoding="utf-8")
block = re.search(r"const EVENT_LABEL = \{(.*?)\n \}", script, re.S)
labels = dict(re.findall(r"'([a-z_.]+)': '([^']+)'", block.group(1)))
assert labels["ledger.feedback"] == "对账说明的反馈"
assert _summary("ledger.feedback", {"verdict": "useful"}) == "反馈:说明有用"
assert _summary("ledger.feedback", {"verdict": "unclear"}) == "反馈:看不懂"
+869
View File
@@ -0,0 +1,869 @@
"""口径召回(next-round 02 做法 7,拍板 L6、L7;app/ledger/uses.py、recall.py)。
真的跑:虚构店铺库、mock 模型写报告。受管口径「周销售」v1 只算已支付的,问数据问过几周、正式运行跑过;把它发布成 v2
(改一个筛选条件)之后:
- 计划:按(旧版本、新版本、参数)分组去重——问数回答绑定出的参数和正式运行的输入补齐缺省值之后是同一组;上限 recall_limit
之内自动发起,超出的写「另有 M 组未重算」,手动接着算;再排一次、服务重启之后重算计划,算过的不再发起(幂等);
- 召回运行:派生图里新旧两版的取数和口径卡各一份(前缀不撞),召回卡里算两版之差;同一份数据上两版各算一次;
- 收尾:数变了才开 recall 待办(挂在召回运行上,旧值、新值、差值、原答案当时的值都给),推 ledger.recall,消息里没有数;
数没变的在原答案上记 ledger.recalled(已按 vN 核对,不影响),不开待办;
- 不能重算的照实记:新版入口要的参数这一组里没有、图里会写外部系统;图里别处有人工节点的照样重算(召回图碰不到它),
只是「按新版重新出具」不代人审批;
- 待办「按新版重新出具」走按口径出具同一套判法、「知道了」;
- 目录里影响数字的一项被改了:冻结目录里看得到它的答案标「依据有变」,按新目录多出错误级问题的才开待办;
- 表结构变了:用过这张表的答案标「依据的表结构已变」,口径试算算不出来才开待办;
- E4:每次发布召回了几组、其中数变了的比例。
数据都是编的。
"""
from __future__ import annotations
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.core.events import EventType
from app.db.base import SessionLocal
from app.db.models import Run, RunEvent, Setting, Task, TaskAssignment
from app.engine.runner import run_manager
from app.ledger import match as ledger_match
from app.ledger import recall
from app.main import app
from tests import ledger_samples as S
#: v2:已支付和已退款的都算(召回要算出差值的那种改法)
WITH_REFUNDED = ("SELECT SUM(amount) AS gmv FROM orders WHERE status IN ('paid', 'refunded') "
"AND paid_at >= '{{ input.start }}' AND paid_at < '{{ input.end }}'")
WEEK1 = {"start": "2026-09-01", "end": "2026-09-08"}
def week_sql(start: str, end: str) -> str:
return (f"select sum(o.amount) as 销售额 from orders o where o.paid_at >= '{start}' and o.status = 'paid' "
f"and o.paid_at < '{end}'")
@pytest.fixture(autouse=True)
async def engine_up(tmp_path):
await run_manager.setup()
path = tmp_path / "shop.db"
await S.fresh_source(path)
await ledger_settings(auto_issue=False, issue_daily_limit=100_000)
yield path
await ledger_match.drain()
await recall.drain()
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
async def ledger_settings(**values) -> None:
async with SessionLocal() as session:
row = await session.get(Setting, "ledger")
if row is not None:
await session.delete(row)
session.add(Setting(key="ledger", value=values))
await session.commit()
async def answer(monkeypatch, start: str = "2026-09-01", end: str = "2026-09-08") -> Run:
"""问数据问一周的销售额(独立写出、和口径等价的 SQL)。"""
return await S.explore(monkeypatch, week_sql(start, end), text="本周销售额 [[v:Q1.r0.销售额]]。", origin="chat")
async def publish_v2(wf: str, sql: str = WITH_REFUNDED, *, graph=None) -> None:
await S.publish(graph=graph or S.caliber_graph(sql), version=2, workflow_id=wf)
async def run_recall(wf: str, version: int = 2, *, batch: str = "auto") -> list[Run]:
"""按计划发起一批、等召回运行跑完、等收尾做完。"""
ids = await recall.launch(wf, version, batch=batch)
done = [await S.wait(rid) for rid in ids]
await recall.drain()
return done
async def events(run_id: str, kind: EventType) -> list[dict]:
async with SessionLocal() as session:
return [d for d in (await session.execute(select(RunEvent.data).where(
RunEvent.run_id == run_id, RunEvent.type == kind).order_by(RunEvent.seq))).scalars()]
async def recall_tasks(run_id: str) -> list[Task]:
async with SessionLocal() as session:
return list((await session.execute(select(Task).where(Task.run_id == run_id, Task.kind == "recall"))).scalars())
# --------------------------------------------------------------------------
# 计划:分组、去重、上限、幂等
# --------------------------------------------------------------------------
async def test_plan_groups_answers_and_formal_runs_by_their_binding(monkeypatch):
wf = await S.publish()
a1 = await answer(monkeypatch)
a2 = await answer(monkeypatch) # 同一周再问一次:同一组
a3 = await answer(monkeypatch, "2026-09-08", "2026-09-15")
await publish_v2(wf)
async with SessionLocal() as session:
plan = await recall.build_plan(session, wf, 2)
assert plan.problem is None and [p.old.node_id for p in plan.pairs[1]] == ["card"]
groups = {tuple(sorted(g.binding.items())): g for g in plan.groups}
week1 = groups[(("end", "2026-09-08"), ("start", "2026-09-01"), ("store", "all"))]
assert sorted(u.run_id for u in week1.uses) == sorted([a1.id, a2.id]), "同一组参数去重成一组(缺省值补齐后比)"
assert week1.old_version == 1 and week1.new_version == 2 and week1.problem is None
assert week1.inputs == {"start": "2026-09-01", "end": "2026-09-08", "store": "all"}
assert len(plan.groups) == 2 and len(plan.todo()) == 2
assert {u.run_id for g in plan.groups for u in g.uses} == {a1.id, a2.id, a3.id}
assert all(u.kind == "answer" and u.turn_id is None for g in plan.groups for u in g.uses)
async def test_limit_continue_and_idempotent_replanning(monkeypatch):
await ledger_settings(auto_issue=False, issue_daily_limit=100_000, recall_limit=1)
wf = await S.publish()
await answer(monkeypatch)
await answer(monkeypatch, "2026-09-08", "2026-09-15")
await publish_v2(wf)
async with SessionLocal() as session:
before = await recall.preview(session, wf, 2)
assert before["groups"] == 2 and before["will_recompute"] == 1 and before["over_limit"] == 1
assert before["versions"][0]["answers"] == 2 and before["caliber"] is True
first = await run_recall(wf)
assert len(first) == 1 and first[0].origin["batch"] == "auto"
# 再排一次(重复的发布钩子、服务重启后重算计划):自动那一批已经发满,不再发起
assert await recall.launch(wf, 2, batch="auto") == []
async with SessionLocal() as session:
view = await recall.batches(session, wf, 2)
assert view["counts"]["pending"] == 1 and view["remaining"] == 1 and view["can_continue"] is True
assert sum(1 for g in view["groups"] if g.get("run_id")) == 1
# 手动接着算:发起剩下那一组;再接着算就没有了
second = await run_recall(wf, batch="manual")
assert len(second) == 1 and second[0].origin["batch"] == "manual"
async with SessionLocal() as session:
view = await recall.batches(session, wf, 2)
assert view["remaining"] == 0 and view["can_continue"] is False
assert await recall.launch(wf, 2, batch="manual") == []
async def test_superseded_version_offers_no_continue(client, monkeypatch):
"""v2 自动算了一批、还剩几组没算,接着又发布了 v3:v2 那一版不再给「接着算」(v3 的召回覆盖更早的所有版本,同启动时
接着做只认当前发布版),写明去 v3 那一版看;手动点也照样拒绝,不再开「口径升到 v2」的待办。"""
await ledger_settings(auto_issue=False, issue_daily_limit=100_000, recall_limit=1)
wf = await S.publish()
await answer(monkeypatch)
await answer(monkeypatch, "2026-09-08", "2026-09-15")
await publish_v2(wf)
assert len(await run_recall(wf)) == 1
async with SessionLocal() as session:
view = await recall.batches(session, wf, 2)
assert view["remaining"] == 1 and view["can_continue"] is True and view["continue_reason"] is None
await S.publish(graph=S.caliber_graph(WITH_REFUNDED), version=3, workflow_id=wf)
async with SessionLocal() as session:
view = await recall.batches(session, wf, 2)
assert view["remaining"] == 1 and view["can_continue"] is False
assert view["continue_reason"] == "已有更新的发布版 v3,召回在 v3 那一版接着看"
res = await client.post(f"/api/ledger/recall/{wf}/2/continue")
assert res.status_code == 409 and res.json()["detail"] == view["continue_reason"], res.text
assert await recall.launch(wf, 2, batch="manual") == [], "直接调也不再发起"
async with SessionLocal() as session:
assert len(await recall.recall_runs(session, wf, 2)) == 1
current = await recall.batches(session, wf, 3)
assert current["can_continue"] is True and current["continue_reason"] is None
async def test_zero_limit_lists_but_does_not_recompute(monkeypatch):
await ledger_settings(auto_issue=False, issue_daily_limit=100_000, recall_limit=0)
wf = await S.publish()
await answer(monkeypatch)
await publish_v2(wf)
assert await recall.launch(wf, 2, batch="auto") == []
async with SessionLocal() as session:
view = await recall.batches(session, wf, 2)
assert view["counts"]["pending"] == 1 and view["batch_size"] == 50, "上限是 0 时手动接着算按缺省的组数"
# --------------------------------------------------------------------------
# 召回运行与收尾
# --------------------------------------------------------------------------
async def test_changed_numbers_open_a_recall_task_with_all_three_values(client, monkeypatch):
wf = await S.publish()
a1 = await answer(monkeypatch)
await publish_v2(wf)
[done] = await run_recall(wf)
assert done.status == "succeeded", done.error
assert done.origin_kind == "recall" and done.run_class == "exploratory" and done.workflow_id is None
ids = [n["id"] for n in done.graph["nodes"]]
assert ids == ["derived_in", "old_fetch", "old_card", "new_fetch", "new_card", "recall", "derived_out"]
nodes = {n["id"]: n for n in done.graph["nodes"]}
assert nodes["old_card"]["data"]["config"]["caliber_from"] == {
"workflow_id": wf, "workflow_version": 1, "node_id": "card", "nodes": {"fetch": "old_fetch"}}
assert nodes["new_card"]["data"]["config"]["caliber_from"]["workflow_version"] == 2
assert nodes["derived_out"]["data"]["config"]["contract"] == {"metrics_from": "recall"}
assert not [n for n in done.graph["nodes"] if n["type"] in ("report", "llm", "agent")]
values = await recall.card_values(done.id)
# v1 只算已支付:100 + 50.5 + 20;v2 加上这一周退款的 30 + 8
assert values["old_0"]["value"] == 170.5 and values["new_0"]["value"] == 208.5
assert values["diff_0"]["value"] == 38.0 and values["diff_0"]["rendered"] == "38.00元"
[settled] = [d for d in await events(done.id, EventType.LEDGER_RECALLED) if d.get("role") == "recall"]
assert settled["changed"] is True and settled["answers"] == 1 and len(settled["tasks"]) == 1
assert settled["post_seal"] is True
assert await events(a1.id, EventType.LEDGER_RECALLED) == [], "数变了的不在原答案上记「不影响」"
[task] = await recall_tasks(done.id)
assert task.title == "口径「周销售」升到 v2,答案的数变了"
# 问数据的轮次、证据面板读的账本接口:这个答案有一条没处理的召回待办(界面链到待办箱),没有「已核对」
marks = (await client.get(f"/api/runs/{a1.id}/ledger")).json()["recall"]
assert marks == {"checked": [], "basis": [], "tasks": [{"id": task.id, "reason": "caliber", "to_version": 2}]}
res = await client.get(f"/api/tasks/{task.id}")
assert res.status_code == 200, res.text
payload = res.json()["payload"]
assert payload["kind"] == "recall" and payload["reason"] == "caliber" and payload["to_version"] == 2
[metric] = payload["metrics"]
assert (metric["old"]["value"], metric["new"]["value"], metric["diff"]["value"]) == (170.5, 208.5, 38.0)
assert metric["changed"] is True and metric["old"]["metric"] == "old_0" and metric["diff"]["metric"] == "diff_0"
[ans] = payload["answers"]
assert ans["run_id"] == a1.id and ans["kind"] == "answer"
assert ans["original"][0]["value"] == 170.5 and ans["original"][0]["column"] == "销售额"
assert [a["decision"] for a in payload["actions"]] == ["reissue", "ack"]
assert payload["actions"][0]["available"] is True
# 三个数各自点得开:召回卡上的差值的出处链追到两版的口径卡
chain = await client.get(f"/api/runs/{done.id}/evidence/metrics/recall/diff_0")
assert chain.status_code == 200, chain.text
assert {s["node_id"] for s in chain.json()["chain"] if s["step"] == "input"} == {"old_card", "new_card"}
async def test_unchanged_numbers_are_only_booked_on_the_answer(monkeypatch):
wf = await S.publish()
a1 = await answer(monkeypatch)
# v2 只换了报告怎么写,口径的数据部分没变
graph = S.caliber_graph()
graph["nodes"][3]["data"]["config"]["instructions"] = "写周销售(新的写法)"
await publish_v2(wf, graph=graph)
[done] = await run_recall(wf)
assert done.status == "succeeded", done.error
assert await recall_tasks(done.id) == []
[booked] = await events(a1.id, EventType.LEDGER_RECALLED)
assert booked == {"role": "answer", "changed": False, "recall_run": done.id, "workflow_id": wf,
"from_version": 1, "to_version": 2, "post_seal": True}
# 账本接口带上「已按 v2 核对,不影响」:问数据的轮次、证据面板不用再去翻运行时间线
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
marks = (await c.get(f"/api/runs/{a1.id}/ledger")).json()["recall"]
[checked] = marks["checked"]
assert {k: checked[k] for k in ("workflow_id", "workflow_name", "from_version", "to_version", "changed",
"recall_run")} == {"workflow_id": wf, "workflow_name": "周销售", "from_version": 1,
"to_version": 2, "changed": False, "recall_run": done.id}
assert checked["at"] and marks["basis"] == [] and marks["tasks"] == []
# 再收一次尾不会重复记
assert await recall.settle(done.id) is None
assert len(await events(a1.id, EventType.LEDGER_RECALLED)) == 1
@pytest.mark.parametrize("values", [{}, {"old_0": {"value": 1, "rendered": "1.00元"}}], ids=["none", "half"])
async def test_unreadable_recall_card_is_a_failure_not_unchanged(monkeypatch, values):
"""召回运行成功了、召回卡却读不出来(封存里找不到 recall 节点、读不出封存,或少了某个比较的旧值 / 新值):照实记重算
失败,不当成「数没变」去给原答案盖「已核对,不影响」,也不开待办。"""
wf = await S.publish()
a1 = await answer(monkeypatch)
await publish_v2(wf)
async def unreadable(run_id):
return dict(values)
monkeypatch.setattr(recall, "card_values", unreadable)
[done] = await run_recall(wf)
assert done.status == "succeeded", done.error
[settled] = [d for d in await events(done.id, EventType.LEDGER_RECALLED) if d.get("role") == "recall"]
assert settled["outcome"] == "failed" and settled["changed"] is None and settled["error"] == "读不出召回卡"
assert await events(a1.id, EventType.LEDGER_RECALLED) == [], "读不出的不在原答案上记「不影响」"
assert await recall_tasks(done.id) == []
async with SessionLocal() as session:
view = await recall.batches(session, wf, 2)
[group] = view["groups"]
assert group["state"] == "failed" and group["reason"] == "重算失败:读不出召回卡"
async def test_incompatible_entry_and_side_effects_are_not_recomputed(monkeypatch):
wf = await S.publish()
await answer(monkeypatch)
graph = S.caliber_graph(WITH_REFUNDED)
graph["nodes"][0]["data"]["config"]["fields"].append({"name": "region", "required": True})
await publish_v2(wf, graph=graph)
async with SessionLocal() as session:
[group] = (await recall.build_plan(session, wf, 2)).groups
assert group.problem is not None and group.problem.startswith("新版 v2 的入口要的参数这一组里没有或不合规")
assert await recall.launch(wf, 2) == []
async with SessionLocal() as session:
view = await recall.batches(session, wf, 2)
assert view["counts"]["not_recomputable"] == 1 and view["groups"][0]["reason"] == group.problem
assert view["can_continue"] is False
async def test_human_node_elsewhere_still_recomputes_but_reissue_refuses(client, monkeypatch):
"""新版图里在口径卡下游有审批(取数 → 口径卡 → 报告 → 人工确认 → 出口):召回图只有两版的取数和口径卡,人工节点碰不到,
照样重算、数变了照样开待办(做法 7 不重算的只有三种:缺入口字段、数据部分不能复用、会写外部系统);「按新版重新出具」
走完整的正式运行,仍然不代人审批,照实写不能出具。"""
wf = await S.publish(name="周销售(要审批)")
await answer(monkeypatch)
human = S.caliber_graph(WITH_REFUNDED)
human["nodes"].insert(4, S.node("ok", "human", mode="approve", title="确认"))
human["edges"] = [{"source": a["id"], "target": b["id"]} for a, b in zip(human["nodes"], human["nodes"][1:])]
await S.publish(graph=human, version=2, workflow_id=wf)
async with SessionLocal() as session:
plan = await recall.build_plan(session, wf, 2)
assert plan.groups and all(g.problem is None for g in plan.groups), [g.problem for g in plan.groups]
assert plan.version_problem == {}
[done] = await run_recall(wf)
assert done.status == "succeeded", done.error
assert not [n for n in done.graph["nodes"] if n["type"] == "human"], "召回图里没有人工节点"
[task] = await recall_tasks(done.id)
payload = (await client.get(f"/api/tasks/{task.id}")).json()["payload"]
reissue = next(a for a in payload["actions"] if a["decision"] == "reissue")
assert reissue["available"] is False and "人工节点" in reissue["reason"], reissue
res = await client.post(f"/api/tasks/{task.id}/respond", json={"decision": "reissue"})
assert res.status_code == 409 and "人工节点" in res.json()["detail"], res.text
async def test_writing_to_an_external_system_is_not_recomputed(monkeypatch):
"""新版图里调用工具节点绑的能力会写外部系统(capabilities.registry.writes_externally,同按新口径补算、按口径出具):
照实记「无法重算」,不发起。"""
from types import SimpleNamespace
from app.capabilities import registry
wf = await S.publish()
await answer(monkeypatch)
graph = S.caliber_graph(WITH_REFUNDED)
graph["nodes"].insert(4, S.node("pay", "tool", tool="erp_pay", args={}))
graph["edges"] = [{"source": a["id"], "target": b["id"]} for a, b in zip(graph["nodes"], graph["nodes"][1:])]
await publish_v2(wf, graph=graph)
real = registry.get_capability
async def get_capability(session, name):
if name == "erp_pay":
return SimpleNamespace(name=name, effect="write", effect_system="write", endpoint_id=None)
return await real(session, name)
monkeypatch.setattr(registry, "get_capability", get_capability)
async with SessionLocal() as session:
plan = await recall.build_plan(session, wf, 2)
assert plan.groups and all("会写外部系统(erp_pay)" in (g.problem or "") for g in plan.groups), \
[g.problem for g in plan.groups]
assert await recall.launch(wf, 2) == []
async def test_plan_from_formal_runs_and_exporters_as_owners(monkeypatch):
"""正式运行也是用过旧版的答案;导出过报告的人也是答案的主人(关登录时待办派给任何人,主人照记在召回运行里)。"""
from app.apps.audit import record
from app.engine.launch import launch_run
wf = await S.publish()
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")
formal = await S.wait(formal.id)
assert formal.status == "succeeded", formal.error
async with SessionLocal() as session:
await record(session, action="report.exported", subject_kind="run", subject_id=formal.id, actor="reader",
data={})
await session.commit()
await publish_v2(wf)
async with SessionLocal() as session:
[group] = (await recall.build_plan(session, wf, 2)).groups
usage = await recall.preview(session, wf, 2)
[use] = group.uses
assert use.kind == "formal" and use.exporters == ["reader"]
assert ("user", "analyst") in use.owners and ("user", "reader") in use.owners
assert usage["versions"][0]["exports"] == 1 and usage["versions"][0]["formal"] == 1
[done] = await run_recall(wf)
[task] = await recall_tasks(done.id)
async with SessionLocal() as session:
[assignment] = (await session.execute(select(TaskAssignment).where(TaskAssignment.task_id == task.id))).scalars()
assert assignment.principal_kind == "anyone", "关登录时派给任何人"
from app.api.deps import Principal
from app.ledger.recall import task_payload
async with SessionLocal() as session:
payload = await task_payload(session, await session.get(Task, task.id),
Principal(username=None, role="admin", mode="off", via="off"))
[ans] = payload["answers"]
assert ans["kind"] == "formal" and ans["original"][0]["value"] == 170.5
# --------------------------------------------------------------------------
# 待办的两个动作、通知
# --------------------------------------------------------------------------
async def test_reissue_and_acknowledge(client, monkeypatch):
wf = await S.publish()
await answer(monkeypatch)
await answer(monkeypatch, "2026-09-01", "2026-09-05") # 这几天也有退款:两组的数都变了
await publish_v2(wf)
runs = await run_recall(wf)
tasks = [t for r in runs for t in await recall_tasks(r.id)]
assert len(tasks) == 2
S.script(monkeypatch, "本周销售额 [[m:gmv]]。")
res = await client.post(f"/api/tasks/{tasks[0].id}/respond", json={"decision": "reissue"})
assert res.status_code == 200, res.text
body = res.json()["task"]
assert body["status"] == "resolved" and body["outcome"]["decision"] == "reissue"
async with SessionLocal() as session:
issued = await session.get(Run, body["outcome"]["issued_run"])
assert issued.origin_kind == "ledger_issue" and issued.run_class == "formal" and issued.version == 2
assert issued.origin["recall_run"] == tasks[0].run_id and issued.origin["how"] == "recall"
assert issued.input["start"] == "2026-09-01" and issued.input["end"] in ("2026-09-08", "2026-09-05")
await S.wait(issued.id)
bad = await client.post(f"/api/tasks/{tasks[1].id}/respond", json={"decision": "approve"})
assert bad.status_code == 422
ok = await client.post(f"/api/tasks/{tasks[1].id}/respond", json={"decision": "ack", "note": "知道了"})
assert ok.status_code == 200 and ok.json()["task"]["outcome"]["decision"] == "ack"
again = await client.post(f"/api/tasks/{tasks[1].id}/respond", json={"decision": "ack"})
assert again.status_code == 409
async def test_recall_notification_has_no_numbers(monkeypatch):
from app.core.crypto import encrypt
from app.db.models import NotifyChannel
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-7d"),
events=["ledger.recall", "task.opened"])
session.add(row)
await session.commit()
channel_id = row.id
try:
wf = await S.publish()
await answer(monkeypatch)
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 [m["event"] for m in mine] == ["ledger.recall"], "订阅了两样的渠道只推一次"
# 看给人读的部分(标题、各行):编号是十六进制的 id,不拿来比
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 个",
"召回": dict(mine[0]["lines"])["召回"]}
assert "周销售" in mine[0]["title"] and "v2" in mine[0]["title"]
async def test_recall_pushes_the_shared_channel_once_per_recall_run(monkeypatch):
"""开登录时一组答案有几个主人就开几条待办,但全局渠道(团队群)按召回运行只收一条(答案、待办都按这次召回汇总,链到
待办箱);订阅了 task.opened 的全局渠道也不再按每条待办各推一次。每个主人自己的个人渠道各收他那一条。"""
from app.core.crypto import encrypt
from app.db.models import NotifyChannel, User
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)
wf = await S.publish()
first = await answer(monkeypatch)
second = await answer(monkeypatch) # 同一周:同一组,两个主人
made: list[str] = []
async with SessionLocal() as session:
for name, run_id in (("analyst_a", first.id), ("analyst_b", second.id)):
user = User(username=name, role="user")
session.add(user)
await session.flush()
(await session.get(Run, run_id)).started_by = name
channel = NotifyChannel(name=f"个人-{name}", scope="user", owner_id=user.id, format="generic",
url=encrypt(f"https://hooks.example.com/{name}"), events=["ledger.recall"])
session.add(channel)
await session.flush()
made.append(channel.id)
for name, events_ in (("团队-召回", ["ledger.recall", "task.opened"]), ("团队-待办", ["task.opened"])):
channel = NotifyChannel(name=name, scope="global", url=encrypt("https://hooks.example.com/team"),
events=events_)
session.add(channel)
await session.flush()
made.append(channel.id)
await session.commit()
monkeypatch.setenv("AGENTLAB_AUTH_MODE", "local")
try:
await publish_v2(wf)
[done] = await run_recall(wf)
await service.drain()
finally:
async with SessionLocal() as session:
for channel_id in made:
await session.delete(await session.get(NotifyChannel, channel_id))
await session.commit()
tasks = await recall_tasks(done.id)
assert len(tasks) == 2, "每个主人一条待办"
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 [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
# 账本接口里的召回待办只列看的这个人手上的:第一个答案的主人看得到自己那条,另一个主人看不到
from app.api.deps import Principal
async with SessionLocal() as session:
own = await recall.answer_marks(session, first.id, Principal(username="analyst_a", role="user", mode="local"))
other = await recall.answer_marks(session, first.id, Principal(username="analyst_b", role="user", mode="local"))
assert len(own["tasks"]) == 1 and own["tasks"][0]["reason"] == "caliber" and other["tasks"] == []
# --------------------------------------------------------------------------
# 目录事实被改、表结构变了
# --------------------------------------------------------------------------
async def _catalog(source_id: str, notes: dict) -> None:
"""探查一次表结构(查询快照才冻结得进目录)、写这张表的目录。"""
from app.data import catalog
from app.data.engine import engines
from app.data.introspect import introspect
from app.db.models import DataSource
async with SessionLocal() as session:
row = await session.get(DataSource, source_id)
row.schema_cache = await introspect(row)
await session.commit()
await engines.invalidate(source_id)
async with SessionLocal() as session:
entry = await catalog.read_entry(session, source_id, "orders")
await catalog.write_entry(session, source_id, "orders", notes, if_version=entry.version if entry else 0,
actor="tester")
def _item(value, status="confirmed"):
return {"value": value, "source": "human", "status": status}
async def test_catalog_fact_change_marks_answers_and_notifies_only_on_new_errors(engine_up, monkeypatch):
from app.data import catalog
source_id = await S.set_source(engine_up)
await _catalog(source_id, {"business_date": _item({"column": "paid_at"}),
"columns": {"amount": {"measure": _item("flow")}}})
row = await answer(monkeypatch)
unrelated = await S.explore(monkeypatch, S.ELSEWHERE, text="门店 [[v:Q1.r0.n]] 家。", origin="chat")
async with SessionLocal() as session:
entry = await catalog.read_entry(session, source_id, "orders")
# 改成存量:按新目录,对存量列求和、分组里没有业务日期是错误级问题(跨期加总存量)
await catalog.save_human_edit(session, source_id, "orders",
{**entry.notes, "columns": {"amount": {"measure": _item("stock")}}},
if_version=entry.version, actor="tester")
await recall.drain()
[marked] = await events(row.id, EventType.LEDGER_BASIS_CHANGED)
assert marked["kind"] == "catalog" and marked["table"] == "orders"
assert marked["items"] == [{"path": "columns.amount.measure", "label": "列 amount 的度量类型", "change": "changed"}]
assert marked["new_errors"] == 1 and marked["checks"][0]["code"] == "stock_summed"
assert await events(unrelated.id, EventType.LEDGER_BASIS_CHANGED) == [], "没查过这张表的不标"
[task] = await recall_tasks(row.id)
assert task.correlation_key.startswith("catalog:")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
marks = (await c.get(f"/api/runs/{row.id}/ledger")).json()["recall"]
quiet = (await c.get(f"/api/runs/{unrelated.id}/ledger")).json()["recall"]
[basis] = marks["basis"]
assert {k: basis[k] for k in ("kind", "items", "new_errors")} == {"kind": "catalog", "items": 1, "new_errors": 1}
assert basis["at"] and marks["tasks"] == [{"id": task.id, "reason": "catalog"}] and marks["checked"] == []
assert quiet == {"checked": [], "basis": [], "tasks": []}
async with SessionLocal() as session:
from app.api.deps import Principal
payload = await recall.task_payload(session, task, Principal(username=None, role="admin", mode="off", via="off"))
assert payload["reason"] == "catalog" and payload["basis"]["new_errors"] == 1
assert [a["decision"] for a in payload["actions"]] == ["ack"]
# 只改名称说明(不影响数字的项):不标
async with SessionLocal() as session:
entry = await catalog.read_entry(session, source_id, "orders")
await catalog.save_human_edit(session, source_id, "orders", {**entry.notes, "label": _item("订单")},
if_version=entry.version, actor="tester")
await recall.drain()
assert len(await events(row.id, EventType.LEDGER_BASIS_CHANGED)) == 1
async def test_catalog_edits_on_one_source_share_one_pass(engine_up, monkeypatch):
"""批量审阅一口气改了同一个数据源的几张表:只在后台做一遍(同一个数据源排着做,几张表合在一次里查),查询快照按数据源
在库里筛,冻结的表结构快照(大库的能有几 MB)同一份只读一次。"""
from collections import Counter
from app.core import artifact_store
from app.data import catalog
source_id = await S.set_source(engine_up)
await _catalog(source_id, {"business_date": _item({"column": "paid_at"}),
"columns": {"amount": {"measure": _item("flow")}}})
async with SessionLocal() as session:
await catalog.write_entry(session, source_id, "stores", {"valid_filter": _item("id > 0")}, if_version=0,
actor="tester")
week1 = await answer(monkeypatch)
week2 = await answer(monkeypatch, "2026-09-08", "2026-09-15")
shops = await S.explore(monkeypatch, S.ELSEWHERE, text="门店 [[v:Q1.r0.n]] 家。", origin="chat")
scans: list[list[str]] = []
loads: list[str] = []
real_scan, real_load = recall._explore_snapshots, artifact_store.load
async def scan(session, source, tables, **kw):
scans.append(sorted(tables))
return await real_scan(session, source, tables, **kw)
def load(artifact_id):
loads.append(artifact_id)
return real_load(artifact_id)
monkeypatch.setattr(recall, "_explore_snapshots", scan)
monkeypatch.setattr(artifact_store, "load", load)
async with SessionLocal() as session:
orders = await catalog.read_entry(session, source_id, "orders")
stores = await catalog.read_entry(session, source_id, "stores")
rejected = {**orders.notes, "business_date": {**orders.notes["business_date"], "status": "rejected"}}
# 批量审阅逐表写完逐表调审阅之后的钩子(catalog.after_review → recall.after_review),中间不让出事件循环
assert recall.after_review(source_id, "orders", orders.notes, rejected, orders.version + 1) == 1
assert recall.after_review(source_id, "stores", stores.notes,
{"valid_filter": {**stores.notes["valid_filter"], "status": "rejected"}},
stores.version + 1) == 1
await recall.drain()
assert scans == [["orders", "stores"]], "同一个数据源的几张表合成一遍"
for run in (week1, week2):
[marked] = await events(run.id, EventType.LEDGER_BASIS_CHANGED)
assert marked["table"] == "orders" and marked["items"][0]["path"] == "business_date"
[marked] = await events(shops.id, EventType.LEDGER_BASIS_CHANGED)
assert marked["table"] == "stores" and marked["items"][0]["path"] == "valid_filter"
schemas = set()
for snapshot in {a for a in loads}:
content = real_load(snapshot)
if isinstance(content, dict) and content.get("schema_artifact"):
schemas.add(content["schema_artifact"])
assert schemas and all(Counter(loads)[sid] == 1 for sid in schemas), Counter(loads)
async def test_snapshot_scan_pages_newest_first_and_stops_when_full(engine_up, monkeypatch):
"""翻查询快照:只取这个数据源的、新的在前,每张表凑够 BASIS_MAX_RUNS 次运行就不再往下翻。"""
from app.db.models import DataSource
source_id = await S.set_source(engine_up)
runs = [await answer(monkeypatch, f"2026-09-0{d}", "2026-09-08") for d in (1, 2, 3)]
pages: list[int] = []
monkeypatch.setattr(recall, "BASIS_MAX_RUNS", 2)
monkeypatch.setattr(recall, "SNAPSHOT_PAGE", 1)
async with SessionLocal() as session:
source = await session.get(DataSource, source_id)
real_execute = session.execute
async def counted(stmt, *args, **kwargs):
pages.append(1)
return await real_execute(stmt, *args, **kwargs)
monkeypatch.setattr(session, "execute", counted)
found = await recall._explore_snapshots(session, source, ["ORDERS"])
read = len(pages)
other = DataSource(id="elsewhere", name="elsewhere")
nothing = await recall._explore_snapshots(session, other, ["orders"])
assert [r for r, _ in found["ORDERS"]] == [runs[2].id, runs[1].id], "新的在前、凑够两次运行就停"
# 一页一行:第三页读到第三次运行时两次已经凑够,停下,不再翻第四页
assert read == 3, f"凑够了不再往下翻(翻了 {read} 页)"
assert nothing == {"orders": []}, "别的数据源的快照不读回来"
def test_fact_changes_only_counts_items_that_affect_numbers():
before = {"valid_filter": _item("status <> 'open'"), "label": _item("订单"),
"columns": {"amount": {"measure": _item("flow"), "label": _item("金额")}}}
after = {"valid_filter": {**_item("status <> 'open'"), "status": "rejected"}, "label": _item("订单表"),
"columns": {"amount": {"label": _item("金额")}}}
changes = recall.fact_changes(before, after)
assert [(c["path"], c["change"]) for c in changes] == [("valid_filter", "rejected"),
("columns.amount.measure", "removed")]
assert recall.fact_changes(after, after) == []
async def test_structure_change_marks_answers_and_probe_failure_notifies(engine_up, monkeypatch):
import sqlite3
from app.data import source_changes as sc
from app.db.models import DataSource, WorkflowVersion
wf = await S.publish()
async with SessionLocal() as session:
row = (await session.execute(select(WorkflowVersion).where(WorkflowVersion.workflow_id == wf))).scalar_one()
row.published_by = "publisher"
await session.commit()
a1 = await answer(monkeypatch)
# 列真的删掉了:口径的取数按这一版再算就算不出来
db = sqlite3.connect(engine_up)
db.executescript("ALTER TABLE orders DROP COLUMN amount;")
db.close()
async with SessionLocal() as session:
source = (await session.execute(select(DataSource).where(DataSource.name == S.CURRENT["source"]))).scalar_one()
change, _ = await sc.record(session, source, sc.NewChange(kind="column_removed", table="orders", column="amount",
severity="action", key="v2"),
sc.Detected(job_kind="introspect"))
await session.commit()
out = sc.change_out(change)
assert recall.basis_kind(out) == "structure"
assert await recall.process_change(out) == 1
[marked] = await events(a1.id, EventType.LEDGER_BASIS_CHANGED)
assert marked["kind"] == "structure" and marked["change"] == out["id"] and marked["column"] == "amount"
async with SessionLocal() as session:
[probe] = [r for r in (await session.execute(select(Run).where(Run.origin_kind == "recall"))).scalars()
if (r.origin or {}).get("change") == out["id"]]
assert probe.origin["probe"] == 1 and probe.origin["workflow_id"] == wf and probe.origin["inputs_from"] == "answer"
probe = await S.wait(probe.id)
assert probe.status == "failed"
await recall.drain()
[task] = await recall_tasks(probe.id)
assert task.title == "口径「周销售」v1 依据的表结构变了,试算没有算出来"
# 同一条变化再处理一次:不再标、不再试算
assert await recall.process_change(out) == 0
async with SessionLocal() as session:
probes = (await session.execute(select(Run).where(Run.origin_kind == "recall"))).scalars().all()
assert len([p for p in probes if (p.origin or {}).get("change") == out["id"]]) == 1
# --------------------------------------------------------------------------
# 版本用量、预告接口、E4
# --------------------------------------------------------------------------
async def test_usage_preview_batches_and_stats_endpoints(client, monkeypatch):
wf = await S.publish()
await answer(monkeypatch)
usage = (await client.get(f"/api/ledger/workflow-versions/{wf}/1")).json()
assert usage["answers"] == 1 and usage["exports"] == 0 and usage["recall"] == {"groups": 0, "changed": 0,
"recalled_from": 0}
await publish_v2(wf)
preview = (await client.get(f"/api/workflows/{wf}/recall-preview", params={"version": 2})).json()
assert preview["groups"] == 1 and preview["will_recompute"] == 1 and preview["over_limit"] == 0
await run_recall(wf)
view = (await client.get(f"/api/ledger/recall/{wf}/2")).json()
assert view["counts"]["changed"] == 1 and view["groups"][0]["state"] == "changed"
assert view["groups"][0]["changed_metrics"] == ["销售额"]
again = await client.post(f"/api/ledger/recall/{wf}/2/continue")
assert again.status_code == 409
usage = (await client.get(f"/api/ledger/workflow-versions/{wf}/2")).json()
assert usage["recall"]["groups"] == 1 and usage["recall"]["changed"] == 1
old = (await client.get(f"/api/ledger/workflow-versions/{wf}/1")).json()
assert old["recall"]["recalled_from"] == 1
stats = (await client.get("/api/ledger/stats")).json()["e4"]["per_publish"]
mine = next(p for p in stats["publishes"] if p["workflow_id"] == wf)
assert mine == {"caliber": f"{wf}@2", "workflow_id": wf, "version": 2, "groups": 1, "settled": 1, "changed": 1,
"failed": 0, "changed_rate": 1.0}
async def test_publish_hook_queues_recall_when_automatic(client, monkeypatch):
from app.core.config import settings
monkeypatch.setattr(settings, "scheduler", True)
wf = await S.publish()
await answer(monkeypatch)
await S.publish(graph=S.caliber_graph(WITH_REFUNDED), version=2, workflow_id=wf, level="governed")
async with SessionLocal() as session:
from app.db.models import Workflow
(await session.get(Workflow, wf)).published_version = 1
await session.commit()
res = await client.post(f"/api/workflows/{wf}/publish", json={"level": "published", "version": 2})
assert res.status_code == 200 and res.json()["ok"], res.text
await recall.drain()
async with SessionLocal() as session:
runs = await recall.recall_runs(session, wf, 2)
assert len(runs) == 1 and runs[0].origin["batch"] == "auto"
await S.wait(runs[0].id)
await recall.drain()
async def test_recall_graph_is_deterministic_and_events_have_offline_labels(monkeypatch):
"""同样的组永远得到同一张召回图;两个新事件在可验证报告的离线核对页、封存后事件摘要里有说法(不带数)。"""
import re
from pathlib import Path
from app.bundle.build import _summary
wf = await S.publish()
await answer(monkeypatch)
await publish_v2(wf)
async with SessionLocal() as session:
plan = await recall.build_plan(session, wf, 2)
[group] = plan.groups
a, meta = recall.build_graph(group, plan.pairs[1])
b, _ = recall.build_graph(group, plan.pairs[1])
assert a.graph == b.graph and a.kind == "recall"
assert meta == [{"k": 0, "card": "card", "metric": "gmv", "name": "销售额", "unit": "元", "decimals": 2,
"format": "thousands", "expression": "cell(nodes.fetch, 0, 'gmv')"}]
card = next(n for n in a.graph["nodes"] if n["id"] == "recall")["data"]["config"]
assert [m["id"] for m in card["metrics"]] == ["old_0", "new_0", "diff_0"] and card["on_missing"] == "null"
script = (Path(__file__).resolve().parents[1] / "app" / "bundle" / "verifier.js").read_text(encoding="utf-8")
block = re.search(r"const EVENT_LABEL = \{(.*?)\n \}", script, re.S)
labels = dict(re.findall(r"'([a-z_.]+)': '([^']+)'", block.group(1)))
assert labels["ledger.recalled"] == "口径召回核对" and labels["ledger.basis_changed"] == "答案的依据有变"
assert labels["ledger.matched"] == "比对已发布口径"
assert _summary("ledger.recalled", {"role": "answer", "changed": False, "to_version": 2}) == "已按 v2 核对,不影响"
assert _summary("ledger.basis_changed", {"kind": "structure", "items": [{"label": "x"}]}) == "依据的表结构有变(1 项)"
async def test_restart_resumes_the_auto_batch_and_settles_finished_runs(monkeypatch):
"""服务重启:计划从已有召回运行重算——自动那一批没发完的接着发,结束了还没收尾的补上收尾(不建任务表)。"""
from app.db.base import utcnow
from app.db.models import WorkflowVersion
await ledger_settings(auto_issue=False, issue_daily_limit=100_000, recall_limit=2)
wf = await S.publish()
await answer(monkeypatch)
await answer(monkeypatch, "2026-09-01", "2026-09-05")
await publish_v2(wf)
async with SessionLocal() as session:
row = (await session.execute(select(WorkflowVersion).where(WorkflowVersion.workflow_id == wf,
WorkflowVersion.version == 2))).scalar_one()
row.published_at = utcnow()
await session.commit()
# 「重启之前」:只发起了一组,收尾也没做(进程在这之后没了)
monkeypatch.setattr(recall, "schedule_settle", lambda run_id: None)
async with SessionLocal() as session:
plan = await recall.build_plan(session, wf, 2)
first = await recall._launch_group(plan, plan.todo()[0], batch="auto", actor=None)
first = await S.wait(first.id)
assert await recall_tasks(first.id) == [] and await events(first.id, EventType.LEDGER_RECALLED) == []
monkeypatch.undo()
out = await recall.resume()
# 同一个测试库里别的用例的版本也会被接着做:只数这一份口径的
assert out["launched"] >= 1 and out["settled"] >= 1
async with SessionLocal() as session:
runs = await recall.recall_runs(session, wf, 2)
assert len(runs) == 2 and {r.origin["batch"] for r in runs} == {"auto"}
for run in runs:
await S.wait(run.id)
await recall.drain()
assert len(await recall_tasks(first.id)) == 1, "补上的收尾照样开了待办"
async with SessionLocal() as session:
view = await recall.batches(session, wf, 2)
assert view["counts"]["changed"] == 2 and view["remaining"] == 0
await recall.resume()
async with SessionLocal() as session:
assert len(await recall.recall_runs(session, wf, 2)) == 2, "再重启一次:都算过了,不再发起"
+15 -8
View File
@@ -41,7 +41,8 @@ PUSHED_THIS_ROUND = {
"key.expiring": {"global", "user"},
# 第 4 波:W4-d 打开 source.changed 时在这里加它推给的几条路
# (两行之间隔一行不动的,两个包各改各的、合并不撞)
# 第 4 波:W4-a 打开 ledger.recall 时在这里加它推给的几条路
# 第 4 波 W4-a:口径召回数真变了的待办推全局渠道和答案主人自己的个人渠道(notify_recall)
"ledger.recall": {"global", "user"},
}
@@ -120,19 +121,24 @@ async def test_events_endpoint_says_who_receives_each_event(client):
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"] == []
assert by["ledger.recall"]["scopes"] == ["global", "user"]
assert by["review.done"]["scopes"] == ["global", "user"]
async def test_events_not_pushed_yet_cannot_be_subscribed(client):
#: 登记了、还没有推送的演示事件(第 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", "ledger.recall"]})
"name": "事件登记-评测", "url": URL, "events": ["task.opened", UNPUSHED]})
assert resp.status_code == 422, resp.text
detail = resp.json()["detail"]
assert "口径升版,答案的数变了" in detail and "还没有推送" in 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 "还没推送的演示事件" not in unknown.json()["detail"], "可选的里面不列还没有推送的"
assert "待办打开" in unknown.json()["detail"]
@@ -163,16 +169,17 @@ async def test_scope_mismatch_is_refused_only_for_newly_ticked_events(client):
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", "ledger.recall"])
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, "ledger.recall", None, []) == [], "还没有推送的:谁都不推"
assert await service._channels(session, UNPUSHED, None, []) == [], "还没有推送的:谁都不推"
with pytest.raises(ValueError, match="没有登记"):
await service._channels(session, "demo.unregistered", None, [])
finally:
+4 -1
View File
@@ -1222,7 +1222,10 @@ function watchTasks(go: Go): () => void {
title: `${TASK_NOTIFY_TEXT.title}${t.title || TASK_NOTIFY_TEXT.untitled}`,
body: [owner, t.node_label].filter(Boolean).join(' · ') || undefined,
tag: `task:${t.id}`,
onClick: () => go(t.app_id ? `/apps/inbox/${encodeURIComponent(t.id)}` : `/runs/${t.run_id}`),
// 应用里的待办、口径召回(挂在召回运行上,记录页里没有这张卡)、使用者(进不了记录页)都去待办页;
// 搭建者的别的待办去那次运行
onClick: () => go(t.app_id || t.kind === 'recall' || !builder
? `/apps/inbox/${encodeURIComponent(t.id)}` : `/runs/${t.run_id}`),
})
}
} catch {
+7 -2
View File
@@ -10,6 +10,7 @@ import type {
ViewShareRow,
} from '../typesApp'
import type { ConversationDetail, EvidenceSegmentDetail } from '../types'
import type { VersionRecallCounts } from '../typesRecall'
import { del, get, patch, post, put, qs, request, type RequestOptions } from './http'
const at = (id: string) => `/apps/${encodeURIComponent(id)}`
@@ -105,8 +106,12 @@ type AssistantConversation =
const assistantInflight = new Map<string, Promise<AssistantConversation>>()
export const ledger = {
/** 一个工作流版本被哪些应用钉住、生效记录数、正式运行数 */
/**
* 一个工作流版本被哪些应用固定、生效记录数、正式运行数;口径账本另加的(第 4 波,老服务端不给):被几次问数回答用过、
* 导出过几次、定时任务几个、召回(以这一版为新版本召回过几组、其中数变了几组,作为旧版本被召回过几组)
*/
version: (workflowId: string, version: number) =>
get<{ apps: { app_id: string; name: string; app_version: number }[]; effects: number; formal_runs: number }>(
get<{ apps: { app_id: string; name: string; app_version: number }[]; effects: number; formal_runs: number
answers?: number; exports?: number; schedules?: number; recall?: VersionRecallCounts }>(
`/ledger/workflow-versions/${encodeURIComponent(workflowId)}/${version}`),
}
+3
View File
@@ -27,6 +27,7 @@ import { whatif } from './whatif'
import { reportExport } from './reportExport'
// 应用平台:各自的接口文件,这里只挂上去
import { apps, ledger } from './apps'
import { recall } from './recall'
import { actions, tasks } from './tasks'
import { auth, identity } from './auth'
import { accessKeys } from './accessKeys'
@@ -957,6 +958,8 @@ export const api = {
// ---- 应用平台(typesApp.ts 是接口约定) ----
apps,
ledger,
// 口径召回(下一轮第 4 波)
recall,
tasks,
actions,
auth,
+23
View File
@@ -0,0 +1,23 @@
/**
* 口径召回的接口(docs/design/next-round/02-口径账本.md 做法 7、8),挂在 api.recall 上。形状见 typesRecall.ts。
*
* - preview:发布弹窗的召回预告(把这一版发布成新版本之后,旧版本被谁用过、预计重算几组、超出上限几组);
* - batches:版本历史里口径升到这一版之后的召回批次(每组的结论、「另有 M 组未重算」);
* - continueBatch:手动接着算下一批(都算过了回 409)。
* 待办里的「按新版重新出具」「知道了」走待办接口(api.tasks.respond,decision=reissue / ack)。
*
* 请求函数从 ./http 导入,不要从 ./client 导入(循环导入,见 http.ts 开头)。
*/
import type { RecallBatches, RecallPreview } from '../typesRecall'
import { get, post, qs, type RequestOptions } from './http'
const wf = (id: string) => encodeURIComponent(id)
export const recall = {
preview: (workflowId: string, version: number, opts?: RequestOptions) =>
get<RecallPreview>(`/workflows/${wf(workflowId)}/recall-preview${qs({ version })}`, opts),
batches: (workflowId: string, version: number, opts?: RequestOptions) =>
get<RecallBatches>(`/ledger/recall/${wf(workflowId)}/${version}`, opts),
continueBatch: (workflowId: string, version: number) =>
post<RecallBatches>(`/ledger/recall/${wf(workflowId)}/${version}/continue`),
}
+5
View File
@@ -17,6 +17,7 @@ import {
publishedLevelOf,
} from '../lib/terms'
import { VersionUsageLine, useVersionUsage } from './versionUsage'
import { RecallPreviewRow } from './RecallPanel'
import { newAppPath } from '../pages/apps/shared'
import { isSqlCheckCode, sqlRuleLabel } from '../lib/sqlcheck'
import { SqlLevelBadge } from '../run/SqlChecks'
@@ -255,6 +256,10 @@ export function PublishDialog({ workflow, onClose, onDone, onLocate }: {
</dd>
</div>
)}
{/* 口径召回的预告(next-round 02 做法 7):发布新版本之后用过旧版本的答案按参数分组重算,规模先摆出来 */}
{published != null && published !== workflow.version && (
<RecallPreviewRow workflowId={workflow.id} version={workflow.version} />
)}
<div className="flex items-start gap-2" data-publish-signer={signer.verified ? 'verified' : actor ? 'actor' : 'unsigned'}>
<dt className="w-14 shrink-0 pt-px text-faint">{signer.verified ? '发布人' : '署名'}</dt>
<dd className="min-w-0 flex-1">
+186
View File
@@ -0,0 +1,186 @@
/**
* 口径召回在画布上的两处(docs/design/next-round/02-口径账本.md 做法 7、8):
* - 发布弹窗的召回预告(RecallPreviewRow):把这一版发布成新版本之后,旧版本被多少问数回答、导出、定时任务、应用用过,
* 预计自动重算几组,超出上限的写明(GET /api/workflows/{id}/recall-preview)。和发布之后真的召回走同一份计划,规模一致;
* - 版本历史里口径升到这一版之后的召回批次(RecallBatches):每组的结论(数变了 / 没变 / 无法重算:原因 / 重算失败 / 正在
* 重算 / 未重算),「另有 M 组未重算」和「接着算」(GET / POST /api/ledger/recall/{wf}/{v})。之后又发布了更新的版本的,
* 旧的那一版不再给「接着算」,写服务端给的原因(continue_reason)。
*
* 界面层不放逻辑:几组、能不能重算、为什么不能、能不能接着算都由服务端给。老服务端没有这两个接口(404)时不画。
*/
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { ExternalLink, Play } from 'lucide-react'
import { api } from '../api/client'
import { Spinner, toast } from '../components/ui'
import { formatDateTime } from '../lib/format'
import { RECALL_TEXT } from '../lib/termsRecall'
import type { RecallBatches as Batches, RecallGroup, RecallPreview } from '../typesRecall'
const P = RECALL_TEXT.preview
const B = RECALL_TEXT.batches
/** 发布弹窗「召回」那一行。旧版本里没有口径、也没有用过的答案时不画(发布不牵涉召回) */
export function RecallPreviewRow({ workflowId, version }: { workflowId: string; version: number }) {
const [preview, setPreview] = useState<RecallPreview | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
let alive = true
setPreview(null)
setFailed(false)
api.recall.preview(workflowId, version)
.then((p) => { if (alive) setPreview(p) })
.catch(() => { if (alive) setFailed(true) })
return () => { alive = false }
}, [workflowId, version])
if (failed || !preview) {
return failed ? null : (
<div className="flex gap-2" data-publish-recall="loading">
<dt className="w-14 shrink-0 text-faint">{P.label}</dt>
<dd className="min-w-0 flex-1 text-faint">{P.loading}</dd>
</div>
)
}
if (!preview.caliber && !preview.groups) return null
const uses = preview.versions.reduce((acc, v) => ({
answers: acc.answers + v.answers, exports: acc.exports + v.exports, schedules: acc.schedules + v.schedules,
apps: acc.apps + v.apps, formal: acc.formal + v.formal, pinned: acc.pinned + v.pinned,
}), { answers: 0, exports: 0, schedules: 0, apps: 0, formal: 0, pinned: 0 })
return (
<div className="flex gap-2" data-publish-recall={preview.groups ? 'groups' : 'none'}>
<dt className="w-14 shrink-0 text-faint">{P.label}</dt>
<dd className="min-w-0 flex-1 space-y-0.5">
{!preview.groups ? <div className="text-faint">{P.none}</div> : (
<>
<div data-publish-recall-uses="">{P.uses(uses)}</div>
<div data-publish-recall-groups={preview.groups} data-publish-recall-will={preview.will_recompute}>
{P.groups(preview.groups, preview.will_recompute)}
{preview.already > 0 && <span className="text-faint">({P.already(preview.already)})</span>}
</div>
{preview.limit === 0 && <div style={{ color: 'var(--st-waiting)' }} data-publish-recall-zero="">{P.zero}</div>}
{preview.limit > 0 && preview.over_limit > 0 && (
<div style={{ color: 'var(--st-waiting)' }} data-publish-recall-over={preview.over_limit}>
{P.over(preview.over_limit, preview.limit)}
</div>
)}
{preview.not_recomputable > 0 && (
<div className="text-dim" data-publish-recall-unrecomputable={preview.not_recomputable}>
{P.notRecomputable(preview.not_recomputable)}:{preview.reasons.map((r) => r.reason).join(';')}
</div>
)}
{preview.truncated && <div className="text-faint">{P.truncated}</div>}
<div className="text-faint">{P.hint}</div>
</>
)}
</dd>
</div>
)
}
const STATE_COLOR: Record<string, string | undefined> = {
changed: 'var(--st-waiting)', failed: 'var(--err)', not_recomputable: 'var(--text-faint)', unchanged: 'var(--ok)',
}
/** 列表先列几组,其余收起 */
const SHOWN = 6
/** 版本历史里这一版的召回。还没有任何一组(旧版本没被用过、或者这是第一版)时不画 */
export function RecallBatches({ workflowId, version }: { workflowId: string; version: number }) {
const [data, setData] = useState<Batches | null>(null)
const [failed, setFailed] = useState(false)
const [busy, setBusy] = useState(false)
const [all, setAll] = useState(false)
useEffect(() => {
let alive = true
setData(null)
setFailed(false)
api.recall.batches(workflowId, version)
.then((d) => { if (alive) setData(d) })
.catch(() => { if (alive) setFailed(true) })
return () => { alive = false }
}, [workflowId, version])
const continueBatch = async () => {
if (busy) return
setBusy(true)
try {
const next = await api.recall.continueBatch(workflowId, version)
setData(next)
toast.ok(B.continued(next.launched?.length ?? 0))
} catch (e) {
toast.error(e)
} finally {
setBusy(false)
}
}
if (failed) return null
if (!data) return <div className="mt-3 border-t pt-2 text-2xs text-faint" data-version-recall="loading">{B.loading}</div>
if (!data.groups.length) return null
const shown = all ? data.groups : data.groups.slice(0, SHOWN)
const summary = B.summary(data.counts)
return (
<div className="mt-3 border-t pt-2 text-2xs" data-version-recall={data.groups.length}>
<div className="font-medium text-dim">{B.title}</div>
{summary && <div className="mt-1 text-dim" data-version-recall-summary="">{summary}</div>}
<ul className="mt-1 space-y-1">
{shown.map((g) => <GroupRow key={g.id} group={g} />)}
</ul>
{!all && data.groups.length > SHOWN && (
<button type="button" className="mt-1 text-faint underline-offset-2 hover:underline" onClick={() => setAll(true)}>
{B.more(data.groups.length - SHOWN)}
</button>
)}
{data.remaining > 0 && (
<div className="mt-1.5" data-version-recall-remaining={data.remaining}>
<div style={{ color: 'var(--st-waiting)' }}>{B.remaining(data.remaining)}</div>
{data.can_continue && (
<button type="button" className="btn btn-sm mt-1 w-full justify-center" disabled={busy}
data-version-recall-continue="" onClick={() => void continueBatch()}>
{busy ? <Spinner size={11} /> : <Play size={11} aria-hidden />} {busy ? B.continuing : B.continue(data.batch_size)}
</button>
)}
{!data.can_continue && data.continue_reason && (
<div className="mt-0.5 text-dim" data-version-recall-continue-reason="">{B.cannotContinue(data.continue_reason)}</div>
)}
</div>
)}
{data.truncated && <div className="mt-1 text-faint">{B.truncated}</div>}
</div>
)
}
function GroupRow({ group }: { group: RecallGroup }) {
return (
<li className="rounded border bg-bg px-2 py-1" data-version-recall-group={group.id} data-state={group.state}>
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
<span className="chip" style={STATE_COLOR[group.state] ? { color: STATE_COLOR[group.state] } : undefined}>
{B.state[group.state] ?? group.state}
</span>
<span className="text-dim">{B.from(group.from_version)}</span>
<span className="text-faint">{B.uses(group.uses)}</span>
{group.latest && <span className="text-faint">· {formatDateTime(group.latest)}</span>}
</div>
<div className="mt-0.5 flex flex-wrap gap-1">
{group.binding.length
? group.binding.map((b) => <span key={b.field} className="chip mono">{b.field}={String(b.value ?? '')}</span>)
: <span className="text-faint">{B.noBinding}</span>}
</div>
{group.reason && (
<div className="mt-0.5" style={{ color: group.state === 'pending' ? 'var(--st-waiting)' : 'var(--text-dim)' }}
data-version-recall-reason="">
{B.reason(group.state, group.reason)}
</div>
)}
{!!group.changed_metrics?.length && <div className="mt-0.5 text-dim">{B.changedMetrics(group.changed_metrics)}</div>}
<div className="mt-0.5 flex flex-wrap gap-x-3">
{!!group.tasks && <span className="text-faint">{B.tasks(group.tasks)}</span>}
{group.run_id && (
<Link to={`/runs/${encodeURIComponent(group.run_id)}`} data-version-recall-run={group.run_id}
className="inline-flex items-center gap-1 text-dim underline-offset-2 hover:text-fg hover:underline">
<ExternalLink size={10} aria-hidden /> {B.openRun}
</Link>
)}
</div>
</li>
)
}
+5 -1
View File
@@ -12,6 +12,7 @@ import { formatDateTime, formatTime } from '../lib/format'
import { CATALOG_DRIFT_TEXT, VERSION_USAGE_TEXT, WORKFLOW_STATUS_LABEL, publishedLevelOf } from '../lib/terms'
import { useAtLeast } from '../store/auth'
import { RetiredNote, VersionUsageLine, retireBlocked, retireVersion, useVersionUsage } from './versionUsage'
import { RecallBatches } from './RecallPanel'
import { hintOf } from './shortcuts'
import { newAppPath } from '../pages/apps/shared'
import type { Edge } from '@xyflow/react'
@@ -240,7 +241,8 @@ function VersionPreview({ version, target, diff, lock, onRestore }: {
const DIFF_COLOR = { add: 'var(--ok)', del: 'var(--err)', mod: 'var(--warn)' } as const
/**
* 发布过的版本:被哪些应用固定、生效记录和正式运行各有多少(L4),做成应用,以及退役(A1)。草稿版本都没有,不画。
* 发布过的版本:被哪些应用固定、生效记录和正式运行各有多少(L4),问数回答、导出、召回(next-round 02 做法 8),口径升到
* 这一版之后的召回批次,做成应用,以及退役(A1)。草稿版本都没有,不画。
* 退役之后这一版不能再发起正式运行,也不能再做成应用(应用只能固定已发布且未退役的版本);固定它的应用标「需升级」,
* 进行中的实例照常走完。退役的人、时间和说明写在列表那一行上,这里不再重复
*/
@@ -261,6 +263,8 @@ function VersionLifecycle({ workflowId, version, current, onRetired }: {
<div className="mt-1">
{usage ? <VersionUsageLine usage={usage} /> : <span className="text-faint">…</span>}
</div>
{/* 口径升到这一版之后的召回批次:每组的结论、「另有 M 组未重算」、接着算(next-round 02 做法 7) */}
<RecallBatches workflowId={workflowId} version={version.version} />
{!version.retired_at && (
<>
{builder && (
+16 -2
View File
@@ -4,11 +4,13 @@ import { api } from '../api/client'
import { confirmDialog, promptDialog, toast } from '../components/ui'
import { formatDateTime } from '../lib/format'
import { VERSION_USAGE_TEXT as T } from '../lib/terms'
import { RECALL_TEXT } from '../lib/termsRecall'
/*
* 工作流版本的使用情况与退役(A1、L4):发布弹窗、版本历史、退役确认三处共用。
*
* 使用情况取账本的只读接口(/api/ledger/workflow-versions/{wf}/{v}):被哪些应用固定、生效记录数、正式运行数。
* 使用情况取账本的只读接口(/api/ledger/workflow-versions/{wf}/{v}):被哪些应用固定、生效记录数、正式运行数;口径账本另加
* 问数回答、导出、定时任务、召回(next-round 02 做法 8,老服务端不给就不写)。
* 退役只给管理员(Q8);关登录时所有人等同管理员。当前发布版不能退役,服务端回 409,这里先不给按钮
*/
@@ -27,8 +29,19 @@ export function useVersionUsage(workflowId: string, version: number | null, epoc
return usage
}
/** 「被 2 个应用固定:报销审批、周报看板 · 生效记录 3 条 · 正式运行 14 次」,应用名可点开编辑 */
/**
* 「被 2 个应用固定:报销审批、周报看板 · 生效记录 3 条 · 正式运行 14 次 · 问数回答 5 次 · 导出 2 次 · 召回 3 组(数变了
* 1 组)」,应用名可点开编辑。账本另加的几项老服务端不给就不写;召回两头都是 0 的不写
*/
export function VersionUsageLine({ usage }: { usage: VersionUsage }) {
const U = RECALL_TEXT.usage
const extra = [
usage.answers != null ? U.answers(usage.answers) : null,
usage.exports != null ? U.exports(usage.exports) : null,
usage.schedules ? U.schedules(usage.schedules) : null,
usage.recall?.groups ? U.recall(usage.recall.groups, usage.recall.changed) : null,
usage.recall?.recalled_from ? U.recalledFrom(usage.recall.recalled_from) : null,
].filter((x): x is string => !!x)
return (
<span className="text-dim" data-version-usage="">
{T.apps(usage.apps.length)}
@@ -47,6 +60,7 @@ export function VersionUsageLine({ usage }: { usage: VersionUsage }) {
</>
)}
<span className="text-faint"> · {T.effects(usage.effects)} · {T.formalRuns(usage.formal_runs)}</span>
{extra.length > 0 && <span className="text-faint" data-version-usage-ledger=""> · {extra.join(' · ')}</span>}
</span>
)
}
+1
View File
@@ -77,6 +77,7 @@ export const TASK_KIND_LABEL: Record<TaskKind, string> = {
reconcile: '核实结果',
signal: '等待外部系统',
review: '审核个人应用',
recall: '口径召回',
}
export const TASK_STATUS_LABEL: Record<TaskStatus, string> = {
+170
View File
@@ -0,0 +1,170 @@
/**
* 口径召回的文案(docs/design/next-round/02-口径账本.md 做法 7、8):版本用量、发布弹窗的召回预告、版本历史里的召回批次、
* recall 待办卡、时间线上的两个封存后事件。规矩同 terms.ts 开头的术语表:「固定版本」不写钉住;「运行」不写跑;受管 /
* 已发布;「完整出具 / 降档出具 / 不予出具」;不在句子里裸写键名。叫法同总方案 X10:召回、口径对账、来自口径。
*
* 召回只运行口径的数据部分(取数节点和口径卡)、不调用模型;两版在同一次运行里查同一份数据,差值在召回卡里算——这几句
* 写在预告和待办里,让人知道这些数是怎么来的。消息、待办标题里不放数(服务端照规矩不放),界面上的数都点得开出处。
*/
import { formatNumber } from './format'
const n = (v: number) => formatNumber(v)
export const RECALL_TEXT = {
// ---- 版本用量(版本历史、发布弹窗的「使用情况」那一行,跟在应用、生效记录、正式运行后面) ----
usage: {
answers: (count: number) => `问数回答 ${n(count)} 次`,
exports: (count: number) => `导出 ${n(count)} 次`,
schedules: (count: number) => `定时任务 ${n(count)} 个`,
recall: (groups: number, changed: number) => `召回 ${n(groups)} 组${groups ? `(数变了 ${n(changed)} 组)` : ''}`,
recalledFrom: (groups: number) => `被新版本召回 ${n(groups)} 组`,
},
// ---- 发布弹窗的召回预告 ----
preview: {
label: '召回',
loading: '正在估算召回规模…',
failed: '无法估算召回规模',
none: '旧版本没有被问数回答、导出、定时任务或应用用过,发布后不需要召回',
notCaliber: '旧版本里没有可复用的口径卡,发布后不召回',
uses: (c: { answers: number; exports: number; schedules: number; apps: number; formal: number; pinned: number }) => {
const parts = [
c.answers ? `${n(c.answers)} 次问数回答` : '',
c.exports ? `${n(c.exports)} 次导出` : '',
c.schedules ? `${n(c.schedules)} 个定时任务` : '',
c.apps ? `${n(c.apps)} 个应用` : '',
c.formal ? `${n(c.formal)} 次正式运行` : '',
c.pinned ? `${n(c.pinned)} 次固定了它的工作流的正式运行` : '',
].filter(Boolean)
return parts.length ? `旧版本被 ${parts.join('、')}用过` : '旧版本没有被用过'
},
groups: (groups: number, will: number) => `按参数去重共 ${n(groups)} 组,发布后自动重算 ${n(will)} 组`,
already: (count: number) => `其中 ${n(count)} 组之前已经召回过`,
over: (count: number, limit: number) => `另有 ${n(count)} 组超出每次 ${n(limit)} 组的上限,发布后可在版本历史里手动接着算`,
zero: '设置里每次最多重算 0 组:发布后不自动重算,只在版本历史里列出受影响的答案,可以手动接着算',
notRecomputable: (count: number) => `${n(count)} 组无法重算`,
hint: '重算只运行口径的数据部分(取数和口径卡),不调用模型;新旧两版在同一份数据上各算一次,数真变了才给答案的主人开待办',
truncated: '用过的答案太多,只看了最近的一部分',
},
// ---- 版本历史里口径升到这一版之后的召回批次 ----
batches: {
title: '这一版的召回',
loading: '正在读取召回情况…',
failed: '无法读取召回情况',
none: '旧版本没有被用过的答案,不需要召回',
notCaliber: '旧版本里没有可复用的口径卡,不召回',
summary: (c: Record<string, number>) => {
const parts = [
c.changed ? `数变了 ${n(c.changed)} 组` : '',
c.unchanged ? `没变 ${n(c.unchanged)} 组` : '',
c.running ? `正在重算 ${n(c.running)} 组` : '',
c.failed ? `重算失败 ${n(c.failed)} 组` : '',
c.not_recomputable ? `无法重算 ${n(c.not_recomputable)} 组` : '',
].filter(Boolean)
return parts.join(',')
},
state: {
changed: '数变了', unchanged: '没变', failed: '重算失败', running: '正在重算', pending: '未重算',
not_recomputable: '无法重算',
} as Record<string, string>,
reason: (state: string, reason: string) => (state === 'not_recomputable' ? `无法重算:${reason}` : reason),
remaining: (count: number) => `另有 ${n(count)} 组未重算`,
/** 还有没算的组却不能接着算(之后又发布了更新的版本……):照服务端给的原因写 */
cannotContinue: (reason: string) => `不再接着算:${reason}`,
continue: (size: number) => `接着算(每次 ${n(size)} 组)`,
continuing: '正在发起…',
continued: (count: number) => (count ? `已发起 ${n(count)} 组召回` : '没有发起新的召回'),
from: (v: number) => `v${v} 的答案`,
binding: '参数',
noBinding: '没有参数',
uses: (items: { kind_text: string; count: number }[]) => items.map((u) => `${u.kind_text} ${n(u.count)}`).join('、'),
changedMetrics: (names: string[]) => `变了的指标:${names.join('、')}`,
tasks: (count: number) => `开了 ${n(count)} 条待办`,
openRun: '查看召回运行',
more: (count: number) => `还有 ${n(count)} 组…`,
truncated: '用过的答案太多,只看了最近的一部分',
},
// ---- recall 待办卡(待办箱、待办页) ----
task: {
intro: (name: string, from: number | null | undefined, to: number | null | undefined) =>
`口径「${name}」从 v${from ?? '?'} 升到了 v${to ?? '?'}。在同一份数据上把新旧两版各算了一次,这一组答案用到的数变了。`,
how: '两版在同一次运行里查同一份数据,差值在召回卡里算;原答案当时的值另外列出,和旧口径的值不同说明这段时间数据有变化。',
binding: '这一组的参数',
metric: '指标',
old: (v: number | null | undefined) => `旧口径 v${v ?? '?'}`,
new: (v: number | null | undefined) => `新口径 v${v ?? '?'}`,
diff: '两版之差',
original: '原答案当时的值',
changed: '变了',
same: '没变',
masked: '已遮罩',
missing: '—',
answers: '用到这份口径的答案',
answerOf: (kindText: string, at: string | null) => `${kindText}${at ? ` · ${at}` : ''}`,
openAnswer: '查看答案',
openTurn: '回到问数据',
evidenceTitle: (name: string, side: string) => `${name} · ${side}`,
reissue: '按新版重新出具',
reissueHint: '按新版口径、这一组的参数发起一次正式运行(和按口径正式出具同一套检查)',
reissued: '已按新版重新出具',
issuedRun: '查看出具的正式运行',
/** 使用者进不了记录页:只写已经出具,不给点了回不来的链接(同问数据轮次下的出具结果) */
issuedUserNote: '正式运行的记录由搭建者在记录页查看',
ack: '知道了',
acked: '已记为知道了',
doneReissue: '已按新版发起正式出具',
doneAck: '已记为知道了',
resolved: (decision: string | undefined) => (decision === 'reissue' ? '已按新版重新出具' : '已记为知道了'),
catalogIntro: '答案依据的数据目录有变:按新目录检查,这次执行过的 SQL 出现了新的错误级问题。答案本身没有改,请核对是否还能用。',
catalogItems: '变了的目录项',
catalogChecks: (count: number) => `按新目录新出现的错误级问题 ${n(count)} 条`,
structureIntro: (name: string, version: number | null | undefined) =>
`口径「${name}」v${version ?? '?'} 依据的表结构变了,按最近一次的参数试算没有算出来。请检查口径的取数,必要时发布新版本。`,
structureWhere: (table: string, column?: string | null) => `表 ${table}${column ? ` 的列 ${column}` : ''}`,
probeError: '试算的报错',
openProbe: '查看试算运行',
change: {
removed: '删掉了', rejected: '被驳回', changed: '改了',
} as Record<string, string>,
},
// ---- 时间线(run/decode.ts 认 ledger.recalled、ledger.basis_changed) ----
timeline: {
answer: (v: number | null) => (v != null ? `已按 v${v} 核对,不影响` : '已按新口径核对,不影响'),
recallChanged: (from: number | null, to: number | null) =>
`召回核对${from != null && to != null ? `(v${from} → v${to})` : ''}:数变了`,
recallSame: (from: number | null, to: number | null) =>
`召回核对${from != null && to != null ? `(v${from} → v${to})` : ''}:数没变`,
recallFailed: '召回重算失败',
/** 认不出结论(缺字段)时只说核对过,不猜变没变 */
generic: '口径召回核对',
probeOk: '表结构变了之后试算口径:算出来了',
probeFailed: '表结构变了之后试算口径:没有算出来',
tasks: (count: number) => `开了 ${n(count)} 条待办`,
basis: {
catalog: '依据有变:数据目录里影响数字的项被驳回或修改',
structure: '依据的表结构已变',
values: '依据有变:出现了触及有效记录条件的新码值',
catalog_edit: '依据有变:处理数据源变化时改了数据目录',
} as Record<string, string>,
/** 认不出是哪一种依据变了(缺字段、以后加的种类):只说依据有变,不猜 */
basisGeneric: '答案的依据有变',
basisItems: (labels: string[]) => labels.join('、'),
newErrors: (count: number) => `按新目录检查新出现 ${n(count)} 条错误级问题`,
sub: '封存后追加,不在封存范围内',
},
}
/**
* 对账结果上的「说明有用 / 看不懂」在时间线上的说法(run/decode.ts 认 ledger.feedback,记在对账运行上,服务端
* app/ledger/feedback.py)。按钮在对账结果卡片上(问数据轮次、证据面板),这里只管时间线那一行。
*/
export const LEDGER_FEEDBACK_TEXT = {
verdict: { useful: '说明有用', unclear: '看不懂' } as Record<string, string>,
title: (verdict: string | undefined, by: string | null | undefined) =>
`${by ? `${by} ` : ''}反馈:${(verdict && LEDGER_FEEDBACK_TEXT.verdict[verdict]) || '对账说明'}`,
generic: '对账说明的反馈',
sub: '封存后追加,不在封存范围内',
}
+275
View File
@@ -0,0 +1,275 @@
/**
* recall 待办的卡身(待办箱、待办页里 TaskCard 的一段;docs/design/next-round/02-口径账本.md 做法 7)。
*
* 三种来由(payload.reason,服务端 ledger/recall.task_payload 给):
* - caliber:口径发布了新版本,同一份数据上新旧两版各算一次,这一组答案用到的数变了。列旧口径的值、新口径的值、两版之差
* (召回运行里召回卡上的三个指标)和每个答案当时的值,每个数都点得开出处(召回卡的指标链;正式运行的口径卡指标链;问数
* 回答的那条查询快照)。动作「按新版重新出具」(服务端走按口径出具同一套判法,能不能、为什么不能由它给)和「知道了」;
* - catalog:答案依据的数据目录里影响数字的项被驳回或改了,按新目录检查这次执行过的 SQL 出现了新的错误级问题;只有「知道了」;
* - structure:口径依据的表结构变了,试算没有算出来;只有「知道了」。
*
* 界面层不放逻辑:数变没变、值是不是遮罩、能不能重新出具都照服务端给的写。使用者看到的值服务端已经按遮罩换过(masked)。
*/
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Check, ExternalLink, RefreshCw } from 'lucide-react'
import { EvidenceDrawer, type EvidenceTarget } from '../apps/EvidenceDrawer'
import { Spinner } from '../components/ui'
import { useAtLeast } from '../store/auth'
import { formatDateTime } from '../lib/format'
import { RECALL_TEXT } from '../lib/termsRecall'
import type { Task } from '../typesApp'
import type { RecallAnswer, RecallCell, RecallOriginal, RecallTaskPayload } from '../typesRecall'
const T = RECALL_TEXT.task
function cellText(cell: { text: string | null; masked: boolean } | undefined): string {
if (!cell) return T.missing
if (cell.masked) return T.masked
return cell.text ?? T.missing
}
export function RecallTask({ task, canAct, busy, onRespond }: {
task: Task; canAct: boolean; busy: boolean; onRespond: (decision: 'reissue' | 'ack') => void
}) {
const payload = (task.payload ?? {}) as Partial<RecallTaskPayload>
const [evidence, setEvidence] = useState<EvidenceTarget | null>(null)
const reason = payload.reason ?? 'caliber'
const name = payload.workflow_name || ''
const actions = Array.isArray(payload.actions) ? payload.actions : []
const reissue = actions.find((a) => a.decision === 'reissue')
const ack = actions.find((a) => a.decision === 'ack')
const pending = task.status === 'pending'
const outcome = (task.outcome ?? payload.outcome ?? null) as Record<string, any> | null
return (
<div className="mb-2 space-y-2" data-task-recall={reason}>
{reason === 'caliber' && (
<>
<p className="text-2xs leading-relaxed text-dim">{T.intro(name, payload.from_version, payload.to_version)}</p>
{!!payload.binding?.length && (
<div className="flex flex-wrap items-center gap-1 text-2xs" data-recall-binding="">
<span className="text-faint">{T.binding}:</span>
{payload.binding.map((b) => (
<span key={b.field} className="chip mono" data-recall-binding-field={b.field}>
{b.field}={b.masked ? T.masked : String(b.value ?? '')}
</span>
))}
</div>
)}
<Metrics payload={payload} onOpen={setEvidence} />
<Answers payload={payload} onOpen={setEvidence} />
<p className="text-2xs leading-relaxed text-faint">{T.how}</p>
</>
)}
{reason === 'catalog' && <CatalogBasis payload={payload} />}
{reason === 'structure' && <StructureBasis payload={payload} name={name} />}
{!pending && outcome?.decision && (
<div className="flex flex-wrap items-center gap-x-2 text-2xs text-dim" data-recall-outcome={outcome.decision}>
<span>{T.resolved(outcome.decision)}</span>
{outcome.issued_run && <IssuedLink runId={String(outcome.issued_run)} />}
</div>
)}
{canAct && (
<div className="flex gap-2">
{reissue && (
<button type="button" className="btn btn-primary flex-1 justify-center" data-recall-reissue=""
disabled={busy || !reissue.available} title={reissue.reason ?? T.reissueHint}
onClick={() => onRespond('reissue')}>
{busy ? <Spinner size={12} /> : <RefreshCw size={12} aria-hidden />} {T.reissue}
</button>
)}
{ack && (
<button type="button" className="btn flex-1 justify-center" data-recall-ack="" disabled={busy || !ack.available}
onClick={() => onRespond('ack')}>
<Check size={12} aria-hidden /> {T.ack}
</button>
)}
</div>
)}
{canAct && reissue && !reissue.available && reissue.reason && (
<div className="text-2xs leading-relaxed" style={{ color: 'var(--st-waiting)' }} data-recall-reissue-reason="">
{reissue.reason}
</div>
)}
{canAct && reissue?.issued_run && <IssuedLink runId={reissue.issued_run} />}
<EvidenceDrawer target={evidence} onClose={() => setEvidence(null)} />
</div>
)
}
/** 三个数(旧口径、新口径、两版之差)一行一个指标,每个都点得开召回卡上那个指标的出处链 */
function Metrics({ payload, onOpen }: { payload: Partial<RecallTaskPayload>; onOpen: (t: EvidenceTarget) => void }) {
const metrics = Array.isArray(payload.metrics) ? payload.metrics : []
if (!metrics.length) return null
const run = payload.recall_run ?? ''
const open = (label: string, side: string, cell: RecallCell) => {
if (!run || !cell.metric) return
onOpen({ evidence: { run_id: run, kind: 'metric', node: 'recall', metric: cell.metric },
label: T.evidenceTitle(label, side), text: cellText(cell) })
}
const heads = [T.old(payload.from_version), T.new(payload.to_version), T.diff]
return (
<div className="overflow-x-auto rounded border bg-bg" data-recall-metrics={metrics.length}>
<table className="w-full text-2xs">
<thead>
<tr className="text-left text-faint">
<th className="px-2 py-1 font-normal">{T.metric}</th>
{heads.map((h) => <th key={h} className="px-2 py-1 text-right font-normal">{h}</th>)}
</tr>
</thead>
<tbody>
{metrics.map((m) => (
<tr key={m.k} className="border-t" data-recall-metric={m.metric} data-changed={m.changed ? 'true' : 'false'}>
<td className="px-2 py-1">
{m.name}
<span className="ml-1 text-faint" style={m.changed ? { color: 'var(--st-waiting)' } : undefined}>
{m.changed ? T.changed : T.same}
</span>
</td>
{(['old', 'new', 'diff'] as const).map((side, i) => (
<td key={side} className="px-2 py-1 text-right">
<button type="button" className="tnum underline-offset-2 hover:underline" data-recall-cell={side}
disabled={m[side].masked} onClick={() => open(m.name, heads[i], m[side])}>
{cellText(m[side])}
</button>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
/** 用到这份口径的答案:哪一种、什么时候、当时的值(点得开当时的出处)、去哪里看 */
function Answers({ payload, onOpen }: { payload: Partial<RecallTaskPayload>; onOpen: (t: EvidenceTarget) => void }) {
const answers = Array.isArray(payload.answers) ? payload.answers : []
const names = new Map((payload.metrics ?? []).map((m) => [m.k, m.name]))
if (!answers.length) return null
const open = (answer: RecallAnswer, value: RecallOriginal) => {
const label = T.evidenceTitle(names.get(value.k) ?? '', T.original)
if (value.node && value.metric) {
onOpen({ evidence: { run_id: answer.run_id, kind: 'metric', node: value.node, metric: value.metric }, label,
text: cellText(value) })
} else if (value.query) {
onOpen({ evidence: { run_id: answer.run_id, kind: 'cell', artifact: value.query }, label, text: cellText(value) })
}
}
return (
<div data-recall-answers={answers.length}>
<div className="mb-1 text-2xs font-medium text-dim">{T.answers}</div>
<ul className="space-y-1">
{answers.map((a) => (
<li key={`${a.run_id}-${a.kind}`} className="rounded border bg-bg px-2 py-1 text-2xs" data-recall-answer={a.run_id}
data-recall-answer-kind={a.kind}>
<div className="flex flex-wrap items-center gap-x-2">
<span className="text-dim">{T.answerOf(a.kind_text, a.at ? formatDateTime(a.at) : null)}</span>
<AnswerLink answer={a} />
</div>
<div className="mt-0.5 flex flex-wrap gap-x-3">
{a.original.map((v) => (
<span key={v.k}>
<span className="text-faint">{names.get(v.k) ?? ''} · {T.original}:</span>
{v.missing && !v.masked
? <span className="text-faint" title={v.missing}>{T.missing}</span>
: (
<button type="button" className="tnum underline-offset-2 hover:underline" data-recall-original={v.k}
disabled={v.masked || (!v.metric && !v.query)} onClick={() => open(a, v)}>
{cellText(v)}
</button>
)}
</span>
))}
</div>
</li>
))}
</ul>
</div>
)
}
/** 去看这个答案:问数回答回到那段对话;别的(正式运行)给搭建者去记录页 */
function AnswerLink({ answer }: { answer: RecallAnswer }) {
const builder = useAtLeast('builder')
if (answer.kind === 'answer' && answer.conversation_id) {
return (
<Link to={`/chat/${encodeURIComponent(answer.conversation_id)}`} data-recall-answer-link="chat"
className="inline-flex items-center gap-1 text-dim underline-offset-2 hover:text-fg hover:underline">
<ExternalLink size={10} aria-hidden /> {T.openTurn}
</Link>
)
}
if (!builder) return null
return (
<Link to={`/runs/${encodeURIComponent(answer.run_id)}`} data-recall-answer-link="run"
className="inline-flex items-center gap-1 text-dim underline-offset-2 hover:text-fg hover:underline">
<ExternalLink size={10} aria-hidden /> {T.openAnswer}
</Link>
)
}
/** 重新出具的那次正式运行:搭建者链到记录页;使用者进不了记录页(点了会被送回应用首页),只写已经出具 */
function IssuedLink({ runId }: { runId: string }) {
const builder = useAtLeast('builder')
if (!builder) {
return <span className="text-2xs text-dim" data-recall-issued-note="">{T.issuedUserNote}</span>
}
return (
<Link to={`/runs/${encodeURIComponent(runId)}`} data-recall-issued={runId}
className="inline-flex items-center gap-1 text-2xs text-dim underline-offset-2 hover:text-fg hover:underline">
<ExternalLink size={10} aria-hidden /> {T.issuedRun}
</Link>
)
}
function CatalogBasis({ payload }: { payload: Partial<RecallTaskPayload> }) {
const basis = payload.basis ?? {}
const items = Array.isArray(basis.items) ? basis.items : []
const checks = Array.isArray(basis.checks) ? basis.checks : []
return (
<div className="space-y-1.5 text-2xs" data-recall-basis="catalog">
<p className="leading-relaxed text-dim">{T.catalogIntro}</p>
{items.length > 0 && (
<div>
<span className="text-faint">{T.catalogItems}:</span>
{items.map((i, k) => (
<span key={`${i.path}-${k}`}>
{k > 0 && '、'}{i.label ?? i.path}{i.change ? `(${T.change[i.change] ?? i.change})` : ''}
</span>
))}
</div>
)}
{basis.new_errors != null && <div className="text-dim">{T.catalogChecks(basis.new_errors)}</div>}
{checks.length > 0 && (
<ul className="list-disc space-y-0.5 pl-4" data-recall-checks={checks.length}>
{checks.map((c, k) => <li key={`${c.code}-${k}`}>{c.message}</li>)}
</ul>
)}
</div>
)
}
function StructureBasis({ payload, name }: { payload: Partial<RecallTaskPayload>; name: string }) {
const basis = payload.basis ?? {}
const builder = useAtLeast('builder')
return (
<div className="space-y-1.5 text-2xs" data-recall-basis="structure">
<p className="leading-relaxed text-dim">{T.structureIntro(name, basis.version)}</p>
{basis.table && <div className="text-dim">{T.structureWhere(basis.table, basis.column)}</div>}
{basis.error && (
<div className="rounded border bg-bg p-1.5">
<span className="text-faint">{T.probeError}:</span>{basis.error}
</div>
)}
{builder && basis.probe_run && (
<Link to={`/runs/${encodeURIComponent(basis.probe_run)}`}
className="inline-flex items-center gap-1 text-dim underline-offset-2 hover:text-fg hover:underline">
<ExternalLink size={10} aria-hidden /> {T.openProbe}
</Link>
)}
</div>
)
}
+12 -3
View File
@@ -10,6 +10,8 @@
* - 等外部系统(signal):说明由入站钩子完成,不在这里处理;
* - 审核个人应用(review,docs/design/normal-user-apps.md N7):同意不在这里点,处理人取一份共享副本、在副本上试运行和
* 走发布单(发布单做完这条待办自动记为已发布);不发布就退回。
* - 口径召回(recall,docs/design/next-round/02-口径账本.md 做法 7):旧口径、新口径、两版之差和原答案当时的值都点得开,
* 「按新版重新出具」「知道了」(run/RecallTask.tsx)。
*
* 不碰画布的 store:恢复之后谁去重新接事件流、刷新哪张列表,由调用方在 onResolved 里决定。会签时一个人的回复记下了、
* 还没凑齐(resolved 为假),照样回调——调用方据此刷新卡片。
@@ -21,8 +23,8 @@ import { useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import {
Ban, Check, CheckCheck, Clock, Code, Copy, ExternalLink, Hand, Keyboard, PenLine, Send, SearchCheck, TriangleAlert,
UserCheck, Webhook, Wrench,
Ban, Check, CheckCheck, Clock, Code, Copy, ExternalLink, Hand, History, Keyboard, PenLine, Send, SearchCheck,
TriangleAlert, UserCheck, Webhook, Wrench,
} from 'lucide-react'
import clsx from 'clsx'
import { ApiError, api } from '../api/client'
@@ -38,6 +40,7 @@ import {
TASK_TEXT, TOOL_TRUST_TEXT, TRIAL_TEXT, runName,
} from '../lib/terms'
import { REVIEW_TASK_TEXT } from '../lib/termsApp'
import { RECALL_TEXT } from '../lib/termsRecall'
import { blueprintPath } from '../pages/blueprints/shared'
import type { Approval, ApprovalReport } from '../types'
import type {
@@ -48,6 +51,7 @@ import { ApprovalReports, ApprovalRunClass } from './ApprovalReport'
import { ChallengeSummary } from './ChallengeSummary'
import { DualCheckApproval } from './DualCheckApproval'
import { useInquiryBusy } from './Inquiry'
import { RecallTask } from './RecallTask'
export interface TaskCardProps {
task: Task
@@ -73,7 +77,7 @@ export interface TaskCardProps {
const KIND_ICON: Record<TaskKind, typeof Hand> = {
approve: Hand, input: Keyboard, edit: PenLine, tool: Wrench, code: Code, dual_check: Hand,
action_confirm: Send, reconcile: SearchCheck, signal: Webhook, review: UserCheck,
action_confirm: Send, reconcile: SearchCheck, signal: Webhook, review: UserCheck, recall: History,
}
/** 一天没人处理就用琥珀色写等了多久,同审批卡 */
@@ -216,6 +220,11 @@ export function TaskCard({ task, onResolved, compact = false, send, showOwner =
<Review task={task} canAct={canAct} busy={busy} note={note} setNote={setNote}
onReject={() => void respond({ decision: 'reject' }, REVIEW_TASK_TEXT.rejected)} />
)}
{kind === 'recall' && (
<RecallTask task={task} canAct={canAct} busy={busy}
onRespond={(decision) => void respond({ decision },
decision === 'reissue' ? RECALL_TEXT.task.doneReissue : RECALL_TEXT.task.doneAck)} />
)}
{showForm && (
<div className="mb-2 rounded border bg-bg p-2.5">
+56
View File
@@ -12,6 +12,7 @@ import {
evidenceTally, issuanceLabel, nodeTypeLabel,
} from '../lib/terms'
import { catalogPatchOf, patchBeforeText, patchValueText, patchWhere } from './catalogPatch'
import { LEDGER_FEEDBACK_TEXT, RECALL_TEXT } from '../lib/termsRecall'
import { claimCountsOf, isJudged, statsTally } from '../lib/evidence'
import { isSqlCheckCode, sortSqlChecks, sqlCheckOf, sqlChecksOf, sqlCheckWhere, sqlRuleLabel } from '../lib/sqlcheck'
@@ -2418,6 +2419,61 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt
break
}
case 'ledger.recalled': {
// 口径召回(next-round 02 做法 7):封存之后追加。原答案上的是「已按 vN 核对,不影响」;召回运行自己身上的是收尾的
// 结论(数变没变、开了几条待办);表结构变了之后的口径试算是 probe。事件里不带数
const R = RECALL_TEXT.timeline
const to = typeof d.to_version === 'number' ? d.to_version : null
const from = typeof d.from_version === 'number' ? d.from_version : null
const tasks = Array.isArray(d.tasks) ? d.tasks.length : 0
const failed = d.outcome === 'failed'
const title = d.role === 'answer' ? R.answer(to)
: d.probe ? (failed ? R.probeFailed : R.probeOk)
: failed ? R.recallFailed : d.changed === true ? R.recallChanged(from, to)
: d.changed === false ? R.recallSame(from, to) : R.generic
out.push({
id: `lr-${seq}`, seq, kind: 'note', status: 'done', code: 'ledger_recalled',
level: failed || (d.role !== 'answer' && d.changed) ? 'warn' : 'info',
title, sub: R.sub,
...(tasks ? { meta: R.tasks(tasks) } : {}),
...(failed && typeof d.error === 'string' && d.error ? { detail: d.error } : {}),
})
break
}
case 'ledger.feedback': {
// 对账结果上的「说明有用 / 看不懂」(next-round 02 做法 10,服务端 ledger/feedback):记在对账运行上、封存之后追加。
// 同一个人改主意会再记一条(统计只算最后一次),时间线照实一条一行
const F = LEDGER_FEEDBACK_TEXT
const verdict = typeof d.verdict === 'string' ? d.verdict : undefined
out.push({
id: `lf-${seq}`, seq, kind: 'note', status: 'done', code: 'ledger_feedback', level: 'info',
title: verdict && F.verdict[verdict] ? F.title(verdict, typeof d.by === 'string' ? d.by : null) : F.generic,
sub: F.sub,
})
break
}
case 'ledger.basis_changed': {
// 答案依据的东西变了(目录事实被驳回或修改、表结构变了、新码值):封存之后追加,标「依据有变」。按新目录多出错误级
// 问题的写几条(服务端已经开了待办)
const R = RECALL_TEXT.timeline
const items = Array.isArray(d.items) ? d.items : []
const labels = items.map((i: any) => (i && typeof i.label === 'string' ? i.label : '')).filter(Boolean)
const errors = typeof d.new_errors === 'number' ? d.new_errors : 0
const where = [d.source, d.table].filter((x) => typeof x === 'string' && x).join(' · ')
out.push({
id: `lb-${seq}`, seq, kind: 'note', status: 'done', code: 'ledger_basis_changed',
level: errors > 0 || d.kind === 'structure' ? 'warn' : 'info',
title: R.basis[String(d.kind)] ?? R.basisGeneric,
sub: R.sub,
...(where ? { meta: where } : {}),
...(labels.length || errors ? { detail: [labels.length ? R.basisItems(labels) : '', errors ? R.newErrors(errors) : '']
.filter(Boolean).join('\n') } : {}),
})
break
}
case 'approval.inquiry':
// 审批人在批准前就待审的报告提问(审批质询):运行停在审批上时服务端追加这一条,node_id 为空,运行本身没动、
// 报告也不改(相位照旧,见 nextPhase)。封存之后探索运行也能问,带 post_seal。一条事件就是一问一答,挂在顶层
+6 -2
View File
@@ -520,10 +520,13 @@ export interface InstanceRow {
/**
* 待办的种类。review 是个人应用提交审核(docs/design/normal-user-apps.md N7):不挂在运行的中断上,同意不在待办上点——
* 处理人取一份共享副本、走发布单(发布单做完这条自动记为已发布),不发布就退回(decision=reject);payload 见 ReviewTaskPayload
* 处理人取一份共享副本、走发布单(发布单做完这条自动记为已发布),不发布就退回(decision=reject);payload 见 ReviewTaskPayload。
* recall 是口径召回(docs/design/next-round/02-口径账本.md 做法 7):口径升版后答案的数变了(「按新版重新出具」decision=reissue、
* 「知道了」decision=ack),或者答案依据的目录、表结构变了(只有「知道了」);payload 见 typesRecall.ts 的 RecallTaskPayload
*/
export type TaskKind =
| 'approve' | 'input' | 'edit' | 'tool' | 'code' | 'dual_check' | 'action_confirm' | 'reconcile' | 'signal' | 'review'
| 'recall'
export type TaskStatus = 'pending' | 'resolving' | 'resolved' | 'cancelled' | 'expired'
export type TaskPolicy = { kind: 'any' } | { kind: 'all' } | { kind: 'quorum'; m: number }
@@ -627,7 +630,8 @@ export interface ReviewTaskPayload {
}
export interface TaskRespond {
decision: 'approve' | 'reject' | 'submit' | 'confirmed' | 'not_applied_resend' | 'not_applied_abandon'
/** reissue / ack 只给口径召回(recall):按新版重新出具、知道了 */
decision: 'approve' | 'reject' | 'submit' | 'confirmed' | 'not_applied_resend' | 'not_applied_abandon' | 'reissue' | 'ack'
values?: Record<string, any>
note?: string
/** 动作确认:确认卡上显示的哈希,原样带回 */
+178
View File
@@ -0,0 +1,178 @@
/**
* 口径召回的接口形状(服务端 app/ledger/recall.py、uses.py;docs/design/next-round/02-口径账本.md 做法 7、8)。
*
* 界面层不放逻辑:几组、能不能重算、为什么不能、数变没变、能不能重新出具,都由服务端给,界面只照着画。
*/
/** 一种用法的计数:问数回答几次、导出几次、定时任务几个、应用几个、正式运行几次、固定了它的工作流发起的正式运行几次 */
export interface RecallUseCounts {
answers: number
exports: number
schedules: number
apps: number
formal: number
pinned: number
/** 反查有上限(按新的在前),少看了的为真 */
truncated: boolean
}
/** 发布弹窗的召回预告(GET /api/workflows/{id}/recall-preview?version=) */
export interface RecallPreview {
workflow_id: string
version: number
/** 每次发布最多自动重算几组(设置 ledger.recall_limit;0 表示不自动重算) */
limit: number
/** 旧版本里有没有能复用的口径卡(没有就不是口径,发布后不召回) */
caliber: boolean
problem: string | null
/** 每个旧版本被谁用过;problem 是这一版整体不能重算的原因 */
versions: (RecallUseCounts & { version: number; problem?: string })[]
/** 一共几组(按旧版本、参数去重) */
groups: number
recomputable: number
not_recomputable: number
/** 已经召回过的组(重新发布同一版时) */
already: number
/** 发布之后预计自动重算几组 */
will_recompute: number
/** 超出上限、要手动接着算的几组 */
over_limit: number
truncated: boolean
/** 不能重算的原因各几组 */
reasons: { reason: string; groups: number }[]
}
export type RecallGroupState = 'changed' | 'unchanged' | 'failed' | 'running' | 'pending' | 'not_recomputable'
/** 召回批次里的一组 */
export interface RecallGroup {
id: string
from_version: number
to_version: number
binding: { field: string; value: unknown }[]
uses: { kind: string; kind_text: string; count: number }[]
latest: string | null
state: RecallGroupState
/** 不能重算的原因、重算失败的原因、发起被拒的原因 */
reason?: string
run_id?: string
batch?: 'auto' | 'manual'
/** 开了几条待办(数变了的) */
tasks?: number
/** 数变了的指标名 */
changed_metrics?: string[]
}
/** 版本历史里「这一版的召回」(GET /api/ledger/recall/{wf}/{v};POST …/continue 交回同样的形状加 launched) */
export interface RecallBatches {
workflow_id: string
workflow_name: string
version: number
limit: number
/** 手动接着算一次发起几组 */
batch_size: number
problem: string | null
caliber: boolean
counts: Record<RecallGroupState, number>
groups: RecallGroup[]
/** 还没重算的几组(「另有 M 组未重算」) */
remaining: number
can_continue: boolean
/** 还有没算的组却不能接着算时为什么(之后又发布了更新的版本……);能接着算、都算过了为 null */
continue_reason?: string | null
truncated: boolean
launched?: string[]
}
/** 版本用量里的召回:以这一版为新版本召回过几组、其中数变了几组;作为旧版本被召回过几组 */
export interface VersionRecallCounts {
groups: number
changed: number
recalled_from: number
}
/** recall 待办里的一个数:值(遮罩了的为 null)、显示的字、出处(召回卡上哪个指标) */
export interface RecallCell {
value: unknown
text: string | null
masked: boolean
metric?: string
}
/** 原答案当时的值:正式运行给口径卡节点和指标,问数回答给那条查询快照和列;没有单独这个数的写 missing */
export interface RecallOriginal {
k: number
value: unknown
text: string | null
masked: boolean
node?: string
metric?: string
query?: string
column?: string
missing?: string
}
export interface RecallAnswer {
kind: string
kind_text: string
run_id: string
version?: number
conversation_id?: string
turn_id?: string
schedule_id?: string
app_id?: string
at?: string
via_workflow?: string
via_version?: number
original: RecallOriginal[]
}
export interface RecallAction {
decision: 'reissue' | 'ack'
available: boolean
reason?: string
issued_run?: string
}
/**
* recall 待办的 payload(服务端 ledger/recall.task_payload)。reason:caliber 口径升版数变了(旧值、新值、差值、原答案当时
* 的值);catalog 答案依据的数据目录有变、按新目录检查出了新的问题;structure 口径依据的表结构变了、试算没有算出来
*/
export interface RecallTaskPayload {
kind: 'recall'
reason: 'caliber' | 'catalog' | 'structure'
recall_run: string
workflow_id?: string | null
workflow_name?: string
from_version?: number | null
to_version?: number | null
binding?: { field: string; value: unknown; masked: boolean }[]
metrics?: {
k: number
name: string
unit: string
changed: boolean
card: string
metric: string
old: RecallCell
new: RecallCell
diff: RecallCell
}[]
answers?: RecallAnswer[]
basis?: {
kind?: string
source?: string
table?: string
column?: string
items?: { path?: string; label?: string; change?: string }[]
new_errors?: number
checks?: { code: string; message: string }[]
workflow_id?: string
version?: number
change_kind?: string
error?: string | null
probe_run?: string
}
actions: RecallAction[]
outcome?: Record<string, any> | null
}
+8 -6
View File
@@ -332,7 +332,7 @@ await section('设置:身份、通知、审计', async () => {
await section('设置 → 通知:可订阅的事件照服务端登记列(总方案 X6)', async () => {
// 列表从真后端的 GET /api/notify/events 来(GET 放行):这种范围的渠道收得到的才列;从来推不到的(以前列着、一勾就 422
// 的「待办升级」「动作已生效」……)和登记了还没推送的(召回、待办汇总……)都不列
// 的「待办升级」「动作已生效」……)和登记了还没推送的(数据源变化……)都不列
const { page, errors } = await open('/settings/notify')
await page.getByRole('button', { name: '添加渠道' }).first().click()
const dlg = page.locator('[role="dialog"]')
@@ -343,21 +343,23 @@ await section('设置 → 通知:可订阅的事件照服务端登记列(总
}
const every = ['task.opened', 'run.finished', 'run.failed', 'action.unknown']
// 第 3 波起:定时评测的两个事件只推全局渠道(评测是搭建者们共用的一把尺,不归哪个人;服务端 notify/service.notify_eval);
// 访问密钥快到期推给全局渠道(服务账号的密钥)和个人渠道(个人密钥),每天的待办汇总只推个人渠道
// 访问密钥快到期推给全局渠道(服务账号的密钥)和个人渠道(个人密钥),每天的待办汇总只推个人渠道。第 4 波:口径召回
// 数变了的推全局渠道和答案主人的个人渠道(notify_recall),不归哪个应用
check('全局渠道:推给全局渠道的几项', await waitFor(page, async () => await dlg.locator('[data-notify-events="global"]').count() === 1)
&& JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done', 'eval.regressed', 'eval.failed', 'key.expiring']),
&& JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done', 'ledger.recall', 'eval.regressed', 'eval.failed',
'key.expiring']),
JSON.stringify(await listed()))
const text = await dlg.innerText()
// 只推定时发起的评测:叫法照实带「定时」,勾上的人不会以为手动发起的那次也推
check('评测的两个事件照实说是定时的', ['定时评测有退步', '定时评测没有评完'].every((t) => text.includes(t)), text)
check('从来推不到的不列', !['待办升级', '待办超时', '待办处理完', '动作失败', '动作已生效'].some((t) => text.includes(t)), text)
check('登记了还没推送的不列', !['数据源有变化要处理', '口径升版,答案的数变了']
check('登记了还没推送的不列', !['数据源有变化要处理']
.some((t) => text.includes(t)), text)
check('只推个人渠道的「每天的待办汇总」全局渠道不列', !text.includes('每天的待办汇总'), text)
check('应用的渠道:不列只发给提交人的「我提交审核的应用有结果」', await scope('app')
check('应用的渠道:不列只发给提交人的「我提交审核的应用有结果」,也不列口径召回', await scope('app')
&& JSON.stringify(await listed()) === JSON.stringify(every), JSON.stringify(await listed()))
check('个人渠道:连同待办汇总、密钥快到期都列', await scope('user')
&& JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done', 'task.digest', 'key.expiring']),
&& JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done', 'ledger.recall', 'task.digest', 'key.expiring']),
JSON.stringify(await listed()))
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
})
+68
View File
@@ -1888,6 +1888,74 @@ await section('口径账本:封存后比对已发布口径(ledger.matched)
check('不改相位:运行照旧是结束了', phase.phase === 'succeeded', phase.phase)
})
await section('口径召回:封存后的核对结论与依据有变(ledger.recalled、ledger.basis_changed)', async () => {
// next-round 02 做法 7:口径发布新版本后召回运行在同一份数据上重算两版,数没变的在原答案上记「已按 vN 核对,不影响」,
// 召回运行自己记结论;目录事实被驳回或修改、表结构变了的在答案上记「依据有变」。都在封存之后,事件里不带数
const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data })
const base = [E(1, 'run.started', null, {}), E(2, 'node.started', 'q', { node_type: 'tool' }), E(3, 'node.finished', 'q', {}),
E(4, 'run.finished', null, { status: 'succeeded' })]
const run = (...more) => flatten(mod.decodeRun([...base, ...more]))
const answer = run(E(5, 'ledger.recalled', null, { role: 'answer', changed: false, recall_run: 'rc-1', workflow_id: 'wf-week',
from_version: 1, to_version: 2, post_seal: true }))
check('认得它们:不是「未识别的事件」', !answer.some((s) => s.title === '未识别的事件'))
const ok = answer.find((s) => s.code === 'ledger_recalled')
check('原答案上:已按 v2 核对,不影响;写明封存后追加', ok?.title === '已按 v2 核对,不影响'
&& ok?.sub === '封存后追加,不在封存范围内' && ok?.level === 'info' && ok?.kind === 'note', `${ok?.title} / ${ok?.sub}`)
const changed = run(E(5, 'ledger.recalled', null, { role: 'recall', outcome: 'changed', changed: true, from_version: 1,
to_version: 2, answers: 2, tasks: ['t1', 't2'], metrics: [{ k: 0, name: '销售额', changed: true }] }))
.find((s) => s.code === 'ledger_recalled')
check('召回运行上:数变了是提醒,写开了几条待办,不带数', changed?.title === '召回核对(v1 → v2):数变了'
&& changed?.level === 'warn' && changed?.meta === '开了 2 条待办' && !/\d+\.\d/.test(`${changed?.title}${changed?.detail ?? ''}`),
`${changed?.title} / ${changed?.meta}`)
const same = run(E(5, 'ledger.recalled', null, { role: 'recall', outcome: 'unchanged', changed: false, from_version: 1, to_version: 2 }))
.find((s) => s.code === 'ledger_recalled')
check('……数没变的是一般说明', same?.title === '召回核对(v1 → v2):数没变' && same?.level === 'info', same?.title)
const failedRun = run(E(5, 'ledger.recalled', null, { role: 'recall', outcome: 'failed', changed: null, error: '数据源已停用' }))
.find((s) => s.code === 'ledger_recalled')
check('……重算失败照实写原因', failedRun?.title === '召回重算失败' && failedRun?.detail === '数据源已停用' && failedRun?.level === 'warn',
`${failedRun?.title} / ${failedRun?.detail}`)
const probe = run(E(5, 'ledger.recalled', null, { role: 'recall', probe: true, outcome: 'failed', error: 'no such column' }))
.find((s) => s.code === 'ledger_recalled')
check('表结构变了之后的口径试算:没有算出来', probe?.title === '表结构变了之后试算口径:没有算出来', probe?.title)
const basis = run(E(5, 'ledger.basis_changed', null, { kind: 'catalog', source: 'shop', table: 'orders',
items: [{ path: 'columns.amount.measure', label: '列 amount 的度量类型', change: 'changed' }], new_errors: 1 }))
.find((s) => s.code === 'ledger_basis_changed')
check('目录事实被改:依据有变,列出哪几项、新出现几条错误级问题', basis?.title === '依据有变:数据目录里影响数字的项被驳回或修改'
&& basis?.meta === 'shop · orders' && basis?.detail === '列 amount 的度量类型\n按新目录检查新出现 1 条错误级问题'
&& basis?.level === 'warn', `${basis?.title} / ${basis?.detail}`)
const structure = run(E(5, 'ledger.basis_changed', null, { kind: 'structure', table: 'orders', column: 'amount', items: [] }))
.find((s) => s.code === 'ledger_basis_changed')
check('表结构变了:依据的表结构已变', structure?.title === '依据的表结构已变' && structure?.level === 'warn', structure?.title)
const odd = run(E(5, 'ledger.recalled', null, {}), E(6, 'ledger.basis_changed', null, { kind: 'x', items: 'y' }))
check('缺字段:不出 undefined、不瞎填数,认不出的结论和种类不猜', !odd.some((s) => /undefined|NaN/.test(`${s.title}${s.sub ?? ''}${s.detail ?? ''}${s.meta ?? ''}`))
&& odd.some((s) => s.title === '口径召回核对') && odd.some((s) => s.title === '答案的依据有变'),
odd.map((s) => s.title).join(' | '))
const phase = [E(4, 'run.finished', null, {}), E(5, 'ledger.recalled', null, {}), E(6, 'ledger.basis_changed', null, {})]
.reduce((s, ev) => mod.nextPhase(s, ev), { phase: 'running', awaiting: false })
check('不改相位:运行照旧是结束了', phase.phase === 'succeeded', phase.phase)
})
await section('对账结果的反馈:说明有用 / 看不懂(ledger.feedback)', async () => {
// next-round 02 做法 10 的 E2:对账结果卡片上的反馈记成对账运行上的封存后事件(服务端 ledger/feedback),时间线一条一行
const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data })
const base = [E(1, 'run.started', null, {}), E(2, 'node.started', 'reconcile', { node_type: 'metrics' }),
E(3, 'node.finished', 'reconcile', {}), E(4, 'run.finished', null, { status: 'succeeded' })]
const steps = flatten(mod.decodeRun([...base,
E(5, 'ledger.feedback', null, { verdict: 'useful', by: '分析员甲', verified: true, source_run: 'run-a', post_seal: true }),
E(6, 'ledger.feedback', null, { verdict: 'unclear', by: null, post_seal: true }),
E(7, 'ledger.feedback', null, { verdict: 'great' })]))
const lines = steps.filter((s) => s.code === 'ledger_feedback')
check('认得它:不是「未识别的事件」,一条一行', !steps.some((s) => s.title === '未识别的事件') && lines.length === 3,
steps.map((s) => s.title).join(' | '))
check('写谁给了哪一种反馈、封存后追加', lines[0]?.title === '分析员甲 反馈:说明有用' && lines[0]?.sub === '封存后追加,不在封存范围内'
&& lines[0]?.kind === 'note' && lines[0]?.level === 'info', `${lines[0]?.title} / ${lines[0]?.sub}`)
check('没署名的不写名字', lines[1]?.title === '反馈:看不懂', lines[1]?.title)
check('认不出的反馈不猜', lines[2]?.title === '对账说明的反馈', lines[2]?.title)
const phase = [E(4, 'run.finished', null, {}), E(5, 'ledger.feedback', null, { verdict: 'useful' })]
.reduce((s, ev) => mod.nextPhase(s, ev), { phase: 'running', awaiting: false })
check('不改相位:运行照旧是结束了', phase.phase === 'succeeded', phase.phase)
})
await section('口径账本:问数据的助手选用已发布口径(use_caliber 操作)', async () => {
// next-round 02 做法 4:取数节点和口径卡由服务端按那一版生成(ledger/compose.py),不另转 add_node。过程里一行说清选用了
// 哪一版、生成了几个节点;拒收的照实写原因(同样交回了模型去改)
+211
View File
@@ -17,6 +17,10 @@
// - 审核个人应用(kind=review,payload 同服务端 blueprints/personal.review_payload):写提交人(显示名)、目的、说明;没有
// 「同意」——「取副本,去发布」调 POST /api/blueprints/{id}/copy 后进副本蓝图;取过的给「打开我的副本」(只认自己取的那
// 一份,别人取的不算);「退回」可以写原因,走待办接口 decision=reject。
// - 口径召回(kind=recall,payload 同服务端 ledger/recall.task_payload,next-round 02 做法 7):旧口径、新口径、两版之差
// 一行一个指标,每个数点得开出处(召回卡的指标链);每个答案当时的值也点得开(正式运行的口径卡、问数回答的查询快照);
// 问数回答链回那段对话;「按新版重新出具」decision=reissue、「知道了」decision=ack;服务端说不能重新出具时按钮停用、
// 写原因;使用者看到的遮罩值写「已遮罩」、点不开;目录有变那一种只有「知道了」,列出变了的目录项和新出现的问题。
//
// 接口全部在浏览器层答掉(/api/tasks/**),数据是这里虚构的;写请求记在探针里。
//
@@ -134,6 +138,65 @@ const TASKS = {
{ id: 'bp_daily_mine', owner: '审批人甲', status: 'trialed', app_id: null }] },
people: { zhangsan: '张三' },
},
// 口径召回:口径升到 v2,这一组答案的数变了(payload 同服务端 ledger/recall.task_payload)
recall: {
...base, id: 'recall', kind: 'recall', title: '口径「周销售」升到 v2,答案的数变了', node_id: null, app_id: null,
app_name: null, app_version: null, workflow_name: '口径召回:周销售 v1 → v2', run_id: 'rc_1',
assignments: [assignment('rc1', '审批人甲', 'pending', null, { source: 'recall' })],
payload: { kind: 'recall', reason: 'caliber', recall_run: 'rc_1', workflow_id: 'wf_week', workflow_name: '周销售',
from_version: 1, to_version: 2,
binding: [{ field: 'end', value: '2026-09-08', masked: false }, { field: 'start', value: '2026-09-01', masked: false }],
metrics: [{ k: 0, name: '销售额', unit: '元', changed: true, card: 'card', metric: 'gmv',
old: { value: 170.5, text: '170.50元', masked: false, metric: 'old_0' },
new: { value: 208.5, text: '208.50元', masked: false, metric: 'new_0' },
diff: { value: 38, text: '38.00元', masked: false, metric: 'diff_0' } }],
answers: [
{ kind: 'answer', kind_text: '问数回答', run_id: 'run_ans_1', version: 1, conversation_id: 'conv_1', turn_id: 'turn_1',
at: ago(86400_000), original: [{ k: 0, value: 170.5, text: '170.50元', masked: false, query: 'snap_1', column: '销售额' }] },
{ kind: 'schedule', kind_text: '定时任务', run_id: 'run_fm_1', version: 1, schedule_id: 'sch_1', at: ago(3 * 86400_000),
original: [{ k: 0, value: 165, text: '165.00元', masked: false, node: 'card', metric: 'gmv' }] },
],
actions: [{ decision: 'reissue', available: true }, { decision: 'ack', available: true }] },
},
// 使用者看的:遮罩值写「已遮罩」、点不开;没有被他能用的应用固定,服务端说不能重新出具
recallUser: {
...base, id: 'recallUser', kind: 'recall', title: '口径「客户分层」升到 v3,答案的数变了', node_id: null, app_id: null,
app_name: null, app_version: null, workflow_name: '口径召回:客户分层 v2 → v3', run_id: 'rc_2',
assignments: [assignment('rc2', 'zhangsan', 'pending', null, { source: 'recall' })],
payload: { kind: 'recall', reason: 'caliber', recall_run: 'rc_2', workflow_id: 'wf_tier', workflow_name: '客户分层',
from_version: 2, to_version: 3, binding: [{ field: 'phone', value: null, masked: true }],
metrics: [{ k: 0, name: '消费额', unit: '元', changed: true, card: 'card', metric: 'spend',
old: { value: null, text: '已遮罩', masked: true, metric: 'old_0' },
new: { value: 90, text: '90元', masked: false, metric: 'new_0' },
diff: { value: null, text: '已遮罩', masked: true, metric: 'diff_0' } }],
answers: [{ kind: 'answer', kind_text: '问数回答', run_id: 'run_ans_2', version: 2, conversation_id: 'conv_2',
original: [{ k: 0, value: null, text: null, masked: false, missing: '原答案里没有单独的这个数' }] }],
actions: [{ decision: 'reissue', available: false,
reason: '这份口径没有被你能使用的应用固定,不能由你按口径出具,请搭建者出具' }, { decision: 'ack', available: true }] },
},
// 已经按新版重新出具过的(处理完的):出具的那次正式运行搭建者点得开,使用者只写一句(进不了记录页)
recallIssued: {
...base, id: 'recallIssued', kind: 'recall', title: '口径「客户分层」升到 v3,答案的数变了', node_id: null, app_id: null,
app_name: null, app_version: null, workflow_name: '口径召回:客户分层 v2 → v3', run_id: 'rc_3', status: 'resolved',
resolved_by: 'zhangsan', resolved_at: ago(60_000), can_respond: false,
outcome: { decision: 'reissue', note: '', issued_run: 'run_issued_9' },
assignments: [assignment('rc4', 'zhangsan', 'responded', 'reissue', { source: 'recall' })],
payload: { kind: 'recall', reason: 'caliber', recall_run: 'rc_3', workflow_id: 'wf_tier', workflow_name: '客户分层',
from_version: 2, to_version: 3, binding: [], metrics: [], answers: [],
outcome: { decision: 'reissue', note: '', issued_run: 'run_issued_9' },
actions: [{ decision: 'reissue', available: false, issued_run: 'run_issued_9' }, { decision: 'ack', available: false }] },
},
// 答案依据的目录有变、按新目录检查出了新的错误级问题:只有「知道了」
recallCatalog: {
...base, id: 'recallCatalog', kind: 'recall', title: '答案依据的数据目录有变:按新目录检查出了新的问题', node_id: null,
app_id: null, app_name: null, app_version: null, workflow_name: '', run_id: 'run_ans_3',
assignments: [assignment('rc3', '审批人甲', 'pending', null, { source: 'recall' })],
payload: { kind: 'recall', reason: 'catalog', recall_run: 'run_ans_3',
basis: { kind: 'catalog', source: 'shop', table: 'orders', new_errors: 1,
items: [{ path: 'columns.amount.measure', label: '列 amount 的度量类型', change: 'changed' }],
checks: [{ code: 'stock_summed', message: '对存量列 amount 求和,分组里没有业务日期' }] },
actions: [{ decision: 'ack', available: true }] },
},
// 补充输入里选复核人(选人控件):使用者按这条待办的候选池选
pick: {
...base, id: 'pick', approval_id: 'ap8', kind: 'input', title: '指定复核人',
@@ -158,6 +221,21 @@ async function open(state) {
page.errors = []
page.on('pageerror', (e) => page.errors.push(e.message))
await page.addInitScript(() => localStorage.setItem('agentlab_actor', '审批人甲'))
if (state.notify) {
// 系统通知换成记录器:开了后台提醒、已授权;记下每一条通知(点它就是调它的 onclick),页面一直在后台
await page.addInitScript(() => {
localStorage.setItem('agentlab.notify', '1')
window.__notes = []
class FakeNotification {
static permission = 'granted'
static async requestPermission() { return 'granted' }
constructor(title, opts) { this.title = title; this.tag = opts?.tag; window.__notes.push(this) }
close() {}
}
window.Notification = FakeNotification
Object.defineProperty(document, 'hidden', { configurable: true, get: () => true })
})
}
await page.route((url) => url.pathname.startsWith('/api/'), (route) => {
const r = route.request()
const p = new URL(r.url()).pathname
@@ -196,6 +274,13 @@ async function open(state) {
}
// 进了副本蓝图之后的页面不在这里查:蓝图一律答「不存在」,不打到真实服务端
if (p.startsWith('/api/blueprints/')) return route.fulfill({ status: 404, json: { detail: '蓝图不存在或已被删除' } })
// state.live:派给我的待处理待办由这一段自己换(外壳的待办通知看数和最新一条的时间变没变)
if (p === '/api/tasks/count' && state.live) {
return route.fulfill({ json: { mine: state.live.length, apps: 0, other: state.live.length,
latest: state.live.map((t) => t.created_at).sort().at(-1) ?? null } })
}
if (p === '/api/tasks' && state.live) return route.fulfill({ json: { items: state.live } })
if (p === '/api/approvals' && state.live) return route.fulfill({ json: [] })
if (p === '/api/tasks/count') return route.fulfill({ json: { mine: Object.keys(TASKS).length, apps: Object.keys(TASKS).length, other: 0 } })
if (p === '/api/tasks') return route.fulfill({ json: { items: Object.values(TASKS) } })
const m = p.match(/^\/api\/tasks\/([^/]+)$/)
@@ -403,6 +488,130 @@ await section('审核个人应用:取副本去发布、打开我的副本、
check('退回不取副本', !s3.writes.some((w) => w.path.endsWith('/copy')))
})
await section('口径召回:三个值和差值、原答案当时的值、两个动作', async () => {
const state = { writes: [] }
const page = await open(state)
const asked = []
page.on('request', (r) => { if (r.url().includes('/api/')) asked.push(new URL(r.url()).pathname) })
await page.goto(`${WEB}/apps/inbox/recall`)
const box = page.locator('[data-task-recall="caliber"]')
await box.waitFor({ timeout: 15000 })
check('写明口径从 v1 升到 v2、同一份数据上两版各算一次', (await box.innerText()).includes('口径「周销售」从 v1 升到了 v2')
&& (await box.innerText()).includes('同一份数据'), (await box.innerText()).slice(0, 80))
check('这一组的参数', (await box.locator('[data-recall-binding-field="start"]').innerText()) === 'start=2026-09-01')
const row = box.locator('[data-recall-metric="gmv"]')
check('旧口径、新口径、两版之差一行,标「变了」', await row.getAttribute('data-changed') === 'true'
&& (await row.locator('[data-recall-cell="old"]').innerText()) === '170.50元'
&& (await row.locator('[data-recall-cell="new"]').innerText()) === '208.50元'
&& (await row.locator('[data-recall-cell="diff"]').innerText()) === '38.00元', (await row.innerText()).replace(/\s+/g, ' '))
const head = await box.locator('[data-recall-metrics] thead').innerText()
check('表头写旧口径 v1、新口径 v2、两版之差', head.includes('旧口径 v1') && head.includes('新口径 v2') && head.includes('两版之差'), head)
check('两个答案各一行,写原答案当时的值', await box.locator('[data-recall-answer]').count() === 2
&& (await box.locator('[data-recall-answer="run_ans_1"] [data-recall-original="0"]').innerText()) === '170.50元'
&& (await box.locator('[data-recall-answer="run_fm_1"] [data-recall-original="0"]').innerText()) === '165.00元')
check('问数回答链回那段对话', (await box.locator('[data-recall-answer="run_ans_1"] [data-recall-answer-link="chat"]')
.getAttribute('href')) === '/chat/conv_1')
check('正式运行(搭建者)链到记录页', (await box.locator('[data-recall-answer="run_fm_1"] [data-recall-answer-link="run"]')
.getAttribute('href')) === '/runs/run_fm_1')
const wait = (part) => page.waitForRequest((r) => r.url().includes(part), { timeout: 5000 }).then(() => true, () => false)
let hit = wait('/api/runs/rc_1/evidence/metrics/recall/diff_0')
await row.locator('[data-recall-cell="diff"]').click()
check('点差值:取召回卡上这个指标的出处链', await hit, asked.slice(-3).join(' , '))
await page.keyboard.press('Escape')
hit = wait('/api/runs/run_fm_1/evidence/metrics/card/gmv')
await box.locator('[data-recall-answer="run_fm_1"] [data-recall-original="0"]').click()
check('点定时任务那次当时的值:取那次正式运行口径卡的指标链', await hit, asked.slice(-3).join(' , '))
await page.keyboard.press('Escape')
hit = wait('snap_1')
await box.locator('[data-recall-answer="run_ans_1"] [data-recall-original="0"]').click()
check('点问数回答当时的值:打开那条查询快照', await hit, asked.slice(-3).join(' , '))
await page.keyboard.press('Escape')
await page.locator('[data-recall-reissue]').click()
await page.waitForTimeout(300)
const sent = state.writes.find((w) => w.path === '/api/tasks/recall/respond')
check('「按新版重新出具」走待办接口,decision 是 reissue', sent?.body?.decision === 'reissue', JSON.stringify(sent?.body))
check('……提示已按新版发起正式出具', await page.getByText('已按新版发起正式出具').first().waitFor({ timeout: 3000 }).then(() => true, () => false))
check('不给审批那一套按钮', await page.locator('[data-task-approve], [data-task-reject]').count() === 0)
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
const s2 = { writes: [] }
const p2 = await open(s2)
await p2.goto(`${WEB}/apps/inbox/recall`)
await p2.locator('[data-recall-ack]').waitFor({ timeout: 15000 })
await p2.locator('[data-recall-ack]').click()
await p2.waitForTimeout(300)
check('「知道了」decision 是 ack', s2.writes.find((w) => w.path === '/api/tasks/recall/respond')?.body?.decision === 'ack',
JSON.stringify(s2.writes))
})
await section('口径召回:使用者看到的遮罩值、不能重新出具的原因;目录有变那一种', async () => {
const state = { writes: [], me: { mode: 'local', username: 'zhangsan', display_name: '张三', role: 'user', groups: [], verified: true } }
const page = await open(state)
await page.goto(`${WEB}/apps/inbox/recallUser`)
const box = page.locator('[data-task-recall="caliber"]')
await box.waitFor({ timeout: 15000 })
const row = box.locator('[data-recall-metric="spend"]')
check('遮罩值写「已遮罩」、点不开', (await row.locator('[data-recall-cell="old"]').innerText()) === '已遮罩'
&& await row.locator('[data-recall-cell="old"]').isDisabled() && (await row.locator('[data-recall-cell="new"]').innerText()) === '90元')
check('参数里的遮罩值同样遮住', (await box.locator('[data-recall-binding-field="phone"]').innerText()) === 'phone=已遮罩')
check('原答案里没有单独这个数:写「—」不给链接', (await box.locator('[data-recall-answer="run_ans_2"]').innerText()).includes('—')
&& await box.locator('[data-recall-answer="run_ans_2"] [data-recall-original]').count() === 0)
check('不能重新出具:按钮停用,写服务端给的原因', await page.locator('[data-recall-reissue]').isDisabled()
&& (await page.locator('[data-recall-reissue-reason]').innerText()).includes('请搭建者出具'))
check('使用者不给记录页的链接', await box.locator('[data-recall-answer-link="run"]').count() === 0)
const p3 = await open({ writes: [], me: state.me })
await p3.goto(`${WEB}/apps/inbox/recallIssued`)
const done = p3.locator('[data-recall-outcome="reissue"]')
await done.waitFor({ timeout: 15000 })
check('使用者重新出具过:只写已出具、正式运行由搭建者在记录页查看,不给点了回不来的记录页链接',
(await done.innerText()).includes('正式运行的记录由搭建者在记录页查看')
&& await p3.locator('[data-recall-issued]').count() === 0, (await done.innerText()).replace(/\s+/g, ' '))
const p4 = await open({ writes: [] })
await p4.goto(`${WEB}/apps/inbox/recallIssued`)
await p4.locator('[data-recall-outcome="reissue"]').waitFor({ timeout: 15000 })
check('……搭建者照样链到那次正式运行', (await p4.locator('[data-recall-outcome] [data-recall-issued]').getAttribute('href'))
=== '/runs/run_issued_9' && await p4.locator('[data-recall-issued-note]').count() === 0)
const p2 = await open({ writes: [] })
await p2.goto(`${WEB}/apps/inbox/recallCatalog`)
const basis = p2.locator('[data-recall-basis="catalog"]')
await basis.waitFor({ timeout: 15000 })
const text = await basis.innerText()
check('目录有变:写变了的目录项和新出现的问题', text.includes('列 amount 的度量类型(改了)') && text.includes('新出现的错误级问题 1 条')
&& text.includes('分组里没有业务日期'), text.replace(/\s+/g, ' '))
check('……只有「知道了」', await p2.locator('[data-recall-ack]').count() === 1 && await p2.locator('[data-recall-reissue]').count() === 0)
check('没有未捕获的运行时错误', [page, p2, p3, p4].every((x) => x.errors.length === 0),
[page, p2, p3, p4].flatMap((x) => x.errors)[0] ?? '')
})
await section('口径召回:后台的系统通知点开去待办页(不去记录页)', async () => {
// recall 待办不属于应用(app_id 为空),挂在召回运行上:记录页里没有这张卡,使用者也进不了记录页。点通知一律去待办页
const users = {
builder: null,
user: { mode: 'local', username: 'zhangsan', display_name: '张三', role: 'user', groups: [], verified: true },
}
for (const [who, me] of Object.entries(users)) {
const state = { writes: [], notify: true, live: [], me: me ?? undefined }
const page = await open(state)
await page.goto(`${WEB}/apps`)
await page.locator('nav[aria-label="主导航"]').waitFor({ timeout: 15000 })
await page.waitForTimeout(1200)
state.live = [{ ...TASKS.recall, created_at: new Date().toISOString() }]
const noted = await page.waitForFunction(() => window.__notes.some((n) => n.tag === 'task:recall'), null, { timeout: 15000 })
.then(() => true, () => false)
check(`${who === 'user' ? '使用者' : '搭建者'}:来了 recall 待办,发系统通知`, noted,
(await page.evaluate(() => window.__notes.map((n) => n.tag))).join(','))
if (!noted) continue
await page.evaluate(() => window.__notes.find((n) => n.tag === 'task:recall').onclick())
const there = await page.waitForURL((u) => u.pathname === '/apps/inbox/recall', { timeout: 8000 }).then(() => true, () => false)
check('……点通知去这条待办的待办页', there, new URL(page.url()).pathname)
check('……待办页画出召回卡', await page.locator('[data-task-recall="caliber"]').waitFor({ timeout: 8000 })
.then(() => true, () => false))
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
}
})
await section('待办箱:列表和徽标', async () => {
const page = await open({ writes: [] })
await page.goto(`${WEB}/apps?tab=inbox`)
@@ -410,6 +619,8 @@ await section('待办箱:列表和徽标', async () => {
check('每条待办一行,链到详情', (await page.locator('[data-inbox-task]').count()) === Object.keys(TASKS).length)
check('审核个人应用的一行写种类「审核个人应用」', (await page.locator('[data-inbox-task="review"]').innerText()).includes('审核个人应用')
&& (await page.locator('[data-inbox-task="review"]').getAttribute('data-task-kind')) === 'review')
check('口径召回的一行写种类「口径召回」', (await page.locator('[data-inbox-task="recall"]').innerText()).includes('口径召回')
&& (await page.locator('[data-inbox-task="recall"]').getAttribute('data-task-kind')) === 'recall')
check('「应用」导航上的徽标数应用里的待办', (await page.locator('[data-apps-badge]').innerText()).trim() === String(Object.keys(TASKS).length))
check('页签上带数', /\d/.test(await page.getByRole('tab', { name: /待办/ }).innerText()))
})
+158
View File
@@ -2075,6 +2075,164 @@ await section('应用平台:发布成功做成应用;已发布版本的使
}
})
// 口径召回(next-round 02 做法 7、8):发布弹窗按这一版预告召回的规模(旧版本被谁用过、预计自动重算几组、超出上限几组、
// 不能重算的原因),版本用量加问数回答、导出、召回;版本历史里看口径升到这一版之后的召回批次(每组的结论),「另有 M 组未
// 重算」能手动接着算。预告、批次、接着算全在浏览器层伪造,写请求拦下核对
const PREVIEW = { workflow_id: 'pf-hard', version: 3, limit: 50, caliber: true, problem: null, groups: 60, recomputable: 58,
not_recomputable: 2, already: 0, will_recompute: 50, over_limit: 8, truncated: false,
versions: [{ version: 2, answers: 3, exports: 1, schedules: 1, apps: 2, formal: 4, pinned: 0, truncated: false }],
reasons: [{ reason: '新版 v3 的入口要的参数这一组里没有或不合规:「区域」必填', groups: 2 }] }
const BATCHES = { workflow_id: 'pf-lib', workflow_name: '__publish_check_lib__', version: 4, limit: 50, batch_size: 50,
problem: null, caliber: true, truncated: false, remaining: 1, can_continue: true,
counts: { changed: 1, unchanged: 1, failed: 1, running: 0, pending: 1, not_recomputable: 1 },
groups: [
{ id: '3-a', from_version: 3, to_version: 4, binding: [{ field: 'week', value: '2026-W38' }],
uses: [{ kind: 'answer', kind_text: '问数回答', count: 2 }], latest: '2026-10-01T02:00:00Z', state: 'changed',
run_id: 'rc-a', batch: 'auto', tasks: 2, changed_metrics: ['销售额'] },
{ id: '3-b', from_version: 3, to_version: 4, binding: [{ field: 'week', value: '2026-W37' }],
uses: [{ kind: 'schedule', kind_text: '定时任务', count: 1 }], latest: '2026-09-24T02:00:00Z', state: 'unchanged',
run_id: 'rc-b', batch: 'auto', tasks: 0, changed_metrics: [] },
{ id: '3-c', from_version: 3, to_version: 4, binding: [], uses: [{ kind: 'formal', kind_text: '正式运行', count: 1 }],
latest: '2026-09-20T02:00:00Z', state: 'failed', reason: '重算失败:数据源「shop」已停用', run_id: 'rc-c', batch: 'auto' },
{ id: '2-d', from_version: 2, to_version: 4, binding: [{ field: 'week', value: '2026-W30' }],
uses: [{ kind: 'answer', kind_text: '问数回答', count: 1 }], latest: '2026-08-01T02:00:00Z', state: 'not_recomputable',
reason: '新版 v4 的入口要的参数这一组里没有或不合规:「区域」必填' },
{ id: '3-e', from_version: 3, to_version: 4, binding: [{ field: 'week', value: '2026-W20' }],
uses: [{ kind: 'answer', kind_text: '问数回答', count: 1 }], latest: '2026-07-01T02:00:00Z', state: 'pending' },
] }
await section('口径召回:发布弹窗预告召回规模、版本用量加问数回答和召回', async () => {
const usage = { apps: [], effects: 0, formal_runs: 4, answers: 5, exports: 2, schedules: 1,
recall: { groups: 1, changed: 1, recalled_from: 0 } }
for (const variant of ['over', 'zero', 'none', 'not-caliber']) {
const { page, errors } = await open({ id: 'pf-hard' })
const asked = []
const body = variant === 'over' ? PREVIEW : variant === 'zero' ? { ...PREVIEW, limit: 0, will_recompute: 0, over_limit: 58 }
: variant === 'none' ? { ...PREVIEW, groups: 0, recomputable: 0, not_recomputable: 0, will_recompute: 0, over_limit: 0,
versions: [], reasons: [] }
: { ...PREVIEW, caliber: false, groups: 0, recomputable: 0, not_recomputable: 0, will_recompute: 0, over_limit: 0,
versions: [], reasons: [] }
await page.route(/\/api\/workflows\/pf-hard\/recall-preview(\?.*)?$/, (route) => {
asked.push(new URL(route.request().url()).search)
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
})
await page.route(/\/api\/ledger\/workflow-versions\/.+$/, (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(usage) }))
await openDialog(page)
const row = dialog(page).locator('[data-publish-recall]')
if (variant === 'not-caliber') {
await waitFor(page, async () => asked.length > 0)
await page.waitForTimeout(300)
check('旧版本里没有口径、也没有用过的答案:不画召回那一行', await row.count() === 0)
continue
}
check(`预告(${variant}):按要发布的这一版问(version=3)`, await waitFor(page, async () => asked.length > 0)
&& asked[0].includes('version=3'), asked.join(','))
await waitFor(page, async () => (await row.getAttribute('data-publish-recall').catch(() => '')) !== 'loading')
const text = await row.innerText().catch(() => '')
if (variant === 'over') {
check('写旧版本被谁用过', text.includes('旧版本被 3 次问数回答、1 次导出、1 个定时任务、2 个应用、4 次正式运行用过'), text)
check('写去重后几组、发布后自动重算几组', text.includes('按参数去重共 60 组,发布后自动重算 50 组')
&& await row.locator('[data-publish-recall-will="50"]').count() === 1, text)
check('超出上限的写明可以手动接着算', (await row.locator('[data-publish-recall-over="8"]').innerText())
.includes('另有 8 组超出每次 50 组的上限'))
check('不能重算的写几组和原因', (await row.locator('[data-publish-recall-unrecomputable="2"]').innerText())
.includes('「区域」必填'))
check('写明重算只运行口径的数据部分、不调用模型', text.includes('不调用模型'), text)
const line = await dialog(page).locator('[data-publish-usage] [data-version-usage-ledger]').innerText().catch(() => '')
check('使用情况加上问数回答、导出、召回', line.includes('问数回答 5 次') && line.includes('导出 2 次')
&& line.includes('召回 1 组(数变了 1 组)'), line)
await shoot(page, 'publish-recall-preview', dialog(page))
} else if (variant === 'zero') {
check('上限是 0:写明发布后不自动重算、只列出受影响的答案', await row.locator('[data-publish-recall-zero]').count() === 1
&& await row.locator('[data-publish-recall-over]').count() === 0, text)
} else {
check('是口径但旧版本没被用过:写不需要召回', await row.getAttribute('data-publish-recall') === 'none'
&& text.includes('不需要召回'), text)
}
check(`没有运行时报错(${variant})`, errors.length === 0, errors.join(' | '))
}
})
await section('口径召回:版本历史看召回批次、另有 M 组未重算、接着算', async () => {
const { page, errors } = await open({ id: 'pf-lib' })
const versions = [4, 3, 2].map((n) => ({ id: `v${n}`, version: n, note: '', created_at: '2026-09-26T02:00:00Z',
published: n === 4, level: 'published', retired_at: null, retired_by: null }))
await page.route(/\/api\/workflows\/pf-lib\/versions(\/\d+)?$/, (route) => {
const v = Number(new URL(route.request().url()).pathname.split('/')[5])
const body = v ? { ...versions.find((x) => x.version === v), workflow_id: 'pf-lib', graph: FAKES['pf-lib'].graph, graph_hash: 'x' } : versions
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
})
await page.route(/\/api\/ledger\/workflow-versions\/.+$/, (route) => route.fulfill({ status: 200, contentType: 'application/json',
body: JSON.stringify({ apps: [], effects: 0, formal_runs: 3, answers: 4, exports: 0, schedules: 1,
recall: { groups: 4, changed: 1, recalled_from: 0 } }) }))
const continued = []
await page.route(/\/api\/ledger\/recall\/pf-lib\/(\d+)(\/continue)?$/, (route) => {
const req = route.request()
const v = Number(new URL(req.url()).pathname.split('/')[5])
// v2:口径升到 v2 时自动算了一批、还剩一组,后来又发布了 v4——v2 那一版不再给「接着算」,服务端写明原因
if (v === 2) return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ...BATCHES, version: 2,
groups: [{ ...BATCHES.groups[4], id: '1-e', from_version: 1, to_version: 2 }], remaining: 1, can_continue: false,
continue_reason: '已有更新的发布版 v4,召回在 v4 那一版接着看',
counts: { changed: 0, unchanged: 0, failed: 0, running: 0, pending: 1, not_recomputable: 0 } }) })
if (v !== 4) return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ...BATCHES, version: v,
groups: [], remaining: 0, can_continue: false, counts: { changed: 0, unchanged: 0, failed: 0, running: 0, pending: 0,
not_recomputable: 0 } }) })
if (req.method() === 'POST') {
continued.push(new URL(req.url()).pathname)
const groups = BATCHES.groups.map((g) => (g.state === 'pending' ? { ...g, state: 'running', run_id: 'rc-e', batch: 'manual' } : g))
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ...BATCHES, groups, remaining: 0,
can_continue: false, launched: ['rc-e'], counts: { ...BATCHES.counts, pending: 0, running: 1 } }) })
}
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(BATCHES) })
})
await page.evaluate(() => document.activeElement?.blur?.())
await page.keyboard.press('Alt+KeyH')
const sheet = page.locator('[role="dialog"][aria-label="版本历史"]')
await sheet.waitFor()
await waitFor(page, async () => await sheet.locator('ol button', { hasText: 'v4' }).count() > 0)
await sheet.locator('ol button', { hasText: 'v4' }).first().click()
const box = sheet.locator('[data-version-recall="5"]')
check('展开 v4:列出口径升到这一版之后的召回批次', await waitFor(page, async () => await box.count() === 1))
const summary = await box.locator('[data-version-recall-summary]').innerText().catch(() => '')
check('……一句总的:数变了、没变、重算失败、无法重算各几组', summary === '数变了 1 组,没变 1 组,重算失败 1 组,无法重算 1 组', summary)
const state = async (id) => (await box.locator(`[data-version-recall-group="${id}"] .chip`).first().innerText().catch(() => ''))
check('每组写结论', await state('3-a') === '数变了' && await state('3-b') === '没变' && await state('3-c') === '重算失败'
&& await state('2-d') === '无法重算' && await state('3-e') === '未重算')
check('数变了的写变了的指标、开了几条待办,能看召回运行', (await box.locator('[data-version-recall-group="3-a"]').innerText())
.includes('变了的指标:销售额') && (await box.locator('[data-version-recall-group="3-a"]').innerText()).includes('开了 2 条待办')
&& (await box.locator('[data-version-recall-run="rc-a"]').getAttribute('href')) === '/runs/rc-a')
check('无法重算照实写原因', (await box.locator('[data-version-recall-group="2-d"] [data-version-recall-reason]').innerText())
=== '无法重算:新版 v4 的入口要的参数这一组里没有或不合规:「区域」必填')
check('重算失败写原因', (await box.locator('[data-version-recall-group="3-c"] [data-version-recall-reason]').innerText())
.includes('数据源「shop」已停用'))
const usageLine = await sheet.locator('[data-version-lifecycle="4"] [data-version-usage-ledger]').innerText().catch(() => '')
check('版本用量加上问数回答、召回', usageLine.includes('问数回答 4 次') && usageLine.includes('召回 4 组(数变了 1 组)'), usageLine)
check('另有 1 组未重算', (await box.locator('[data-version-recall-remaining="1"]').innerText()).includes('另有 1 组未重算'))
const go = box.locator('[data-version-recall-continue]')
check('给「接着算」,写每次几组', (await go.innerText()).includes('接着算(每次 50 组)'))
await shoot(page, 'versions-recall', sheet)
await go.click()
check('接着算:POST /api/ledger/recall/pf-lib/4/continue', await waitFor(page, async () => continued.length === 1)
&& continued[0] === '/api/ledger/recall/pf-lib/4/continue', continued.join(','))
check('……之后照服务端交回的批次画:没有未重算的、刚发起的那组正在重算', await waitFor(page, async () =>
await box.locator('[data-version-recall-remaining]').count() === 0 && await state('3-e') === '正在重算'))
check('……提示已发起几组', await page.getByText('已发起 1 组召回').first().waitFor({ timeout: 3000 }).then(() => true, () => false))
await sheet.locator('ol button', { hasText: 'v3' }).first().click()
await sheet.locator('[data-version-lifecycle="3"]').waitFor()
await page.waitForTimeout(300)
check('没有召回过的版本不画召回那一块', await sheet.locator('[data-version-lifecycle="3"] [data-version-recall]').count() === 0)
await sheet.locator('ol button', { hasText: 'v2' }).first().click()
const old = sheet.locator('[data-version-lifecycle="2"] [data-version-recall]')
check('之后又发布了更新版本的旧版:照样列出另有几组未重算', await waitFor(page, async () => await old.count() === 1)
&& (await old.locator('[data-version-recall-remaining="1"]').innerText().catch(() => '')).includes('另有 1 组未重算'))
check('……不给「接着算」,写服务端给的原因', await old.locator('[data-version-recall-continue]').count() === 0
&& (await old.locator('[data-version-recall-continue-reason]').innerText().catch(() => ''))
=== '不再接着算:已有更新的发布版 v4,召回在 v4 那一版接着看')
check('……没有多发接着算的请求', continued.length === 1, continued.join(','))
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
// 开了登录:发布记的是登录的人(服务端写 published_by,不看署名),不给署名框、不说「未署名」;受管级只给管理员,
// 搭建者那一档当场置灰、写明原因,不等点了发布才被拒(V1-21、V1-22)。受管工作流的新版本不论哪一档都只有管理员能发:
// 降成「已发布」会让此后的正式运行不再过治理门禁,和以受管级发布同一级(Q8,服务端同样拦)——搭建者两档都锁住