mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 20:51:59 +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 并注明理由
595 lines
28 KiB
Python
595 lines
28 KiB
Python
"""管理页背后的接口:调试台只看不改、危险操作先说清后果、没保存的配置也能先测。
|
||
|
||
每一条都对应界面上一个「看起来生效、其实没生效」或者「说的和做的不一样」的地方:
|
||
回忆测试会抬高召回计数;标着「需确认」的工具点一下就真跑;数据源的提示指向
|
||
表单上不存在的入口;填错一项要保存→关弹窗→点测试→再打开改。
|
||
"""
|
||
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']}")
|