Files
AgentLab/backend/tests/test_manage_api.py
T
ThearchyHelios 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 并注明理由
2026-09-30 08:05:15 +08:00

595 lines
28 KiB
Python
Raw 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.
"""管理页背后的接口:调试台只看不改、危险操作先说清后果、没保存的配置也能先测。
每一条都对应界面上一个「看起来生效、其实没生效」或者「说的和做的不一样」的地方:
回忆测试会抬高召回计数;标着「需确认」的工具点一下就真跑;数据源的提示指向
表单上不存在的入口;填错一项要保存→关弹窗→点测试→再打开改。
"""
from __future__ import annotations
import sqlite3
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import func, select
from app.db.base import SessionLocal
from app.db.models import DataSource, Provider, Run, RunEvent, Workflow
from app.main import app
from app.memory import embeddings as emb
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest.fixture(autouse=True)
def _local_embedder():
emb.configure("local")
yield
emb.configure("local")
@pytest.fixture
def shop_db(tmp_path):
path = tmp_path / "shop.db"
db = sqlite3.connect(path)
db.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, amount REAL)")
db.execute("INSERT INTO orders VALUES (1, 9.5)")
db.commit()
db.close()
return str(path)
async def _count(model) -> int:
async with SessionLocal() as session:
return int((await session.execute(select(func.count()).select_from(model))).scalar_one())
def _no_class_names(text: str) -> None:
for name in ("Error:", "Exception", "Traceback", "<locals>"):
assert name not in text, f"面向用户的报错里露出了内部符号 {name!r}:{text}"
# --------------------------------------------------------------------------
# manage-4:调试台和运行时对得上,回忆测试只看不改
# --------------------------------------------------------------------------
async def _use_count(client, scope: str) -> int:
items = (await client.get("/api/memory", params={"scope": scope})).json()
return items[0]["use_count"]
async def test_peek_recall_leaves_the_use_count_alone(client):
r = await client.post("/api/memory", json={"content": "活跃用户指当月有订单的用户", "scope": "peek"})
assert r.status_code == 201
r = await client.get("/api/memory/search", params={"q": "活跃用户", "scope": "peek", "peek": "true"})
assert r.json()["results"], "peek 也得照常召回,否则调试台就没用了"
assert await _use_count(client, "peek") == 0
# 不带 peek 的是运行时的语义:计一次,下次排序时用得上
await client.get("/api/memory/search", params={"q": "活跃用户", "scope": "peek"})
assert await _use_count(client, "peek") == 1
async def test_kb_status_and_search_agree_on_the_default_alpha(client):
status = (await client.get("/api/kb/embedding")).json()
assert status["default_alpha"] == emb.default_alpha()
body = (await client.get("/api/kb/search", params={"q": "口径"})).json()
assert body["alpha"] == status["default_alpha"], "不传 alpha 时实际用了多少,要跟结果一起交出去"
body = (await client.get("/api/kb/search", params={"q": "口径", "alpha": 0.3})).json()
assert body["alpha"] == 0.3
# --------------------------------------------------------------------------
# manage-5:危险工具在工具库里直接执行,先说清会做什么
# --------------------------------------------------------------------------
async def test_a_dangerous_tool_is_not_run_without_confirm(client):
from app.core.config import settings
target = settings.session_dir("playground") / "confirm-probe.txt"
target.unlink(missing_ok=True)
args = {"path": "confirm-probe.txt", "content": "hi"}
r = await client.post("/api/tools/file_write/run", json={"args": args})
assert r.status_code == 409
detail = r.json()["detail"]
assert "confirm-probe.txt" in detail and "审批" in detail, detail
# 怎么确认(弹窗、再点一次)是界面的事,后端的话不能替它许诺
assert "再点" not in detail and "点击" not in detail, detail
assert not target.exists(), "409 之前就已经写下去了"
r = await client.post("/api/tools/file_write/run", params={"confirm": "true"}, json={"args": args})
assert r.status_code == 200 and r.json()["ok"] is True
assert target.exists()
target.unlink()
r = await client.post("/api/tools/file_write/run", json={"args": args, "confirm": True})
assert r.status_code == 200 and target.exists()
target.unlink()
async def test_a_safe_tool_runs_straight_away(client):
r = await client.post("/api/tools/file_list/run", json={"args": {}})
assert r.status_code == 200 and r.json()["ok"] is True
async def test_the_tool_list_says_whether_runtime_approval_really_applies(client):
r = await client.post("/api/custom-tools", json={"name": "probe_hook", "kind": "http",
"config": {"url": "https://example.com"}})
assert r.status_code == 201
try:
tools = {t["id"]: t for t in (await client.get("/api/tools")).json()}
assert tools["file_write"]["runtime_approval"] is True
assert tools["file_read"]["runtime_approval"] is False
# 自定义工具默认「等审批」:运行时的关卡真的会停下来(tools/trust.py)
assert tools["probe_hook"]["dangerous"] is True
assert tools["probe_hook"]["runtime_approval"] is True
assert (tools["probe_hook"]["trust"], tools["probe_hook"]["trust_key"]) == ("ask", "probe_hook")
finally:
await client.delete(f"/api/custom-tools/{r.json()['id']}")
# --------------------------------------------------------------------------
# manage-7:提示只提表单上有的东西
# --------------------------------------------------------------------------
async def test_datasource_hints_only_point_at_fields_the_form_has(client):
kinds = {k["value"]: k for k in (await client.get("/api/datasources/kinds")).json()["kinds"]}
for kind in kinds.values():
assert "options" not in kind["hint"], f"{kind['value']} 的提示指向了表单上没有的 options:{kind['hint']}"
assert "schema" in kinds["oracle"]["hint"] and "SID" in kinds["oracle"]["hint"]
# 高级连接参数里有什么,由后端给出:提示里提到的键在这里都找得到
assert "charset" in [a["key"] for a in kinds["mysql"]["advanced"]]
assert {"service_name", "sid"} <= {a["key"] for a in kinds["oracle"]["advanced"]}
async def _oracle(name: str, **fields) -> dict:
"""直接写库,好造出新表单之前的存法:service_name 在「数据库」一栏里。"""
async with SessionLocal() as session:
row = DataSource(name=name, kind="oracle", host="db", username="u", **fields)
session.add(row)
await session.commit()
return {"id": row.id}
async def _oracle_url(source_id: str) -> str:
from app.data.engine import build_url
async with SessionLocal() as session:
return build_url(await session.get(DataSource, source_id))
async def test_switching_an_old_oracle_source_to_sid_is_not_ignored(client):
"""引擎是 `service_name or database`:老数据的「数据库」一栏有值时,新表单切到
SID 存下去会被悄悄无视,连的还是原来那个 service_name。"""
src = await _oracle("ora_sid", database="ORCLPDB1", options={})
try:
r = await client.patch(f"/api/datasources/{src['id']}", json={"options": {"sid": "XE"}})
assert r.status_code == 200, r.text
assert r.json()["database"] is None and r.json()["options"] == {"sid": "XE"}
assert (await _oracle_url(src["id"])).endswith("/?sid=XE")
r = await client.patch(f"/api/datasources/{src['id']}", json={"options": {"service_name": "BI"}})
assert (await _oracle_url(src["id"])).endswith("/?service_name=BI")
finally:
await client.delete(f"/api/datasources/{src['id']}")
async def test_oracle_service_name_is_kept_in_one_place(client):
# 改无关的字段:老数据挪进 options.service_name,连的还是同一个库
src = await _oracle("ora_legacy", database="ORCLPDB1", options={"schema": "ODS"})
try:
r = await client.patch(f"/api/datasources/{src['id']}", json={"enabled": False})
body = r.json()
assert body["database"] is None
assert body["options"] == {"schema": "ODS", "service_name": "ORCLPDB1"}
assert (await _oracle_url(src["id"])).endswith("/?service_name=ORCLPDB1")
finally:
await client.delete(f"/api/datasources/{src['id']}")
# 旧表单把 service_name 框绑在「数据库」上,保存时整张表单连 options 一起发:
# 新填的值得生效,不能被 options 里原样带回来的旧值压住
src = await _oracle("ora_oldform", database=None, options={"service_name": "BI", "schema": "ODS"})
try:
r = await client.patch(f"/api/datasources/{src['id']}", json={
"database": "BI2", "options": {"service_name": "BI", "schema": "ODS"}})
assert r.json()["options"] == {"service_name": "BI2", "schema": "ODS"}
assert (await _oracle_url(src["id"])).endswith("/?service_name=BI2")
finally:
await client.delete(f"/api/datasources/{src['id']}")
# 两处都有值的老数据原样再存一次:引擎原来连哪个,存完还连哪个
src = await _oracle("ora_both", database="STALE", options={"service_name": "BI"})
try:
r = await client.patch(f"/api/datasources/{src['id']}", json={
"database": "STALE", "options": {"service_name": "BI"}})
assert r.json()["database"] is None
assert (await _oracle_url(src["id"])).endswith("/?service_name=BI")
finally:
await client.delete(f"/api/datasources/{src['id']}")
r = await client.post("/api/datasources", json={"name": "ora_new", "kind": "oracle", "host": "db",
"username": "u", "database": "LEFTOVER",
"options": {"sid": "XE"}})
assert r.status_code == 201, r.text
created = r.json()
try:
assert created["database"] is None and created["options"] == {"sid": "XE"}
finally:
await client.delete(f"/api/datasources/{created['id']}")
# --------------------------------------------------------------------------
# manage-8:没保存的配置先测,不落库
# --------------------------------------------------------------------------
async def test_testing_an_unsaved_datasource_does_not_save_it(client, shop_db):
before = await _count(DataSource)
r = await client.post("/api/datasources/test", json={"name": "draft", "kind": "sqlite",
"database": shop_db})
assert r.status_code == 200 and r.json()["ok"] is True, r.text
r = await client.post("/api/datasources/test", json={"kind": "sqlite",
"database": "/no/such/dir/x.db"})
body = r.json()
assert body["ok"] is False
_no_class_names(body["error"])
assert body["hint"] and body["detail"], "人话之外,原始报错也得留着给排查用"
assert await _count(DataSource) == before, "测试接口往库里写了东西"
async def test_testing_a_mistyped_sqlite_path_does_not_create_the_file(client, tmp_path):
"""可写模式下 SQLite 连一个不存在的路径会当场建一个空库:测试说「连得上」,
指的却是一个空文件,而且「只测不存」的接口往磁盘上写了东西。"""
missing = tmp_path / "typo.db"
r = await client.post("/api/datasources/test", json={"kind": "sqlite", "database": str(missing),
"readonly": False})
body = r.json()
assert body["ok"] is False, body
assert "无法打开该数据库文件" in body["error"] and body["hint"] and body["detail"]
assert not missing.exists(), "测连接把一个空库建出来了"
r = await client.post("/api/datasources/test", json={"kind": "sqlite", "database": "", "readonly": False})
assert r.json()["ok"] is False, "没填路径时连的是内存库,说「连得上」是假的"
# 已保存的数据源走的是缓存连接,同样不能顺手建文件
r = await client.post("/api/datasources", json={"name": "typo_src", "kind": "sqlite",
"database": str(missing), "readonly": False})
saved = r.json()
try:
body = (await client.post(f"/api/datasources/{saved['id']}/test")).json()
assert body["ok"] is False and not missing.exists()
finally:
await client.delete(f"/api/datasources/{saved['id']}")
async def test_database_connect_failures_get_database_hints(client):
"""连不上、等超时、密码错这几类,按错误类别换成数据库的说法:说主机和端口、用户名和密码,
不说 API Key、Base URL。以前按报错文字全等比较,通用那句多了半句就永远比不上,数据库的提示从不出现;
前缀「连不上:」还和原因「连不上对方的服务」叠成同一句话说两遍。"""
import asyncio
import socket
from app.api.datasources import _explain_connect
# 真连一个没人监听的端口:驱动抛的是自己的异常类,走的是类别判断,不是字面匹配
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
r = await client.post("/api/datasources/test", json={"kind": "postgres", "host": "127.0.0.1", "port": port,
"database": "x", "username": "u", "password": "p"})
body = r.json()
assert body["ok"] is False, body
if "驱动" not in body["error"]: # 没装 asyncpg 的环境只能测到「没装驱动」
assert body["error"] == "连接失败:网络不通,或数据库服务未启动", body
assert "「主机」" in body["hint"] and "「端口」" in body["hint"], body
assert "API" not in body["hint"] and "Base URL" not in body["hint"], body
reason, hint = _explain_connect(ConnectionRefusedError(61, "Connection refused"), "mysql")
assert (reason, "「主机」" in hint) == ("网络不通,或数据库服务未启动", True)
reason, hint = _explain_connect(asyncio.TimeoutError(), "postgres")
assert reason == "数据库长时间没有响应" and "「端口」" in hint
class InvalidAuthorizationSpecificationError(Exception):
"""asyncpg 的鉴权异常:原文不一定带 password 字样,按类名认"""
reason, hint = _explain_connect(InvalidAuthorizationSpecificationError("role \"u\" is not permitted"), "postgres")
assert reason == "用户名或密码错误" and "「用户名」" in hint and "API Key" not in hint
# 测连接的错误不能把同一句话说两遍
for e in (ConnectionRefusedError(61, "Connection refused"), asyncio.TimeoutError()):
reason, _ = _explain_connect(e, "postgres")
assert "连不上" not in f"连接失败:{reason}" and "连接" not in reason
async def test_draft_test_of_an_existing_source_keeps_the_saved_password(client, shop_db):
from app.api.datasources import DataSourceTestIn, _draft_source
from app.core.crypto import decrypt, encrypt
row = DataSource(name="saved", kind="postgres", host="db", password=encrypt("s3cret"))
draft = _draft_source(DataSourceTestIn(kind="postgres", host="db2"), row)
assert decrypt(draft.password) == "s3cret", "编辑时密码框留空表示不改,测连接也得用存着的那个"
assert draft.host == "db2"
draft = _draft_source(DataSourceTestIn(kind="postgres", password="new"), row)
assert decrypt(draft.password) == "new"
async def test_testing_an_unsaved_provider_does_not_save_it(client):
before = await _count(Provider)
r = await client.post("/api/providers/test", json={"name": "草稿", "kind": "mock",
"default_model": "mock-fast"})
body = r.json()
assert body["ok"] is True, body
assert body["reply"]
# 设置页照着契约里的路径调也得通
r = await client.post("/api/settings/providers/test", json={"kind": "mock", "default_model": "mock-fast"})
assert r.json()["ok"] is True
r = await client.post("/api/providers/test", json={"kind": "openai_compatible", "api_key": "k",
"default_model": "m"})
body = r.json()
assert body["ok"] is False and "Base URL" in body["error"]
assert await _count(Provider) == before
async def test_an_openai_compatible_provider_needs_a_base_url(client):
r = await client.post("/api/providers", json={"name": "no-url", "kind": "openai_compatible",
"api_key": "k"})
assert r.status_code == 400 and "Base URL" in r.json()["detail"]
async def test_listing_models_of_an_unsaved_provider(client):
r = await client.post("/api/providers/models", json={"kind": "mock"})
body = r.json()
assert body["ok"] is True and "mock-fast" in body["models"]
# 连不上时照实说,不抛 500
r = await client.post("/api/providers/models", json={"kind": "openai_compatible",
"base_url": "http://127.0.0.1:9/v1"})
body = r.json()
assert body["ok"] is False
_no_class_names(body["error"])
@pytest.fixture
def models_endpoint():
"""一个本地的 /models:记下每次请求带了哪些头,拿来看钥匙发给了谁。"""
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
seen: list[dict[str, str]] = []
class Handler(BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802 - http.server 的约定
seen.append({k.lower(): v for k, v in self.headers.items()})
body = json.dumps({"data": [{"id": "m1"}]}).encode()
self.send_response(200)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
try:
yield f"http://127.0.0.1:{server.server_address[1]}", seen
finally:
server.shutdown()
server.server_close()
async def test_listing_models_never_sends_the_env_openai_key_to_another_host(
client, models_endpoint, monkeypatch
):
base, seen = models_endpoint
monkeypatch.setenv("OPENAI_API_KEY", "sk-env-secret")
for name in ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"):
monkeypatch.delenv(name, raising=False)
# 表单上还没填 Key 就点「拉取模型」:兼容网关是别人家的服务,环境里那把
# OpenAI 的钥匙不能顺手发过去——真正调用时(factory)也从不这么做
r = await client.post("/api/providers/models", json={"kind": "openai_compatible",
"base_url": f"{base}/v1"})
assert r.json()["models"] == ["m1"]
assert "authorization" not in seen[-1], seen[-1]
r = await client.post("/api/providers/models", json={"kind": "openai_compatible", "api_key": "sk-typed",
"base_url": f"{base}/v1"})
assert seen[-1]["authorization"] == "Bearer sk-typed"
# 官方 OpenAI 本来就会退回环境变量,和真正调用时一致
r = await client.post("/api/providers/models", json={"kind": "openai", "base_url": f"{base}/v1"})
assert seen[-1]["authorization"] == "Bearer sk-env-secret"
async def test_listing_models_authenticates_like_the_real_call(client, models_endpoint):
base, seen = models_endpoint
await client.post("/api/providers/models", json={"kind": "anthropic", "api_key": "k1", "base_url": base})
assert seen[-1]["x-api-key"] == "k1" and "authorization" not in seen[-1]
await client.post("/api/providers/models", json={"kind": "anthropic", "api_key": "k2", "base_url": base,
"extra": {"auth_style": "bearer"}})
assert seen[-1]["authorization"] == "Bearer k2" and "x-api-key" not in seen[-1]
# 编辑已有接入时,请求头回显的是 ***:拉模型得换回存着的真值
r = await client.post("/api/providers", json={
"name": "gw-headers", "kind": "openai_compatible", "base_url": f"{base}/v1", "api_key": "k3",
"extra": {"headers": {"X-Tenant": "acme-real"}},
})
assert r.status_code in (200, 201), r.text
saved = r.json()
try:
await client.post("/api/providers/models", json={
"id": saved["id"], "kind": "openai_compatible", "base_url": f"{base}/v1",
"extra": {"headers": {"X-Tenant": "***"}},
})
assert seen[-1]["x-tenant"] == "acme-real"
assert seen[-1]["authorization"] == "Bearer k3", "Key 框留空表示沿用已保存的"
finally:
await client.delete(f"/api/providers/{saved['id']}")
# --------------------------------------------------------------------------
# manage-17:记忆看得出是哪来的,能原地改
# --------------------------------------------------------------------------
async def test_memory_items_say_where_they_came_from_and_can_be_edited(client):
async with SessionLocal() as session:
run = Run(workflow_name="周报", status="succeeded", graph={"nodes": [
{"id": "remember_table", "type": "memory", "data": {"label": "记下口径", "config": {}}},
], "edges": []})
session.add(run)
await session.commit()
run_id = run.id
r = await client.post("/api/memory", json={"content": "毛利率按含税收入算", "scope": "src",
"meta": {"run_id": run_id, "node_id": "remember_table"}})
from_run = r.json()
assert from_run["source"] == {"kind": "run", "run_id": run_id, "node_id": "remember_table",
"node_label": "记下口径", "workflow_name": "周报", "run_exists": True}
assert from_run["created_at"]
r = await client.post("/api/memory", json={"content": "我叫张三", "scope": "src"})
assert r.json()["source"]["kind"] == "manual"
listed = {m["id"]: m for m in (await client.get("/api/memory", params={"scope": "src"})).json()}
assert listed[from_run["id"]]["source"]["node_label"] == "记下口径"
r = await client.patch(f"/api/memory/{from_run['id']}",
json={"content": "毛利率按不含税收入算", "importance": 0.8})
assert r.status_code == 200, r.text
edited = r.json()
assert edited["content"] == "毛利率按不含税收入算" and edited["importance"] == 0.8
assert edited["source"]["run_id"] == run_id, "改内容不该把来源抹掉"
from app.db.models import MemoryItem
from app.memory.embeddings import embed_text, from_blob
async with SessionLocal() as session:
row = await session.get(MemoryItem, from_run["id"])
stored = from_blob(row.embedding, row.embed_dim)
fresh = await embed_text("毛利率按不含税收入算")
assert stored is not None and list(stored) == list(fresh), "改完内容,向量得跟着重算"
assert (await client.patch("/api/memory/nope", json={"content": "x"})).status_code == 404
# 只有空白的内容和空串是一回事:存下去就是一条空记忆
for blank in ("", " ", "\n\t"):
r = await client.patch(f"/api/memory/{from_run['id']}", json={"content": blank})
assert r.status_code == 422, (blank, r.text)
assert (await client.post("/api/memory", json={"content": " ", "scope": "src"})).status_code == 422
async def test_memory_items_do_not_offer_a_time_that_moves_on_recall(client):
"""updated_at 每次召回都会跟着动(记计数也是一次写),当「修改于」显示就是错的。
界面要的两个时间是「记下于」和「上次召回」,各自有字段。
"""
r = await client.post("/api/memory", json={"content": "库存周转按月末库存算", "scope": "when"})
item = r.json()
assert item["created_at"] and "last_used_at" in item
assert "updated_at" not in item
await client.get("/api/memory/search", params={"q": "库存周转", "scope": "when"})
listed = (await client.get("/api/memory", params={"scope": "when"})).json()[0]
assert listed["last_used_at"] and "updated_at" not in listed
# --------------------------------------------------------------------------
# manage-6:知识库收什么格式,一处定义
# --------------------------------------------------------------------------
async def test_kb_formats_come_from_the_parser(client):
formats = (await client.get("/api/kb/formats")).json()
assert ".pptx" in formats["extensions"]
assert ".csv" not in formats["extensions"] and ".csv" in formats["tabular"]
assert formats["accept"] == ",".join(formats["extensions"])
from app.memory.parsing import extract
for ext in formats["text"]:
assert extract("你好".encode(), f"a{ext}") == "你好"
async def test_a_csv_upload_is_pointed_at_datasources(client):
r = await client.post("/api/kb/upload", files={"file": ("sales.csv", b"a,b\n1,2", "text/csv")})
assert r.status_code == 415
detail = r.json()["detail"]
assert "「数据」页" in detail and "上传表格" in detail, detail
# --------------------------------------------------------------------------
# studio-05:取一个历史版本的图
# --------------------------------------------------------------------------
def _graph(field: str) -> dict:
return {"nodes": [{"id": "a", "type": "input", "position": {"x": 0, "y": 0},
"data": {"label": "入口", "config": {"fields": [{"name": field}]}}}],
"edges": []}
async def test_a_published_version_can_be_fetched_with_its_input_fields(client):
wf = (await client.post("/api/workflows", json={"name": "版本", "graph": _graph("q")})).json()
await client.patch(f"/api/workflows/{wf['id']}", json={"graph": _graph("q2")})
r = await client.get(f"/api/workflows/{wf['id']}/versions/1")
assert r.status_code == 200, r.text
body = r.json()
assert body["version"] == 1 and body["graph"] == _graph("q")
assert [f["name"] for f in body["input_fields"]] == ["q"]
assert body["graph_hash"]
assert (await client.get(f"/api/workflows/{wf['id']}/versions/9")).status_code == 404
assert (await client.get("/api/workflows/nope/versions/1")).status_code == 404
await client.delete(f"/api/workflows/{wf['id']}")
# --------------------------------------------------------------------------
# runs-15:只从跑通的运行提取模板
# --------------------------------------------------------------------------
async def _run_with(status: str) -> str:
graph = {"nodes": [{"id": "a", "type": "input", "data": {"label": "入口", "config": {}}}],
"edges": []}
async with SessionLocal() as session:
run = Run(workflow_name="提取", status=status, graph=graph)
session.add(run)
await session.flush()
session.add(RunEvent(run_id=run.id, seq=1, type="node.started", node_id="a", ts=0.0, data={}))
await session.commit()
return run.id
async def test_only_a_succeeded_run_can_become_a_template(client):
for status in ("failed", "cancelled", "interrupted", "running"):
r = await client.post("/api/copilot/from-run", json={"run_id": await _run_with(status)})
assert r.status_code == 409, (status, r.text)
assert "未成功结束" in r.json()["detail"]
before = await _count(Workflow)
r = await client.post("/api/copilot/from-run", json={"run_id": await _run_with("succeeded")})
assert r.status_code == 200, r.text
assert await _count(Workflow) == before + 1
await client.delete(f"/api/workflows/{r.json()['workflow_id']}")