- capabilities/{manifests,registry,providers,install_manager}.py + tool_runner.py:
三层机制(注册层/调用层/安装管理层),四 Provider,热加载差分刷新
- configs/capabilities.yaml: 唯一注册入口(明文热加载面)
- api.py: QARequest+history/capabilities, /capabilities/install loopback 路由
- llm_client.py: complete() tools 透传 + complete_raw_messages() 工具调用循环支持
- pipeline.py/answer_composer.py: history 线程化(只进 Dialogue Context,检索零回归)
- sdk: services/history.py(3轮/16k预算) + routers/qa.py session_id 剥离注入
- compile_qa.sh: rsync 允许子目录 + capabilities.yaml 入 install 清单
- tooling/cap/: fake_mcp_server.py + cap_selfcheck.py 机制自检
- requirements.lock: mcp 2.1.1 + 依赖 12 包
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
106 lines
3.0 KiB
Python
106 lines
3.0 KiB
Python
"""test_history.py —— 多轮记忆组装单测(S2,SDK 层)。
|
||
|
||
覆盖:空 messages→[] / N 轮截断 / 预算截断保留最近一轮 user / streaming 排除 /
|
||
查询异常→空 history。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||
|
||
from app.services.history import assemble_history # noqa: E402
|
||
|
||
|
||
class FakeRow(dict):
|
||
"""aiosqlite.Row 的最小替身(支持键访问)。"""
|
||
|
||
def __getattr__(self, key):
|
||
return self[key]
|
||
|
||
|
||
class FakeDB:
|
||
def __init__(self, rows=None, error=None):
|
||
self.rows = rows or []
|
||
self.error = error
|
||
|
||
async def fetchall(self, sql, params):
|
||
if self.error:
|
||
raise self.error
|
||
# 模拟 SQL 的 status != 'streaming' 过滤(与真实查询一致)
|
||
return [r for r in self.rows if r.get("status") != "streaming"]
|
||
|
||
|
||
def row(role, text, status="done"):
|
||
return FakeRow(role=role, text=text, status=status)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_empty_messages():
|
||
db = FakeDB([])
|
||
assert await assemble_history(db, "s1") == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_db_error_returns_empty():
|
||
db = FakeDB(error=RuntimeError("boom"))
|
||
assert await assemble_history(db, "s1") == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_single_round():
|
||
db = FakeDB([row("user", "水稻SPL是什么?"), row("assistant", "答案是……")])
|
||
history = await assemble_history(db, "s1", rounds=3)
|
||
assert [m["role"] for m in history] == ["user", "assistant"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_rounds_truncation():
|
||
rows = []
|
||
for i in range(5):
|
||
rows.append(row("user", f"问题{i}"))
|
||
rows.append(row("assistant", f"答案{i}"))
|
||
db = FakeDB(rows)
|
||
history = await assemble_history(db, "s1", rounds=3)
|
||
# 3 轮 = 3 user + 3 assistant(最近 3 轮)
|
||
assert [m["content"] for m in history if m["role"] == "user"] == ["问题2", "问题3", "问题4"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_budget_truncation_keeps_last_user():
|
||
rows = [
|
||
row("user", "Q1"),
|
||
row("assistant", "A" * 20000), # 超大答案被截断
|
||
row("user", "Q2"),
|
||
]
|
||
db = FakeDB(rows)
|
||
history = await assemble_history(db, "s1", rounds=3, max_answer_chars=12000)
|
||
# 最近一轮 user(Q2)必须保留
|
||
assert history[-1]["role"] == "user"
|
||
assert history[-1]["content"] == "Q2"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_streaming_excluded():
|
||
rows = [
|
||
row("user", "Q1"),
|
||
row("assistant", "流式中…", status="streaming"),
|
||
row("user", "Q2"),
|
||
row("assistant", "完成答案"),
|
||
]
|
||
db = FakeDB(rows)
|
||
history = await assemble_history(db, "s1", rounds=3)
|
||
contents = [m["content"] for m in history]
|
||
assert "流式中…" not in contents
|
||
assert "完成答案" in contents
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_empty_session_id():
|
||
db = FakeDB([])
|
||
assert await assemble_history(db, "") == []
|
||
assert await assemble_history(db, None) == []
|