Files
AgentLab/backend/tests/test_copilot_analysis.py

466 lines
28 KiB
Python
Raw Permalink 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.
"""助手搭图认分析能力(00-总方案 §3.7):节点手册教分析卡和分析工具、报告的分析结论引用写法;问数据的包装多一种「分析类
问题」的形状;自查时问题是分析类(app/analysis/intent)、查库的 Agent 却没绑分析工具,交回模型改一轮(只挡一轮,照 coverage
的样板),改过一轮仍在的不挡运行、留作提醒;报告的上游只有分析卡也算有可引用的来源。评测「分析工具开、关」对照的「关」那一臂
(answer_ops 的 analysis_tools、chat/drive.TurnDriver 的同名属性):工具清单、节点手册、问数据的包装里都没有分析,图里绑了
也按「绑定的工具不存在」挡,别的和开着时一样。
手册里举的引用标记交给真正的解析器,确认写法和系统认的是同一套(同 tests/test_copilot_entity_guide.py)。问法、节点、
表都是虚构的零售场景。
"""
from __future__ import annotations
import json
import re
import pytest
from langchain_core.messages import AIMessageChunk
from app.analysis import contract
from app.api.copilot import (_ASSIST_RULES, ANALYSIS_SHAPE_PROMPT, ANALYSIS_TOOLS_OFF, NODE_REFERENCE, GenerateIn,
_evidence_source, _question_of, _tool_catalog, _user_message, node_reference)
from app.api.copilot_gen import ANALYSIS_TOOLS_MISSING, GraphApplier, GraphChecker
from app.engine.evidence import _CELL_REF, _ROW_REF, SUPPORTED_KINDS, parse_markers
from app.engine.schema import ANALYSIS_CLAIMS_VALUES, ANALYSIS_SCOPE_VALUES, GraphNode, NodeType
from tests.test_copilot_answer_ops import _sse, _use, client # noqa: F401
TOOLS = list(contract.TOOL_NAMES.values())
def _section(start: str, end: str) -> str:
i = NODE_REFERENCE.index(start)
return NODE_REFERENCE[i:NODE_REFERENCE.index(end, i)]
def _report() -> str:
return _section("- report:", "\n- output")
# --------------------------------------------------------------------------
# 节点手册
# --------------------------------------------------------------------------
def test_the_card_entry_teaches_the_analysis_card():
card = _section("- analysis:分析卡", "\n- dual_check")
for words in (["items", "overrides", "from", "params", "hypothesis"], # 配置
["模板里每次都要做的分析", "周报的异动"], # 用途
["nodes.<id>.results.<项 id>", "verdict", "branch"], # 产出、按判定分支
["tool 节点或 merge", "agent 不能当", "headline", "parent"], # 输入只认查询节点
["不要把结果交给口径卡", "追不到原始数据"]): # D16
assert all(w in card for w in words), [w for w in words if w not in card]
assert all(op in card for op in contract.OPS)
assert all(mode in card for modes in contract.MODES.values() for mode in modes)
# 例子里的阈值键、判定码都是契约里的
assert all(key in contract.DEFAULTS for key in re.findall(r"\b(k_warn|main_share|k_alert|flat_pct)\b", card))
branch = re.search(r"verdict in \[([^\]]+)\]", card).group(1)
assert {v.strip(" '") for v in branch.split(",")} <= set(contract.VERDICTS["baseline"])
def test_the_agent_entry_names_the_five_analysis_tools():
agent = _section("- agent:", "\n- supervisor")
assert all(name in agent for name in TOOLS)
for words in ("分析方法", "注入", "不要写阈值", "只绑给同时绑了", "db_query__", "不能放进 tool 节点"):
assert words in agent, words
def test_the_markers_of_analysis_conclusions_parse_as_the_system_reads_them():
markers = [m for m in parse_markers(_report()) if "A1" in m["body"]]
kinds = {m["kind"] for m in markers}
assert {contract.MARKER_KIND, "v", "table", "see"} <= kinds <= SUPPORTED_KINDS
by_kind = {m["kind"]: m for m in markers}
plain = [m for m in markers if m["kind"] == contract.MARKER_KIND and m["ref"] == "A1"]
named = [m for m in markers if m["kind"] == contract.MARKER_KIND and m["ref"] != "A1"]
assert plain and named and named[0]["ref"] == "A1.top_share" and named[0]["conv"] == "pct"
cell = _CELL_REF.match(by_kind["v"]["ref"])
assert cell and cell.group("alias") == "A1" and cell.group("column") == "share"
assert _ROW_REF.match(by_kind["table"]["ref"]).group("alias") == "A1"
assert by_kind["see"]["refs"] == ["A1"]
# 拆解结果表的列 id(判定写法里举的 delta、share)是真结果表的列
from app.analysis.decompose import _FIXED_ADDITIVE
cols = by_kind["table"]["options"]["cols"].split(",")
assert set(cols[1:]) <= set(_FIXED_ADDITIVE), cols
def test_the_report_entry_spells_out_analysis_claims_and_scope():
report = _report()
assert "analysis_claims" in report and "analysis_scope" in report
assert all(v in report for v in ANALYSIS_CLAIMS_VALUES) and all(v in report for v in ANALYSIS_SCOPE_VALUES)
assert "off < check < strict" in report and "只能往严里改" in report
for words in ("主要来自", "异常", "持续上升", "待核实的推测", "说明不了为什么"):
assert words in report, words
def test_governed_templates_and_the_publish_assist_keep_analysis_claims_strict():
governed = NODE_REFERENCE[NODE_REFERENCE.index("要按受管级别发布的模板"):]
assert 'analysis_claims: "strict"' in governed[:400]
rules = " ".join(_ASSIST_RULES.split())
assert re.search(r"analysis_claims.{0,40}只能往严里改|只能往严里改.{0,80}analysis_claims: strict", rules), rules
assert "off < check < strict" in rules and "不改回 check" in rules
def test_the_build_rules_bind_analysis_tools_for_analysis_questions():
rules = NODE_REFERENCE[NODE_REFERENCE.index("搭图规则"):NODE_REFERENCE.index("模型字段")]
seventh = rules[rules.index("\n7."):]
assert "分析工具" in seventh and "分析卡" in seventh and "不进口径卡" in seventh
# --------------------------------------------------------------------------
# 问数据的包装
# --------------------------------------------------------------------------
def test_the_answer_request_has_an_analysis_shape():
text = _user_message(GenerateIn(instruction="上周销售额为什么下降", intent="answer"), patch=False)
shape = next(line for line in text.splitlines() if line.startswith("- 问的是为什么"))
assert "input → agent → report → output" in shape
assert all(name in shape for name in TOOLS) and "db_query__" in shape and "db_schema__" in shape
assert "按分析结论写" in shape and "判定语" in shape and "不写阈值" in shape
# 问数是探索运行:不替人打开结论句策略(test_copilot_entity_guide 守着),说法核对用默认、不关
assert not re.search(r"(?<!analysis_)claims\s*[:=]\s*['\"]?(require_citation|judge)", text)
assert "analysis_claims " in shape and "不要关" in shape
def test_the_question_for_the_self_check():
"""自查按用户原本的问题判意图:问数据有原问题时用它(重来时 instruction 是带着原因的那段话),没给用 instruction;
画布上用户说的是流程,不判。"""
retry = "这个工作流上一次运行没有成功:用满了步数。\n\n请针对这个原因改它,改完继续回答原来的问题:上周销售额多少"
assert _question_of(GenerateIn(instruction=retry, intent="answer", question="上周销售额多少")) == "上周销售额多少"
assert _question_of(GenerateIn(instruction="上周销售额为什么下降", intent="answer")) == "上周销售额为什么下降"
assert _question_of(GenerateIn(instruction="做一个分析销售额为什么下降的流程")) is None
def test_the_chat_drivers_pass_the_original_question():
from app.chat.drive import TurnDriver
driver = TurnDriver("上周销售额为什么下降", datasource_ids=[])
payload = driver.generate_in("这个工作流上一次运行没有成功:…改完继续回答原来的问题:上周销售额为什么下降", None)
assert payload.question == "上周销售额为什么下降" and payload.intent == "answer"
# --------------------------------------------------------------------------
# 报告的证据来源认分析卡
# --------------------------------------------------------------------------
def test_an_analysis_card_counts_as_an_evidence_source():
card = GraphNode.model_validate({"id": "check", "type": "analysis", "data": {"label": "异动分析卡", "config": {}}})
assert card.type == NodeType.ANALYSIS and _evidence_source(card)
# --------------------------------------------------------------------------
# 自查:分析类问题,查库的 Agent 要绑分析工具(只挡一轮)
# --------------------------------------------------------------------------
def _n(nid: str, ntype: str, **config) -> dict:
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def _graph(*, tools=("db_query__shop", "db_schema__shop"), card=False):
nodes = {"start": _n("start", "input"),
"bot": _n("bot", "agent", prompt="{{ input.question }}", tools=list(tools)),
"write": _n("write", "report", instructions="回答:{{ input.question }}"),
"out": _n("out", "output", fields=[{"name": "回答", "value": "{{ nodes.write.text }}"}])}
if card:
nodes["check"] = _n("check", "analysis", items=[])
order = ["start", "bot", "write", "out"]
return nodes, [{"source": a, "target": b} for a, b in zip(order, order[1:])]
async def test_an_analysis_question_without_analysis_tools_is_returned_once():
nodes, edges = _graph()
checker = GraphChecker(GraphApplier(nodes, edges), question="上周销售额为什么下降")
[issue] = [i for i in await checker.issues() if i["code"] == ANALYSIS_TOOLS_MISSING]
assert (issue["level"], issue["node_id"], issue["field"]) == ("error", "bot", "tools")
assert issue["message"].startswith("问题问的是原因(「为什么下降」),查库的「bot」没有绑定分析工具")
assert all(name in issue["for_model"] for name in TOOLS) and "explanation" in issue["for_model"]
# 交回模型改过一轮:之后不再挡;收尾时作为提醒
assert not [i for i in await checker.issues() if i["code"] == ANALYSIS_TOOLS_MISSING]
[warning] = checker.analysis_gaps(level="warning")
assert (warning["level"], warning["code"]) == ("warning", ANALYSIS_TOOLS_MISSING)
@pytest.mark.parametrize("question, graph", [
("上周销售额为什么下降", _graph(tools=("db_query__shop", "db_schema__shop", "analysis_decompose"))), # 绑了一个就算
("上周销售额为什么下降", _graph(card=True)), # 有分析卡
("上周销售额为什么下降", _graph(tools=("current_time",))), # 没有查库的 Agent
("上周销售额是多少", _graph()), # 不是分析类问题
(None, _graph()), # 画布:没有问题
])
async def test_the_rule_stays_quiet_otherwise(question, graph):
nodes, edges = graph
checker = GraphChecker(GraphApplier(nodes, edges), question=question)
assert not [i for i in await checker.issues() if i["code"] == ANALYSIS_TOOLS_MISSING]
assert checker.analysis_gaps(level="warning") == []
def _graph_ops(nodes: dict, edges: list[dict]) -> list[dict]:
return [{"op": "plan", "summary": "查销售额"},
*({"op": "add_node", "node": {"id": n["id"], "type": n["type"], "label": n["id"],
"config": n["data"]["config"]}} for n in nodes.values()),
*({"op": "add_edge", "edge": e} for e in edges), {"op": "done", "explanation": "查一下", "run": True}]
def _ops(tools: list[str]) -> list[dict]:
return _graph_ops(*_graph(tools=tools))
def _card_ops(*, subgraph: str | None = None) -> list[dict]:
"""带分析卡的图:入口 → 查库的 Agent → 分析卡 → 报告 → 出口。subgraph 给了就把分析卡换成一个嵌套它的子工作流节点。"""
middle = (_n("check", "analysis", items=[{"id": "wow", "op": "baseline", "from": "q",
"params": {"period": "week", "measure": "销售额"}}])
if subgraph is None else _n("sub", "subgraph", workflow_id=subgraph))
nodes = {"start": _n("start", "input"),
"q": _n("q", "tool", tool="db_query__shop", args={"sql": "SELECT 1"}),
"bot": _n("bot", "agent", prompt="{{ input.question }}", tools=["db_query__shop", "db_schema__shop"]),
middle["id"]: middle,
"write": _n("write", "report", instructions="回答:{{ input.question }}"),
"out": _n("out", "output", fields=[{"name": "回答", "value": "{{ nodes.write.text }}"}])}
order = ["start", "q", middle["id"], "write", "out"]
return _graph_ops(nodes, [{"source": a, "target": b} for a, b in zip(order, order[1:])])
def _scripted(first: list[dict], fix: list[dict], seen: list[str]):
class Scripted:
async def astream(self, messages):
human = next(text for role, text in messages if role == "human")
seen.append(human)
for op in (fix if "上线前自查" in human else first):
yield AIMessageChunk(content=json.dumps(op, ensure_ascii=False) + "\n")
return Scripted()
async def test_the_stream_asks_for_one_repair_and_the_model_binds_the_tools(client, monkeypatch): # noqa: F811
seen: list[str] = []
fix = [{"op": "update_node", "id": "bot", "config": {"tools": ["db_query__shop", "db_schema__shop", *TOOLS]}},
{"op": "done", "explanation": "绑上了分析工具"}]
_use(monkeypatch, _scripted(_ops(["db_query__shop", "db_schema__shop"]), fix, seen))
got = await _sse(client, {"instruction": "上周销售额为什么下降", "intent": "answer"})
[repairing] = [op for op in got if op["op"] == "check" and op["status"] == "repairing"]
assert ANALYSIS_TOOLS_MISSING in [i["code"] for i in repairing["issues"]]
assert any("上线前自查" in h and "analysis_decompose" in h for h in seen), "交回模型改一轮"
[final] = [op for op in got if op["op"] == "final"]
bot = next(n for n in final["graph"]["nodes"] if n["id"] == "bot")
assert set(TOOLS) <= set(bot["data"]["config"]["tools"])
assert not [i for i in final["issues"] if i["code"] == ANALYSIS_TOOLS_MISSING]
async def test_a_model_that_keeps_its_graph_still_runs_with_a_warning(client, monkeypatch): # noqa: F811
seen: list[str] = []
keep = [{"op": "done", "explanation": "这个问题其实只要一个数,不用分析"}]
_use(monkeypatch, _scripted(_ops(["db_query__shop", "db_schema__shop"]), keep, seen))
got = await _sse(client, {"instruction": "上周销售额为什么下降", "intent": "answer"})
assert sum("上线前自查" in h for h in seen) == 1, "只挡一轮"
[final] = [op for op in got if op["op"] == "final"]
assert final["next"] == "run", "改过一轮仍在的不挡运行"
assert [i["level"] for i in final["issues"] if i["code"] == ANALYSIS_TOOLS_MISSING] == ["warning"]
async def test_a_retry_reads_the_original_question(client, monkeypatch): # noqa: F811
"""重来时 instruction 是带着原因的那段话(里面有「原因」两个字):按原问题判,原问题不是分析类就不提。"""
seen: list[str] = []
_use(monkeypatch, _scripted(_ops(["db_query__shop", "db_schema__shop"]), [], seen))
retry = "这个工作流上一次运行没有成功:用满了步数。\n\n请针对这个原因改它,改完继续回答原来的问题:上周销售额多少"
got = await _sse(client, {"instruction": retry, "intent": "answer", "question": "上周销售额多少"})
assert not [op for op in got if op["op"] == "check" and op["status"] == "repairing"]
[final] = [op for op in got if op["op"] == "final"]
assert not [i for i in final["issues"] if i["code"] == ANALYSIS_TOOLS_MISSING]
# --------------------------------------------------------------------------
# 关掉分析工具(评测「分析工具开、关」对照的「关」那一臂:answer_ops 的 analysis_tools、TurnDriver 的同名属性)
# --------------------------------------------------------------------------
def test_the_catalog_and_the_manual_leave_analysis_out_when_switched_off():
"""关掉分析工具的那一臂:工具清单里没有五个分析工具,节点手册里一处都不提分析卡、分析工具、analysis_ 开头的配置键和
标记——助手不知道有这回事,也不会被教着去用。开着时一字不差(node_reference() 就是 NODE_REFERENCE)。"""
assert all(f"- {name}(" in _tool_catalog([]) for name in TOOLS)
off = _tool_catalog([], analysis_tools=False)
assert not [line for line in off.splitlines() if line.startswith("- analysis_")]
assert off == "\n".join(line for line in _tool_catalog([]).splitlines() if not line.startswith("- analysis_"))
assert node_reference() is NODE_REFERENCE
manual = node_reference(analysis_tools=False)
assert not any(name in manual for name in TOOLS)
for gone in ("分析卡", "分析工具", "analysis_"):
assert gone not in manual, gone
# 去掉的是 agent 段里「分析类问题绑分析工具」那一条、分析卡一整条、搭图规则第 7 条;报告段里分析结论的来处和引用写法、
# analysis_claims / analysis_scope 两个配置、受管规则里分析卡的那一句也不给(关的那一臂没有分析结论可引)
assert "- analysis:" not in manual and "\n7. " not in manual and "**问题问的是为什么" not in manual
assert "[[a:" not in manual and "判定语" not in manual
# 别的段落一字不少:报告、双盲复算、反方审稿、受管规则里和分析无关的几条照留
for kept in (" **默认不要写 max_steps", "- dual_check:", "\n模型字段", "- report:", "- challenge:",
"claims: \"require_citation\"", "[[m:指标id]]", "[[see:m:指标id,Q1,K1]]", "受管级别"):
assert kept in manual, kept
request = GenerateIn(instruction="上周销售额为什么下降", intent="answer")
on, off_text = _user_message(request, patch=False), _user_message(request, patch=False, analysis_tools=False)
assert ANALYSIS_SHAPE_PROMPT in on and ANALYSIS_SHAPE_PROMPT not in off_text
assert on.replace(ANALYSIS_SHAPE_PROMPT, "") == off_text
def _recording(first: list[dict], fix: list[dict], seen: list[list[tuple[str, str]]]):
class Recording:
async def astream(self, messages):
seen.append(list(messages))
human = next(text for role, text in messages if role == "human")
for op in (fix if "上线前自查" in human else first):
yield AIMessageChunk(content=json.dumps(op, ensure_ascii=False) + "\n")
return Recording()
async def _ops_of(question: str, *, analysis_tools: bool) -> list[dict]:
from app.api.copilot import answer_ops
return [op async for op in answer_ops(GenerateIn(instruction=question, intent="answer"),
analysis_tools=analysis_tools)
if op.get("op") not in ("heartbeat", "usage")]
async def test_switched_off_the_copilot_sees_no_analysis_and_bound_tools_are_blocked(monkeypatch):
seen: list[list[tuple[str, str]]] = []
fix = [{"op": "update_node", "id": "bot", "config": {"tools": ["db_query__shop", "db_schema__shop"]}},
{"op": "done", "explanation": "去掉了分析工具"}]
_use(monkeypatch, _recording(_ops(["db_query__shop", "db_schema__shop", "analysis_decompose"]), fix, seen))
got = await _ops_of("上周销售额为什么下降", analysis_tools=False)
system = seen[0][0][1]
assert seen[0][0][0] == "system" and not any(name in system for name in TOOLS)
assert ANALYSIS_TOOLS_MISSING not in json.dumps(got, ensure_ascii=False), "问题意图那条自查不提"
[repairing] = [op for op in got if op["op"] == "check" and op["status"] == "repairing"]
[issue] = [i for i in repairing["issues"] if i["code"] == ANALYSIS_TOOLS_OFF]
assert issue["node_id"] == "bot" and issue["message"].startswith("绑定的工具不存在:「bot」绑定的 analysis_decompose")
[final] = [op for op in got if op["op"] == "final"]
assert final["next"] == "run" and not [i for i in final["issues"] if i["code"] == ANALYSIS_TOOLS_OFF]
# 改不好:照实列出、不运行
seen.clear()
_use(monkeypatch, _recording(_ops(["db_query__shop", "analysis_decompose"]), [{"op": "done", "explanation": "x"}],
seen))
got = await _ops_of("上周销售额为什么下降", analysis_tools=False)
[final] = [op for op in got if op["op"] == "final"]
assert final["next"] == "fail" and [i["level"] for i in final["issues"] if i["code"] == ANALYSIS_TOOLS_OFF] == ["error"]
async def test_switched_off_an_analysis_card_is_blocked_and_removed(monkeypatch):
"""分析卡也挡:关掉分析工具的那一臂,图里有分析卡就交回模型去掉它、把上下游直接连起来。改好了照常运行。"""
seen: list[list[tuple[str, str]]] = []
fix = [{"op": "remove_node", "id": "check"},
{"op": "add_edge", "edge": {"source": "bot", "target": "write"}},
{"op": "done", "explanation": "去掉了分析卡"}]
_use(monkeypatch, _recording(_card_ops(), fix, seen))
got = await _ops_of("上周销售额为什么下降", analysis_tools=False)
rounds = [op for op in got if op["op"] == "check" and op["status"] == "repairing"]
[issue] = [i for r in rounds for i in r["issues"] if i["code"] == ANALYSIS_TOOLS_OFF]
assert (issue["level"], issue["node_id"], issue["field"]) == ("error", "check", None)
assert issue["message"] == "这次不能用分析卡:「check」是分析卡,这一轮没有分析能力可用,请去掉它"
# 交回模型的那一句守规矩:说清去掉它、上下游接回去、数怎么来;关的那一臂手册里没有「分析卡」这个说法,
# 所以指节点说它是 analysis 节点
assert "「check」是 analysis 节点" in issue["for_model"] and "remove_node" in issue["for_model"]
assert "去掉它" in issue["for_model"] and "写进查询" in issue["for_model"]
[final] = [op for op in got if op["op"] == "final"]
assert final["next"] == "run", final["issues"]
assert "check" not in {n["id"] for n in final["graph"]["nodes"]}
assert not [i for i in final["issues"] if i["code"] == ANALYSIS_TOOLS_OFF]
# 改不好:照实列出、不运行(图里还留着分析卡)
seen.clear()
_use(monkeypatch, _recording(_card_ops(), [{"op": "done", "explanation": "x"}], seen))
got = await _ops_of("上周销售额为什么下降", analysis_tools=False)
[final] = [op for op in got if op["op"] == "final"]
assert final["next"] == "fail"
[left] = [i for i in final["issues"] if i["code"] == ANALYSIS_TOOLS_OFF]
assert (left["level"], left["node_id"]) == ("error", "check")
async def test_switched_off_a_subgraph_that_uses_analysis_is_blocked(monkeypatch, tmp_path):
"""钉了版本、里面用了分析能力的子工作流(这里是里面有分析卡)也挡:这一轮用不了它。里面没有分析能力的子工作流光看外层
看不出来,照常运行。"""
from app.db.base import SessionLocal
from app.db.models import Workflow
async def make(name: str, graph: dict) -> str:
async with SessionLocal() as session:
row = Workflow(name=name, graph=graph, version=1, published_version=1)
session.add(row)
await session.commit()
return row.id
inner_card = _graph(tools=("db_query__shop", "db_schema__shop"), card=True)[0]
plain_nodes = _graph(tools=("db_query__shop", "db_schema__shop"))[0]
with_card = await make("异动分析(虚构)", {"nodes": list(inner_card.values()),
"edges": [{"source": a, "target": b}
for a, b in zip(inner_card, list(inner_card)[1:])]})
without_card = await make("只查数(虚构)", {"nodes": list(plain_nodes.values()),
"edges": [{"source": a, "target": b}
for a, b in zip(plain_nodes, list(plain_nodes)[1:])]})
seen: list[list[tuple[str, str]]] = []
fix = [{"op": "remove_node", "id": "sub"},
{"op": "add_edge", "edge": {"source": "q", "target": "write"}},
{"op": "done", "explanation": "去掉了子工作流"}]
_use(monkeypatch, _recording(_card_ops(subgraph=with_card), fix, seen))
got = await _ops_of("上周销售额为什么下降", analysis_tools=False)
[repairing] = [op for op in got if op["op"] == "check" and op["status"] == "repairing"]
[issue] = [i for i in repairing["issues"] if i["code"] == ANALYSIS_TOOLS_OFF]
assert (issue["node_id"], issue["field"]) == ("sub", "workflow_id")
assert "这次不能用这个子工作流" in issue["message"] and "分析卡或分析工具" in issue["message"]
assert "去掉这个 subgraph 节点" in issue["for_model"]
[final] = [op for op in got if op["op"] == "final"]
assert final["next"] == "run", final["issues"]
assert "sub" not in {n["id"] for n in final["graph"]["nodes"]}
assert not [i for i in final["issues"] if i["code"] == ANALYSIS_TOOLS_OFF]
# 里面没分析能力的子工作流不挡;开着分析工具时,有分析卡的子工作流也不挡
seen.clear()
_use(monkeypatch, _recording(_card_ops(subgraph=without_card), [], seen))
got = await _ops_of("上周销售额为什么下降", analysis_tools=False)
assert not [i for op in got if op["op"] == "check" and op["status"] == "repairing"
for i in op["issues"] if i["code"] == ANALYSIS_TOOLS_OFF]
_use(monkeypatch, _recording(_card_ops(subgraph=with_card), [], seen))
got = await _ops_of("上周销售额为什么下降", analysis_tools=True)
assert not [i for op in got if op["op"] == "check" and op["status"] == "repairing"
for i in op["issues"] if i["code"] == ANALYSIS_TOOLS_OFF]
async def test_switched_on_bound_analysis_tools_are_fine(monkeypatch):
"""缺省开着:绑了分析工具不挡,和以前一样直接运行。"""
seen: list[list[tuple[str, str]]] = []
_use(monkeypatch, _recording(_ops(["db_query__shop", "db_schema__shop", *TOOLS]), [], seen))
got = await _ops_of("上周销售额为什么下降", analysis_tools=True)
assert all(name in seen[0][0][1] for name in TOOLS)
assert not [op for op in got if op["op"] == "check" and op["status"] == "repairing"]
[final] = [op for op in got if op["op"] == "final"]
assert final["next"] == "run"
async def test_other_turns_go_the_same_either_way(monkeypatch):
"""没碰分析工具的一轮(不是分析类问题、图里没绑分析工具):开、关两臂交出的操作逐条一样,差的只是给助手看的那几段。"""
runs = {}
for flag in (True, False):
seen: list[list[tuple[str, str]]] = []
_use(monkeypatch, _recording(_ops(["db_query__shop", "db_schema__shop"]), [], seen))
runs[flag] = (await _ops_of("上周销售额是多少", analysis_tools=flag), seen[0])
assert runs[True][0] == runs[False][0]
(_, system_on), (_, human_on) = runs[True][1]
(_, system_off), (_, human_off) = runs[False][1]
assert human_on.replace(ANALYSIS_SHAPE_PROMPT, "") == human_off and system_on != system_off
async def test_the_turn_driver_passes_the_switch_through(monkeypatch):
import app.api.copilot as copilot
from app.chat.drive import TurnDriver
calls: list[dict] = []
async def fake_ops(payload, **kwargs):
calls.append({"question": payload.question, **kwargs})
yield {"op": "reply", "text": "上一轮已经查过了。"}
monkeypatch.setattr(copilot, "answer_ops", fake_ops)
assert TurnDriver("上周销售额为什么下降", datasource_ids=[]).analysis_tools is True
driver = TurnDriver("上周销售额为什么下降", datasource_ids=[], analysis_tools=False)
ops, refused = await driver._generate("上周销售额为什么下降", None)
assert refused is None and ops == [{"op": "reply", "text": "上一轮已经查过了。"}]
assert calls == [{"question": "上周销售额为什么下降", "catalog_view": None, "analysis_tools": False}]