From f1c068d6a99307deefa3f24c20248cdd73d0e879 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 00:42:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A0=B8=E6=9F=A5=E5=B7=A5=E5=85=B7=E7=AE=B1?= =?UTF-8?q?=E6=94=B6=E5=B0=BE=EF=BC=9A=E8=BF=9E=E6=8E=A5=E7=9A=84=E6=96=B9?= =?UTF-8?q?=E9=9D=A2=E8=A1=A5=E4=B8=8A=E5=8F=A6=E4=B8=80=E4=BE=A7=EF=BC=88?= =?UTF-8?q?=E5=85=B3=E8=81=94=E6=9D=A1=E4=BB=B6=E9=87=8C=E5=87=BA=E7=8E=B0?= =?UTF-8?q?=E7=9A=84=E5=88=AB=E7=9A=84=E8=A1=A8=EF=BC=8CCROSS=20JOIN=20?= =?UTF-8?q?=E5=86=99=E5=AE=83=E4=B9=8B=E5=89=8D=E7=9A=84=E6=9D=A5=E6=BA=90?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E5=8F=8C=E7=9B=B2=E5=A4=8D=E7=AE=97=E5=AF=B9?= =?UTF-8?q?=E7=85=A7=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95=E9=87=8C=E7=9A=84?= =?UTF-8?q?=E5=85=B3=E7=B3=BB=E6=97=B6=E7=94=A8=E5=BE=97=E4=B8=8A=EF=BC=9B?= =?UTF-8?q?=E5=8F=AA=E8=AF=BB=20agent=20=E6=94=B6=E5=B0=BE=E5=89=8D?= =?UTF-8?q?=E5=85=88=E6=8B=BF=E5=88=B0=E6=94=B6=E5=B0=BE=E7=94=A8=E7=9A=84?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=86=8D=E9=A2=84=E7=95=99=E9=87=91=E9=A2=9D?= =?UTF-8?q?=EF=BC=8C=E6=A8=A1=E5=9E=8B=E6=8B=BF=E4=B8=8D=E5=88=B0=E6=97=B6?= =?UTF-8?q?=E4=B8=8D=E5=9C=A8=E6=AF=8F=E6=97=A5=E8=B4=A6=E4=B8=8A=E7=99=BD?= =?UTF-8?q?=E8=AE=B0=E4=B8=80=E6=AC=A1=E8=B0=83=E7=94=A8=EF=BC=9B=E6=94=B6?= =?UTF-8?q?=E5=B0=BE=E6=B6=88=E6=81=AF=E7=9A=84=E5=8F=82=E6=95=B0=E6=94=B9?= =?UTF-8?q?=E5=90=8D=20limit=5Fwords=EF=BC=8C=E8=AF=B4=E6=B8=85=E6=98=AF?= =?UTF-8?q?=E5=93=AA=E4=B8=AA=E4=B8=8A=E9=99=90=E7=9A=84=E8=AF=B4=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/data/sql_semantics.py | 14 ++++++++++---- backend/app/engine/verify_agent.py | 22 +++++++++++----------- backend/tests/test_sql_semantics.py | 5 ++++- backend/tests/test_verify_agent.py | 3 ++- 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/backend/app/data/sql_semantics.py b/backend/app/data/sql_semantics.py index cc07568c..d9830c25 100644 --- a/backend/app/data/sql_semantics.py +++ b/backend/app/data/sql_semantics.py @@ -217,12 +217,14 @@ class Part: @dataclass(frozen=True) class JoinFacet: - """一次连接。kind:inner / left / right / full / cross / semi / anti / natural。on 是关联条件的合取式排好序后 - 用 AND 连起来的写法,没有条件是 None。""" + """一次连接。kind:inner / left / right / full / cross / semi / anti / natural。table 是关联进来的那一侧,other + 是另一侧:关联条件里出现的别的表(给人看的真实表名);没有条件的(CROSS JOIN)是在它之前出现的各个来源。on 是 + 关联条件的合取式排好序后用 AND 连起来的写法,没有条件是 None。""" kind: str table: Part on: Part | None = None + other: tuple[str, ...] = () @dataclass(frozen=True) @@ -688,7 +690,8 @@ class _Statement: conds = [self.canonical(exp.EQ(this=exp.column(ident.name, table=first_alias), expression=exp.column(ident.name, table=alias)), sources) for ident in join.args["using"]] - drafts.append({"kind": _join_kind(join), "slot": slot, "source": sources.get(slot), "conds": conds}) + drafts.append({"kind": _join_kind(join), "slot": slot, "source": sources.get(slot), "conds": conds, + "before": [s.name for key, s in list(sources.items())[:i + 1] if key != slot]}) return drafts def _joins_on_where(self, cond: Any, sources: dict[str, _Source], drafts: list[dict[str, Any]]) -> bool: @@ -720,7 +723,10 @@ class _Statement: on = Part(key=" AND ".join(t[0] for t in triples), text=" AND ".join(t[2] for t in triples), loose=" AND ".join(sorted(t[1] for t in triples)), full=" AND ".join(t[2] for t in triples)) \ if triples else None - return JoinFacet(kind=draft["kind"], table=table, on=on) + mentioned = {table for c in draft["conds"] for table, _ in self.part(c, sources).columns} + other = [name for name in dict.fromkeys(t for t in mentioned if t) if name != table.text] if draft["conds"] \ + else draft["before"] + return JoinFacet(kind=draft["kind"], table=table, on=on, other=tuple(sorted(other))) def _time(self, cond: Any, sources: dict[str, _Source]) -> TimePredicate | None: """时间条件:一边恰好一列(可以套着日期函数、CAST),另一边没有列;列名像日期,或者用了日期函数、像日期的字符串。""" diff --git a/backend/app/engine/verify_agent.py b/backend/app/engine/verify_agent.py index eabc69ce..4294f043 100644 --- a/backend/app/engine/verify_agent.py +++ b/backend/app/engine/verify_agent.py @@ -323,12 +323,13 @@ def _text_of(messages: list[BaseMessage]) -> str: for m in messages) -def _final_messages(system: str, task: str, queries: list[dict[str, Any]], said: str, stopped: str | None +def _final_messages(system: str, task: str, queries: list[dict[str, Any]], said: str, limit_words: str | None ) -> list[BaseMessage]: - """收尾那次调用的消息:任务、查到的结果(每次查询列出行号)、模型最后说的话,以及交结果的规则。""" + """收尾那次调用的消息:任务、查到的结果(每次查询列出行号)、模型最后说的话,以及交结果的规则。 + limit_words:触顶时是哪个上限(「最多查询 6 次」),告诉模型为什么不能再查。""" parts = [f"任务:\n{task.strip()}"] - if stopped: - parts.append(f"已达上限({stopped}),不能再查询,按已经查到的结果交结果。") + if limit_words: + parts.append(f"已达上限({limit_words}),不能再查询,按已经查到的结果交结果。") parts.append(f"你查到的结果:\n{_digest(queries) if queries else '(没有成功的查询)'}") parts.append(f"你最后的说明(只作参考,数以查询结果为准):\n{said.strip() or '(没有)'}") parts.append(VERIFY_FINAL_PROMPT) @@ -541,7 +542,12 @@ async def run_verify_agent( await _announce(emit, EventType.LOG, level="warn", code="verify_limit", limit=stopped, message=f"核查已达上限({words}),已停止查询,按已有结果收尾") - # ---- 收尾:按结构交结果 + # ---- 收尾:按结构交结果。关掉 thinking:结构化输出靠强制工具调用,和思考模式不兼容 + try: + async with SessionLocal() as session: + settler, _ = await get_chat_model(session, spec.model_copy(update={"thinking": "off"})) + except Exception as e: # noqa: BLE001 + return outcome(model=model_id, limit=stopped, answered=answered, error=f"核查模型不可用({describe_exception(e)})") schema = result_schema(output_schema) final = _final_messages(head, task, queries, said, words) estimate = budget.estimate(_text_of(final), _OUT_FINAL) @@ -554,12 +560,6 @@ async def run_verify_agent( remaining = left() grace = None if remaining is None else max(remaining, SETTLE_GRACE_S) finish = None if grace is None else time.monotonic() + grace - try: - async with SessionLocal() as session: - settler, _ = await get_chat_model(session, spec.model_copy(update={"thinking": "off"})) - except Exception as e: # noqa: BLE001 - await spend.settle(day or "", -estimate) - return outcome(model=model_id, limit=stopped, answered=answered, error=f"核查模型不可用({describe_exception(e)})") def until_finish() -> float | None: return None if finish is None else finish - time.monotonic() diff --git a/backend/tests/test_sql_semantics.py b/backend/tests/test_sql_semantics.py index ee3cd7e1..690fd351 100644 --- a/backend/tests/test_sql_semantics.py +++ b/backend/tests/test_sql_semantics.py @@ -116,7 +116,7 @@ def test_tables_are_the_real_tables_without_cte_names(): def test_joins_carry_kind_both_sides_and_a_normalized_condition(): f = facets("SELECT o.id FROM orders o LEFT JOIN refunds r ON r.order_id = o.id AND r.state = 'done'") [join] = f.joins - assert join.kind == "left" and join.table.text == "refunds" + assert join.kind == "left" and join.table.text == "refunds" and join.other == ("orders",) # 别名换成真实表名,两边排好顺序,几个条件按 key 排序后用 AND 连起来 assert join.on.text == "orders.id = refunds.order_id AND refunds.state = 'done'" same = facets("SELECT orders.id FROM orders LEFT OUTER JOIN refunds ON refunds.state = 'done' " @@ -128,6 +128,9 @@ def test_a_comma_join_is_the_same_as_an_inner_join(): comma = facets("SELECT a.x FROM orders a, stores b WHERE a.store_id = b.id AND b.city = '虚构市'") inner = facets("SELECT orders.x FROM orders JOIN stores ON stores.id = orders.store_id WHERE stores.city = '虚构市'") assert comma.joins == inner.joins and comma.joins[0].kind == "inner" + assert comma.joins[0].table.text == "stores" and comma.joins[0].other == ("orders",) + cross = facets("SELECT 1 FROM orders o CROSS JOIN stores s") + assert (cross.joins[0].kind, cross.joins[0].on, cross.joins[0].other) == ("cross", None, ("orders",)) assert [p.key for p in comma.where] == [p.key for p in inner.where] == ["stores.city = '虚构市'"] diff --git a/backend/tests/test_verify_agent.py b/backend/tests/test_verify_agent.py index 242317dc..7e72083f 100644 --- a/backend/tests/test_verify_agent.py +++ b/backend/tests/test_verify_agent.py @@ -212,7 +212,8 @@ async def test_writes_are_refused_on_a_writable_source(monkeypatch, shop): script = Script(monkeypatch, [q(name, "DELETE FROM orders WHERE is_deleted = 1", "w1"), q(name, "WITH k AS (SELECT 1) UPDATE orders SET amount = 0", "w2"), q(name, VALID_SUM, "c1")], - {"value": {"alias": "Q1", "row": 0, "column": "total"}, "verdict": "一致", "rationale": "见 [[see:Q1]]。"}) + {"value": {"alias": "Q1", "row": 0, "column": "total"}, "verdict": "一致", + "rationale": "见 [[see:Q1]]。"}) out = await run(name) seen = script.tool_results() assert seen["w1"].startswith("SQL 被拒绝:") and seen["w2"].startswith("SQL 被拒绝:")