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