fix: 修复会话上下文记忆链路
This commit is contained in:
164
ai_knowledge_base_v2/apps/backend/tests/test_chat_context.py
Normal file
164
ai_knowledge_base_v2/apps/backend/tests/test_chat_context.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.models import Base
|
||||
from app.models.ai_config import ModelConfig, SystemConfig
|
||||
from app.models.chat import ChatMessage, ChatSession
|
||||
from app.services.chat_context_service import ChatContextService
|
||||
from app.services.model_service import (
|
||||
ModelClientService,
|
||||
_fit_source_messages,
|
||||
_max_output_tokens,
|
||||
_openai_messages,
|
||||
_rough_token_count,
|
||||
_system_and_turn_messages,
|
||||
)
|
||||
from app.services.rag_service import PromptService, RagResult
|
||||
|
||||
|
||||
def _database(context_count: str = "10") -> Session:
|
||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
db = Session(engine)
|
||||
db.add(SystemConfig(id=1, config_key="chat_context_message_count", config_value=context_count))
|
||||
db.commit()
|
||||
return db
|
||||
|
||||
|
||||
def _messages(count: int) -> list[ChatMessage]:
|
||||
result = []
|
||||
for index in range(1, count + 1):
|
||||
result.append(
|
||||
ChatMessage(
|
||||
id=index,
|
||||
session_id=1,
|
||||
user_id=1,
|
||||
role="user" if index % 2 else "assistant",
|
||||
content=f"消息{index}",
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def test_context_message_count_is_read_at_runtime_and_zero_disables_all_memory():
|
||||
with _database("2") as db:
|
||||
history = _messages(4)
|
||||
messages = PromptService.build_messages(db, "现在的问题", [], history, "旧摘要")
|
||||
assert [item["content"] for item in messages if item["role"] in {"user", "assistant"}] == [
|
||||
"消息3",
|
||||
"消息4",
|
||||
"现在的问题",
|
||||
]
|
||||
assert any("旧摘要" in item["content"] for item in messages)
|
||||
|
||||
config = db.query(SystemConfig).filter_by(config_key="chat_context_message_count").one()
|
||||
config.config_value = "0"
|
||||
db.commit()
|
||||
messages = PromptService.build_messages(db, "现在的问题", [], history, "旧摘要")
|
||||
assert [item["content"] for item in messages if item["role"] in {"user", "assistant"}] == ["现在的问题"]
|
||||
assert all("旧摘要" not in item["content"] for item in messages)
|
||||
|
||||
|
||||
def test_messages_leaving_window_are_summarized_immediately_and_summary_is_rewritten(monkeypatch):
|
||||
with _database("4") as db:
|
||||
session = ChatSession(id=1, user_id=1, title="测试", summary="已有摘要", message_count=6)
|
||||
captured = {}
|
||||
|
||||
def summarize(_db, messages_text, previous_summary=None):
|
||||
captured["messages"] = messages_text
|
||||
captured["previous"] = previous_summary
|
||||
return "覆盖后的完整摘要"
|
||||
|
||||
monkeypatch.setattr(ModelClientService, "summarize_or_raise", summarize)
|
||||
result = ChatContextService.update_summary(db, session, _messages(6))
|
||||
|
||||
assert captured == {"messages": "用户:消息1\n大本营答疑助手:消息2", "previous": "已有摘要"}
|
||||
assert session.summary == "覆盖后的完整摘要"
|
||||
assert session.summary_up_to_message_id == 2
|
||||
assert result.trace["status"] == "success"
|
||||
|
||||
|
||||
def test_messages_already_covered_by_summary_are_not_sent_twice_after_limit_increases():
|
||||
with _database("4") as db:
|
||||
messages = PromptService.build_messages(
|
||||
db,
|
||||
"现在的问题",
|
||||
[],
|
||||
_messages(4),
|
||||
"已经覆盖消息1到消息3的摘要",
|
||||
summary_up_to_message_id=3,
|
||||
)
|
||||
turns = [item["content"] for item in messages if item["role"] in {"user", "assistant"}]
|
||||
assert turns == ["消息4", "现在的问题"]
|
||||
|
||||
|
||||
def test_summary_failure_is_visible_and_does_not_break_chat(monkeypatch, caplog):
|
||||
with _database("2") as db:
|
||||
session = ChatSession(id=9, user_id=1, title="测试", summary=None, message_count=4)
|
||||
|
||||
def fail(*_args, **_kwargs):
|
||||
raise RuntimeError("摘要服务不可用")
|
||||
|
||||
monkeypatch.setattr(ModelClientService, "summarize_or_raise", fail)
|
||||
result = ChatContextService.update_summary(db, session, _messages(4))
|
||||
|
||||
assert session.summary is None
|
||||
assert result.trace["status"] == "fallback"
|
||||
assert result.trace["error"] == "摘要服务不可用"
|
||||
assert "Chat history summary failed" in caplog.text
|
||||
|
||||
|
||||
def test_provider_payload_uses_native_roles_and_normalizes_leading_assistant():
|
||||
rag = RagResult(
|
||||
question="当前问题",
|
||||
knowledge_scopes=[],
|
||||
chunks=[],
|
||||
prompt="兼容文本",
|
||||
messages=[
|
||||
{"role": "system", "content": "系统规则"},
|
||||
{"role": "assistant", "content": "窗口从助手消息开始"},
|
||||
{"role": "user", "content": "历史问题"},
|
||||
{"role": "assistant", "content": "历史回答"},
|
||||
{"role": "system", "content": "知识上下文"},
|
||||
{"role": "user", "content": "当前问题"},
|
||||
],
|
||||
)
|
||||
|
||||
openai_messages = _openai_messages(rag)
|
||||
system, turns = _system_and_turn_messages(rag)
|
||||
assert [item["role"] for item in openai_messages] == ["system", "user", "assistant", "user"]
|
||||
assert "窗口从助手消息开始" in system
|
||||
assert "知识上下文" in system
|
||||
assert turns[-1] == {"role": "user", "content": "当前问题"}
|
||||
|
||||
|
||||
def test_provider_messages_are_trimmed_to_configured_model_context_window():
|
||||
rag = RagResult(
|
||||
question="当前问题",
|
||||
knowledge_scopes=[],
|
||||
chunks=[],
|
||||
prompt="兼容文本",
|
||||
messages=[
|
||||
{"role": "system", "content": "规则" * 500},
|
||||
{"role": "user", "content": "很早的问题" * 200},
|
||||
{"role": "assistant", "content": "很早的回答" * 200},
|
||||
{"role": "system", "content": "知识" * 500},
|
||||
{"role": "user", "content": "当前问题"},
|
||||
],
|
||||
)
|
||||
model = ModelConfig(
|
||||
provider="test",
|
||||
display_name="test",
|
||||
api_type="openai_compatible",
|
||||
model_name="test",
|
||||
context_window=1000,
|
||||
max_token=600,
|
||||
)
|
||||
|
||||
fitted = _fit_source_messages(rag, model)
|
||||
input_tokens = sum(_rough_token_count(item["content"]) for item in fitted)
|
||||
assert input_tokens + _max_output_tokens(model) + 64 <= model.context_window
|
||||
assert fitted[-1] == {"role": "user", "content": "当前问题"}
|
||||
@@ -15,6 +15,7 @@ from app.models.knowledge import (
|
||||
KnowledgeSourceSnapshot,
|
||||
KnowledgeVersion,
|
||||
)
|
||||
from app.models.chat import ChatMessage
|
||||
from app.services.knowledge_agent_service import KnowledgeAgentService
|
||||
|
||||
|
||||
@@ -118,7 +119,8 @@ def test_common_question_skips_knowledge_tools():
|
||||
result = asyncio.run(KnowledgeAgentService.build_result(db, question="今天北京天气怎么样?"))
|
||||
assert result.allow_general_knowledge is True
|
||||
assert result.chunks == []
|
||||
assert result.tool_trace[1]["needKnowledge"] is False
|
||||
decision = next(item for item in result.tool_trace if item["tool"] == "agent_decision")
|
||||
assert decision["needKnowledge"] is False
|
||||
|
||||
|
||||
def test_course_question_without_catalog_does_not_fall_back_to_general_knowledge():
|
||||
@@ -136,3 +138,19 @@ def test_course_question_searches_chunk_and_reads_parent_section():
|
||||
assert len(result.chunks) == 1
|
||||
assert "先稳定自己的焦虑" in result.chunks[0].content
|
||||
assert any(item["tool"] == "read_knowledge_sections" for item in result.tool_trace)
|
||||
|
||||
|
||||
def test_contextual_follow_up_is_rewritten_before_agent_decision():
|
||||
with _database() as db:
|
||||
history = [
|
||||
ChatMessage(id=1, session_id=1, user_id=1, role="user", content="课程退款条件有哪些?"),
|
||||
ChatMessage(id=2, session_id=1, user_id=1, role="assistant", content="第一种和第二种情况不同。"),
|
||||
]
|
||||
result = asyncio.run(
|
||||
KnowledgeAgentService.build_result(db, question="那第二种情况呢?", history=history)
|
||||
)
|
||||
rewrite = next(item for item in result.tool_trace if item["tool"] == "rewrite_contextual_question")
|
||||
decision = next(item for item in result.tool_trace if item["tool"] == "agent_decision")
|
||||
|
||||
assert rewrite["rewrittenQuestion"] == "关于“课程退款条件有哪些?”的追问:那第二种情况呢?"
|
||||
assert decision["request"]["question"] == rewrite["rewrittenQuestion"]
|
||||
|
||||
Reference in New Issue
Block a user