Files
AgentLab/backend/tests/test_ask_data_optimization.py
T
ThearchyHelios 46a2988dc1 问数据双轨重构:快速直查与深度分析分流、可点击数字证据闭环及双语文档
- 重构问数据执行架构为「快速直查(fast)」与「深度分析(deep)」双模式,内置语义预命中(Grounding)注入指标卡片、标准实体编码与表目录血缘
- 快速直查免工作流生成单节点执行,新增零额外模型调用的数字证据归一与封存(direct_evidence),打通可点击数字溯源与 /verify 校验
- 深度分析与助手编排接入跨源本体穿透(自动升级重跑与一键沿本体排查关联数据源),优化数据源范围智能收敛与往返缓存
- 补齐端到端回归测试矩阵与中英文全量文档说明
2026-11-12 12:58:42 +08:00

204 lines
11 KiB
Python

"""Legacy ask-data optimization through real query tools, evidence sealing and migration APIs."""
from __future__ import annotations
import copy
import pytest
from langchain_core.messages import AIMessage
from app.chat.optimization import prepare
from app.db.base import SessionLocal
from app.db.models import CaliberDraft, DataSource, Document, Workflow
from app.engine.guards import call_key
from app.engine.runner import run_manager, verify_manifest
from app.ledger.migration import audit
from tests import ledger_samples as S
from tests.test_semantic_pipeline import ( # noqa: F401
SOURCE, client, cleanup_own_rows, engine_up, no_model, oee_asset, oee_graph, _ops,
)
from tests.test_report_node import events, report_of
def ask_graph():
return S.chain(S.node("start", "input"),
S.node("work", "agent", prompt="昨天1050的OEE是多少", tools=[S.tool()], approval="never"),
S.node("write", "report", instructions="简短回答查询问题并引用证据"),
S.node("out", "output", fields=[{"name": "答案", "value": "{{ nodes.write.text }}"}]))
def test_only_simple_lookup_graphs_are_opted_in_without_mutating_saved_graph():
graph = ask_graph()
before = copy.deepcopy(graph)
optimized = prepare(graph, "昨天1050的OEE是多少")
assert optimized["nodes"][1]["data"]["config"]["ask_data_optimization"] == 1
assert optimized["nodes"][2]["data"]["config"]["reuse_answer_from"] == "work"
assert graph == before
assert prepare(graph, "分析昨天1050的OEE为什么下降") == graph
graph["nodes"][-1]["data"]["config"]["contract"] = {"required_metrics": ["oee"]}
assert prepare(graph, "昨天1050的OEE是多少") == graph
def test_normalized_calls_preserve_literals_sources_and_parameters():
key = lambda sql, **args: call_key("db_query__shop", {"sql": sql, **args}, canonical=True)
assert key("SELECT x FROM t") == key("SELECT\nx FROM\nt")
assert key("SELECT 'a b'") != key("SELECT 'a b'")
assert key('SELECT "a b" FROM t') != key('SELECT "a b" FROM t')
assert key("SELECT x FROM t -- a\nWHERE id=1") != key("SELECT x FROM t WHERE id=1")
assert key("SELECT x FROM t", limit=1) != key("SELECT x FROM t", limit=100)
assert call_key("db_query__shop", {"sql": "SELECT x"}) != call_key("db_query__shop", {"sql": "SELECT x"})
assert call_key("db_schema__shop", {"table": ["a", "b"]}, canonical=True) == call_key(
"db_schema__shop", {"table": "b,a"}, canonical=True)
assert call_key("db_schema__shop", {"table": ["a"]}, canonical=True) != call_key(
"db_schema__other", {"table": ["a"]}, canonical=True)
@pytest.mark.parametrize("mode, expected_calls", [("clean", 0), ("bare", 1), ("wrong_ref", 1), ("formal", 1)])
async def test_agent_answer_reuse_still_checks_evidence_and_seals(oee_asset, monkeypatch, mode, expected_calls):
from app.providers.mock_model import MockChatModel
sql = "SELECT SUM(oee * hours) / SUM(hours) AS oee FROM shifts WHERE oee IS NOT NULL AND plant='1050' AND production_day='2026-11-09'"
calls = {"agent": 0, "report": 0}
def decide(self, messages):
if not self.tools:
calls["report"] += 1
return AIMessage(content="OEE 为 [[v:Q1.r0.oee]]%。[[see:Q1]]")
calls["agent"] += 1
step = calls["agent"]
if step <= 2:
return AIMessage(content="", tool_calls=[{"name": S.tool(), "id": f"call_{step}",
"args": {"sql": sql if step == 1 else sql.replace(" FROM ", "\n FROM ")}}])
return AIMessage(content={"bare": "OEE 为 999%。", "wrong_ref": "OEE 为 [[v:Q99.r0.oee]]%。"}.get(
mode, "OEE 为 [[v:Q1.r0.oee]]%。[[see:Q1]]"))
monkeypatch.setattr(MockChatModel, "_decide", decide)
run = await run_manager.start(graph=prepare(ask_graph(), "昨天1050的OEE是多少"), input_payload={},
run_class="formal" if mode == "formal" else "exploratory")
row = await S.wait(run.id)
assert row.status == "succeeded", row.error
assert calls == {"agent": 3, "report": expected_calls}
tool_ends = [e for e in await events(run.id) if e.type == "tool.end"]
assert len(tool_ends) == 1, [e.type for e in await events(run.id)]
checked, doc = await report_of(run.id)
assert checked["ok"] and "40" in doc["markdown"] and "999" not in doc["markdown"]
assert (await verify_manifest(run.id))["ok"]
assert bool([e for e in await events(run.id) if e.data.get("code") == "report_answer_reused"]) == (mode == "clean")
def test_migration_reports_grain_source_and_boundary_without_rewriting():
graph = oee_graph()
graph["nodes"][1]["data"]["config"]["args"]["sql"] = "SELECT oee FROM shifts"
metric = graph["nodes"][2]["data"]["config"]["metrics"][0]
metric.pop("semantic")
before = copy.deepcopy(graph)
result = audit(graph, workflow_id="legacy", version=3, sources=set())
codes = {f["code"] for f in result["metrics"][0]["findings"]}
assert {"semantic_missing", "source_missing", "scalar_unproven"} <= codes
assert not result["runtime_verified"] and graph == before
async def test_migration_draft_is_independent_idempotent_and_keeps_source(oee_asset, client):
async with SessionLocal() as session:
workflow = await session.get(Workflow, oee_asset["workflow_id"])
old_graph, old_revision, old_published = copy.deepcopy(workflow.graph), workflow.version, workflow.published_version
doc = Document(title="指标公式总表.md", source="migration-test", content=SOURCE.read_text(), status="ready")
session.add(doc)
await session.flush()
draft = CaliberDraft(title="迁移测试", status="questions_open", workflow_id=workflow.id,
origin={"documents": [doc.id], "fixed_filters": "仅测试范围"}, payload={})
session.add(draft)
await session.commit()
draft_id = draft.id
check = await client.get(f"/api/ledger/drafts/{draft_id}/migration")
assert check.status_code == 200, check.text
assert check.json()["metrics_total"] == 1 and check.json()["can_rebuild"]
response = await client.post(f"/api/ledger/drafts/{draft_id}/migration", json={"expected_revision": old_revision})
assert response.status_code == 201, response.text
again = await client.post(f"/api/ledger/drafts/{draft_id}/migration", json={"expected_revision": old_revision})
assert response.json() == again.json()
fresh = (await client.get(f"/api/ledger/drafts/{response.json()['draft_id']}" )).json()
assert fresh["workflow_id"] is None and fresh["definition_count"] == 48
assert fresh["origin"]["fixed_filters"] == "仅测试范围"
async with SessionLocal() as session:
wf = await session.get(Workflow, oee_asset["workflow_id"])
assert wf.graph == old_graph and wf.version == old_revision and wf.published_version == old_published
stale = await client.post(f"/api/ledger/drafts/{draft_id}/migration", json={"expected_revision": old_revision + 1})
assert stale.status_code == 409
async def test_explicit_source_scope_cannot_be_bypassed_by_fast_route(oee_asset):
from app.api.copilot import GenerateIn, _auto_route_ops
from tests.fixtures.sources import drop_source
async with SessionLocal() as session:
source = DataSource(name="only_this_scope", kind="sqlite", database=":memory:")
session.add(source)
await session.commit()
source_id = source.id
try:
# A caliber from another database is not a candidate for this scoped question.
# Return control to scoped exploration, rather than executing it or hijacking the turn with clarification.
ops = await _auto_route_ops(GenerateIn(instruction="昨天1050的OEE是多少", intent="answer",
datasource_ids=[source_id]))
assert ops is None
finally:
await drop_source(source_id)
async def test_migration_requires_builder_even_for_read_only_audit(client):
from tests.fakes import auth_env
await auth_env.reset()
admin = await auth_env.turn_on_local()
await auth_env.make_user("migration-user", role="user")
user = await auth_env.login("migration-user")
try:
response = await user.get("/api/ledger/drafts/absent/migration")
write = await user.post("/api/ledger/drafts/absent/migration", json={"expected_revision": 1})
assert response.status_code == write.status_code == 403
finally:
await user.aclose()
await admin.aclose()
await auth_env.reset()
async def test_matching_and_repeated_look_do_not_call_picker_again():
from tests.test_look_caliber_definition import _view
from app.look.calibers import CaliberKind
from app.look.service import LookService
class Picker:
async def pick(self, **kwargs):
raise AssertionError("known metric retrieval must not need a model")
kind = CaliberKind([_view(name=f"无关指标{i}", metric_id=f"x{i}") for i in range(50)] + [
_view(name="装配线 OEE", metric_id="assembly"), _view(name="发泡线 OEE", metric_id="foam")], picker=Picker())
result, reason = await kind.pick("昨天1050的OEE是多少", None)
assert reason is None and {v.metric_id for v, _ in result} == {"assembly", "foam"}
service = LookService([kind])
request = {"need": "昨天1050的OEE是多少", "targets": [{"kind": "caliber"}]}
first = await service.look(request)
assert first.record["kinds"] == ["caliber"]
async def no_more(*args, **kwargs):
raise AssertionError("duplicate look must short circuit before pick/detail")
kind.pick = no_more
second = await service.look(request)
assert second.record is None
async def test_analysis_request_is_not_blocked_by_disabled_or_ambiguous_semantics(monkeypatch):
from app.ledger import nlchange
async def cards(_):
return [{"workflow_id": "one", "version": 1, "workflow_name": "OEE口径", "caliber": "OEE",
"card_title": "OEE", "card_id": "card", "metrics": [
{"id": "m1", "name": "装配线 OEE", "semantic": {"enabled": False, "aliases": ["OEE"]}},
{"id": "m2", "name": "发泡线 OEE", "semantic": {"enabled": False, "aliases": ["OEE"]}}]}]
monkeypatch.setattr(nlchange, "_auto_cards", cards)
for question in ("分析昨天1050的OEE", "昨天1050的OEE为什么下降", "查一下昨天1050的OEE,解释原因"):
assert await nlchange.auto_route(None, question=question) is None
# Analysis does not grant access to an otherwise inaccessible matched asset.
refused = await nlchange.auto_route(None, question="分析昨天1050的OEE", allowed=set())
assert refused.mode == nlchange.ROUTE_REFUSED