mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 09:11:59 +00:00
3dc5f5add4
真实模型验收里 deepseek 偶发只出思考、不出操作,生成过程交出 error「助手本轮未完成:模型没有给出任何修改,也没有说明 原因」,问数据这一轮照实报了,但不会自动重来——而这种偶发情况重来一次多半就好,「重来一次」的规矩本来就有 (chat/turn 的 MAX_ATTEMPTS = 2,含第一次)。 - chat/turn:error 也由 next_step 判:机读码是 EMPTY_GENERATION(copilot_empty)的判 retry,别的仍判失败(被截断的不重来: 额度不变,重来还是截断);error_reason 取重来时交给助手的原因(error 带的 reason,没有就去掉「助手本轮未完成:」前缀); - api/copilot.answer_ops:一个操作都没给时 CopilotIncomplete 带上这个码;error 操作多带 reason 和 next(retry | fail),和 final 的 next 一样由服务端定; - 问数据页(store/chat.ts)照 error.next 走:retry 就带着原因重来一次(同 retry_instruction 的从零重建措辞,次数照 MAX_ATTEMPTS),试满了才照实报;进度写「助手上一次没有给出任何操作,正在重新生成工作流…」(lib/terms 的 CHAT_RETRY_TEXT); 第一次建图的用量照样记进这一轮; - 评测(eval/answer.Driver.drive)用同一个 next_step / error_reason / retry_instruction:先重来一次,还不行才记建图失败、 助手调用失败;每次自动重来的原因照实记在 turn.retries(没过检查、复核判不可用的重来也记),评测页逐题明细写出来。 测试:test_chat_turn_policy.py 的 test_next_step_branches(error 的两支和 error_reason)、 test_the_frontend_retries_an_error_the_server_says_to_retry、test_answer_ops_says_to_retry_an_empty_generation、 test_the_eval_driver_retries_an_empty_generation_once(重来后回答 / 重来还不行,两次的用量都算); test_copilot_answer_ops.py 的 SSE 对照多认 error 上新加的 next、reason、code。去掉修复回跑都失败。 浏览器:check-chat 新加 emptyretry 一节(重来一次后照常回答、交回去的话带原因、两次用量都记;两次都不行照实报、不试第三次), check-chat 840 项全过。
182 lines
7.9 KiB
Python
182 lines
7.9 KiB
Python
"""生成过程抽成可直接调用的 answer_ops(细案 01 做法 2):SSE 路由只包一层,同一个剧本下输出逐条不变。
|
||
|
||
评测和问数据页要走同一条生成路径:评测直接迭代 answer_ops 拿 model / look / check / final / reply / error 这些操作,
|
||
问数据页照旧收 SSE。这里守:
|
||
|
||
- 几种结局(建图交付、直接回答、只问不改、自查改不好、模型出错、没给操作、输出被截断)的 SSE 输出,和抽出来之前
|
||
逐条一样(剧本见 SCENARIOS,抽出来之前录下的输出在 fixtures/copilot_answer_ops_baseline.json),末尾只多一条 usage,
|
||
final 只多一个 next(问数据一轮的决定,chat/turn.py);error 多一个 next 和原因 reason(一个操作都没给的另带机读码 code,
|
||
next 是 retry:问数据页和评测据此自动重来一次);
|
||
- 直接迭代 answer_ops 和 SSE 收到的是同一串;
|
||
- 开流前的拒绝(模型没配好、限定的数据源都不在)照旧是 HTTP 错误,不进流。
|
||
|
||
剧本、需求都是虚构的。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from httpx import ASGITransport, AsyncClient
|
||
from langchain_core.messages import AIMessageChunk
|
||
|
||
from app.main import app
|
||
|
||
BASELINE = Path(__file__).parent / "fixtures" / "copilot_answer_ops_baseline.json"
|
||
#: 和时间有关、每次都不一样的字段:比较前去掉
|
||
_TIMING = ("elapsed_ms", "ms", "duration_ms")
|
||
|
||
|
||
@pytest.fixture
|
||
async def client():
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||
yield c
|
||
|
||
|
||
def _node(nid: str, ntype: str, config: dict) -> dict:
|
||
return {"op": "add_node", "node": {"id": nid, "type": ntype, "label": nid, "config": config}}
|
||
|
||
|
||
def _edge(a: str, b: str, **extra) -> dict:
|
||
return {"op": "add_edge", "edge": {"source": a, "target": b, **extra}}
|
||
|
||
|
||
def _loop_graph(condition: str) -> list[dict]:
|
||
return [
|
||
{"op": "plan", "summary": "数一数"},
|
||
_node("start", "input", {"fields": [{"name": "question"}]}),
|
||
_node("init", "transform", {"mode": "expression", "expression": "[1, 2]", "assign_to": "items"}),
|
||
_edge("start", "init"),
|
||
_node("lp", "loop", {"mode": "while", "condition": condition, "max_iterations": 2}),
|
||
_edge("init", "lp"),
|
||
_node("body", "transform", {"mode": "expression", "expression": "1"}),
|
||
_edge("lp", "body", sourceHandle="body"),
|
||
_edge("body", "lp"),
|
||
_node("out", "output", {"fields": [{"name": "结果", "value": "{{ vars.items }}"}]}),
|
||
_edge("lp", "out", sourceHandle="done"),
|
||
{"op": "done", "explanation": "数一数", "run": True},
|
||
]
|
||
|
||
|
||
def _fix(condition: str) -> list[dict]:
|
||
return [{"op": "plan", "summary": "改条件"},
|
||
{"op": "update_node", "id": "lp", "config": {"mode": "while", "condition": condition, "max_iterations": 2}},
|
||
{"op": "done", "explanation": "改好了"}]
|
||
|
||
|
||
def _chunks(ops: list) -> list[AIMessageChunk]:
|
||
return [op if isinstance(op, AIMessageChunk) else AIMessageChunk(content=json.dumps(op, ensure_ascii=False) + "\n")
|
||
for op in ops]
|
||
|
||
|
||
#: 剧本:(第一次调用吐的, 自查修正时吐的)。None 表示这一次调用直接抛错
|
||
SCENARIOS: dict[str, tuple[list | None, list | None]] = {
|
||
"graph_ok": (_loop_graph("len(vars.items) > 5"), []),
|
||
"graph_fixed": (_loop_graph("{{ vars.items | length }} > 5"), _fix("len(vars.items) > 5")),
|
||
"check_failed": (_loop_graph("foo(vars.items)"), _fix("bar(vars.items)")),
|
||
"reply": ([{"op": "reply", "text": "上一轮已经查过了:一共 3 家门店。"}], []),
|
||
"ask_only": ([{"op": "plan", "summary": "先问清楚"},
|
||
{"op": "ask", "question": "要看哪个月的?", "options": ["九月", "十月"]}], []),
|
||
"error": (None, []),
|
||
"no_ops": (["模型的随口注释,不是 JSON"], []),
|
||
"truncated": ([{"op": "plan", "summary": "想了很久"},
|
||
AIMessageChunk(content="", response_metadata={"finish_reason": "length"})], []),
|
||
}
|
||
|
||
|
||
class _Scripted:
|
||
"""照 SCENARIOS 吐:human 里有「上线前自查」时吐修正那一份。"""
|
||
|
||
def __init__(self, first: list | None, fix: list | None) -> None:
|
||
self.first, self.fix = first, fix
|
||
|
||
async def astream(self, messages):
|
||
human = next(text for role, text in messages if role == "human")
|
||
lines = self.fix if "上线前自查" in human else self.first
|
||
if lines is None:
|
||
raise RuntimeError("接入返回了 502")
|
||
for chunk in _chunks(lines):
|
||
yield chunk
|
||
|
||
|
||
def _use(monkeypatch, model) -> None:
|
||
import app.api.copilot as copilot
|
||
|
||
async def _model(*_a, **_k):
|
||
return model, "mock-fast"
|
||
|
||
monkeypatch.setattr(copilot, "get_chat_model", _model)
|
||
# 心跳和时间有关:拍子调大,剧本里不会有
|
||
monkeypatch.setattr(copilot, "_HEARTBEAT_SECONDS", 3600.0)
|
||
|
||
|
||
def _clean(value):
|
||
if isinstance(value, dict):
|
||
return {k: _clean(v) for k, v in value.items() if k not in _TIMING}
|
||
if isinstance(value, list):
|
||
return [_clean(v) for v in value]
|
||
return value
|
||
|
||
|
||
async def _sse(client, body: dict) -> list[dict]:
|
||
out: list[dict] = []
|
||
async with client.stream("POST", "/api/copilot/generate-stream", json=body) as r:
|
||
assert r.status_code == 200, await r.aread()
|
||
async for line in r.aiter_lines():
|
||
if line.startswith("data: "):
|
||
out.append(json.loads(line[6:]))
|
||
return [_clean(op) for op in out if op.get("op") != "heartbeat"]
|
||
|
||
|
||
BODY = {"instruction": "数一数门店", "intent": "answer"}
|
||
|
||
|
||
#: final 带的 next(chat/turn.next_step):问数据页照它走
|
||
NEXT = {"graph_ok": "run", "graph_fixed": "run", "check_failed": "fail", "ask_only": "ready"}
|
||
#: error 带的 (next, code):只有一个操作都没给的重来一次
|
||
ERROR_NEXT = {"error": ("fail", None), "no_ops": ("retry", "copilot_empty"), "truncated": ("fail", None)}
|
||
|
||
|
||
@pytest.mark.parametrize("name", list(SCENARIOS))
|
||
async def test_the_sse_output_is_unchanged_but_for_a_trailing_usage(client, monkeypatch, name):
|
||
_use(monkeypatch, _Scripted(*SCENARIOS[name]))
|
||
got = await _sse(client, BODY)
|
||
expected = json.loads(BASELINE.read_text(encoding="utf-8"))[name]
|
||
assert got[-1]["op"] == "usage", got[-1]
|
||
# final 多了 next(问数据一轮的决定下沉到后端),别的一个字不变
|
||
finals = [op for op in got if op["op"] == "final"]
|
||
assert [op.pop("next") for op in finals] == ([NEXT[name]] if name in NEXT else [])
|
||
errors = [op for op in got if op["op"] == "error"]
|
||
assert [(op.pop("next"), op.pop("code", None)) for op in errors] == \
|
||
([ERROR_NEXT[name]] if name in ERROR_NEXT else [])
|
||
for op in errors:
|
||
assert op["message"] == f"助手本轮未完成:{op.pop('reason')}", op
|
||
assert got[:-1] == expected
|
||
|
||
|
||
@pytest.mark.parametrize("name", ["graph_ok", "reply", "error"])
|
||
async def test_iterating_answer_ops_directly_gives_the_same_ops(client, monkeypatch, name):
|
||
from app.api.copilot import GenerateIn, answer_ops
|
||
|
||
_use(monkeypatch, _Scripted(*SCENARIOS[name]))
|
||
sse = await _sse(client, BODY)
|
||
direct = [_clean(op) async for op in answer_ops(GenerateIn(**BODY)) if op.get("op") != "heartbeat"]
|
||
assert direct == sse
|
||
|
||
|
||
async def test_refusals_before_the_stream_stay_http_errors(client, monkeypatch):
|
||
import app.api.copilot as copilot
|
||
from app.providers.factory import ProviderNotConfigured
|
||
|
||
async def _unconfigured(*_a, **_k):
|
||
raise ProviderNotConfigured("provider 'gw' 需要填 base_url")
|
||
|
||
monkeypatch.setattr(copilot, "get_chat_model", _unconfigured)
|
||
r = await client.post("/api/copilot/generate-stream", json=BODY)
|
||
assert r.status_code == 400 and "模型接入「gw」" in r.json()["detail"]
|
||
|
||
_use(monkeypatch, _Scripted(*SCENARIOS["reply"]))
|
||
r = await client.post("/api/copilot/generate-stream", json={**BODY, "datasource_ids": ["no-such-source"]})
|
||
assert r.status_code == 400 and r.json()["code"] == "datasource_scope_empty"
|