From fd8e392f489836d90199b2c773fc0b7e3fe8a0cf Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 21 Oct 2026 16:02:44 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=B4=E5=BA=A6=E5=88=97=E3=80=81=E6=9C=9F?= =?UTF-8?q?=E6=AC=A1=E5=88=97=E5=92=8C=E7=BB=93=E6=9E=9C=E8=A1=A8=E7=9A=84?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E5=88=97=E9=87=8D=E5=90=8D=E6=97=B6=E6=8A=A5?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E9=94=99=E8=AF=AF=EF=BC=88=E5=A4=8D=E6=A0=B8?= =?UTF-8?q?=20R17=EF=BC=89=EF=BC=9A=E7=BB=93=E6=9E=9C=E8=A1=A8=E7=9A=84?= =?UTF-8?q?=E5=88=97=20id=20=E6=98=AF=E8=BE=93=E5=85=A5=E7=9A=84=E7=BB=B4?= =?UTF-8?q?=E5=BA=A6=E5=88=97=EF=BC=88=E6=9C=9F=E6=AC=A1=E5=88=97=EF=BC=89?= =?UTF-8?q?=E5=8A=A0=E4=B8=8A=E5=9B=BA=E5=AE=9A=E7=9A=84=E5=87=A0=E5=88=97?= =?UTF-8?q?=EF=BC=8C=E7=BB=B4=E5=BA=A6=E5=88=97=E5=8F=AB=20flag=E3=80=81ba?= =?UTF-8?q?se=E3=80=81target=E3=80=81delta=E3=80=81share=EF=BC=88=E5=8F=AF?= =?UTF-8?q?=E5=8A=A0=EF=BC=89=E6=88=96=20mix=E3=80=81rate=E3=80=81total?= =?UTF-8?q?=E3=80=81w=5Fbase=E2=80=A6=E2=80=A6=EF=BC=88=E6=AF=94=E7=8E=87?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E6=9C=9F=E6=AC=A1=E5=88=97=E5=8F=AB=20value?= =?UTF-8?q?=E3=80=81change=E3=80=81role=EF=BC=88=E6=B3=A2=E5=8A=A8?= =?UTF-8?q?=E5=9F=BA=E7=BA=BF=E3=80=81=E8=B6=8B=E5=8A=BF=EF=BC=89=E6=97=B6?= =?UTF-8?q?=E5=88=97=20id=20=E9=87=8D=E5=A4=8D=EF=BC=8C=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E8=A1=A8=E7=9A=84=E6=A0=BC=E4=BC=9A=E8=AE=A4?= =?UTF-8?q?=E9=94=99=EF=BC=8C=E4=B8=8B=E9=92=BB=E8=BF=98=E4=BC=9A=E6=8A=A5?= =?UTF-8?q?=E3=80=8CA1=20=E6=8C=89=200=20=E5=88=97=E6=8B=86=E3=80=8D?= =?UTF-8?q?=E3=80=82common.reserved=5Fcheck=20=E4=B8=8D=E5=88=86=E5=A4=A7?= =?UTF-8?q?=E5=B0=8F=E5=86=99=E6=8C=A1=E4=BD=8F=E8=BF=99=E4=BA=9B=E5=90=8D?= =?UTF-8?q?=E5=AD=97=EF=BC=8C=E5=91=8A=E8=AF=89=20agent=20=E5=9C=A8=20SQL?= =?UTF-8?q?=20=E9=87=8C=E6=8D=A2=E4=B8=AA=E5=88=AB=E5=90=8D=EF=BC=88?= =?UTF-8?q?=E6=AF=94=E5=A6=82=20AS=20flag=5Fname=EF=BC=89=EF=BC=9B?= =?UTF-8?q?=E8=BF=99=E6=98=AF=E7=BB=93=E6=9E=84=E6=80=A7=E7=9A=84=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E9=94=99=E8=AF=AF=EF=BC=8C=E5=88=86=E6=9E=90=E5=8D=A1?= =?UTF-8?q?=E9=87=8C=E7=85=A7=E6=97=A7=E8=AE=A9=E5=8D=A1=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/analysis/common.py | 10 +++++++++- backend/app/analysis/decompose.py | 18 +++++++++++------- backend/app/analysis/series.py | 8 ++++++-- backend/tests/test_analysis_decompose.py | 17 +++++++++++++++++ backend/tests/test_analysis_series.py | 8 ++++++++ 5 files changed, 51 insertions(+), 10 deletions(-) diff --git a/backend/app/analysis/common.py b/backend/app/analysis/common.py index b497e83f..3c278c5b 100644 --- a/backend/app/analysis/common.py +++ b/backend/app/analysis/common.py @@ -106,6 +106,14 @@ def need(raw: Mapping[str, Any], key: str, what: str) -> Any: return value +def reserved_check(column: str, reserved: Sequence[str], what: str) -> None: + """结果表的列 id 是输入的维度列(期次列)加上固定的几列:重名了列 id 就重复,引用结果表的格、下钻按的维度数都会认错。 + 遇到这些名字(不分大小写)是调用错误,让 agent 在 SQL 里换个别名。""" + if column.lower() in {r.lower() for r in reserved}: + raise CallError("reserved_column", f"{what}「{column}」和结果表的列重名(结果表要用 {'、'.join(reserved)} 这几个名字):" + f"在 SQL 里给它换个别名再分析,比如 AS {column}_name") + + def written_places(raw: Any) -> int: """快照里一格写到第几位小数(headline 是取整过的,按它的书写精度容差):数字文本按字面,浮点按最短写法,整数为 0。是整数 的浮点(SQLite 的 ROUND() 交回 2120.0)是取到个位的,也为 0——最短写法里的「.0」不是书写精度。""" @@ -152,4 +160,4 @@ def sign_word(value: Fraction | float | int) -> str | None: __all__ = ["DERIVED_PARAMS", "Env", "build", "closes", "column_label", "column_types", "label_for", "need", "refuse", - "sign_word", "today_param", "written_places"] + "reserved_check", "sign_word", "today_param", "written_places"] diff --git a/backend/app/analysis/decompose.py b/backend/app/analysis/decompose.py index 8d09e453..1efc4291 100644 --- a/backend/app/analysis/decompose.py +++ b/backend/app/analysis/decompose.py @@ -26,8 +26,8 @@ from typing import Any from app.analysis import comparable as cmp from app.analysis import phrases as ph -from app.analysis.common import (Env, build, closes, column_label, column_types, label_for, need, refuse, sign_word, - today_param) +from app.analysis.common import (Env, build, closes, column_label, column_types, label_for, need, refuse, + reserved_check, sign_word, today_param) from app.analysis.contract import CallError from app.analysis.inputs import Snap, as_list, hashable, same_value from app.analysis.numbers import canon, to_fraction @@ -81,9 +81,10 @@ def _key_strings(key: Sequence[Any]) -> list[str]: return [ph.key_text([k]) for k in key] -def _sides(raw: Mapping[str, Any], env: Env, roles: Sequence[tuple[str, str]], *, by_needed: bool = True - ) -> tuple[dict[str, Any], Snap, Snap, dict[str, str], list[str]]: - """一份或两份快照:(规范化的参数, 基期快照, 本期快照, {角色: 列名}, 维度列)。roles 是 [(参数名, 角色的叫法)]。""" +def _sides(raw: Mapping[str, Any], env: Env, roles: Sequence[tuple[str, str]], *, by_needed: bool = True, + reserved: Sequence[str] = ()) -> tuple[dict[str, Any], Snap, Snap, dict[str, str], list[str]]: + """一份或两份快照:(规范化的参数, 基期快照, 本期快照, {角色: 列名}, 维度列)。roles 是 [(参数名, 角色的叫法)];reserved 是 + 结果表固定的列名,维度列不能叫这些。""" params: dict[str, Any] = {} two = raw.get("base_query") is not None or raw.get("target_query") is not None if two: @@ -105,6 +106,8 @@ def _sides(raw: Mapping[str, Any], env: Env, roles: Sequence[tuple[str, str]], * if two: for c in by: base.column(c, "维度列") + for c in by: + reserved_check(c, reserved, "维度列") params["by"] = by cols: dict[str, str] = {} for key, what in roles: @@ -284,7 +287,7 @@ def _share_rank(segments: Sequence[Any], share: Any) -> list[Any]: def additive(raw: Mapping[str, Any], env: Env) -> Any: used = list(USED["additive"]) - params, bs, ts, cols, by = _sides(raw, env, [("measure", "度量列")]) + params, bs, ts, cols, by = _sides(raw, env, [("measure", "度量列")], reserved=_FIXED_ADDITIVE) measure = cols["measure"] label = label_for(env, raw.get("label"), ts, measure) if raw.get("label") is not None: @@ -444,7 +447,8 @@ def _additive_result(env: Env, params: dict[str, Any], used: list[str], segs: li def ratio(raw: Mapping[str, Any], env: Env) -> Any: used = list(USED["ratio"]) - params, bs, ts, cols, by = _sides(raw, env, [("numerator", "分子列"), ("denominator", "分母列")]) + params, bs, ts, cols, by = _sides(raw, env, [("numerator", "分子列"), ("denominator", "分母列")], + reserved=_FIXED_RATIO) num, den = cols["numerator"], cols["denominator"] num_label, den_label = column_label(env, ts, num), column_label(env, ts, den) label = ph.pick_label(raw.get("label"), ph.say(num_label, "与", den_label, "之比")) diff --git a/backend/app/analysis/series.py b/backend/app/analysis/series.py index 840f4d56..c9b57687 100644 --- a/backend/app/analysis/series.py +++ b/backend/app/analysis/series.py @@ -31,7 +31,8 @@ from app.analysis import comparable as cmp from app.analysis import periods as pd from app.analysis import phrases as ph from app.analysis import stats -from app.analysis.common import Env, build, column_label, column_types, label_for, need, refuse, sign_word, today_param +from app.analysis.common import (Env, build, column_label, column_types, label_for, need, refuse, reserved_check, + sign_word, today_param) from app.analysis.contract import CallError from app.analysis.inputs import Snap, same_value from app.analysis.numbers import canon, to_fraction @@ -45,6 +46,8 @@ USED: dict[str, tuple[str, ...]] = { "baseline": ("k_warn", "k_alert", "min_history", "max_history"), "trend": ("trend_min_points", "trend_alpha", "max_history"), } +#: 结果表在期次列之外固定的几列(期次列不能叫这些) +FIXED: dict[str, tuple[str, ...]] = {"baseline": ("value", "change", "role"), "trend": ("value", "change")} def refusal_basis(op: str, settings: Mapping[str, Any]) -> tuple[list[str], str]: @@ -83,6 +86,7 @@ def _load(raw: Mapping[str, Any], env: Env, used: list[str]) -> Series | Any: """读出一条序列;算不了时直接交回 refused 的结论。""" snap = env.inputs.snap(need(raw, "query", "查询编号"), "查询编号") period = snap.column(need(raw, "period", "期次列"), "期次列") + reserved_check(period, FIXED[env.op], "期次列") measure = snap.column(need(raw, "measure", "度量列"), "度量列") grain = raw.get("grain") if grain is not None and grain not in (*pd.GRAINS, "seq"): @@ -369,4 +373,4 @@ def _tail_gap(series: Series, ordinal: int) -> str: return "再往前缺期" -__all__ = ["MAD_SCALE", "USED", "baseline", "refusal_basis", "run_length", "trend", "unreadable_text"] +__all__ = ["FIXED", "MAD_SCALE", "USED", "baseline", "refusal_basis", "run_length", "trend", "unreadable_text"] diff --git a/backend/tests/test_analysis_decompose.py b/backend/tests/test_analysis_decompose.py index 7cb21a10..67ab1946 100644 --- a/backend/tests/test_analysis_decompose.py +++ b/backend/tests/test_analysis_decompose.py @@ -9,6 +9,7 @@ from fractions import Fraction import pytest +from app.analysis import DATA_ERRORS from app.analysis.decompose import lmdi, ratio_effects from tests.analysis_samples import analyze, call_error, fixed_prober, notes, snap @@ -487,3 +488,19 @@ async def test_a_label_cannot_carry_a_judgment_into_the_phrase(): # 消过一遍会变的叫法:复算拿记下的叫法再消,判定语逐字相同(analyze 核对复算逐字段相同) marked = await additive(label="销售额[ [v:Q1.r0.gmv] ]") assert marked["params"]["label"] == "销售额" and marked["phrase"].startswith("销售额下降 194") + + +async def test_a_dimension_named_like_a_result_column_is_a_call_error(): + rows = [["线上", "2026-W41", 820], ["线上", "2026-W42", 700], ["线下", "2026-W41", 600], ["线下", "2026-W42", 560]] + flagged = snap("Q2", "SELECT channel AS flag, week, SUM(amount) AS gmv FROM orders GROUP BY 1, 2", + ["flag", "week", "gmv"], rows) + error = await call_error("decompose", "additive", snaps=[flagged], query="Q2", by=["flag"], period="week", + base="2026-W41", target="2026-W42", measure="gmv") + assert error.code == "reserved_column" and "在 SQL 里给它换个别名" in error.message and "AS flag_name" in error.message + assert error.code not in DATA_ERRORS # 结构性的:分析卡里照旧让卡失败 + ratio_rows = [["线上", "W41", 50, 1000], ["线上", "W42", 80, 1000], ["线下", "W41", 20, 1000], ["线下", "W42", 26, 1000]] + mixed = snap("Q1", "SELECT channel AS Mix, week, SUM(refund_flag) AS refunds, COUNT(*) AS orders FROM orders " + "GROUP BY 1, 2", ["Mix", "week", "refunds", "orders"], ratio_rows) + error = await call_error("decompose", "ratio", snaps=[mixed], query="Q1", by="Mix", period="week", base="W41", + target="W42", numerator="refunds", denominator="orders") + assert error.code == "reserved_column" and "「Mix」" in error.message diff --git a/backend/tests/test_analysis_series.py b/backend/tests/test_analysis_series.py index f52ae0c6..d88e31dd 100644 --- a/backend/tests/test_analysis_series.py +++ b/backend/tests/test_analysis_series.py @@ -319,3 +319,11 @@ async def test_a_falling_trend_whose_last_weeks_rise_says_but(): falling = await trend(weeks([200, 196, 190, 188, 181, 176, 170, 165, 160, 162, 165, 168])) assert falling["verdict"]["code"] == "falling" and falling["values"]["run_direction"] == "up" assert falling["phrase"].endswith(",但最近已连续 3 周上升") + + +async def test_a_period_column_named_like_a_result_column_is_a_call_error(): + rows = [[r[0], r[1]] for r in weeks(NOISE + [101])] + named = snap("Q1", "SELECT DATE(order_date, 'weekday 0', '-6 days') AS value, COUNT(*) AS orders FROM orders " + "GROUP BY 1", ["value", "orders"], rows) + error = await call_error("baseline", snaps=[named], query="Q1", period="value", measure="orders") + assert error.code == "reserved_column" and "期次列「value」和结果表的列重名" in error.message