chat_tools.py 47 KB

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