|
|
@@ -9,6 +9,7 @@ from models.total import RecommendQuestion
|
|
|
from utils.config import settings
|
|
|
from utils.logger import logger
|
|
|
from services.qwen_service import qwen_service
|
|
|
+from services.deepseek_service import deepseek_service
|
|
|
from utils.prompt_loader import load_prompt
|
|
|
from utils.thinking_summary import split_thinking_and_answer, summarize_thinking_content
|
|
|
import time
|
|
|
@@ -252,6 +253,310 @@ async def _rag_search(message: str, top_k: int = 5) -> str:
|
|
|
return ""
|
|
|
|
|
|
|
|
|
+SAFETY_TRAINING_PLAN_SYSTEM_PROMPT = """
|
|
|
+你是安全培训需求整理助手。请把用户的自然语言输入整理成安全培训PPT大纲生成任务。
|
|
|
+
|
|
|
+规则:
|
|
|
+1. 只输出一个 JSON 对象,不要输出 Markdown、解释或额外文字。
|
|
|
+2. 即使用户说“通知”“材料”“文档”,也必须理解为安全培训模块中的 PPT 大纲需求,不要切换到其他文档生成任务。
|
|
|
+3. 如果字段缺失,请根据安全培训场景合理补全,但不要编造具体制度编号、人员姓名或不存在的事实。
|
|
|
+4. template 字段用于选择大纲模板,默认填“标准安全培训PPT大纲”。
|
|
|
+5. content_focus 至少给出 3 个要点。
|
|
|
+
|
|
|
+JSON 字段:
|
|
|
+{
|
|
|
+ "topic": "培训主题",
|
|
|
+ "template": "模板名称",
|
|
|
+ "content_focus": ["内容要点1", "内容要点2", "内容要点3"],
|
|
|
+ "audience": "参训对象",
|
|
|
+ "time": "培训时间",
|
|
|
+ "location": "培训地点",
|
|
|
+ "goal": "培训目标",
|
|
|
+ "notes": "其他要求",
|
|
|
+ "normalized_request": "归一化后的安全培训PPT大纲生成需求"
|
|
|
+}
|
|
|
+"""
|
|
|
+
|
|
|
+
|
|
|
+def _extract_tag_value(message: str, tag: str) -> str:
|
|
|
+ match = re.search(fr"<{tag}>(.*?)</{tag}>", message or "", re.DOTALL)
|
|
|
+ return match.group(1).strip() if match else ""
|
|
|
+
|
|
|
+
|
|
|
+def _strip_document_tags(message: str) -> str:
|
|
|
+ text = message or ""
|
|
|
+ for tag in ("word", "filename", "filesize"):
|
|
|
+ text = re.sub(fr"<{tag}>.*?</{tag}>", " ", text, flags=re.DOTALL)
|
|
|
+ return re.sub(r"\s+", " ", text).strip()
|
|
|
+
|
|
|
+
|
|
|
+def _extract_safety_training_request_payload(message: str) -> dict:
|
|
|
+ return {
|
|
|
+ "document_content": _extract_tag_value(message, "word"),
|
|
|
+ "filename": _extract_tag_value(message, "filename"),
|
|
|
+ "filesize": _extract_tag_value(message, "filesize"),
|
|
|
+ "request": _strip_document_tags(message),
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def _clean_safety_training_topic(message: str) -> str:
|
|
|
+ request_text = _extract_safety_training_request_payload(message)["request"]
|
|
|
+ first_clause = re.split(r"[,。;;,\n]", request_text, maxsplit=1)[0].strip()
|
|
|
+ topic = first_clause or request_text or "安全培训"
|
|
|
+ for token in ("请", "帮我", "帮忙", "生成", "制作", "输出", "一份", "一个", "一下", "PPT大纲", "ppt大纲", "大纲", "通知", "文档", "材料"):
|
|
|
+ topic = topic.replace(token, "")
|
|
|
+ topic = re.sub(r"\s+", "", topic).strip(" ::,,。;;")
|
|
|
+ if not topic:
|
|
|
+ topic = "安全培训"
|
|
|
+ if "培训" not in topic:
|
|
|
+ topic = f"{topic}安全培训"
|
|
|
+ return topic
|
|
|
+
|
|
|
+
|
|
|
+def _parse_json_object(text: str) -> dict:
|
|
|
+ if not text:
|
|
|
+ return {}
|
|
|
+ cleaned = re.sub(r"```(?:json)?\s*", "", str(text)).replace("```", "").strip()
|
|
|
+ match = re.search(r"\{.*\}", cleaned, re.DOTALL)
|
|
|
+ if not match:
|
|
|
+ return {}
|
|
|
+ try:
|
|
|
+ parsed = json.loads(match.group(0))
|
|
|
+ return parsed if isinstance(parsed, dict) else {}
|
|
|
+ except json.JSONDecodeError:
|
|
|
+ return {}
|
|
|
+
|
|
|
+
|
|
|
+def _build_fallback_safety_training_plan(message: str) -> dict:
|
|
|
+ topic = _clean_safety_training_topic(message)
|
|
|
+ payload = _extract_safety_training_request_payload(message)
|
|
|
+ return {
|
|
|
+ "topic": topic,
|
|
|
+ "template": "标准安全培训PPT大纲",
|
|
|
+ "content_focus": ["安全生产责任", "现场风险识别", "安全意识提升", "培训纪律与行为规范"],
|
|
|
+ "audience": "参训员工",
|
|
|
+ "time": "",
|
|
|
+ "location": "",
|
|
|
+ "goal": "提升参训人员安全意识和施工现场风险防控能力",
|
|
|
+ "notes": payload["request"],
|
|
|
+ "normalized_request": f"围绕{topic}生成安全培训PPT大纲",
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def _normalize_safety_training_plan(message: str, raw_plan: dict) -> dict:
|
|
|
+ plan = _build_fallback_safety_training_plan(message)
|
|
|
+ if not isinstance(raw_plan, dict):
|
|
|
+ return plan
|
|
|
+
|
|
|
+ for key in ("topic", "template", "audience", "time", "location", "goal", "notes", "normalized_request"):
|
|
|
+ value = raw_plan.get(key)
|
|
|
+ if isinstance(value, str) and value.strip():
|
|
|
+ plan[key] = value.strip()
|
|
|
+
|
|
|
+ focus = raw_plan.get("content_focus")
|
|
|
+ if isinstance(focus, list):
|
|
|
+ normalized_focus = [str(item).strip() for item in focus if str(item).strip()]
|
|
|
+ if normalized_focus:
|
|
|
+ plan["content_focus"] = normalized_focus
|
|
|
+ elif isinstance(focus, str) and focus.strip():
|
|
|
+ plan["content_focus"] = [item.strip() for item in re.split(r"[、,,;\n]", focus) if item.strip()]
|
|
|
+
|
|
|
+ if "培训" not in plan["topic"]:
|
|
|
+ plan["topic"] = f"{plan['topic']}安全培训"
|
|
|
+ if "PPT大纲" not in plan["template"]:
|
|
|
+ plan["template"] = f"{plan['template']}PPT大纲"
|
|
|
+ return plan
|
|
|
+
|
|
|
+
|
|
|
+def _build_safety_training_generation_message(message: str, plan: dict) -> str:
|
|
|
+ payload = _extract_safety_training_request_payload(message)
|
|
|
+ focus_text = "、".join(plan.get("content_focus") or [])
|
|
|
+ lines = [
|
|
|
+ "输出类型:安全培训PPT大纲",
|
|
|
+ "请基于以下结构化需求生成安全培训PPT大纲,不要生成通知正文,不要切换到其他文档生成任务。",
|
|
|
+ f"主题:{plan.get('topic') or '安全培训'}",
|
|
|
+ f"模板:{plan.get('template') or '标准安全培训PPT大纲'}",
|
|
|
+ f"内容要点:{focus_text or '安全生产责任、风险识别、应急处置、安全意识提升'}",
|
|
|
+ f"参训对象:{plan.get('audience') or '参训员工'}",
|
|
|
+ f"培训时间:{plan.get('time') or '未指定'}",
|
|
|
+ f"培训地点:{plan.get('location') or '未指定'}",
|
|
|
+ f"培训目标:{plan.get('goal') or '提升参训人员安全意识和风险防控能力'}",
|
|
|
+ f"其他要求:{plan.get('notes') or '无'}",
|
|
|
+ f"归一化需求:{plan.get('normalized_request') or ''}",
|
|
|
+ f"原始需求:{payload['request'] or message}",
|
|
|
+ ]
|
|
|
+ if payload["filename"] or payload["document_content"]:
|
|
|
+ lines.extend([
|
|
|
+ f"上传文档名称:{payload['filename'] or '未命名文档'}",
|
|
|
+ f"上传文档大小:{payload['filesize'] or '未知'}",
|
|
|
+ "上传文档内容:",
|
|
|
+ payload["document_content"] or "无",
|
|
|
+ ])
|
|
|
+ return "\n".join(lines)
|
|
|
+
|
|
|
+
|
|
|
+async def _infer_safety_training_plan(message: str) -> dict:
|
|
|
+ payload = _extract_safety_training_request_payload(message)
|
|
|
+ planning_input = payload["request"] or message
|
|
|
+ if payload["document_content"]:
|
|
|
+ planning_input = (
|
|
|
+ f"{planning_input}\n\n"
|
|
|
+ f"上传文档名称:{payload['filename'] or '未命名文档'}\n"
|
|
|
+ f"上传文档内容摘要:{payload['document_content'][:3000]}"
|
|
|
+ )
|
|
|
+
|
|
|
+ try:
|
|
|
+ response = await qwen_service.chat([
|
|
|
+ {"role": "system", "content": SAFETY_TRAINING_PLAN_SYSTEM_PROMPT},
|
|
|
+ {"role": "user", "content": planning_input},
|
|
|
+ ])
|
|
|
+ return _normalize_safety_training_plan(message, _parse_json_object(response))
|
|
|
+ except Exception as e:
|
|
|
+ logger.warning(f"[safety_training] 需求整理失败,使用兜底结构: {type(e).__name__}: {e}")
|
|
|
+ return _build_fallback_safety_training_plan(message)
|
|
|
+
|
|
|
+
|
|
|
+def _clean_ai_writing_response(content: str) -> str:
|
|
|
+ text = str(content or "").strip()
|
|
|
+ if not text:
|
|
|
+ return ""
|
|
|
+
|
|
|
+ text = re.sub(r"```(?:html)?\s*", "", text, flags=re.IGNORECASE).replace("```", "").strip()
|
|
|
+
|
|
|
+ body_match = re.search(r"<body[^>]*>(.*?)</body>", text, re.IGNORECASE | re.DOTALL)
|
|
|
+ if body_match:
|
|
|
+ text = body_match.group(1).strip()
|
|
|
+
|
|
|
+ first_content_tag = re.search(
|
|
|
+ r"<(?:article|section|main|div|h[1-6]|p|table|ul|ol)\b",
|
|
|
+ text,
|
|
|
+ re.IGNORECASE,
|
|
|
+ )
|
|
|
+ if first_content_tag and text[:first_content_tag.start()].strip():
|
|
|
+ text = text[first_content_tag.start():]
|
|
|
+
|
|
|
+ cleanup_patterns = (
|
|
|
+ r"<!DOCTYPE[^>]*>",
|
|
|
+ r"<html[^>]*>",
|
|
|
+ r"</html>",
|
|
|
+ r"<head[^>]*>.*?</head>",
|
|
|
+ r"<body[^>]*>",
|
|
|
+ r"</body>",
|
|
|
+ r"<style[^>]*>.*?</style>",
|
|
|
+ r"<script[^>]*>.*?</script>",
|
|
|
+ r"<meta[^>]*>",
|
|
|
+ r"<title[^>]*>.*?</title>",
|
|
|
+ )
|
|
|
+ for pattern in cleanup_patterns:
|
|
|
+ text = re.sub(pattern, "", text, flags=re.IGNORECASE | re.DOTALL)
|
|
|
+
|
|
|
+ return text.strip()
|
|
|
+
|
|
|
+
|
|
|
+async def _generate_ai_writing_response(message: str) -> str:
|
|
|
+ rag_context = await _rag_search(message, top_k=10)
|
|
|
+ system_content = load_prompt(
|
|
|
+ "document_writing",
|
|
|
+ userMessage=message,
|
|
|
+ contextJSON=rag_context if rag_context else "暂无相关知识库内容",
|
|
|
+ )
|
|
|
+
|
|
|
+ messages = [
|
|
|
+ {"role": "system", "content": system_content},
|
|
|
+ {
|
|
|
+ "role": "user",
|
|
|
+ "content": (
|
|
|
+ "请根据上面的写作规范和我的原始需求,直接生成可放入富文本编辑器的公文正文 HTML 片段。"
|
|
|
+ "不要输出道歉、解释、DOCTYPE、html、head、body、style 或 script 标签。\n\n"
|
|
|
+ f"原始需求:\n{message}"
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ ]
|
|
|
+
|
|
|
+ raw_response = await deepseek_service.chat(messages)
|
|
|
+ raw_thinking, raw_answer = split_thinking_and_answer(raw_response)
|
|
|
+ answer_text = _clean_ai_writing_response(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=deepseek_service,
|
|
|
+ context="document_writing",
|
|
|
+ )
|
|
|
+ return (
|
|
|
+ f"思考过程:\n{thinking_summary}\n\n回答:\n{answer_text}"
|
|
|
+ if thinking_summary
|
|
|
+ else answer_text
|
|
|
+ )
|
|
|
+ return answer_text
|
|
|
+
|
|
|
+
|
|
|
+async def _generate_ppt_outline_response(message: str) -> str:
|
|
|
+ training_plan = await _infer_safety_training_plan(message)
|
|
|
+ generation_message = _build_safety_training_generation_message(message, training_plan)
|
|
|
+ rag_context = await _rag_search(generation_message, top_k=10)
|
|
|
+ system_content = load_prompt(
|
|
|
+ "ppt_outline",
|
|
|
+ userMessage=generation_message,
|
|
|
+ contextJSON=rag_context if rag_context else "暂无相关知识库内容",
|
|
|
+ )
|
|
|
+
|
|
|
+ messages = [
|
|
|
+ {"role": "system", "content": system_content},
|
|
|
+ {"role": "user", "content": "请直接输出安全培训PPT大纲正文,从标题开始,不要解释提示词或安全规则。"},
|
|
|
+ ]
|
|
|
+
|
|
|
+ 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",
|
|
|
+ )
|
|
|
+ return (
|
|
|
+ f"思考过程:\n{thinking_summary}\n\n回答:\n{answer_text}"
|
|
|
+ if thinking_summary
|
|
|
+ else answer_text
|
|
|
+ )
|
|
|
+ return answer_text
|
|
|
+
|
|
|
+
|
|
|
+def _persist_message_pair(db: Session, conv_id: int, user, user_content: str, ai_content: str):
|
|
|
+ now_ts = int(time.time())
|
|
|
+ user_message = AIMessage(
|
|
|
+ ai_conversation_id=conv_id,
|
|
|
+ user_id=user.user_id,
|
|
|
+ type="user",
|
|
|
+ content=user_content,
|
|
|
+ created_at=now_ts,
|
|
|
+ updated_at=now_ts,
|
|
|
+ is_deleted=0,
|
|
|
+ )
|
|
|
+ db.add(user_message)
|
|
|
+ db.commit()
|
|
|
+ db.refresh(user_message)
|
|
|
+
|
|
|
+ ai_message = AIMessage(
|
|
|
+ ai_conversation_id=conv_id,
|
|
|
+ user_id=user.user_id,
|
|
|
+ type="ai",
|
|
|
+ content=ai_content,
|
|
|
+ prev_user_id=user_message.id,
|
|
|
+ created_at=now_ts,
|
|
|
+ updated_at=now_ts,
|
|
|
+ is_deleted=0,
|
|
|
+ )
|
|
|
+ db.add(ai_message)
|
|
|
+ db.commit()
|
|
|
+ db.refresh(ai_message)
|
|
|
+ return user_message, ai_message
|
|
|
+
|
|
|
+
|
|
|
def _build_history_messages(conv_id: int, limit: int = 10) -> list:
|
|
|
"""从数据库读取最近对话历史,构建 messages 列表"""
|
|
|
db = SessionLocal()
|
|
|
@@ -342,6 +647,7 @@ async def send_deepseek_message(
|
|
|
db.commit()
|
|
|
|
|
|
response_text = ""
|
|
|
+ ai_message_id = 0
|
|
|
|
|
|
if data.business_type == 0:
|
|
|
# AI问答:意图识别 + RAG
|
|
|
@@ -407,37 +713,32 @@ async def send_deepseek_message(
|
|
|
elif data.business_type == 1:
|
|
|
# PPT大纲生成
|
|
|
try:
|
|
|
- rag_context = await _rag_search(message, top_k=10)
|
|
|
-
|
|
|
- # 使用prompt加载器加载PPT大纲生成prompt
|
|
|
- system_content = load_prompt(
|
|
|
- "ppt_outline",
|
|
|
- userMessage=message,
|
|
|
- contextJSON=rag_context if rag_context else "暂无相关知识库内容"
|
|
|
+ response_text = await _generate_ppt_outline_response(message)
|
|
|
+ _, ai_message = _persist_message_pair(
|
|
|
+ db=db,
|
|
|
+ conv_id=conv_id,
|
|
|
+ user=user,
|
|
|
+ user_content=message,
|
|
|
+ ai_content=response_text,
|
|
|
)
|
|
|
-
|
|
|
- messages = [
|
|
|
- {"role": "user", "content": system_content},
|
|
|
- ]
|
|
|
-
|
|
|
- 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
|
|
|
+ ai_message_id = ai_message.id
|
|
|
+ _refresh_conversation_snapshot(db, conv_id, user.user_id)
|
|
|
+ db.commit()
|
|
|
+ return {
|
|
|
+ "statusCode": 200,
|
|
|
+ "msg": "success",
|
|
|
+ "data": {
|
|
|
+ "conversation_id": conv_id,
|
|
|
+ "ai_conversation_id": conv_id,
|
|
|
+ "response": response_text,
|
|
|
+ "reply": response_text,
|
|
|
+ "content": response_text,
|
|
|
+ "message": response_text,
|
|
|
+ "ai_message_id": ai_message_id,
|
|
|
+ "user_id": user.user_id,
|
|
|
+ "business_type": data.business_type,
|
|
|
+ },
|
|
|
+ }
|
|
|
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}")
|
|
|
@@ -446,37 +747,32 @@ async def send_deepseek_message(
|
|
|
elif data.business_type == 2:
|
|
|
# AI写作
|
|
|
try:
|
|
|
- rag_context = await _rag_search(message, top_k=10)
|
|
|
-
|
|
|
- # 使用prompt加载器加载公文写作prompt
|
|
|
- system_content = load_prompt(
|
|
|
- "document_writing",
|
|
|
- userMessage=message,
|
|
|
- contextJSON=rag_context if rag_context else "暂无相关知识库内容"
|
|
|
+ response_text = await _generate_ai_writing_response(message)
|
|
|
+ _, ai_message = _persist_message_pair(
|
|
|
+ db=db,
|
|
|
+ conv_id=conv_id,
|
|
|
+ user=user,
|
|
|
+ user_content=message,
|
|
|
+ ai_content=response_text,
|
|
|
)
|
|
|
-
|
|
|
- messages = [
|
|
|
- {"role": "user", "content": system_content},
|
|
|
- ]
|
|
|
-
|
|
|
- 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
|
|
|
+ ai_message_id = ai_message.id
|
|
|
+ _refresh_conversation_snapshot(db, conv_id, user.user_id)
|
|
|
+ db.commit()
|
|
|
+ return {
|
|
|
+ "statusCode": 200,
|
|
|
+ "msg": "success",
|
|
|
+ "data": {
|
|
|
+ "conversation_id": conv_id,
|
|
|
+ "ai_conversation_id": conv_id,
|
|
|
+ "response": response_text,
|
|
|
+ "reply": response_text,
|
|
|
+ "content": response_text,
|
|
|
+ "message": response_text,
|
|
|
+ "ai_message_id": ai_message_id,
|
|
|
+ "user_id": user.user_id,
|
|
|
+ "business_type": data.business_type,
|
|
|
+ },
|
|
|
+ }
|
|
|
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}")
|