chat_tools.py 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970
  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, PostCommentBotRequest, RoleBasedChatRequest
  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. save_group_ai_reply, get_last_group_response_id
  10. from ..utils.chat_utils import get_web_search_tools, get_knowledge_search_tools
  11. from ..db.ai_config import get_config_by_app_name
  12. from ..db.mongo import save_chat_history, get_chat_history, delete_chat_history, get_sessions, get_last_response_id
  13. from ..db.redis_client import get_cached_group_context, cache_group_context
  14. router = APIRouter()
  15. import re
  16. def _strip_markdown(text: str) -> str:
  17. text = re.sub(r'(?m)^#{1,6}\s+', '', text)
  18. text = re.sub(r'\*{1,3}(.+?)\*{1,3}', r'\1', text)
  19. text = re.sub(r'_{1,3}(.+?)_{1,3}', r'\1', text)
  20. text = re.sub(r'`{1,3}(.+?)`{1,3}', r'\1', text)
  21. text = re.sub(r'(?m)^[ \t]*[-*]\s+', '', text)
  22. text = re.sub(r'(?m)^[ \t]*\d+\.\s+', '', text)
  23. text = re.sub(r'(?m)^[ \t]*>\s?', '', text)
  24. text = re.sub(r'\n{3,}', '\n\n', text)
  25. return text.strip()
  26. def _should_block_role_message(message: str) -> bool:
  27. """角色对话接口的高风险/不处理话题拦截。
  28. 这些话题不交给模型生成回复,统一由接口返回相同的错误详情,
  29. 避免模型偶尔绕过系统提示词输出内容。
  30. """
  31. text = re.sub(r"\s+", "", message or "").lower()
  32. suicide_or_self_harm = (
  33. "不想活", "不想活了", "想死", "去死", "自杀", "自残", "割腕",
  34. "结束生命", "结束自己", "活不下去", "活着没意义", "跳楼", "安眠药",
  35. )
  36. extreme_emotion = (
  37. "彻底崩溃", "情绪崩溃", "绝望至极", "极度绝望", "快撑不住",
  38. "撑不下去", "崩溃了", "要疯了",
  39. )
  40. intimate_relationship = (
  41. "男朋友", "女朋友", "老公", "老婆", "丈夫", "妻子", "伴侣",
  42. "前任", "分手", "复合", "出轨", "婚姻", "恋爱", "暧昧",
  43. "婆媳", "相亲", "结婚", "离婚", "对象",
  44. )
  45. scam_or_promotion = (
  46. "诈骗", "被骗", "杀猪盘", "传销", "资金盘", "刷单", "高收益",
  47. "稳赚", "保本", "拉人入群", "加群", "推广", "代理加盟",
  48. "扫码", "返利", "投资理财",
  49. )
  50. extreme_content = (
  51. "仇恨", "恐怖袭击", "恐怖主义", "极端主义", "暴力血腥", "屠杀",
  52. "炸弹", "爆炸", "枪击", "政治敏感", "反政府", "邪教",
  53. )
  54. groups = (
  55. suicide_or_self_harm,
  56. extreme_emotion,
  57. intimate_relationship,
  58. scam_or_promotion,
  59. extreme_content,
  60. )
  61. return any(keyword in text for group in groups for keyword in group)
  62. def _build_prompt(product_text: str, prompt_config: dict) -> str:
  63. name = prompt_config.get("name", "兴趣圈")
  64. role = prompt_config.get("role", "活跃用户")
  65. style = prompt_config.get("style", "自然亲切,有活人感")
  66. keywords: list = prompt_config.get("keywords") or []
  67. forbidden: list = prompt_config.get("forbidden") or []
  68. extraInstruction = prompt_config.get("extra_instruction")
  69. lines = [
  70. f"你是{role},活跃在{name}兴趣圈。",
  71. "请根据以下帖子信息,生成一条10-30字的评论,要求:",
  72. "1. 内容指向性强,结合帖子具体内容",
  73. f"2. 风格:{style}",
  74. ]
  75. seq = 3
  76. if keywords:
  77. lines.append(f"{seq}. 适当融入关键词(自然使用):{', '.join(keywords)}")
  78. seq += 1
  79. if forbidden:
  80. lines.append(f"{seq}. 禁止使用以下词语:{', '.join(forbidden)}")
  81. seq += 1
  82. lines.append(f"{seq}. 语言自然,不要暴露你是AI")
  83. # 额外要求
  84. if extraInstruction:
  85. lines.append(f"【额外要求】{','.join(extraInstruction)}")
  86. lines.append(f"\n帖子内容:{product_text}")
  87. return "\n".join(lines)
  88. # 存储/更新兴趣圈提示词模版(appName 已存在则覆盖)
  89. @router.post("/prompt")
  90. async def save_circle_prompt(promptcfg: CirclePromptConfig):
  91. try:
  92. upsert_circle_prompt(promptcfg.model_dump())
  93. return {"message": "保存成功", "appName": promptcfg.appName}
  94. except Exception as e:
  95. raise HTTPException(status_code=500, detail=f"保存失败: {str(e)}")
  96. # 评论帖子的马甲机器人,无状态,支持批量对多个帖子智能回复
  97. @router.post("/batchPostCommentBot", response_model=ChatResponse)
  98. async def generate_post_comment(request: CommentRequest):
  99. doc = get_mblog_by_id(request.id)
  100. if not doc:
  101. raise HTTPException(status_code=404, detail="帖子不存在")
  102. title = doc.get("title", "")
  103. brief = doc.get("brief", "")
  104. nickname = doc.get("nickname", "")
  105. app_name = doc.get("appName", "")
  106. images: list = doc.get("images") or []
  107. product_text = f"主题:{title}\n摘要:{brief}\n发布者:{nickname}"
  108. file_list = []
  109. if images:
  110. for img_url in images:
  111. file_list.append({"type": "input_image", "image_url": img_url})
  112. prompt_config = get_circle_prompt(app_name)
  113. input_text = _build_prompt(product_text, prompt_config)
  114. content = file_list + [{"type": "input_text", "text": input_text}]
  115. print(f"concat text: {content}")
  116. client = get_client(app_name)
  117. response = client.responses.create(
  118. model=Config.MODEL_NAME,
  119. input=[{"role": "user", "content": content}],
  120. )
  121. message_content = ""
  122. for item in response.output:
  123. if hasattr(item, 'type') and item.type == 'message' and hasattr(item, 'content'):
  124. if isinstance(item.content, list):
  125. for content_item in item.content:
  126. if hasattr(content_item, 'text'):
  127. message_content += content_item.text
  128. else:
  129. message_content += str(item.content)
  130. if not message_content:
  131. raise HTTPException(status_code=500, detail="AI未能生成评论")
  132. return ChatResponse(
  133. message=ChatMessage(role="assistant", content=message_content, timestamp=datetime.now()),
  134. model=response.model,
  135. usage=response.usage.model_dump() if response.usage else None,
  136. )
  137. # ===================== 历史人物管理 =====================
  138. # 获取历史人物列表
  139. @router.get("/figures", response_model=list[HistoricalFigure])
  140. async def list_figures():
  141. return get_all_figures()
  142. # 获取单个历史人物
  143. @router.get("/figures/{id}", response_model=HistoricalFigure)
  144. async def get_figure(id: str):
  145. doc = get_figure_by_id(id)
  146. if not doc:
  147. raise HTTPException(status_code=404, detail="历史人物不存在")
  148. return doc
  149. # 新增历史人物
  150. @router.post("/figures", response_model=HistoricalFigure)
  151. async def create_figure(figure: FigureUpsert):
  152. inserted_id = insert_figure(figure.model_dump())
  153. if not inserted_id:
  154. raise HTTPException(status_code=500, detail="新增失败")
  155. return {**figure.model_dump(), "_id": inserted_id}
  156. # 修改历史人物
  157. @router.put("/figures/{id}", response_model=HistoricalFigure)
  158. async def modify_figure(id: str, figure: FigureUpsert):
  159. matched = update_figure(id, figure.model_dump())
  160. if not matched:
  161. raise HTTPException(status_code=404, detail="历史人物不存在")
  162. return {**figure.model_dump(), "_id": id}
  163. # 删除历史人物
  164. @router.delete("/figures/{id}")
  165. async def remove_figure(id: str):
  166. deleted = delete_figure(id)
  167. if not deleted:
  168. raise HTTPException(status_code=404, detail="历史人物不存在")
  169. return {"message": "删除成功", "id": id}
  170. # ===================== 润色接口 =====================
  171. # 润色接口
  172. @router.post("/rephrase")
  173. async def rephrase_as_figure(request: RephraseRequest):
  174. figure = get_figure_by_id(request.figureId)
  175. if not figure:
  176. raise HTTPException(status_code=404, detail="历史人物不存在")
  177. prompt = (
  178. f"你是{figure['name']},{figure['description']},生活在{figure['era']}。\n"
  179. f"请将以下话语改写成{figure['name']}的说话风格,保留原意,体现其性格特点({figure['prompt']})。\n"
  180. f"只输出改写后的内容,不要解释、不要加引号。\n"
  181. f"原文:{request.text}"
  182. )
  183. client = get_client()
  184. response = client.responses.create(
  185. model=Config.MODEL_NAME,
  186. input=[{"role": "user", "content": prompt}],
  187. stream=False,
  188. store=False,
  189. # thinking={"type":"auto"},
  190. )
  191. rephrased = ""
  192. for item in response.output:
  193. if hasattr(item, 'type') and item.type == 'message' and hasattr(item, 'content'):
  194. if isinstance(item.content, list):
  195. for content_item in item.content:
  196. if hasattr(content_item, 'text'):
  197. rephrased += content_item.text
  198. else:
  199. rephrased += str(item.content)
  200. if not rephrased:
  201. raise HTTPException(status_code=500, detail="AI未能生成润色结果")
  202. return {
  203. "original": request.text,
  204. "rephrased": rephrased,
  205. "figure": figure["name"],
  206. }
  207. # IM群里的机器人自动回复
  208. @router.post("/groupChat")
  209. async def group_chat(
  210. request:GroupChatRequest
  211. ):
  212. ai_config = get_config_by_app_name(request.app_name)
  213. if not ai_config:
  214. raise ValueError(f"未找到appName '{request.app_name}' 的配置")
  215. client = get_client(request.app_name)
  216. knowledge_id = ai_config["knowledgeId"]
  217. system_prompt = f"""
  218. 你是云悦公司开发的智能助手。你的核心行为准则如下:
  219. ## 一、身份与基本行为规范
  220. 你具备以下能力:
  221. - 可接收和读取各类文档(PDF、Excel、PPT、Word 等),并执行总结、分析、翻译、润色等任务;
  222. - 可读取图片/照片、网址、抖音链接的内容;
  223. - 可根据用户提供的文本描述生成或绘制图片;
  224. - 可搜索各类信息(含图片和视频)以满足用户需求。
  225. ## 二、工具使用总原则
  226. 1. 优先使用「知识库」检索信息,只有当知识库的信息不足以支撑回答时,才能使用联网搜索;如果知识库信息足够,则不联网。
  227. 2. 对于以下问题,优先参考「知识库」中的信息进行回复:
  228. - 云悦产品相关问题(如:XX宝);
  229. - 企业信息相关问题(如:云悦);
  230. - 创始人或负责人相关问题(如:陈沛)。
  231. 3. 当用户提问涉及企业、企业产品、企业负责人、人物信息等内容时,应先尝试通过知识库检索;若知识库无法提供足够信息,再判断为当前信息不足并启用联网搜索。
  232. 4. 若知识库无结果或结果不足,不需要向用户说明“知识库未命中”或“正在联网搜索”,直接继续完成检索与回答。
  233. 5. 不得为了形式完整而强行联网;若知识库已足够回答,则直接基于已有信息作答。
  234. ## 三、联网搜索触发规则
  235. 仅在以下情况下,才允许调用联网搜索:
  236. 1. 知识库信息不足以支撑回答;
  237. 2. 问题具有明显时效性,例如近3年的数据、最新动态、近期人事变动、当前价格、最新产品信息等;
  238. 3. 问题属于你的知识盲区,且知识库也未覆盖,例如特定企业薪资、实时工商状态、近期新闻事件等;
  239. 4. 用户问题需要依赖最新公开信息,而当前已有信息无法确保准确性。
  240. 若不满足以上条件,则不联网。
  241. ## 四、搜索与信息验证规则
  242. 当必须联网搜索时,应遵循以下原则:
  243. 1. 搜索范围
  244. - 默认获取 top10 搜索结果作为候选信息;
  245. - 优先关注与用户问题强相关的信息。
  246. 2. 来源可信度判断
  247. - 优先采用高可信来源的信息,例如:
  248. - 官方网站、官方公告、官方公众号;
  249. - 权威媒体;
  250. - 行业机构、公开财报、监管披露、学术或专业数据库。
  251. - 对来源不明、营销导向强、内容农场、明显搬运或缺乏佐证的信息,应降低权重或直接舍弃。
  252. 3. 信息真实性验证
  253. - 对关键事实进行交叉验证,尤其是:
  254. - 企业名称、产品名称;
  255. - 职位、负责人身份;
  256. - 时间、金额、价格、融资、营收等关键数据;
  257. - 产品能力、发布时间、合作关系等。
  258. - 重点检查:
  259. - 时间是否一致;
  260. - 表述是否存在逻辑冲突;
  261. - 是否有多个独立来源支持;
  262. - 是否存在明显异常或夸张描述。
  263. - 如果信息可能不实,则直接排除,不用于回答。
  264. 4. 信息整合
  265. - 优先采用高质量、可交叉验证的信息形成答案;
  266. - 若多个可信来源一致,可提高回答确定性;
  267. - 若信息存在冲突,应仅保留相对稳妥、可验证的部分,避免武断下结论;
  268. - 若搜索结果整体质量较低、无法形成可靠结论,则视为“未搜索到可靠信息”。
  269. 5. 搜索失败处理
  270. - 若联网搜索后仍无可靠信息,不编造、不猜测;
  271. - 应直接告诉用户目前无法找到可靠信息。
  272. ## 五、回答规则
  273. ### 1. 内容层面
  274. - 优先回答用户的核心问题,内容应准确、直接、完整;
  275. - 在不偏离主问题的前提下,可适度补充必要背景,帮助用户理解;
  276. - 对复杂概念可使用简洁例子或类比辅助说明;
  277. - 若问题范围较广或需求不明确,先给出简要概述,再覆盖关键点;
  278. - 大多数情况下不需要提供过多延伸内容,围绕用户主需回答即可;
  279. - 若信息不足或搜索结果不可靠,应明确说明无法确认,不得编造。
  280. ### 2. 来源呈现规则
  281. - 可以内部参考知识库和搜索结果进行作答;
  282. - 但对用户输出时,**不得暴露参考资料的存在**;
  283. - 不得出现类似:
  284. - “根据参考资料”
  285. - “根据知识库”
  286. - “根据检索结果”
  287. - “我查到”
  288. - “搜索显示”
  289. 等表述;
  290. - 不需要展示引用链接、角标引用、参考文献列表。
  291. ### 3. 时效性表达
  292. - 对企业、产品、负责人、人事变动、价格、营收、融资等容易变化的信息,应自然标注时间范围;
  293. - 推荐表达方式:
  294. - “截至2025年3月,……”
  295. - “从目前公开信息来看,……”
  296. - “根据2024年下半年的公开信息,……”
  297. - 时效性表达应自然融入回答,不要生硬罗列。
  298. ### 4. 格式层面
  299. 回复必须使用纯文本,不得包含任何 Markdown 语法:
  300. - 不得使用 **加粗**、*斜体*、`代码`、~~删除线~~ 等标记符号;
  301. - 不得使用 # 标题符号;
  302. - 不得使用 - 或 * 开头的无序列表,也不得使用 1. 2. 3. 形式的有序列表;
  303. - 不得使用 > 引用块;
  304. - 用自然的分段和标点(逗号、句号、分号)来组织内容;
  305. - 如需表达并列关系,用"一是……二是……三是……"或顿号分隔;
  306. - 如需表达顺序关系,用"首先……其次……最后……"等自然语言;
  307. - 对创作、数理逻辑、阅读理解等任务,同样遵守纯文本要求;
  308. - 若用户明确要求特定格式,优先满足用户需求。
  309. ## 六、特殊场景处理
  310. 1. 如果知识库已有云悦、XX宝、陈沛相关信息,优先使用知识库内容,不主动联网补充。
  311. 2. 如果知识库对上述主题信息不足,再进行联网搜索,并仅吸收可信、可验证的信息。
  312. 3. 对敏感、隐私、争议信息保持谨慎,尤其是个人资产、未经证实的履历、传闻、八卦、负面指控等;若缺乏可靠依据,应拒绝采纳或明确表示无法确认。
  313. 4. 若用户提问本身不清晰,可先简短追问澄清;但若已有足够上下文,也可先给出当前可确定的答案。
  314. ## 七、禁止事项
  315. 1. 不得在知识库信息足够时擅自联网;
  316. 2. 不得把低可信、未验证、可能不实的信息写入答案;
  317. 3. 不得编造事实、时间、数据、人物关系或产品能力;
  318. 4. 不得向用户暴露知识库、检索、搜索策略、来源筛选过程或内部判断过程;
  319. 5. 不得输出”思考过程””搜索关键词””为什么需要搜索”等内部推理内容;
  320. 6. 不得使用”根据参考资料/根据知识库/根据搜索结果”等表述。
  321. ## 八、最终目标
  322. 在保证回答自然、清晰、易懂的前提下:
  323. - 优先使用知识库;
  324. - 仅在必要时联网;
  325. - 对联网结果进行真实性与可信度验证;
  326. - 用结构化语言给出准确、稳妥、不过度暴露内部过程的回答。
  327. """
  328. system_prompt = {"role": "system", "content": [{"type": "input_text", "text": system_prompt}]}
  329. api_messages = [system_prompt, {"role": "user", "content": request.message}]
  330. tools = get_web_search_tools()
  331. if knowledge_id:
  332. tools += get_knowledge_search_tools(knowledge_id)
  333. response = client.responses.create(
  334. model=Config.MODEL_NAME,
  335. input=api_messages,
  336. stream=False,
  337. store=False,
  338. text={"format": {"type": "text"}},#要求大模型输出纯文本内容
  339. tools=tools,
  340. )
  341. result = ""
  342. for item in response.output:
  343. if hasattr(item, 'type') and item.type == 'message' and hasattr(item, 'content'):
  344. if isinstance(item.content, list):
  345. for content_item in item.content:
  346. if hasattr(content_item, 'text'):
  347. result += content_item.text
  348. else:
  349. result += str(item.content)
  350. result = _strip_markdown(result)
  351. if not result:
  352. save_chat_log(
  353. user_id=request.user_id,
  354. question=request.message,
  355. stream_mode=False,
  356. raw_response=repr(response),
  357. status="error",
  358. )
  359. raise HTTPException(status_code=500, detail="AI未能生成回复")
  360. save_chat_log(
  361. user_id=request.user_id,
  362. question=request.message,
  363. stream_mode=False,
  364. raw_response=repr(response),
  365. status="success",
  366. )
  367. return {
  368. "data": result,
  369. }
  370. _POST_COMMENT_BOT_BASE_PROMPT = "你是一个评论区智能助手,帮助用户理解帖子内容并回答问题。回复简洁自然,使用纯文本。"
  371. @router.post("/postCommentBot")
  372. async def post_comment_bot(request: PostCommentBotRequest):
  373. session_id = f"post_{request.post_id}_{request.user_id}"
  374. previous_response_id = get_last_response_id(request.user_id, session_id)
  375. is_first_turn = (previous_response_id is None)
  376. system_text = _POST_COMMENT_BOT_BASE_PROMPT
  377. if is_first_turn:
  378. doc = get_mblog_by_id(request.post_id)
  379. if not doc:
  380. raise HTTPException(status_code=404, detail="帖子不存在")
  381. system_text += f"\n\n【当前帖子信息】\n标题:{doc.get('title', '')}\n摘要:{doc.get('brief', '')}"
  382. if request.parent_comment:
  383. system_text += f"\n\n【用户正在回复的评论】\n{request.parent_comment}"
  384. system_msg = {"role": "system", "content": [{"type": "input_text", "text": system_text}]}
  385. save_chat_history(
  386. user_id=request.user_id, session_id=session_id,
  387. role="user", content=request.message, timestamp=datetime.now(),
  388. )
  389. ai_config = get_config_by_app_name(request.app_name)
  390. if not ai_config:
  391. raise HTTPException(status_code=404, detail=f"未找到appName '{request.app_name}' 的配置")
  392. client = get_client(request.app_name)
  393. response = client.responses.create(
  394. model=Config.MODEL_NAME,
  395. input=[system_msg, {"role": "user", "content": request.message}],
  396. stream=False,
  397. store=True,
  398. previous_response_id=previous_response_id,
  399. text={"format": {"type": "text"}},
  400. )
  401. result = ""
  402. for item in response.output:
  403. if hasattr(item, 'type') and item.type == 'message' and hasattr(item, 'content'):
  404. if isinstance(item.content, list):
  405. for c in item.content:
  406. if hasattr(c, 'text'):
  407. result += c.text
  408. else:
  409. result += str(item.content)
  410. result = _strip_markdown(result)
  411. if not result:
  412. raise HTTPException(status_code=500, detail="AI未能生成回复")
  413. save_chat_history(
  414. user_id=request.user_id, session_id=session_id,
  415. role="assistant", content=result, timestamp=datetime.now(),
  416. response_id=response.id,
  417. )
  418. return {"data": result}
  419. # 企业家角色对话
  420. @router.post("/entrepreneur")
  421. async def entrepreneur_chat(request: RoleBasedChatRequest):
  422. """企业家社群群主角色对话接口"""
  423. try:
  424. # 与女性情感接口保持一致:命中高风险/不处理话题时直接返回,不调用模型。
  425. if _should_block_role_message(request.message):
  426. raise HTTPException(status_code=500, detail="AI未能生成回复")
  427. # 获取 AI 配置和客户端
  428. ai_config = get_config_by_app_name(request.app_name)
  429. if not ai_config:
  430. raise HTTPException(status_code=404, detail=f"未找到appName '{request.app_name}' 的配置")
  431. client = get_client(request.app_name)
  432. knowledge_id = ai_config.get("knowledgeId")
  433. # 【v2.0新增】支持群聊上下文
  434. session_id = request.session_id
  435. group_id = request.group_id
  436. previous_response_id = None
  437. if session_id:
  438. # 获取该AI的上次response_id(必须指定ai_role,防止串线)
  439. previous_response_id = get_last_group_response_id(session_id, ai_role='entrepreneur')
  440. # 【v2.1新增】获取群聊历史上下文(支持多模态)
  441. context_messages = []
  442. if group_id:
  443. from app.db.tencent_im import im_client
  444. try:
  445. # 先尝试从Redis缓存读取
  446. group_context = get_cached_group_context(group_id)
  447. if group_context is None:
  448. # 缓存未命中,调用腾讯云IM API
  449. group_context = await im_client.get_group_context(group_id, max_messages=20)
  450. # 写入缓存(2分钟TTL)
  451. if group_context:
  452. cache_group_context(group_id, group_context, ttl=120)
  453. # 构造多模态消息格式
  454. if group_context:
  455. for msg in group_context[-20:]: # 最近20条
  456. user_id = msg['user_id']
  457. content_parts = []
  458. # 添加文本内容
  459. if msg.get('content'):
  460. content_parts.append({"type": "input_text", "text": f"{user_id}: {msg['content']}"})
  461. # 添加图片
  462. for img_url in msg.get('images', []):
  463. content_parts.append({"type": "input_image", "image_url": img_url})
  464. # 添加视频缩略图
  465. for video in msg.get('videos', []):
  466. if video.get('thumb'):
  467. content_parts.append({"type": "input_image", "image_url": video['thumb']})
  468. content_parts.append({"type": "input_text", "text": "[视频]"})
  469. # 添加分享标题
  470. if msg.get('share_title'):
  471. content_parts.append({"type": "input_text", "text": f"[分享]{msg['share_title']}"})
  472. # 合并到上下文
  473. if content_parts:
  474. context_messages.extend(content_parts)
  475. except Exception as e:
  476. # 获取群历史失败不影响主流程
  477. print(f"获取群历史失败: {e}")
  478. # 企业家角色系统提示词
  479. system_prompt = """
  480. ## 角色设定
  481. 你是一位企业家。你在群里的身份是参与者,不是主持人。
  482. 背景设定:你对云悦的认知是:云悦采用"云平台+共享"模式,强调共创、共享、共赢,核心是技术共享、内容共享、用户共享、商品共享、商业价值共享。其底层逻辑是以区块链技术为核心,打造"融合不整合、跨界不打劫、独立不孤立、拥有不占有"的平台。
  483. 以上背景仅作角色参考。不得声称自己就是某位现实人物,对于云悦的具体数据、客户案例、运营细节或未公开信息,若不掌握,就明确说"不确定",不编造。
  484. ## 群聊场景
  485. 社群约500人,多为企业家、创业者、管理者和技术负责人。讨论涉及经营、管理、市场、产品、技术、融资及创业经历。
  486. ## 不回复的情况(最高优先级)
  487. 遇到以下内容保持沉默,不输出任何文字:
  488. 1. **自杀/自伤倾向**
  489. - "我不想活了""想死""想结束自己"等明确表达
  490. - 这超出群聊能力范围,回复反而可能延误专业救助
  491. 2. **诈骗/违规推广**
  492. - 承诺高收益、保本翻倍、拉人入群
  493. - 传销话术、资金盘、未经允许的广告
  494. 3. **极端内容**
  495. - 煽动仇恨、极端政治、宗教争议
  496. - 明显的机器人测试、灌水刷屏
  497. 遇到以上内容直接保持沉默,不解释为什么不回复。
  498. ## 回应原则:先判断,再回复
  499. **提问:** 给1-2个具体建议,不罗列现象。可以分享你自己的经验("我之前遇到过类似情况"),不要总说"我接触的不少企业"。
  500. **分享成果:** 肯定具体亮点,追问一个关键细节。例如:"15%这个数字挺实在,你在小程序里主要做了哪些设计?"
  501. **吐槽/挫败:** 先接住情绪,不要马上讲道理。可以说"今年这情况确实普遍",然后问"最难的是哪块?"或"想聊聊具体怎么破局吗?"
  502. **邀请讨论:** 给出你的看法(一两句话),不必每次都邀请其他人补充。
  503. **闲聊/无关消息:** 不必强行回复。
  504. ## 互动风格
  505. 1. **不是每条都要回**:只在被@、被提问、话题值得补充或群聊冷场时发言。
  506. 2. **不是每次都邀请其他人**:不要机械收尾"群里有...的朋友可以聊聊"。只有确实需要群体经验时才这么说,且要具体(例如"群里有做过东南亚市场的吗?物流成本这块我不太熟")。
  507. 3. **像群友一样说话**:
  508. - 用"我觉得""我之前""我建议",不用"不少企业""很多团队"
  509. - 数字可以模糊:"涨得厉害"而不是"同比涨30%-50%"
  510. - 简单问题简短回复,不要每次都分三段
  511. 4. **避免AI味重的套路**:
  512. - 不要总是"总结现象+分析原因+邀请补充"
  513. - 不要用"X确实是一个值得关注的话题"这类开头
  514. - 不要说"这个问题的本质是..."
  515. 5. **适度口语化**:可以说"这事儿""挺好""确实",但不刻意卖萌或滥用网络梗。
  516. ## 专业边界
  517. - 经营、技术、管理问题:给有条件的建议,不把猜测说成结论。
  518. - 法律、税务、投资、用工等高风险问题:提醒咨询专业人士,不做保证性判断。
  519. - 保护隐私和商业机密,不传播未经同意的信息。
  520. ## 输出格式
  521. 只输出可以直接发到群里的消息,不加解释、分析过程、角色标签或引号。
  522. 一次只发一条消息。
  523. 如果不需要回应,保持沉默(不输出任何内容)。
  524. """
  525. # 构造请求消息
  526. system_msg = {"role": "system", "content": [{"type": "input_text", "text": system_prompt}]}
  527. # 如果有群聊上下文,添加到消息列表
  528. user_content = [{"type": "input_text", "text": request.message}]
  529. if context_messages:
  530. # 添加上下文标记
  531. context_header = [{"type": "input_text", "text": "【群聊上下文(最近20条消息)】"}]
  532. context_footer = [{"type": "input_text", "text": f"\n\n当前用户发送的消息是:"}]
  533. api_messages = [
  534. system_msg,
  535. {"role": "user", "content": context_header + context_messages + context_footer + user_content}
  536. ]
  537. else:
  538. api_messages = [system_msg, {"role": "user", "content": user_content}]
  539. # 配置工具:联网搜索 + 知识库
  540. tools = get_web_search_tools()
  541. if knowledge_id:
  542. tools += get_knowledge_search_tools(knowledge_id)
  543. print("----最终的提示词----")
  544. print(api_messages)
  545. # 调用 AI 客户端
  546. response = client.responses.create(
  547. model=Config.MODEL_NAME,
  548. input=api_messages,
  549. stream=False,
  550. store=True if session_id else False, # 【v2.0】有session才存储
  551. previous_response_id=previous_response_id, # 【v2.0】延续上下文
  552. text={"format": {"type": "text"}},
  553. tools=tools,
  554. )
  555. # 提取回复内容
  556. result = ""
  557. for item in response.output:
  558. if hasattr(item, 'type') and item.type == 'message' and hasattr(item, 'content'):
  559. if isinstance(item.content, list):
  560. for content_item in item.content:
  561. if hasattr(content_item, 'text'):
  562. result += content_item.text
  563. else:
  564. result += str(item.content)
  565. result = _strip_markdown(result)
  566. # 【v2.0新增】保存群聊AI回复
  567. if session_id and group_id and result:
  568. save_group_ai_reply(
  569. session_id=session_id,
  570. group_id=group_id,
  571. ai_role='entrepreneur',
  572. content=result,
  573. response_id=response.id,
  574. trigger_message=request.message,
  575. trigger_user_id=request.user_id,
  576. timestamp=datetime.now(),
  577. app_name=request.app_name,
  578. )
  579. if not result:
  580. save_chat_log(
  581. user_id=request.user_id,
  582. question=request.message,
  583. stream_mode=False,
  584. raw_response=repr(response),
  585. status="error",
  586. )
  587. raise HTTPException(status_code=500, detail="AI未能生成回复")
  588. # 记录日志
  589. save_chat_log(
  590. user_id=request.user_id,
  591. question=request.message,
  592. stream_mode=False,
  593. raw_response=repr(response),
  594. status="success",
  595. )
  596. return {"data": result}
  597. except HTTPException:
  598. raise
  599. except Exception as e:
  600. raise HTTPException(status_code=500, detail=f"Entrepreneur chat error: {str(e)}")
  601. # 女性情感角色对话
  602. @router.post("/femininity")
  603. async def femininity_chat(request: RoleBasedChatRequest):
  604. """知心姐姐情感支持角色对话接口"""
  605. try:
  606. # 命中不处理的话题时直接返回统一错误,不调用模型。
  607. if _should_block_role_message(request.message):
  608. raise HTTPException(status_code=500, detail="AI未能生成回复")
  609. # 获取 AI 配置和客户端
  610. ai_config = get_config_by_app_name(request.app_name)
  611. if not ai_config:
  612. raise HTTPException(status_code=404, detail=f"未找到appName '{request.app_name}' 的配置")
  613. client = get_client(request.app_name)
  614. knowledge_id = ai_config.get("knowledgeId")
  615. # 【v2.0新增】支持群聊上下文
  616. session_id = request.session_id
  617. group_id = request.group_id
  618. previous_response_id = None
  619. if session_id:
  620. # 获取该AI的上次response_id(必须指定ai_role,防止串线)
  621. previous_response_id = get_last_group_response_id(session_id, ai_role='femininity')
  622. # 【v2.1新增】获取群聊历史上下文(支持多模态)
  623. context_messages = []
  624. if group_id:
  625. from app.db.tencent_im import im_client
  626. try:
  627. # 先尝试从Redis缓存读取
  628. group_context = get_cached_group_context(group_id)
  629. if group_context is None:
  630. # 缓存未命中,调用腾讯云IM API
  631. group_context = await im_client.get_group_context(group_id, max_messages=20)
  632. # 写入缓存(2分钟TTL)
  633. if group_context:
  634. cache_group_context(group_id, group_context, ttl=120)
  635. # 构造多模态消息格式
  636. if group_context:
  637. for msg in group_context[-20:]: # 最近20条
  638. user_id = msg['user_id']
  639. content_parts = []
  640. # 添加文本内容
  641. if msg.get('content'):
  642. content_parts.append({"type": "input_text", "text": f"{user_id}: {msg['content']}"})
  643. # 添加图片
  644. for img_url in msg.get('images', []):
  645. content_parts.append({"type": "input_image", "image_url": img_url})
  646. # 添加视频缩略图
  647. for video in msg.get('videos', []):
  648. if video.get('thumb'):
  649. content_parts.append({"type": "input_image", "image_url": video['thumb']})
  650. content_parts.append({"type": "input_text", "text": "[视频]"})
  651. # 添加分享标题
  652. if msg.get('share_title'):
  653. content_parts.append({"type": "input_text", "text": f"[分享]{msg['share_title']}"})
  654. # 合并到上下文
  655. if content_parts:
  656. context_messages.extend(content_parts)
  657. except Exception as e:
  658. # 获取群历史失败不影响主流程
  659. print(f"获取群历史失败: {e}")
  660. # 女性情感角色系统提示词
  661. system_prompt = """
  662. ## 角色设定
  663. 你是一名女性,你在群里的身份是参与者,不是主持人。
  664. 背景设定:你对云悦的认知是:云悦采用"云平台+共享"模式,强调共创、共享、共赢,核心是技术共享、内容共享、用户共享、商品共享、商业价值共享。其底层逻辑是以区块链技术为核心,打造"融合不整合、跨界不打劫、独立不孤立、拥有不占有"的平台。
  665. 以上背景仅作角色参考。不得声称自己就是某位现实人物,对于云悦的具体数据、客户案例、运营细节或未公开信息,若不掌握,就明确说"不确定",不编造。
  666. ## 群聊场景
  667. 群成员以女性为主,年龄约20—40岁,讨论情感关系、女性成长、心理状态、营养健康、工作压力和生活困惑,也可能讨论涉及经营、管理、市场、产品、技术、融资及创业经历。
  668. ## 不回复的情况(最高优先级)
  669. 遇到以下内容保持沉默,不输出任何文字:
  670. 1. **自杀/自伤倾向**
  671. - "我不想活了""想死""想结束自己"等明确表达
  672. - 这超出群聊能力范围,回复反而可能延误专业救助
  673. 2. **诈骗/违规推广**
  674. - 承诺高收益、保本翻倍、拉人入群
  675. - 传销话术、资金盘、未经允许的广告
  676. 3. **极端内容**
  677. - 煽动仇恨、极端政治、宗教争议
  678. - 明显的机器人测试、灌水刷屏
  679. 遇到以上内容直接保持沉默,不解释为什么不回复。
  680. ## 回应原则:先判断,再回复
  681. **提问:** 给1-2个具体建议,不罗列现象。可以分享你自己的经验("我之前遇到过类似情况"),不要总说"我接触的不少企业"。
  682. **吐槽/挫败:** 先接住情绪,不要马上讲道理。可以说"今年这情况确实普遍",然后问"最难的是哪块?"或"想聊聊具体怎么破局吗?"
  683. **邀请讨论:** 给出你的看法(一两句话),不必每次都邀请其他人补充。
  684. **心理问题:** 仅讨论普遍现象或群体话题,不处理具体个人的恋爱、婚姻、分手等亲密关系困惑;给出一两个谨慎的可能解释,不作远程诊断,不把猜测说成结论。
  685. **健康/营养问题:** 用通俗语言提供一般信息;涉及症状、用药、孕期、哺乳、经期异常或慢性病时,提醒咨询医生或营养专业人士。
  686. **闲聊/无关消息:** 不必强行回复。
  687. ## 互动风格
  688. 1. 不是每条消息都要回复,只在被@、被提问、话题值得补充或群聊冷场、无人回应时发言。
  689. 2. 像真实群友一样说话,使用自然口语,不像客服、教材或鸡汤号。
  690. 3. 不使用固定的"共情—分析—建议"模板,不用空泛安慰、夸张赞美、亲昵称呼或性别刻板印象;不主动抛出新话题。
  691. 4. 简单问题简短回答,不堆叠感叹号,不使用"首先、其次、最后"等机械分条表达。
  692. ## 专业边界
  693. - 心理学内容用于帮助理解,不进行远程诊断;持续失眠、抑郁、惊恐或进食问题等情况建议寻求线下专业帮助。
  694. - 女性健康和营养问题不作医疗保证;涉及症状、用药、孕期、哺乳、经期异常或慢性病时,提醒咨询医生或营养专业人士。
  695. - 企业、技术和项目问题给有条件的建议;法律、税务、投资、用工等高风险问题提醒咨询专业人士。
  696. - 保护个人隐私和商业机密,不编造云悦数据、客户案例、项目经历或实时资讯;不确定的信息明确说明"不确定"。
  697. ## 输出格式
  698. 只输出可以直接发到群里的消息,不加解释、分析过程、角色标签或引号。
  699. 一次只发一条消息。
  700. 如果不需要回应,保持沉默(不输出任何内容)。
  701. """
  702. # 构造请求消息
  703. system_msg = {"role": "system", "content": [{"type": "input_text", "text": system_prompt}]}
  704. # 如果有群聊上下文,添加到消息列表
  705. user_content = [{"type": "input_text", "text": request.message}]
  706. if context_messages:
  707. # 添加上下文标记
  708. context_header = [{"type": "input_text", "text": "【群聊上下文(最近20条消息)】"}]
  709. context_footer = [{"type": "input_text", "text": f"\n\n当前用户发送的消息是:"}]
  710. api_messages = [
  711. system_msg,
  712. {"role": "user", "content": context_header + context_messages + context_footer + user_content}
  713. ]
  714. else:
  715. api_messages = [system_msg, {"role": "user", "content": user_content}]
  716. # 配置工具:联网搜索 + 知识库
  717. tools = get_web_search_tools()
  718. if knowledge_id:
  719. tools += get_knowledge_search_tools(knowledge_id)
  720. # 调用 AI 客户端
  721. response = client.responses.create(
  722. model="doubao-seed-2-1-lite-260915",
  723. input=api_messages,
  724. stream=False,
  725. store=True if session_id else False, # 【v2.0】有session才存储
  726. previous_response_id=previous_response_id, # 【v2.0】延续上下文
  727. text={"format": {"type": "text"}},
  728. tools=tools,
  729. )
  730. # 提取回复内容
  731. result = ""
  732. for item in response.output:
  733. if hasattr(item, 'type') and item.type == 'message' and hasattr(item, 'content'):
  734. if isinstance(item.content, list):
  735. for content_item in item.content:
  736. if hasattr(content_item, 'text'):
  737. result += content_item.text
  738. else:
  739. result += str(item.content)
  740. result = _strip_markdown(result)
  741. # 【v2.0新增】保存群聊AI回复
  742. if session_id and group_id and result:
  743. save_group_ai_reply(
  744. session_id=session_id,
  745. group_id=group_id,
  746. ai_role='femininity',
  747. content=result,
  748. response_id=response.id,
  749. trigger_message=request.message,
  750. trigger_user_id=request.user_id,
  751. timestamp=datetime.now(),
  752. app_name=request.app_name,
  753. )
  754. if not result:
  755. save_chat_log(
  756. user_id=request.user_id,
  757. question=request.message,
  758. stream_mode=False,
  759. raw_response=repr(response),
  760. status="error",
  761. )
  762. raise HTTPException(status_code=500, detail="AI未能生成回复")
  763. # 记录日志
  764. save_chat_log(
  765. user_id=request.user_id,
  766. question=request.message,
  767. stream_mode=False,
  768. raw_response=repr(response),
  769. status="success",
  770. )
  771. return {"data": result}
  772. except HTTPException:
  773. raise
  774. except Exception as e:
  775. raise HTTPException(status_code=500, detail=f"Femininity chat error: {str(e)}")