- sdk/services/capabilities.py: MANIFEST+DEFAULTS+sanitize+QA 注册表合并 - sdk/services/humanizer.py: 9 类中文 AI 痕迹规则(块级保护引用/表格/代码/图片) - sdk/routers/capabilities.py: GET /api/capabilities + POST install 透传 - sdk/schemas.py+sessions.py: SessionMeta.capabilities + PATCH meta 白名单 merge - sdk/routers/qa.py: humanize 会话开关注入 SSE;chat.js: body 加 session_id - sse_relay.py: humanize_enabled 参数链(修 _pump_events 可见性编译错误) - api.py(QA): /capabilities/registry loopback(BFF 合并) - ui: settings store+SettingsPanel+AppShell 接线+SessionPanel 展开态设置入口+labels - 测试: humanizer 8 用例+capabilities_svc 6 用例;SDK 146 全绿;UI vitest 172 全绿 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
244 lines
8.5 KiB
Python
244 lines
8.5 KiB
Python
"""app/test_sessions_api.py —— 会话/消息路由集成单测(API.md §5 / §1 信封)。
|
||
|
||
TestClient + 临时 SQLite(dependency_overrides 指向临时 Database),
|
||
不触发 app lifespan(不跑 init_db/上游探测)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.db import Database, dumps_json, get_db
|
||
from app.main import app
|
||
from app.upstream import envelope
|
||
|
||
|
||
@pytest.fixture()
|
||
async def db(tmp_path):
|
||
database = Database(tmp_path / "api" / "plantai.db")
|
||
await database.connect()
|
||
yield database
|
||
await database.close()
|
||
|
||
|
||
@pytest.fixture()
|
||
def client(db):
|
||
app.dependency_overrides[get_db] = lambda: db
|
||
try:
|
||
yield TestClient(app)
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 错误信封构造器(§1)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@pytest.mark.parametrize(
|
||
"code,expected_status",
|
||
[
|
||
("UPSTREAM_DOWN", 503),
|
||
("UPSTREAM_ERROR", 502),
|
||
("TIMEOUT", 504),
|
||
("INVALID_CITATION", 502),
|
||
("NOT_FOUND", 404),
|
||
("VALIDATION", 422),
|
||
("INTERNAL", 500),
|
||
],
|
||
)
|
||
def test_envelope_defaults(code, expected_status):
|
||
body, status = envelope(code)
|
||
assert status == expected_status
|
||
assert body["ok"] is False
|
||
assert body["error"]["code"] == code
|
||
assert isinstance(body["error"]["message"], str) and body["error"]["message"]
|
||
assert "degraded" in body["error"]
|
||
|
||
|
||
def test_envelope_custom():
|
||
body, status = envelope("UPSTREAM_DOWN", message="自定义", degraded=True, status=503)
|
||
assert body["error"]["message"] == "自定义"
|
||
assert body["error"]["degraded"] is True
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 会话 CRUD 全链路
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_session_crud_full_flow(client):
|
||
# POST(默认标题 + 自定义标题)
|
||
r = client.post("/api/sessions", json={})
|
||
assert r.status_code == 201, r.text
|
||
sess = r.json()
|
||
assert sess["title"] == "新会话"
|
||
assert sess["status"] == "idle"
|
||
assert sess["message_count"] == 0
|
||
assert sess["meta"] == {"title_manual": False, "capabilities": {}}
|
||
|
||
r2 = client.post("/api/sessions", json={"title": " 水稻SPL "})
|
||
sess2 = r2.json()
|
||
assert sess2["title"] == "水稻SPL"
|
||
|
||
# GET 列表(updated_at 倒序,最新在前)
|
||
r = client.get("/api/sessions")
|
||
assert r.status_code == 200
|
||
items = r.json()
|
||
assert [s["id"] for s in items][:2] == [sess2["id"], sess["id"]]
|
||
|
||
# POST user 消息
|
||
r = client.post(f"/api/sessions/{sess['id']}/messages", json={"role": "user", "text": "问题原文"})
|
||
assert r.status_code == 201, r.text
|
||
msg = r.json()
|
||
assert msg["role"] == "user" and msg["status"] == "done"
|
||
assert msg["text"] == "问题原文"
|
||
assert msg["blocks"] == [] and msg["raw"] is None
|
||
|
||
# POST assistant 流式骨架
|
||
r = client.post(
|
||
f"/api/sessions/{sess['id']}/messages",
|
||
json={
|
||
"role": "assistant", "kind": "qa", "status": "streaming",
|
||
"blocks": [{"type": "pipeline", "status": "running", "steps": []}],
|
||
},
|
||
)
|
||
assert r.status_code == 201, r.text
|
||
skeleton = r.json()
|
||
assert skeleton["status"] == "streaming"
|
||
assert skeleton["blocks"][0]["type"] == "pipeline"
|
||
assert "status" in skeleton["blocks"][0]
|
||
|
||
# 消息活动后会话状态 running、message_count 更新
|
||
r = client.get(f"/api/sessions/{sess['id']}")
|
||
detail = r.json()
|
||
assert detail["status"] == "running"
|
||
assert detail["message_count"] == 2
|
||
assert [m["role"] for m in detail["messages"]] == ["user", "assistant"]
|
||
|
||
# PATCH 消息(流式检查点:status/blocks/raw)
|
||
r = client.patch(
|
||
f"/api/sessions/{sess['id']}/messages/{skeleton['id']}",
|
||
json={
|
||
"status": "done",
|
||
"blocks": [{"type": "text", "html": "<p>最终答案</p>"}],
|
||
"raw": {"answer": "最终答案"},
|
||
},
|
||
)
|
||
assert r.status_code == 200, r.text
|
||
updated = r.json()
|
||
assert updated["status"] == "done"
|
||
assert updated["blocks"] == [{"type": "text", "html": "<p>最终答案</p>"}]
|
||
assert updated["raw"] == {"answer": "最终答案"}
|
||
|
||
# PATCH 会话标题 → title_manual=true
|
||
r = client.patch(f"/api/sessions/{sess['id']}", json={"title": "手工标题"})
|
||
assert r.status_code == 200
|
||
assert r.json()["meta"] == {"title_manual": True, "capabilities": {}}
|
||
|
||
# PATCH 会话状态
|
||
r = client.patch(f"/api/sessions/{sess['id']}", json={"status": "done"})
|
||
assert r.status_code == 200
|
||
assert r.json()["status"] == "done"
|
||
|
||
# DELETE 204 + 级联
|
||
r = client.delete(f"/api/sessions/{sess['id']}")
|
||
assert r.status_code == 204
|
||
r = client.get(f"/api/sessions/{sess['id']}")
|
||
assert r.status_code == 404
|
||
body = r.json()
|
||
assert body["ok"] is False
|
||
assert body["error"]["code"] == "NOT_FOUND"
|
||
|
||
|
||
def test_not_found_envelopes(client):
|
||
cases = [
|
||
("get", "/api/sessions/nope", None),
|
||
("patch", "/api/sessions/nope", {"title": "x"}),
|
||
("delete", "/api/sessions/nope", None),
|
||
("post", "/api/sessions/nope/messages", {"role": "user", "text": "hi"}),
|
||
("get", "/api/sessions/nope/sources", None),
|
||
]
|
||
for method, url, body in cases:
|
||
kwargs = {"json": body} if body is not None else {}
|
||
r = getattr(client, method)(url, **kwargs)
|
||
assert r.status_code == 404, (method, url)
|
||
payload = r.json()
|
||
assert payload["ok"] is False
|
||
assert payload["error"]["code"] == "NOT_FOUND"
|
||
|
||
r = client.patch("/api/sessions/nope/messages/nope-msg", json={"status": "done"})
|
||
assert r.status_code == 404
|
||
assert r.json()["error"]["code"] == "NOT_FOUND"
|
||
|
||
|
||
def test_message_404_when_session_exists_but_message_missing(client):
|
||
r = client.post("/api/sessions", json={"title": "t"})
|
||
sid = r.json()["id"]
|
||
r = client.patch(f"/api/sessions/{sid}/messages/ghost", json={"status": "done"})
|
||
assert r.status_code == 404
|
||
assert r.json()["error"]["code"] == "NOT_FOUND"
|
||
|
||
|
||
def test_empty_title_falls_back_to_default(client):
|
||
r = client.post("/api/sessions", json={"title": " "})
|
||
assert r.json()["title"] == "新会话"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 来源聚合(GET /api/sessions/{id}/sources)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _source_item(i: str, paper: str | None, title: str) -> dict:
|
||
return {
|
||
"id": i, "title": title, "paper_id": paper, "chunk_id": None,
|
||
"pmid": None, "pmcid": None, "doi": None, "url": None,
|
||
"snippet": "…", "score": 0.9, "locator": None, "section": None,
|
||
"support_count": None, "type": "assertion", "metadata": {},
|
||
}
|
||
|
||
|
||
async def test_sources_aggregation(client, db):
|
||
r = client.post("/api/sessions", json={"title": "聚合"})
|
||
sid = r.json()["id"]
|
||
|
||
# 两个助手消息各带一个 sources 块
|
||
blocks1 = [
|
||
{
|
||
"type": "sources",
|
||
"items": [
|
||
_source_item("a1", "PAPER_A", "Paper A"),
|
||
_source_item("b1", "PAPER_B", "Paper B"),
|
||
_source_item("x1", None, "No paper id"),
|
||
],
|
||
}
|
||
]
|
||
blocks2 = [
|
||
{
|
||
"type": "sources",
|
||
"items": [
|
||
_source_item("a2", "PAPER_A", "Paper A"),
|
||
_source_item("x2", None, "No paper id"),
|
||
],
|
||
}
|
||
]
|
||
await db.execute(
|
||
"INSERT INTO messages (id, session_id, role, kind, status, blocks, raw, text, "
|
||
"created_at, updated_at) VALUES "
|
||
"('m1', ?, 'assistant', 'qa', 'done', ?, NULL, NULL, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'), "
|
||
"('m2', ?, 'assistant', 'qa', 'done', ?, NULL, NULL, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')",
|
||
(sid, dumps_json(blocks1), sid, dumps_json(blocks2)),
|
||
)
|
||
|
||
r = client.get(f"/api/sessions/{sid}/sources")
|
||
assert r.status_code == 200, r.text
|
||
body = r.json()
|
||
assert body["session_id"] == sid
|
||
# PAPER_A 出现 2 次(2 个消息各 1 条),PAPER_B 1 次;无 paper_id 的按 id 各 1 次
|
||
assert body["total"] == 5
|
||
by_key = {(s["paper_id"] or s["id"]): s for s in body["sources"]}
|
||
assert by_key["PAPER_A"]["count"] == 2
|
||
assert by_key["PAPER_A"]["title"] == "Paper A"
|
||
assert by_key["PAPER_B"]["count"] == 1
|
||
assert by_key["x1"]["count"] == 1
|
||
assert by_key["x2"]["count"] == 1
|
||
assert len(body["sources"]) == 4
|