Files
AgentLab/backend/tests/test_fast_ask_general_optimizations.py
T
ThearchyHelios 09949fc8ac 快速直查通用提速与证据精准绑定:跨网关关闭深度思考、关联维表统计探查与零误报核验
- 模型接入:支持跨 Anthropic / OpenAI 兼容网关显式关闭深度思考(thinking=off / effort=low),对不识别推理参数的网关自动剥离重发并做进程级记忆
- 快速直查:收敛直查图推理预算(max_steps=4),杜绝长思维链与多轮现场盲探
- 语义指标与预命中:Oracle / PostgreSQL 分组聚合补充 DESC NULLS LAST;基于关系图、逆表频共享键与低基数统计(1..20 distinct)实现零硬编码的通用关联维表发现、分类列抽样及全空分组折叠诊断
- 零延迟数字证据:支持纯数字文本编码(含前导零)无损回绑、多表同行实体键与列序号消歧,结合上下文事实白名单与冲突守卫消除误报并保留防编造拦截
2026-11-12 14:40:00 +08:00

510 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""快速直查通用性能优化、异构表/关联字典通用探查与零延迟数字证据防误报回归测试。"""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock
import httpx
import openai
import pytest
import sqlglot
from langchain_core.messages import AIMessageChunk, HumanMessage
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from app.chat.direct import build_direct_graph
from app.chat.direct_evidence import collect_fast_allow_numbers, normalize_and_bind_fast_citations
from app.chat.grounding import GroundingBundle, ground_question
from app.data import catalog
from app.data.guard import check as guard_check
from app.data.semantic_metrics import (
compile_metric_sql,
create_metric,
find_companion_lookup_tables,
probe_schema_lookup_and_discriminators,
)
from app.core.crypto import encrypt
from app.db.base import SessionLocal
from app.db.models import DataSource, Provider
from app.engine.evidence import compose_doc
from app.providers.factory import (
ModelSpec,
_NO_REASONING_HINT,
_ReasoningAwareChatOpenAI,
_openai_reasoning_hint,
build_chat_model,
)
from app.tools.metrics import run_metric_query
from app.tools.registry import ToolContext, Versions
from tests.fixtures.sources import drop_source, sqlite_source
# ---------------------------------------------------------------------------
# 1. 跨模型网关关闭深度思考(thinking="off" / effort="low")与 400 自动回退
# ---------------------------------------------------------------------------
def test_fast_direct_graph_disables_thinking_and_caps_steps() -> None:
graph = build_direct_graph(bundle=GroundingBundle(), has_metrics=True)
agent_node = next(n for n in graph["nodes"] if n["id"] == "agent1")
cfg = agent_node["data"]["config"]
assert cfg["thinking"] == "off"
assert cfg["effort"] == "low"
assert cfg["max_steps"] == 4
assert cfg["parallel_tools"] is True
def test_provider_thinking_off_payloads_across_vendors() -> None:
anth_prov = Provider(name="anth-test", kind="anthropic", api_key=encrypt("sk-test"), enabled=True)
# Anthropic 普通模型:显式 disabled + effort=low
anth_std = build_chat_model(
anth_prov,
ModelSpec(model="claude-sonnet-4-6", thinking="off", effort="low"),
)
assert anth_std.thinking == {"type": "disabled"}
assert anth_std.output_config == {"effort": "low"}
# Anthropic 强制思考模型(catalog 中标注 thinking='always'):不发 disabled,仅设为隐藏式 adaptive + effort=low
anth_req = build_chat_model(
anth_prov,
ModelSpec(model="claude-fable-5-1", thinking="off", effort="low"),
)
assert anth_req.thinking == {"type": "adaptive", "display": "omitted"}
assert anth_req.output_config == {"effort": "low"}
# OpenAI 兼容网关各主流家族推理开关映射
off_spec = ModelSpec(thinking="off", effort="low")
qwen_hint = _openai_reasoning_hint("openai_compatible", "qwen3-235b-a22b", off_spec)
assert qwen_hint["extra_body"]["enable_thinking"] is False
ds_hint = _openai_reasoning_hint("openai_compatible", "deepseek-v3.2", off_spec)
assert ds_hint["extra_body"] == {"thinking": {"type": "disabled"}}
gpt_hint = _openai_reasoning_hint("openai", "gpt-5", off_spec)
assert gpt_hint == {"reasoning_effort": "low"}
@pytest.mark.asyncio
async def test_openai_compatible_gateway_strips_rejected_reasoning_hint_and_caches(monkeypatch: pytest.MonkeyPatch) -> None:
_NO_REASONING_HINT.clear()
seen_kwargs: list[dict[str, Any]] = []
async def fake_agenerate(self: Any, messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any) -> ChatResult:
seen_kwargs.append(dict(kwargs))
if "reasoning_effort" in kwargs or "extra_body" in kwargs:
req = httpx.Request("POST", "https://strict-gateway.example/v1/chat/completions")
resp = httpx.Response(
400,
request=req,
json={"error": {"message": "Unrecognized request argument supplied: reasoning_effort"}},
)
raise openai.BadRequestError("Unrecognized request argument supplied: reasoning_effort", response=resp, body=resp.json())
from langchain_core.messages import AIMessage
return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))])
monkeypatch.setattr("langchain_openai.ChatOpenAI._agenerate", fake_agenerate)
llm = _ReasoningAwareChatOpenAI(
model="custom-corp-llm",
api_key="sk-test",
base_url="https://strict-gateway.example/v1",
reasoning_hint={"reasoning_effort": "low"},
)
res1 = await llm._agenerate([HumanMessage(content="hi")])
assert res1.generations[0].message.content == "ok"
assert len(seen_kwargs) == 2
assert seen_kwargs[0].get("reasoning_effort") == "low"
assert "reasoning_effort" not in seen_kwargs[1]
# 第二次调用命中进程级缓存,不再触发 400 重试
seen_kwargs.clear()
await llm._agenerate([HumanMessage(content="hi again")])
assert len(seen_kwargs) == 1
assert "reasoning_effort" not in seen_kwargs[0]
@pytest.mark.asyncio
async def test_openai_stream_strips_rejected_reasoning_hint(monkeypatch: pytest.MonkeyPatch) -> None:
_NO_REASONING_HINT.clear()
calls = 0
async def fake_astream(self: Any, messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any):
nonlocal calls
calls += 1
if "extra_body" in kwargs:
req = httpx.Request("POST", "https://strict-stream.example/v1/chat/completions")
resp = httpx.Response(
422,
request=req,
json={"detail": "Extra inputs are not permitted: enable_thinking"},
)
raise openai.UnprocessableEntityError("Extra inputs are not permitted", response=resp, body=resp.json())
yield ChatGenerationChunk(message=AIMessageChunk(content="fast-answer"))
monkeypatch.setattr("langchain_openai.ChatOpenAI._astream", fake_astream)
llm = _ReasoningAwareChatOpenAI(
model="qwen3-coder",
api_key="sk-test",
base_url="https://strict-stream.example/v1",
reasoning_hint={"extra_body": {"enable_thinking": False}},
)
chunks = [c async for c in llm._astream([HumanMessage(content="hello")])]
assert calls == 2
assert "".join(str(c.message.content) for c in chunks) == "fast-answer"
# ---------------------------------------------------------------------------
# 2. Oracle / PostgreSQL 分组指标 DESC NULLS LAST 方言修复与稀疏聚合验证
# ---------------------------------------------------------------------------
def test_compile_metric_sql_nulls_last_by_dialect() -> None:
metric_def = {
"key": "sparse_ratio",
"name": "稀疏达成率",
"calc_mode": "expression",
"table_name": "fact_perf",
"sql_expression": "ROUND(SUM(actual_val) * 100.0 / NULLIF(SUM(target_val), 0), 2)",
"date_column": "stat_date",
"filter_sql": "",
"dimensions": [{"key": "unit_id", "label": "单元", "column": "unit_id"}],
}
schema = {
"tables": {
"fact_perf": {
"qualified": "DW.fact_perf",
"columns": [
{"name": "unit_id", "type": "VARCHAR2(32)"},
{"name": "stat_date", "type": "DATE"},
{"name": "actual_val", "type": "NUMBER"},
{"name": "target_val", "type": "NUMBER"},
],
}
}
}
ora_ds = DataSource(id="ora1", name="ora_dw", kind="oracle", schema_cache=schema)
ora_sql, _ = compile_metric_sql(metric_def, source=ora_ds, group_by=["unit_id"], limit=50)
assert "ORDER BY 2 DESC NULLS LAST" in ora_sql
assert "WHERE ROWNUM <= 50" in ora_sql
guard_check(ora_sql, readonly=True, dialect="oracle", source_name="ora_dw")
sqlglot.parse_one(ora_sql, read="oracle")
pg_ds = DataSource(id="pg1", name="pg_dw", kind="postgresql", schema_cache=schema)
pg_sql, _ = compile_metric_sql(metric_def, source=pg_ds, group_by=["unit_id"], limit=50)
assert "ORDER BY 2 DESC NULLS LAST LIMIT 50" in pg_sql
sqlglot.parse_one(pg_sql, read="postgres")
mysql_ds = DataSource(id="my1", name="my_dw", kind="mysql", schema_cache=schema)
my_sql, _ = compile_metric_sql(metric_def, source=mysql_ds, group_by=["unit_id"], limit=50)
assert "ORDER BY 2 DESC LIMIT 50" in my_sql
assert "NULLS LAST" not in my_sql
sqlglot.parse_one(my_sql, read="mysql")
# ---------------------------------------------------------------------------
# 3. 通用关联维表 / 字典配置表发现、低基数分类列实探与全空分组折叠诊断
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_general_companion_lookup_and_discriminator_probing_across_domains(tmp_path: Path) -> None:
db_file = tmp_path / "general_erp.sqlite"
conn = sqlite3.connect(db_file)
conn.executescript(
"""
CREATE TABLE fact_ledger_wide (
tx_id TEXT,
org_code TEXT,
book_date TEXT,
entry_category TEXT,
secret_tag TEXT,
val_01 REAL,
val_02 REAL,
val_03 REAL,
val_04 REAL,
val_05 REAL,
val_06 REAL,
val_07 REAL,
val_08 REAL
);
CREATE TABLE meta_category_schema (
entry_category TEXT,
category_title TEXT,
slot_col TEXT,
slot_label TEXT,
slot_unit TEXT
);
CREATE TABLE dim_org_unit (
org_code TEXT,
org_name TEXT,
region_name TEXT
);
"""
)
# 插入带前导零的分类码 '009' 与 '003',以及 >20 种高基数流水号 tx_id(验证高基数列自动排除)
for i in range(25):
cat = "009" if i < 5 else "003"
v1 = 100.0 if cat == "009" else None
v2 = 12.0 if cat == "009" else None
conn.execute(
"INSERT INTO fact_ledger_wide VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL)",
(f"TX-{i:04d}", "3950" if i % 2 == 0 else "3951", "2026-11-11", cat, f"SEC-{i}", v1, v2),
)
conn.executemany(
"INSERT INTO meta_category_schema VALUES (?, ?, ?, ?, ?)",
[
("003", "能耗台账", "val_01", "总电量", "kWh"),
("009", "运营统计", "val_01", "应出勤秒数", "秒"),
("009", "运营统计", "val_02", "异常中断秒数", "秒"),
],
)
conn.executemany(
"INSERT INTO dim_org_unit VALUES (?, ?, ?)",
[("3950", "一分部", "华东"), ("3951", "二分部", "华东")],
)
conn.commit()
conn.close()
ds_id = await sqlite_source(
"erp_general_test",
db_file,
options={"mask_columns": ["secret_tag"]},
schema=True,
)
try:
async with SessionLocal() as session:
ds = await session.get(DataSource, ds_id)
assert ds is not None
schema_cache = ds.schema_cache
# 1) 通用关联紧凑维表发现(无外键约束,纯靠高区分度共享键与结构识别)
tables_map = schema_cache["tables"]
masked = {"secret_tag"}
companions = find_companion_lookup_tables(
tables_map,
["fact_ledger_wide"],
masked_set=masked,
)
comp_names = [c["table_name"] for c in companions]
assert "meta_category_schema" in comp_names
assert "dim_org_unit" in comp_names
# 2) 低基数分类列与紧凑字典实探:保留 '009' 前导零,高基数 tx_id 与遮罩列 secret_tag 绝不混入
hints = await probe_schema_lookup_and_discriminators(
ds,
tables_map,
["fact_ledger_wide"],
masked_set=masked,
)
assert "'009'" in hints
assert "'003'" in hints
assert "异常中断秒数" in hints
assert "TX-0001" not in hints
assert "secret_tag" not in hints
assert "SEC-" not in hints
# 3) 语义预命中自动捎带紧凑关联配置表,且普通使用者模式不泄露物理表名
metric_row, _ = await create_metric(
session,
{
"key": "erp_interrupt_rate",
"name": "异常中断率",
"source_id": ds.id,
"calc_mode": "expression",
"table_name": "fact_ledger_wide",
"sql_expression": "ROUND(SUM(val_02) * 100.0 / NULLIF(SUM(val_01), 0), 2)",
"date_column": "book_date",
"filter_sql": "",
"dimensions": [{"key": "tx_id", "label": "流水", "column": "tx_id"}],
},
)
await catalog.write_entry(
session,
ds.id,
"fact_ledger_wide",
{
"dedup": {"value": "同表含多类台账记录,聚合前须限定 entry_category", "source": "human", "status": "confirmed"},
},
if_version=0,
actor="test",
)
await session.commit()
bundle = await ground_question(
session,
"查询 2026-11-11 的异常中断率",
datasource_ids=[ds.id],
restricted=False,
)
grounded_tbls = [t["table_name"] for t in bundle.matched_tables]
assert "fact_ledger_wide" in grounded_tbls
assert any(t in grounded_tbls for t in ("meta_category_schema", "dim_org_unit"))
assert any(g["kind"] == "dedup" for g in bundle.guardrails)
bundle_restricted = await ground_question(
session,
"查询 2026-11-11 的异常中断率",
datasource_ids=[ds.id],
restricted=True,
)
for ut in bundle_restricted.ui_summary["tables"]:
assert "fact_ledger_wide" not in ut["table_name"]
assert "meta_category_schema" not in ut["reason"]
# 4) 当分组查询返回全 NULL 时,自动折叠重复 NULL 行并输出通用目录诊断提示 + 最新可用日期
raw_tool_out = await run_metric_query(
ToolContext(data_versions=Versions.CURRENT),
metric=metric_row.key,
date_from="2026-11-11",
date_to="2026-11-11",
group_by=["tx_id"],
filters={"tx_id": [f"TX-{i:04d}" for i in range(5, 18)]},
limit=50,
)
tool_payload = json.loads(raw_tool_out)
assert tool_payload["all_metric_values_null"] is True
assert tool_payload["null_group_row_count"] == 13
assert len(tool_payload["rows"]) == 5
assert tool_payload["latest_available_date"] == "2026-11-11"
assert "diagnostic_hint" in tool_payload
assert "meta_category_schema" in tool_payload["diagnostic_hint"]
await session.delete(metric_row)
await session.commit()
finally:
await drop_source(ds_id)
# ---------------------------------------------------------------------------
# 4. 快速直查零延迟数字证据封装:字符串编码、多表消歧与零误报验证
# ---------------------------------------------------------------------------
def test_fast_evidence_binds_string_codes_tables_and_preserves_hallucination_guard() -> None:
snapshots: dict[str, dict[str, Any]] = {
"art_dict": {
"columns": ["cat_code", "cat_name"],
"column_types": {"cat_code": "text", "cat_name": "text"},
"rows": [
["003", "能耗数据"],
["7", "效率报表"],
["009", "班次统计"],
],
"truncated": False,
},
"art_sum": {
"columns": ["site_code", "unit_cnt", "stop_sec", "open_sec", "stop_rate", "unplanned_rate"],
"column_types": {
"site_code": "text",
"unit_cnt": "number",
"stop_sec": "number",
"open_sec": "number",
"stop_rate": "number",
"unplanned_rate": "number",
},
"rows": [
["3950", 1, 8653, 82751, 10.46, 0.0],
["3951", 7, 20571, 580986, 3.54, 0.42],
],
"truncated": False,
},
"art_dtl": {
"columns": ["site_code", "unit_id", "unit_name", "open_sec", "stop_sec", "stop_rate", "unplanned_rate"],
"column_types": {
"site_code": "text",
"unit_id": "text",
"unit_name": "text",
"open_sec": "number",
"stop_sec": "number",
"stop_rate": "number",
"unplanned_rate": "number",
},
"rows": [
["3950", "U395001", "一号单元42", 82751, 8653, 10.46, 0.0],
["3951", "U395102", "二号单元28", 75264, 10471, 13.91, 2.2],
["3951", "U395101", "三号单元", 73722, 10100, 13.7, 1.1],
["3951", "U395103", "四号单元", 86400, 0, 0.0, 0.0],
["3951", "U395104", "五号单元", 86400, 0, 0.0, 0.0],
["3951", "U395105", "六号单元", 86400, 0, 0.0, 0.0],
["3951", "U395106", "七号单元", 86400, 0, 0.0, 0.0],
["3951", "U395107", "八号单元", 86400, 0, 0.0, 0.0],
],
"truncated": False,
},
}
cat = {
"Q1": {"kind": "query", "artifact": "art_dict", "columns": snapshots["art_dict"]["columns"]},
"Q2": {"kind": "query", "artifact": "art_sum", "columns": snapshots["art_sum"]["columns"]},
"Q3": {"kind": "query", "artifact": "art_dtl", "columns": snapshots["art_dtl"]["columns"]},
}
loader = lambda aid: snapshots[aid]
question = "3951和395011月11 的异常率"
raw_answer = """### 2026-11-11 异常率(站点 3950 / 3951)
按「班次统计」(`cat_code='009'`)口径汇总 —— 异常率 = 异常时长 ÷ 应开时长 × 100.0%:
| 站点 | 单元数 | 异常时长(秒) | 应开时长(秒) | 异常率 | 计划外率 |
| --- | --- | --- | --- | --- | --- |
| 3950 | 1 | 8,653 | 82,751 | 10.46% | 0% |
| 3951 | 7 | 20,571 | 580,986 | 3.54% | 0.42% |
一句话结论:3950 当日异常率 10.46%(唯一单元一号单元42,异常 8,653 秒);3951 异常率 3.54%,且 7 个单元中有 5 个为 0。
### 3951 单元级明细
| site_code | unit_id | unit_name | open_sec | stop_sec | stop_rate | unplanned_rate |
| --- | --- | --- | --- | --- | --- | --- |
| 3950 | U395001 | 一号单元42 | 82,751 | 8,653 | 10.46 | 0 |
| 3951 | U395102 | 二号单元28 | 75,264 | 10,471 | 13.91 | 2.2 |
| 3951 | U395101 | 三号单元 | 73,722 | 10,100 | 13.7 | 1.1 |
| 3951 | U395103 | 四号单元 | 86,400 | 0 | 0 | 0 |
说明:2026-11-11 两站点均已含两个班次记录;默认卡片混算了 003 与 7 分组,本次已按 009 分组计算。"""
prepared = normalize_and_bind_fast_citations(raw_answer, cat, loader=loader)
# 验证前导零字符串编码 '009' 与 '003' 被正确绑定且渲染保留前导零
assert "[[v:Q1.r2.cat_code]]" in prepared
assert "[[v:Q1.r0.cat_code]]" in prepared
allow = collect_fast_allow_numbers(prepared, cat, question=question, loader=loader)
doc = compose_doc(
prepared,
cat,
node_id="report1",
run_id="run_test_ok",
allow_numbers=allow,
cells_allowed=True,
entities=False,
loader=loader,
)
assert "009" in doc["markdown"]
assert "003" in doc["markdown"]
assert doc["stats"]["numbers_cited"] >= 15
assert doc["stats"]["uncited_numbers"] == 0
assert doc["violations"] == []
# 反例:若模型在回答中凭空编造了一个快照与提问中都不存在的数字(如 99.87% 或额外编造的 100 次),必须照常标出 uncited_number!
hallucinated_raw = raw_answer + "\n\n此外昨日异常率为 99.87%,累计发生 100 次故障。"
prep_bad = normalize_and_bind_fast_citations(hallucinated_raw, cat, loader=loader)
allow_bad = collect_fast_allow_numbers(prep_bad, cat, question=question, loader=loader)
doc_bad = compose_doc(
prep_bad,
cat,
node_id="report1",
run_id="run_test_bad",
allow_numbers=allow_bad,
cells_allowed=True,
entities=False,
loader=loader,
)
uncited_msgs = [v["message"] for v in doc_bad["violations"] if v["code"] == "uncited_number"]
assert any("99.87%" in m for m in uncited_msgs)
assert any("100" in m for m in uncited_msgs)