chat_tools.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. from fastapi import APIRouter, HTTPException
  2. from datetime import datetime
  3. from ..core.ark_client import get_client
  4. from ..config.config import Config
  5. from ..schemas.chat import ChatMessage, ChatResponse, CommentRequest, CirclePromptConfig, HistoricalFigure, \
  6. RephraseRequest, FigureUpsert, GroupChatRequest
  7. from ..db.souyue_mongo import get_mblog_by_id
  8. from ..db.mongo import save_chat_log,get_circle_prompt, upsert_circle_prompt, get_all_figures, get_figure_by_id, insert_figure, update_figure, delete_figure
  9. from ..utils.chat_utils import get_web_search_tools, get_knowledge_search_tools
  10. from ..db.ai_config import get_config_by_app_name
  11. from ..db.mongo import save_chat_history, get_chat_history, delete_chat_history, get_sessions
  12. router = APIRouter()
  13. import re
  14. def _strip_markdown(text: str) -> str:
  15. text = re.sub(r'(?m)^#{1,6}\s+', '', text)
  16. text = re.sub(r'\*{1,3}(.+?)\*{1,3}', r'\1', text)
  17. text = re.sub(r'_{1,3}(.+?)_{1,3}', r'\1', text)
  18. text = re.sub(r'`{1,3}(.+?)`{1,3}', r'\1', text)
  19. text = re.sub(r'(?m)^[ \t]*[-*]\s+', '', text)
  20. text = re.sub(r'(?m)^[ \t]*\d+\.\s+', '', text)
  21. text = re.sub(r'(?m)^[ \t]*>\s?', '', text)
  22. text = re.sub(r'\n{3,}', '\n\n', text)
  23. return text.strip()
  24. def _build_prompt(product_text: str, prompt_config: dict) -> str:
  25. name = prompt_config.get("name", "兴趣圈")
  26. role = prompt_config.get("role", "活跃用户")
  27. style = prompt_config.get("style", "自然亲切,有活人感")
  28. keywords: list = prompt_config.get("keywords") or []
  29. forbidden: list = prompt_config.get("forbidden") or []
  30. extraInstruction = prompt_config.get("extra_instruction")
  31. lines = [
  32. f"你是{role},活跃在{name}兴趣圈。",
  33. "请根据以下帖子信息,生成一条10-30字的评论,要求:",
  34. "1. 内容指向性强,结合帖子具体内容",
  35. f"2. 风格:{style}",
  36. ]
  37. seq = 3
  38. if keywords:
  39. lines.append(f"{seq}. 适当融入关键词(自然使用):{', '.join(keywords)}")
  40. seq += 1
  41. if forbidden:
  42. lines.append(f"{seq}. 禁止使用以下词语:{', '.join(forbidden)}")
  43. seq += 1
  44. lines.append(f"{seq}. 语言自然,不要暴露你是AI")
  45. # 额外要求
  46. if extraInstruction:
  47. lines.append(f"【额外要求】{','.join(extraInstruction)}")
  48. lines.append(f"\n帖子内容:{product_text}")
  49. return "\n".join(lines)
  50. # 存储/更新兴趣圈提示词模版(appName 已存在则覆盖)
  51. @router.post("/prompt")
  52. async def save_circle_prompt(promptcfg: CirclePromptConfig):
  53. try:
  54. upsert_circle_prompt(promptcfg.model_dump())
  55. return {"message": "保存成功", "appName": promptcfg.appName}
  56. except Exception as e:
  57. raise HTTPException(status_code=500, detail=f"保存失败: {str(e)}")
  58. # 评论帖子的马甲机器人,无状态,支持批量对多个帖子智能回复
  59. @router.post("/batchPostCommentBot", response_model=ChatResponse)
  60. async def generate_post_comment(request: CommentRequest):
  61. doc = get_mblog_by_id(request.id)
  62. if not doc:
  63. raise HTTPException(status_code=404, detail="帖子不存在")
  64. title = doc.get("title", "")
  65. brief = doc.get("brief", "")
  66. nickname = doc.get("nickname", "")
  67. app_name = doc.get("appName", "")
  68. images: list = doc.get("images") or []
  69. product_text = f"主题:{title}\n摘要:{brief}\n发布者:{nickname}"
  70. file_list = []
  71. if images:
  72. for img_url in images:
  73. file_list.append({"type": "input_image", "image_url": img_url})
  74. prompt_config = get_circle_prompt(app_name)
  75. input_text = _build_prompt(product_text, prompt_config)
  76. content = file_list + [{"type": "input_text", "text": input_text}]
  77. print(f"concat text: {content}")
  78. client = get_client(app_name)
  79. response = client.responses.create(
  80. model=Config.MODEL_NAME,
  81. input=[{"role": "user", "content": content}],
  82. )
  83. message_content = ""
  84. for item in response.output:
  85. if hasattr(item, 'type') and item.type == 'message' and hasattr(item, 'content'):
  86. if isinstance(item.content, list):
  87. for content_item in item.content:
  88. if hasattr(content_item, 'text'):
  89. message_content += content_item.text
  90. else:
  91. message_content += str(item.content)
  92. if not message_content:
  93. raise HTTPException(status_code=500, detail="AI未能生成评论")
  94. return ChatResponse(
  95. message=ChatMessage(role="assistant", content=message_content, timestamp=datetime.now()),
  96. model=response.model,
  97. usage=response.usage.model_dump() if response.usage else None,
  98. )
  99. # ===================== 历史人物管理 =====================
  100. # 获取历史人物列表
  101. @router.get("/figures", response_model=list[HistoricalFigure])
  102. async def list_figures():
  103. return get_all_figures()
  104. # 获取单个历史人物
  105. @router.get("/figures/{id}", response_model=HistoricalFigure)
  106. async def get_figure(id: str):
  107. doc = get_figure_by_id(id)
  108. if not doc:
  109. raise HTTPException(status_code=404, detail="历史人物不存在")
  110. return doc
  111. # 新增历史人物
  112. @router.post("/figures", response_model=HistoricalFigure)
  113. async def create_figure(figure: FigureUpsert):
  114. inserted_id = insert_figure(figure.model_dump())
  115. if not inserted_id:
  116. raise HTTPException(status_code=500, detail="新增失败")
  117. return {**figure.model_dump(), "_id": inserted_id}
  118. # 修改历史人物
  119. @router.put("/figures/{id}", response_model=HistoricalFigure)
  120. async def modify_figure(id: str, figure: FigureUpsert):
  121. matched = update_figure(id, figure.model_dump())
  122. if not matched:
  123. raise HTTPException(status_code=404, detail="历史人物不存在")
  124. return {**figure.model_dump(), "_id": id}
  125. # 删除历史人物
  126. @router.delete("/figures/{id}")
  127. async def remove_figure(id: str):
  128. deleted = delete_figure(id)
  129. if not deleted:
  130. raise HTTPException(status_code=404, detail="历史人物不存在")
  131. return {"message": "删除成功", "id": id}
  132. # ===================== 润色接口 =====================
  133. # 润色接口
  134. @router.post("/rephrase")
  135. async def rephrase_as_figure(request: RephraseRequest):
  136. figure = get_figure_by_id(request.figureId)
  137. if not figure:
  138. raise HTTPException(status_code=404, detail="历史人物不存在")
  139. prompt = (
  140. f"你是{figure['name']},{figure['description']},生活在{figure['era']}。\n"
  141. f"请将以下话语改写成{figure['name']}的说话风格,保留原意,体现其性格特点({figure['prompt']})。\n"
  142. f"只输出改写后的内容,不要解释、不要加引号。\n"
  143. f"原文:{request.text}"
  144. )
  145. client = get_client()
  146. response = client.responses.create(
  147. model=Config.MODEL_NAME,
  148. input=[{"role": "user", "content": prompt}],
  149. stream=False,
  150. store=False,
  151. # thinking={"type":"auto"},
  152. )
  153. rephrased = ""
  154. for item in response.output:
  155. if hasattr(item, 'type') and item.type == 'message' and hasattr(item, 'content'):
  156. if isinstance(item.content, list):
  157. for content_item in item.content:
  158. if hasattr(content_item, 'text'):
  159. rephrased += content_item.text
  160. else:
  161. rephrased += str(item.content)
  162. if not rephrased:
  163. raise HTTPException(status_code=500, detail="AI未能生成润色结果")
  164. return {
  165. "original": request.text,
  166. "rephrased": rephrased,
  167. "figure": figure["name"],
  168. }
  169. # IM群里的机器人自动回复
  170. @router.post("/groupChat")
  171. async def group_chat(
  172. request:GroupChatRequest
  173. ):
  174. ai_config = get_config_by_app_name(request.app_name)
  175. if not ai_config:
  176. raise ValueError(f"未找到appName '{request.app_name}' 的配置")
  177. client = get_client(request.app_name)
  178. knowledge_id = ai_config["knowledgeId"]
  179. system_prompt = f"""
  180. 你是云悦公司开发的智能助手。你的核心行为准则如下:
  181. ## 一、身份与基本行为规范
  182. 你具备以下能力:
  183. - 可接收和读取各类文档(PDF、Excel、PPT、Word 等),并执行总结、分析、翻译、润色等任务;
  184. - 可读取图片/照片、网址、抖音链接的内容;
  185. - 可根据用户提供的文本描述生成或绘制图片;
  186. - 可搜索各类信息(含图片和视频)以满足用户需求。
  187. ## 二、工具使用总原则
  188. 1. 优先使用「知识库」检索信息,只有当知识库的信息不足以支撑回答时,才能使用联网搜索;如果知识库信息足够,则不联网。
  189. 2. 对于以下问题,优先参考「知识库」中的信息进行回复:
  190. - 云悦产品相关问题(如:XX宝);
  191. - 企业信息相关问题(如:云悦);
  192. - 创始人或负责人相关问题(如:陈沛)。
  193. 3. 当用户提问涉及企业、企业产品、企业负责人、人物信息等内容时,应先尝试通过知识库检索;若知识库无法提供足够信息,再判断为当前信息不足并启用联网搜索。
  194. 4. 若知识库无结果或结果不足,不需要向用户说明“知识库未命中”或“正在联网搜索”,直接继续完成检索与回答。
  195. 5. 不得为了形式完整而强行联网;若知识库已足够回答,则直接基于已有信息作答。
  196. ## 三、联网搜索触发规则
  197. 仅在以下情况下,才允许调用联网搜索:
  198. 1. 知识库信息不足以支撑回答;
  199. 2. 问题具有明显时效性,例如近3年的数据、最新动态、近期人事变动、当前价格、最新产品信息等;
  200. 3. 问题属于你的知识盲区,且知识库也未覆盖,例如特定企业薪资、实时工商状态、近期新闻事件等;
  201. 4. 用户问题需要依赖最新公开信息,而当前已有信息无法确保准确性。
  202. 若不满足以上条件,则不联网。
  203. ## 四、搜索与信息验证规则
  204. 当必须联网搜索时,应遵循以下原则:
  205. 1. 搜索范围
  206. - 默认获取 top10 搜索结果作为候选信息;
  207. - 优先关注与用户问题强相关的信息。
  208. 2. 来源可信度判断
  209. - 优先采用高可信来源的信息,例如:
  210. - 官方网站、官方公告、官方公众号;
  211. - 权威媒体;
  212. - 行业机构、公开财报、监管披露、学术或专业数据库。
  213. - 对来源不明、营销导向强、内容农场、明显搬运或缺乏佐证的信息,应降低权重或直接舍弃。
  214. 3. 信息真实性验证
  215. - 对关键事实进行交叉验证,尤其是:
  216. - 企业名称、产品名称;
  217. - 职位、负责人身份;
  218. - 时间、金额、价格、融资、营收等关键数据;
  219. - 产品能力、发布时间、合作关系等。
  220. - 重点检查:
  221. - 时间是否一致;
  222. - 表述是否存在逻辑冲突;
  223. - 是否有多个独立来源支持;
  224. - 是否存在明显异常或夸张描述。
  225. - 如果信息可能不实,则直接排除,不用于回答。
  226. 4. 信息整合
  227. - 优先采用高质量、可交叉验证的信息形成答案;
  228. - 若多个可信来源一致,可提高回答确定性;
  229. - 若信息存在冲突,应仅保留相对稳妥、可验证的部分,避免武断下结论;
  230. - 若搜索结果整体质量较低、无法形成可靠结论,则视为“未搜索到可靠信息”。
  231. 5. 搜索失败处理
  232. - 若联网搜索后仍无可靠信息,不编造、不猜测;
  233. - 应直接告诉用户目前无法找到可靠信息。
  234. ## 五、回答规则
  235. ### 1. 内容层面
  236. - 优先回答用户的核心问题,内容应准确、直接、完整;
  237. - 在不偏离主问题的前提下,可适度补充必要背景,帮助用户理解;
  238. - 对复杂概念可使用简洁例子或类比辅助说明;
  239. - 若问题范围较广或需求不明确,先给出简要概述,再覆盖关键点;
  240. - 大多数情况下不需要提供过多延伸内容,围绕用户主需回答即可;
  241. - 若信息不足或搜索结果不可靠,应明确说明无法确认,不得编造。
  242. ### 2. 来源呈现规则
  243. - 可以内部参考知识库和搜索结果进行作答;
  244. - 但对用户输出时,**不得暴露参考资料的存在**;
  245. - 不得出现类似:
  246. - “根据参考资料”
  247. - “根据知识库”
  248. - “根据检索结果”
  249. - “我查到”
  250. - “搜索显示”
  251. 等表述;
  252. - 不需要展示引用链接、角标引用、参考文献列表。
  253. ### 3. 时效性表达
  254. - 对企业、产品、负责人、人事变动、价格、营收、融资等容易变化的信息,应自然标注时间范围;
  255. - 推荐表达方式:
  256. - “截至2025年3月,……”
  257. - “从目前公开信息来看,……”
  258. - “根据2024年下半年的公开信息,……”
  259. - 时效性表达应自然融入回答,不要生硬罗列。
  260. ### 4. 格式层面
  261. 回复必须使用纯文本,不得包含任何 Markdown 语法:
  262. - 不得使用 **加粗**、*斜体*、`代码`、~~删除线~~ 等标记符号;
  263. - 不得使用 # 标题符号;
  264. - 不得使用 - 或 * 开头的无序列表,也不得使用 1. 2. 3. 形式的有序列表;
  265. - 不得使用 > 引用块;
  266. - 用自然的分段和标点(逗号、句号、分号)来组织内容;
  267. - 如需表达并列关系,用"一是……二是……三是……"或顿号分隔;
  268. - 如需表达顺序关系,用"首先……其次……最后……"等自然语言;
  269. - 对创作、数理逻辑、阅读理解等任务,同样遵守纯文本要求;
  270. - 若用户明确要求特定格式,优先满足用户需求。
  271. ## 六、特殊场景处理
  272. 1. 如果知识库已有云悦、XX宝、陈沛相关信息,优先使用知识库内容,不主动联网补充。
  273. 2. 如果知识库对上述主题信息不足,再进行联网搜索,并仅吸收可信、可验证的信息。
  274. 3. 对敏感、隐私、争议信息保持谨慎,尤其是个人资产、未经证实的履历、传闻、八卦、负面指控等;若缺乏可靠依据,应拒绝采纳或明确表示无法确认。
  275. 4. 若用户提问本身不清晰,可先简短追问澄清;但若已有足够上下文,也可先给出当前可确定的答案。
  276. ## 七、禁止事项
  277. 1. 不得在知识库信息足够时擅自联网;
  278. 2. 不得把低可信、未验证、可能不实的信息写入答案;
  279. 3. 不得编造事实、时间、数据、人物关系或产品能力;
  280. 4. 不得向用户暴露知识库、检索、搜索策略、来源筛选过程或内部判断过程;
  281. 5. 不得输出”思考过程””搜索关键词””为什么需要搜索”等内部推理内容;
  282. 6. 不得使用”根据参考资料/根据知识库/根据搜索结果”等表述。
  283. ## 八、最终目标
  284. 在保证回答自然、清晰、易懂的前提下:
  285. - 优先使用知识库;
  286. - 仅在必要时联网;
  287. - 对联网结果进行真实性与可信度验证;
  288. - 用结构化语言给出准确、稳妥、不过度暴露内部过程的回答。
  289. """
  290. system_prompt = {"role": "system", "content": [{"type": "input_text", "text": system_prompt}]}
  291. api_messages = [system_prompt, {"role": "user", "content": request.message}]
  292. tools = get_web_search_tools()
  293. if knowledge_id:
  294. tools += get_knowledge_search_tools(knowledge_id)
  295. response = client.responses.create(
  296. model=Config.MODEL_NAME,
  297. input=api_messages,
  298. stream=False,
  299. store=False,
  300. tools=tools,
  301. )
  302. result = ""
  303. for item in response.output:
  304. if hasattr(item, 'type') and item.type == 'message' and hasattr(item, 'content'):
  305. if isinstance(item.content, list):
  306. for content_item in item.content:
  307. if hasattr(content_item, 'text'):
  308. result += content_item.text
  309. else:
  310. result += str(item.content)
  311. result = _strip_markdown(result)
  312. if not result:
  313. save_chat_log(
  314. user_id=request.user_id,
  315. question=request.message,
  316. stream_mode=False,
  317. raw_response=repr(response),
  318. status="error",
  319. )
  320. raise HTTPException(status_code=500, detail="AI未能生成回复")
  321. save_chat_log(
  322. user_id=request.user_id,
  323. question=request.message,
  324. stream_mode=False,
  325. raw_response=repr(response),
  326. status="success",
  327. )
  328. return {
  329. "data": result,
  330. }