From e859c1343364550efbc45ec7fba0aebca1ad9466 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 29 Sep 2026 16:07:19 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=93=E8=AE=BA=E5=8F=A5=E8=A3=81=E5=88=A4?= =?UTF-8?q?=E7=9C=8B=E5=BE=97=E5=88=B0=E8=A1=A8=E5=90=8D=E3=80=81=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=90=8D=E5=87=BA=E7=8E=B0=E8=BF=87=E7=9A=84=20SQL?= =?UTF-8?q?=EF=BC=9B=E8=AE=B2=E7=AE=97=E6=B3=95=E7=9A=84=E5=8F=A5=E5=AD=90?= =?UTF-8?q?=E6=8C=82=E5=88=B0=E9=82=A3=E6=AC=A1=E6=9F=A5=E8=AF=A2=E4=B8=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 以前裁判给表名、字段名的证据只有一行名字。「这个数由某表按某字段去重计数得出」这种讲算法的句子, 证据在 SQL 里(COUNT(DISTINCT …)),裁判看不到,只能判「证据不支持」——面板上明明写着「出现在查询 Q1」 - 现在摘录里附上名字出现过的查询的 SQL(每条截 300 字,最多 3 条,只读封存范围内的工件): 表用它自己记的查询;只在 SQL 里出现、不是结果列的字段,从它所在的表用到的查询里找, 文档目录里没有这张表时看全部查询,SQL 里要写了字段名(允许 t.字段 这种前缀)和表名才算 - 写作提示:有表结构时多一句——说明一个数是怎么算出来的(按哪个字段去重、怎么汇总、筛了什么条件), 句末挂 [[see:Qn]] 指向算出它的那次查询;只挂表名、字段名指不出是哪一次查询算出的 --- backend/app/engine/evidence.py | 4 +- backend/app/engine/judge.py | 43 ++++++++- backend/tests/test_judge_entity_sql.py | 126 +++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 backend/tests/test_judge_entity_sql.py diff --git a/backend/app/engine/evidence.py b/backend/app/engine/evidence.py index 15678850..2b497848 100644 --- a/backend/app/engine/evidence.py +++ b/backend/app/engine/evidence.py @@ -2656,7 +2656,9 @@ CELL_RULES = ( ENTITY_RULES = ( "表和字段(系统会核对):提到表名、字段名时写 [[t:表名]]、[[c:表名.字段名]],或者放进反引号(`表名`);" "只写下面列出的、或者这次查过的数据源里真实存在的名字——本次运行的表结构快照、查询用到的表、" - "查询结果列里都没有的名字,会被标成「可能是编造的名字」。" + "查询结果列里都没有的名字,会被标成「可能是编造的名字」。\n" + "说明一个数是怎么算出来的(按哪个字段去重、怎么汇总、筛了什么条件)时,句末挂 [[see:Q1]] 指向算出它的那次" + "查询(下面每张表后面写了哪几次查询用到它):只挂 [[t:]]、[[c:]] 指不出是哪一次查询算出的这个数。" ) #: 引原话的写法。只跟着目录里的知识库检索出现 QUOTE_RULES = ( diff --git a/backend/app/engine/judge.py b/backend/app/engine/judge.py index c14a551a..57400af2 100644 --- a/backend/app/engine/judge.py +++ b/backend/app/engine/judge.py @@ -73,6 +73,8 @@ SQL_CHARS = 300 EXCERPT_ROWS = 20 EXCERPT_COLS = 12 CHUNK_CHARS = 600 +#: 表名、字段名的摘录里附上它出现过的查询的 SQL,最多几次(「按会员号去重计数」这种句子,证据在 SQL 里) +ENTITY_QUERIES = 3 EXCERPT_HITS = 3 #: 短句:看得见的字(不算标点、空白)少于这么多,又没挂依据、没有方向词的,不送裁判 SHORT_CHARS = 6 @@ -359,7 +361,7 @@ def _retrieval_excerpt(alias: str, entry: dict[str, Any], picked: list[int], who def _excerpt(alias: str, entry: dict[str, Any], cands: list[Candidate], fetch: _Fetch, - masked: dict[str, Any] | None) -> str | None: + masked: dict[str, Any] | None, catalog: dict[str, Any]) -> str | None: kind = entry.get("kind") if kind == "metric": return _metric_excerpt(alias, entry, fetch) @@ -374,13 +376,46 @@ def _excerpt(alias: str, entry: dict[str, Any], cands: list[Candidate], fetch: _ picked = [h for c in cands for a, h in c.hits if a == alias] return _retrieval_excerpt(alias, entry, picked, any(alias in c.whole for c in cands), fetch) if kind == "table": - return f"【{alias}】表 {entry.get('name')}" + (f"(数据源 {entry['source']})" if entry.get("source") else "") + head = f"【{alias}】表 {entry.get('name')}" + (f"(数据源 {entry['source']})" if entry.get("source") else "") + return "\n".join([head, *_entity_sql(entry, catalog, fetch)]) if kind == "column": table = (entry.get("locator") or {}).get("table") - return f"【{alias}】字段 {f'{table}.' if table else ''}{(entry.get('locator') or {}).get('column') or entry.get('name')}" + head = f"【{alias}】字段 {f'{table}.' if table else ''}{(entry.get('locator') or {}).get('column') or entry.get('name')}" + return "\n".join([head, *_entity_sql(entry, catalog, fetch)]) return None +def _entity_sql(entry: dict[str, Any], catalog: dict[str, Any], fetch: _Fetch) -> list[str]: + """表名、字段名出现过的查询的 SQL(每条截到 SQL_CHARS,最多 ENTITY_QUERIES 条)。 + + 讲算法的句子(「按会员号去重计数得出」)证据在 SQL 里;只给名字,裁判只能判不支持。 + 查询用条目自己记的(表:SQL 里用到它;字段:它是结果列)。只在 SQL 里出现、不是结果列的字段自己没记: + 有表条目就从它记的查询里找,没有(文档目录只留正文用到的实体)就看全部查询,都只留 SQL 里写了这个 + 字段名(有表名的还要写了表名)的。快照经 fetch 取:取不到(不在封存范围里)的不给。 + """ + locator = entry.get("locator") or {} + column = locator.get("column") if entry.get("kind") == "column" else None + table = locator.get("table") + aliases = [q for q in entry.get("queries") or [] if isinstance(catalog.get(q), dict)] + need = [str(column)] if column else [] + if not aliases and column: + owner = catalog.get(f"t:{table}") if table else None + aliases = [q for q in (owner.get("queries") or []) if isinstance(catalog.get(q), dict)] \ + if isinstance(owner, dict) else \ + [a for a, e in catalog.items() if isinstance(e, dict) and e.get("kind") == "query"] + need += [str(table)] if table and not isinstance(owner, dict) else [] + lines: list[str] = [] + for q in aliases: + snapshot = fetch(catalog[q].get("artifact")) + sql = _clean(str(snapshot.get("sql") or "")) if isinstance(snapshot, dict) else "" + if not sql or any(not re.search(rf"(? SQL_CHARS else ''}") + if len(lines) == ENTITY_QUERIES: + break + return lines + + def _tokens(text: str) -> int: """估算令牌数:中日韩的字一个字算一个,其余大约 3.5 个字符一个。宁可估多,不估少。""" wide = sum(1 for ch in text if ord(ch) > 0x2E7F) @@ -441,7 +476,7 @@ def prepare(doc: dict[str, Any], catalog: dict[str, Any] | None = None, *, units excerpts: dict[str, str] = {} for alias in dict.fromkeys(a for c in cands for a in c.aliases): entry = catalog.get(alias) - if isinstance(entry, dict) and (text := _excerpt(alias, entry, cands, fetch, masked)): + if isinstance(entry, dict) and (text := _excerpt(alias, entry, cands, fetch, masked, catalog)): excerpts[alias] = text picked = {c.unit for c in cands} skipped: dict[str, str] = {} diff --git a/backend/tests/test_judge_entity_sql.py b/backend/tests/test_judge_entity_sql.py new file mode 100644 index 00000000..dbcf8f20 --- /dev/null +++ b/backend/tests/test_judge_entity_sql.py @@ -0,0 +1,126 @@ +"""裁判看引用表名、字段名的句子时,要看得到这些名字出现过的那几次查询的 SQL。 + +「这个数由 orders 按 orders.member_no 去重计数得出」这种讲算法的句子,证据在 SQL 里 +(COUNT(DISTINCT member_no))。以前表名、字段名的摘录只有一行名字,裁判只能判「证据不支持」—— +面板上明明写着「出现在查询 Q1」,这层关联却没交给裁判。 +""" +from __future__ import annotations + +from app.engine.evidence import build_catalog, compose_doc +from app.engine.judge import ENTITY_QUERIES, SQL_CHARS, prepare + +SOURCE = "warehouse" +SCHEMA_ART, Q1_ART, Q2_ART, Q3_ART = "5" * 64, "a" * 64, "b" * 64, "c" * 64 +SQL1 = "SELECT COUNT(DISTINCT member_no) AS active_users FROM orders WHERE created_at >= '2026-09-01'" +SQL2 = "SELECT region, SUM(amount) AS gmv FROM orders GROUP BY region" +SQL3 = "SELECT COUNT(*) AS refund_cnt FROM refunds" +SNAPS = { + Q1_ART: {"columns": ["active_users"], "rows": [[321]], "sql": SQL1, "source": SOURCE}, + Q2_ART: {"columns": ["region", "gmv"], "rows": [["华东", 100.0]], "sql": SQL2, "source": SOURCE}, + Q3_ART: {"columns": ["refund_cnt"], "rows": [[4]], "sql": SQL3, "source": SOURCE}, +} + + +def query(art: str, columns: list[str], tables: list[str]) -> dict: + return {"kind": "query", "node_id": "fetch", "exec": 1, "artifact": art, "tool": f"db_query__{SOURCE}", + "source": SOURCE, "columns": columns, "rows": 1, "truncated": False, "schema_artifact": SCHEMA_ART, + "tables": tables} + + +SCHEMA = {"kind": "schema", "node_id": "fetch", "exec": 1, "artifact": SCHEMA_ART, "source": SOURCE, + "tables": {"orders": ["id", "member_no", "region", "amount", "created_at"], "refunds": ["id", "amount"]}} + + +def catalog(): + return build_catalog(nodes={}, ledger=[query(Q1_ART, ["active_users"], ["orders"]), + query(Q2_ART, ["region", "gmv"], ["orders"]), + query(Q3_ART, ["refund_cnt"], ["refunds"]), SCHEMA]) + + +def excerpts_for(text: str) -> dict[str, str]: + cat = catalog() + doc = compose_doc(text, cat) + return prepare(doc, cat, loader=SNAPS.get).excerpts + + +def test_a_table_excerpt_carries_the_sql_of_the_queries_it_appears_in(): + ex = excerpts_for("活跃用户数由 [[t:orders]] 按 [[c:orders.member_no]] 去重计数得出。") + table = ex["t:orders"] + assert table.startswith("【t:orders】表 orders") + assert "COUNT(DISTINCT member_no)" in table and "Q1" in table + assert "SUM(amount)" in table and "Q2" in table # 用到这张表的查询都给 + assert "refunds" not in table # 没用到这张表的不给 + + +def test_a_column_only_in_the_sql_falls_back_to_queries_that_mention_it(): + # member_no 不是任何查询的结果列,条目自己没记查询:按所在的表找、只留 SQL 里写了它的 + ex = excerpts_for("活跃用户数按 [[c:orders.member_no]] 去重计数得出。") + column = ex["c:orders.member_no"] + assert "COUNT(DISTINCT member_no)" in column and "Q1" in column + assert "SUM(amount)" not in column # Q2 用了 orders 但没写 member_no + + +def test_a_result_column_uses_the_queries_it_came_from(): + ex = excerpts_for("各区的销售额按 [[c:orders.region]] 汇总。") + assert "GROUP BY region" in ex["c:orders.region"] and "Q2" in ex["c:orders.region"] + + +def test_entity_sql_is_capped(): + long_sql = "SELECT COUNT(DISTINCT member_no) AS n FROM orders WHERE " + " AND ".join(f"f{i} = 1" for i in range(200)) + snaps = {art: {**SNAPS[Q1_ART], "sql": long_sql} for art in "defgh"} + ledger = [query(art * 64, ["n"], ["orders"]) for art in "defgh"] + [SCHEMA] + cat = build_catalog(nodes={}, ledger=ledger) + doc = compose_doc("活跃用户数由 [[t:orders]] 去重计数得出。", cat) + table = prepare(doc, cat, loader=lambda a: snaps.get(a[0])).excerpts["t:orders"] + assert table.count("SELECT") == ENTITY_QUERIES + assert all(len(line) < SQL_CHARS + 40 for line in table.splitlines()) + + +def test_an_unsealed_snapshot_is_simply_left_out(): + # loader 只认封存范围内的工件:取不到的查询不给 SQL,名字那一行照样在 + cat = catalog() + doc = compose_doc("活跃用户数由 [[t:orders]] 去重计数得出。", cat) + table = prepare(doc, cat, loader=lambda a: None).excerpts["t:orders"] + assert table.startswith("【t:orders】表 orders") and "SELECT" not in table + + +def test_a_qualified_column_in_the_sql_still_counts(): + # SQL 里写成 o.member_no(表别名前缀)也算写了这个字段;只沾了边的 member_no_hash 不算 + snaps = {Q1_ART: {**SNAPS[Q1_ART], "sql": "SELECT COUNT(DISTINCT o.member_no) AS n FROM orders o"}, + Q2_ART: {**SNAPS[Q2_ART], "sql": "SELECT COUNT(DISTINCT member_no_hash) AS n FROM orders"}} + cat = build_catalog(nodes={}, ledger=[query(Q1_ART, ["n"], ["orders"]), query(Q2_ART, ["n"], ["orders"]), SCHEMA]) + doc = compose_doc("活跃用户数按 [[c:orders.member_no]] 去重计数得出。", cat) + column = prepare(doc, cat, loader=snaps.get).excerpts["c:orders.member_no"] + assert "o.member_no" in column and "member_no_hash" not in column + + +def test_the_same_column_name_in_another_tables_query_is_not_evidence(): + # 只引了字段、没引表(文档目录里没有 t:orders):看全部查询时,别的表上写了同名字段的不算 + snaps = {**SNAPS, Q3_ART: {**SNAPS[Q3_ART], "sql": "SELECT COUNT(DISTINCT member_no) AS n FROM refunds"}} + cat = catalog() + doc = compose_doc("活跃用户数按 [[c:orders.member_no]] 去重计数得出。", cat) + assert "t:orders" not in doc["catalog"] + column = prepare(doc, doc["catalog"], loader=snaps.get).excerpts["c:orders.member_no"] + assert "FROM orders" in column and "FROM refunds" not in column + + +# -------------------------------------------------------------------------- +# 写作提示:讲算法的句子挂 [[see:Qn]],别只写表名、字段名 +# -------------------------------------------------------------------------- + + +def test_the_writer_is_told_to_point_method_sentences_at_the_query(): + from app.engine.evidence import catalog_prompt + + prompt = catalog_prompt(catalog()) + [rule] = [line for line in prompt.splitlines() if "算出来" in line] + assert "[[see:Q" in rule and "去重" in rule + assert "[[t:" in rule and "[[c:" in rule # 点明只挂表名、字段名说明不了算法 + + +def test_a_catalog_without_tables_does_not_carry_the_method_rule(): + from app.engine.evidence import catalog_prompt + + ledger = [{"kind": "query", "node_id": "fetch", "exec": 1, "artifact": Q1_ART, "tool": f"db_query__{SOURCE}", + "source": SOURCE, "columns": ["active_users"], "rows": 1, "truncated": False}] + assert "算出来" not in catalog_prompt(build_catalog(nodes={}, ledger=ledger))