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