|
|
@@ -10,9 +10,11 @@ from utils.config import settings
|
|
|
from utils.logger import logger
|
|
|
from services.qwen_service import qwen_service
|
|
|
from utils.prompt_loader import load_prompt
|
|
|
+from utils.thinking_summary import split_thinking_and_answer, summarize_thinking_content
|
|
|
import time
|
|
|
import json
|
|
|
import httpx
|
|
|
+import re
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@@ -36,6 +38,139 @@ def _build_conversation_title(conversation: AIConversation) -> str:
|
|
|
return _build_conversation_preview(conversation.content or "", limit=30)
|
|
|
|
|
|
|
|
|
+def _normalize_related_question(question: str) -> str:
|
|
|
+ if not isinstance(question, str):
|
|
|
+ return ""
|
|
|
+
|
|
|
+ text = question.strip().strip('"').strip("'")
|
|
|
+ text = re.sub(r"^[0-9]+[\.\)\]、]\s*", "", text)
|
|
|
+ text = re.sub(r"^[-*]\s*", "", text)
|
|
|
+ return text.strip()
|
|
|
+
|
|
|
+
|
|
|
+def _is_placeholder_related_question(question: str) -> bool:
|
|
|
+ normalized = _normalize_related_question(question).lower()
|
|
|
+ if not normalized:
|
|
|
+ return True
|
|
|
+
|
|
|
+ placeholder_patterns = (
|
|
|
+ r"^q\s*\d+$",
|
|
|
+ r"^question\s*\d+$",
|
|
|
+ r"^questions?\s*\d+$",
|
|
|
+ r"^问题\s*\d+$",
|
|
|
+ r"^相关问题\s*\d+$",
|
|
|
+ r"^推荐问题\s*\d+$",
|
|
|
+ r"^更多相关问题$",
|
|
|
+ r"^更多问题$",
|
|
|
+ )
|
|
|
+
|
|
|
+ return any(re.fullmatch(pattern, normalized) for pattern in placeholder_patterns)
|
|
|
+
|
|
|
+
|
|
|
+def _contains_chinese(text: str) -> bool:
|
|
|
+ return any("\u4e00" <= char <= "\u9fff" for char in text or "")
|
|
|
+
|
|
|
+
|
|
|
+def _is_invalid_related_question(question: str) -> bool:
|
|
|
+ normalized = _normalize_related_question(question)
|
|
|
+ if (
|
|
|
+ not normalized
|
|
|
+ or len(normalized) < 4
|
|
|
+ or _is_placeholder_related_question(normalized)
|
|
|
+ or not _contains_chinese(normalized)
|
|
|
+ ):
|
|
|
+ return True
|
|
|
+
|
|
|
+ lowered = normalized.lower()
|
|
|
+ blocked_keywords = (
|
|
|
+ "thinking process",
|
|
|
+ "analyze the request",
|
|
|
+ "role:",
|
|
|
+ "**role",
|
|
|
+ "professional question recommendation",
|
|
|
+ "infrastructure construction technology",
|
|
|
+ "output format",
|
|
|
+ "json",
|
|
|
+ "prompt",
|
|
|
+ "system",
|
|
|
+ "assistant",
|
|
|
+ "角色定义",
|
|
|
+ "任务目标",
|
|
|
+ "输入内容",
|
|
|
+ "生成要求",
|
|
|
+ "输出格式",
|
|
|
+ "开始生成",
|
|
|
+ )
|
|
|
+
|
|
|
+ return any(keyword in lowered for keyword in blocked_keywords)
|
|
|
+
|
|
|
+
|
|
|
+def _extract_related_question_topic(content: str) -> str:
|
|
|
+ if not content:
|
|
|
+ return "当前话题"
|
|
|
+
|
|
|
+ text = re.sub(r"<[^>]+>", " ", str(content))
|
|
|
+ text = re.sub(r"\s+", " ", text).strip()
|
|
|
+ text = re.sub(
|
|
|
+ r"^(好的[!!,, ]*|我理解您提出的问题[,, ]*|这个问题[,, ]*|总的来说[::,, ]*)+",
|
|
|
+ "",
|
|
|
+ text,
|
|
|
+ )
|
|
|
+
|
|
|
+ pattern = re.search(
|
|
|
+ r"(?:主要围绕|围绕|关于|针对|聚焦)([^。!?\n,,;;]{4,32})",
|
|
|
+ text,
|
|
|
+ )
|
|
|
+ if pattern:
|
|
|
+ topic = pattern.group(1).strip("“”\"' ::,,")
|
|
|
+ if topic:
|
|
|
+ return topic
|
|
|
+
|
|
|
+ sentence = re.split(r"[。!?\n]", text, maxsplit=1)[0].strip("“”\"' ::,,")
|
|
|
+ if sentence:
|
|
|
+ return sentence[:24]
|
|
|
+
|
|
|
+ return "当前话题"
|
|
|
+
|
|
|
+
|
|
|
+def _build_related_question_fallbacks(content: str) -> list[str]:
|
|
|
+ topic = _extract_related_question_topic(content)
|
|
|
+ return [
|
|
|
+ f"{topic}在现场实施时需要重点关注哪些风险点?",
|
|
|
+ f"{topic}相关的方案编制、审批和验收要求有哪些?",
|
|
|
+ f"针对{topic},日常检查和监测应抓住哪些关键指标?",
|
|
|
+ ]
|
|
|
+
|
|
|
+
|
|
|
+def _finalize_related_questions(questions: list, content: str, limit: int = 3) -> list[str]:
|
|
|
+ cleaned_questions = []
|
|
|
+ seen = set()
|
|
|
+
|
|
|
+ for question in questions or []:
|
|
|
+ normalized = _normalize_related_question(question)
|
|
|
+ lowered = normalized.lower()
|
|
|
+ if (
|
|
|
+ _is_invalid_related_question(normalized)
|
|
|
+ or lowered in seen
|
|
|
+ ):
|
|
|
+ continue
|
|
|
+ cleaned_questions.append(normalized)
|
|
|
+ seen.add(lowered)
|
|
|
+ if len(cleaned_questions) == limit:
|
|
|
+ return cleaned_questions
|
|
|
+
|
|
|
+ for fallback in _build_related_question_fallbacks(content):
|
|
|
+ lowered = fallback.lower()
|
|
|
+ if lowered in seen:
|
|
|
+ continue
|
|
|
+ cleaned_questions.append(fallback)
|
|
|
+ seen.add(lowered)
|
|
|
+ if len(cleaned_questions) == limit:
|
|
|
+ break
|
|
|
+
|
|
|
+ return cleaned_questions[:limit]
|
|
|
+
|
|
|
+
|
|
|
def _refresh_conversation_snapshot(db: Session, conversation_id: int, user_id: int) -> None:
|
|
|
latest_message = (
|
|
|
db.query(AIMessage)
|
|
|
@@ -235,16 +370,35 @@ async def send_deepseek_message(
|
|
|
]
|
|
|
|
|
|
qwen_response = await qwen_service.chat(messages)
|
|
|
+ raw_thinking, raw_answer = split_thinking_and_answer(qwen_response)
|
|
|
+ answer_source = raw_answer or qwen_response
|
|
|
|
|
|
+ # 兼容模型直接返回 JSON 的场景
|
|
|
+ answer_text = answer_source
|
|
|
try:
|
|
|
- if isinstance(qwen_response, str) and qwen_response.strip().startswith("{"):
|
|
|
- response_json = json.loads(qwen_response)
|
|
|
- response_text = response_json.get(
|
|
|
- "natural_language_answer", qwen_response)
|
|
|
- else:
|
|
|
- response_text = qwen_response
|
|
|
+ if isinstance(answer_source, str) and answer_source.strip().startswith("{"):
|
|
|
+ response_json = json.loads(answer_source)
|
|
|
+ answer_text = response_json.get(
|
|
|
+ "natural_language_answer", answer_source
|
|
|
+ )
|
|
|
except Exception:
|
|
|
- response_text = qwen_response
|
|
|
+ answer_text = answer_source
|
|
|
+
|
|
|
+ if raw_thinking:
|
|
|
+ thinking_summary = await summarize_thinking_content(
|
|
|
+ user_question=message,
|
|
|
+ raw_thinking=raw_thinking,
|
|
|
+ final_answer=answer_text,
|
|
|
+ chat_service=qwen_service,
|
|
|
+ context="send_message",
|
|
|
+ )
|
|
|
+ response_text = (
|
|
|
+ f"思考过程:\n{thinking_summary}\n\n回答:\n{answer_text}"
|
|
|
+ if thinking_summary
|
|
|
+ else answer_text
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ response_text = answer_text
|
|
|
except Exception as e:
|
|
|
error_detail = str(e).strip() if str(e).strip() else f"未知错误({type(e).__name__})"
|
|
|
logger.error(f"[send_deepseek_message] AI问答异常: {type(e).__name__}: {error_detail}")
|
|
|
@@ -266,7 +420,24 @@ async def send_deepseek_message(
|
|
|
{"role": "user", "content": system_content},
|
|
|
]
|
|
|
|
|
|
- response_text = await qwen_service.chat(messages)
|
|
|
+ raw_response = await qwen_service.chat(messages)
|
|
|
+ raw_thinking, raw_answer = split_thinking_and_answer(raw_response)
|
|
|
+ answer_text = raw_answer or raw_response
|
|
|
+ if raw_thinking:
|
|
|
+ thinking_summary = await summarize_thinking_content(
|
|
|
+ user_question=message,
|
|
|
+ raw_thinking=raw_thinking,
|
|
|
+ final_answer=answer_text,
|
|
|
+ chat_service=qwen_service,
|
|
|
+ context="ppt_outline",
|
|
|
+ )
|
|
|
+ response_text = (
|
|
|
+ f"思考过程:\n{thinking_summary}\n\n回答:\n{answer_text}"
|
|
|
+ if thinking_summary
|
|
|
+ else answer_text
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ response_text = answer_text
|
|
|
except Exception as e:
|
|
|
error_detail = str(e).strip() if str(e).strip() else f"未知错误({type(e).__name__})"
|
|
|
logger.error(f"[send_deepseek_message] PPT大纲生成异常: {type(e).__name__}: {error_detail}")
|
|
|
@@ -288,7 +459,24 @@ async def send_deepseek_message(
|
|
|
{"role": "user", "content": system_content},
|
|
|
]
|
|
|
|
|
|
- response_text = await qwen_service.chat(messages)
|
|
|
+ raw_response = await qwen_service.chat(messages)
|
|
|
+ raw_thinking, raw_answer = split_thinking_and_answer(raw_response)
|
|
|
+ answer_text = raw_answer or raw_response
|
|
|
+ if raw_thinking:
|
|
|
+ thinking_summary = await summarize_thinking_content(
|
|
|
+ user_question=message,
|
|
|
+ raw_thinking=raw_thinking,
|
|
|
+ final_answer=answer_text,
|
|
|
+ chat_service=qwen_service,
|
|
|
+ context="document_writing",
|
|
|
+ )
|
|
|
+ response_text = (
|
|
|
+ f"思考过程:\n{thinking_summary}\n\n回答:\n{answer_text}"
|
|
|
+ if thinking_summary
|
|
|
+ else answer_text
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ response_text = answer_text
|
|
|
except Exception as e:
|
|
|
error_detail = str(e).strip() if str(e).strip() else f"未知错误({type(e).__name__})"
|
|
|
logger.error(f"[send_deepseek_message] AI写作异常: {type(e).__name__}: {error_detail}")
|
|
|
@@ -595,8 +783,82 @@ async def stream_chat(request: Request, data: StreamChatRequest):
|
|
|
]
|
|
|
|
|
|
try:
|
|
|
+ buffer = ""
|
|
|
+ pre_answer = ""
|
|
|
+ thinking_buf = ""
|
|
|
+ in_think = False
|
|
|
+ thinking_done = False
|
|
|
+ max_input_chars = getattr(settings.thinking_summary, "max_input_chars", 1500)
|
|
|
+
|
|
|
async for chunk in qwen_service.stream_chat(messages):
|
|
|
- yield f"data: {json.dumps({'content': chunk}, ensure_ascii=False)}\n\n"
|
|
|
+ buffer += chunk
|
|
|
+
|
|
|
+ while buffer:
|
|
|
+ lower = buffer.lower()
|
|
|
+ if not thinking_done:
|
|
|
+ if not in_think:
|
|
|
+ start_idx = lower.find("<think>")
|
|
|
+ if start_idx == -1:
|
|
|
+ yield f"data: {json.dumps({'content': buffer}, ensure_ascii=False)}\n\n"
|
|
|
+ buffer = ""
|
|
|
+ break
|
|
|
+
|
|
|
+ pre_answer += buffer[:start_idx]
|
|
|
+ buffer = buffer[start_idx + len("<think>"):]
|
|
|
+ in_think = True
|
|
|
+ continue
|
|
|
+
|
|
|
+ end_idx = lower.find("</think>")
|
|
|
+ if end_idx == -1:
|
|
|
+ if max_input_chars and len(thinking_buf) < max_input_chars:
|
|
|
+ thinking_buf += buffer[: max_input_chars - len(thinking_buf)]
|
|
|
+ buffer = ""
|
|
|
+ break
|
|
|
+
|
|
|
+ if max_input_chars and len(thinking_buf) < max_input_chars:
|
|
|
+ thinking_part = buffer[:end_idx]
|
|
|
+ thinking_buf += thinking_part[: max_input_chars - len(thinking_buf)]
|
|
|
+
|
|
|
+ buffer = buffer[end_idx + len("</think>"):]
|
|
|
+ in_think = False
|
|
|
+ thinking_done = True
|
|
|
+
|
|
|
+ thinking_summary = await summarize_thinking_content(
|
|
|
+ user_question=message,
|
|
|
+ raw_thinking=thinking_buf,
|
|
|
+ final_answer="",
|
|
|
+ chat_service=qwen_service,
|
|
|
+ context="stream_chat",
|
|
|
+ )
|
|
|
+ if thinking_summary:
|
|
|
+ prefix = f"思考过程:\n{thinking_summary}\n\n回答:\n"
|
|
|
+ yield f"data: {json.dumps({'content': prefix}, ensure_ascii=False)}\n\n"
|
|
|
+
|
|
|
+ answer_chunk = (pre_answer + buffer).lstrip()
|
|
|
+ if answer_chunk:
|
|
|
+ yield f"data: {json.dumps({'content': answer_chunk}, ensure_ascii=False)}\n\n"
|
|
|
+
|
|
|
+ pre_answer = ""
|
|
|
+ buffer = ""
|
|
|
+ break
|
|
|
+
|
|
|
+ yield f"data: {json.dumps({'content': buffer}, ensure_ascii=False)}\n\n"
|
|
|
+ buffer = ""
|
|
|
+
|
|
|
+ # 流结束但未遇到 </think>:仅尝试生成要点(不回退输出 raw thinking)
|
|
|
+ if in_think and not thinking_done and thinking_buf:
|
|
|
+ thinking_summary = await summarize_thinking_content(
|
|
|
+ user_question=message,
|
|
|
+ raw_thinking=thinking_buf,
|
|
|
+ final_answer="",
|
|
|
+ chat_service=qwen_service,
|
|
|
+ context="stream_chat_eof",
|
|
|
+ )
|
|
|
+ if thinking_summary:
|
|
|
+ prefix = f"思考过程:\n{thinking_summary}\n\n回答:\n"
|
|
|
+ yield f"data: {json.dumps({'content': prefix}, ensure_ascii=False)}\n\n"
|
|
|
+ if pre_answer:
|
|
|
+ yield f"data: {json.dumps({'content': pre_answer}, ensure_ascii=False)}\n\n"
|
|
|
except Exception as e:
|
|
|
logger.error(f"[stream/chat] 流式输出异常: {e}")
|
|
|
yield f"data: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n"
|
|
|
@@ -790,14 +1052,101 @@ async def stream_chat_with_db(request: Request, data: StreamChatWithDBRequest):
|
|
|
# 8. 流式输出并收集完整回复
|
|
|
full_response = ""
|
|
|
try:
|
|
|
+ summary_enabled = getattr(settings.thinking_summary, "enabled", True)
|
|
|
+ max_input_chars = getattr(settings.thinking_summary, "max_input_chars", 1500)
|
|
|
+
|
|
|
+ buffer = ""
|
|
|
+ pre_answer = ""
|
|
|
+ thinking_buf = ""
|
|
|
+ in_think = False
|
|
|
+ thinking_done = False
|
|
|
+
|
|
|
async for chunk in qwen_service.stream_chat(messages):
|
|
|
- escaped_chunk = chunk.replace("\n", "\\n")
|
|
|
- full_response += chunk
|
|
|
- yield f"data: {escaped_chunk}\n\n"
|
|
|
+ if not summary_enabled:
|
|
|
+ escaped_chunk = chunk.replace("\n", "\\n")
|
|
|
+ full_response += chunk
|
|
|
+ yield f"data: {escaped_chunk}\n\n"
|
|
|
+ continue
|
|
|
+
|
|
|
+ buffer += chunk
|
|
|
+ while buffer:
|
|
|
+ lower = buffer.lower()
|
|
|
+ if not thinking_done:
|
|
|
+ if not in_think:
|
|
|
+ start_idx = lower.find("<think>")
|
|
|
+ if start_idx == -1:
|
|
|
+ escaped_text = buffer.replace("\n", "\\n")
|
|
|
+ full_response += buffer
|
|
|
+ yield f"data: {escaped_text}\n\n"
|
|
|
+ buffer = ""
|
|
|
+ break
|
|
|
+
|
|
|
+ pre_answer += buffer[:start_idx]
|
|
|
+ buffer = buffer[start_idx + len("<think>") :]
|
|
|
+ in_think = True
|
|
|
+ continue
|
|
|
+
|
|
|
+ end_idx = lower.find("</think>")
|
|
|
+ if end_idx == -1:
|
|
|
+ if max_input_chars and len(thinking_buf) < max_input_chars:
|
|
|
+ thinking_buf += buffer[: max_input_chars - len(thinking_buf)]
|
|
|
+ buffer = ""
|
|
|
+ break
|
|
|
+
|
|
|
+ if max_input_chars and len(thinking_buf) < max_input_chars:
|
|
|
+ thinking_part = buffer[:end_idx]
|
|
|
+ thinking_buf += thinking_part[: max_input_chars - len(thinking_buf)]
|
|
|
+
|
|
|
+ buffer = buffer[end_idx + len("</think>") :]
|
|
|
+ in_think = False
|
|
|
+ thinking_done = True
|
|
|
+
|
|
|
+ thinking_summary = await summarize_thinking_content(
|
|
|
+ user_question=message,
|
|
|
+ raw_thinking=thinking_buf,
|
|
|
+ final_answer="",
|
|
|
+ chat_service=qwen_service,
|
|
|
+ context="stream_chat_with_db",
|
|
|
+ )
|
|
|
+ if thinking_summary:
|
|
|
+ prefix = f"思考过程:\n{thinking_summary}\n\n回答:\n"
|
|
|
+ full_response += prefix
|
|
|
+ yield f"data: {prefix.replace('\n', '\\n')}\n\n"
|
|
|
+
|
|
|
+ answer_chunk = (pre_answer + buffer).lstrip()
|
|
|
+ if answer_chunk:
|
|
|
+ full_response += answer_chunk
|
|
|
+ yield f"data: {answer_chunk.replace('\n', '\\n')}\n\n"
|
|
|
+
|
|
|
+ pre_answer = ""
|
|
|
+ buffer = ""
|
|
|
+ break
|
|
|
+
|
|
|
+ escaped_text = buffer.replace("\n", "\\n")
|
|
|
+ full_response += buffer
|
|
|
+ yield f"data: {escaped_text}\n\n"
|
|
|
+ buffer = ""
|
|
|
except Exception as e:
|
|
|
logger.error(f"[stream/chat-with-db] 流式输出异常: {e}")
|
|
|
yield f"data: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n"
|
|
|
|
|
|
+ # 流结束但未遇到 </think>:仅尝试生成要点(不回退输出 raw thinking)
|
|
|
+ if summary_enabled and in_think and not thinking_done and thinking_buf:
|
|
|
+ thinking_summary = await summarize_thinking_content(
|
|
|
+ user_question=message,
|
|
|
+ raw_thinking=thinking_buf,
|
|
|
+ final_answer="",
|
|
|
+ chat_service=qwen_service,
|
|
|
+ context="stream_chat_with_db_eof",
|
|
|
+ )
|
|
|
+ if thinking_summary:
|
|
|
+ prefix = f"思考过程:\n{thinking_summary}\n\n回答:\n"
|
|
|
+ full_response += prefix
|
|
|
+ yield f"data: {prefix.replace('\n', '\\n')}\n\n"
|
|
|
+ if pre_answer:
|
|
|
+ full_response += pre_answer
|
|
|
+ yield f"data: {pre_answer.replace('\n', '\\n')}\n\n"
|
|
|
+
|
|
|
# 9. 更新 AI 消息内容
|
|
|
if full_response:
|
|
|
now_ts = int(time.time())
|
|
|
@@ -871,7 +1220,6 @@ async def guess_you_want(
|
|
|
|
|
|
try:
|
|
|
# 尝试从响应中提取 JSON
|
|
|
- import re
|
|
|
json_match = re.search(
|
|
|
r'\{[^{}]*"questions"[^{}]*\}', response, re.DOTALL)
|
|
|
if json_match:
|
|
|
@@ -889,9 +1237,7 @@ async def guess_you_want(
|
|
|
if not questions:
|
|
|
questions = ["该话题的具体应用场景?", "有哪些注意事项?", "相关案例分析?"]
|
|
|
|
|
|
- questions = questions[:3]
|
|
|
- while len(questions) < 3:
|
|
|
- questions.append("更多相关问题")
|
|
|
+ questions = _finalize_related_questions(questions, ai_msg.content, limit=3)
|
|
|
|
|
|
guess_json = json.dumps({"questions": questions}, ensure_ascii=False)
|
|
|
|