From cf30f999f673222a18996efcc2d46348812d06a9 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 21 Oct 2026 18:08:59 +0000 Subject: [PATCH] =?UTF-8?q?=E9=AA=8C=E6=94=B6=E5=A4=8D=E6=A0=B8=E6=9F=A5?= =?UTF-8?q?=E5=87=BA=E7=9A=84=E4=B8=89=E5=A4=84=EF=BC=9A=E6=9C=89=E9=87=8F?= =?UTF-8?q?=E7=BA=B2=E6=AF=94=E7=8E=87=E7=9A=84=E6=95=88=E5=BA=94=E5=80=BC?= =?UTF-8?q?=EF=BC=88=E7=BB=93=E6=9E=84=E6=95=88=E5=BA=94=E3=80=81=E8=87=AA?= =?UTF-8?q?=E8=BA=AB=E6=95=88=E5=BA=94=E3=80=81=E5=90=88=E8=AE=A1=E5=BD=B1?= =?UTF-8?q?=E5=93=8D=E3=80=81=E5=8F=98=E5=8C=96=EF=BC=89=E7=85=A7=E5=8E=9F?= =?UTF-8?q?=E5=80=BC=E6=98=BE=E7=A4=BA=EF=BC=8C=E4=B8=8D=E5=86=8D=E4=B9=98?= =?UTF-8?q?=E4=B8=80=E7=99=BE=E8=AF=B4=E6=88=90=E7=99=BE=E5=88=86=E7=82=B9?= =?UTF-8?q?=EF=BC=88=E5=AE=A2=E5=8D=95=E4=BB=B7=E5=BD=B1=E5=93=8D=20-11.12?= =?UTF-8?q?=20=E5=85=83=20/=20=E5=8D=95=E4=BB=A5=E5=89=8D=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=20-1,112.1=20=E4=B8=AA=E7=99=BE=E5=88=86=E7=82=B9?= =?UTF-8?q?=EF=BC=89=EF=BC=9B=E5=8F=AA=E8=AF=BB=E4=B8=80=E5=BC=A0=E8=A1=A8?= =?UTF-8?q?=E3=80=81=E7=9B=AE=E5=BD=95=E8=BF=98=E6=B2=A1=E7=BB=B4=E6=8A=A4?= =?UTF-8?q?=E6=97=B6=E5=88=86=E6=9E=90=E8=8C=83=E5=9B=B4=E7=85=A7=E5=AE=9E?= =?UTF-8?q?=E8=AF=B4=E7=9B=AE=E5=BD=95=E6=B2=A1=E7=BB=B4=E6=8A=A4=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E8=AF=B4=E6=88=90=E5=AF=B9=E4=B8=8D=E4=B8=8A=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E9=87=8C=E7=9A=84=E5=88=97=EF=BC=9B=E5=86=99=E4=BD=9C?= =?UTF-8?q?=E8=A7=84=E5=88=99=E8=A1=A5=E4=B8=80=E6=9D=A1=E4=B8=8D=E5=86=99?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E5=88=A4=E5=AE=9A=E8=AF=AD=E5=BC=80=E5=A4=B4?= =?UTF-8?q?=E7=9A=84=E5=BC=95=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/analysis/decompose.py | 16 ++++++++++------ backend/app/engine/analysis_claims.py | 4 +++- backend/app/engine/analysis_scope.py | 6 ++++++ .../tests/test_analysis_conclusion_formats.py | 5 +++-- backend/tests/test_analysis_scope.py | 13 +++++++++++++ 5 files changed, 35 insertions(+), 9 deletions(-) diff --git a/backend/app/analysis/decompose.py b/backend/app/analysis/decompose.py index 2d872552..a1933a2b 100644 --- a/backend/app/analysis/decompose.py +++ b/backend/app/analysis/decompose.py @@ -693,15 +693,19 @@ def _ratio_result(env: Env, params: dict[str, Any], used: list[str], segs: list[ values["gap"] = canon(max(gaps.values(), key=abs)) verdict = {"code": code, "subjects": [_key_strings(x.key) for x in subjects], "direction": sign_word(delta) if code in ("mix_driven", "rate_driven", "both") else None} + # 显示格式按「这一段(这个数)是占比还是带量纲的比率」分: + # - 各段分子都不超过分母时,整体和各段的比率是个占比(退款率):比率按百分数,结构 / 自身 / 合计影响是比率的变化, + # 按百分点 + # - 否则比率本身带量纲(客单价,元 / 单,可以大于 1):比率照原值;影响也是同一个量纲的变化(元 / 单),照原值, + # 不能乘一百说成「个百分点」——否则判定语写「客单价下降 9.89」、表里同一件事写「-988.7 个百分点」 + ratio_rate = ("r_base", "r_target") if proportion else () + points = ("mix", "rate", "total") if proportion else () + named_points = ("delta", "mix_total", "rate_total") if proportion else () return build(env, params, used, table=table, values=values, verdict=verdict, criterion=criterion, phrase=ph.with_caveats(main, caveats, skip=("comparability",) if outcome is None else ()), caveats=caveats, - # 权重(占比)按百分数;结构效应、自身效应、合计影响是比率的变化,按百分点。各段的比率和整体比率本身: - # 各段分子都不超过分母时它是个占比(退款率),按百分数;否则它是有量纲的比率(客单价,元 / 单,可以大于 1), - # 照原值显示——一律套百分数会把客单价 160.76 显示成 16,076.0% - formats=_rate_formats(columns, rate=("w_base", "w_target") + (("r_base", "r_target") if proportion else ()), - points=("mix", "rate", "total")), - named={k: CONV_POINTS for k in ("delta", "mix_total", "rate_total")} + formats=_rate_formats(columns, rate=("w_base", "w_target") + ratio_rate, points=points), + named={k: CONV_POINTS for k in named_points} | ({k: CONV_PCT for k in ("r_base_total", "r_target_total")} if proportion else {}) | {"mix_share": CONV_PCT, "rate_share": CONV_PCT}) diff --git a/backend/app/engine/analysis_claims.py b/backend/app/engine/analysis_claims.py index 3c72c44f..e7692f95 100644 --- a/backend/app/engine/analysis_claims.py +++ b/backend/app/engine/analysis_claims.py @@ -1130,7 +1130,9 @@ ANALYSIS_RULES_PROMPT = ( "否则写成待核实的推测,比如「可能与促销结束有关,待核实」。\n" "- 判定语整句引用([[a:A1]])时一句只引用一份结论,不要把几份不同维度、不同期间的判定语用逗号拼成一句;" "要说两件事就分成两句,各自挂各自的依据。\n" - "- 「核对不了」「未能核对」是系统对核对情况的说明,不是结论:不要把它写进正文当结论,也不要据此下判断。" + "- 「核对不了」「未能核对」是系统对核对情况的说明,不是结论:不要把它写进正文当结论,也不要据此下判断。\n" + "- 引用判定语时不要再在它前面写一句把它开头重复一遍的引言(「过去 26 周过去 26 周退款率…」「波动基线没有做成:" + "波动基线没有做成:…」):要么直接写判定语,要么写一句自己的话再引用。" ) #: 写作目录里没有分析结论时,拼在写作规则后面 ANALYSIS_NONE_PROMPT = ( diff --git a/backend/app/engine/analysis_scope.py b/backend/app/engine/analysis_scope.py index 54151d8c..c6626778 100644 --- a/backend/app/engine/analysis_scope.py +++ b/backend/app/engine/analysis_scope.py @@ -225,6 +225,7 @@ def _undecomposed(decomposes: list[dict[str, Any]], catalog: Mapping[str, Any], if not snapshots: return None, NOTE_NO_QUERY known: dict[str, tuple[str, str, str]] = {} # 这份拆解能对上的列:name_key → (源, 表, 列) + bare = False # 目录还没维护(没有任何列说明):对不上是目录的事,不是数据的事 for artifact in snapshots: try: snapshot = load(str(artifact)) @@ -245,6 +246,7 @@ def _undecomposed(decomposes: list[dict[str, Any]], catalog: Mapping[str, Any], # 目录还没维护(表类型、字段的度量类型都没标)时说清楚是目录没维护,不说成「认不出事实表」—— # 后者听着像数据的问题,实际上补一下目录就能列 return None, NOTE_NO_FACT if _maintained(tables) else NOTE_NOT_MAINTAINED + bare = bare or not any((n.get("columns") or {}) for _, n in facts) for table, notes in [*facts, *(r for t, n in facts for r in _related(n, tables))]: for column in (notes.get("columns") or {}): known.setdefault(name_key(str(column)), (source, table, name_key(str(column)))) @@ -253,6 +255,10 @@ def _undecomposed(decomposes: list[dict[str, Any]], catalog: Mapping[str, Any], for dim, label in zip(_dims(result), _dim_labels(result)): hit = known.get(name_key(dim)) if hit is None: + # 目录一列说明都没有时,「对不上」是目录没维护造成的(单表查询时 _fact_tables 会兜底认出那张表, + # 走不到上面 NOTE_NOT_MAINTAINED 那一条),照实说目录没维护,不说成数据对不上 + if bare or not _maintained(tables): + return None, NOTE_NOT_MAINTAINED return None, f"候选维度核对不了:拆解按的「{label}」对不上数据目录里的列" used.add(hit) return [label for key, label in pool.items() if key not in used], None diff --git a/backend/tests/test_analysis_conclusion_formats.py b/backend/tests/test_analysis_conclusion_formats.py index 963fd008..7989d309 100644 --- a/backend/tests/test_analysis_conclusion_formats.py +++ b/backend/tests/test_analysis_conclusion_formats.py @@ -369,7 +369,8 @@ def test_a_ratio_with_a_unit_is_not_shown_as_a_percentage(): result = run_sync(analyze("decompose", "ratio", snaps=[snaps], query="Q5", by=["region"], period="week", base="2026-W41", target="2026-W42", numerator="gmv", denominator="orders", label="客单价")) - assert result["table"]["formats"] == {"w_base": CONV_PCT, "w_target": CONV_PCT, "mix": CONV_POINTS, - "rate": CONV_POINTS, "total": CONV_POINTS} + assert result["table"]["formats"] == {"w_base": CONV_PCT, "w_target": CONV_PCT} assert "r_base_total" not in result["value_formats"] and "r_target_total" not in result["value_formats"] + # 影响也是同一个量纲的变化(元 / 单),不能乘一百说成「个百分点」:raw 1.174 不能显示 +117.4 个百分点 + assert [k for k in ("delta", "mix_total", "rate_total") if k in result["value_formats"]] == [] assert result["value_formats"]["mix_share"] == CONV_PCT diff --git a/backend/tests/test_analysis_scope.py b/backend/tests/test_analysis_scope.py index d9e956e9..ffc90918 100644 --- a/backend/tests/test_analysis_scope.py +++ b/backend/tests/test_analysis_scope.py @@ -204,3 +204,16 @@ async def test_the_report_reads_the_catalog_through_the_run_view(shop): assert block["not_decomposed"] == ["渠道", "订单状态", "门店类型", "城市"] block = await scope.scope_of(catalog, {"mode": "off"}, load=lambda a: snapshot) assert block["not_decomposed"] is None and block["note"] == scope.NOTE_NO_CATALOG + + +def test_an_unmaintained_single_table_catalog_says_so_instead_of_a_column_mismatch(): + """V15(验收复核):只读一张表、目录还没维护时,_fact_tables 会兜底认出那张表,于是「对不上数据目录里的列」这条 + 把「目录没维护」说成了数据对不上。目录一列说明都没有时照实说目录没维护。""" + from app.engine.analysis_scope import NOTE_NOT_MAINTAINED, _undecomposed + + result = {"op": "decompose", "mode": "additive", "inputs": [{"role": "data", "artifact": "a" * 64}], + "params": {"by": ["channel"]}, "table": {"columns": ["channel", "base"], "labels": {"channel": "渠道"}}} + sources = {"源": {"tables": {"orders": {}}, "masked": []}} + snapshots = {"a" * 64: {"source": "源", "sql": "SELECT channel, SUM(amount) AS amt FROM orders GROUP BY channel", + "columns": ["channel", "amt"]}} + assert _undecomposed([result], {}, sources, snapshots.get) == (None, NOTE_NOT_MAINTAINED)