372 lines
12 KiB
Python
372 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
import deerflow.agents.middlewares.title_middleware as title_middleware_module
|
|
from deerflow.agents.middlewares.title_middleware import (
|
|
TitleMiddleware,
|
|
finalize_thread_title,
|
|
)
|
|
from deerflow.config.title_config import TitleConfig
|
|
|
|
|
|
class _FakeCheckpointer:
|
|
def __init__(self, checkpoint: dict, metadata: dict | None = None):
|
|
self._tuple = SimpleNamespace(
|
|
checkpoint=checkpoint,
|
|
metadata=metadata or {},
|
|
config={"configurable": {"thread_id": "thread-1", "checkpoint_ns": ""}},
|
|
)
|
|
self.aput_calls: list[tuple[dict, dict, dict, dict]] = []
|
|
self._version = 0
|
|
|
|
async def aget_tuple(self, _config):
|
|
return self._tuple
|
|
|
|
def get_next_version(self, _current, _channel):
|
|
self._version += 1
|
|
return self._version
|
|
|
|
async def aput(self, config, checkpoint, metadata, versions):
|
|
self.aput_calls.append((config, checkpoint, metadata, versions))
|
|
self._tuple = SimpleNamespace(
|
|
checkpoint=checkpoint,
|
|
metadata=metadata,
|
|
config={
|
|
"configurable": {
|
|
"thread_id": config["configurable"]["thread_id"],
|
|
"checkpoint_ns": config["configurable"]["checkpoint_ns"],
|
|
"checkpoint_id": "new-ckpt",
|
|
}
|
|
},
|
|
)
|
|
return self._tuple.config
|
|
|
|
|
|
class _FakeThreadStore:
|
|
def __init__(self):
|
|
self.updated: list[tuple[str, str, str | None]] = []
|
|
|
|
async def update_display_name(self, thread_id: str, title: str, *, user_id=None):
|
|
self.updated.append((thread_id, title, user_id))
|
|
|
|
|
|
def _source_state() -> dict:
|
|
return {
|
|
"messages": [
|
|
SimpleNamespace(type="human", content="please analyze the first topic"),
|
|
SimpleNamespace(type="ai", content="this is the first answer"),
|
|
],
|
|
"title": "please analyze the first topic",
|
|
"title_provisional": True,
|
|
}
|
|
|
|
|
|
def _dict_source_state() -> dict:
|
|
return {
|
|
"messages": [
|
|
{"type": "human", "content": "please analyze the first topic"},
|
|
{"type": "ai", "content": "this is the first answer"},
|
|
],
|
|
"title": "please analyze the first topic",
|
|
"title_provisional": True,
|
|
}
|
|
|
|
|
|
def _knowledge_base_source_state(question: str = "这个库讲了什么") -> dict:
|
|
return {
|
|
"messages": [
|
|
SimpleNamespace(
|
|
type="human",
|
|
content=question,
|
|
additional_kwargs={
|
|
"llmwiki_knowledge_base_context": [
|
|
{"id": "kb-1", "name": "对话沉淀"}
|
|
]
|
|
},
|
|
),
|
|
SimpleNamespace(type="ai", content="这个知识库汇总了对话资料。"),
|
|
],
|
|
"title": question,
|
|
"title_provisional": True,
|
|
}
|
|
|
|
|
|
class _FakeTitleModel:
|
|
def __init__(self, content: str):
|
|
self.content = content
|
|
self.extra_body: dict = {}
|
|
self.prompt = ""
|
|
|
|
async def ainvoke(self, prompt, config):
|
|
self.prompt = prompt
|
|
return SimpleNamespace(content=self.content)
|
|
|
|
|
|
def _title_config() -> TitleConfig:
|
|
return TitleConfig(
|
|
enabled=True,
|
|
max_words=6,
|
|
max_chars=60,
|
|
prompt_template="User: {user_msg}\nAssistant: {assistant_msg}\nMax: {max_words}",
|
|
)
|
|
|
|
|
|
def test_admin_question_title_switch_finalizes_question_before_first_model_call(monkeypatch) -> None:
|
|
monkeypatch.setattr(
|
|
title_middleware_module,
|
|
"get_conversation_context_settings",
|
|
lambda: SimpleNamespace(use_question_as_title=True),
|
|
)
|
|
middleware = TitleMiddleware(
|
|
title_config=_title_config().model_copy(update={"enabled": False})
|
|
)
|
|
state = {
|
|
"messages": [SimpleNamespace(type="human", content="请帮我分析这份合同")],
|
|
}
|
|
|
|
result = middleware.before_model(state, runtime=SimpleNamespace())
|
|
|
|
assert result == {
|
|
"title": "请帮我分析这份合同",
|
|
"title_provisional": False,
|
|
}
|
|
|
|
|
|
def test_admin_question_title_switch_off_keeps_async_title_generation(monkeypatch) -> None:
|
|
monkeypatch.setattr(
|
|
title_middleware_module,
|
|
"get_conversation_context_settings",
|
|
lambda: SimpleNamespace(use_question_as_title=False),
|
|
)
|
|
middleware = TitleMiddleware(title_config=_title_config())
|
|
state = {
|
|
"messages": [SimpleNamespace(type="human", content="请帮我分析这份合同")],
|
|
}
|
|
|
|
result = middleware.before_model(state, runtime=SimpleNamespace())
|
|
|
|
assert result == {
|
|
"title": "请帮我分析这份合同",
|
|
"title_provisional": True,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_finalize_thread_title_updates_checkpoint_and_display_name(monkeypatch) -> None:
|
|
async def _fake_generate(self, state):
|
|
assert state["title_provisional"] is True
|
|
return {"title": "first topic analysis", "title_provisional": False}
|
|
|
|
monkeypatch.setattr(TitleMiddleware, "_agenerate_title_result", _fake_generate)
|
|
|
|
checkpointer = _FakeCheckpointer({"channel_values": _source_state()})
|
|
thread_store = _FakeThreadStore()
|
|
|
|
updated = await finalize_thread_title(
|
|
checkpointer=checkpointer,
|
|
thread_store=thread_store,
|
|
thread_id="thread-1",
|
|
source_state=_source_state(),
|
|
)
|
|
|
|
assert updated is True
|
|
assert len(checkpointer.aput_calls) == 1
|
|
_, written_checkpoint, _, new_versions = checkpointer.aput_calls[0]
|
|
assert written_checkpoint["channel_values"]["title"] == "first topic analysis"
|
|
assert written_checkpoint["channel_values"]["title_provisional"] is False
|
|
assert set(new_versions) == {"title", "title_provisional"}
|
|
assert thread_store.updated == [("thread-1", "first topic analysis", None)]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_finalize_thread_title_skips_if_current_thread_already_finalized(monkeypatch) -> None:
|
|
async def _fake_generate(self, state):
|
|
return {"title": "first topic analysis", "title_provisional": False}
|
|
|
|
monkeypatch.setattr(TitleMiddleware, "_agenerate_title_result", _fake_generate)
|
|
|
|
checkpointer = _FakeCheckpointer(
|
|
{
|
|
"channel_values": {
|
|
**_source_state(),
|
|
"title": "manually renamed title",
|
|
"title_provisional": False,
|
|
}
|
|
}
|
|
)
|
|
thread_store = _FakeThreadStore()
|
|
|
|
updated = await finalize_thread_title(
|
|
checkpointer=checkpointer,
|
|
thread_store=thread_store,
|
|
thread_id="thread-1",
|
|
source_state=_source_state(),
|
|
)
|
|
|
|
assert updated is False
|
|
assert checkpointer.aput_calls == []
|
|
assert thread_store.updated == []
|
|
|
|
|
|
def test_sync_after_model_keeps_provisional_title_for_background_finalize() -> None:
|
|
middleware = TitleMiddleware()
|
|
|
|
result = middleware.after_model(_source_state(), runtime=SimpleNamespace())
|
|
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_after_model_keeps_provisional_title_for_background_finalize() -> None:
|
|
middleware = TitleMiddleware()
|
|
|
|
result = await middleware.aafter_model(_source_state(), runtime=SimpleNamespace())
|
|
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_finalize_thread_title_accepts_serialized_dict_messages(monkeypatch) -> None:
|
|
async def _fake_generate(self, state):
|
|
return {"title": "first topic analysis", "title_provisional": False}
|
|
|
|
monkeypatch.setattr(TitleMiddleware, "_agenerate_title_result", _fake_generate)
|
|
|
|
checkpointer = _FakeCheckpointer({"channel_values": _dict_source_state()})
|
|
|
|
updated = await finalize_thread_title(
|
|
checkpointer=checkpointer,
|
|
thread_store=None,
|
|
thread_id="thread-1",
|
|
source_state=_dict_source_state(),
|
|
)
|
|
|
|
assert updated is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deictic_knowledge_base_question_rejects_invented_model_title(
|
|
monkeypatch,
|
|
) -> None:
|
|
model = _FakeTitleModel("Rust异步编程库简介")
|
|
monkeypatch.setattr(
|
|
title_middleware_module,
|
|
"create_chat_model",
|
|
lambda **_kwargs: model,
|
|
)
|
|
middleware = TitleMiddleware(title_config=_title_config())
|
|
|
|
result = await middleware._agenerate_title_result(
|
|
_knowledge_base_source_state()
|
|
)
|
|
|
|
assert result == {
|
|
"title": "对话沉淀:知识库概览",
|
|
"title_provisional": False,
|
|
}
|
|
assert "服务端已验证的当前知识库名称" in model.prompt
|
|
assert '"对话沉淀"' in model.prompt
|
|
assert "禁止猜测或改写为其他库名" in model.prompt
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deictic_knowledge_base_question_keeps_title_with_selected_name(
|
|
monkeypatch,
|
|
) -> None:
|
|
model = _FakeTitleModel("对话沉淀内容概览")
|
|
monkeypatch.setattr(
|
|
title_middleware_module,
|
|
"create_chat_model",
|
|
lambda **_kwargs: model,
|
|
)
|
|
middleware = TitleMiddleware(title_config=_title_config())
|
|
|
|
result = await middleware._agenerate_title_result(
|
|
_knowledge_base_source_state("请介绍本知识库的主要内容")
|
|
)
|
|
|
|
assert result == {
|
|
"title": "对话沉淀内容概览",
|
|
"title_provisional": False,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ordinary_question_title_generation_is_unchanged(monkeypatch) -> None:
|
|
model = _FakeTitleModel("量子计算发展概览")
|
|
monkeypatch.setattr(
|
|
title_middleware_module,
|
|
"create_chat_model",
|
|
lambda **_kwargs: model,
|
|
)
|
|
middleware = TitleMiddleware(title_config=_title_config())
|
|
state = {
|
|
"messages": [
|
|
SimpleNamespace(type="human", content="请介绍量子计算的发展"),
|
|
SimpleNamespace(type="ai", content="量子计算经历了多个发展阶段。"),
|
|
],
|
|
"title": "请介绍量子计算的发展",
|
|
"title_provisional": True,
|
|
}
|
|
|
|
result = await middleware._agenerate_title_result(state)
|
|
|
|
assert result == {
|
|
"title": "量子计算发展概览",
|
|
"title_provisional": False,
|
|
}
|
|
assert "服务端已验证的当前知识库名称" not in model.prompt
|
|
|
|
|
|
def test_serialized_user_message_uses_knowledge_base_aware_local_title() -> None:
|
|
middleware = TitleMiddleware(title_config=_title_config())
|
|
state = {
|
|
"messages": [
|
|
{
|
|
"type": "human",
|
|
"content": "当前库主要说了什么?",
|
|
"additional_kwargs": {
|
|
"llmwiki_knowledge_base_context": [
|
|
{"id": "kb-1", "name": "政策资料"}
|
|
]
|
|
},
|
|
},
|
|
{"type": "ai", "content": "该库收录了政策资料。"},
|
|
],
|
|
"title": "当前库主要说了什么?",
|
|
"title_provisional": True,
|
|
}
|
|
|
|
assert middleware._generate_title_result(state) == {
|
|
"title": "政策资料:知识库概览",
|
|
"title_provisional": False,
|
|
}
|
|
|
|
|
|
def test_title_prompt_uses_last_non_empty_assistant_body_without_tool_calls() -> None:
|
|
middleware = TitleMiddleware(title_config=_title_config())
|
|
state = {
|
|
"messages": [
|
|
SimpleNamespace(type="human", content="请分析材料"),
|
|
SimpleNamespace(
|
|
type="ai",
|
|
content="准备读取文件",
|
|
tool_calls=[{"name": "read_file", "args": {}}],
|
|
),
|
|
SimpleNamespace(type="tool", content="工具返回内容"),
|
|
SimpleNamespace(type="ai", content="这是基于材料形成的最终结论。"),
|
|
SimpleNamespace(type="ai", content="<think>仅内部思考</think>"),
|
|
],
|
|
"title": "请分析材料",
|
|
"title_provisional": True,
|
|
}
|
|
|
|
prompt, _ = middleware._build_title_prompt(state)
|
|
|
|
assert "这是基于材料形成的最终结论。" in prompt
|
|
assert "准备读取文件" not in prompt
|
|
assert "仅内部思考" not in prompt
|