fix: 修复会话上下文记忆链路

This commit is contained in:
2026-07-13 15:43:29 +08:00
parent dda2e040ca
commit bbd19cf9ed
12 changed files with 854 additions and 175 deletions

View 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": "当前问题"}

View File

@@ -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"]