对账要人选对照时按逐格比对的结论预选:这一列单独比和口径取数的某一列等价(ledger/match._cells 证明了),口径卡上只读那一格的指标就是它最可能的对照,409 的 options 里给 suggested;对账框照它预选、标「预选」,人可以改。只是「相关」的那一格不预选(比到的是第一个试的列,不是最像的),一个口径列对上几个指标的也不预选

This commit is contained in:
ThearchyHelios
2026-10-19 10:05:04 +08:00
parent aee237f457
commit 71d535c4ce
6 changed files with 110 additions and 6 deletions
+42 -4
View File
@@ -350,9 +350,11 @@ def _plain_cell(expression: str, fetch: str) -> str | None:
def plan_pairs(*, ours_columns: list[str], ours_rows: list[Any], hidden: set[str], caliber_columns: list[str] | None,
metrics: list[dict[str, Any]], fetch: str, card: str, fallback: bool, chosen: Any,
referenced: list[tuple[int, str]], projected: Any = None) -> Plan:
referenced: list[tuple[int, str]], projected: Any = None, cells: Any = None,
caliber: str | None = None) -> Plan:
"""配列、(多行时)定行和键:交回 Plan,配不了的抛 ReconcileRefused(见模块说明)。projected 是比对候选的 projection
(按引用的列投影过的:{ours: [这次留下的列], caliber: [口径留下的列]},按位置一一对应)。纯计算。"""
(按引用的列投影过的:{ours: [这次留下的列], caliber: [口径留下的列]},按位置一一对应)。cells 是这条查询逐格比的结论
(ledger/match._cells),caliber 是这份口径的键:要人选对照时按它预选(_suggested)。纯计算。"""
def check_column(column: str) -> None:
if column not in ours_columns:
raise ReconcileRefused(422, PAIR_COLUMN.format(column=column))
@@ -365,7 +367,8 @@ def plan_pairs(*, ours_columns: list[str], ours_rows: list[Any], hidden: set[str
numeric = [c for i, c in enumerate(ours_columns) if c.lower() not in hidden and _numeric(ours_rows, i)]
options = {"options": {"columns": numeric,
"metrics": [{"id": str(m.get("id")), "name": str(m.get("name") or m.get("id")),
"unit": str(m.get("unit") or "")} for m in metrics if m.get("id")]}}
"unit": str(m.get("unit") or "")} for m in metrics if m.get("id")],
"suggested": _suggested(cells, caliber, fetch, numeric, metrics)}}
if multi:
return _plan_rows(ours_columns, ours_rows, hidden, caliber_columns, referenced, check_column)
if chosen is not None:
@@ -409,6 +412,40 @@ def plan_pairs(*, ours_columns: list[str], ours_rows: list[Any], hidden: set[str
return Plan(pairs=pairs)
def _suggested(cells: Any, caliber: str | None, fetch: str, columns: list[str],
metrics: list[dict[str, Any]]) -> list[dict[str, str]]:
"""要人选对照时预选哪几对:[{ours, metric}]。按逐格比的结论(ledger/match._cells)——这次结果的这一列单独比,和这份口径
取数节点的某一列等价(逐格证明了,projection.caliber 是那一列),口径卡上只读这一列这一格的指标就是它最可能的对照。只认
证明了的:只是「相关」的那一格比到的是第一个试的列,不是最像的,不替人猜;一个口径列对上几个指标的也不预选。界面照它
预选、人可以改。"""
from app.engine.expressions import ExpressionError, cell_parts, leaf_refs, parse_expression
if not isinstance(cells, dict) or not caliber:
return []
reads: dict[str, list[str]] = {}
for m in metrics:
try:
leaves = leaf_refs(parse_expression(str(m.get("expression") or ""))[0])
except ExpressionError:
continue
cols = {_plain_cell(str(leaf), fetch) for leaf in leaves if cell_parts(str(leaf)) is not None}
if len(leaves) == 1 and len(cols) == 1 and None not in cols and m.get("id"):
reads.setdefault(str(next(iter(cols))).lower(), []).append(str(m["id"]))
out: list[dict[str, str]] = []
for col in columns:
cell = cells.get(col)
cands = cell.get("candidates") if isinstance(cell, dict) else None
proven = next((c for c in cands or [] if isinstance(c, dict) and c.get("status") == "proven"
and c.get("caliber") == caliber and c.get("node_id") == fetch), None)
theirs = ((proven or {}).get("projection") or {}).get("caliber") if proven else None
if not isinstance(theirs, list) or len(theirs) != 1 or not isinstance(theirs[0], str):
continue
hits = reads.get(theirs[0].lower()) or []
if len(hits) == 1:
out.append({"ours": col, "metric": hits[0]})
return out
def _projected_pairs(projected: Any, ours_columns: list[str]) -> list[tuple[str, str]] | None:
"""比对候选的 projection → [(这次的列, 口径的列)],按位置一一对应;不是投影过的、两边列数不一样、这次的列不在结果里的
为 None(照旧按整条的位置配)。"""
@@ -583,7 +620,8 @@ async def reconcile(run_id: str, req: Request, *, principal: Any) -> Run:
caliber_columns=_caliber_columns(template, inputs, list(pinned.entry_fields), kind, schema),
metrics=pinned.metrics, fetch=str(fetch["id"]), card=pinned.card_title,
fallback=bool((cand.get("diff") or {}).get("fallback")), chosen=req.pairs,
referenced=_referenced(sealed, req.query), projected=cand.get("projection"))
referenced=_referenced(sealed, req.query), projected=cand.get("projection"),
cells=query.get("cells"), caliber=key)
graph = build(pinned, sql=snap["sql"], tool=f"{QUERY_PREFIX}{source}", plan=plan, inputs=inputs,
fetch=str(fetch["id"]))
+15
View File
@@ -280,3 +280,18 @@ async def test_multi_row_reconcile_groups_carry_the_row_labels(client, monkeypat
assert [g["label"] for g in listed["groups"]] == ["区域=east · gmv", "区域=west · gmv"]
assert listed["groups"][1] == {"label": "区域=west · gmv", "ours": "ours_1_0", "caliber": "cal_1_0", "diff": "diff_1_0",
"rel": "rel_1_0"}
async def test_a_related_query_prefills_the_pairing_from_the_cells_that_were_proven(client, monkeypatch):
"""要人选对照时,按逐格比过的结论预选:这次的 gmv 那一列单独比是和口径取数的 gmv 等价的(逐格证明了),口径卡上读这一列的
指标就是它最可能的对照,options.suggested 给出来,界面预选上、人可以改;单数那一列没有证明,不替人猜。"""
wf = await S.publish(graph=S.caliber_graph(expression="cell(nodes.fetch, 0, 'gmv') * 1"))
sql = ("SELECT SUM(amount) AS gmv, COUNT(*) AS 单数 FROM orders WHERE status = 'paid' "
"AND paid_at >= '2026-09-01' AND paid_at < '2026-09-08'")
row = await S.explore(monkeypatch, sql, text="本周销售额 [[v:Q1.r0.gmv]],共 [[v:Q1.r0.单数]] 单。")
[action] = [a for a in (await ledger_of(client, row.id))["actions"] if a["kind"] == "reconcile"]
body = {"query": action["query"], "workflow_id": wf, "version": 1, "inputs": CHECKED, "confirm": True}
res = await client.post(f"/api/ledger/answers/{row.id}/reconcile", json=body)
assert res.status_code == 409 and res.json()["code"] == ledger_reconcile.NEEDS_PAIRS, res.text
options = res.json()["options"]
assert options["suggested"] == [{"ours": "gmv", "metric": "gmv"}], options
+3
View File
@@ -87,6 +87,9 @@ export const LEDGER_UI = {
maskedPlaceholder: '已遮罩,请重新填写',
pairs: '选择要对照的指标',
pairsHint: '认不出这次的哪一列对应口径的哪个指标,请为要对照的列选一个口径卡上的指标',
/** 有预选的:按逐格比对证明了的那几列预选上,人可以改 */
pairsSuggested: '已按逐格比对的结论预选了能证明对应的列,请核对,可以改选',
pairSuggestedTag: '预选',
pairNone: '不对照',
pairEmpty: '至少选一组对照',
launch: '发起对账',
+12 -2
View File
@@ -84,7 +84,12 @@ export function ReconcileDialog({ runId, action, related, onClose }: {
} catch (e) {
if (e instanceof ApiError && e.code === NEEDS_PAIRS) {
const raw = (e.body as { options?: LedgerPairOptions } | undefined)?.options
if (raw && Array.isArray(raw.columns) && Array.isArray(raw.metrics)) setOptions(raw)
if (raw && Array.isArray(raw.columns) && Array.isArray(raw.metrics)) {
setOptions(raw)
// 逐格证明了的那几列预选上(人可以改);已经选过的不盖掉
const picks = (raw.suggested ?? []).filter((s) => raw.columns.includes(s.ours) && raw.metrics.some((m) => m.id === s.metric))
if (picks.length) setPairs((p) => ({ ...Object.fromEntries(picks.map((s) => [s.ours, s.metric])), ...p }))
}
setProblem(e.message)
} else if (e instanceof ApiError && e.code === CHECK_INPUTS) {
setEditing(true)
@@ -152,7 +157,9 @@ export function ReconcileDialog({ runId, action, related, onClose }: {
{options && (
<section data-ledger-pairs="">
<div className="mb-1 text-2xs font-medium text-faint">{LEDGER_UI.reconcile.pairs}</div>
<p className="mb-1.5 text-2xs text-dim">{LEDGER_UI.reconcile.pairsHint}</p>
<p className="mb-1.5 text-2xs text-dim">
{options.suggested?.length ? LEDGER_UI.reconcile.pairsSuggested : LEDGER_UI.reconcile.pairsHint}
</p>
<div className="space-y-1.5">
{options.columns.map((col) => (
<label key={col} className="flex items-center gap-2 text-2xs">
@@ -165,6 +172,9 @@ export function ReconcileDialog({ runId, action, related, onClose }: {
<option key={m.id} value={m.id}>{m.name}{m.unit ? `(${m.unit})` : ''}</option>
))}
</select>
{options.suggested?.some((s) => s.ours === col && s.metric === pairs[col]) && (
<span className="chip shrink-0" data-ledger-pair-suggested={col}>{LEDGER_UI.reconcile.pairSuggestedTag}</span>
)}
</label>
))}
</div>
+5
View File
@@ -305,6 +305,11 @@ export interface LedgerReconcileOut {
export interface LedgerPairOptions {
columns: string[]
metrics: { id: string; name: string; unit?: string }[]
/**
* 预选的对照(服务端 ledger/reconcile._suggested):这一列单独比和口径取数的某一列等价(逐格证明了),口径卡上只读那一格的
* 指标。界面照它预选、人可以改;老服务端不给
*/
suggested?: { ours: string; metric: string }[]
}
// ---------------------------------------------------------------- 认证队列(可沉淀的问题)
+33
View File
@@ -306,6 +306,39 @@ await section('口径对账:核对参数 → 发起 → 选对照的指标 →
// ---------------------------------------------------------------- 「按口径」那个数的出处跨卡追到取数查询
await section('口径对账:逐格证明了的列预选上对照,人可以改', async () => {
// 服务端按逐格比对(ledger/match._cells)证明了的那一列给出预选(options.suggested),界面照它预选、标「预选」
const posts = []
const extra = (method, p, body) => {
if (method === 'POST' && p === `/ledger/answers/${RUN}/reconcile`) {
posts.push(body)
if (!body?.pairs) {
return { status: 409, json: { detail: '认不出这次的哪一列对应口径的哪个指标,请选择要对照的指标', code: 'ledger_reconcile_needs_pairs',
options: { columns: ['orders', 'refunds'], metrics: [{ id: 'orders', name: '订单数', unit: '单' }],
suggested: [{ ours: 'orders', metric: 'orders' }] } } }
}
return { status: 201, json: { run_id: 'run-rec-9', reconciled: reconciledOf('running') } }
}
return null
}
const { page, errors } = await open('/ui-harness.html?evidence=1', { reply: evidenceReply(() => ledgerOf(RUN, {}), extra) })
await page.waitForSelector('#evidence-wide [data-seg="s8"]', { timeout: 10000 })
await openSeg(page, 's8')
await page.locator('[data-evidence-panel] [data-ledger-reconcile-open]').click()
await page.locator('[data-ledger-reconcile-dialog]').waitFor({ timeout: 4000 })
await page.locator('[data-ledger-reconcile-launch]').click()
await page.locator('[data-ledger-pairs]').waitFor({ timeout: 4000 })
check('证明了的那一列预选上对照的指标', await page.locator('[data-ledger-pair="orders"]').inputValue() === 'orders')
check('……标「预选」,说明可以改选', await page.locator('[data-ledger-pair-suggested="orders"]').count() === 1
&& (await page.locator('[data-ledger-pairs]').innerText()).includes('可以改选'))
check('没证明的那一列不替人猜', await page.locator('[data-ledger-pair="refunds"]').inputValue() === '')
await page.locator('[data-ledger-reconcile-launch]').click()
await until(() => posts.length === 2)
check('直接发起:带上预选的对照', JSON.stringify(posts[1]?.pairs) === JSON.stringify([{ ours: 'orders', metric: 'orders' }]),
JSON.stringify(posts[1]))
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
await section('对账结果里「按口径」那个数:出处跨卡追到口径的取数查询', async () => {
const extra = (method, p) => {
const m = p.match(/^\/runs\/run-rec-1\/evidence\/metrics\/reconcile\/([^/]+)$/)