mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 23:22:00 +00:00
03c18c4d78
一、文案(前端约 1600 处,后端传到界面的约 880 处) - 术语统一:工作流层面用「运行」(继续运行、重新运行、试运行),节点和工具层面用「执行」,不再用「跑」; 总时长 / 执行时长 / 等待审批;服务端;助手;Agent;token;工作流;固定版本;模型接入;系统提示;批准 / 驳回;受管级别 - 口语改书面:取不到、没取回来、读不懂、连不上、多半、照样、兜底、揉成这类说法改成「无法 / 未能 / 失败 / 请」;去掉拟人写法 - 不再露出实现细节:界面上不写 checkpoint、同一拍、哈希(凭证处除外),不写改版历史(「以前…」「老数据」); 报错、校验、门禁、自动修复里裸写的配置键名和枚举值换成界面上的中文标签,由新的 backend/app/engine/labels.py 统一提供; 发布弹窗里结论句的选项不再是 off / require_citation - 同一件事只留一种说法:生成阶段文案、出具档位说明、画布锁定提示各合成一份 - 去掉界面上的工业场景示例(MES、ERP、「张工」「王工」)和真实产品名,换成通用示例 二、说错的提示(都有测试证明) - 数据库测连接失败时的专用提示以前从不出现(按报错文字全等比较,永远比不上):改为按错误类型判断,不再拼出重复的前缀 - 「设置 → 供应商」改指「设置 → 模型接入」;「在设置里建数据源」改指「数据」页 - 缺少 API Key、模型接入已停用的报错不再夹英文,写明去哪里填 - 两处原因说错(「这种引用在后续版本支持」「这一版还不支持」)改成真实原因 - 节点说明里不准确的说法改正:代码节点在 microVM 或系统沙箱中执行;报告撰写、口径卡的说明不再说过头 - 导出的证据 CSV 里两列同名,第三列改为「片段编号」 三、前后端对齐 - 前端按后端原文识别日志、报错的地方,新旧两种写法都认得(历史运行里存的是旧文字),逐条补了新旧原文的核对 - 后端交回模型的原指令放进单独的 for_model 字段,前端在解析响应时去掉,不会显示到界面上 四、检查 - 检查脚本里按原文匹配的断言改成新文案;改完会变成永远成立的反向断言逐条修正,并做了变异验证 - 新增 check-copy(静态扫描前端界面字符串和后端上界面的文案):再出现「跑」「后端」「多半」「令牌」、 界面上的 Copilot、裸写的配置键名、半角标点等就报错;确需保留的写进 scripts/copy-allow.json 并注明理由
373 lines
16 KiB
Python
373 lines
16 KiB
Python
"""会话:持久化、列表,以及上下文真的进了 prompt。
|
||
|
||
起因是三条 run(d983c4e6 / 14103db5 / 08c3de1c)在用户看来是一次对话,
|
||
系统里却毫无关联:每轮 base_graph=None 从零建图,每轮一条独立的 thread_id,
|
||
而前端的 turns 只活在内存里——刷新页面就全没了。
|
||
|
||
这里最要紧的不是 CRUD,是最后那组:**上下文确实到了模型手上**。拼字符串的
|
||
函数单独测通过、但没接到 messages 里,是这类功能最典型的失败方式,而且界面
|
||
上完全看不出来——它只是"答得不太对"。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
|
||
import pytest
|
||
from httpx import ASGITransport, AsyncClient
|
||
|
||
from app.api.conversations import HISTORY_TURNS, history_text, title_from
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import Conversation, ConversationTurn
|
||
from app.main import app
|
||
|
||
|
||
@pytest.fixture
|
||
async def client():
|
||
transport = ASGITransport(app=app)
|
||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||
yield c
|
||
|
||
|
||
async def _conversation(client, **kw) -> dict:
|
||
r = await client.post("/api/conversations", json={"kind": "chat", **kw})
|
||
assert r.status_code == 201, r.text
|
||
return r.json()
|
||
|
||
|
||
async def _turn(client, conv_id: str, question: str, **patch) -> dict:
|
||
r = await client.post(f"/api/conversations/{conv_id}/turns", json={"question": question})
|
||
assert r.status_code == 201, r.text
|
||
turn = r.json()
|
||
if patch:
|
||
r = await client.patch(f"/api/conversations/{conv_id}/turns/{turn['id']}", json=patch)
|
||
assert r.status_code == 200, r.text
|
||
turn = r.json()
|
||
return turn
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 会话本身
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def test_long_titles_are_cut_not_wrapped() -> None:
|
||
assert title_from("短问题") == "短问题"
|
||
assert title_from("") == "新对话"
|
||
long = title_from("请帮我统计一下过去十二个月每个门店每个月的订单总额并按同比排序")
|
||
assert len(long) <= 25 and long.endswith("…")
|
||
|
||
|
||
async def test_deleting_a_conversation_takes_its_turns(client) -> None:
|
||
conv = await _conversation(client)
|
||
turn = await _turn(client, conv["id"], "问点什么")
|
||
assert (await client.delete(f"/api/conversations/{conv['id']}")).status_code == 204
|
||
assert (await client.get(f"/api/conversations/{conv['id']}")).status_code == 404
|
||
async with SessionLocal() as session:
|
||
assert await session.get(ConversationTurn, turn["id"]) is None
|
||
|
||
|
||
async def test_archived_conversations_leave_the_list(client) -> None:
|
||
conv = await _conversation(client)
|
||
await client.patch(f"/api/conversations/{conv['id']}", json={"archived": True})
|
||
assert conv["id"] not in {r["id"] for r in (await client.get("/api/conversations")).json()}
|
||
rows = (await client.get("/api/conversations?include_archived=true")).json()
|
||
assert conv["id"] in {r["id"] for r in rows}
|
||
|
||
|
||
async def test_canvas_conversations_stay_out_of_the_chat_list(client) -> None:
|
||
"""画布的对话依附工作流,和问数据页不是一回事,混在一起反而难找。"""
|
||
canvas = await _conversation(client, kind="canvas")
|
||
assert canvas["id"] not in {r["id"] for r in (await client.get("/api/conversations")).json()}
|
||
rows = (await client.get("/api/conversations?kind=canvas")).json()
|
||
assert canvas["id"] in {r["id"] for r in rows}
|
||
|
||
|
||
async def test_a_failed_turn_is_still_recorded(client) -> None:
|
||
"""只记成功的轮次,历史就成了一份美化过的记录——而人回来最想搞清楚的
|
||
恰恰是上次卡在哪。"""
|
||
conv = await _conversation(client)
|
||
await _turn(client, conv["id"], "查点什么", status="error", error="模型不存在")
|
||
detail = (await client.get(f"/api/conversations/{conv['id']}")).json()
|
||
assert detail["turns"][0]["status"] == "error"
|
||
assert "模型不存在" in detail["turns"][0]["error"]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 给模型看的那一份
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
GRAPH = {
|
||
"nodes": [
|
||
{"id": "ask", "type": "input", "position": {"x": 1, "y": 2}, "data": {"config": {}}},
|
||
{"id": "shop_agent", "type": "agent", "position": {"x": 9, "y": 9},
|
||
"data": {"label": "查门店", "config": {"tools": ["db_query__shop"]}}},
|
||
],
|
||
"edges": [{"source": "ask", "target": "shop_agent"}],
|
||
}
|
||
|
||
|
||
async def _seeded(client) -> str:
|
||
conv = await _conversation(client)
|
||
await _turn(client, conv["id"], "shop 里有几家店",
|
||
answer="一共 12 家", graph=GRAPH, status="done")
|
||
return conv["id"]
|
||
|
||
|
||
async def test_history_text_is_empty_without_a_conversation(client) -> None:
|
||
async with SessionLocal() as session:
|
||
assert await history_text(session, None) == ""
|
||
assert await history_text(session, (await _conversation(client))["id"]) == ""
|
||
|
||
|
||
async def test_the_turn_in_flight_is_not_its_own_history(client) -> None:
|
||
"""当前这一轮还没有答案,把它算进上下文只会得到一句自己的问题。"""
|
||
conv = await _conversation(client)
|
||
await _turn(client, conv["id"], "正在问的这句") # 保持 running
|
||
async with SessionLocal() as session:
|
||
assert await history_text(session, conv["id"]) == ""
|
||
|
||
|
||
async def test_history_keeps_only_the_recent_turns(client) -> None:
|
||
conv = await _conversation(client)
|
||
for i in range(HISTORY_TURNS + 3):
|
||
await _turn(client, conv["id"], f"第{i}问", answer=f"第{i}答", status="done")
|
||
async with SessionLocal() as session:
|
||
text = await history_text(session, conv["id"])
|
||
assert "第0问" not in text # 最早的挤出去了
|
||
assert f"第{HISTORY_TURNS + 2}问" in text
|
||
assert text.count("问:") == HISTORY_TURNS
|
||
|
||
|
||
async def test_a_huge_answer_does_not_crowd_out_the_other_turns(client) -> None:
|
||
conv = await _conversation(client)
|
||
await _turn(client, conv["id"], "给我全部数据", answer="x" * 50_000, status="done")
|
||
await _turn(client, conv["id"], "那总共多少", answer="12", status="done")
|
||
async with SessionLocal() as session:
|
||
text = await history_text(session, conv["id"])
|
||
assert len(text) < 2000
|
||
assert "那总共多少" in text # 后一轮没被前一轮挤掉
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 上下文真的到了模型手上
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def test_no_conversation_means_no_history_header(client, monkeypatch) -> None:
|
||
"""不带 conversation_id 时不能凭空多出一个空的历史标题。"""
|
||
import app.api.copilot as copilot
|
||
|
||
captured: list = []
|
||
|
||
class _Recorder:
|
||
def with_structured_output(self, *_a, **_k):
|
||
return self
|
||
|
||
async def ainvoke(self, messages):
|
||
captured.append(messages)
|
||
return {"nodes": [], "edges": []}
|
||
|
||
async def _model(*_a, **_k):
|
||
return _Recorder(), "mock-fast"
|
||
|
||
monkeypatch.setattr(copilot, "get_chat_model", _model)
|
||
|
||
r = await client.post("/api/copilot/generate", json={
|
||
"instruction": "查一下门店", "intent": "answer",
|
||
})
|
||
assert r.status_code == 200, r.text
|
||
human = next(text for role, text in captured[0] if role == "human")
|
||
assert "之前聊过的" not in human
|
||
assert human.startswith("用户问了下面这个问题")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 追问不该把图搞丢
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
class _Streamer:
|
||
"""按脚本吐操作行的假模型。"""
|
||
|
||
def __init__(self, lines: list[str]) -> None:
|
||
self._lines = lines
|
||
|
||
async def astream(self, _messages):
|
||
from langchain_core.messages import AIMessageChunk
|
||
|
||
for line in self._lines:
|
||
yield AIMessageChunk(content=line + "\n")
|
||
|
||
|
||
async def _stream_graph(client, monkeypatch, conv_id: str, lines: list[str]) -> dict:
|
||
"""跑一次 generate-stream,返回最后那条 final/error。"""
|
||
import app.api.copilot as copilot
|
||
|
||
async def _model(*_a, **_k):
|
||
return _Streamer(lines), "mock-fast"
|
||
|
||
monkeypatch.setattr(copilot, "get_chat_model", _model)
|
||
|
||
async with client.stream("POST", "/api/copilot/generate-stream", json={
|
||
"instruction": "哪六个?哪五个?", "intent": "answer", "conversation_id": conv_id,
|
||
}) as r:
|
||
last = {}
|
||
async for line in r.aiter_lines():
|
||
if line.startswith("data: "):
|
||
last = json.loads(line[6:])
|
||
return last
|
||
|
||
|
||
async def test_a_follow_up_that_only_sends_edits_still_produces_a_graph(
|
||
client, monkeypatch,
|
||
) -> None:
|
||
"""真踩过的那条:f5db9809 的第二轮。
|
||
|
||
上下文注入是对的——模型的说明写着"在上一轮已接好 Shop 数据源的 agent
|
||
节点上更新提示词",它完全看懂了。但它照着改图的协议只发了 update_node,
|
||
而这条路径的累加器从空图起步,于是那些操作全落空,用户收到的是
|
||
"图校验未通过:图是空的,先拖一个节点进来"——他只是问了句追问。
|
||
"""
|
||
conv_id = await _seeded(client)
|
||
final = await _stream_graph(client, monkeypatch, conv_id, [
|
||
'{"op":"plan","summary":"在上一轮的 agent 上改提示词"}',
|
||
'{"op":"update_node","id":"shop_agent","config":{"prompt":"列出具体名单"}}',
|
||
'{"op":"done","explanation":"沿用上一轮的图,只改了提示词"}',
|
||
])
|
||
|
||
assert final.get("op") == "final", final
|
||
ids = [n["id"] for n in final["graph"]["nodes"]]
|
||
assert ids, "重放到上一轮那张图上之后不该还是空的"
|
||
assert "shop_agent" in ids
|
||
# 改动要真的落上去,不是原样把旧图还回来
|
||
agent = next(n for n in final["graph"]["nodes"] if n["id"] == "shop_agent")
|
||
assert agent["data"]["config"]["prompt"] == "列出具体名单"
|
||
|
||
|
||
async def test_a_full_rebuild_does_not_drag_the_old_nodes_along(
|
||
client, monkeypatch,
|
||
) -> None:
|
||
"""模型照约定完整重发时,兜底不能画蛇添足。
|
||
|
||
否则上一轮的 shop_agent 会和新节点一起留在图里,变成一个没人连的孤儿。
|
||
"""
|
||
conv_id = await _seeded(client)
|
||
final = await _stream_graph(client, monkeypatch, conv_id, [
|
||
'{"op":"plan","summary":"重新搭"}',
|
||
'{"op":"add_node","node":{"id":"in","type":"input","label":"入口","config":{}}}',
|
||
'{"op":"add_node","node":{"id":"fresh_agent","type":"agent","label":"查","config":{}}}',
|
||
'{"op":"add_edge","edge":{"source":"in","target":"fresh_agent"}}',
|
||
'{"op":"add_node","node":{"id":"out","type":"output","label":"成果","config":{}}}',
|
||
'{"op":"add_edge","edge":{"source":"fresh_agent","target":"out"}}',
|
||
'{"op":"done","explanation":"新图"}',
|
||
])
|
||
|
||
assert final.get("op") == "final", final
|
||
ids = {n["id"] for n in final["graph"]["nodes"]}
|
||
assert ids == {"in", "fresh_agent", "out"}
|
||
assert "shop_agent" not in ids
|
||
|
||
|
||
async def test_the_first_turn_has_nothing_to_fall_back_on(client, monkeypatch) -> None:
|
||
"""没有上一轮时,空图就是空图——不能凭空变出一张来掩盖模型什么都没给。"""
|
||
conv = await _conversation(client)
|
||
final = await _stream_graph(client, monkeypatch, conv["id"], [
|
||
'{"op":"plan","summary":"想想"}',
|
||
'{"op":"done","explanation":"什么都没搭"}',
|
||
])
|
||
assert final["op"] == "final"
|
||
assert final["graph"]["nodes"] == []
|
||
assert any("还没有节点" in str(i.get("message", "")) for i in final["issues"]), final["issues"]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 不是每句话都值得跑一遍库
|
||
# --------------------------------------------------------------------------
|
||
#
|
||
# 以前必执行:final 一到就无条件 launch。于是「重试」「继续找找」这种对过程
|
||
# 说的话,也各自重建了一整张图、跑一遍 153 张表的库;而一句明确要「设计一个
|
||
# 工作流」的,建完也直接跑了——用户要的是那张图,不是这一次的结果。
|
||
|
||
|
||
async def test_a_question_about_earlier_turns_needs_no_workflow(client, monkeypatch) -> None:
|
||
"""reply 这条路径:不建图、不跑、也不该走到那套排版校验上。
|
||
|
||
走到了的话,空图会撞出"图校验未通过:图是空的,先拖一个节点进来"。
|
||
"""
|
||
conv_id = await _seeded(client)
|
||
final = await _stream_graph(client, monkeypatch, conv_id, [
|
||
'{"op":"reply","text":"第 1 轮走的是 role.level IN (platform, regional) 这个口径。"}',
|
||
])
|
||
assert final["op"] == "reply"
|
||
assert "口径" in final["text"]
|
||
assert "graph" not in final
|
||
|
||
|
||
async def test_asking_for_a_workflow_builds_it_but_does_not_run_it(client, monkeypatch) -> None:
|
||
"""用户要的是流程本身,跑一遍只是替他多花一次钱。"""
|
||
conv_id = await _seeded(client)
|
||
final = await _stream_graph(client, monkeypatch, conv_id, [
|
||
'{"op":"plan","summary":"搭一个每天能跑的"}',
|
||
'{"op":"add_node","node":{"id":"in","type":"input","label":"入口","config":{}}}',
|
||
'{"op":"add_node","node":{"id":"out","type":"output","label":"成果","config":{}}}',
|
||
'{"op":"add_edge","edge":{"source":"in","target":"out"}}',
|
||
'{"op":"done","explanation":"每天跑一次","run":false}',
|
||
])
|
||
assert final["op"] == "final"
|
||
assert final["autorun"] is False
|
||
assert [n["id"] for n in final["graph"]["nodes"]] == ["in", "out"]
|
||
|
||
|
||
async def test_a_normal_data_question_still_runs(client, monkeypatch) -> None:
|
||
"""默认不变:问数据就是建图 + 跑。没写 run 就当 true。"""
|
||
conv_id = await _seeded(client)
|
||
final = await _stream_graph(client, monkeypatch, conv_id, [
|
||
'{"op":"plan","summary":"查一下"}',
|
||
'{"op":"add_node","node":{"id":"in","type":"input","label":"入口","config":{}}}',
|
||
'{"op":"add_node","node":{"id":"out","type":"output","label":"成果","config":{}}}',
|
||
'{"op":"add_edge","edge":{"source":"in","target":"out"}}',
|
||
'{"op":"done","explanation":"跑完给结论"}',
|
||
])
|
||
assert final["op"] == "final"
|
||
assert final["autorun"] is True
|
||
|
||
|
||
def test_the_three_paths_are_spelled_out_for_the_model() -> None:
|
||
"""路径选择全靠这段约定,尤其是那条不许凭印象给数字的硬约束。"""
|
||
from app.api.copilot import GenerateIn, _STREAM_PROTOCOL, _user_message
|
||
|
||
text = _user_message(GenerateIn(instruction="随便问问", intent="answer"), patch=False)
|
||
assert "直接回答" in text and "建图并执行" in text and "建图但不执行" in text
|
||
assert "不得新断言任何数据事实" in text
|
||
# 协议里要给得出这两个出口,否则模型没法表达
|
||
assert '"op":"reply"' in _STREAM_PROTOCOL
|
||
assert '"run":true' in _STREAM_PROTOCOL
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 主动记忆
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def test_writing_a_memory_is_visible_in_the_trace(client, monkeypatch) -> None:
|
||
"""自动记忆的前提是看得见。
|
||
|
||
以前 memory 节点发的是 info 日志,而解码层丢弃所有 info——等于系统背着
|
||
用户往长期记忆里存东西。让 Copilot 主动记之后这条不可接受。
|
||
"""
|
||
from app.core.events import EventType
|
||
|
||
assert EventType.MEMORY_END == "memory.end"
|
||
|
||
# 节点确实发这条事件,而且带上了记了什么
|
||
import inspect
|
||
|
||
from app.engine.nodes import knowledge as node
|
||
|
||
src = inspect.getsource(node.run_memory)
|
||
assert "MEMORY_END" in src
|
||
assert "LOG, level=\"info\"" not in src, "又退回 info 日志了,界面上会看不见"
|
||
assert 'result.get("content"' in src, "没把记了什么带出去,只报条数等于没说"
|