From 77fc0efa1040de4bcf63f22c73c400abe8143666 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 19:52:03 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E5=8F=A3=E5=BE=84=E5=8F=AC=E5=9B=9E?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=EF=BC=88W4-a=EF=BC=89=EF=BC=9A=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E6=96=B0=E7=89=88=E6=9C=AC=E5=90=8E=E5=8F=8D=E6=9F=A5?= =?UTF-8?q?=E7=94=A8=E8=BF=87=E6=97=A7=E7=89=88=E7=9A=84=E7=AD=94=E6=A1=88?= =?UTF-8?q?=E3=80=81=E6=8C=89=E5=8F=82=E6=95=B0=E5=88=86=E7=BB=84=E5=9C=A8?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E4=BB=BD=E6=95=B0=E6=8D=AE=E4=B8=8A=E9=87=8D?= =?UTF-8?q?=E7=AE=97=E4=B8=A4=E7=89=88=E4=B9=8B=E5=B7=AE=EF=BC=8C=E6=95=B0?= =?UTF-8?q?=E7=9C=9F=E5=8F=98=E4=BA=86=E6=89=8D=E5=BC=80=20recall=20?= =?UTF-8?q?=E5=BE=85=E5=8A=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:口径升版是被迫处置的事件(02 做法 7、拍板 L6),已交付的答案以前没人追;目录事实被驳回、表结构变了也没有地方标。 - ledger/uses.py(新):反查旧版本被谁用过——比对工件里等价已证明的问数回答(含轮次、对话主人)、W 的正式运行(定时任务、 应用各取最近 4 期)、固定了它的工作流版本发起的正式运行、固定这一版的应用、导出审计(导出人也是主人);预告、召回、 版本用量共用这一份 - ledger/recall.py(新):计划按(旧版本、新版本、input_key)去重成组,从已有召回运行重算(幂等、不建任务表);每组一次 召回运行(origin_kind=recall,探索运行),派生图经 derive.recall_graph(新旧两版取数和口径卡带前缀、召回卡里算两版之差); 不能重算照实记原因(数据部分不能复用、新版没这张卡、入口参数不齐、图里有人工节点或写入);每次发布最多 recall_limit 组, 超出的手动接着算;收尾时按渲染的旧值新值判数变没变:变了开 recall 待办(开登录按主人各一条、关登录派给任何人)、推 ledger.recall,没变在原答案上记 ledger.recalled;待办「按新版重新出具」走 issue.check_target 同一套判法、「知道了」 - 目录事实被驳回或修改(审阅、人工编辑、保存提案、批量审阅之后):冻结目录里看得到这一项的探索运行记 ledger.basis_changed, 按新目录多出错误级 SQL 检查问题的才开待办;表结构变动消费 source_changes(游标、处理时改了目录的当场交过去),标依据的 表结构已变,受影响的口径试算一次,算不出来才通知发布人 - 发布钩子在 commit_publish 提交之后排队(自动的几样和调度循环同一个开关),GET /api/workflows/{id}/recall-preview 给发布 弹窗预告;GET/POST /api/ledger/recall/{wf}/{v}[/continue] 给版本历史看批次、接着算;版本用量加问数回答、导出、召回 - ledger.recall 打开订阅(全局、个人渠道),同一渠道只推一次,消息不放数;新事件 ledger.recalled、ledger.basis_changed 登记为封存后追加的事件,可验证报告离线核对页认得;E4 每次发布召回了几组、数变了的比例 - runner 收尾时召回运行在后台收尾,启动时接着做没发完的批次 --- backend/app/api/ledger.py | 49 +- backend/app/api/workflows.py | 17 + backend/app/bundle/build.py | 13 + backend/app/bundle/verifier.js | 1 + backend/app/core/events.py | 10 + backend/app/data/catalog.py | 20 +- backend/app/data/catalog_review.py | 5 +- backend/app/data/source_changes.py | 20 +- backend/app/db/models_process.py | 5 +- backend/app/engine/runner.py | 12 +- backend/app/ledger/derive.py | 65 +- backend/app/ledger/issue.py | 107 +- backend/app/ledger/recall.py | 1463 +++++++++++++++++++++++++++ backend/app/ledger/stats.py | 51 +- backend/app/ledger/uses.py | 382 +++++++ backend/app/notify/service.py | 57 +- backend/app/process/tasks.py | 89 +- backend/tests/test_app_versions.py | 4 +- backend/tests/test_ledger_api.py | 3 +- backend/tests/test_ledger_recall.py | 563 +++++++++++ backend/tests/test_notify_events.py | 23 +- 21 files changed, 2873 insertions(+), 86 deletions(-) create mode 100644 backend/app/ledger/recall.py create mode 100644 backend/app/ledger/uses.py create mode 100644 backend/tests/test_ledger_recall.py diff --git a/backend/app/api/ledger.py b/backend/app/api/ledger.py index 5c8baa41..5917c529 100644 --- a/backend/app/api/ledger.py +++ b/backend/app/api/ledger.py @@ -11,7 +11,10 @@ L1、L5:参数只从比对工件取,使用者只能按他有权使用的应用固定着的版本出具); - POST /api/ledger/answers/{run_id}/reconcile(同上):口径对账,点开才跑一次确定性派生的只读图(app/ledger/reconcile.py); - 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 +27,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 +43,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 +172,38 @@ async def reconcile_answer(run_id: str, payload: ReconcileIn) -> dict[str, Any]: return {"run_id": run.id, "reconciled": ledger_reconcile.reconciled_view(run)} +@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。""" + 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("problem") 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 形状、问题聚类、被反复用的答案。每项给代表问题、 diff --git a/backend/app/api/workflows.py b/backend/app/api/workflows.py index 62dee247..6355d69f 100644 --- a/backend/app/api/workflows.py +++ b/backend/app/api/workflows.py @@ -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)) + + # -------------------------------------------------------------------------- # 发布前检查与自动修复:两个接口都只读,不改库、不发布 # -------------------------------------------------------------------------- diff --git a/backend/app/bundle/build.py b/backend/app/bundle/build.py index caada4ab..9dd73f2e 100644 --- a/backend/app/bundle/build.py +++ b/backend/app/bundle/build.py @@ -1184,6 +1184,19 @@ 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.LOG and isinstance(data.get("message"), str): return _clip(data["message"]) return "" diff --git a/backend/app/bundle/verifier.js b/backend/app/bundle/verifier.js index 48edbc12..f4ae077d 100644 --- a/backend/app/bundle/verifier.js +++ b/backend/app/bundle/verifier.js @@ -65,6 +65,7 @@ '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': '答案的依据有变', 'log': '日志', 'usage': '用量', } const T = { diff --git a/backend/app/core/events.py b/backend/app/core/events.py index 3a39eb14..4cbd7c7a 100644 --- a/backend/app/core/events.py +++ b/backend/app/core/events.py @@ -92,6 +92,16 @@ 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" # 应用平台(docs/design/app-platform-and-look.md §3.11) # 运行来源:发起时写进 launch_run 的 notes,在封存范围内(engine/origin.py) diff --git a/backend/app/data/catalog.py b/backend/app/data/catalog.py index a1144e39..fb39a3e9 100644 --- a/backend/app/data/catalog.py +++ b/backend/app/data/catalog.py @@ -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 # ========================================================================== diff --git a/backend/app/data/catalog_review.py b/backend/app/data/catalog_review.py index 5e7fba68..43e27594 100644 --- a/backend/app/data/catalog_review.py +++ b/backend/app/data/catalog_review.py @@ -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] diff --git a/backend/app/data/source_changes.py b/backend/app/data/source_changes.py index 876bcdf2..fe95b0af 100644 --- a/backend/app/data/source_changes.py +++ b/backend/app/data/source_changes.py @@ -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 diff --git a/backend/app/db/models_process.py b/backend/app/db/models_process.py index 100ebdf6..db828902 100644 --- a/backend/app/db/models_process.py +++ b/backend/app/db/models_process.py @@ -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 环境时的演练,不发送 diff --git a/backend/app/engine/runner.py b/backend/app/engine/runner.py index 714fa680..ac31b861 100644 --- a/backend/app/engine/runner.py +++ b/backend/app/engine/runner.py @@ -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: """对到此为止的全部事件算清单哈希,记下封到了哪一条。""" diff --git a/backend/app/ledger/derive.py b/backend/app/ledger/derive.py index 4d22ff4c..8d37d063 100644 --- a/backend/app/ledger/derive.py +++ b/backend/app/ledger/derive.py @@ -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"] diff --git a/backend/app/ledger/issue.py b/backend/app/ledger/issue.py index a05c81d8..66f701bd 100644 --- a/backend/app/ledger/issue.py +++ b/backend/app/ledger/issue.py @@ -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"] diff --git a/backend/app/ledger/recall.py b/backend/app/ledger/recall.py new file mode 100644 index 00000000..a9e736a0 --- /dev/null +++ b/backend/app/ledger/recall.py @@ -0,0 +1,1463 @@ +"""口径召回:口径升版、目录事实被驳回或修改、表结构变动之后,追回已经交付的答案(docs/design/next-round/02-口径账本.md +做法 7,拍板 L6、L7;总方案 X1、X3、X6)。 + +**一、口径升版。** 工作流 W 发布了新版本 N(api/workflows.commit_publish 提交之后在后台排队,失败不影响发布): + +- 反查(ledger/uses):W 的旧版本 V < N 被哪些问数回答、正式运行、固定了它的工作流、定时任务、应用、导出用过。旧版本里 + 没有源头口径卡(metrics 节点、不是转抄别处的 caliber_from)的不是口径,不召回。 +- 分组:按(旧版本、新版本、input_key(参数))去重成组。参数先按旧版本入口字段整理(只留入口有的字段、空的补旧版的缺省值), + 这样问数回答绑定出的 {start, end} 和定时任务的 {start, end, store: all} 是同一组。组按最近一次用到的先后排。 +- 每组一次召回运行(来源 recall,探索运行,系统派生的运行不进使用统计):派生图经 ledger/derive.recall_graph(全站唯一的 + 派生图生成器)——入口(这一组的参数)→ 旧版取数 + 旧版口径卡、新版取数 + 新版口径卡(caliber_from.nodes 改名,不撞 id) + → 召回卡(recall_card:每个两版都有的指标三个数:旧版、新版、两版之差;差值在卡里算,叙述层无算术)→ 出口(契约 + metrics_from 指向召回卡;不加报告、不调模型)。两版在同一次运行里查同一份数据,差值只反映口径;数据随时间的变化另外拿 + 原答案当时的值对照着说(task_payload 的 original)。 +- 不能重算的照实记「无法重算:原因」,不发起:口径的数据部分不能复用(derive.NotDerivable)、新版里没有这张口径卡、两版 + 没有同名的指标、新版入口要的参数这一组里没有或不合规(compose.check_inputs 同一套校验)、新版的图里有人工节点或会写外部 + 系统(apps/backfill.graph_side_effect,和按新口径补算、按口径出具同一个判断:替人重跑不代人做这些事)。 +- 上限:每次发布最多自动重算 ledger.recall_limit 组(设置,缺省 50;0 表示不自动重算,只列出受影响的答案)。超出的写 + 「另有 M 组未重算」,在版本历史里点「接着算」手动接着发起下一批(每批同样的组数,上限是 0 时按缺省值)。 +- **计划从已有的召回运行重算**(照 apps/backfill 的写法,不建任务表):一组算没算过,看有没有 origin 指向(W、V、N、 + 参数指纹)的召回运行;不能重算的每次按同样的规则重新判出来。幂等:重复排队、服务重启之后重算一遍计划,已经算过的跳过。 + 启动时(resume)把最近 RESUME_DAYS 天发布、自动那一批没发完的接着发完,结束了还没收尾的召回运行补上收尾。同一个(W、N) + 的计划和发起在进程内串行(单进程部署,同 apps/backfill 的锁)。 +- **收尾**(settle,召回运行结束时 runner 调):读召回卡(封存范围内的指标集),每个指标按它的单位、小数位渲染的旧值和新值 + 不同就是数变了。数变了:给这一组答案的主人开 recall 待办(挂在召回运行上;开登录时每个主人一条,只列他的答案;关登录时 + 一条派给任何人),推 ledger.recall 通知。数没变:只记账——在每个原答案的运行上追加 ledger.recalled(「已按 vN 核对, + 不影响」)。召回运行自己也追加一条 ledger.recalled 记结论(只记变没变、几个答案、几条待办,不记数),它同时是「收过尾」的 + 记号。召回运行没成功的照实记重算失败。 + +**二、目录事实被驳回或修改**(只管影响数字的那一组:有效记录条件、去重、业务日期、关联、业务主键、度量类型,同 +data/catalog_review.FIELD_GROUP 的 facts):审阅写入之后(data/catalog 的审阅、人工编辑、保存提案,catalog_review 的批量审阅) +在后台找出查过这张表、而且查询快照冻结的目录里看得到这一项的探索运行,追加 ledger.basis_changed(kind=catalog),界面标 +「依据有变」;对这些运行执行过的 SQL 按新目录跑一遍 SQL 检查(data/sqlcheck,纯计算),比冻结目录下多出错误级问题的才开 +一条 recall 待办(reason=catalog,只有「知道了」)通知答案的主人(拍板 L7:不自动重问)。 + +**三、表结构变动**:消费 data/source_changes(list_changes(since_seq) 按 seq 游标拉,游标记在设置表里;探查、剖析任务结束、 +处理变化改了目录时触发)。只看 triggers_recall 认的:表或列被删、列的类型变了标「依据的表结构已变」(kind=structure); +触及有效记录条件的新码值(values)、处理时改了目录的(catalog_edit)标依据有变,同二按新目录跑 SQL 检查。表结构变了的, +受影响的口径(最新的已发布版本里查这张表的口径)按最近一次正式运行的输入派生数据部分试算一次(召回运行,origin.probe; +这一版还没有正式运行的退回最近一次等价于它的问数回答的参数,再没有用入口的缺省值;参数凑不齐的不试算),算不出来才开 +recall 待办(reason=structure)通知发布人。 + +**通知**:ledger.recall 只推口径升版、数真变了的那一种待办(消息照 notify/service 的规矩:标题、状态、条数、链接,不放 +新旧值和差值);目录和表结构的两种待办照别的待办推 task.opened。 + +**自动的那几样**(发布后自动那一批、探查剖析之后拉表结构变化、启动时接着做)和调度循环同一个开关(automatic, +AGENTLAB_SCHEDULER):它们是在后台替人查业务库,测试、拷来的数据目录(检查脚本)不做;关着时版本历史里照样列出受影响的 +答案、能手动接着算,审阅之后标依据有变照做(只读应用库)。 + +只读:召回运行带 READ_ONLY,只查库、不写;不调模型。召回是辅助步骤:出错只记日志,不影响发布、审阅和探查。 +""" +from __future__ import annotations + +import asyncio +import logging +import weakref +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from typing import Any + +from sqlalchemy import func, select + +from app.core.events import EventType +from app.db.base import SessionLocal, utcnow +from app.db.models import Run, RunEvent, Workflow +from app.ledger import uses as ledger_uses + +logger = logging.getLogger(__name__) + +ORIGIN = "recall" +#: 召回待办的种类(process/tasks.RECALL_KIND)和三种来由 +TASK_KIND = "recall" +REASONS = ("caliber", "catalog", "structure") +#: 发起的批次:发布后自动的、版本历史里手动接着算的 +BATCHES = ("auto", "manual") +#: 一组的结论 +OUTCOMES = ("changed", "unchanged", "failed", "running", "pending", "not_recomputable") +#: 启动时接着做最近几天发布的 +RESUME_DAYS = 7 +#: 目录、表结构变了时最多看多少次探索运行(新的在前) +BASIS_MAX_RUNS = 200 +#: 设置表里记表结构变化游标的键(通知自己记的账同 notify_state:* 的写法,设置接口不列) +CURSOR_KEY = "ledger_state:recall_changes" +#: 被拒的机器码 +REFUSED = "ledger_recall_refused" + +#: 不能重算的原因 +NOT_CALIBER = "这一版里没有源头口径卡,不是口径,不召回" +NOT_REUSABLE = "口径的数据部分不能复用:{reason}" +CARD_GONE = "新版 v{version} 里没有口径卡「{card}」" +NO_COMMON = "新旧两版的口径卡没有同名的指标,比不了" +SIDE_EFFECT = "「{name}」v{version}{reason},召回不代人做这些事" +NEW_INPUTS = "新版 v{version} 的入口要的参数这一组里没有或不合规:{problem}" +NEW_GONE = "新版本不存在,可能已被删除" +LAUNCH_REFUSED = "没能发起召回运行:{reason}" +RUN_FAILED = "重算失败:{reason}" +#: 收尾 +CHANGED_TITLE = "口径「{name}」升到 v{version},答案的数变了" +CATALOG_TITLE = "答案依据的数据目录有变:按新目录检查出了新的问题" +STRUCTURE_TITLE = "口径「{name}」v{version} 依据的表结构变了,试算没有算出来" +#: 依据有变的四种(ledger.basis_changed 的 kind) +BASIS_KINDS = ("catalog", "structure", "values", "catalog_edit") +#: 目录里影响数字的那一组(同 data/catalog_review.FIELD_GROUP 的 facts) +FACT_SLOTS = (("t", "valid_filter"), ("t", "dedup"), ("t", "business_date"), ("t", "keys"), ("c", "measure")) + + +class RecallRefused(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 + + +# ========================================================================== +# 计划 +# ========================================================================== + + +@dataclass +class Pair: + """一对口径卡:旧版那一张、新版同 id 的那一张。""" + + old: Any + new: Any + + +@dataclass +class Group: + """(旧版本、新版本、参数)一组:这一组的用法、能不能重算、算过没有。""" + + old_version: int + new_version: int + key: str + binding: dict[str, Any] + uses: list[ledger_uses.Use] = field(default_factory=list) + latest: datetime | None = None + problem: str | None = None + #: 发起召回运行用的输入(按新版入口校验过、补了缺省值;旧版独有的字段接在后面) + inputs: dict[str, Any] = field(default_factory=dict) + run: Run | None = None + refused: str | None = None + + @property + def id(self) -> str: + return f"{self.old_version}-{self.key[:16]}" + + +@dataclass +class Plan: + workflow_id: str + workflow_name: str + new_version: int + groups: list[Group] = field(default_factory=list) + #: 每个旧版本的口径卡对、旧版本自己的问题(不是口径、数据部分不能复用……) + pairs: dict[int, list[Pair]] = field(default_factory=dict) + version_problem: dict[int, str] = field(default_factory=dict) + found: ledger_uses.Found | None = None + problem: str | None = None + #: 自动那一批已经发起了几组 + auto_launched: int = 0 + + def todo(self) -> list[Group]: + """还没算、能算的组(按最近一次用到的先后)。""" + return [g for g in self.groups if g.run is None and g.problem is None] + + +def _aware(moment: datetime | None) -> datetime | None: + if moment is None: + return None + return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc) + + +def normalize(binding: Mapping[str, Any], fields: Sequence[Mapping[str, Any]]) -> dict[str, Any]: + """参数按旧版入口整理:只留入口有的字段,没给(或给了空)的补旧版的缺省值。分组、发起都按它。""" + out: dict[str, Any] = {} + for f in fields: + name = str(f.get("name") or "") + if not name: + continue + value = binding.get(name) + if value in (None, ""): + value = f.get("default") + if value not in (None, ""): + out[name] = value + return out + + +def source_cards(graph: Any) -> list[str]: + """一版图里的源头口径卡(metrics 节点、不是转抄别处的 caliber_from),按图里的先后。""" + out: list[str] = [] + for node in (graph or {}).get("nodes") or [] if isinstance(graph, dict) else []: + if not isinstance(node, dict) or node.get("type") != "metrics": + continue + cfg = (node.get("data") or {}).get("config") if isinstance(node.get("data"), dict) else None + if isinstance(cfg, dict) and cfg.get("caliber_from") in (None, "", {}): + out.append(str(node.get("id"))) + return out + + +async def recall_runs(session: Any, workflow_id: str, new_version: int | None = None) -> list[Run]: + """以 W(新版本 N)发起过的召回运行(试算的不算),新的在前。origin_kind 有索引,先按它筛再看 origin。""" + stmt = select(Run).where(Run.origin_kind == ORIGIN, + func.json_extract(Run.origin, "$.workflow_id") == workflow_id, + func.coalesce(func.json_extract(Run.origin, "$.probe"), 0) == 0) + if new_version is not None: + stmt = stmt.where(func.json_extract(Run.origin, "$.to_version") == int(new_version)) + return list((await session.execute(stmt.order_by(Run.created_at.desc()))).scalars()) + + +async def build_plan(session: Any, workflow_id: str, new_version: int) -> Plan: + """W 升到 N 的召回计划(见模块说明)。只读,不发起。""" + from app.apps import versions as app_versions + from app.apps.backfill import graph_side_effect + from app.ledger import compose, derive + + workflow = await session.get(Workflow, workflow_id) + plan = Plan(workflow_id=workflow_id, workflow_name=workflow.name if workflow is not None else "", + new_version=int(new_version)) + new_row = await app_versions.version_row(session, workflow_id, int(new_version)) if workflow else None + if new_row is None: + plan.problem = NEW_GONE + return plan + found = await ledger_uses.find_uses(session, workflow_id, below=int(new_version)) + plan.found = found + side = await graph_side_effect(session, new_row.graph, level=new_row.level) + for version in found.versions(): + old_row = await app_versions.version_row(session, workflow_id, version) + cards = source_cards(old_row.graph if old_row is not None else None) + if not cards: + plan.version_problem[version] = NOT_CALIBER + continue + pairs: list[Pair] = [] + problems: list[str] = [] + for card in cards: + try: + old = await derive.load_pinned(session, workflow_id, version, card) + except derive.NotDerivable as e: + problems.append(NOT_REUSABLE.format(reason=e.message)) + continue + try: + new = await derive.load_pinned(session, workflow_id, int(new_version), card) + except derive.NotDerivable as e: + problems.append(CARD_GONE.format(version=new_version, card=old.card_title) + if e.code == derive.GONE else NOT_REUSABLE.format(reason=e.message)) + continue + if not {m["id"] for m in old.metrics} & {m["id"] for m in new.metrics}: + problems.append(NO_COMMON) + continue + pairs.append(Pair(old, new)) + if side: + plan.version_problem[version] = SIDE_EFFECT.format(name=plan.workflow_name, version=new_version, + reason=side) + elif not pairs: + plan.version_problem[version] = problems[0] if problems else NOT_CALIBER + plan.pairs[version] = pairs + groups: dict[tuple[int, str], Group] = {} + for use in found.uses: + if plan.version_problem.get(use.version) == NOT_CALIBER: + continue + pairs = plan.pairs.get(use.version) or [] + fields = pairs[0].old.entry_fields if pairs else () + binding = normalize(use.binding, fields) if pairs else dict(use.binding) + key = ledger_uses._key(binding) + group = groups.get((use.version, key)) + if group is None: + group = groups[(use.version, key)] = Group(old_version=use.version, new_version=int(new_version), key=key, + binding=binding, problem=plan.version_problem.get(use.version)) + group.uses.append(use) + at = _aware(use.at) + if at is not None and (group.latest is None or at > group.latest): + group.latest = at + for group in groups.values(): + pairs = plan.pairs.get(group.old_version) or [] + if group.problem is not None or not pairs: + continue + new = pairs[0].new + new_names = {str(f.get("name")) for f in new.entry_fields} + try: + checked = compose.check_inputs(new, {k: v for k, v in group.binding.items() if k in new_names}) + except compose.ComposeRefused as e: + group.problem = NEW_INPUTS.format(version=new_version, problem=e.message) + continue + group.inputs = {**checked, **{k: v for k, v in group.binding.items() if k not in new_names}} + existing = await recall_runs(session, workflow_id, int(new_version)) + by_key: dict[tuple[int, str], Run] = {} + for run in existing: + origin = run.origin if isinstance(run.origin, dict) else {} + k = (int(origin.get("from_version") or 0), str(origin.get("input_key") or "")) + by_key.setdefault(k, run) + if origin.get("batch") == "auto": + plan.auto_launched += 1 + for k, group in groups.items(): + group.run = by_key.get(k) + group.refused = _refusals.get((workflow_id, int(new_version), k[0], k[1])) + far = datetime.min.replace(tzinfo=timezone.utc) + plan.groups = sorted(groups.values(), key=lambda g: (-(g.latest or far).timestamp(), g.old_version, g.key)) + return plan + + +#: 发起时被拒的组(launch_run 拒绝了:工具不在、数据源停用……):(W, N, V, 参数指纹) → 原因。只在进程里记(下次「接着算」 +#: 重试;重启后忘掉、照样算待算) +_refusals: dict[tuple[str, int, int, str], str] = {} + + +# ========================================================================== +# 召回卡与发起 +# ========================================================================== + + +def _metric(mid: str, name: str, expression: str, src: Mapping[str, Any]) -> dict[str, Any]: + out: dict[str, Any] = {"id": mid, "name": name, "unit": str(src.get("unit") or ""), "expression": expression} + if src.get("decimals") is not None: + out["decimals"] = src["decimals"] + if src.get("format"): + out["format"] = src["format"] + return out + + +def recall_card(pairs: Sequence[Pair]) -> tuple[dict[str, Any], list[dict[str, Any]]]: + """召回卡(derive.recall_graph 的 tail)和每个比较的说明。每个两版都有(按指标 id)的指标三个数:old_k 旧版、new_k + 新版、diff_k 两版之差(新版 − 旧版),单位、小数位、显示格式照新版的定义。缺输入记空值(on_missing=null)。纯计算。""" + from app.ledger import derive + + metrics: list[dict[str, Any]] = [] + meta: list[dict[str, Any]] = [] + k = 0 + for i, pair in enumerate(pairs): + old_prefix, new_prefix = derive.recall_prefixes(i) + new_by = {m["id"]: (j, m) for j, m in enumerate(pair.new.metrics)} + for idx, m in enumerate(pair.old.metrics): + if m["id"] not in new_by: + continue + j, nm = new_by[m["id"]] + old_expr = f"nodes.{old_prefix}{pair.old.node_id}.metrics[{idx}].value" + new_expr = f"nodes.{new_prefix}{pair.new.node_id}.metrics[{j}].value" + name = str(nm.get("name") or m["id"]) + metrics += [_metric(f"old_{k}", f"{name} · 旧版 v{pair.old.version}", old_expr, nm), + _metric(f"new_{k}", f"{name} · 新版 v{pair.new.version}", new_expr, nm), + _metric(f"diff_{k}", f"{name} · 两版之差", f"({new_expr}) - ({old_expr})", nm)] + # 旧版的式子(定义,不是数)一并记下:问数回答当时的值要按它找到答案里对应的那一格(original_values) + meta.append({"k": k, "card": pair.old.node_id, "metric": m["id"], "name": name, + "unit": str(nm.get("unit") or ""), "decimals": nm.get("decimals"), "format": nm.get("format"), + "expression": str(m.get("expression") or "")}) + k += 1 + node = derive._node(derive.RECALL_CARD, "metrics", "召回卡", caliber="口径召回", caliber_version="v1", + on_missing="null", metrics=metrics) + return node, meta + + +def build_graph(group: Group, pairs: Sequence[Pair]) -> tuple[Any, list[dict[str, Any]]]: + """一组的召回图(derive.recall_graph)和比较说明。同样的输入永远得到同一张图。""" + from app.ledger import derive + + card, meta = recall_card(pairs) + graph = derive.recall_graph([(p.old, p.new) for p in pairs], [card], inputs=group.inputs) + return graph, meta + + +async def _launch_group(plan: Plan, group: Group, *, batch: str, actor: str | None) -> Run | None: + from app.engine.launch import LaunchRefused, launch_run + from app.tools.datasource import READ_ONLY_OPTION + + pairs = plan.pairs.get(group.old_version) or [] + graph, meta = build_graph(group, pairs) + origin = {"workflow_id": plan.workflow_id, "from_version": group.old_version, "to_version": group.new_version, + "input_key": group.key, "binding": dict(group.binding), "batch": batch, + "cards": [p.old.node_id for p in pairs], "metrics": meta, + "uses": [u.describe() for u in group.uses], "derive": graph.origin} + key = (plan.workflow_id, plan.new_version, group.old_version, group.key) + async with SessionLocal() as session: + try: + run = await launch_run(session, graph=graph.graph, input_payload=dict(group.inputs), + run_class="exploratory", actor=actor, origin_kind=ORIGIN, origin=origin, + options={READ_ONLY_OPTION: True}, + workflow_name=f"口径召回:{plan.workflow_name} v{group.old_version} → v{group.new_version}") + except LaunchRefused as e: + _refusals[key] = LAUNCH_REFUSED.format(reason=e.message) + logger.info("口径召回没能发起(%s v%s → v%s):%s", plan.workflow_id, group.old_version, group.new_version, + e.message) + return None + _refusals.pop(key, None) + return run + + +#: 同一(W、N)的计划和发起串行:{事件循环: {键: 锁}} +_locks: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict[tuple[str, int], asyncio.Lock]]" = \ + weakref.WeakKeyDictionary() + + +def _lock(key: tuple[str, int]) -> asyncio.Lock: + group = _locks.setdefault(asyncio.get_running_loop(), {}) + return group.setdefault(key, asyncio.Lock()) + + +async def launch(workflow_id: str, new_version: int, *, batch: str = "auto", actor: str | None = None) -> list[str]: + """按计划发起一批召回运行,交回发起了的运行 id。auto:发布后自动的那一批,一次发布最多 recall_limit 组(已经自动发起 + 过的扣掉,重复调幂等);manual:「接着算」,再发起一批(上限是 0 时按缺省的组数)。""" + from app.api.settings import LEDGER_DEFAULTS, ledger_settings + + if batch not in BATCHES: + raise ValueError(f"不认识的召回批次:{batch}") + async with _lock((workflow_id, int(new_version))): + async with SessionLocal() as session: + plan = await build_plan(session, workflow_id, int(new_version)) + limit = int((await ledger_settings(session))["recall_limit"]) + if plan.problem is not None: + return [] + if batch == "auto": + size = max(0, limit - plan.auto_launched) + else: + size = limit or int(LEDGER_DEFAULTS["recall_limit"]) + launched: list[str] = [] + for group in plan.todo()[:size]: + run = await _launch_group(plan, group, batch=batch, actor=actor) + if run is not None: + launched.append(run.id) + if launched: + logger.info("口径召回:%s 升到 v%s,%s 发起了 %d 组", workflow_id, new_version, batch, len(launched)) + return launched + + +# ========================================================================== +# 预告、批次、版本用量 +# ========================================================================== + + +def _counts(plan: Plan) -> dict[str, int]: + out = dict.fromkeys(OUTCOMES, 0) + for group in plan.groups: + out[_group_state(group)[0]] += 1 + return out + + +async def preview(session: Any, workflow_id: str, version: int) -> dict[str, Any]: + """发布弹窗的召回预告:把 version 发布成新版本之后,旧版本被多少问数回答、导出、定时任务、应用用过,预计重算几组,超出 + 上限的写明。只读、不发起(和发布之后真的召回走同一份计划,规模一致)。""" + from app.api.settings import ledger_settings + + plan = await build_plan(session, workflow_id, int(version)) + limit = int((await ledger_settings(session))["recall_limit"]) + found = plan.found or ledger_uses.Found(uses=[]) + per_version = [] + for v in sorted({*found.versions(), *plan.version_problem}): + if plan.version_problem.get(v) == NOT_CALIBER: + continue + sub = ledger_uses.Found(uses=[u for u in found.uses if u.version == v], apps={v: found.apps.get(v, [])}) + counts = ledger_uses.summarize(sub) + counts["exports"] = await ledger_uses.exports_count(session, [u.run_id for u in sub.uses]) + per_version.append({"version": v, **counts, **({"problem": plan.version_problem[v]} + if v in plan.version_problem else {})}) + todo = len(plan.todo()) + done = sum(1 for g in plan.groups if g.run is not None) + auto_left = max(0, limit - plan.auto_launched) + will = min(todo, auto_left) + return { + "workflow_id": workflow_id, "version": int(version), "limit": limit, "caliber": bool(plan.pairs), + "problem": plan.problem, "versions": per_version, "groups": len(plan.groups), + "recomputable": todo + done, "not_recomputable": sum(1 for g in plan.groups if g.problem is not None), + "already": done, "will_recompute": will, "over_limit": max(0, todo - will), + "truncated": found.truncated, + "reasons": _reasons(plan), + } + + +def _reasons(plan: Plan) -> list[dict[str, Any]]: + """不能重算的原因各几组(同一句话合在一起)。""" + by: dict[str, int] = {} + for group in plan.groups: + if group.problem: + by[group.problem] = by.get(group.problem, 0) + 1 + return [{"reason": r, "groups": n} for r, n in sorted(by.items(), key=lambda x: (-x[1], x[0]))] + + +async def outcome_of(session: Any, run: Run) -> tuple[str, dict[str, Any]]: + """一次召回运行的结论:(changed / unchanged / failed / running, 收尾时记下的那一条 ledger.recalled 的内容)。""" + data = await _settled(session, run.id) + if data is not None: + if data.get("outcome") == "failed": + return "failed", data + return ("changed" if data.get("changed") else "unchanged"), data + if run.status in ("failed", "cancelled"): + return "failed", {"error": run.error} + return "running", {} + + +def _group_state(group: Group) -> tuple[str, str | None]: + """没读收尾记录之前的状态:不能重算、待算、发起被拒(照样算待算,原因另写)、算过(具体结论看 outcome_of)。""" + if group.run is not None: + return "running", None + if group.problem is not None: + return "not_recomputable", group.problem + return "pending", group.refused + + +async def batches(session: Any, workflow_id: str, version: int, *, principal: Any = None) -> dict[str, Any]: + """版本历史里「这一版的召回」:每组的结论(数变了 / 没变 / 无法重算:原因 / 重算失败 / 还在算 / 未重算),「另有 M 组 + 未重算」和能不能接着算。参数是遮过的(不给使用者看遮罩值:这个接口只给搭建者,还是照规矩遮)。""" + from app.api.settings import LEDGER_DEFAULTS, ledger_settings + + plan = await build_plan(session, workflow_id, int(version)) + limit = int((await ledger_settings(session))["recall_limit"]) + counts = dict.fromkeys(OUTCOMES, 0) + items: list[dict[str, Any]] = [] + for group in plan.groups: + state, reason = _group_state(group) + extra: dict[str, Any] = {} + if group.run is not None: + state, data = await outcome_of(session, group.run) + if state == "failed": + reason = RUN_FAILED.format(reason=str(data.get("error") or group.run.error or "运行没有成功结束")[:200]) + extra = {"run_id": group.run.id, "batch": (group.run.origin or {}).get("batch"), + "tasks": len(data.get("tasks") or []) if data else 0, + "changed_metrics": [m.get("name") for m in data.get("metrics") or [] if m.get("changed")] + if data else []} + counts[state] += 1 + kinds: dict[str, int] = {} + for use in group.uses: + kinds[use.kind] = kinds.get(use.kind, 0) + 1 + items.append({"id": group.id, "from_version": group.old_version, "to_version": group.new_version, + "binding": [{"field": k, "value": v} for k, v in sorted(group.binding.items())], + "uses": [{"kind": k, "kind_text": ledger_uses.KIND_TEXT.get(k, k), "count": n} + for k, n in sorted(kinds.items())], + "latest": group.latest.isoformat() if group.latest else None, + "state": state, **({"reason": reason} if reason else {}), **extra}) + pending = counts["pending"] + return {"workflow_id": workflow_id, "workflow_name": plan.workflow_name, "version": int(version), "limit": limit, + "batch_size": limit or int(LEDGER_DEFAULTS["recall_limit"]), "problem": plan.problem, + "caliber": bool(plan.pairs) or bool(plan.groups), "counts": counts, "groups": items, + "remaining": pending, "can_continue": pending > 0 and plan.problem is None, + "truncated": bool(plan.found and plan.found.truncated)} + + +async def version_recall_counts(session: Any, workflow_id: str, version: int) -> dict[str, Any]: + """版本用量里的召回:以这一版为新版本召回过几组(其中数变了几组),这一版作为旧版本被召回过几组。""" + as_new = await recall_runs(session, workflow_id, version) + changed = 0 + for run in as_new: + state, _ = await outcome_of(session, run) + changed += state == "changed" + as_old = int(await session.scalar(select(func.count()).select_from(Run).where( + Run.origin_kind == ORIGIN, func.json_extract(Run.origin, "$.workflow_id") == workflow_id, + func.json_extract(Run.origin, "$.from_version") == int(version), + func.coalesce(func.json_extract(Run.origin, "$.probe"), 0) == 0)) or 0) + return {"groups": len(as_new), "changed": changed, "recalled_from": as_old} + + +# ========================================================================== +# 收尾:数变没变、开待办或记账 +# ========================================================================== + + +async def _settled(session: Any, run_id: str) -> dict[str, Any] | None: + """召回运行上收尾时记的 ledger.recalled(role=recall);没收过尾为 None。""" + rows = (await session.execute(select(RunEvent.data).where( + RunEvent.run_id == run_id, RunEvent.type == EventType.LEDGER_RECALLED).order_by(RunEvent.seq.desc()))).scalars() + for data in rows: + if isinstance(data, dict) and data.get("role") == "recall": + return data + return None + + +async def card_values(run_id: str) -> dict[str, dict[str, Any]]: + """召回运行封存范围内的召回卡:{指标 id: 指标}(value、rendered……)。读不出为空。""" + from app.api.evidence import _sealed, _sealed_cards + from app.ledger import derive + + sealed = await _sealed(run_id) + for node, _, card in _sealed_cards(sealed): + if node == derive.RECALL_CARD: + return {str(m.get("id")): m for m in card.get("metrics") or [] if isinstance(m, dict)} + return {} + + +def compare(meta: Sequence[Mapping[str, Any]], values: Mapping[str, Mapping[str, Any]]) -> list[dict[str, Any]]: + """每个比较的旧值、新值、两版之差,和数变没变:按指标的单位、小数位渲染的旧值和新值不同就是变了(召回卡上的渲染, + 同报告里显示的那个字)。只读召回卡,不在这里算差值。""" + out = [] + for item in meta: + k = item.get("k") + old, new, diff = (values.get(f"{p}_{k}") or {} for p in ("old", "new", "diff")) + changed = str(old.get("rendered")) != str(new.get("rendered")) + out.append({"k": k, "card": item.get("card"), "metric": item.get("metric"), "name": item.get("name"), + "unit": item.get("unit"), "old": old.get("value"), "new": new.get("value"), + "diff": diff.get("value"), "old_text": old.get("rendered"), "new_text": new.get("rendered"), + "diff_text": diff.get("rendered"), "changed": changed}) + return out + + +_settling: set[str] = set() +_background: set[asyncio.Task[Any]] = set() + + +def spawn(coro: Any) -> None: + """在后台做(召回是辅助步骤:出错只记日志)。""" + task = asyncio.create_task(_guarded(coro)) + _background.add(task) + task.add_done_callback(_background.discard) + + +async def _guarded(coro: Any) -> None: + try: + await coro + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 - 辅助步骤 + logger.exception("口径召回的后台任务出错") + + +def schedule_settle(run_id: str) -> None: + """召回运行结束了(runner 收尾时调):在后台收尾。""" + spawn(settle(run_id)) + + +async def _mark(run_id: str, data: dict[str, Any]) -> bool: + from app.engine.run_append import append_events + + return await append_events(run_id, [(EventType.LEDGER_RECALLED, None, data)], require="sealed") is not None + + +async def settle(run_id: str) -> str | None: + """一次召回运行结束之后:数变了开 recall 待办、数没变在原答案上记 ledger.recalled,最后在召回运行上记结论。返回结论 + (changed / unchanged / failed),不是召回运行、还没结束、收过尾的为 None。幂等:待办按 correlation_key 认、原答案上的 + 记账按召回运行认,中途断了再收一次不会多开。""" + if run_id in _settling: + return None + _settling.add(run_id) + try: + async with SessionLocal() as session: + run = await session.get(Run, run_id) + if run is None or run.origin_kind != ORIGIN or run.status not in ("succeeded", "failed", "cancelled") \ + or not run.manifest_hash: + return None + if await _settled(session, run_id) is not None: + return None + origin = run.origin if isinstance(run.origin, dict) else {} + if origin.get("probe"): + return await _settle_probe(run) + base = {"role": "recall", "workflow_id": origin.get("workflow_id"), "from_version": origin.get("from_version"), + "to_version": origin.get("to_version")} + if run.status != "succeeded": + await _mark(run_id, {**base, "outcome": "failed", "changed": None, + "error": str(run.error or "运行没有成功结束")[:300]}) + return "failed" + results = compare(origin.get("metrics") or [], await card_values(run_id)) + changed = any(r["changed"] for r in results) + answers = list(dict.fromkeys(str(u.get("run_id")) for u in origin.get("uses") or [] if u.get("run_id"))) + tasks: list[str] = [] + if changed: + tasks = await open_tasks(run, reason="caliber") + else: + for answer in answers: + await _mark_answer(answer, run) + await _mark(run_id, {**base, "outcome": "changed" if changed else "unchanged", "changed": changed, + "answers": len(answers), "tasks": tasks, + "metrics": [{"k": r["k"], "name": r["name"], "changed": r["changed"]} for r in results]}) + return "changed" if changed else "unchanged" + finally: + _settling.discard(run_id) + + +async def _mark_answer(answer_id: str, recall_run: Run) -> None: + """数没变:在原答案的运行上记一条 ledger.recalled(已按 vN 核对,不影响)。这次召回记过的不再记。""" + origin = recall_run.origin if isinstance(recall_run.origin, dict) else {} + async with SessionLocal() as session: + rows = (await session.execute(select(RunEvent.data).where( + RunEvent.run_id == answer_id, RunEvent.type == EventType.LEDGER_RECALLED))).scalars() + if any(isinstance(d, dict) and d.get("recall_run") == recall_run.id for d in rows): + return + await _mark(answer_id, {"role": "answer", "changed": False, "recall_run": recall_run.id, + "workflow_id": origin.get("workflow_id"), "from_version": origin.get("from_version"), + "to_version": origin.get("to_version")}) + + +# ========================================================================== +# 待办 +# ========================================================================== + + +async def _owner_targets(session: Any, owners: Iterable[tuple[str, str]]) -> list[tuple[str, str]]: + """答案的主人 → 待办派给谁:开登录时每个主人一份(组照留,开待办时展开;服务账号不派),关登录时派给任何人。""" + from app.auth import current_mode + from app.process.tasks import _service_accounts + + if current_mode() == "off": + return [("anyone", "")] + owners = list(dict.fromkeys((k, n) for k, n in owners if k in ("user", "group") and n)) + service = await _service_accounts(session, [n for k, n in owners if k == "user"]) + out = [(k, n) for k, n in owners if not (k == "user" and n in service)] + return out or [("anyone", "")] + + +def _uses_of(origin: Mapping[str, Any], owner: tuple[str, str]) -> list[dict[str, Any]]: + """这条待办列哪几个答案:派给任何人的列全部,派给某人(组)的只列他是主人的。""" + items = [u for u in origin.get("uses") or [] if isinstance(u, dict)] + if owner[0] == "anyone": + return items + return [u for u in items if [owner[0], owner[1]] in (u.get("owners") or [])] + + +def correlation(run_id: str, owner: tuple[str, str], reason: str = "caliber", extra: str = "") -> str: + """recall 待办的 correlation_key:来由:运行[:附加]:派给的种类:名字。同一条按它认,最后两段是派给谁(_owner_of_key)。""" + return ":".join([reason, run_id, *([extra] if extra else []), owner[0], owner[1]]) + + +async def open_tasks(run: Run, *, reason: str, owners: Iterable[tuple[str, str]] | None = None, + title: str | None = None, extra: str = "") -> list[str]: + """给答案的主人开 recall 待办(挂在 run 上)。owners 不给就按召回运行 origin 里各答案的主人。按 correlation_key 认同一条, + 开过的不再开。提交之后推通知。返回这次新开的待办 id。""" + from app.process import tasks as process_tasks + + origin = run.origin if isinstance(run.origin, dict) else {} + async with SessionLocal() as session: + if owners is None: + owners = [tuple(o) for u in origin.get("uses") or [] for o in u.get("owners") or [] if len(o) == 2] + targets = await _owner_targets(session, owners) + name = await ledger_uses.workflow_name(session, str(origin.get("workflow_id") or "")) + if title is None: + title = CHANGED_TITLE.format(name=name or "口径", version=origin.get("to_version")) + opened: list[str] = [] + for owner in targets: + key = correlation(run.id, owner, reason, extra) + if await session.scalar(select(process_tasks.Task.id).where(process_tasks.Task.correlation_key == key)): + continue + task = await process_tasks.open_recall(session, run=run, title=title, owner=owner, correlation_key=key) + opened.append(task.id) + await session.commit() + for task_id in opened: + await process_tasks.announce(task_id) + return opened + + +def _owner_of_key(key: str) -> tuple[str, str]: + """correlation_key 的最后两段是派给谁(见 correlation)。""" + parts = (key or "").split(":") + return (parts[-2], parts[-1]) if len(parts) >= 4 else ("anyone", "") + + +async def task_payload(session: Any, task: Any, principal: Any) -> dict[str, Any]: + """recall 待办给处理人看的内容(待办箱、待办详情的 payload,process/tasks._describe 调):旧口径的值、新口径的值、两版之差 + 和原答案当时的值(每个都带出处:哪次运行、哪张卡、哪个指标,点得开),这一组的参数,能做的动作。使用者看到的值照样 + 遮罩(按那次运行查到过的遮罩值,同账本接口)。""" + from app.api.evidence import _sealed + from app.ledger.view import _hider + + run = await session.get(Run, task.run_id) + origin = run.origin if run is not None and isinstance(run.origin, dict) else {} + key = task.correlation_key or "" + reason = key.split(":", 1)[0] if key.split(":", 1)[0] in REASONS else "caliber" + base: dict[str, Any] = {"kind": TASK_KIND, "reason": reason, "recall_run": task.run_id, + "workflow_id": origin.get("workflow_id"), "from_version": origin.get("from_version"), + "to_version": origin.get("to_version")} + if origin.get("workflow_id"): + base["workflow_name"] = await ledger_uses.workflow_name(session, str(origin["workflow_id"])) + pending = task.status == "pending" + if reason != "caliber": + return {**base, "basis": await _basis_of(session, task, run, reason), + "actions": [{"decision": "ack", "available": pending}]} + builder = getattr(principal, "mode", "off") == "off" or principal.at_least("builder") + hide = (await _hider(await _sealed(task.run_id))).hide if not builder and run is not None else (lambda _v: False) + values = await card_values(task.run_id) if run is not None else {} + metrics = [] + for item in compare(origin.get("metrics") or [], values): + cells = {} + for side in ("old", "new", "diff"): + value, text = item[side], item[f"{side}_text"] + masked = value is not None and hide(value) + cells[side] = {"value": None if masked else value, "text": "已遮罩" if masked else text, + "metric": f"{side}_{item['k']}", "masked": masked} + metrics.append({"k": item["k"], "name": item["name"], "unit": item["unit"], "changed": item["changed"], + "card": item["card"], "metric": item["metric"], **cells}) + answers = [] + for use in _uses_of(origin, _owner_of_key(key)): + answers.append({**{k: use.get(k) for k in ("kind", "run_id", "version", "conversation_id", "turn_id", + "schedule_id", "app_id", "at", "via_workflow", "via_version") + if use.get(k) is not None}, + "kind_text": ledger_uses.KIND_TEXT.get(str(use.get("kind")), str(use.get("kind"))), + "original": await original_values(use, origin.get("metrics") or [], hide_for=not builder)}) + binding = [{"field": k, "value": None if hide(v) else v, "masked": bool(hide(v))} + for k, v in sorted((origin.get("binding") or {}).items())] + actions = [await reissue_action(session, task, principal, origin), {"decision": "ack", "available": pending}] + return {**base, "binding": binding, "metrics": metrics, "answers": answers, "actions": actions, + "outcome": task.outcome} + + +async def _basis_of(session: Any, task: Any, run: Run | None, reason: str) -> dict[str, Any]: + """目录、表结构两种 recall 待办说的是什么:目录的读答案运行上那一条 ledger.basis_changed(correlation_key 里记着它的序号), + 表结构的读试算运行的 origin 和它为什么没算出来。""" + if reason == "structure": + origin = run.origin if run is not None and isinstance(run.origin, dict) else {} + return {"workflow_id": origin.get("workflow_id"), "version": origin.get("version"), "table": origin.get("table"), + "column": origin.get("column"), "change_kind": origin.get("change_kind"), "change": origin.get("change"), + "source_id": origin.get("source_id"), "error": (run.error if run is not None else None), + "probe_run": task.run_id} + parts = (task.correlation_key or "").split(":") + seq = int(parts[2]) if len(parts) >= 5 and parts[2].isdigit() else None + data = await session.scalar(select(RunEvent.data).where( + RunEvent.run_id == task.run_id, RunEvent.type == EventType.LEDGER_BASIS_CHANGED, RunEvent.seq == seq)) \ + if seq is not None else None + data = data if isinstance(data, dict) else {} + return {k: data.get(k) for k in ("kind", "source_id", "source", "table", "items", "new_errors", "checks", + "catalog_version") if data.get(k) is not None} + + +async def original_values(use: Mapping[str, Any], meta: Sequence[Mapping[str, Any]], *, + hide_for: bool) -> list[dict[str, Any]]: + """原答案当时的值(每个比较一项):正式运行读它封存的口径卡(固定了这一版的读转抄来的那张);问数回答读等价的那条查询 + 快照里那一格(口径卡的指标就是取数第一行某一列时,按列映射找这次的列)。读不出、原答案里没有单独这个数的为空,写为什么。 + hide_for:给使用者看,按那次运行查到过的遮罩值遮。""" + from app.api.evidence import _sealed, _sealed_cards + from app.ledger.view import _hider + + run_id = str(use.get("run_id") or "") + sealed = await _sealed(run_id) + hide = (await _hider(sealed)).hide if hide_for else (lambda _v: False) + out: list[dict[str, Any]] = [] + if use.get("kind") != "answer": + cards = {node: card for node, _, card in _sealed_cards(sealed)} + for item in meta: + card = _card_for(sealed, cards, str(item.get("card"))) + metric = next((m for m in (card or {}).get("metrics") or [] if m.get("id") == item.get("metric")), None) + out.append(_cell(item, metric.get("value") if metric else None, metric.get("rendered") if metric else None, + hide, node=_card_node(sealed, cards, str(item.get("card"))), metric=item.get("metric"), + missing=None if metric else "原答案里没有这个指标")) + return out + import ast + + from app.core.artifact_store import load + from app.engine.evidence import render_number + from app.engine.expressions import cell_parts + from app.ledger import store + + recorded = await store.latest(run_id) + content = recorded.content if recorded is not None else None + query = next((q for q in (content or {}).get("queries") or [] if q.get("artifact") == use.get("query")), None) + cand = next((c for c in (query or {}).get("candidates") or [] if c.get("status") == "proven" + and c.get("node_id") == use.get("node_id")), None) + try: + snap = load(str(use.get("query") or "")) if use.get("query") else None + except ValueError: + snap = None + columns = [str(c) for c in (snap or {}).get("columns") or []] if isinstance(snap, dict) else [] + rows = (snap or {}).get("rows") if isinstance(snap, dict) else None + for item in meta: + value = text = missing = None + expression = str(item.get("expression") or "") + parts = cell_parts(expression.strip()) if expression else None + column = None + if parts is not None and parts[0] == f"nodes.{use.get('node_id')}" and parts[1].strip() == "0": + try: + column = ast.literal_eval(parts[2]) + except (ValueError, SyntaxError): + column = None + ours = next((m.get("ours") for m in (cand or {}).get("columns") or [] if m.get("caliber") == column), None) \ + if isinstance(column, str) else None + if ours in columns and isinstance(rows, list) and rows and isinstance(rows[0], (list, tuple)): + value = rows[0][columns.index(ours)] + try: + text = render_number(value, unit=str(item.get("unit") or ""), decimals=item.get("decimals"), + fmt=item.get("format")) + except Exception: # noqa: BLE001 - 渲染不出就写原值 + text = str(value) + else: + missing = "原答案里没有单独的这个数" + out.append(_cell(item, value, text, hide, query=use.get("query"), column=ours, missing=missing)) + return out + + +def _card_for(sealed: Any, cards: Mapping[str, Any], card_id: str) -> Mapping[str, Any] | None: + node = _card_node(sealed, cards, card_id) + return cards.get(node) if node else None + + +def _card_node(sealed: Any, cards: Mapping[str, Any], card_id: str) -> str | None: + """正式运行里这张口径卡的节点:同 id 的(W 自己的运行),或者 caliber_from 固定到它的那张(固定了这一版的工作流)。""" + if card_id in cards: + return card_id + for node in (sealed.graph or {}).get("nodes") or []: + cfg = (node.get("data") or {}).get("config") if isinstance(node, dict) and isinstance(node.get("data"), dict) \ + else None + ref = cfg.get("caliber_from") if isinstance(cfg, dict) else None + if isinstance(ref, dict) and ref.get("node_id") == card_id and node.get("id") in cards: + return str(node["id"]) + return None + + +def _cell(item: Mapping[str, Any], value: Any, text: Any, hide: Any, *, missing: str | None = None, + **where: Any) -> dict[str, Any]: + masked = value is not None and bool(hide(value)) + out = {"k": item.get("k"), "value": None if masked else value, "text": "已遮罩" if masked else text, + "masked": masked, **{k: v for k, v in where.items() if v is not None}} + if missing: + out["missing"] = missing + return out + + +# ========================================================================== +# 「按新版重新出具」「知道了」 +# ========================================================================== + + +async def _reissue_plan(session: Any, task: Any, principal: Any, origin: Mapping[str, Any]) -> Any: + """按新版重新出具的打算(走按口径出具同一套判法,ledger/issue.check_target:已发布未退役、没有人工节点和写入、参数过 + 校验、使用者只限有权使用的应用固定着的那一版、每日上限)。来源记成这条待办列的第一个答案。""" + from app.ledger import issue as ledger_issue + from app.ledger.labels import caliber_key + + workflow_id, version = str(origin.get("workflow_id") or ""), int(origin.get("to_version") or 0) + plan = ledger_issue.Plan(run_id=task.run_id, workflow_id=workflow_id, version=version, + caliber=caliber_key(workflow_id, version), how="recall", + binding=dict(origin.get("binding") or {})) + workflow = await session.get(Workflow, workflow_id) + plan.workflow_name = workflow.name if workflow is not None else "" + answers = _uses_of(origin, _owner_of_key(task.correlation_key or "")) + source = next((u["run_id"] for u in answers if u.get("kind") == "answer"), None) \ + or next((u["run_id"] for u in answers), None) or task.run_id + await ledger_issue.check_target(session, plan, workflow, principal=principal, source_run=str(source)) + return plan, str(source) + + +async def reissue_action(session: Any, task: Any, principal: Any, origin: Mapping[str, Any]) -> dict[str, Any]: + """待办里「按新版重新出具」这一项:能不能、不能的原因、已经出具过的那一次。""" + out: dict[str, Any] = {"decision": "reissue", "available": False} + if task.status != "pending": + issued = (task.outcome or {}).get("issued_run") + return {**out, **({"issued_run": issued} if issued else {})} + plan, _ = await _reissue_plan(session, task, principal, origin) + out["available"] = plan.reason is None + if plan.reason: + out["reason"] = plan.reason + if plan.existing: + out["issued_run"] = plan.existing + return out + + +async def reissue(task_id: str, principal: Any) -> Run: + """按新版重新出具:经 launch_run 发起一次正式运行(来源 ledger_issue,origin 记来源答案、召回运行、这条待办)。同一个来源、 + 同一版、同一组参数出具过的交回那一次。不能出具抛 RecallRefused。""" + from app.engine.launch import LaunchRefused, launch_run + from app.ledger import issue as ledger_issue + from app.process.tasks import Task + + async with SessionLocal() as session: + task = await session.get(Task, task_id) + if task is None: + raise RecallRefused(404, "待办不存在") + run = await session.get(Run, task.run_id) + origin = run.origin if run is not None and isinstance(run.origin, dict) else {} + plan, source = await _reissue_plan(session, task, principal, origin) + if plan.reason is not None: + raise RecallRefused(plan.status, plan.reason, plan.code) + if plan.existing is not None: + return await session.get(Run, plan.existing) + from app.apps.resolve import input_key + + try: + return await launch_run( + session, workflow_id=plan.workflow_id, run_class="formal", version=plan.version, + input_payload=plan.inputs, actor=principal.username, origin_kind=ledger_issue.ORIGIN, + origin={"source_run": source, "recall_run": task.run_id, "task_id": task_id, "caliber": plan.caliber, + "how": "recall", "binding_hash": input_key(plan.binding), "auto": False, + **({"granted_by_app": plan.granted_by_app} if plan.granted_by_app else {})}, + expect_hash=plan.expect_hash) + except LaunchRefused as e: + raise RecallRefused(e.status_code, e.message, e.code or REFUSED) from e + + +# ========================================================================== +# 二、目录事实被驳回或修改 +# ========================================================================== + + +def fact_changes(before: Mapping[str, Any] | None, after: Mapping[str, Any] | None) -> list[dict[str, Any]]: + """这次写入改动了的影响数字的项:写入前看得到(没驳回)、写入后被驳回、删掉或改了值的。{path, label, change}。纯函数。""" + from app.data import catalog + + old, new = catalog._slots(before), catalog._slots(after) + out: list[dict[str, Any]] = [] + for key, item in old.items(): + if item.get("status") == "rejected": + continue + fact = key[0] == "r" or (key[0], key[-1]) in FACT_SLOTS + if not fact: + continue + now = new.get(key) + if now is None: + change = "removed" + elif now.get("status") == "rejected": + change = "rejected" + elif catalog._value_of(key, now) != catalog._value_of(key, item): + change = "changed" + else: + continue + from app.data.catalog_review import slot_path + + out.append({"path": slot_path(key), "label": catalog.describe_slot(key), "change": change}) + return out + + +async def _explore_snapshots(session: Any, source: Any, table: str, *, before: datetime | None = None + ) -> list[tuple[str, str]]: + """查过 source 这张表的探索运行(计入统计的、成功封存的)和那条查询快照:[(运行, 快照)],新的在前,最多 BASIS_MAX_RUNS + 次运行。表名按 name_key 比,表已经不在表结构里也认(表被删了正要找用过它的)。""" + from app.data.catalog import QUERY_SNAPSHOT_KIND + from app.data.names import name_key + from app.db.models import Artifact + from app.engine.origin import counted_runs + + want = name_key(table) + stmt = (select(Artifact.id, Artifact.meta, Run.id, Run.finished_at).join(Run, Run.id == Artifact.run_id) + .where(Artifact.kind == QUERY_SNAPSHOT_KIND, Run.run_class == "exploratory", Run.status == "succeeded", + Run.manifest_hash.is_not(None), counted_runs()).order_by(Run.finished_at.desc())) + out: list[tuple[str, str]] = [] + runs: set[str] = set() + cut = _aware(before) + for artifact, meta, run_id, finished in (await session.execute(stmt)).all(): + if cut is not None and (_aware(finished) is None or _aware(finished) > cut): + continue + meta = meta if isinstance(meta, dict) else {} + if meta.get("source_id") != source.id and meta.get("source") != source.name: + continue + if not any(name_key(str(t).rsplit(".", 1)[-1]) == want for t in meta.get("tables") or []): + continue + if run_id not in runs and len(runs) >= BASIS_MAX_RUNS: + continue + runs.add(run_id) + out.append((run_id, artifact)) + return out + + +def _frozen_of(snapshot_id: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + """查询快照 → (快照内容, 它当时冻结的表结构快照)。读不出为 None。""" + from app.core.artifact_store import load + + try: + snap = load(snapshot_id) + except ValueError: + return None, None + if not isinstance(snap, dict): + return None, None + try: + schema = load(str(snap.get("schema_artifact") or "")) if snap.get("schema_artifact") else None + except ValueError: + schema = None + return snap, schema if isinstance(schema, dict) else None + + +def _visible(frozen: Mapping[str, Any] | None, table: str, paths: Iterable[str]) -> list[str]: + """冻结的目录里这张表看得到这几项里的哪几项。""" + from app.data import catalog + from app.data.names import name_key + + entries = (frozen or {}).get("catalog") if isinstance((frozen or {}).get("catalog"), dict) else {} + notes = next((e.get("notes") for t, e in entries.items() if name_key(t) == name_key(table) + and isinstance(e, dict)), None) + slots = catalog._slots(notes if isinstance(notes, dict) else {}) + out = [] + for path in paths: + try: + key = catalog.parse_path(path) + except catalog.CatalogPathError: + continue + if key in slots and slots[key].get("status") != "rejected": + out.append(path) + return out + + +def new_errors(sql: str, frozen: Mapping[str, Any], current: Mapping[str, Mapping[str, Any]], + kind: str | None) -> list[dict[str, Any]]: + """这条 SQL 按新目录(current:现在的目录)跑 SQL 检查,比按冻结的目录多出来的错误级问题。纯计算。""" + from app.data.sqlcheck import SqlChecker, frozen_checker + + def key(c: Any) -> tuple[Any, ...]: + return (c.code, (c.table or "").lower(), (c.column or "").lower(), c.relation_id) + + old = frozen_checker(frozen, kind=kind) + before = {key(c) for c in (old.check(sql) if old is not None else []) if c.level == "error"} + cache = {"tables": frozen.get("tables") or {}, **({"schema": frozen["schema"]} if frozen.get("schema") else {})} + after = SqlChecker(kind=kind, schema_cache=cache, notes=dict(current), source_name=frozen.get("source")).check(sql) + return [{"code": c.code, "message": c.message, "table": c.table, **({"column": c.column} if c.column else {})} + for c in after if c.level == "error" and key(c) not in before] + + +async def _basis_marked(session: Any, run_id: str, match: Mapping[str, Any]) -> bool: + rows = (await session.execute(select(RunEvent.data).where( + RunEvent.run_id == run_id, RunEvent.type == EventType.LEDGER_BASIS_CHANGED))).scalars() + return any(isinstance(d, dict) and all(d.get(k) == v for k, v in match.items()) for d in rows) + + +async def _answer_owner(session: Any, run_id: str) -> list[tuple[str, str]]: + """一次探索运行(答案)的主人:对话的 created_by,没有的是发起人。""" + use = ledger_uses.Use(kind="answer", run_id=run_id, version=0, binding={}) + await ledger_uses._chat_owners(session, [use]) + return use.owners + + +async def catalog_changed(source_id: str, table: str, changes: Sequence[Mapping[str, Any]], *, + catalog_version: int | None, kind: str = "catalog", change_id: str | None = None) -> int: + """目录里这张表影响数字的几项被驳回或改了(见模块说明二):冻结目录里看得到其中某项的探索运行追加 ledger.basis_changed; + 按新目录多出错误级问题的开 recall 待办(reason=catalog)。返回标了几次运行。""" + from app.data import catalog + from app.db.models import DataSource + + paths = [str(c.get("path")) for c in changes if c.get("path")] + if not paths and kind == "catalog": + return 0 + async with SessionLocal() as session: + source = await session.get(DataSource, source_id) + if source is None: + return 0 + found = await _explore_snapshots(session, source, table) + current = {t: e.notes for t, e in (await catalog.read_catalog(session, source_id)).items()} + by_run: dict[str, list[tuple[dict[str, Any], dict[str, Any]]]] = {} + for run_id, snapshot in found: + snap, frozen = await asyncio.to_thread(_frozen_of, snapshot) + if snap is None or frozen is None: + continue + by_run.setdefault(run_id, []).append((snap, frozen)) + marked = 0 + labels = {str(c.get("path")): c for c in changes} + for run_id, items in by_run.items(): + seen = sorted({p for _, frozen in items for p in _visible(frozen, table, paths)}) if paths else [] + if paths and not seen: + continue + errors: list[dict[str, Any]] = [] + for snap, frozen in items: + if isinstance(snap.get("sql"), str): + errors += new_errors(snap["sql"], frozen, current, source.kind) + match = {"kind": kind, "source_id": source_id, "table": table, + **({"catalog_version": catalog_version} if catalog_version is not None else {}), + **({"change": change_id} if change_id else {})} + async with SessionLocal() as session: + if await _basis_marked(session, run_id, match): + continue + data = {**match, "source": source.name, + "items": [{"path": p, "label": labels.get(p, {}).get("label"), "change": labels.get(p, {}).get("change")} + for p in seen], "new_errors": len(errors), "checks": errors[:10]} + from app.engine.run_append import append_events + + written = await append_events(run_id, [(EventType.LEDGER_BASIS_CHANGED, None, data)], require="sealed") + if not written: + continue + marked += 1 + if errors: + await _open_basis_task(run_id, written[0].seq) + return marked + + +async def _open_basis_task(run_id: str, seq: int) -> list[str]: + """答案依据的目录有变、按新目录检查出了新的错误级问题:给答案的主人开一条 recall 待办(reason=catalog,挂在答案的运行 + 上,只有「知道了」)。说的是哪一条依据有变记在 correlation_key 里(那条 ledger.basis_changed 的序号,_basis_of 按它读)。""" + async with SessionLocal() as session: + run = await session.get(Run, run_id) + if run is None: + return [] + owners = await _answer_owner(session, run_id) + return await open_tasks(run, reason="catalog", owners=owners, title=CATALOG_TITLE, extra=str(seq)) + + +def after_review(source_id: str, table: str, before: Mapping[str, Any] | None, after: Mapping[str, Any] | None, + version: int | None) -> int: + """审阅写入之后(data/catalog、catalog_review 调):有影响数字的项被驳回或改了,在后台标依据有变。返回改了几项。 + 出错只记日志:审阅已经写进去了。""" + try: + changes = fact_changes(before, after) + if changes: + spawn(catalog_changed(source_id, table, changes, catalog_version=version)) + return len(changes) + except Exception: # noqa: BLE001 - 辅助步骤 + logger.exception("审阅写入后排队标依据有变时出错(%s · %s)", source_id, table) + return 0 + + +# ========================================================================== +# 三、表结构变动 +# ========================================================================== + +#: 「依据的表结构已变」认哪几种结构变化(表或列被删、列的类型变了) +STRUCTURE_KINDS = ("table_removed", "column_removed", "column_type") + + +def basis_kind(change: Mapping[str, Any]) -> str | None: + """一条变化记录算哪一种依据有变(triggers_recall 认的里面):structure / values / catalog_edit;别的结构变化(新加了列、 + 注释变了……)不动答案,为 None。""" + from app.data import source_changes + + if not source_changes.triggers_recall(change): + return None + if (change.get("resolution") or {}).get("catalog_version"): + return "catalog_edit" + if change.get("kind") in STRUCTURE_KINDS: + return "structure" + if change.get("kind") == source_changes.CODES_NEW: + return "values" + return None + + +async def process_change(change: Mapping[str, Any]) -> int: + """处理一条变化记录:用过这张表的探索运行标依据有变;目录有改动的(新码值、处理时改了目录)按新目录查 SQL;表结构变了的 + 让受影响的口径试算一次。返回标了几次运行。""" + kind = basis_kind(change) + if kind is None or not change.get("table"): + return 0 + if kind in ("values", "catalog_edit"): + return await catalog_changed(str(change["source_id"]), str(change["table"]), [], + catalog_version=(change.get("resolution") or {}).get("catalog_version"), + kind=kind, change_id=str(change.get("id"))) + marked = await _mark_structure(change) + await _probe_calibers(change) + return marked + + +async def _mark_structure(change: Mapping[str, Any]) -> int: + from app.db.models import DataSource + from app.engine.run_append import append_events + + async with SessionLocal() as session: + source = await session.get(DataSource, str(change.get("source_id"))) + if source is None: + return 0 + detected = change.get("detected_at") + at = datetime.fromisoformat(detected) if isinstance(detected, str) else None + found = await _explore_snapshots(session, source, str(change["table"]), before=at) + marked = 0 + for run_id in dict.fromkeys(r for r, _ in found): + match = {"kind": "structure", "change": change.get("id")} + async with SessionLocal() as session: + if await _basis_marked(session, run_id, match): + continue + data = {**match, "source_id": source.id, "source": source.name, "table": change.get("table"), + "column": change.get("column"), "change_kind": change.get("kind"), "seq": change.get("seq"), + "items": [{"label": str(change.get("kind")), "column": change.get("column")}]} + if await append_events(run_id, [(EventType.LEDGER_BASIS_CHANGED, None, data)], require="sealed") is not None: + marked += 1 + return marked + + +async def _probe_calibers(change: Mapping[str, Any]) -> list[str]: + """表结构变了:最新的已发布版本里查这张表的口径,按最近一次正式运行的输入(没有就按入口的缺省值)派生数据部分试算一次 + (召回运行,origin.probe)。同一条变化、同一版口径只试算一次。返回发起了的运行。""" + from app.data.names import name_key + from app.engine.launch import LaunchRefused, launch_run + from app.ledger import compose, derive + from app.ledger import match as ledger_match + from app.tools.datasource import READ_ONLY_OPTION + + table = name_key(str(change.get("table") or "").rsplit(".", 1)[-1]) + launched: list[str] = [] + async with SessionLocal() as session: + calibers = await ledger_match.load_calibers(session) + for caliber in calibers: + if not any(f.source == change.get("source_name") and table in f.tables for f in caliber.fetches): + continue + async with SessionLocal() as session: + done = await session.scalar(select(func.count()).select_from(Run).where( + Run.origin_kind == ORIGIN, func.json_extract(Run.origin, "$.probe") == 1, + func.json_extract(Run.origin, "$.workflow_id") == caliber.workflow_id, + func.json_extract(Run.origin, "$.version") == caliber.version, + func.json_extract(Run.origin, "$.change") == change.get("id"))) + if done: + continue + latest = (await session.execute(select(Run.input).where( + Run.workflow_id == caliber.workflow_id, Run.version == caliber.version, Run.run_class == "formal", + Run.status == "succeeded").order_by(Run.finished_at.desc()).limit(1))).scalar_one_or_none() + inputs_from = "formal" if latest else "defaults" + if not latest: + # 这一版还没有正式运行:退回最近一次等价于它的问数回答绑定出的参数,再没有才只用入口的缺省值 + found = await ledger_uses.find_uses(session, caliber.workflow_id, only=caliber.version) + answer = next((u for u in found.uses if u.kind == "answer"), None) + if answer is not None: + latest, inputs_from = dict(answer.binding), "answer" + for card in caliber.cards[:1]: + try: + pinned = await derive.load_pinned(session, caliber.workflow_id, caliber.version, card) + names = {str(f.get("name")) for f in pinned.entry_fields} + inputs = compose.check_inputs(pinned, {k: v for k, v in (latest or {}).items() if k in names}) + except (derive.NotDerivable, compose.ComposeRefused): + continue + probe = derive.caliber_probe(pinned) + origin = {"probe": 1, "workflow_id": caliber.workflow_id, "version": caliber.version, + "change": change.get("id"), "source_id": change.get("source_id"), "table": change.get("table"), + "column": change.get("column"), "change_kind": change.get("kind"), + "inputs_from": inputs_from, "derive": probe.origin} + try: + run = await launch_run(session, graph=probe.graph, input_payload=inputs, run_class="exploratory", + actor=None, origin_kind=ORIGIN, origin=origin, + options={READ_ONLY_OPTION: True}, + workflow_name=f"口径试算:{pinned.workflow_name} v{caliber.version}") + except LaunchRefused as e: + logger.info("表结构变了之后口径试算没能发起(%s v%s):%s", caliber.workflow_id, caliber.version, + e.message) + continue + launched.append(run.id) + return launched + + +async def _settle_probe(run: Run) -> str: + """试算运行结束:算出来了只记结论;没算出来给那一版口径的发布人开 recall 待办(reason=structure)。""" + from app.apps import versions as app_versions + + origin = run.origin if isinstance(run.origin, dict) else {} + ok = run.status == "succeeded" + tasks: list[str] = [] + if not ok: + async with SessionLocal() as session: + row = await app_versions.version_row(session, str(origin.get("workflow_id")), int(origin.get("version") or 0)) + name = await ledger_uses.workflow_name(session, str(origin.get("workflow_id") or "")) + publisher = getattr(row, "published_by", None) if row is not None else None + tasks = await open_tasks(run, reason="structure", owners=[("user", publisher)] if publisher else [], + title=STRUCTURE_TITLE.format(name=name or "口径", version=origin.get("version"))) + await _mark(run.id, {"role": "recall", "probe": True, "outcome": "unchanged" if ok else "failed", + "changed": None, "workflow_id": origin.get("workflow_id"), "version": origin.get("version"), + "tasks": tasks, **({} if ok else {"error": str(run.error or "")[:300]})}) + return "unchanged" if ok else "failed" + + +async def _cursor(session: Any) -> int: + from app.db.models import Setting + + row = await session.get(Setting, CURSOR_KEY) + value = (row.value or {}).get("seq") if row is not None and isinstance(row.value, dict) else None + return int(value) if isinstance(value, int) else 0 + + +async def _set_cursor(session: Any, seq: int) -> None: + from app.db.models import Setting + + row = await session.get(Setting, CURSOR_KEY) + if row is None: + session.add(Setting(key=CURSOR_KEY, value={"seq": int(seq)})) + else: + row.value = {"seq": int(seq)} + await session.commit() + + +_consuming: set[asyncio.AbstractEventLoop] = set() + + +async def consume_source_changes() -> int: + """按 seq 游标拉变化记录(data/source_changes.list_changes),逐条处理(process_change),游标前移。探查、剖析任务结束时 + 调。同一时刻只拉一份。返回处理了几条。""" + from app.data import source_changes + + if not automatic(): + return 0 + loop = asyncio.get_running_loop() + if loop in _consuming: + return 0 + _consuming.add(loop) + try: + count = 0 + while True: + async with SessionLocal() as session: + since = await _cursor(session) + batch = await source_changes.list_changes(session, since_seq=since, limit=200) + if not batch: + return count + for change in batch: + try: + if basis_kind(change) is not None: + await process_change(change) + count += 1 + except Exception: # noqa: BLE001 - 一条出错不挡后面的 + logger.exception("召回处理变化记录 #%s 出错", change.get("seq")) + async with SessionLocal() as session: + await _set_cursor(session, int(batch[-1]["seq"])) + finally: + _consuming.discard(loop) + + +# ========================================================================== +# 发布钩子、启动时接着做、测试用的 drain +# ========================================================================== + + +def automatic() -> bool: + """自动召回(发布后自动那一批、探查剖析之后拉表结构变化、启动时接着做)开没开:和调度循环同一个开关 + (AGENTLAB_SCHEDULER)。这几样都是在后台替人查业务库(召回运行、口径试算),测试、拷来的数据目录(检查脚本)不做—— + 同 ledger/match 的启动补比、定时任务。手动「接着算」、审阅之后标依据有变(只读应用库)不受它管。""" + from app.core.config import settings + + return bool(settings.scheduler) + + +def after_publish(workflow_id: str, version: int, actor: str | None) -> bool: + """工作流发布了第 version 版(api/workflows.commit_publish 提交之后):在后台按计划自动召回一批(automatic 关着时不做, + 版本历史里照样列出受影响的答案、能手动接着算)。不挡发布。返回排没排上。""" + if not automatic(): + return False + try: + spawn(launch(workflow_id, int(version), batch="auto", actor=actor)) + return True + except Exception: # noqa: BLE001 - 辅助步骤 + logger.exception("工作流 %s 发布 v%s 后排队召回出错", workflow_id, version) + return False + + +async def resume(*, days: int = RESUME_DAYS) -> dict[str, int]: + """启动时:最近 days 天发布过的版本,自动那一批没发完的接着发;结束了还没收尾的召回运行补上收尾;表结构变化的游标没有 + 时从现在开始(升级之前的变化不追)。""" + from app.db.models import WorkflowVersion + + since = utcnow() - timedelta(days=days) + async with SessionLocal() as session: + rows = (await session.execute(select(WorkflowVersion.workflow_id, WorkflowVersion.version, WorkflowVersion.published_by) + .where(WorkflowVersion.published_at >= since, + WorkflowVersion.level.is_not(None)))).all() + unsettled = [r for (r,) in (await session.execute(select(Run.id).where( + Run.origin_kind == ORIGIN, Run.status.in_(("succeeded", "failed", "cancelled")), + Run.manifest_hash.is_not(None)))).all()] + from app.db.models import Setting + + if await session.get(Setting, CURSOR_KEY) is None: + from app.db.models_sources import SourceChange + + top = int(await session.scalar(select(func.max(SourceChange.seq))) or 0) + await _set_cursor(session, top) + launched = 0 + for workflow_id, version, actor in rows: + launched += len(await launch(workflow_id, int(version), batch="auto", actor=actor)) + settled = 0 + for run_id in unsettled: + settled += await settle(run_id) is not None + return {"launched": launched, "settled": settled} + + +def start_resume() -> bool: + """启动时在后台接着做(engine/runner 的 setup 调)。和调度循环同一个开关(automatic):拷来的数据目录、测试不替原库补做 + 后台的事。""" + if not automatic(): + return False + spawn(resume()) + return True + + +def _mine() -> list[asyncio.Task[Any]]: + loop = asyncio.get_running_loop() + return [t for t in _background if t.get_loop() is loop] + + +async def drain() -> None: + """等这个事件循环里召回的后台任务都做完(测试用)。""" + while True: + live = [t for t in _mine() if not t.done()] + if not live: + return + await asyncio.gather(*live, return_exceptions=True) + + +async def shutdown() -> None: + tasks = _mine() + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + +__all__ = ["BASIS_KINDS", "BATCHES", "CURSOR_KEY", "OUTCOMES", "ORIGIN", "REASONS", "TASK_KIND", "Group", "Pair", "Plan", + "RecallRefused", "after_publish", "after_review", "automatic", "basis_kind", "batches", "build_graph", "build_plan", + "card_values", "catalog_changed", "compare", "consume_source_changes", "drain", "fact_changes", "launch", + "new_errors", "normalize", "open_tasks", "original_values", "outcome_of", "preview", "process_change", + "recall_card", "recall_runs", "reissue", "reissue_action", "resume", "schedule_settle", "settle", "shutdown", + "source_cards", "spawn", "start_resume", "task_payload", "version_recall_counts"] diff --git a/backend/app/ledger/stats.py b/backend/app/ledger/stats.py index b0f4bb8e..9f5e4832 100644 --- a/backend/app/ledger/stats.py +++ b/backend/app/ledger/stats.py @@ -17,8 +17,9 @@ 段对话的算重复的形状,给出这样的形状几个、有重复形状的运行占几成;问题聚类(app/chat/questions:全站唯一的聚类,只取问题 原文、不含 history、排除系统派生的运行)里 ≥ REPEAT_RUNS 个问题的类占几成。认证队列的转化(第 3 波,ledger/certify):固化成 口径草稿几份(origin.freeze 的工作流)、其中发布了几份、点了「不是口径」几项。 -- E4 召回:每个口径版本被用了多少次(比对里等价已证明的运行数、这一版的正式运行数)。每次发布召回了几组、其中数变了的比例要等 - 召回(第 4 波),这之前照实写没有数据,只给召回运行的次数。 +- E4 召回:每个口径版本被用了多少次(比对里等价已证明的运行数、这一版的正式运行数);每次发布(口径升到某一版)召回了几组、 + 收过尾的几组、其中数变了的几组和比例、重算失败的几组(ledger/recall:召回运行和它收尾时记的 ledger.recalled,第 4 波)。 + 按召回运行的发起时间筛窗口;表结构变了之后的口径试算不算召回。 """ from __future__ import annotations @@ -43,7 +44,6 @@ 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 @@ -294,7 +294,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, diff --git a/backend/app/ledger/uses.py b/backend/app/ledger/uses.py new file mode 100644 index 00000000..1bfdd825 --- /dev/null +++ b/backend/app/ledger/uses.py @@ -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"] diff --git a/backend/app/notify/service.py b/backend/app/notify/service.py index 8d0fea86..b8bfeac4 100644 --- a/backend/app/notify/service.py +++ b/backend/app/notify/service.py @@ -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,47 @@ 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。 + + 消息照模块说明的规矩(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) + reason = (task.correlation_key or "").split(":", 1)[0] + channels = await _channels(session, event, run, users) if reason == "caliber" else [] + 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: + answers = len(recall._uses_of(origin, recall._owner_of_key(task.correlation_key or ""))) + lines = [("状态", "待处理"), ("口径", f"「{name[:80]}」"), + ("版本", f"v{origin.get('from_version')} → v{origin.get('to_version')}"), ("答案", f"{answers} 个"), + ("召回", f"#{task.run_id[:6]}")] + message = {"event": event, "status": "pending", "ok": False, + "title": recall.CHANGED_TITLE.format(name=name[:80] or "口径", version=origin.get("to_version")), + "lines": lines, "links": [("去处理", f"{base}/apps/inbox/{task_id}")] if base else [], + "subject": {"task_id": task_id, "run_id": task.run_id, "workflow_id": origin.get("workflow_id")}} + pushed = await _push(channels, message, 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)) + + async def notify_run(run_id: str, event: str) -> int: """应用实例结束、失败(run.finished / run.failed)。只推应用发起的运行。 diff --git a/backend/app/process/tasks.py b/backend/app/process/tasks.py index e2ee4842..0080edb5 100644 --- a/backend/app/process/tasks.py +++ b/backend/app/process/tasks.py @@ -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, diff --git a/backend/tests/test_app_versions.py b/backend/tests/test_app_versions.py index a36765d9..a5039022 100644 --- a/backend/tests/test_app_versions.py +++ b/backend/tests/test_app_versions.py @@ -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)] diff --git a/backend/tests/test_ledger_api.py b/backend/tests/test_ledger_api.py index 24cc969a..5a5a2001 100644 --- a/backend/tests/test_ledger_api.py +++ b/backend/tests/test_ledger_api.py @@ -237,7 +237,8 @@ async def test_stats_count_what_the_fixtures_did(client, monkeypatch): 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): diff --git a/backend/tests/test_ledger_recall.py b/backend/tests/test_ledger_recall.py new file mode 100644 index 00000000..86c74aea --- /dev/null +++ b/backend/tests/test_ledger_recall.py @@ -0,0 +1,563 @@ +"""口径召回(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_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,答案的数变了" + 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} + # 再收一次尾不会重复记 + assert await recall.settle(done.id) is None + assert len(await events(a1.id, EventType.LEDGER_RECALLED)) == 1 + + +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 + + wf2 = await S.publish(name="周销售(要审批)") + await answer(monkeypatch) + human = S.caliber_graph(WITH_REFUNDED) + human["nodes"].insert(3, S.node("ok", "human", mode="approve", title="确认")) + await S.publish(graph=human, version=2, workflow_id=wf2) + async with SessionLocal() as session: + plan = await recall.build_plan(session, wf2, 2) + assert plan.groups and all("人工节点" in (g.problem or "") for g in plan.groups) + + +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 _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 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 + + +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 项)" diff --git a/backend/tests/test_notify_events.py b/backend/tests/test_notify_events.py index 4957bfb5..cc8ec89e 100644 --- a/backend/tests/test_notify_events.py +++ b/backend/tests/test_notify_events.py @@ -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: From 35583ba1d8c50d09e3ab7b79c0a6dea05fd8d09a Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 20:05:24 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E5=8F=A3=E5=BE=84=E5=8F=AC=E5=9B=9E?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=88W4-a=EF=BC=89=EF=BC=9A=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E7=94=A8=E9=87=8F=E5=8A=A0=E9=97=AE=E6=95=B0=E5=9B=9E?= =?UTF-8?q?=E7=AD=94=E5=92=8C=E5=8F=AC=E5=9B=9E=E3=80=81=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E9=A2=84=E5=91=8A=E5=8F=AC=E5=9B=9E=E8=A7=84?= =?UTF-8?q?=E6=A8=A1=E3=80=81=E7=89=88=E6=9C=AC=E5=8E=86=E5=8F=B2=E7=9C=8B?= =?UTF-8?q?=E5=8F=AC=E5=9B=9E=E6=89=B9=E6=AC=A1=E3=80=81recall=20=E5=BE=85?= =?UTF-8?q?=E5=8A=9E=E5=8D=A1=E3=80=81=E6=97=B6=E9=97=B4=E7=BA=BF=E8=AE=A4?= =?UTF-8?q?=E4=B8=A4=E4=B8=AA=E6=96=B0=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:前三波召回只有接口;这一波收口,口径升版之后谁的答案受影响、重算了几组、数变没变、怎么处置都要在界面上走得通。 - canvas/versionUsage:使用情况那一行加问数回答、导出、定时任务、召回(老服务端不给就不写) - canvas/RecallPanel(新):发布弹窗「召回」一行——旧版本被谁用过、按参数去重几组、发布后自动重算几组、超出上限几组(可在 版本历史接着算)、上限为 0 时只列不算、不能重算的原因;版本历史展开一版——每组结论(数变了 / 没变 / 无法重算:原因 / 重算失败 / 正在重算 / 未重算)、变了的指标、开了几条待办、召回运行链接,「另有 M 组未重算」+ 接着算 - run/RecallTask(新)+ TaskCard:recall 待办卡——旧口径、新口径、两版之差一行一个指标,每个数点得开召回卡的指标链;每个答案 当时的值点得开(正式运行的口径卡、问数回答的查询快照),问数回答链回对话;「按新版重新出具」(不能时停用并写服务端给的 原因)、「知道了」;遮罩值写已遮罩、点不开;目录有变、表结构变了两种只有「知道了」 - run/decode:ledger.recalled(已按 vN 核对,不影响;召回核对数变没变;试算结果)、ledger.basis_changed(依据有变、依据的 表结构已变);缺字段不猜 - 文案进 lib/termsRecall.ts;接口 api/recall.ts、形状 typesRecall.ts;TaskKind 加 recall - 检查:check-inbox 加 recall 卡两段,check-publish 加召回预告和召回批次两段(版本历史、发布弹窗都在这里测;check-versions 测的是上传表格的版本页,和召回无关),check-decode 加两个新事件的夹具,check-capabilities 的可订阅事件跟上 ledger.recall --- frontend/src/api/apps.ts | 9 +- frontend/src/api/client.ts | 3 + frontend/src/api/recall.ts | 23 +++ frontend/src/canvas/PublishDialog.tsx | 5 + frontend/src/canvas/RecallPanel.tsx | 182 +++++++++++++++++ frontend/src/canvas/VersionsSheet.tsx | 6 +- frontend/src/canvas/versionUsage.tsx | 18 +- frontend/src/lib/termsApp.ts | 1 + frontend/src/lib/termsRecall.ts | 152 +++++++++++++++ frontend/src/run/RecallTask.tsx | 270 ++++++++++++++++++++++++++ frontend/src/run/TaskCard.tsx | 15 +- frontend/src/run/decode.ts | 43 ++++ frontend/src/typesApp.ts | 8 +- frontend/src/typesRecall.ts | 176 +++++++++++++++++ scripts/check-capabilities.mjs | 14 +- scripts/check-decode.mjs | 46 +++++ scripts/check-inbox.mjs | 136 +++++++++++++ scripts/check-publish.mjs | 145 ++++++++++++++ 18 files changed, 1236 insertions(+), 16 deletions(-) create mode 100644 frontend/src/api/recall.ts create mode 100644 frontend/src/canvas/RecallPanel.tsx create mode 100644 frontend/src/lib/termsRecall.ts create mode 100644 frontend/src/run/RecallTask.tsx create mode 100644 frontend/src/typesRecall.ts diff --git a/frontend/src/api/apps.ts b/frontend/src/api/apps.ts index ecda1b52..be7813cd 100644 --- a/frontend/src/api/apps.ts +++ b/frontend/src/api/apps.ts @@ -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>() 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}`), } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d37f2e4b..c98c404d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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, diff --git a/frontend/src/api/recall.ts b/frontend/src/api/recall.ts new file mode 100644 index 00000000..448fd0a5 --- /dev/null +++ b/frontend/src/api/recall.ts @@ -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(`/workflows/${wf(workflowId)}/recall-preview${qs({ version })}`, opts), + batches: (workflowId: string, version: number, opts?: RequestOptions) => + get(`/ledger/recall/${wf(workflowId)}/${version}`, opts), + continueBatch: (workflowId: string, version: number) => + post(`/ledger/recall/${wf(workflowId)}/${version}/continue`), +} diff --git a/frontend/src/canvas/PublishDialog.tsx b/frontend/src/canvas/PublishDialog.tsx index 4168a95c..b0244401 100644 --- a/frontend/src/canvas/PublishDialog.tsx +++ b/frontend/src/canvas/PublishDialog.tsx @@ -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 }: { )} + {/* 口径召回的预告(next-round 02 做法 7):发布新版本之后用过旧版本的答案按参数分组重算,规模先摆出来 */} + {published != null && published !== workflow.version && ( + + )}
{signer.verified ? '发布人' : '署名'}
diff --git a/frontend/src/canvas/RecallPanel.tsx b/frontend/src/canvas/RecallPanel.tsx new file mode 100644 index 00000000..5898fc27 --- /dev/null +++ b/frontend/src/canvas/RecallPanel.tsx @@ -0,0 +1,182 @@ +/** + * 口径召回在画布上的两处(docs/design/next-round/02-口径账本.md 做法 7、8): + * - 发布弹窗的召回预告(RecallPreviewRow):把这一版发布成新版本之后,旧版本被多少问数回答、导出、定时任务、应用用过, + * 预计自动重算几组,超出上限的写明(GET /api/workflows/{id}/recall-preview)。和发布之后真的召回走同一份计划,规模一致; + * - 版本历史里口径升到这一版之后的召回批次(RecallBatches):每组的结论(数变了 / 没变 / 无法重算:原因 / 重算失败 / 正在 + * 重算 / 未重算),「另有 M 组未重算」和「接着算」(GET / POST /api/ledger/recall/{wf}/{v})。 + * + * 界面层不放逻辑:几组、能不能重算、为什么不能、能不能接着算都由服务端给。老服务端没有这两个接口(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(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 : ( +
+
{P.label}
+
{P.loading}
+
+ ) + } + 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 ( +
+
{P.label}
+
+ {!preview.groups ?
{P.none}
: ( + <> +
{P.uses(uses)}
+
+ {P.groups(preview.groups, preview.will_recompute)} + {preview.already > 0 && ({P.already(preview.already)})} +
+ {preview.limit === 0 &&
{P.zero}
} + {preview.limit > 0 && preview.over_limit > 0 && ( +
+ {P.over(preview.over_limit, preview.limit)} +
+ )} + {preview.not_recomputable > 0 && ( +
+ {P.notRecomputable(preview.not_recomputable)}:{preview.reasons.map((r) => r.reason).join(';')} +
+ )} + {preview.truncated &&
{P.truncated}
} +
{P.hint}
+ + )} +
+
+ ) +} + +const STATE_COLOR: Record = { + 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(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
{B.loading}
+ if (!data.groups.length) return null + const shown = all ? data.groups : data.groups.slice(0, SHOWN) + const summary = B.summary(data.counts) + return ( +
+
{B.title}
+ {summary &&
{summary}
} +
    + {shown.map((g) => )} +
+ {!all && data.groups.length > SHOWN && ( + + )} + {data.remaining > 0 && ( +
+
{B.remaining(data.remaining)}
+ {data.can_continue && ( + + )} +
+ )} + {data.truncated &&
{B.truncated}
} +
+ ) +} + +function GroupRow({ group }: { group: RecallGroup }) { + return ( +
  • +
    + + {B.state[group.state] ?? group.state} + + {B.from(group.from_version)} + {B.uses(group.uses)} + {group.latest && · {formatDateTime(group.latest)}} +
    +
    + {group.binding.length + ? group.binding.map((b) => {b.field}={String(b.value ?? '')}) + : {B.noBinding}} +
    + {group.reason && ( +
    + {B.reason(group.state, group.reason)} +
    + )} + {!!group.changed_metrics?.length &&
    {B.changedMetrics(group.changed_metrics)}
    } +
    + {!!group.tasks && {B.tasks(group.tasks)}} + {group.run_id && ( + + {B.openRun} + + )} +
    +
  • + ) +} diff --git a/frontend/src/canvas/VersionsSheet.tsx b/frontend/src/canvas/VersionsSheet.tsx index 31c98dd5..d53ecbb4 100644 --- a/frontend/src/canvas/VersionsSheet.tsx +++ b/frontend/src/canvas/VersionsSheet.tsx @@ -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 }: {
    {usage ? : …}
    + {/* 口径升到这一版之后的召回批次:每组的结论、「另有 M 组未重算」、接着算(next-round 02 做法 7) */} + {!version.retired_at && ( <> {builder && ( diff --git a/frontend/src/canvas/versionUsage.tsx b/frontend/src/canvas/versionUsage.tsx index 1d02084d..797a1a20 100644 --- a/frontend/src/canvas/versionUsage.tsx +++ b/frontend/src/canvas/versionUsage.tsx @@ -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 ( {T.apps(usage.apps.length)} @@ -47,6 +60,7 @@ export function VersionUsageLine({ usage }: { usage: VersionUsage }) { )} · {T.effects(usage.effects)} · {T.formalRuns(usage.formal_runs)} + {extra.length > 0 && · {extra.join(' · ')}} ) } diff --git a/frontend/src/lib/termsApp.ts b/frontend/src/lib/termsApp.ts index 43d29ef6..9f7fb8c4 100644 --- a/frontend/src/lib/termsApp.ts +++ b/frontend/src/lib/termsApp.ts @@ -77,6 +77,7 @@ export const TASK_KIND_LABEL: Record = { reconcile: '核实结果', signal: '等待外部系统', review: '审核个人应用', + recall: '口径召回', } export const TASK_STATUS_LABEL: Record = { diff --git a/frontend/src/lib/termsRecall.ts b/frontend/src/lib/termsRecall.ts new file mode 100644 index 00000000..879e7149 --- /dev/null +++ b/frontend/src/lib/termsRecall.ts @@ -0,0 +1,152 @@ +/** + * 口径召回的文案(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) => { + 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, + reason: (state: string, reason: string) => (state === 'not_recomputable' ? `无法重算:${reason}` : reason), + remaining: (count: number) => `另有 ${n(count)} 组未重算`, + 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: '查看出具的正式运行', + 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, + }, + + // ---- 时间线(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, + basisItems: (labels: string[]) => labels.join('、'), + newErrors: (count: number) => `按新目录检查新出现 ${n(count)} 条错误级问题`, + sub: '封存后追加,不在封存范围内', + }, +} diff --git a/frontend/src/run/RecallTask.tsx b/frontend/src/run/RecallTask.tsx new file mode 100644 index 00000000..a0b2b194 --- /dev/null +++ b/frontend/src/run/RecallTask.tsx @@ -0,0 +1,270 @@ +/** + * 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 + const [evidence, setEvidence] = useState(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 | null + + return ( +
    + {reason === 'caliber' && ( + <> +

    {T.intro(name, payload.from_version, payload.to_version)}

    + {!!payload.binding?.length && ( +
    + {T.binding}: + {payload.binding.map((b) => ( + + {b.field}={b.masked ? T.masked : String(b.value ?? '')} + + ))} +
    + )} + + +

    {T.how}

    + + )} + {reason === 'catalog' && } + {reason === 'structure' && } + + {!pending && outcome?.decision && ( +
    + {T.resolved(outcome.decision)} + {outcome.issued_run && } +
    + )} + {canAct && ( +
    + {reissue && ( + + )} + {ack && ( + + )} +
    + )} + {canAct && reissue && !reissue.available && reissue.reason && ( +
    + {reissue.reason} +
    + )} + {canAct && reissue?.issued_run && } + setEvidence(null)} /> +
    + ) +} + +/** 三个数(旧口径、新口径、两版之差)一行一个指标,每个都点得开召回卡上那个指标的出处链 */ +function Metrics({ payload, onOpen }: { payload: Partial; 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 ( +
    + + + + + {heads.map((h) => )} + + + + {metrics.map((m) => ( + + + {(['old', 'new', 'diff'] as const).map((side, i) => ( + + ))} + + ))} + +
    {T.metric}{h}
    + {m.name} + + {m.changed ? T.changed : T.same} + + + +
    +
    + ) +} + +/** 用到这份口径的答案:哪一种、什么时候、当时的值(点得开当时的出处)、去哪里看 */ +function Answers({ payload, onOpen }: { payload: Partial; 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 ( +
    +
    {T.answers}
    +
      + {answers.map((a) => ( +
    • +
      + {T.answerOf(a.kind_text, a.at ? formatDateTime(a.at) : null)} + +
      +
      + {a.original.map((v) => ( + + {names.get(v.k) ?? ''} · {T.original}: + {v.missing && !v.masked + ? {T.missing} + : ( + + )} + + ))} +
      +
    • + ))} +
    +
    + ) +} + +/** 去看这个答案:问数回答回到那段对话;别的(正式运行)给搭建者去记录页 */ +function AnswerLink({ answer }: { answer: RecallAnswer }) { + const builder = useAtLeast('builder') + if (answer.kind === 'answer' && answer.conversation_id) { + return ( + + {T.openTurn} + + ) + } + if (!builder) return null + return ( + + {T.openAnswer} + + ) +} + +function IssuedLink({ runId }: { runId: string }) { + return ( + + {T.issuedRun} + + ) +} + +function CatalogBasis({ payload }: { payload: Partial }) { + const basis = payload.basis ?? {} + const items = Array.isArray(basis.items) ? basis.items : [] + const checks = Array.isArray(basis.checks) ? basis.checks : [] + return ( +
    +

    {T.catalogIntro}

    + {items.length > 0 && ( +
    + {T.catalogItems}: + {items.map((i, k) => ( + + {k > 0 && '、'}{i.label ?? i.path}{i.change ? `(${T.change[i.change] ?? i.change})` : ''} + + ))} +
    + )} + {basis.new_errors != null &&
    {T.catalogChecks(basis.new_errors)}
    } + {checks.length > 0 && ( +
      + {checks.map((c, k) =>
    • {c.message}
    • )} +
    + )} +
    + ) +} + +function StructureBasis({ payload, name }: { payload: Partial; name: string }) { + const basis = payload.basis ?? {} + const builder = useAtLeast('builder') + return ( +
    +

    {T.structureIntro(name, basis.version)}

    + {basis.table &&
    {T.structureWhere(basis.table, basis.column)}
    } + {basis.error && ( +
    + {T.probeError}:{basis.error} +
    + )} + {builder && basis.probe_run && ( + + {T.openProbe} + + )} +
    + ) +} diff --git a/frontend/src/run/TaskCard.tsx b/frontend/src/run/TaskCard.tsx index 96098fd1..78484f99 100644 --- a/frontend/src/run/TaskCard.tsx +++ b/frontend/src/run/TaskCard.tsx @@ -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 = { 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 = void respond({ decision: 'reject' }, REVIEW_TASK_TEXT.rejected)} /> )} + {kind === 'recall' && ( + void respond({ decision }, + decision === 'reissue' ? RECALL_TEXT.task.doneReissue : RECALL_TEXT.task.doneAck)} /> + )} {showForm && (
    diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index 6e2eeeb7..7ac59c5b 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -12,6 +12,7 @@ import { evidenceTally, issuanceLabel, nodeTypeLabel, } from '../lib/terms' import { catalogPatchOf, patchBeforeText, patchValueText, patchWhere } from './catalogPatch' +import { 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,48 @@ 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.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.basis.catalog, + 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。一条事件就是一问一答,挂在顶层 diff --git a/frontend/src/typesApp.ts b/frontend/src/typesApp.ts index fbc45ca7..a5fa0217 100644 --- a/frontend/src/typesApp.ts +++ b/frontend/src/typesApp.ts @@ -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 note?: string /** 动作确认:确认卡上显示的哈希,原样带回 */ diff --git a/frontend/src/typesRecall.ts b/frontend/src/typesRecall.ts new file mode 100644 index 00000000..84428b4e --- /dev/null +++ b/frontend/src/typesRecall.ts @@ -0,0 +1,176 @@ +/** + * 口径召回的接口形状(服务端 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 + groups: RecallGroup[] + /** 还没重算的几组(「另有 M 组未重算」) */ + remaining: number + can_continue: boolean + 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 | null +} diff --git a/scripts/check-capabilities.mjs b/scripts/check-capabilities.mjs index 2e85afe6..840715a6 100644 --- a/scripts/check-capabilities.mjs +++ b/scripts/check-capabilities.mjs @@ -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(' | ')) }) diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index 77af3970..2d0ac338 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -1888,6 +1888,52 @@ 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.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('口径账本:问数据的助手选用已发布口径(use_caliber 操作)', async () => { // next-round 02 做法 4:取数节点和口径卡由服务端按那一版生成(ledger/compose.py),不另转 add_node。过程里一行说清选用了 // 哪一版、生成了几个节点;拒收的照实写原因(同样交回了模型去改) diff --git a/scripts/check-inbox.mjs b/scripts/check-inbox.mjs index 1fba2777..33031729 100644 --- a/scripts/check-inbox.mjs +++ b/scripts/check-inbox.mjs @@ -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,53 @@ 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 }] }, + }, + // 答案依据的目录有变、按新目录检查出了新的错误级问题:只有「知道了」 + 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: '指定复核人', @@ -403,6 +454,89 @@ 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 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.errors.length === 0 && p2.errors.length === 0, page.errors[0] ?? p2.errors[0] ?? '') +}) + await section('待办箱:列表和徽标', async () => { const page = await open({ writes: [] }) await page.goto(`${WEB}/apps?tab=inbox`) @@ -410,6 +544,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())) }) diff --git a/scripts/check-publish.mjs b/scripts/check-publish.mjs index c689c241..be84f74a 100644 --- a/scripts/check-publish.mjs +++ b/scripts/check-publish.mjs @@ -2075,6 +2075,151 @@ 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]) + 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) + check('没有运行时报错', errors.length === 0, errors.join(' | ')) +}) + // 开了登录:发布记的是登录的人(服务端写 published_by,不看署名),不给署名框、不说「未署名」;受管级只给管理员, // 搭建者那一档当场置灰、写明原因,不等点了发布才被拒(V1-21、V1-22)。受管工作流的新版本不论哪一档都只有管理员能发: // 降成「已发布」会让此后的正式运行不再过治理门禁,和以受管级发布同一级(Q8,服务端同样拦)——搭建者两档都锁住 From bbf6ebb79db867f08a8229bbf6b693d43229f03e Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 20:11:46 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E5=8F=AC=E5=9B=9E=E6=94=B6=E5=B0=BE?= =?UTF-8?q?=EF=BC=9A=E5=90=AF=E5=8A=A8=E6=97=B6=E5=8F=AA=E6=8E=A5=E7=9D=80?= =?UTF-8?q?=E5=81=9A=E5=BD=93=E5=89=8D=E5=8F=91=E5=B8=83=E7=89=88=E7=9A=84?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=89=B9=E6=AC=A1=E3=80=81=E8=A1=A5=E6=B5=8B?= =?UTF-8?q?=E9=87=8D=E5=90=AF=E5=90=8E=E4=BB=8E=E5=B7=B2=E6=9C=89=E5=8F=AC?= =?UTF-8?q?=E5=9B=9E=E8=BF=90=E8=A1=8C=E9=87=8D=E7=AE=97=E8=AE=A1=E5=88=92?= =?UTF-8?q?=EF=BC=9B=E6=97=B6=E9=97=B4=E7=BA=BF=E8=AE=A4=E4=B8=8D=E5=87=BA?= =?UTF-8?q?=E7=9A=84=E4=BE=9D=E6=8D=AE=E7=A7=8D=E7=B1=BB=E4=B8=8D=E7=8C=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ledger/recall.resume:最近发布、之后又发布了更新版本的不再接着做(新版本的召回本来就覆盖更早的所有版本) - 测试:重启前只发起了一组、也没收尾,重启后接着发完自动那一批、补上收尾照样开待办,再重启一次不再发起 - run/decode:ledger.basis_changed 认不出种类时写「答案的依据有变」,不当成目录事实变了;batches 的说明照实写参数原样给搭建者 --- backend/app/ledger/recall.py | 14 ++++++---- backend/tests/test_ledger_recall.py | 41 +++++++++++++++++++++++++++++ frontend/src/lib/termsRecall.ts | 2 ++ frontend/src/run/decode.ts | 2 +- scripts/check-decode.mjs | 3 ++- 5 files changed, 55 insertions(+), 7 deletions(-) diff --git a/backend/app/ledger/recall.py b/backend/app/ledger/recall.py index a9e736a0..bfce4cba 100644 --- a/backend/app/ledger/recall.py +++ b/backend/app/ledger/recall.py @@ -494,9 +494,10 @@ def _group_state(group: Group) -> tuple[str, str | None]: return "pending", group.refused -async def batches(session: Any, workflow_id: str, version: int, *, principal: Any = None) -> dict[str, Any]: +async def batches(session: Any, workflow_id: str, version: int) -> dict[str, Any]: """版本历史里「这一版的召回」:每组的结论(数变了 / 没变 / 无法重算:原因 / 重算失败 / 还在算 / 未重算),「另有 M 组 - 未重算」和能不能接着算。参数是遮过的(不给使用者看遮罩值:这个接口只给搭建者,还是照规矩遮)。""" + 未重算」和能不能接着算。接口只给搭建者(api/ledger 的 router):参数原样给,同他在召回运行的输入里读到的一样;使用者 + 看的是 recall 待办(task_payload,按遮罩换过)。""" from app.api.settings import LEDGER_DEFAULTS, ledger_settings plan = await build_plan(session, workflow_id, int(version)) @@ -1397,15 +1398,18 @@ def after_publish(workflow_id: str, version: int, actor: str | None) -> bool: async def resume(*, days: int = RESUME_DAYS) -> dict[str, int]: - """启动时:最近 days 天发布过的版本,自动那一批没发完的接着发;结束了还没收尾的召回运行补上收尾;表结构变化的游标没有 - 时从现在开始(升级之前的变化不追)。""" + """启动时:最近 days 天发布过、现在仍是当前发布版的版本,自动那一批没发完的接着发(之后又发布了更新的版本的不再接着做: + 新版本的召回本来就覆盖更早的所有版本);结束了还没收尾的召回运行补上收尾;表结构变化的游标没有时从现在开始(升级之前的 + 变化不追)。""" from app.db.models import WorkflowVersion since = utcnow() - timedelta(days=days) async with SessionLocal() as session: rows = (await session.execute(select(WorkflowVersion.workflow_id, WorkflowVersion.version, WorkflowVersion.published_by) + .join(Workflow, Workflow.id == WorkflowVersion.workflow_id) .where(WorkflowVersion.published_at >= since, - WorkflowVersion.level.is_not(None)))).all() + WorkflowVersion.level.is_not(None), + Workflow.published_version == WorkflowVersion.version))).all() unsettled = [r for (r,) in (await session.execute(select(Run.id).where( Run.origin_kind == ORIGIN, Run.status.in_(("succeeded", "failed", "cancelled")), Run.manifest_hash.is_not(None)))).all()] diff --git a/backend/tests/test_ledger_recall.py b/backend/tests/test_ledger_recall.py index 86c74aea..867d09d5 100644 --- a/backend/tests/test_ledger_recall.py +++ b/backend/tests/test_ledger_recall.py @@ -561,3 +561,44 @@ async def test_recall_graph_is_deterministic_and_events_have_offline_labels(monk 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, "再重启一次:都算过了,不再发起" diff --git a/frontend/src/lib/termsRecall.ts b/frontend/src/lib/termsRecall.ts index 879e7149..82b40be9 100644 --- a/frontend/src/lib/termsRecall.ts +++ b/frontend/src/lib/termsRecall.ts @@ -145,6 +145,8 @@ export const RECALL_TEXT = { values: '依据有变:出现了触及有效记录条件的新码值', catalog_edit: '依据有变:处理数据源变化时改了数据目录', } as Record, + /** 认不出是哪一种依据变了(缺字段、以后加的种类):只说依据有变,不猜 */ + basisGeneric: '答案的依据有变', basisItems: (labels: string[]) => labels.join('、'), newErrors: (count: number) => `按新目录检查新出现 ${n(count)} 条错误级问题`, sub: '封存后追加,不在封存范围内', diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index 7ac59c5b..c763383b 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -2452,7 +2452,7 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt 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.basis.catalog, + 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) : ''] diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index 2d0ac338..48483cf3 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -1927,7 +1927,8 @@ await section('口径召回:封存后的核对结论与依据有变(ledger.r .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 ?? ''}`)), + 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 }) From b3dcddb52e1fcd84a2e4e31c161c2e8f0be4bbac Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 22:16:58 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E5=8F=AC=E5=9B=9E=E5=A4=8D=E6=A0=B8?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=EF=BC=88=E5=90=8E=E7=AB=AF=EF=BC=89=EF=BC=9A?= =?UTF-8?q?=E4=BA=BA=E5=B7=A5=E8=8A=82=E7=82=B9=E4=B8=8D=E5=86=8D=E6=8C=A1?= =?UTF-8?q?=E5=8F=AC=E5=9B=9E=E3=80=81=E8=AF=BB=E4=B8=8D=E5=87=BA=E5=8F=AC?= =?UTF-8?q?=E5=9B=9E=E5=8D=A1=E8=AE=B0=E5=A4=B1=E8=B4=A5=E3=80=81=E6=97=A7?= =?UTF-8?q?=E7=89=88=E4=B8=8D=E5=86=8D=E6=8E=A5=E7=9D=80=E7=AE=97=E3=80=81?= =?UTF-8?q?=E5=9B=A2=E9=98=9F=E6=B8=A0=E9=81=93=E4=B8=80=E6=AC=A1=E5=8F=AC?= =?UTF-8?q?=E5=9B=9E=E5=8F=AA=E6=8E=A8=E4=B8=80=E6=9D=A1=E3=80=81=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E6=A0=B8=E5=AF=B9=E6=8C=89=E6=95=B0=E6=8D=AE=E6=BA=90?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E6=8E=92=E9=98=9F=EF=BC=9B=E8=B4=A6=E6=9C=AC?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E5=B8=A6=E4=B8=8A=E5=8F=AC=E5=9B=9E=E6=A0=87?= =?UTF-8?q?=E8=AE=B0=EF=BC=8C=E5=AF=B9=E8=B4=A6=E7=BB=93=E6=9E=9C=E5=8F=AF?= =?UTF-8?q?=E4=BB=A5=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - W4R-01:召回只查新版图里会不会写外部系统(apps/backfill 抽出 graph_external_write,graph_side_effect 复用它)。召回图只有两版 的取数和口径卡,图里别处的人工节点、按流程使用的问题碰不到它;口径卡上游的人工节点 derive 本来就认作数据部分不能复用。 「按新版重新出具」走完整的正式运行,仍按 graph_side_effect 全套拒绝。原来带审批的口径升版后一组都不召回,L6 对它们失效。 - W4R-09:收尾时召回卡读不出、或缺了某个比较的旧值 / 新值,照实记重算失败(读不出召回卡),原答案一个不动。原来两边都是空, 按渲染比被当成「没变」,给每个原答案永久盖上「已核对,不影响」,收过尾之后再也不核对。 - W4R-12:这一版已经不是当前发布版(之后又发布了更新的版本)时不再发起召回、不给接着算,批次里写 continue_reason,接着算的 接口 409 带同一句。同启动时接着做只认当前发布版:新版本的召回覆盖更早的所有版本,再按旧版开「口径升到 vN」的待办只是添乱。 - W4R-13:开登录时一组答案几个主人就几条待办,全局渠道却按这一批(recall.batch_of_key:同一次召回、同一个来由)只在最早开的 那条上推一次,答案、待办按这一批汇总,几条的链到待办箱;别的几条连 task.opened 也不再推全局渠道。个人渠道照旧每人一条。 - W4R-11:目录审阅之后的核对按数据源排队、批量审阅的几张表合成一遍(catalog_pass,同一数据源一把锁);查询快照按数据源在库里 筛、按运行结束时间分页、每张表凑够 BASIS_MAX_RUNS 次运行就停;冻结的表结构快照这一遍里按工件 id 只读一次。原来批量审阅 几十张表就同时起几十个后台任务,每个都全表扫查询快照、反复读同一份几 MB 的表结构快照。 - W4R-03(本包那一半):GET /api/runs/{id}/ledger 加 recall:checked(已按 vN 核对,不影响)、basis(依据有变:哪一种、几项、 新出现几条错误级问题)、tasks(看的这个人手上说的是这个答案、还没处理的 recall 待办)。不放数、不放表名。几种状态都带。 - W4R-10(本包那一半):新事件 ledger.feedback(对账运行上的封存后事件),POST /api/ledger/answers/{run}/reconcile/{rid}/feedback {verdict: useful | unclear} 记反馈,同一个人改主意只算最后一次(app/ledger/feedback.py);账本接口的 reconciled 每一项带 feedback {mine, useful, unclear};统计 E2 的 feedback 数到它,去掉「第 4 波接上」的占位说法。离线核对页、封存后事件摘要 都认得这个事件。 测试:test_ledger_recall 新增七个用例、改了人工节点那一个(召回照算、重新出具照实拒绝);新增 test_ledger_feedback; test_ledger_api 的 E2 断言跟上。每个修复都先写失败的测试、去掉修复回跑确认抓得住。后端全量 14237 通过。 --- backend/app/api/ledger.py | 28 ++- backend/app/apps/backfill.py | 33 ++- backend/app/bundle/build.py | 4 + backend/app/bundle/verifier.js | 1 + backend/app/core/events.py | 4 + backend/app/ledger/feedback.py | 108 ++++++++ backend/app/ledger/recall.py | 349 +++++++++++++++++++++----- backend/app/ledger/stats.py | 11 +- backend/app/ledger/view.py | 23 +- backend/app/notify/service.py | 56 ++++- backend/tests/test_ledger_api.py | 3 +- backend/tests/test_ledger_feedback.py | 119 +++++++++ backend/tests/test_ledger_recall.py | 277 +++++++++++++++++++- 13 files changed, 912 insertions(+), 104 deletions(-) create mode 100644 backend/app/ledger/feedback.py create mode 100644 backend/tests/test_ledger_feedback.py diff --git a/backend/app/api/ledger.py b/backend/app/api/ledger.py index 5917c529..0b063434 100644 --- a/backend/app/api/ledger.py +++ b/backend/app/api/ledger.py @@ -10,6 +10,8 @@ - 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)。 - GET /api/ledger/recall/{workflow_id}/{version}、POST …/continue(router,搭建者,第 4 波):口径升到这一版之后的召回批次—— @@ -172,6 +174,28 @@ 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]: @@ -188,7 +212,7 @@ async def recall_batches(workflow_id: str, version: int, session: AsyncSession = async def recall_continue(workflow_id: str, version: int, session: AsyncSession = Depends(get_session) ) -> dict[str, Any]: """接着算:把还没重算的组再发起一批(每批 recall_limit 组,上限是 0 时按缺省的组数),交回发起了的召回运行和新的批次 - 情况。都算过了的回 409。""" + 情况。都算过了的、这一版已经不是当前发布版的(之后又发布了更新的版本)回 409,detail 写为什么。""" from app.api.coded import CodedHTTPException from app.api.deps import current_principal from app.ledger import recall @@ -197,7 +221,7 @@ async def recall_continue(workflow_id: str, version: int, session: AsyncSession raise HTTPException(404, "工作流不存在,可能已被删除") current = await recall.batches(session, workflow_id, version) if not current["can_continue"]: - raise CodedHTTPException(409, current.get("problem") or "没有还没重算的组", recall.REFUSED) + 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) diff --git a/backend/app/apps/backfill.py b/backend/app/apps/backfill.py index 47349ed9..97630d6e 100644 --- a/backend/app/apps/backfill.py +++ b/backend/app/apps/backfill.py @@ -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 diff --git a/backend/app/bundle/build.py b/backend/app/bundle/build.py index 9dd73f2e..b088980a 100644 --- a/backend/app/bundle/build.py +++ b/backend/app/bundle/build.py @@ -1197,6 +1197,10 @@ def _summary(etype: str, data: Any) -> str: 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 "" diff --git a/backend/app/bundle/verifier.js b/backend/app/bundle/verifier.js index f4ae077d..24bf3a60 100644 --- a/backend/app/bundle/verifier.js +++ b/backend/app/bundle/verifier.js @@ -66,6 +66,7 @@ '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 = { diff --git a/backend/app/core/events.py b/backend/app/core/events.py index 4cbd7c7a..fa295c27 100644 --- a/backend/app/core/events.py +++ b/backend/app/core/events.py @@ -102,6 +102,10 @@ class EventType(StrEnum): # 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) diff --git a/backend/app/ledger/feedback.py b/backend/app/ledger/feedback.py new file mode 100644 index 00000000..216b8057 --- /dev/null +++ b/backend/app/ledger/feedback.py @@ -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"] diff --git a/backend/app/ledger/recall.py b/backend/app/ledger/recall.py index bfce4cba..938a81b4 100644 --- a/backend/app/ledger/recall.py +++ b/backend/app/ledger/recall.py @@ -13,8 +13,10 @@ metrics_from 指向召回卡;不加报告、不调模型)。两版在同一次运行里查同一份数据,差值只反映口径;数据随时间的变化另外拿 原答案当时的值对照着说(task_payload 的 original)。 - 不能重算的照实记「无法重算:原因」,不发起:口径的数据部分不能复用(derive.NotDerivable)、新版里没有这张口径卡、两版 - 没有同名的指标、新版入口要的参数这一组里没有或不合规(compose.check_inputs 同一套校验)、新版的图里有人工节点或会写外部 - 系统(apps/backfill.graph_side_effect,和按新口径补算、按口径出具同一个判断:替人重跑不代人做这些事)。 + 没有同名的指标、新版入口要的参数这一组里没有或不合规(compose.check_inputs 同一套校验)、新版的图里会写外部系统 + (apps/backfill.graph_external_write,和按新口径补算、按口径出具同一个判断)。新版图里别处有人工节点、按流程使用不安全的 + 照样重算:召回图只有两版的取数和口径卡,碰不到它们(口径卡上游的人工节点 derive 已经认作数据部分不能复用);「按新版 + 重新出具」走完整的正式运行,仍按 graph_side_effect 全套拒绝(ledger/issue.check_target)。 - 上限:每次发布最多自动重算 ledger.recall_limit 组(设置,缺省 50;0 表示不自动重算,只列出受影响的答案)。超出的写 「另有 M 组未重算」,在版本历史里点「接着算」手动接着发起下一批(每批同样的组数,上限是 0 时按缺省值)。 - **计划从已有的召回运行重算**(照 apps/backfill 的写法,不建任务表):一组算没算过,看有没有 origin 指向(W、V、N、 @@ -25,13 +27,14 @@ 不同就是数变了。数变了:给这一组答案的主人开 recall 待办(挂在召回运行上;开登录时每个主人一条,只列他的答案;关登录时 一条派给任何人),推 ledger.recall 通知。数没变:只记账——在每个原答案的运行上追加 ledger.recalled(「已按 vN 核对, 不影响」)。召回运行自己也追加一条 ledger.recalled 记结论(只记变没变、几个答案、几条待办,不记数),它同时是「收过尾」的 - 记号。召回运行没成功的照实记重算失败。 + 记号。召回运行没成功的、成功了却读不出召回卡(或缺了某个比较的旧值、新值)的照实记重算失败,原答案一个不动。 **二、目录事实被驳回或修改**(只管影响数字的那一组:有效记录条件、去重、业务日期、关联、业务主键、度量类型,同 data/catalog_review.FIELD_GROUP 的 facts):审阅写入之后(data/catalog 的审阅、人工编辑、保存提案,catalog_review 的批量审阅) 在后台找出查过这张表、而且查询快照冻结的目录里看得到这一项的探索运行,追加 ledger.basis_changed(kind=catalog),界面标 「依据有变」;对这些运行执行过的 SQL 按新目录跑一遍 SQL 检查(data/sqlcheck,纯计算),比冻结目录下多出错误级问题的才开 -一条 recall 待办(reason=catalog,只有「知道了」)通知答案的主人(拍板 L7:不自动重问)。 +一条 recall 待办(reason=catalog,只有「知道了」)通知答案的主人(拍板 L7:不自动重问)。同一个数据源排着做、批量审阅的几张表 +合成一遍(catalog_pass):查询快照按数据源在库里筛、凑够就停,冻结的表结构快照(大库的能有几 MB)同一份只读一次。 **三、表结构变动**:消费 data/source_changes(list_changes(since_seq) 按 seq 游标拉,游标记在设置表里;探查、剖析任务结束、 处理变化改了目录时触发)。只看 triggers_recall 认的:表或列被删、列的类型变了标「依据的表结构已变」(kind=structure); @@ -41,7 +44,11 @@ data/catalog_review.FIELD_GROUP 的 facts):审阅写入之后(data/catalog recall 待办(reason=structure)通知发布人。 **通知**:ledger.recall 只推口径升版、数真变了的那一种待办(消息照 notify/service 的规矩:标题、状态、条数、链接,不放 -新旧值和差值);目录和表结构的两种待办照别的待办推 task.opened。 +新旧值和差值);目录和表结构的两种待办照别的待办推 task.opened。全局渠道按一批待办(一次召回开给几个主人的那几条, +batch_of_key)只推一次,个人渠道每个主人各一条(notify/service.notify_recall)。 + +**答案上的标记**(answer_marks):账本接口 GET /api/runs/{id}/ledger 的 recall——已按 vN 核对、依据有变、看的这个人手上说的 +是这个答案的 recall 待办,给问数据的轮次和证据面板「口径」一步。 **自动的那几样**(发布后自动那一批、探查剖析之后拉表结构变化、启动时接着做)和调度循环同一个开关(automatic, AGENTLAB_SCHEDULER):它们是在后台替人查业务库,测试、拷来的数据目录(检查脚本)不做;关着时版本历史里照样列出受影响的 @@ -95,6 +102,11 @@ NEW_INPUTS = "新版 v{version} 的入口要的参数这一组里没有或不合 NEW_GONE = "新版本不存在,可能已被删除" LAUNCH_REFUSED = "没能发起召回运行:{reason}" RUN_FAILED = "重算失败:{reason}" +#: 召回运行成功了,召回卡(或其中某个比较的旧值、新值)却读不出来:照实算重算失败,不当成数没变 +CARD_UNREADABLE = "读不出召回卡" +#: 这一版已经不是当前发布版:不再接着算(新版本的召回本来就覆盖更早的所有版本,同 resume 只接着做当前发布版) +SUPERSEDED = "已有更新的发布版 v{latest},召回在 v{latest} 那一版接着看" +NOT_CURRENT = "v{version} 已不是当前发布版,召回不再接着算" #: 收尾 CHANGED_TITLE = "口径「{name}」升到 v{version},答案的数变了" CATALOG_TITLE = "答案依据的数据目录有变:按新目录检查出了新的问题" @@ -152,6 +164,8 @@ class Plan: workflow_id: str workflow_name: str new_version: int + #: 工作流现在的发布版(不是 new_version 的不再接着算,见 stale) + published_version: int | None = None groups: list[Group] = field(default_factory=list) #: 每个旧版本的口径卡对、旧版本自己的问题(不是口径、数据部分不能复用……) pairs: dict[int, list[Pair]] = field(default_factory=dict) @@ -165,6 +179,15 @@ class Plan: """还没算、能算的组(按最近一次用到的先后)。""" return [g for g in self.groups if g.run is None and g.problem is None] + def stale(self) -> str | None: + """这一版已经不是当前发布版时不再发起(手动接着算也不)的原因:之后又发布了更新的版本,新版本的召回覆盖更早的所有 + 版本(同 resume 只接着做当前发布版),再按这一版开「口径升到 vN」的待办只是添乱。是当前发布版为 None。""" + if self.published_version == self.new_version: + return None + if self.published_version is not None and self.published_version > self.new_version: + return SUPERSEDED.format(latest=self.published_version) + return NOT_CURRENT.format(version=self.new_version) + def _aware(moment: datetime | None) -> datetime | None: if moment is None: @@ -212,19 +235,20 @@ async def recall_runs(session: Any, workflow_id: str, new_version: int | None = async def build_plan(session: Any, workflow_id: str, new_version: int) -> Plan: """W 升到 N 的召回计划(见模块说明)。只读,不发起。""" from app.apps import versions as app_versions - from app.apps.backfill import graph_side_effect + from app.apps.backfill import graph_external_write from app.ledger import compose, derive workflow = await session.get(Workflow, workflow_id) plan = Plan(workflow_id=workflow_id, workflow_name=workflow.name if workflow is not None else "", - new_version=int(new_version)) + new_version=int(new_version), + published_version=workflow.published_version if workflow is not None else None) new_row = await app_versions.version_row(session, workflow_id, int(new_version)) if workflow else None if new_row is None: plan.problem = NEW_GONE return plan found = await ledger_uses.find_uses(session, workflow_id, below=int(new_version)) plan.found = found - side = await graph_side_effect(session, new_row.graph, level=new_row.level) + side = await graph_external_write(session, new_row.graph) for version in found.versions(): old_row = await app_versions.version_row(session, workflow_id, version) cards = source_cards(old_row.graph if old_row is not None else None) @@ -405,7 +429,7 @@ async def launch(workflow_id: str, new_version: int, *, batch: str = "auto", act async with SessionLocal() as session: plan = await build_plan(session, workflow_id, int(new_version)) limit = int((await ledger_settings(session))["recall_limit"]) - if plan.problem is not None: + if plan.problem is not None or plan.stale() is not None: return [] if batch == "auto": size = max(0, limit - plan.auto_launched) @@ -496,7 +520,8 @@ def _group_state(group: Group) -> tuple[str, str | None]: async def batches(session: Any, workflow_id: str, version: int) -> dict[str, Any]: """版本历史里「这一版的召回」:每组的结论(数变了 / 没变 / 无法重算:原因 / 重算失败 / 还在算 / 未重算),「另有 M 组 - 未重算」和能不能接着算。接口只给搭建者(api/ledger 的 router):参数原样给,同他在召回运行的输入里读到的一样;使用者 + 未重算」和能不能接着算(还有没算的组却不能接着算时 continue_reason 写为什么:之后又发布了更新的版本……)。 + 接口只给搭建者(api/ledger 的 router):参数原样给,同他在召回运行的输入里读到的一样;使用者 看的是 recall 待办(task_payload,按遮罩换过)。""" from app.api.settings import LEDGER_DEFAULTS, ledger_settings @@ -526,10 +551,11 @@ async def batches(session: Any, workflow_id: str, version: int) -> dict[str, Any "latest": group.latest.isoformat() if group.latest else None, "state": state, **({"reason": reason} if reason else {}), **extra}) pending = counts["pending"] + why_not = (plan.problem or plan.stale()) if pending > 0 else None return {"workflow_id": workflow_id, "workflow_name": plan.workflow_name, "version": int(version), "limit": limit, "batch_size": limit or int(LEDGER_DEFAULTS["recall_limit"]), "problem": plan.problem, "caliber": bool(plan.pairs) or bool(plan.groups), "counts": counts, "groups": items, - "remaining": pending, "can_continue": pending > 0 and plan.problem is None, + "remaining": pending, "can_continue": pending > 0 and why_not is None, "continue_reason": why_not, "truncated": bool(plan.found and plan.found.truncated)} @@ -644,7 +670,14 @@ async def settle(run_id: str) -> str | None: await _mark(run_id, {**base, "outcome": "failed", "changed": None, "error": str(run.error or "运行没有成功结束")[:300]}) return "failed" - results = compare(origin.get("metrics") or [], await card_values(run_id)) + meta = origin.get("metrics") or [] + values = await card_values(run_id) + # 读不出召回卡、少了哪个比较的旧值或新值:两边都是空,按渲染比会被当成「没变」,给每个原答案盖上「已核对,不影响」 + # ——照实记重算失败,原答案一个不动(收过尾就不再核对,盖错了收不回来) + if not meta or any(f"{side}_{m.get('k')}" not in values for m in meta for side in ("old", "new")): + await _mark(run_id, {**base, "outcome": "failed", "changed": None, "error": CARD_UNREADABLE}) + return "failed" + results = compare(meta, values) changed = any(r["changed"] for r in results) answers = list(dict.fromkeys(str(u.get("run_id")) for u in origin.get("uses") or [] if u.get("run_id"))) tasks: list[str] = [] @@ -738,6 +771,13 @@ def _owner_of_key(key: str) -> tuple[str, str]: return (parts[-2], parts[-1]) if len(parts) >= 4 else ("anyone", "") +def batch_of_key(key: str) -> str: + """correlation_key 去掉派给谁的那两段:同一次召回(同一个来由、同一次运行、同一条依据)给各个主人开的那一批待办是同一个 + 值。全局渠道按它只推一次(notify/service.notify_recall)。""" + parts = (key or "").split(":") + return ":".join(parts[:-2]) if len(parts) >= 4 else key or "" + + async def task_payload(session: Any, task: Any, principal: Any) -> dict[str, Any]: """recall 待办给处理人看的内容(待办箱、待办详情的 payload,process/tasks._describe 调):旧口径的值、新口径的值、两版之差 和原答案当时的值(每个都带出处:哪次运行、哪张卡、哪个指标,点得开),这一组的参数,能做的动作。使用者看到的值照样 @@ -896,6 +936,72 @@ def _cell(item: Mapping[str, Any], value: Any, text: Any, hide: Any, *, missing: return out +# ========================================================================== +# 答案上的召回标记(账本接口 GET /api/runs/{id}/ledger 的 recall,ledger/view.run_view) +# ========================================================================== + + +async def answer_marks(session: Any, run_id: str, principal: Any) -> dict[str, Any]: + """一个答案(探索运行、正式运行)身上的召回标记,给问数据的轮次和证据面板「口径」一步(细案做法 7:界面标「依据有变」、 + 「已按 vN 核对,不影响」): + + - checked:数没变、追加在这次运行上的 ledger.recalled(role=answer)——按哪份口径、从哪一版核对到哪一版、哪次召回、什么时候; + - basis:ledger.basis_changed——哪一种依据(目录事实、表结构、取值、处理时改了目录)、几项、按新目录新出现几条错误级问题、 + 什么时候; + - tasks:看的这个人手上还没处理的、说的是这个答案的 recall 待办(数变了的那种挂在召回运行上,列着这个答案;目录有变的 + 挂在这次运行上),界面链到待办箱。 + + 不放数(新旧值、差值只在待办里给,按遮罩换过),不放表名、取值。""" + from app.process.tasks import Task, _assignments, handles + + checked: list[dict[str, Any]] = [] + basis: list[dict[str, Any]] = [] + rows = (await session.execute(select(RunEvent.type, RunEvent.data, RunEvent.ts).where( + RunEvent.run_id == run_id, RunEvent.type.in_((EventType.LEDGER_RECALLED, EventType.LEDGER_BASIS_CHANGED))) + .order_by(RunEvent.seq))).all() + def at(ts: Any) -> str | None: + return datetime.fromtimestamp(ts, timezone.utc).isoformat(timespec="seconds") \ + if isinstance(ts, (int, float)) else None + + workflows: set[str] = set() + for etype, data, ts in rows: + data = data if isinstance(data, dict) else {} + if etype == EventType.LEDGER_RECALLED and data.get("role") == "answer": + checked.append({k: data.get(k) for k in ("workflow_id", "from_version", "to_version", "recall_run")} + | {"changed": bool(data.get("changed")), "at": at(ts)}) + workflows.add(str(data.get("workflow_id") or "")) + elif etype == EventType.LEDGER_BASIS_CHANGED: + items = data.get("items") + basis.append({"kind": data.get("kind"), "items": len(items) if isinstance(items, list) else 0, + "new_errors": int(data.get("new_errors") or 0), "at": at(ts)}) + names = dict((await session.execute(select(Workflow.id, Workflow.name).where( + Workflow.id.in_(sorted(w for w in workflows if w))))).all()) if workflows - {""} else {} + for item in checked: + item["workflow_name"] = names.get(str(item.get("workflow_id") or "")) + # 说的是这个答案、还没处理的 recall 待办:挂在这次运行上的(目录有变),和召回运行 origin.uses 里列着它的(数变了)。 + # 运行 id 是 32 位十六进制,在 uses 的 JSON 文字里找子串不会误中,找到的再逐条核对 + candidates = (await session.execute( + select(Task, Run.origin).join(Run, Run.id == Task.run_id) + .where(Task.kind == TASK_KIND, Task.status == "pending", + (Task.run_id == run_id) | ((Run.origin_kind == ORIGIN) + & (func.instr(func.json_extract(Run.origin, "$.uses"), run_id) > 0))) + .order_by(Task.created_at, Task.id))).all() + mine = await _assignments(session, [t.id for t, _ in candidates]) + tasks: list[dict[str, Any]] = [] + for task, origin in candidates: + key = task.correlation_key or "" + reason = key.split(":", 1)[0] if key.split(":", 1)[0] in REASONS else "caliber" + if task.run_id != run_id: + origin = origin if isinstance(origin, dict) else {} + if not any(u.get("run_id") == run_id for u in _uses_of(origin, _owner_of_key(key))): + continue + if not handles(mine.get(task.id, []), principal): + continue + tasks.append({"id": task.id, "reason": reason, + **({"to_version": (origin or {}).get("to_version")} if reason == "caliber" else {})}) + return {"checked": checked, "basis": basis, "tasks": tasks} + + # ========================================================================== # 「按新版重新出具」「知道了」 # ========================================================================== @@ -999,51 +1105,85 @@ def fact_changes(before: Mapping[str, Any] | None, after: Mapping[str, Any] | No return out -async def _explore_snapshots(session: Any, source: Any, table: str, *, before: datetime | None = None - ) -> list[tuple[str, str]]: - """查过 source 这张表的探索运行(计入统计的、成功封存的)和那条查询快照:[(运行, 快照)],新的在前,最多 BASIS_MAX_RUNS - 次运行。表名按 name_key 比,表已经不在表结构里也认(表被删了正要找用过它的)。""" +#: 查询快照一页取几行(按运行结束时间从新到旧翻,每张表凑够 BASIS_MAX_RUNS 次运行就不再往下翻) +SNAPSHOT_PAGE = 500 + + +async def _explore_snapshots(session: Any, source: Any, tables: Iterable[str], *, before: datetime | None = None + ) -> dict[str, list[tuple[str, str]]]: + """查过 source 这几张表的探索运行(计入统计的、成功封存的)和那几条查询快照:{表: [(运行, 快照)]},新的在前,每张表最多 + BASIS_MAX_RUNS 次运行。表名按 name_key 比,表已经不在表结构里也认(表被删了正要找用过它的)。 + + 数据源在库里就筛掉(引用的 meta 里记着源 id 和源名,query_snapshot_meta):部署久了查询快照有几万条,别的源的不该读回来; + 按运行结束时间、运行 id 排序分页翻(同一次运行的几条挨在一起),每张表都凑够了就停。表名不在库里筛:meta 里的表名是 + SQL 里写的样子(带 schema 前缀、大小写不一),按 name_key 比只能在这里做。""" + from sqlalchemy import or_ + from app.data.catalog import QUERY_SNAPSHOT_KIND from app.data.names import name_key from app.db.models import Artifact from app.engine.origin import counted_runs - want = name_key(table) + want: dict[str, list[str]] = {} + for t in dict.fromkeys(tables): + want.setdefault(name_key(t), []).append(t) + out: dict[str, list[tuple[str, str]]] = {t: [] for names in want.values() for t in names} + runs: dict[str, set[str]] = {t: set() for t in out} stmt = (select(Artifact.id, Artifact.meta, Run.id, Run.finished_at).join(Run, Run.id == Artifact.run_id) .where(Artifact.kind == QUERY_SNAPSHOT_KIND, Run.run_class == "exploratory", Run.status == "succeeded", - Run.manifest_hash.is_not(None), counted_runs()).order_by(Run.finished_at.desc())) - out: list[tuple[str, str]] = [] - runs: set[str] = set() + Run.manifest_hash.is_not(None), counted_runs(), + or_(func.json_extract(Artifact.meta, "$.source_id") == source.id, + func.json_extract(Artifact.meta, "$.source") == source.name)) + .order_by(Run.finished_at.desc(), Run.id, Artifact.id)) cut = _aware(before) - for artifact, meta, run_id, finished in (await session.execute(stmt)).all(): - if cut is not None and (_aware(finished) is None or _aware(finished) > cut): - continue - meta = meta if isinstance(meta, dict) else {} - if meta.get("source_id") != source.id and meta.get("source") != source.name: - continue - if not any(name_key(str(t).rsplit(".", 1)[-1]) == want for t in meta.get("tables") or []): - continue - if run_id not in runs and len(runs) >= BASIS_MAX_RUNS: - continue - runs.add(run_id) - out.append((run_id, artifact)) + offset = 0 + last_run: str | None = None + while want: + page = (await session.execute(stmt.limit(SNAPSHOT_PAGE).offset(offset))).all() + offset += len(page) + for artifact, meta, run_id, finished in page: + if run_id != last_run and all(len(seen) >= BASIS_MAX_RUNS for seen in runs.values()): + return out + last_run = run_id + if cut is not None and (_aware(finished) is None or _aware(finished) > cut): + continue + meta = meta if isinstance(meta, dict) else {} + if meta.get("source_id") != source.id and meta.get("source") != source.name: + continue + hit = {name_key(str(t).rsplit(".", 1)[-1]) for t in meta.get("tables") or []} + for table in (t for key in hit & want.keys() for t in want[key]): + if run_id not in runs[table] and len(runs[table]) >= BASIS_MAX_RUNS: + continue + runs[table].add(run_id) + # 翻页之间进来了新的运行,偏移会错开一两行:同一条不收两次 + if (run_id, artifact) not in out[table]: + out[table].append((run_id, artifact)) + if len(page) < SNAPSHOT_PAGE: + break return out -def _frozen_of(snapshot_id: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: - """查询快照 → (快照内容, 它当时冻结的表结构快照)。读不出为 None。""" +def _frozen_of(snapshot_id: str, cache: dict[str, Any] | None = None + ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + """查询快照 → (快照内容, 它当时冻结的表结构快照)。读不出为 None。cache 是这一遍里读过的工件({工件 id: 内容}):工件按内容 + 寻址、不会变,几十次运行冻结的常常是同一份表结构快照(大库的能有几 MB),同一遍里只读一次。""" from app.core.artifact_store import load - try: - snap = load(snapshot_id) - except ValueError: - return None, None + def read(artifact_id: str) -> Any: + if cache is not None and artifact_id in cache: + return cache[artifact_id] + try: + content = load(artifact_id) + except ValueError: + content = None + if cache is not None: + cache[artifact_id] = content + return content + + snap = read(snapshot_id) if not isinstance(snap, dict): return None, None - try: - schema = load(str(snap.get("schema_artifact") or "")) if snap.get("schema_artifact") else None - except ValueError: - schema = None + schema = read(str(snap["schema_artifact"])) if snap.get("schema_artifact") else None return snap, schema if isinstance(schema, dict) else None @@ -1096,28 +1236,77 @@ async def _answer_owner(session: Any, run_id: str) -> list[tuple[str, str]]: return use.owners +@dataclass +class CatalogEdit: + """一张表这一次影响数字的改动(目录审阅写入、新码值、处理变化时改了目录):catalog_changed 一遍里可以有好几张表。""" + + table: str + changes: Sequence[Mapping[str, Any]] + catalog_version: int | None = None + kind: str = "catalog" + change_id: str | None = None + + async def catalog_changed(source_id: str, table: str, changes: Sequence[Mapping[str, Any]], *, catalog_version: int | None, kind: str = "catalog", change_id: str | None = None) -> int: """目录里这张表影响数字的几项被驳回或改了(见模块说明二):冻结目录里看得到其中某项的探索运行追加 ledger.basis_changed; - 按新目录多出错误级问题的开 recall 待办(reason=catalog)。返回标了几次运行。""" + 按新目录多出错误级问题的开 recall 待办(reason=catalog)。返回标了几次运行。同一个数据源的排着做(_catalog_lock)。""" + return await catalog_pass(source_id, [CatalogEdit(table=table, changes=changes, catalog_version=catalog_version, + kind=kind, change_id=change_id)]) + + +#: 同一个数据源的目录核对排着做:{事件循环: {数据源: 锁}}。批量审阅几十张表、几个人同时审阅,每一遍都要翻查询快照、读冻结 +#: 的表结构快照,同时跑几十遍会把单进程的服务拖住 +_catalog_locks: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict[str, asyncio.Lock]]" = \ + weakref.WeakKeyDictionary() + + +def _catalog_lock(source_id: str) -> asyncio.Lock: + return _catalog_locks.setdefault(asyncio.get_running_loop(), {}).setdefault(source_id, asyncio.Lock()) + + +async def catalog_pass(source_id: str, edits: Sequence[CatalogEdit]) -> int: + """一遍核对同一个数据源的几张表(catalog_changed、审阅之后排着的那一批):查询快照只翻一次,冻结的表结构快照同一份只读 + 一次。返回标了几次运行。""" + async with _catalog_lock(source_id): + return await _catalog_pass(source_id, edits) + + +async def _catalog_pass(source_id: str, edits: Sequence[CatalogEdit]) -> int: from app.data import catalog from app.db.models import DataSource - paths = [str(c.get("path")) for c in changes if c.get("path")] - if not paths and kind == "catalog": + # 目录审阅那一种得有改了的项(路径)才核对;新码值、处理时改了目录的没有逐项路径,照样核对 + edits = [e for e in edits if e.kind != "catalog" or any(c.get("path") for c in e.changes)] + if not edits: return 0 async with SessionLocal() as session: source = await session.get(DataSource, source_id) if source is None: return 0 - found = await _explore_snapshots(session, source, table) + found = await _explore_snapshots(session, source, list(dict.fromkeys(e.table for e in edits))) current = {t: e.notes for t, e in (await catalog.read_catalog(session, source_id)).items()} - by_run: dict[str, list[tuple[dict[str, Any], dict[str, Any]]]] = {} - for run_id, snapshot in found: - snap, frozen = await asyncio.to_thread(_frozen_of, snapshot) - if snap is None or frozen is None: - continue - by_run.setdefault(run_id, []).append((snap, frozen)) + cache: dict[str, Any] = {} + marked = 0 + for edit in edits: + by_run: dict[str, list[tuple[dict[str, Any], dict[str, Any]]]] = {} + for run_id, snapshot in found.get(edit.table) or []: + snap, frozen = await asyncio.to_thread(_frozen_of, snapshot, cache) + if snap is None or frozen is None: + continue + by_run.setdefault(run_id, []).append((snap, frozen)) + marked += await _mark_catalog(source, edit, by_run, current) + return marked + + +async def _mark_catalog(source: Any, edit: CatalogEdit, + by_run: Mapping[str, list[tuple[dict[str, Any], dict[str, Any]]]], + current: Mapping[str, Mapping[str, Any]]) -> int: + """一张表的这次改动:冻结目录里看得到改动项的运行追加 ledger.basis_changed,按新目录多出错误级问题的开待办。""" + from app.engine.run_append import append_events + + table, changes = edit.table, edit.changes + paths = [str(c.get("path")) for c in changes if c.get("path")] marked = 0 labels = {str(c.get("path")): c for c in changes} for run_id, items in by_run.items(): @@ -1128,17 +1317,15 @@ async def catalog_changed(source_id: str, table: str, changes: Sequence[Mapping[ for snap, frozen in items: if isinstance(snap.get("sql"), str): errors += new_errors(snap["sql"], frozen, current, source.kind) - match = {"kind": kind, "source_id": source_id, "table": table, - **({"catalog_version": catalog_version} if catalog_version is not None else {}), - **({"change": change_id} if change_id else {})} + match = {"kind": edit.kind, "source_id": source.id, "table": table, + **({"catalog_version": edit.catalog_version} if edit.catalog_version is not None else {}), + **({"change": edit.change_id} if edit.change_id else {})} async with SessionLocal() as session: if await _basis_marked(session, run_id, match): continue data = {**match, "source": source.name, "items": [{"path": p, "label": labels.get(p, {}).get("label"), "change": labels.get(p, {}).get("change")} for p in seen], "new_errors": len(errors), "checks": errors[:10]} - from app.engine.run_append import append_events - written = await append_events(run_id, [(EventType.LEDGER_BASIS_CHANGED, None, data)], require="sealed") if not written: continue @@ -1159,20 +1346,48 @@ async def _open_basis_task(run_id: str, seq: int) -> list[str]: return await open_tasks(run, reason="catalog", owners=owners, title=CATALOG_TITLE, extra=str(seq)) +#: 审阅之后排着的目录改动:{事件循环: {数据源: [CatalogEdit]}}。一个数据源一个后台任务(_drain_catalog),批量审阅逐表写完 +#: 逐表调 after_review,这期间(不让出事件循环)进来的几张表合成一遍做;做的时候又进来的,做完这一遍接着做 +_catalog_queues: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict[str, list[CatalogEdit]]]" = \ + weakref.WeakKeyDictionary() + + def after_review(source_id: str, table: str, before: Mapping[str, Any] | None, after: Mapping[str, Any] | None, version: int | None) -> int: - """审阅写入之后(data/catalog、catalog_review 调):有影响数字的项被驳回或改了,在后台标依据有变。返回改了几项。 - 出错只记日志:审阅已经写进去了。""" + """审阅写入之后(data/catalog、catalog_review 调):有影响数字的项被驳回或改了,排进这个数据源的后台任务标依据有变(同一个 + 数据源的几张表合成一遍)。返回改了几项。出错只记日志:审阅已经写进去了。""" try: changes = fact_changes(before, after) if changes: - spawn(catalog_changed(source_id, table, changes, catalog_version=version)) + queues = _catalog_queues.setdefault(asyncio.get_running_loop(), {}) + edit = CatalogEdit(table=table, changes=changes, catalog_version=version) + if source_id in queues: + queues[source_id].append(edit) + else: + queues[source_id] = [edit] + spawn(_drain_catalog(source_id)) return len(changes) except Exception: # noqa: BLE001 - 辅助步骤 logger.exception("审阅写入后排队标依据有变时出错(%s · %s)", source_id, table) return 0 +async def _drain_catalog(source_id: str) -> None: + """一个数据源排着的目录改动一遍一遍做完。一遍出错只记日志,接着做后面排进来的。""" + queues = _catalog_queues.setdefault(asyncio.get_running_loop(), {}) + try: + while edits := queues.get(source_id): + queues[source_id] = [] + try: + await catalog_pass(source_id, edits) + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 - 辅助步骤 + logger.exception("审阅之后标依据有变时出错(%s)", source_id) + finally: + queues.pop(source_id, None) + + # ========================================================================== # 三、表结构变动 # ========================================================================== @@ -1222,7 +1437,8 @@ async def _mark_structure(change: Mapping[str, Any]) -> int: return 0 detected = change.get("detected_at") at = datetime.fromisoformat(detected) if isinstance(detected, str) else None - found = await _explore_snapshots(session, source, str(change["table"]), before=at) + table = str(change["table"]) + found = (await _explore_snapshots(session, source, [table], before=at)).get(table) or [] marked = 0 for run_id in dict.fromkeys(r for r, _ in found): match = {"kind": "structure", "change": change.get("id")} @@ -1459,9 +1675,10 @@ async def shutdown() -> None: await asyncio.gather(*tasks, return_exceptions=True) -__all__ = ["BASIS_KINDS", "BATCHES", "CURSOR_KEY", "OUTCOMES", "ORIGIN", "REASONS", "TASK_KIND", "Group", "Pair", "Plan", - "RecallRefused", "after_publish", "after_review", "automatic", "basis_kind", "batches", "build_graph", "build_plan", - "card_values", "catalog_changed", "compare", "consume_source_changes", "drain", "fact_changes", "launch", - "new_errors", "normalize", "open_tasks", "original_values", "outcome_of", "preview", "process_change", - "recall_card", "recall_runs", "reissue", "reissue_action", "resume", "schedule_settle", "settle", "shutdown", +__all__ = ["BASIS_KINDS", "BATCHES", "CURSOR_KEY", "OUTCOMES", "ORIGIN", "REASONS", "TASK_KIND", "CatalogEdit", + "Group", "Pair", "Plan", "RecallRefused", "after_publish", "after_review", "answer_marks", "automatic", + "basis_kind", "batch_of_key", "batches", "build_graph", "build_plan", "card_values", "catalog_changed", + "catalog_pass", "compare", "consume_source_changes", "drain", "fact_changes", "launch", "new_errors", + "normalize", "open_tasks", "original_values", "outcome_of", "preview", "process_change", "recall_card", + "recall_runs", "reissue", "reissue_action", "resume", "schedule_settle", "settle", "shutdown", "source_cards", "spawn", "start_resume", "task_payload", "version_recall_counts"] diff --git a/backend/app/ledger/stats.py b/backend/app/ledger/stats.py index 9f5e4832..180d984a 100644 --- a/backend/app/ledger/stats.py +++ b/backend/app/ledger/stats.py @@ -11,8 +11,8 @@ 的查询里各自的比率;证明等价的绑定是在哪一级配上的;没证明的原因(不可比、参数绑不上、配上了但不等价);不等价的差异类型 分布(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):固化成 @@ -42,8 +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 波接上,目前没有数据" #: 聚类最多取几个问题(同治理接口的上限) 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)} #: 查询快照 → (数据源, 形状)。工件按内容寻址、不会变,记下来不会过期 diff --git a/backend/app/ledger/view.py b/backend/app/ledger/view.py index d1c8f356..640731b3 100644 --- a/backend/app/ledger/view.py +++ b/backend/app/ledger/view.py @@ -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")}, diff --git a/backend/app/notify/service.py b/backend/app/notify/service.py index b8bfeac4..5490dacd 100644 --- a/backend/app/notify/service.py +++ b/backend/app/notify/service.py @@ -347,7 +347,12 @@ async def notify_recall(task_id: str, users: list[str]) -> int: 自己的个人渠道;然后照别的待办推 task.opened(已经推过 ledger.recall 的渠道不再推,同一个渠道只推一次)。目录、表结构变了 的那两种只推 task.opened。 - 消息照模块说明的规矩(X6):口径名(搭建时起的工作流名)、新旧版本号、这条待办列了几个答案、召回编号、去处理的链接;不放 + 全局渠道按这一批待办只推一次(同一次召回运行、同一个来由开的那几条,recall.batch_of_key):开登录时一组答案有几个主人就 + 开几条待办,团队群却只该收到一条——只在这一批里最早开的那条(开出时间、id 排序,几条并发推也认同一条)上推全局渠道, + 答案、待办按这一批汇总,链到待办箱(各人在那里看到派给自己的那条);别的几条连 task.opened 也不再推全局渠道。个人渠道 + 照旧每个主人各一条,只数他的答案、链到他的那条待办。 + + 消息照模块说明的规矩(X6):口径名(搭建时起的工作流名)、新旧版本号、几个答案(几条待办)、召回编号、去处理的链接;不放 旧口径的值、新口径的值、差值和原答案的值。返回推了几个渠道。""" from app.ledger import recall @@ -361,26 +366,51 @@ async def notify_recall(task_id: str, users: list[str]) -> int: if task is None: return 0 run = await session.get(Run, task.run_id) - reason = (task.correlation_key or "").split(":", 1)[0] + 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: - answers = len(recall._uses_of(origin, recall._owner_of_key(task.correlation_key or ""))) - lines = [("状态", "待处理"), ("口径", f"「{name[:80]}」"), - ("版本", f"v{origin.get('from_version')} → v{origin.get('to_version')}"), ("答案", f"{answers} 个"), - ("召回", f"#{task.run_id[:6]}")] - message = {"event": event, "status": "pending", "ok": False, - "title": recall.CHANGED_TITLE.format(name=name[:80] or "口径", version=origin.get("to_version")), - "lines": lines, "links": [("去处理", f"{base}/apps/inbox/{task_id}")] if base else [], - "subject": {"task_id": task_id, "run_id": task.run_id, "workflow_id": origin.get("workflow_id")}} - pushed = await _push(channels, message, subject_kind="task", subject_id=task_id, event=event, - run_id=task.run_id, task_id=task_id) + 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)) + exclude=frozenset(c.id for c in channels) | frozenset(shared)) async def notify_run(run_id: str, event: str) -> int: diff --git a/backend/tests/test_ledger_api.py b/backend/tests/test_ledger_api.py index 5a5a2001..aeedd136 100644 --- a/backend/tests/test_ledger_api.py +++ b/backend/tests/test_ledger_api.py @@ -233,7 +233,8 @@ 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 diff --git a/backend/tests/test_ledger_feedback.py b/backend/tests/test_ledger_feedback.py new file mode 100644 index 00000000..805ead19 --- /dev/null +++ b/backend/tests/test_ledger_feedback.py @@ -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"}) == "反馈:看不懂" diff --git a/backend/tests/test_ledger_recall.py b/backend/tests/test_ledger_recall.py index 867d09d5..0052f306 100644 --- a/backend/tests/test_ledger_recall.py +++ b/backend/tests/test_ledger_recall.py @@ -7,7 +7,8 @@ - 召回运行:派生图里新旧两版的取数和口径卡各一份(前缀不撞),召回卡里算两版之差;同一份数据上两版各算一次; - 收尾:数变了才开 recall 待办(挂在召回运行上,旧值、新值、差值、原答案当时的值都给),推 ledger.recall,消息里没有数; 数没变的在原答案上记 ledger.recalled(已按 vN 核对,不影响),不开待办; -- 不能重算的照实记:新版入口要的参数这一组里没有、图里有人工节点(有副作用); +- 不能重算的照实记:新版入口要的参数这一组里没有、图里会写外部系统;图里别处有人工节点的照样重算(召回图碰不到它), + 只是「按新版重新出具」不代人审批; - 待办「按新版重新出具」走按口径出具同一套判法、「知道了」; - 目录里影响数字的一项被改了:冻结目录里看得到它的答案标「依据有变」,按新目录多出错误级问题的才开待办; - 表结构变了:用过这张表的答案标「依据的表结构已变」,口径试算算不出来才开待办; @@ -146,6 +147,32 @@ async def test_limit_continue_and_idempotent_replanning(monkeypatch): 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() @@ -189,6 +216,9 @@ async def test_changed_numbers_open_a_recall_task_with_all_three_values(client, 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"] @@ -220,11 +250,43 @@ async def test_unchanged_numbers_are_only_booked_on_the_answer(monkeypatch): [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) @@ -240,14 +302,59 @@ async def test_incompatible_entry_and_side_effects_are_not_recomputed(monkeypatc assert view["counts"]["not_recomputable"] == 1 and view["groups"][0]["reason"] == group.problem assert view["can_continue"] is False - wf2 = await S.publish(name="周销售(要审批)") + + +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(3, S.node("ok", "human", mode="approve", title="确认")) - await S.publish(graph=human, version=2, workflow_id=wf2) + 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, wf2, 2) - assert plan.groups and all("人工节点" in (g.problem or "") for g in plan.groups) + 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): @@ -362,6 +469,74 @@ async def test_recall_notification_has_no_numbers(monkeypatch): 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"] == [] + + # -------------------------------------------------------------------------- # 目录事实被改、表结构变了 # -------------------------------------------------------------------------- @@ -411,6 +586,13 @@ async def test_catalog_fact_change_marks_answers_and_notifies_only_on_new_errors 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 @@ -427,6 +609,89 @@ async def test_catalog_fact_change_marks_answers_and_notifies_only_on_new_errors 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("金额")}}} From 464d1c0741a892873f21b9f90bf54913624b4886 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 22:16:58 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E5=8F=AC=E5=9B=9E=E5=A4=8D=E6=A0=B8?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=EF=BC=88=E5=89=8D=E7=AB=AF=EF=BC=89=EF=BC=9A?= =?UTF-8?q?=E5=8F=AC=E5=9B=9E=E5=BE=85=E5=8A=9E=E7=9A=84=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E5=8E=BB=E5=BE=85=E5=8A=9E=E9=A1=B5=E3=80=81?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E8=80=85=E4=B8=8D=E7=BB=99=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E5=87=BA=E5=85=B7=E7=9A=84=E8=AE=B0=E5=BD=95=E9=A1=B5=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E3=80=81=E6=97=A7=E7=89=88=E5=86=99=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=8E=A5=E7=9D=80=E7=AE=97=E7=9A=84=E5=8E=9F=E5=9B=A0=E3=80=81?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E7=BA=BF=E8=AE=A4=E5=AF=B9=E8=B4=A6=E5=8F=8D?= =?UTF-8?q?=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - W4R-06:后台系统通知点开时,口径召回(不属于应用、挂在召回运行上)和使用者(进不了记录页)一律去 /apps/inbox/{id}。原来 没有 app_id 的都去 /runs/{run_id}:使用者被送回应用首页,搭建者落到召回运行的详情页,那里没有这张待办卡。 - W4R-07:recall 待办里「重新出具的正式运行」只给搭建者链到记录页,使用者写「正式运行的记录由搭建者在记录页查看」(同问数据 轮次下的出具结果)。 - W4R-12:版本历史里旧版还有没算的组却不能接着算时,照服务端给的 continue_reason 写「不再接着算:……」,不给按钮。 - decode 认 ledger.feedback(对账运行时间线上一条一行:谁给了哪一种反馈;认不出的不猜)。 检查:check-inbox 加使用者 / 搭建者看重新出具过的待办、系统通知点开去待办页两段(通知换成记录器,点它就是调 onclick); check-publish 加之后又发布了更新版本的旧版不给接着算;check-decode 加 ledger.feedback 一段。去掉修复回跑,新加的几项都不过; 加上修复 decode、inbox、publish 三项 1110 项全过。tsc、check-copy 通过。 --- frontend/src/App.tsx | 5 +- frontend/src/canvas/RecallPanel.tsx | 6 ++- frontend/src/lib/termsRecall.ts | 16 ++++++ frontend/src/run/RecallTask.tsx | 5 ++ frontend/src/run/decode.ts | 15 +++++- frontend/src/typesRecall.ts | 2 + scripts/check-decode.mjs | 21 ++++++++ scripts/check-inbox.mjs | 77 ++++++++++++++++++++++++++++- scripts/check-publish.mjs | 13 +++++ 9 files changed, 156 insertions(+), 4 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e4f8696f..81b7835f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 { diff --git a/frontend/src/canvas/RecallPanel.tsx b/frontend/src/canvas/RecallPanel.tsx index 5898fc27..a09532fd 100644 --- a/frontend/src/canvas/RecallPanel.tsx +++ b/frontend/src/canvas/RecallPanel.tsx @@ -3,7 +3,8 @@ * - 发布弹窗的召回预告(RecallPreviewRow):把这一版发布成新版本之后,旧版本被多少问数回答、导出、定时任务、应用用过, * 预计自动重算几组,超出上限的写明(GET /api/workflows/{id}/recall-preview)。和发布之后真的召回走同一份计划,规模一致; * - 版本历史里口径升到这一版之后的召回批次(RecallBatches):每组的结论(数变了 / 没变 / 无法重算:原因 / 重算失败 / 正在 - * 重算 / 未重算),「另有 M 组未重算」和「接着算」(GET / POST /api/ledger/recall/{wf}/{v})。 + * 重算 / 未重算),「另有 M 组未重算」和「接着算」(GET / POST /api/ledger/recall/{wf}/{v})。之后又发布了更新的版本的, + * 旧的那一版不再给「接着算」,写服务端给的原因(continue_reason)。 * * 界面层不放逻辑:几组、能不能重算、为什么不能、能不能接着算都由服务端给。老服务端没有这两个接口(404)时不画。 */ @@ -138,6 +139,9 @@ export function RecallBatches({ workflowId, version }: { workflowId: string; ver {busy ? : } {busy ? B.continuing : B.continue(data.batch_size)} )} + {!data.can_continue && data.continue_reason && ( +
    {B.cannotContinue(data.continue_reason)}
    + )}
    )} {data.truncated &&
    {B.truncated}
    } diff --git a/frontend/src/lib/termsRecall.ts b/frontend/src/lib/termsRecall.ts index 82b40be9..0cc978be 100644 --- a/frontend/src/lib/termsRecall.ts +++ b/frontend/src/lib/termsRecall.ts @@ -70,6 +70,8 @@ export const RECALL_TEXT = { } as Record, 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)} 组召回` : '没有发起新的召回'), @@ -108,6 +110,8 @@ export const RECALL_TEXT = { reissueHint: '按新版口径、这一组的参数发起一次正式运行(和按口径正式出具同一套检查)', reissued: '已按新版重新出具', issuedRun: '查看出具的正式运行', + /** 使用者进不了记录页:只写已经出具,不给点了回不来的链接(同问数据轮次下的出具结果) */ + issuedUserNote: '正式运行的记录由搭建者在记录页查看', ack: '知道了', acked: '已记为知道了', doneReissue: '已按新版发起正式出具', @@ -152,3 +156,15 @@ export const RECALL_TEXT = { sub: '封存后追加,不在封存范围内', }, } + +/** + * 对账结果上的「说明有用 / 看不懂」在时间线上的说法(run/decode.ts 认 ledger.feedback,记在对账运行上,服务端 + * app/ledger/feedback.py)。按钮在对账结果卡片上(问数据轮次、证据面板),这里只管时间线那一行。 + */ +export const LEDGER_FEEDBACK_TEXT = { + verdict: { useful: '说明有用', unclear: '看不懂' } as Record, + title: (verdict: string | undefined, by: string | null | undefined) => + `${by ? `${by} ` : ''}反馈:${(verdict && LEDGER_FEEDBACK_TEXT.verdict[verdict]) || '对账说明'}`, + generic: '对账说明的反馈', + sub: '封存后追加,不在封存范围内', +} diff --git a/frontend/src/run/RecallTask.tsx b/frontend/src/run/RecallTask.tsx index a0b2b194..f73b8c25 100644 --- a/frontend/src/run/RecallTask.tsx +++ b/frontend/src/run/RecallTask.tsx @@ -211,7 +211,12 @@ function AnswerLink({ answer }: { answer: RecallAnswer }) { ) } +/** 重新出具的那次正式运行:搭建者链到记录页;使用者进不了记录页(点了会被送回应用首页),只写已经出具 */ function IssuedLink({ runId }: { runId: string }) { + const builder = useAtLeast('builder') + if (!builder) { + return {T.issuedUserNote} + } return ( diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index c763383b..42f73195 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -12,7 +12,7 @@ import { evidenceTally, issuanceLabel, nodeTypeLabel, } from '../lib/terms' import { catalogPatchOf, patchBeforeText, patchValueText, patchWhere } from './catalogPatch' -import { RECALL_TEXT } from '../lib/termsRecall' +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' @@ -2441,6 +2441,19 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt 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': { // 答案依据的东西变了(目录事实被驳回或修改、表结构变了、新码值):封存之后追加,标「依据有变」。按新目录多出错误级 // 问题的写几条(服务端已经开了待办) diff --git a/frontend/src/typesRecall.ts b/frontend/src/typesRecall.ts index 84428b4e..1f478a4a 100644 --- a/frontend/src/typesRecall.ts +++ b/frontend/src/typesRecall.ts @@ -78,6 +78,8 @@ export interface RecallBatches { /** 还没重算的几组(「另有 M 组未重算」) */ remaining: number can_continue: boolean + /** 还有没算的组却不能接着算时为什么(之后又发布了更新的版本……);能接着算、都算过了为 null */ + continue_reason?: string | null truncated: boolean launched?: string[] } diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index 48483cf3..21134ddf 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -1935,6 +1935,27 @@ await section('口径召回:封存后的核对结论与依据有变(ledger.r 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。过程里一行说清选用了 // 哪一版、生成了几个节点;拒收的照实写原因(同样交回了模型去改) diff --git a/scripts/check-inbox.mjs b/scripts/check-inbox.mjs index 33031729..edf06083 100644 --- a/scripts/check-inbox.mjs +++ b/scripts/check-inbox.mjs @@ -174,6 +174,18 @@ const TASKS = { 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, @@ -209,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 @@ -247,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\/([^/]+)$/) @@ -526,6 +560,19 @@ await section('口径召回:使用者看到的遮罩值、不能重新出具 && (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"]') @@ -534,7 +581,35 @@ await section('口径召回:使用者看到的遮罩值、不能重新出具 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.errors.length === 0 && p2.errors.length === 0, page.errors[0] ?? p2.errors[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 () => { diff --git a/scripts/check-publish.mjs b/scripts/check-publish.mjs index be84f74a..17dc853e 100644 --- a/scripts/check-publish.mjs +++ b/scripts/check-publish.mjs @@ -2170,6 +2170,11 @@ await section('口径召回:版本历史看召回批次、另有 M 组未重 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 } }) }) @@ -2217,6 +2222,14 @@ await section('口径召回:版本历史看召回批次、另有 M 组未重 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(' | ')) })