feat: 对话摘要+段落按需读取+think标签解析修复

- chat.py: ChatSession 新增 summary/summary_up_to_message_id 字段
- rag_service.py: Prompt 支持历史摘要+最近5轮原文拼接
- model_service.py: 新增 summarize() 方法,复用已启用模型生成摘要
- chat_service.py: 集成对话历史传递,8轮后触发摘要生成
- feishu_service.py: 段落级按需读取,按标题匹配+关键词打分+top5文档

摘要策略:
- 保留最近5轮原文保证追问连续性
- 更早历史由模型压缩成摘要(<=300字)
- 摘要失败不阻断主流程

飞书读取优化:
- 每个知识库最多读5个相关文档
- 每文档提取top10相关段落
- 按原文顺序拼接,不截断

Fix: _extract_openai_answer 重命名为 _extract_gemini_answer
避免同名函数覆盖导致MiniMax模型被错误解析为Gemini响应
This commit is contained in:
2026-07-08 17:49:52 +08:00
parent efd03ff9df
commit 61db0aa9b8
5 changed files with 309 additions and 32 deletions

View File

@@ -14,6 +14,8 @@ class ChatSession(Base, TimestampMixin):
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False) user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
title: Mapped[str] = mapped_column(String(100), nullable=False) title: Mapped[str] = mapped_column(String(100), nullable=False)
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
summary_up_to_message_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
message_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) message_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
last_message_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) last_message_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
is_deleted: Mapped[int] = mapped_column(default=0, nullable=False) is_deleted: Mapped[int] = mapped_column(default=0, nullable=False)

View File

@@ -12,7 +12,7 @@ from app.models.user import User
from app.services.ai_request_log_service import AiRequestLogService from app.services.ai_request_log_service import AiRequestLogService
from app.services.external_errors import ExternalServiceError from app.services.external_errors import ExternalServiceError
from app.services.model_service import ModelClientService from app.services.model_service import ModelClientService
from app.services.rag_service import RagService from app.services.rag_service import RagService, SUMMARY_TRIGGER_ROUNDS
class ChatService: class ChatService:
@@ -88,7 +88,30 @@ class ChatService:
started_at = perf_counter() started_at = perf_counter()
try: try:
rag_result = RagService.build_result(db, user, normalized_question) # 获取历史消息(不含刚插入的 user_message它还没 flush id
history = list(
db.scalars(
select(ChatMessage)
.where(
ChatMessage.session_id == session.id,
ChatMessage.user_id == user.id,
ChatMessage.id < user_message.id,
)
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
)
)
# 判断是否需要生成/更新摘要
rounds = len(history) // 2 # 1 轮 = user + assistant
if rounds >= SUMMARY_TRIGGER_ROUNDS:
ChatService._maybe_update_summary(db, session, history)
# 构建 prompt传入历史 + 摘要)
rag_result = RagService.build_result(
db, user, normalized_question,
history=history,
session_summary=session.summary,
)
completion = ModelClientService.complete(db, rag_result) completion = ModelClientService.complete(db, rag_result)
except ExternalServiceError as exc: except ExternalServiceError as exc:
cost_ms = int((perf_counter() - started_at) * 1000) cost_ms = int((perf_counter() - started_at) * 1000)
@@ -160,6 +183,56 @@ class ChatService:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在")
return session return session
@staticmethod
def _maybe_update_summary(db: Session, session: ChatSession, history: list[ChatMessage]) -> None:
"""当历史超过阈值时,生成/更新摘要。摘要失败不阻断主流程。"""
if not history:
return
# 计算需要摘要的部分:已有摘要覆盖到哪条消息之后的部分
start_idx = 0
if session.summary_up_to_message_id is not None:
for i, msg in enumerate(history):
if msg.id <= session.summary_up_to_message_id:
start_idx = i + 1
else:
break
# 需要摘要的消息 = 已有摘要之后、到最近 KEEP_RECENT*2 条之前的部分
from app.services.rag_service import KEEP_RECENT
end_idx = max(start_idx, len(history) - KEEP_RECENT * 2)
if end_idx <= start_idx:
return # 没有新的需要摘要的内容
to_summarize = history[start_idx:end_idx]
if not to_summarize:
return
# 拼接成文本
import re
role_labels = {"user": "用户", "assistant": "AI助手"}
parts = []
for msg in to_summarize:
content = re.sub(r"<think[\s\S]*?</think\s*>", "", msg.content, flags=re.IGNORECASE).strip()
if content:
parts.append(f"{role_labels.get(msg.role, msg.role)}{content}")
messages_text = "\n".join(parts)
if not messages_text:
return
# 调用模型生成摘要
new_summary = ModelClientService.summarize(db, messages_text)
if new_summary:
# 追加到已有摘要后面
combined = session.summary or ""
if combined:
combined += "\n"
combined += new_summary
session.summary = combined
session.summary_up_to_message_id = to_summarize[-1].id
@staticmethod @staticmethod
def _ensure_quota(user: User) -> None: def _ensure_quota(user: User) -> None:
if user.daily_chat_used >= user.daily_chat_limit: if user.daily_chat_used >= user.daily_chat_limit:

View File

@@ -392,7 +392,7 @@ class FeishuKnowledgeService:
for scope in scopes: for scope in scopes:
try: try:
documents = cls._load_documents(scope, config) documents = cls._load_documents(scope, question, config)
except ExternalServiceError as exc: except ExternalServiceError as exc:
load_errors.append(f"{scope.name}{exc}") load_errors.append(f"{scope.name}{exc}")
continue continue
@@ -432,9 +432,10 @@ class FeishuKnowledgeService:
def _load_documents( def _load_documents(
cls, cls,
scope: KnowledgeScope, scope: KnowledgeScope,
question: str,
config: FeishuRetrievalConfig, config: FeishuRetrievalConfig,
) -> list[tuple[str, str, str | None]]: ) -> list[tuple[str, str, str | None]]:
"""加载指定知识库 scope 下的所有文档""" """加载指定知识库 scope 下的文档,按需读取(先按标题过滤,再读内容)"""
node_id = scope.feishu_node_id node_id = scope.feishu_node_id
base_url = f"https://zcn7hk6n047c.feishu.cn" base_url = f"https://zcn7hk6n047c.feishu.cn"
@@ -461,38 +462,47 @@ class FeishuKnowledgeService:
title = node_info.get("title", node_id) title = node_info.get("title", node_id)
space_id = node_info.get("space_id", scope.feishu_space_id) space_id = node_info.get("space_id", scope.feishu_space_id)
# 如果节点本身就是一个文档 # 如果节点本身就是一个文档,直接读取后做段落筛选
if obj_type == "docx": if obj_type == "docx":
content = cls._get_document_content_with_cache(obj_token, config) content = cls._get_document_content_with_cache(obj_token, config)
source_url = f"{base_url}/wiki/{node_id}" source_url = f"{base_url}/wiki/{node_id}"
return [(title, content, source_url)] filtered = cls._extract_relevant_paragraphs(content, question)
return [(title, filtered, source_url)]
if obj_type == "file": if obj_type == "file":
content = cls._get_file_content_with_cache(obj_token, config) content = cls._get_file_content_with_cache(obj_token, config)
source_url = f"{base_url}/wiki/{node_id}" source_url = f"{base_url}/wiki/{node_id}"
return [(title, content, source_url)] filtered = cls._extract_relevant_paragraphs(content, question)
return [(title, filtered, source_url)]
# 如果节点是文件夹,获取子节点 # 如果节点是文件夹,获取子节点列表(只读标题,不读内容)
children = cls._get_wiki_children_with_cache(space_id, node_id, config) children = cls._get_wiki_children_with_cache(space_id, node_id, config)
# 先按标题和问题做关键词匹配,筛选可能相关的文档
scored_children = cls._score_children_by_title(question, children)
# 只读取 top 5 最可能相关的文档(避免全量读取)
MAX_DOCS_PER_SCOPE = 5
documents = [] documents = []
for child in children: for child, _score in scored_children[:MAX_DOCS_PER_SCOPE]:
child_node_token = child.get("node_token", "") child_node_token = child.get("node_token", "")
child_obj_type = child.get("obj_type", "") child_obj_type = child.get("obj_type", "")
child_obj_token = child.get("obj_token", "") child_obj_token = child.get("obj_token", "")
child_title = child.get("title", "") child_title = child.get("title", "")
if child_obj_type == "docx" and child_obj_token: if not child_obj_token:
try:
content = cls._get_document_content_with_cache(child_obj_token, config)
source_url = f"{base_url}/wiki/{child_node_token}"
documents.append((child_title, content, source_url))
except ExternalServiceError:
continue continue
elif child_obj_type == "file" and child_obj_token:
try: try:
if child_obj_type == "docx":
content = cls._get_document_content_with_cache(child_obj_token, config)
elif child_obj_type == "file":
content = cls._get_file_content_with_cache(child_obj_token, config) content = cls._get_file_content_with_cache(child_obj_token, config)
else:
continue
source_url = f"{base_url}/wiki/{child_node_token}" source_url = f"{base_url}/wiki/{child_node_token}"
documents.append((child_title, content, source_url)) filtered = cls._extract_relevant_paragraphs(content, question)
documents.append((child_title, filtered, source_url))
except ExternalServiceError: except ExternalServiceError:
continue continue
@@ -501,12 +511,112 @@ class FeishuKnowledgeService:
try: try:
content = cls._get_document_content_with_cache(obj_token, config) content = cls._get_document_content_with_cache(obj_token, config)
source_url = f"{base_url}/wiki/{node_id}" source_url = f"{base_url}/wiki/{node_id}"
documents.insert(0, (title, content, source_url)) filtered = cls._extract_relevant_paragraphs(content, question)
documents.insert(0, (title, filtered, source_url))
except ExternalServiceError: except ExternalServiceError:
pass pass
return documents return documents
@classmethod
def _score_children_by_title(cls, question: str, children: list[dict]) -> list[tuple[dict, float]]:
"""按文档标题与问题的相关性打分排序"""
question_lower = question.lower()
question_chars = set(question_lower)
question_bigrams = set(question_lower[i : i + 2] for i in range(len(question_lower) - 1))
scored = []
for child in children:
title = (child.get("title", "") or "").lower()
if not title:
continue
score = 0.0
# 标题包含问题中的字/词
for char in question_chars:
if char in title:
score += 2.0
for bigram in question_bigrams:
if bigram in title:
score += 5.0
# 完全包含问题关键词
if question_lower in title:
score += 10.0
scored.append((child, score))
scored.sort(key=lambda x: x[1], reverse=True)
return scored
@classmethod
def _truncate_content(cls, content: str, max_len: int = 5000) -> str:
"""截断文档内容,保留前 max_len 字符,优先保留完整段落"""
if len(content) <= max_len:
return content
# 找最后一个换行符,尽量保留完整段落
truncated = content[:max_len]
last_newline = truncated.rfind("\n")
if last_newline > max_len * 0.8:
return truncated[:last_newline]
return truncated
@classmethod
def _extract_relevant_paragraphs(cls, content: str, question: str, top_n: int = 10, min_len: int = 15) -> str:
"""把文档拆成段落,只保留和问题最相关的 top_n 段"""
if not content or not question:
return content
# 拆段落:按连续空行拆分,保留标题行
raw_paragraphs = []
for block in content.split("\n\n"):
block = block.strip()
if not block:
continue
# 一个 block 里可能有多行(比如列表),再按行拆
lines = block.split("\n")
for line in lines:
line = line.strip()
if len(line) >= min_len:
raw_paragraphs.append(line)
if not raw_paragraphs:
return content
question_lower = question.lower()
question_chars = set(question_lower)
question_bigrams = set(question_lower[i : i + 2] for i in range(len(question_lower) - 1))
scored = []
for para in raw_paragraphs:
para_lower = para.lower()
score = 0.0
# 完全包含问题关键词(最高权重)
if question_lower in para_lower:
score += 20.0
# 2-gram 匹配
for bigram in question_bigrams:
if bigram in para_lower:
score += 3.0
# 单字匹配
for char in question_chars:
if char in para_lower:
score += 1.0
scored.append((para, score))
# 按分数降序,取 top_n
scored.sort(key=lambda x: x[1], reverse=True)
selected = scored[:top_n]
# 按原文顺序拼接(而不是按分数顺序),这样阅读更自然
selected_set = set(p[0] for p in selected)
result_paras = [p for p in raw_paragraphs if p in selected_set]
return "\n".join(result_paras)
@classmethod @classmethod
def _get_node_info_with_cache(cls, node_token: str, config: FeishuRetrievalConfig) -> dict: def _get_node_info_with_cache(cls, node_token: str, config: FeishuRetrievalConfig) -> dict:
"""带缓存获取节点信息""" """带缓存获取节点信息"""

View File

@@ -27,13 +27,8 @@ class ModelCompletion:
class ModelClientService: class ModelClientService:
@staticmethod @staticmethod
def complete(db: Session, rag_result: RagResult) -> ModelCompletion: def complete(db: Session, rag_result: RagResult) -> ModelCompletion:
model = ModelClientService._get_enabled_model(db)
mock_model_enabled = _system_config_bool(db, "mock_model_enabled", get_settings().mock_model_enabled) mock_model_enabled = _system_config_bool(db, "mock_model_enabled", get_settings().mock_model_enabled)
model = db.scalar(
select(ModelConfig)
.where(ModelConfig.enabled == 1)
.order_by(ModelConfig.id.desc())
.limit(1)
)
if mock_model_enabled: if mock_model_enabled:
model_name = model.model_name if model is not None else "mock-model" model_name = model.model_name if model is not None else "mock-model"
answer = _mock_answer(rag_result) answer = _mock_answer(rag_result)
@@ -42,7 +37,6 @@ class ModelClientService:
raise ExternalServiceError("未启用可用模型,请先在模型管理中启用一个模型。", provider="model") raise ExternalServiceError("未启用可用模型,请先在模型管理中启用一个模型。", provider="model")
model_name = model.model_name model_name = model.model_name
answer = _call_configured_model(model, rag_result) answer = _call_configured_model(model, rag_result)
return ModelCompletion( return ModelCompletion(
answer=answer, answer=answer,
model_id=model.id if model is not None else None, model_id=model.id if model is not None else None,
@@ -51,6 +45,26 @@ class ModelClientService:
output_token=_rough_token_count(answer), output_token=_rough_token_count(answer),
) )
@staticmethod
def summarize(db: Session, messages_text: str) -> str | None:
"""调用模型生成对话摘要,失败时返回 None不阻断主流程"""
model = ModelClientService._get_enabled_model(db)
if model is None:
return None
try:
return _generate_summary(model, messages_text)
except Exception:
return None
@staticmethod
def _get_enabled_model(db: Session) -> ModelConfig | None:
return db.scalar(
select(ModelConfig)
.where(ModelConfig.enabled == 1)
.order_by(ModelConfig.id.desc())
.limit(1)
)
@staticmethod @staticmethod
def test_model(model: ModelConfig) -> dict[str, Any]: def test_model(model: ModelConfig) -> dict[str, Any]:
test_prompt = "请回复 OK用于测试模型配置是否可用。" test_prompt = "请回复 OK用于测试模型配置是否可用。"
@@ -150,6 +164,22 @@ def _call_configured_model(model: ModelConfig, rag_result: RagResult, *, allow_n
return _call_openai_compatible_model(model, rag_result) return _call_openai_compatible_model(model, rag_result)
def _generate_summary(model: ModelConfig, messages_text: str) -> str:
"""用模型把历史对话压缩成摘要"""
summary_prompt = (
"请将以下对话历史压缩成一段简洁的摘要保留关键信息用户问了什么、AI回答了什么要点、"
"是否有未解决的问题)。摘要不超过 300 字,用中文输出。\n\n"
f"对话历史:\n{messages_text}"
)
rag_result = RagResult(
question=summary_prompt,
knowledge_scopes=[],
chunks=[],
prompt=summary_prompt,
)
return _call_configured_model(model, rag_result, allow_no_hit=True)
def _call_openai_compatible_model(model: ModelConfig, rag_result: RagResult) -> str: def _call_openai_compatible_model(model: ModelConfig, rag_result: RagResult) -> str:
payload = { payload = {
"model": model.model_name, "model": model.model_name,

View File

@@ -6,12 +6,18 @@ from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.ai_config import Prompt from app.models.ai_config import Prompt
from app.models.chat import ChatMessage
from app.models.user import User from app.models.user import User
from app.services.feishu_service import FeishuKnowledgeService from app.services.feishu_service import FeishuKnowledgeService
from app.services.knowledge_service import KnowledgeAccessService, KnowledgeScope from app.services.knowledge_service import KnowledgeAccessService, KnowledgeScope
NO_HIT_ANSWER = "当前知识库中未检索到相关内容,请联系管理员补充相关知识。" NO_HIT_ANSWER = "当前知识库中未检索到相关内容,请联系管理员补充相关知识。"
# 摘要配置:超过 KEEP_RECENT 轮的历史会被压缩成摘要
KEEP_RECENT = 5
# 触发摘要的轮数阈值(当历史总轮数 > 这个值时才触发)
SUMMARY_TRIGGER_ROUNDS = 8
@dataclass(frozen=True) @dataclass(frozen=True)
class RetrievedChunk: class RetrievedChunk:
@@ -40,10 +46,16 @@ class RagResult:
class RagService: class RagService:
@staticmethod @staticmethod
def build_result(db: Session, user: User, question: str) -> RagResult: def build_result(
db: Session,
user: User,
question: str,
history: list[ChatMessage] | None = None,
session_summary: str | None = None,
) -> RagResult:
scopes = KnowledgeAccessService.get_allowed_knowledge(db, user) scopes = KnowledgeAccessService.get_allowed_knowledge(db, user)
chunks = FeishuKnowledgeService.retrieve(question, scopes, db) chunks = FeishuKnowledgeService.retrieve(question, scopes, db)
prompt = PromptService.build_prompt(db, question, chunks) prompt = PromptService.build_prompt(db, question, chunks, history, session_summary)
return RagResult(question=question, knowledge_scopes=scopes, chunks=chunks, prompt=prompt) return RagResult(question=question, knowledge_scopes=scopes, chunks=chunks, prompt=prompt)
@@ -54,15 +66,65 @@ class PromptService:
) )
@classmethod @classmethod
def build_prompt(cls, db: Session, question: str, chunks: list[RetrievedChunk]) -> str: def build_prompt(
cls,
db: Session,
question: str,
chunks: list[RetrievedChunk],
history: list[ChatMessage] | None = None,
session_summary: str | None = None,
) -> str:
prompt = cls._load_active_prompt(db) prompt = cls._load_active_prompt(db)
# 知识库上下文
context = "\n\n".join( context = "\n\n".join(
f"[知识片段 {index}] 来源:{chunk.knowledge_name} / {chunk.title}\n{chunk.content}" f"[知识片段 {index}] 来源:{chunk.knowledge_name} / {chunk.title}\n{chunk.content}"
for index, chunk in enumerate(chunks, start=1) for index, chunk in enumerate(chunks, start=1)
) )
if not context: if not context:
context = "未检索到相关知识片段。" context = "未检索到相关知识片段。"
return f"{prompt}\n\n{context}\n\n用户问题:{question.strip()}"
parts = [prompt]
# 对话历史:摘要 + 最近 N 轮原文
history_part = cls._build_history_section(history, session_summary)
if history_part:
parts.append(history_part)
parts.append(context)
parts.append(f"用户问题:{question.strip()}")
return "\n\n".join(parts)
@classmethod
def _build_history_section(
cls,
history: list[ChatMessage] | None,
session_summary: str | None,
) -> str:
if not history:
return ""
lines: list[str] = []
# 如果有历史摘要,先放摘要
if session_summary:
lines.append(f"[历史对话摘要]\n{session_summary}")
# 只保留最近 KEEP_RECENT 轮的原文1 轮 = 1 个 user + 1 个 assistant
recent = history[-(KEEP_RECENT * 2):]
if recent:
role_labels = {"user": "用户", "assistant": "AI助手"}
for msg in recent:
label = role_labels.get(msg.role, msg.role)
content = msg.content.strip()
# 去掉 think 标签,不需要在历史中重复传递
import re
content = re.sub(r"<think[\s\S]*?</think\s*>", "", content, flags=re.IGNORECASE).strip()
if content:
lines.append(f"{label}{content}")
return "[对话历史]\n" + "\n".join(lines) if lines else ""
@classmethod @classmethod
def _load_active_prompt(cls, db: Session) -> str: def _load_active_prompt(cls, db: Session) -> str: