From 496dc27419e9095170661b9bbf1fc037b6f3340c Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 21 Oct 2026 15:37:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=AF=94=E7=8E=87=E6=8B=86=E8=A7=A3=E7=9A=84?= =?UTF-8?q?=E6=80=BB=E6=95=B0=E9=97=AD=E5=90=88=E6=A0=B8=E5=AF=B9=EF=BC=9A?= =?UTF-8?q?=E8=AF=81=E6=8D=AE=E9=9D=A2=E6=9D=BF=E7=9A=84=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E4=B8=80=E6=AD=A5=E6=8C=89=E6=8B=86=E8=A7=A3=E7=9A=84=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E8=AE=A4=20headline=20=E5=AF=B9=E7=9D=80=E5=93=AA?= =?UTF-8?q?=E4=B8=AA=E6=95=B0=EF=BC=88=5FHEADLINE=5FTOTALS=20=E5=88=86?= =?UTF-8?q?=E5=8F=AF=E5=8A=A0=E3=80=81=E6=AF=94=E7=8E=87=E4=B8=A4=E5=A5=97?= =?UTF-8?q?=EF=BC=9A=E5=8F=AF=E5=8A=A0=E5=AF=B9=E5=90=84=E6=AE=B5=E4=B9=8B?= =?UTF-8?q?=E5=92=8C=20base=5Ftotal=20/=20target=5Ftotal=EF=BC=8C=E6=AF=94?= =?UTF-8?q?=E7=8E=87=E5=AF=B9=E6=95=B4=E4=BD=93=E6=AF=94=E7=8E=87=E5=8D=B3?= =?UTF-8?q?=E5=90=84=E6=AE=B5=E5=88=86=E5=AD=90=E4=B9=8B=E5=92=8C=20=C3=B7?= =?UTF-8?q?=20=E5=88=86=E6=AF=8D=E4=B9=8B=E5=92=8C=20r=5Fbase=5Ftotal=20/?= =?UTF-8?q?=20r=5Ftarget=5Ftotal=EF=BC=89=EF=BC=8Cclosure=20=E5=B8=A6?= =?UTF-8?q?=E4=B8=8A=20mode=EF=BC=9B=E6=AF=94=E7=8E=87=E6=8B=86=E8=A7=A3?= =?UTF-8?q?=E7=9A=84=E4=B8=A4=E6=A0=BC=E8=A7=92=E8=89=B2=E5=86=99=E3=80=8C?= =?UTF-8?q?=E5=9F=BA=E6=9C=9F=E6=95=B4=E4=BD=93=E6=AF=94=E7=8E=87=E3=80=8D?= =?UTF-8?q?=E3=80=8C=E6=9C=AC=E6=9C=9F=E6=95=B4=E4=BD=93=E6=AF=94=E7=8E=87?= =?UTF-8?q?=E3=80=8D=E3=80=82=E4=BB=A5=E5=89=8D=E5=8F=AA=E8=AE=A4=20base?= =?UTF-8?q?=5Ftotal=20/=20target=5Ftotal=EF=BC=8C=E6=AF=94=E7=8E=87?= =?UTF-8?q?=E7=9A=84=20headline=20=E5=9C=A8=E8=AF=81=E6=8D=AE=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E4=B8=8D=E6=98=BE=E7=A4=BA=E9=97=AD=E5=90=88=E6=A0=B8?= =?UTF-8?q?=E5=AF=B9=E3=80=82=E5=AF=BC=E5=87=BA=E9=A1=B5=EF=BC=88report=5F?= =?UTF-8?q?export/runtime.js=EF=BC=89=E6=AF=94=E7=8E=87=E7=9A=84=E5=86=99?= =?UTF-8?q?=E3=80=8C=E6=95=B4=E4=BD=93=E6=AF=94=E7=8E=87=20x=20=E4=B8=8E?= =?UTF-8?q?=E5=90=84=E6=AE=B5=E5=88=86=E5=AD=90=E4=B9=8B=E5=92=8C=20=C3=B7?= =?UTF-8?q?=20=E5=88=86=E6=AF=8D=E4=B9=8B=E5=92=8C=20y=20=E9=97=AD?= =?UTF-8?q?=E5=90=88=20/=20=E5=AF=B9=E4=B8=8D=E4=B8=8A=E3=80=8D=E3=80=81?= =?UTF-8?q?=E9=82=A3=E4=B8=80=E6=A0=BC=E5=8F=AB=E3=80=8C=E6=95=B4=E4=BD=93?= =?UTF-8?q?=E6=AF=94=E7=8E=87=E9=82=A3=E4=B8=80=E6=A0=BC=E3=80=8D=EF=BC=8C?= =?UTF-8?q?=E5=8F=AF=E5=8A=A0=E7=9A=84=E7=85=A7=E6=97=A7=E3=80=82=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=EF=BC=9A=E5=8F=AF=E5=8A=A0=E6=8B=86=E8=A7=A3=E7=9A=84?= =?UTF-8?q?=20closure=20=E5=B8=A6=20mode=E3=80=81=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E5=8F=AB=E6=B3=95=E4=B8=8D=E5=8F=98=EF=BC=8C=E6=AF=94=E7=8E=87?= =?UTF-8?q?=E6=8B=86=E8=A7=A3=E4=B8=A4=E6=A0=BC=E5=90=84=E8=87=AA=E9=97=AD?= =?UTF-8?q?=E5=90=88=E3=80=81=E5=A4=8D=E7=AE=97=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/evidence.py | 26 +++++++++----- backend/app/report_export/runtime.js | 13 ++++--- backend/tests/test_analysis_evidence_api.py | 38 +++++++++++++++++++-- 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py index 7b1cd16e..9b768b15 100644 --- a/backend/app/api/evidence.py +++ b/backend/app/api/evidence.py @@ -1006,11 +1006,16 @@ _OP_SETTINGS: dict[str, tuple[str, ...]] = { "differ": ("differ_alpha", "min_n", "min_expected"), "comparable": (), } -#: 输入的角色(analysis.contract.AnalysisInput.role)给人看的叫法 +#: 输入的角色(analysis.contract.AnalysisInput.role)给人看的叫法;比率拆解的 headline 是整体比率,不是总数 _INPUT_ROLES = {"data": "数据", "base": "基期", "target": "本期", "headline_base": "基期总数", "headline_target": "本期总数", "parent": "上一层拆解"} -#: 闭合核对:headline 的那一格对着结论里的哪个总数(细案 01 §2.1) -_HEADLINE_TOTALS = {"headline_base": "base_total", "headline_target": "target_total"} +_RATIO_ROLES = {"headline_base": "基期整体比率", "headline_target": "本期整体比率"} +#: 闭合核对:headline 的那一格对着结论里的哪个数(细案 01 §2.1、§2.2)。可加拆解对各段之和(base_total / target_total), +#: 比率拆解对整体比率,即各段分子之和 ÷ 分母之和(r_base_total / r_target_total) +_HEADLINE_TOTALS = { + "additive": {"headline_base": "base_total", "headline_target": "target_total"}, + "ratio": {"headline_base": "r_base_total", "headline_target": "r_target_total"}, +} #: 计算一步最多给结果表的几行(前 table_top_k 段,加上被引用的那一行前后各 WINDOW 行) MAX_ANALYSIS_ROWS = MAX_WINDOW_ROWS @@ -1181,11 +1186,15 @@ async def _analysis_inputs(alias: str, result: dict[str, Any], catalog: dict[str sealed: _Sealed, masks: _Masks, *, queries: bool = True ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: """输入:每份一步(analysis_input),再给每份输入快照一个查询步骤(_query_steps,高亮用到的列;整份用到的给前 - MAX_WINDOW_ROWS 行;queries 为假时不给)。headline 的那一格另核对和结论里的总数闭不闭合(照实写判定是不是 - unclosed,不在这里重算容差);下钻的上一层落到报告目录的哪个 A。""" + MAX_WINDOW_ROWS 行;queries 为假时不给)。headline 的那一格另核对和结论里的数闭不闭合(_HEADLINE_TOTALS:可加拆解是 + 各段之和,比率拆解是整体比率;closure.mode 写明是哪一种,照实写判定是不是 unclosed,不在这里重算容差);下钻的上一层 + 落到报告目录的哪个 A。""" values = result.get("values") if isinstance(result.get("values"), dict) else {} verdict = result.get("verdict") if isinstance(result.get("verdict"), dict) else {} unclosed = verdict.get("code") == "unclosed" + mode = str(result.get("mode") or "") + totals = _HEADLINE_TOTALS.get(mode, {}) if result.get("op") == "decompose" else {} + roles = {**_INPUT_ROLES, **(_RATIO_ROLES if mode == "ratio" else {})} steps: list[dict[str, Any]] = [] cells: dict[str, list[tuple[Any, Any]]] = {} for item in result.get("inputs") or []: @@ -1195,7 +1204,7 @@ async def _analysis_inputs(alias: str, result: dict[str, Any], catalog: dict[str role = str(item.get("role") or "") step: dict[str, Any] = { "step": "analysis_input", "of": alias, "ref": item.get("ref"), "role": role, - "role_label": _INPUT_ROLES.get(role, role or "输入"), "artifact": artifact or None, + "role_label": roles.get(role, role or "输入"), "artifact": artifact or None, **{k: item[k] for k in ("columns", "rows", "row", "column", "value") if k in item}, } if role == "parent": @@ -1208,10 +1217,9 @@ async def _analysis_inputs(alias: str, result: dict[str, Any], catalog: dict[str query = sealed.queries.get(artifact) step.update(query=_catalog_alias_of(catalog, artifact), node_id=(query or {}).get("node_id"), sealed=sealed.trusted and query is not None) - if role in _HEADLINE_TOTALS and _HEADLINE_TOTALS[role] in values: - total = values[_HEADLINE_TOTALS[role]] + if role in totals and totals[role] in values: with contextlib.suppress(RenderError): - step["closure"] = {"total_key": _HEADLINE_TOTALS[role], "total": render_cell(total), + step["closure"] = {"mode": mode, "total_key": totals[role], "total": render_cell(values[totals[role]]), "headline": render_cell(item.get("value")), "closed": not unclosed} steps.append(step) if artifact and query is not None: diff --git a/backend/app/report_export/runtime.js b/backend/app/report_export/runtime.js index a98595ad..1d574e44 100644 --- a/backend/app/report_export/runtime.js +++ b/backend/app/report_export/runtime.js @@ -95,8 +95,10 @@ calc: '计算', dValues: '具名的数', dFormulas: '关键数的代入式', dResultTable: '结果表', resultRows: (shown, total) => `列出 ${shown} 行,共 ${total} 行`, analysisInput: '输入', dRole: '角色', dRef: '调用里写的名字', dColumnsUsed: '用到的列', dRowsUsed: '用到的行', - allRows: '全部行', dHeadline: '总数那一格', dClosure: '闭合核对', + allRows: '全部行', dHeadline: '总数那一格', dHeadlineRatio: '整体比率那一格', dClosure: '闭合核对', closureOk: (h, t) => `总数 ${h} 与各段之和 ${t} 闭合`, closureBad: (h, t) => `总数 ${h} 与各段之和 ${t} 对不上`, + ratioClosureOk: (h, t) => `整体比率 ${h} 与各段分子之和 ÷ 分母之和 ${t} 闭合`, + ratioClosureBad: (h, t) => `整体比率 ${h} 与各段分子之和 ÷ 分母之和 ${t} 对不上`, dParent: '下钻自', recompute: '复算', seal: '封存', inputOfAnalysis: (alias) => `分析结论 ${alias} 的输入`, inLedger: '封存范围内的台账记过这份分析结论', notInLedger: '封存范围内的台账里没有这份分析结论', viaCard: '记在口径卡的指标集里,随卡封存(不进台账)', @@ -541,10 +543,13 @@ return h('section', { class: 'part', 'data-step': 'analysis_calc' }, h('h3', {}, T.calc), h('div', { class: 'part-body' }, ...body)) } - /** 分析结论 · 输入:角色、落到哪个 Q、用到的列和行、总数那一格和闭合核对、下钻的上一层 */ + /** 分析结论 · 输入:角色、落到哪个 Q、用到的列和行、总数(比率拆解是整体比率)那一格和闭合核对、下钻的上一层 */ function analysisInputPart(step) { const rows = Array.isArray(step.rows) ? step.rows.map((r) => `第 ${Number(r) + 1} 行`).join('、') : step.rows === 'all' ? T.allRows : null const closure = isObj(step.closure) ? step.closure : null + const ratio = closure !== null && closure.mode === 'ratio' + const closed = closure && (ratio ? (closure.closed ? T.ratioClosureOk : T.ratioClosureBad) + : (closure.closed ? T.closureOk : T.closureBad)) const body = [kv([ [T.dRole, String(step.role_label || step.role || NONE)], step.ref ? [T.dRef, h('code', {}, String(step.ref))] : null, @@ -553,8 +558,8 @@ step.node_id ? [T.dNode, nodeLabel(step.node_id)] : null, Array.isArray(step.columns) && step.columns.length ? [T.dColumnsUsed, step.columns.join('、')] : null, rows ? [T.dRowsUsed, rows] : null, - 'value' in step ? [T.dHeadline, `${step.column ? `第 ${Number(step.row) + 1} 行「${step.column}」 = ` : ''}${valueText(step.value)}`] : null, - closure ? [T.dClosure, closure.closed ? T.closureOk(closure.headline, closure.total) : T.closureBad(closure.headline, closure.total)] : null, + 'value' in step ? [ratio ? T.dHeadlineRatio : T.dHeadline, `${step.column ? `第 ${Number(step.row) + 1} 行「${step.column}」 = ` : ''}${valueText(step.value)}`] : null, + closure ? [T.dClosure, closed(closure.headline, closure.total)] : null, step.phrase ? [T.dPhrase, String(step.phrase)] : null, ])] const bits = integrity(step) diff --git a/backend/tests/test_analysis_evidence_api.py b/backend/tests/test_analysis_evidence_api.py index 451b23f3..cb1b891d 100644 --- a/backend/tests/test_analysis_evidence_api.py +++ b/backend/tests/test_analysis_evidence_api.py @@ -305,12 +305,46 @@ async def test_headline_inputs_show_the_closure(client): body = (await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'a:A1')}")).json() inputs = [s for s in body["chain"] if s["step"] == "analysis_input"] assert [s["role"] for s in inputs] == ["headline_base", "headline_target", "data"] - assert inputs[0]["closure"] == {"total_key": "base_total", "total": "2,220", "headline": "2,220", "closed": True} - assert inputs[1]["closure"] == {"total_key": "target_total", "total": "2,026", "headline": "2,026", "closed": True} + assert inputs[0]["closure"] == {"mode": "additive", "total_key": "base_total", "total": "2,220", "headline": "2,220", + "closed": True} + assert inputs[1]["closure"] == {"mode": "additive", "total_key": "target_total", "total": "2,026", + "headline": "2,026", "closed": True} + assert [s["role_label"] for s in inputs[:2]] == ["基期总数", "本期总数"] assert inputs[1]["query"] == "Q2" and inputs[1]["sealed"] is True and inputs[2]["query"] == "Q1" assert next(s for s in body["chain"] if s["step"] == "analysis_recompute")["status"] == "same" +async def test_a_ratio_headline_closes_against_the_overall_ratio(client): + """比率拆解给了整体比率的两格(headline):闭合核对对着结论里的整体比率,即各段分子之和 ÷ 分母之和(r_base_total、 + r_target_total),角色写「基期整体比率」「本期整体比率」,closure.mode 写明是比率(导出页照它说「各段分子之和 ÷ 分母 + 之和」,不说「各段之和」)。以前只认可加拆解的总数键,比率拆解的 headline 不显示闭合核对。""" + rates = {"columns": ["channel", "week", "refunds", "orders"], + "column_types": {"channel": "text", "week": "text", "refunds": "number", "orders": "number"}, + "rows": [["线上", "W41", 50, 1000], ["线上", "W42", 80, 1000], ["线下", "W41", 20, 1000], + ["线下", "W42", 26, 1000]], + "sql": "SELECT channel, week, SUM(refund_flag) AS refunds, COUNT(*) AS orders FROM orders GROUP BY 1, 2", + "source": "shop", "truncated": False, "row_count": 4} + head = {"columns": ["week", "rate"], "column_types": {"week": "text", "rate": "number"}, + "rows": [["W41", "0.035"], ["W42", "0.053"]], + "sql": "SELECT week, SUM(refund_flag) * 1.0 / COUNT(*) AS rate FROM orders GROUP BY week", + "source": "shop", "truncated": False, "row_count": 2} + r, h = (artifact_store.content_hash(artifact_store.canonical_json(c)) for c in (rates, head)) + result = await analyzed({"Q2": (rates, r), "Q5": (head, h)}, {"op": "decompose", "mode": "ratio", "params": {}}, + query="Q2", by=["channel"], period="week", base="W41", target="W42", numerator="refunds", + denominator="orders", label="退款率", headline={"base": "Q5.r0.rate", "target": "Q5.r1.rate"}) + assert result["status"] == "ok" and result["verdict"]["code"] != "unclosed" + made = await sealed_run("[[a:A1]]。", result=result, queries=[rates, head]) + body = (await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'a:A1')}")).json() + inputs = {s["role"]: s for s in body["chain"] if s["step"] == "analysis_input"} + assert inputs["headline_base"]["closure"] == {"mode": "ratio", "total_key": "r_base_total", "total": "0.035", + "headline": "0.035", "closed": True} + assert inputs["headline_target"]["closure"] == {"mode": "ratio", "total_key": "r_target_total", "total": "0.053", + "headline": "0.053", "closed": True} + assert inputs["headline_base"]["role_label"] == "基期整体比率" + assert inputs["headline_target"]["role_label"] == "本期整体比率" + assert next(s for s in body["chain"] if s["step"] == "analysis_recompute")["status"] == "same" + + async def test_a_drill_down_points_at_its_parent_and_recomputes_from_it(client): """下钻(华东按渠道再拆):输入里有上一层结论(parent),落到报告目录的 A1;复算按 sha256 取回台账记过的上一层。""" q = artifact_store.content_hash(artifact_store.canonical_json(SNAPSHOT))