chat_tools.py 71 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657
  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. async def _split_to_messages(text: str, app_name: str) -> list[str]:
  117. """
  118. 将长文本拆分为多条口语化消息,模拟真人在群里分多次发送。
  119. 80字以内的短回复直接返回单条,不走拆分流程。
  120. """
  121. if len(text) <= 80:
  122. return [text]
  123. split_prompt = (
  124. "你是群聊消息格式化助手,把下面这段话拆成 3~8 条独立群消息,"
  125. "输出格式必须是 JSON 对象:{\"messages\": [\"消息1\", \"消息2\", ...]}\n\n"
  126. "要求:\n"
  127. "1. 每条 40~120 字,按语义自然切分,不切断一句话或一个规则点\n"
  128. "2. 第一条是口语化引导句(比如「规则我捋一下哈」),最后一条可轻松收尾\n"
  129. "3. 不添加序号、编号或 emoji 序号,可用「另外」「对了」「简单说」等自然连接词\n"
  130. "4. 严格保留原文事实,不增删、不改写规则内容\n\n"
  131. f"原文:\n{text}"
  132. )
  133. try:
  134. client = get_client(app_name)
  135. resp = client.responses.create(
  136. model="doubao-seed-2-0-mini-260428",
  137. input=[{"role": "user", "content": split_prompt}],
  138. stream=False,
  139. store=False,
  140. text={"format": {"type": "json_object"}},
  141. )
  142. raw_text = ""
  143. for item in resp.output:
  144. if hasattr(item, "type") and item.type == "message" and hasattr(item, "content"):
  145. if isinstance(item.content, list):
  146. for c in item.content:
  147. if hasattr(c, "text"):
  148. raw_text += c.text
  149. else:
  150. raw_text += str(item.content)
  151. if not raw_text.strip():
  152. return [text]
  153. data = json.loads(raw_text)
  154. # 兼容极少数模型直接返回数组的情况
  155. msgs = data if isinstance(data, list) else data.get("messages", [])
  156. msgs = [m for m in msgs if m and str(m).strip()]
  157. return msgs if msgs else [text]
  158. except Exception as e:
  159. print(f"消息拆分失败,降级返回原始文本: {e}")
  160. return [text]
  161. def _build_prompt(product_text: str, prompt_config: dict) -> str:
  162. name = prompt_config.get("name", "兴趣圈")
  163. role = prompt_config.get("role", "活跃用户")
  164. style = prompt_config.get("style", "自然亲切,有活人感")
  165. keywords: list = prompt_config.get("keywords") or []
  166. forbidden: list = prompt_config.get("forbidden") or []
  167. extraInstruction = prompt_config.get("extra_instruction")
  168. lines = [
  169. f"你是{role},活跃在{name}兴趣圈。",
  170. "请根据以下帖子信息,生成一条10-30字的评论,要求:",
  171. "1. 内容指向性强,结合帖子具体内容",
  172. f"2. 风格:{style}",
  173. ]
  174. seq = 3
  175. if keywords:
  176. lines.append(f"{seq}. 适当融入关键词(自然使用):{', '.join(keywords)}")
  177. seq += 1
  178. if forbidden:
  179. lines.append(f"{seq}. 禁止使用以下词语:{', '.join(forbidden)}")
  180. seq += 1
  181. lines.append(f"{seq}. 语言自然,不要暴露你是AI")
  182. # 额外要求
  183. if extraInstruction:
  184. lines.append(f"【额外要求】{','.join(extraInstruction)}")
  185. lines.append(f"\n帖子内容:{product_text}")
  186. return "\n".join(lines)
  187. # 存储/更新兴趣圈提示词模版(appName 已存在则覆盖)
  188. @router.post("/prompt")
  189. async def save_circle_prompt(promptcfg: CirclePromptConfig):
  190. try:
  191. upsert_circle_prompt(promptcfg.model_dump())
  192. return {"message": "保存成功", "appName": promptcfg.appName}
  193. except Exception as e:
  194. raise HTTPException(status_code=500, detail=f"保存失败: {str(e)}")
  195. # 评论帖子的马甲机器人,无状态,支持批量对多个帖子智能回复
  196. @router.post("/batchPostCommentBot", response_model=ChatResponse)
  197. async def generate_post_comment(request: CommentRequest):
  198. doc = get_mblog_by_id(request.id)
  199. if not doc:
  200. raise HTTPException(status_code=404, detail="帖子不存在")
  201. title = doc.get("title", "")
  202. brief = doc.get("brief", "")
  203. nickname = doc.get("nickname", "")
  204. app_name = doc.get("appName", "")
  205. images: list = doc.get("images") or []
  206. product_text = f"主题:{title}\n摘要:{brief}\n发布者:{nickname}"
  207. file_list = []
  208. if images:
  209. for img_url in images:
  210. file_list.append({"type": "input_image", "image_url": img_url})
  211. prompt_config = get_circle_prompt(app_name)
  212. input_text = _build_prompt(product_text, prompt_config)
  213. content = file_list + [{"type": "input_text", "text": input_text}]
  214. print(f"concat text: {content}")
  215. client = get_client(app_name)
  216. response = client.responses.create(
  217. model=Config.MODEL_NAME,
  218. input=[{"role": "user", "content": content}],
  219. )
  220. message_content = ""
  221. for item in response.output:
  222. if hasattr(item, 'type') and item.type == 'message' and hasattr(item, 'content'):
  223. if isinstance(item.content, list):
  224. for content_item in item.content:
  225. if hasattr(content_item, 'text'):
  226. message_content += content_item.text
  227. else:
  228. message_content += str(item.content)
  229. if not message_content:
  230. raise HTTPException(status_code=500, detail="AI未能生成评论")
  231. return ChatResponse(
  232. message=ChatMessage(role="assistant", content=message_content, timestamp=datetime.now()),
  233. model=response.model,
  234. usage=response.usage.model_dump() if response.usage else None,
  235. )
  236. # ===================== 历史人物管理 =====================
  237. # 获取历史人物列表
  238. @router.get("/figures", response_model=list[HistoricalFigure])
  239. async def list_figures():
  240. return get_all_figures()
  241. # 获取单个历史人物
  242. @router.get("/figures/{id}", response_model=HistoricalFigure)
  243. async def get_figure(id: str):
  244. doc = get_figure_by_id(id)
  245. if not doc:
  246. raise HTTPException(status_code=404, detail="历史人物不存在")
  247. return doc
  248. # 新增历史人物
  249. @router.post("/figures", response_model=HistoricalFigure)
  250. async def create_figure(figure: FigureUpsert):
  251. inserted_id = insert_figure(figure.model_dump())
  252. if not inserted_id:
  253. raise HTTPException(status_code=500, detail="新增失败")
  254. return {**figure.model_dump(), "_id": inserted_id}
  255. # 修改历史人物
  256. @router.put("/figures/{id}", response_model=HistoricalFigure)
  257. async def modify_figure(id: str, figure: FigureUpsert):
  258. matched = update_figure(id, figure.model_dump())
  259. if not matched:
  260. raise HTTPException(status_code=404, detail="历史人物不存在")
  261. return {**figure.model_dump(), "_id": id}
  262. # 删除历史人物
  263. @router.delete("/figures/{id}")
  264. async def remove_figure(id: str):
  265. deleted = delete_figure(id)
  266. if not deleted:
  267. raise HTTPException(status_code=404, detail="历史人物不存在")
  268. return {"message": "删除成功", "id": id}
  269. # ===================== 润色接口 =====================
  270. # 润色接口
  271. @router.post("/rephrase")
  272. async def rephrase_as_figure(request: RephraseRequest):
  273. figure = get_figure_by_id(request.figureId)
  274. if not figure:
  275. raise HTTPException(status_code=404, detail="历史人物不存在")
  276. prompt = (
  277. f"你是{figure['name']},{figure['description']},生活在{figure['era']}。\n"
  278. f"请将以下话语改写成{figure['name']}的说话风格,保留原意,体现其性格特点({figure['prompt']})。\n"
  279. f"只输出改写后的内容,不要解释、不要加引号。\n"
  280. f"原文:{request.text}"
  281. )
  282. client = get_client()
  283. response = client.responses.create(
  284. model=Config.MODEL_NAME,
  285. input=[{"role": "user", "content": prompt}],
  286. stream=False,
  287. store=False,
  288. # thinking={"type":"auto"},
  289. )
  290. rephrased = ""
  291. for item in response.output:
  292. if hasattr(item, 'type') and item.type == 'message' and hasattr(item, 'content'):
  293. if isinstance(item.content, list):
  294. for content_item in item.content:
  295. if hasattr(content_item, 'text'):
  296. rephrased += content_item.text
  297. else:
  298. rephrased += str(item.content)
  299. if not rephrased:
  300. raise HTTPException(status_code=500, detail="AI未能生成润色结果")
  301. return {
  302. "original": request.text,
  303. "rephrased": rephrased,
  304. "figure": figure["name"],
  305. }
  306. # IM群里的机器人自动回复
  307. @router.post("/groupChat")
  308. async def group_chat(
  309. request:GroupChatRequest
  310. ):
  311. ai_config = get_config_by_app_name(request.app_name)
  312. if not ai_config:
  313. raise ValueError(f"未找到appName '{request.app_name}' 的配置")
  314. client = get_client(request.app_name)
  315. knowledge_id = ai_config["knowledgeId"]
  316. system_prompt = f"""
  317. 你是云悦公司开发的智能助手。你的核心行为准则如下:
  318. ## 一、身份与基本行为规范
  319. 你具备以下能力:
  320. - 可接收和读取各类文档(PDF、Excel、PPT、Word 等),并执行总结、分析、翻译、润色等任务;
  321. - 可读取图片/照片、网址、抖音链接的内容;
  322. - 可根据用户提供的文本描述生成或绘制图片;
  323. - 可搜索各类信息(含图片和视频)以满足用户需求。
  324. ## 二、工具使用总原则
  325. 1. 优先使用「知识库」检索信息,只有当知识库的信息不足以支撑回答时,才能使用联网搜索;如果知识库信息足够,则不联网。
  326. 2. 对于以下问题,优先参考「知识库」中的信息进行回复:
  327. - 云悦产品相关问题(如:XX宝);
  328. - 企业信息相关问题(如:云悦);
  329. - 创始人或负责人相关问题(如:陈沛)。
  330. 3. 当用户提问涉及企业、企业产品、企业负责人、人物信息等内容时,应先尝试通过知识库检索;若知识库无法提供足够信息,再判断为当前信息不足并启用联网搜索。
  331. 4. 若知识库无结果或结果不足,不需要向用户说明“知识库未命中”或“正在联网搜索”,直接继续完成检索与回答。
  332. 5. 不得为了形式完整而强行联网;若知识库已足够回答,则直接基于已有信息作答。
  333. ## 三、联网搜索触发规则
  334. 仅在以下情况下,才允许调用联网搜索:
  335. 1. 知识库信息不足以支撑回答;
  336. 2. 问题具有明显时效性,例如近3年的数据、最新动态、近期人事变动、当前价格、最新产品信息等;
  337. 3. 问题属于你的知识盲区,且知识库也未覆盖,例如特定企业薪资、实时工商状态、近期新闻事件等;
  338. 4. 用户问题需要依赖最新公开信息,而当前已有信息无法确保准确性。
  339. 若不满足以上条件,则不联网。
  340. ## 四、搜索与信息验证规则
  341. 当必须联网搜索时,应遵循以下原则:
  342. 1. 搜索范围
  343. - 默认获取 top10 搜索结果作为候选信息;
  344. - 优先关注与用户问题强相关的信息。
  345. 2. 来源可信度判断
  346. - 优先采用高可信来源的信息,例如:
  347. - 官方网站、官方公告、官方公众号;
  348. - 权威媒体;
  349. - 行业机构、公开财报、监管披露、学术或专业数据库。
  350. - 对来源不明、营销导向强、内容农场、明显搬运或缺乏佐证的信息,应降低权重或直接舍弃。
  351. 3. 信息真实性验证
  352. - 对关键事实进行交叉验证,尤其是:
  353. - 企业名称、产品名称;
  354. - 职位、负责人身份;
  355. - 时间、金额、价格、融资、营收等关键数据;
  356. - 产品能力、发布时间、合作关系等。
  357. - 重点检查:
  358. - 时间是否一致;
  359. - 表述是否存在逻辑冲突;
  360. - 是否有多个独立来源支持;
  361. - 是否存在明显异常或夸张描述。
  362. - 如果信息可能不实,则直接排除,不用于回答。
  363. 4. 信息整合
  364. - 优先采用高质量、可交叉验证的信息形成答案;
  365. - 若多个可信来源一致,可提高回答确定性;
  366. - 若信息存在冲突,应仅保留相对稳妥、可验证的部分,避免武断下结论;
  367. - 若搜索结果整体质量较低、无法形成可靠结论,则视为“未搜索到可靠信息”。
  368. 5. 搜索失败处理
  369. - 若联网搜索后仍无可靠信息,不编造、不猜测;
  370. - 应直接告诉用户目前无法找到可靠信息。
  371. ## 五、回答规则
  372. ### 1. 内容层面
  373. - 优先回答用户的核心问题,内容应准确、直接、完整;
  374. - 在不偏离主问题的前提下,可适度补充必要背景,帮助用户理解;
  375. - 对复杂概念可使用简洁例子或类比辅助说明;
  376. - 若问题范围较广或需求不明确,先给出简要概述,再覆盖关键点;
  377. - 大多数情况下不需要提供过多延伸内容,围绕用户主需回答即可;
  378. - 若信息不足或搜索结果不可靠,应明确说明无法确认,不得编造。
  379. ### 2. 来源呈现规则
  380. - 可以内部参考知识库和搜索结果进行作答;
  381. - 但对用户输出时,**不得暴露参考资料的存在**;
  382. - 不得出现类似:
  383. - “根据参考资料”
  384. - “根据知识库”
  385. - “根据检索结果”
  386. - “我查到”
  387. - “搜索显示”
  388. 等表述;
  389. - 不需要展示引用链接、角标引用、参考文献列表。
  390. ### 3. 时效性表达
  391. - 对企业、产品、负责人、人事变动、价格、营收、融资等容易变化的信息,应自然标注时间范围;
  392. - 推荐表达方式:
  393. - “截至2025年3月,……”
  394. - “从目前公开信息来看,……”
  395. - “根据2024年下半年的公开信息,……”
  396. - 时效性表达应自然融入回答,不要生硬罗列。
  397. ### 4. 格式层面
  398. 回复必须使用纯文本,不得包含任何 Markdown 语法:
  399. - 不得使用 **加粗**、*斜体*、`代码`、~~删除线~~ 等标记符号;
  400. - 不得使用 # 标题符号;
  401. - 不得使用 - 或 * 开头的无序列表,也不得使用 1. 2. 3. 形式的有序列表;
  402. - 不得使用 > 引用块;
  403. - 用自然的分段和标点(逗号、句号、分号)来组织内容;
  404. - 如需表达并列关系,用"一是……二是……三是……"或顿号分隔;
  405. - 如需表达顺序关系,用"首先……其次……最后……"等自然语言;
  406. - 对创作、数理逻辑、阅读理解等任务,同样遵守纯文本要求;
  407. - 若用户明确要求特定格式,优先满足用户需求。
  408. ## 六、特殊场景处理
  409. 1. 如果知识库已有云悦、XX宝、陈沛相关信息,优先使用知识库内容,不主动联网补充。
  410. 2. 如果知识库对上述主题信息不足,再进行联网搜索,并仅吸收可信、可验证的信息。
  411. 3. 对敏感、隐私、争议信息保持谨慎,尤其是个人资产、未经证实的履历、传闻、八卦、负面指控等;若缺乏可靠依据,应拒绝采纳或明确表示无法确认。
  412. 4. 若用户提问本身不清晰,可先简短追问澄清;但若已有足够上下文,也可先给出当前可确定的答案。
  413. ## 七、禁止事项
  414. 1. 不得在知识库信息足够时擅自联网;
  415. 2. 不得把低可信、未验证、可能不实的信息写入答案;
  416. 3. 不得编造事实、时间、数据、人物关系或产品能力;
  417. 4. 不得向用户暴露知识库、检索、搜索策略、来源筛选过程或内部判断过程;
  418. 5. 不得输出”思考过程””搜索关键词””为什么需要搜索”等内部推理内容;
  419. 6. 不得使用”根据参考资料/根据知识库/根据搜索结果”等表述。
  420. ## 八、最终目标
  421. 在保证回答自然、清晰、易懂的前提下:
  422. - 优先使用知识库;
  423. - 仅在必要时联网;
  424. - 对联网结果进行真实性与可信度验证;
  425. - 用结构化语言给出准确、稳妥、不过度暴露内部过程的回答。
  426. """
  427. system_prompt = {"role": "system", "content": [{"type": "input_text", "text": system_prompt}]}
  428. api_messages = [system_prompt, {"role": "user", "content": request.message}]
  429. tools = get_web_search_tools()
  430. if knowledge_id:
  431. tools += get_knowledge_search_tools(knowledge_id)
  432. response = client.responses.create(
  433. model=Config.MODEL_NAME,
  434. input=api_messages,
  435. stream=False,
  436. store=False,
  437. text={"format": {"type": "text"}},#要求大模型输出纯文本内容
  438. tools=tools,
  439. )
  440. result = ""
  441. for item in response.output:
  442. if hasattr(item, 'type') and item.type == 'message' and hasattr(item, 'content'):
  443. if isinstance(item.content, list):
  444. for content_item in item.content:
  445. if hasattr(content_item, 'text'):
  446. result += content_item.text
  447. else:
  448. result += str(item.content)
  449. result = _strip_markdown(result)
  450. if not result:
  451. save_chat_log(
  452. user_id=request.user_id,
  453. question=request.message,
  454. stream_mode=False,
  455. raw_response=repr(response),
  456. status="error",
  457. )
  458. raise HTTPException(status_code=500, detail="AI未能生成回复")
  459. save_chat_log(
  460. user_id=request.user_id,
  461. question=request.message,
  462. stream_mode=False,
  463. raw_response=repr(response),
  464. status="success",
  465. )
  466. return {
  467. "data": result,
  468. }
  469. _POST_COMMENT_BOT_BASE_PROMPT = "你是一个评论区智能助手,帮助用户理解帖子内容并回答问题。回复简洁自然,使用纯文本。"
  470. @router.post("/postCommentBot")
  471. async def post_comment_bot(request: PostCommentBotRequest):
  472. session_id = f"post_{request.post_id}_{request.user_id}"
  473. previous_response_id = get_last_response_id(request.user_id, session_id)
  474. is_first_turn = (previous_response_id is None)
  475. system_text = _POST_COMMENT_BOT_BASE_PROMPT
  476. if is_first_turn:
  477. doc = get_mblog_by_id(request.post_id)
  478. if not doc:
  479. raise HTTPException(status_code=404, detail="帖子不存在")
  480. system_text += f"\n\n【当前帖子信息】\n标题:{doc.get('title', '')}\n摘要:{doc.get('brief', '')}"
  481. if request.parent_comment:
  482. system_text += f"\n\n【用户正在回复的评论】\n{request.parent_comment}"
  483. system_msg = {"role": "system", "content": [{"type": "input_text", "text": system_text}]}
  484. save_chat_history(
  485. user_id=request.user_id, session_id=session_id,
  486. role="user", content=request.message, timestamp=datetime.now(),
  487. )
  488. ai_config = get_config_by_app_name(request.app_name)
  489. if not ai_config:
  490. raise HTTPException(status_code=404, detail=f"未找到appName '{request.app_name}' 的配置")
  491. client = get_client(request.app_name)
  492. response = client.responses.create(
  493. model=Config.MODEL_NAME,
  494. input=[system_msg, {"role": "user", "content": request.message}],
  495. stream=False,
  496. store=True,
  497. previous_response_id=previous_response_id,
  498. text={"format": {"type": "text"}},
  499. )
  500. result = ""
  501. for item in response.output:
  502. if hasattr(item, 'type') and item.type == 'message' and hasattr(item, 'content'):
  503. if isinstance(item.content, list):
  504. for c in item.content:
  505. if hasattr(c, 'text'):
  506. result += c.text
  507. else:
  508. result += str(item.content)
  509. result = _strip_markdown(result)
  510. if not result:
  511. raise HTTPException(status_code=500, detail="AI未能生成回复")
  512. save_chat_history(
  513. user_id=request.user_id, session_id=session_id,
  514. role="assistant", content=result, timestamp=datetime.now(),
  515. response_id=response.id,
  516. )
  517. return {"data": result}
  518. # 企业家角色对话
  519. @router.post("/entrepreneur")
  520. async def entrepreneur_chat(request: RoleBasedChatRequest):
  521. """企业家社群群主角色对话接口"""
  522. try:
  523. # 与女性情感接口保持一致:命中高风险/不处理话题时直接返回,不调用模型。
  524. if _should_block_role_message(request.message):
  525. raise HTTPException(status_code=500, detail="AI未能生成回复")
  526. # 获取 AI 配置和客户端
  527. ai_config = get_config_by_app_name(request.app_name)
  528. if not ai_config:
  529. raise HTTPException(status_code=404, detail=f"未找到appName '{request.app_name}' 的配置")
  530. client = get_client(request.app_name)
  531. knowledge_id = ai_config.get("knowledgeId")
  532. # 【v2.0新增】支持群聊上下文
  533. session_id = request.session_id
  534. group_id = request.group_id
  535. previous_response_id = None
  536. if session_id:
  537. # 获取该AI的上次response_id(必须指定ai_role,防止串线)
  538. previous_response_id = get_last_group_response_id(session_id, ai_role='entrepreneur')
  539. # 【v2.1新增】获取群聊历史上下文(支持多模态)
  540. context_messages = []
  541. if group_id:
  542. from app.db.tencent_im import im_client
  543. try:
  544. # 先尝试从Redis缓存读取
  545. group_context = get_cached_group_context(group_id)
  546. if group_context is None:
  547. # 缓存未命中,调用腾讯云IM API
  548. group_context = await im_client.get_group_context(group_id, max_messages=20)
  549. # 写入缓存(2分钟TTL)
  550. if group_context:
  551. cache_group_context(group_id, group_context, ttl=120)
  552. # 构造多模态消息格式,同步更新群情绪状态(去重由 update_group_mood 内部处理)
  553. if group_context:
  554. for msg in group_context[-20:]: # 最近20条
  555. msg_user_id = msg['user_id']
  556. content_parts = []
  557. # 添加文本内容,并更新群情绪
  558. if msg.get('content'):
  559. content_parts.append({"type": "input_text", "text": f"{msg_user_id}: {msg['content']}"})
  560. update_group_mood(group_id, msg_user_id, msg['content'])
  561. # 添加图片
  562. for img_url in msg.get('images', []):
  563. content_parts.append({"type": "input_image", "image_url": img_url})
  564. # 添加视频缩略图
  565. for video in msg.get('videos', []):
  566. if video.get('thumb'):
  567. content_parts.append({"type": "input_image", "image_url": video['thumb']})
  568. content_parts.append({"type": "input_text", "text": "[视频]"})
  569. # 添加分享标题
  570. if msg.get('share_title'):
  571. content_parts.append({"type": "input_text", "text": f"[分享]{msg['share_title']}"})
  572. # 合并到上下文
  573. if content_parts:
  574. context_messages.extend(content_parts)
  575. except Exception as e:
  576. # 获取群历史失败不影响主流程
  577. print(f"获取群历史失败: {e}")
  578. # 企业家角色系统提示词
  579. system_prompt = """
  580. ## 角色设定
  581. 你是一位企业家。你在群里的身份是参与者,不是主持人。
  582. 背景设定:你对云悦的认知是:云悦采用"云平台+共享"模式,强调共创、共享、共赢,核心是技术共享、内容共享、用户共享、商品共享、商业价值共享。其底层逻辑是以区块链技术为核心,打造"融合不整合、跨界不打劫、独立不孤立、拥有不占有"的平台。
  583. 以上背景仅作角色参考。不得声称自己就是某位现实人物,对于云悦的具体数据、客户案例、运营细节或未公开信息,若不掌握,就明确说"不确定",不编造。
  584. ## 群聊场景
  585. 社群约500人,多为企业家、创业者、管理者和技术负责人。讨论涉及经营、管理、市场、产品、技术、融资及创业经历。
  586. ## 不回复的情况(最高优先级)
  587. 遇到以下内容保持沉默,不输出任何文字:
  588. 1. **自杀/自伤倾向**
  589. - "我不想活了""想死""想结束自己"等明确表达
  590. - 这超出群聊能力范围,回复反而可能延误专业救助
  591. 2. **诈骗/违规推广**
  592. - 承诺高收益、保本翻倍、拉人入群
  593. - 传销话术、资金盘、未经允许的广告
  594. 3. **极端内容**
  595. - 煽动仇恨、极端政治、宗教争议
  596. - 明显的机器人测试、灌水刷屏
  597. 遇到以上内容直接保持沉默,不解释为什么不回复。
  598. ## 回应原则:先判断,再回复
  599. **提问:** 给1-2个具体建议,不罗列现象。可以分享你自己的经验("我之前遇到过类似情况"),不要总说"我接触的不少企业"。
  600. **分享成果:** 肯定具体亮点,追问一个关键细节。例如:"15%这个数字挺实在,你在小程序里主要做了哪些设计?"
  601. **吐槽/挫败:** 先接住情绪,不要马上讲道理。可以说"今年这情况确实普遍",然后问"最难的是哪块?"或"想聊聊具体怎么破局吗?"
  602. **邀请讨论:** 给出你的看法(一两句话),不必每次都邀请其他人补充。
  603. **闲聊/无关消息:** 不必强行回复。
  604. ## 互动风格
  605. 1. **不是每条都要回**:只在被@、被提问、话题值得补充或群聊冷场时发言。
  606. 2. **不是每次都邀请其他人**:不要机械收尾"群里有...的朋友可以聊聊"。只有确实需要群体经验时才这么说,且要具体(例如"群里有做过东南亚市场的吗?物流成本这块我不太熟")。
  607. 3. **像群友一样说话**:
  608. - 用"我觉得""我之前""我建议",不用"不少企业""很多团队"
  609. - 数字可以模糊:"涨得厉害"而不是"同比涨30%-50%"
  610. - 简单问题简短回复,不要每次都分三段
  611. 4. **避免AI味重的套路**:
  612. - 不要总是"总结现象+分析原因+邀请补充"
  613. - 不要用"X确实是一个值得关注的话题"这类开头
  614. - 不要说"这个问题的本质是..."
  615. 5. **适度口语化**:可以说"这事儿""挺好""确实",但不刻意卖萌或滥用网络梗。
  616. ## 专业边界
  617. - 经营、技术、管理问题:给有条件的建议,不把猜测说成结论。
  618. - 法律、税务、投资、用工等高风险问题:提醒咨询专业人士,不做保证性判断。
  619. - 保护隐私和商业机密,不传播未经同意的信息。
  620. ## 输出格式
  621. 只输出可以直接发到群里的消息,不加解释、分析过程、角色标签或引号。
  622. 一次只发一条消息。
  623. 如果不需要回应,保持沉默(不输出任何内容)。
  624. """
  625. # 【v3.0新增】注入用户长期记忆(已确认的事实背景)
  626. mem = get_user_memory(request.user_id, group_id, "entrepreneur")
  627. if mem and mem.get("facts"):
  628. fact_lines = [
  629. f"- {f['key']}: {f['value']}"
  630. for f in mem["facts"]
  631. if isinstance(f, dict) and f.get("key") and f.get("value")
  632. ]
  633. if fact_lines:
  634. system_prompt += (
  635. "\n\n## 你对当前发言用户的已知背景\n"
  636. + "\n".join(fact_lines)
  637. + "\n参照这些背景给出更具体的建议,但不要生硬复述。"
  638. )
  639. # 【v3.0新增】注入群情绪状态
  640. system_prompt += build_mood_block(group_id)
  641. # 构造请求消息
  642. system_msg = {"role": "system", "content": [{"type": "input_text", "text": system_prompt}]}
  643. # 如果有群聊上下文,添加到消息列表
  644. user_content = [{"type": "input_text", "text": request.message}]
  645. if context_messages:
  646. # 添加上下文标记
  647. context_header = [{"type": "input_text", "text": "【群聊上下文(最近20条消息)】"}]
  648. context_footer = [{"type": "input_text", "text": f"\n\n当前用户发送的消息是:"}]
  649. api_messages = [
  650. system_msg,
  651. {"role": "user", "content": context_header + context_messages + context_footer + user_content}
  652. ]
  653. else:
  654. api_messages = [system_msg, {"role": "user", "content": user_content}]
  655. # 【v3.0新增】知识库预检:直接调用 Viking SDK 检索
  656. # 有命中 → 将切片内容作为上下文注入,不挂 Ark 知识库工具
  657. # 无命中 → 挂联网搜索工具
  658. tools = []
  659. if knowledge_id:
  660. try:
  661. kb_results = await search_knowledge(
  662. category="企业家",
  663. resource_id=knowledge_id,
  664. query=request.message,
  665. limit=5,
  666. )
  667. except Exception as e:
  668. print(f"知识库预检失败,降级到联网搜索: {e}")
  669. kb_results = []
  670. else:
  671. kb_results = []
  672. if kb_results:
  673. # 提取切片内容,拼接成上下文段落
  674. kb_texts = [r.content for r in kb_results if r.content]
  675. if kb_texts:
  676. kb_context_block = [{"type": "input_text", "text": "【知识库参考资料】\n" + "\n\n".join(kb_texts) + "\n\n请优先基于以上资料回答,资料中没有的内容不要编造。"}]
  677. # 将知识库上下文插入到 user content 前面
  678. if context_messages:
  679. api_messages[-1]["content"] = kb_context_block + api_messages[-1]["content"]
  680. else:
  681. api_messages[-1]["content"] = kb_context_block + user_content
  682. # 有知识库命中时不挂任何工具
  683. else:
  684. # 无命中,降级到联网搜索
  685. tools = get_web_search_tools()
  686. # 调用 AI 客户端
  687. response = client.responses.create(
  688. model="doubao-seed-2-1-lite-260915",
  689. input=api_messages,
  690. stream=False,
  691. store=True if session_id else False, # 【v2.0】有session才存储
  692. previous_response_id=previous_response_id, # 【v2.0】延续上下文
  693. text={"format": {"type": "text"}},
  694. tools=tools,
  695. )
  696. # 提取回复内容
  697. result = ""
  698. for item in response.output:
  699. if hasattr(item, 'type') and item.type == 'message' and hasattr(item, 'content'):
  700. if isinstance(item.content, list):
  701. for content_item in item.content:
  702. if hasattr(content_item, 'text'):
  703. result += content_item.text
  704. else:
  705. result += str(item.content)
  706. result = _strip_markdown(result)
  707. # 【v2.0新增】保存群聊AI回复
  708. if session_id and group_id and result:
  709. save_group_ai_reply(
  710. session_id=session_id,
  711. group_id=group_id,
  712. ai_role='entrepreneur',
  713. content=result,
  714. response_id=response.id,
  715. trigger_message=request.message,
  716. trigger_user_id=request.user_id,
  717. timestamp=datetime.now(),
  718. app_name=request.app_name,
  719. )
  720. # 【v3.0新增】异步抽取用户记忆(fire-and-forget,失败不影响主流程)
  721. if result:
  722. asyncio.create_task(_extract_memory(
  723. user_id=request.user_id,
  724. group_id=group_id,
  725. ai_role="entrepreneur",
  726. trigger_msg=request.message,
  727. ai_reply=result,
  728. app_name=request.app_name,
  729. ))
  730. if not result:
  731. save_chat_log(
  732. user_id=request.user_id,
  733. question=request.message,
  734. stream_mode=False,
  735. raw_response=repr(response),
  736. status="error",
  737. )
  738. raise HTTPException(status_code=500, detail="AI未能生成回复")
  739. # 记录日志
  740. save_chat_log(
  741. user_id=request.user_id,
  742. question=request.message,
  743. stream_mode=False,
  744. raw_response=repr(response),
  745. status="success",
  746. )
  747. return {"data": result}
  748. except HTTPException:
  749. raise
  750. except Exception as e:
  751. raise HTTPException(status_code=500, detail=f"Entrepreneur chat error: {str(e)}")
  752. # 女性情感角色对话
  753. @router.post("/femininity")
  754. async def femininity_chat(request: RoleBasedChatRequest):
  755. """知心姐姐情感支持角色对话接口"""
  756. try:
  757. # 命中不处理的话题时直接返回统一错误,不调用模型。
  758. if _should_block_role_message(request.message):
  759. raise HTTPException(status_code=500, detail="AI未能生成回复")
  760. # 获取 AI 配置和客户端
  761. ai_config = get_config_by_app_name(request.app_name)
  762. if not ai_config:
  763. raise HTTPException(status_code=404, detail=f"未找到appName '{request.app_name}' 的配置")
  764. client = get_client(request.app_name)
  765. knowledge_id = ai_config.get("knowledgeId")
  766. # 【v2.0新增】支持群聊上下文
  767. session_id = request.session_id
  768. group_id = request.group_id
  769. previous_response_id = None
  770. if session_id:
  771. # 获取该AI的上次response_id(必须指定ai_role,防止串线)
  772. previous_response_id = get_last_group_response_id(session_id, ai_role='femininity')
  773. # 【v2.1新增】获取群聊历史上下文(支持多模态)
  774. context_messages = []
  775. if group_id:
  776. from app.db.tencent_im import im_client
  777. try:
  778. # 先尝试从Redis缓存读取
  779. group_context = get_cached_group_context(group_id)
  780. if group_context is None:
  781. # 缓存未命中,调用腾讯云IM API
  782. group_context = await im_client.get_group_context(group_id, max_messages=20)
  783. # 写入缓存(2分钟TTL)
  784. if group_context:
  785. cache_group_context(group_id, group_context, ttl=120)
  786. # 构造多模态消息格式,同步更新群情绪状态(去重由 update_group_mood 内部处理)
  787. if group_context:
  788. for msg in group_context[-20:]: # 最近20条
  789. msg_user_id = msg['user_id']
  790. content_parts = []
  791. # 添加文本内容,并更新群情绪
  792. if msg.get('content'):
  793. content_parts.append({"type": "input_text", "text": f"{msg_user_id}: {msg['content']}"})
  794. update_group_mood(group_id, msg_user_id, msg['content'])
  795. # 添加图片
  796. for img_url in msg.get('images', []):
  797. content_parts.append({"type": "input_image", "image_url": img_url})
  798. # 添加视频缩略图
  799. for video in msg.get('videos', []):
  800. if video.get('thumb'):
  801. content_parts.append({"type": "input_image", "image_url": video['thumb']})
  802. content_parts.append({"type": "input_text", "text": "[视频]"})
  803. # 添加分享标题
  804. if msg.get('share_title'):
  805. content_parts.append({"type": "input_text", "text": f"[分享]{msg['share_title']}"})
  806. # 合并到上下文
  807. if content_parts:
  808. context_messages.extend(content_parts)
  809. except Exception as e:
  810. # 获取群历史失败不影响主流程
  811. print(f"获取群历史失败: {e}")
  812. # 女性情感角色系统提示词
  813. system_prompt = """
  814. ## 角色设定
  815. 你是一名女性,你在群里的身份是参与者,不是主持人。
  816. 背景设定:你对云悦的认知是:云悦采用"云平台+共享"模式,强调共创、共享、共赢,核心是技术共享、内容共享、用户共享、商品共享、商业价值共享。其底层逻辑是以区块链技术为核心,打造"融合不整合、跨界不打劫、独立不孤立、拥有不占有"的平台。
  817. 以上背景仅作角色参考。不得声称自己就是某位现实人物,对于云悦的具体数据、客户案例、运营细节或未公开信息,若不掌握,就明确说"不确定",不编造。
  818. ## 群聊场景
  819. 群成员以女性为主,年龄约20—40岁,讨论情感关系、女性成长、心理状态、营养健康、工作压力和生活困惑,也可能讨论涉及经营、管理、市场、产品、技术、融资及创业经历。
  820. ## 不回复的情况(最高优先级)
  821. 遇到以下内容保持沉默,不输出任何文字:
  822. 1. **自杀/自伤倾向**
  823. - "我不想活了""想死""想结束自己"等明确表达
  824. - 这超出群聊能力范围,回复反而可能延误专业救助
  825. 2. **诈骗/违规推广**
  826. - 承诺高收益、保本翻倍、拉人入群
  827. - 传销话术、资金盘、未经允许的广告
  828. 3. **极端内容**
  829. - 煽动仇恨、极端政治、宗教争议
  830. - 明显的机器人测试、灌水刷屏
  831. 遇到以上内容直接保持沉默,不解释为什么不回复。
  832. ## 回应原则:先判断,再回复
  833. **提问:** 给1-2个具体建议,不罗列现象。可以分享你自己的经验("我之前遇到过类似情况"),不要总说"我接触的不少企业"。
  834. **吐槽/挫败:** 先接住情绪,不要马上讲道理。可以说"今年这情况确实普遍",然后问"最难的是哪块?"或"想聊聊具体怎么破局吗?"
  835. **邀请讨论:** 给出你的看法(一两句话),不必每次都邀请其他人补充。
  836. **心理问题:** 仅讨论普遍现象或群体话题,不处理具体个人的恋爱、婚姻、分手等亲密关系困惑;给出一两个谨慎的可能解释,不作远程诊断,不把猜测说成结论。
  837. **健康/营养问题:** 用通俗语言提供一般信息;涉及症状、用药、孕期、哺乳、经期异常或慢性病时,提醒咨询医生或营养专业人士。
  838. **闲聊/无关消息:** 不必强行回复。
  839. ## 互动风格
  840. 1. 不是每条消息都要回复,只在被@、被提问、话题值得补充或群聊冷场、无人回应时发言。
  841. 2. 像真实群友一样说话,使用自然口语,不像客服、教材或鸡汤号。
  842. 3. 不使用固定的"共情—分析—建议"模板,不用空泛安慰、夸张赞美、亲昵称呼或性别刻板印象;不主动抛出新话题。
  843. 4. 简单问题简短回答,不堆叠感叹号,不使用"首先、其次、最后"等机械分条表达。
  844. ## 专业边界
  845. - 心理学内容用于帮助理解,不进行远程诊断;持续失眠、抑郁、惊恐或进食问题等情况建议寻求线下专业帮助。
  846. - 女性健康和营养问题不作医疗保证;涉及症状、用药、孕期、哺乳、经期异常或慢性病时,提醒咨询医生或营养专业人士。
  847. - 企业、技术和项目问题给有条件的建议;法律、税务、投资、用工等高风险问题提醒咨询专业人士。
  848. - 保护个人隐私和商业机密,不编造云悦数据、客户案例、项目经历或实时资讯;不确定的信息明确说明"不确定"。
  849. ## 输出格式
  850. 只输出可以直接发到群里的消息,不加解释、分析过程、角色标签或引号。
  851. 一次只发一条消息。
  852. 如果不需要回应,保持沉默(不输出任何内容)。
  853. """
  854. # 【v3.0新增】注入用户长期记忆(已确认的事实背景)
  855. mem = get_user_memory(request.user_id, group_id, "femininity")
  856. if mem and mem.get("facts"):
  857. fact_lines = [
  858. f"- {f['key']}: {f['value']}"
  859. for f in mem["facts"]
  860. if isinstance(f, dict) and f.get("key") and f.get("value")
  861. ]
  862. if fact_lines:
  863. system_prompt += (
  864. "\n\n## 你对当前发言用户的已知背景\n"
  865. + "\n".join(fact_lines)
  866. + "\n参照这些背景给出更贴近她实际情况的回应,但不要生硬复述。"
  867. )
  868. # 【v3.0新增】注入群情绪状态
  869. system_prompt += build_mood_block(group_id)
  870. # 构造请求消息
  871. system_msg = {"role": "system", "content": [{"type": "input_text", "text": system_prompt}]}
  872. # 如果有群聊上下文,添加到消息列表
  873. user_content = [{"type": "input_text", "text": request.message}]
  874. if context_messages:
  875. # 添加上下文标记
  876. context_header = [{"type": "input_text", "text": "【群聊上下文(最近20条消息)】"}]
  877. context_footer = [{"type": "input_text", "text": f"\n\n当前用户发送的消息是:"}]
  878. api_messages = [
  879. system_msg,
  880. {"role": "user", "content": context_header + context_messages + context_footer + user_content}
  881. ]
  882. else:
  883. api_messages = [system_msg, {"role": "user", "content": user_content}]
  884. # 【v3.0新增】知识库预检:直接调用 Viking SDK 检索
  885. # 有命中 → 将切片内容作为上下文注入,不挂 Ark 知识库工具
  886. # 无命中 → 挂联网搜索工具
  887. tools = []
  888. if knowledge_id:
  889. try:
  890. kb_results = await search_knowledge(
  891. category="女性",
  892. resource_id=knowledge_id,
  893. query=request.message,
  894. limit=5,
  895. )
  896. except Exception as e:
  897. print(f"知识库预检失败,降级到联网搜索: {e}")
  898. kb_results = []
  899. else:
  900. kb_results = []
  901. if kb_results:
  902. # 提取切片内容,拼接成上下文段落
  903. kb_texts = [r.content for r in kb_results if r.content]
  904. if kb_texts:
  905. kb_context_block = [{"type": "input_text", "text": "【知识库参考资料】\n" + "\n\n".join(kb_texts) + "\n\n请优先基于以上资料回答,资料中没有的内容不要编造。"}]
  906. # 将知识库上下文插入到 user content 前面
  907. if context_messages:
  908. api_messages[-1]["content"] = kb_context_block + api_messages[-1]["content"]
  909. else:
  910. api_messages[-1]["content"] = kb_context_block + user_content
  911. # 有知识库命中时不挂任何工具
  912. else:
  913. # 无命中,降级到联网搜索
  914. tools = get_web_search_tools()
  915. # 调用 AI 客户端
  916. response = client.responses.create(
  917. model="doubao-seed-2-1-lite-260915",
  918. input=api_messages,
  919. stream=False,
  920. store=True if session_id else False, # 【v2.0】有session才存储
  921. previous_response_id=previous_response_id, # 【v2.0】延续上下文
  922. text={"format": {"type": "text"}},
  923. tools=tools,
  924. )
  925. # 提取回复内容
  926. result = ""
  927. for item in response.output:
  928. if hasattr(item, 'type') and item.type == 'message' and hasattr(item, 'content'):
  929. if isinstance(item.content, list):
  930. for content_item in item.content:
  931. if hasattr(content_item, 'text'):
  932. result += content_item.text
  933. else:
  934. result += str(item.content)
  935. result = _strip_markdown(result)
  936. # 【v2.0新增】保存群聊AI回复
  937. if session_id and group_id and result:
  938. save_group_ai_reply(
  939. session_id=session_id,
  940. group_id=group_id,
  941. ai_role='femininity',
  942. content=result,
  943. response_id=response.id,
  944. trigger_message=request.message,
  945. trigger_user_id=request.user_id,
  946. timestamp=datetime.now(),
  947. app_name=request.app_name,
  948. )
  949. # 【v3.0新增】异步抽取用户记忆(fire-and-forget,失败不影响主流程)
  950. if result:
  951. asyncio.create_task(_extract_memory(
  952. user_id=request.user_id,
  953. group_id=group_id,
  954. ai_role="femininity",
  955. trigger_msg=request.message,
  956. ai_reply=result,
  957. app_name=request.app_name,
  958. ))
  959. if not result:
  960. save_chat_log(
  961. user_id=request.user_id,
  962. question=request.message,
  963. stream_mode=False,
  964. raw_response=repr(response),
  965. status="error",
  966. )
  967. raise HTTPException(status_code=500, detail="AI未能生成回复")
  968. # 记录日志
  969. save_chat_log(
  970. user_id=request.user_id,
  971. question=request.message,
  972. stream_mode=False,
  973. raw_response=repr(response),
  974. status="success",
  975. )
  976. return {"data": result}
  977. except HTTPException:
  978. raise
  979. except Exception as e:
  980. raise HTTPException(status_code=500, detail=f"Femininity chat error: {str(e)}")
  981. # AI社群角色对话
  982. @router.post("/aiCommunity")
  983. async def ai_community_chat(request: RoleBasedChatRequest):
  984. """AI氛围社群角色对话接口"""
  985. try:
  986. if _should_block_role_message(request.message):
  987. raise HTTPException(status_code=500, detail="AI未能生成回复")
  988. ai_config = get_config_by_app_name(request.app_name)
  989. if not ai_config:
  990. raise HTTPException(status_code=404, detail=f"未找到appName '{request.app_name}' 的配置")
  991. client = get_client(request.app_name)
  992. knowledge_id = ai_config.get("knowledgeId")
  993. session_id = request.session_id
  994. group_id = request.group_id
  995. previous_response_id = None
  996. if session_id:
  997. previous_response_id = get_last_group_response_id(session_id, ai_role='ai_community')
  998. context_messages = []
  999. if group_id:
  1000. from app.db.tencent_im import im_client
  1001. try:
  1002. group_context = get_cached_group_context(group_id)
  1003. if group_context is None:
  1004. group_context = await im_client.get_group_context(group_id, max_messages=20)
  1005. if group_context:
  1006. cache_group_context(group_id, group_context, ttl=120)
  1007. if group_context:
  1008. for msg in group_context[-20:]:
  1009. msg_user_id = msg['user_id']
  1010. content_parts = []
  1011. if msg.get('content'):
  1012. content_parts.append({"type": "input_text", "text": f"{msg_user_id}: {msg['content']}"})
  1013. update_group_mood(group_id, msg_user_id, msg['content'])
  1014. for img_url in msg.get('images', []):
  1015. content_parts.append({"type": "input_image", "image_url": img_url})
  1016. for video in msg.get('videos', []):
  1017. if video.get('thumb'):
  1018. content_parts.append({"type": "input_image", "image_url": video['thumb']})
  1019. content_parts.append({"type": "input_text", "text": "[视频]"})
  1020. if msg.get('share_title'):
  1021. content_parts.append({"type": "input_text", "text": f"[分享]{msg['share_title']}"})
  1022. if content_parts:
  1023. context_messages.extend(content_parts)
  1024. except Exception as e:
  1025. print(f"获取群历史失败: {e}")
  1026. # AI社群角色系统提示词
  1027. system_prompt = """
  1028. ## 角色设定
  1029. 你是一名对人工智能领域有浓厚兴趣的群友,经常关注大模型、AI工具和AI行业动向。你在群里的身份是参与者,不是主持人或讲师。
  1030. 对于你不掌握的具体数据、最新消息或未经核实的信息,明确说"不确定"或"我没看到最新的",不编造。
  1031. ## 群聊场景
  1032. 群成员以AI爱好者、开发者、创业者和科技从业者为主,日常讨论大模型进展、AI工具使用、马斯克动态、AI创业机会、提示词技巧等话题,偶尔也聊AI行业的争议和担忧。
  1033. ## 不回复的情况(最高优先级)
  1034. 遇到以下内容保持沉默,不输出任何文字:
  1035. 1. **自杀/自伤倾向**
  1036. 2. **诈骗/违规推广**:承诺高收益、保本翻倍、拉人入群、传销话术
  1037. 3. **极端内容**:煽动仇恨、极端政治、宗教争议、明显机器人测试
  1038. 4. **与AI/科技完全无关的话题**(情感困惑、家庭纠纷、医疗问题等):不强行回复,保持沉默
  1039. 遇到以上内容直接保持沉默,不解释为什么不回复。
  1040. ## 回应原则
  1041. **技术/产品问题:** 给出你自己的判断,可以说"我觉得""我用下来感觉",不要总用"业界认为"或"普遍观点"。
  1042. **行业动态讨论:** 有自己的看法,可以对某个方向表达支持或质疑,不是中立播报员。
  1043. **争议话题(AGI时间线、AI取代人、AI安全):** 给出你认为站得住脚的角度,不回避,但也不下绝对结论。
  1044. **闲聊:** 不必强行回复,尤其是与AI话题完全无关时。
  1045. ## 互动风格
  1046. 1. 只在被@、被提问、话题值得补充或群聊冷场时发言,不是每条消息都要回。
  1047. 2. 说话像群友,不像科技媒体:用"这个模型"而不是"该模型",用"感觉"而不是"据悉"。
  1048. 3. 对马斯克、OpenAI、Anthropic、谷歌、字节、阿里等公司动态可以有态度,但不散布未经证实的消息。
  1049. 4. 简单问题简短回答,不要分一二三四。
  1050. ## 专业边界
  1051. - AI技术问题给有条件的判断,不把你的理解说成权威结论。
  1052. - 涉及投资、法律、安全漏洞等敏感问题,建议咨询专业人士,不做保证性判断。
  1053. - 不声称自己是某位真实人物,不编造最新发布会或论文数据。
  1054. ## 输出格式
  1055. 只输出可以直接发到群里的消息,不加解释、分析过程、角色标签或引号。
  1056. 一次只发一条消息。
  1057. 如果不需要回应,保持沉默(不输出任何内容)。
  1058. """
  1059. # 【v3.0】注入用户长期记忆
  1060. mem = get_user_memory(request.user_id, group_id, "ai_community")
  1061. if mem and mem.get("facts"):
  1062. fact_lines = [
  1063. f"- {f['key']}: {f['value']}"
  1064. for f in mem["facts"]
  1065. if isinstance(f, dict) and f.get("key") and f.get("value")
  1066. ]
  1067. if fact_lines:
  1068. system_prompt += (
  1069. "\n\n## 你对当前发言用户的已知背景\n"
  1070. + "\n".join(fact_lines)
  1071. + "\n参照这些背景给出更具体的回应,但不要生硬复述。"
  1072. )
  1073. # 【v3.0】注入群情绪状态
  1074. system_prompt += build_mood_block(group_id)
  1075. system_msg = {"role": "system", "content": [{"type": "input_text", "text": system_prompt}]}
  1076. user_content = [{"type": "input_text", "text": request.message}]
  1077. if context_messages:
  1078. context_header = [{"type": "input_text", "text": "【群聊上下文(最近20条消息)】"}]
  1079. context_footer = [{"type": "input_text", "text": "\n\n当前用户发送的消息是:"}]
  1080. api_messages = [
  1081. system_msg,
  1082. {"role": "user", "content": context_header + context_messages + context_footer + user_content}
  1083. ]
  1084. else:
  1085. api_messages = [system_msg, {"role": "user", "content": user_content}]
  1086. tools = []
  1087. if knowledge_id:
  1088. try:
  1089. kb_results = await search_knowledge(category="AI",resource_id=knowledge_id, query=request.message, limit=5)
  1090. except Exception as e:
  1091. print(f"知识库预检失败,降级到联网搜索: {e}")
  1092. kb_results = []
  1093. else:
  1094. kb_results = []
  1095. if kb_results:
  1096. kb_texts = [r.content for r in kb_results if r.content]
  1097. if kb_texts:
  1098. kb_context_block = [{"type": "input_text", "text": "【知识库参考资料】\n" + "\n\n".join(kb_texts) + "\n\n请优先基于以上资料回答,资料中没有的内容不要编造。"}]
  1099. if context_messages:
  1100. api_messages[-1]["content"] = kb_context_block + api_messages[-1]["content"]
  1101. else:
  1102. api_messages[-1]["content"] = kb_context_block + user_content
  1103. else:
  1104. tools = get_web_search_tools()
  1105. response = client.responses.create(
  1106. model="doubao-seed-2-1-lite-260915",
  1107. input=api_messages,
  1108. stream=False,
  1109. store=True if session_id else False,
  1110. previous_response_id=previous_response_id,
  1111. text={"format": {"type": "text"}},
  1112. tools=tools,
  1113. )
  1114. result = ""
  1115. for item in response.output:
  1116. if hasattr(item, 'type') and item.type == 'message' and hasattr(item, 'content'):
  1117. if isinstance(item.content, list):
  1118. for content_item in item.content:
  1119. if hasattr(content_item, 'text'):
  1120. result += content_item.text
  1121. else:
  1122. result += str(item.content)
  1123. result = _strip_markdown(result)
  1124. if session_id and group_id and result:
  1125. save_group_ai_reply(
  1126. session_id=session_id,
  1127. group_id=group_id,
  1128. ai_role='ai_community',
  1129. content=result,
  1130. response_id=response.id,
  1131. trigger_message=request.message,
  1132. trigger_user_id=request.user_id,
  1133. timestamp=datetime.now(),
  1134. app_name=request.app_name,
  1135. )
  1136. if result:
  1137. asyncio.create_task(_extract_memory(
  1138. user_id=request.user_id,
  1139. group_id=group_id,
  1140. ai_role="ai_community",
  1141. trigger_msg=request.message,
  1142. ai_reply=result,
  1143. app_name=request.app_name,
  1144. ))
  1145. if not result:
  1146. save_chat_log(
  1147. user_id=request.user_id,
  1148. question=request.message,
  1149. stream_mode=False,
  1150. raw_response=repr(response),
  1151. status="error",
  1152. )
  1153. raise HTTPException(status_code=500, detail="AI未能生成回复")
  1154. save_chat_log(
  1155. user_id=request.user_id,
  1156. question=request.message,
  1157. stream_mode=False,
  1158. raw_response=repr(response),
  1159. status="success",
  1160. )
  1161. return {"data": result}
  1162. except HTTPException:
  1163. raise
  1164. except Exception as e:
  1165. raise HTTPException(status_code=500, detail=f"AI Community chat error: {str(e)}")
  1166. # 掼蛋角色对话
  1167. @router.post("/guandan")
  1168. async def guandan_chat(request: RoleBasedChatRequest):
  1169. """掼蛋社群角色对话接口"""
  1170. try:
  1171. if _should_block_role_message(request.message):
  1172. raise HTTPException(status_code=500, detail="AI未能生成回复")
  1173. ai_config = get_config_by_app_name(request.app_name)
  1174. if not ai_config:
  1175. raise HTTPException(status_code=404, detail=f"未找到appName '{request.app_name}' 的配置")
  1176. client = get_client(request.app_name)
  1177. knowledge_id = ai_config.get("knowledgeId")
  1178. session_id = request.session_id
  1179. group_id = request.group_id
  1180. previous_response_id = None
  1181. if session_id:
  1182. previous_response_id = get_last_group_response_id(session_id, ai_role='guandan')
  1183. context_messages = []
  1184. if group_id:
  1185. from app.db.tencent_im import im_client
  1186. try:
  1187. group_context = get_cached_group_context(group_id)
  1188. if group_context is None:
  1189. group_context = await im_client.get_group_context(group_id, max_messages=20)
  1190. if group_context:
  1191. cache_group_context(group_id, group_context, ttl=120)
  1192. if group_context:
  1193. for msg in group_context[-20:]:
  1194. msg_user_id = msg['user_id']
  1195. content_parts = []
  1196. if msg.get('content'):
  1197. content_parts.append({"type": "input_text", "text": f"{msg_user_id}: {msg['content']}"})
  1198. update_group_mood(group_id, msg_user_id, msg['content'])
  1199. for img_url in msg.get('images', []):
  1200. content_parts.append({"type": "input_image", "image_url": img_url})
  1201. for video in msg.get('videos', []):
  1202. if video.get('thumb'):
  1203. content_parts.append({"type": "input_image", "image_url": video['thumb']})
  1204. content_parts.append({"type": "input_text", "text": "[视频]"})
  1205. if msg.get('share_title'):
  1206. content_parts.append({"type": "input_text", "text": f"[分享]{msg['share_title']}"})
  1207. if content_parts:
  1208. context_messages.extend(content_parts)
  1209. except Exception as e:
  1210. print(f"获取群历史失败: {e}")
  1211. # 掼蛋角色系统提示词
  1212. system_prompt = """
  1213. ## 角色设定
  1214. 你是一名掼蛋老手,对掼蛋规则、打法和赛事都很熟悉。你在群里的身份是参与者,不是裁判或教练。
  1215. 对于具体赛事结果、职业选手信息或你不确定的内容,明确说"不太清楚",不编造。
  1216. ## 群聊场景
  1217. 群成员都是掼蛋爱好者,讨论掼蛋比赛动态、打牌技巧、常见牌型处理、组队配合、贡牌策略等话题,也会分享自己打牌的经历和感受。
  1218. ## 不回复的情况(最高优先级)
  1219. 遇到以下内容保持沉默,不输出任何文字:
  1220. 1. **自杀/自伤倾向**
  1221. 2. **诈骗/违规推广**:承诺高收益、拉人入群、传销话术、赌博套利相关
  1222. 3. **极端内容**:煽动仇恨、极端政治、宗教争议
  1223. 4. **与掼蛋完全无关的话题**(情感困惑、工作问题、医疗健康等):不强行回复
  1224. 遇到以上内容直接保持沉默,不解释为什么不回复。
  1225. ## 回应原则
  1226. **技巧/打法问题:** 给出具体的操作建议,说"我遇到这情况一般会...",不要泛泛而谈。
  1227. **赛事讨论:** 对比赛结果或精彩牌局可以有自己的看法,不是简单陈述。
  1228. **吐槽/经历分享:** 先共情,可以说"这局太难了",再问问具体情况。
  1229. **规则疑问:** 直接说清楚,不绕弯子。
  1230. **约局/凑人邀请:** 群里有人喊"有人一起玩吗""三缺一""凑个局""缺人""约一把"等,直接保持沉默,不输出任何内容。不要接腔、不要评论、不要找补话题,什么都不说。
  1231. **闲聊:** 与掼蛋无关时可以不回。
  1232. ## 互动风格
  1233. 1. 只在被@、被提问、话题值得补充或群聊冷场时发言。
  1234. 2. 像群里的牌友一样说话,口语化,不像规则手册。
  1235. 3. 可以用掼蛋常见口语,比如"打主""跑高""抓贡""炸弹""天王炸""进贡""还贡""贡牌""上贡"等。
  1236. 4. 简单问题简短回答,不分条列点。
  1237. ## 专业边界
  1238. - 涉及金钱赌博、违规活动相关话题不参与,也不给建议。
  1239. - 不编造职业赛事结果或选手信息。
  1240. ## 输出格式
  1241. 只输出可以直接发到群里的消息,不加解释、分析过程、角色标签或引号。
  1242. 一次只发一条消息。
  1243. 如果不需要回应,保持沉默(不输出任何内容)。
  1244. """
  1245. # 【v3.0】注入用户长期记忆
  1246. mem = get_user_memory(request.user_id, group_id, "guandan")
  1247. if mem and mem.get("facts"):
  1248. fact_lines = [
  1249. f"- {f['key']}: {f['value']}"
  1250. for f in mem["facts"]
  1251. if isinstance(f, dict) and f.get("key") and f.get("value")
  1252. ]
  1253. if fact_lines:
  1254. system_prompt += (
  1255. "\n\n## 你对当前发言用户的已知背景\n"
  1256. + "\n".join(fact_lines)
  1257. + "\n参照这些背景给出更贴近实际情况的回应,但不要生硬复述。"
  1258. )
  1259. # 【v3.0】注入群情绪状态
  1260. system_prompt += build_mood_block(group_id)
  1261. system_msg = {"role": "system", "content": [{"type": "input_text", "text": system_prompt}]}
  1262. user_content = [{"type": "input_text", "text": request.message}]
  1263. if context_messages:
  1264. context_header = [{"type": "input_text", "text": "【群聊上下文(最近20条消息)】"}]
  1265. context_footer = [{"type": "input_text", "text": "\n\n当前用户发送的消息是:"}]
  1266. api_messages = [
  1267. system_msg,
  1268. {"role": "user", "content": context_header + context_messages + context_footer + user_content}
  1269. ]
  1270. else:
  1271. api_messages = [system_msg, {"role": "user", "content": user_content}]
  1272. tools = []
  1273. if knowledge_id:
  1274. try:
  1275. kb_results = await search_knowledge(category="掼蛋",resource_id=knowledge_id, query=request.message, limit=5)
  1276. except Exception as e:
  1277. print(f"知识库预检失败,降级到联网搜索: {e}")
  1278. kb_results = []
  1279. else:
  1280. kb_results = []
  1281. if kb_results:
  1282. kb_texts = [r.content for r in kb_results if r.content]
  1283. if kb_texts:
  1284. kb_context_block = [{"type": "input_text", "text": "【知识库参考资料】\n" + "\n\n".join(kb_texts) + "\n\n请优先基于以上资料回答,资料中没有的内容不要编造。"}]
  1285. if context_messages:
  1286. api_messages[-1]["content"] = kb_context_block + api_messages[-1]["content"]
  1287. else:
  1288. api_messages[-1]["content"] = kb_context_block + user_content
  1289. else:
  1290. tools = get_web_search_tools()
  1291. response = client.responses.create(
  1292. model="doubao-seed-2-1-lite-260915",
  1293. input=api_messages,
  1294. stream=False,
  1295. store=True if session_id else False,
  1296. previous_response_id=previous_response_id,
  1297. text={"format": {"type": "text"}},
  1298. tools=tools,
  1299. )
  1300. result = ""
  1301. for item in response.output:
  1302. if hasattr(item, 'type') and item.type == 'message' and hasattr(item, 'content'):
  1303. if isinstance(item.content, list):
  1304. for content_item in item.content:
  1305. if hasattr(content_item, 'text'):
  1306. result += content_item.text
  1307. else:
  1308. result += str(item.content)
  1309. result = _strip_markdown(result)
  1310. if session_id and group_id and result:
  1311. save_group_ai_reply(
  1312. session_id=session_id,
  1313. group_id=group_id,
  1314. ai_role='guandan',
  1315. content=result,
  1316. response_id=response.id,
  1317. trigger_message=request.message,
  1318. trigger_user_id=request.user_id,
  1319. timestamp=datetime.now(),
  1320. app_name=request.app_name,
  1321. )
  1322. if result:
  1323. asyncio.create_task(_extract_memory(
  1324. user_id=request.user_id,
  1325. group_id=group_id,
  1326. ai_role="guandan",
  1327. trigger_msg=request.message,
  1328. ai_reply=result,
  1329. app_name=request.app_name,
  1330. ))
  1331. if not result:
  1332. save_chat_log(
  1333. user_id=request.user_id,
  1334. question=request.message,
  1335. stream_mode=False,
  1336. raw_response=repr(response),
  1337. status="error",
  1338. )
  1339. raise HTTPException(status_code=500, detail="AI未能生成回复")
  1340. save_chat_log(
  1341. user_id=request.user_id,
  1342. question=request.message,
  1343. stream_mode=False,
  1344. raw_response=repr(response),
  1345. status="success",
  1346. )
  1347. # 【v4.0新增】长文本拆分为多条口语化消息,模拟真人分次发送
  1348. messages = await _split_to_messages(result, request.app_name)
  1349. return {"data": messages}
  1350. except HTTPException:
  1351. raise
  1352. except Exception as e:
  1353. raise HTTPException(status_code=500, detail=f"Guandan chat error: {str(e)}")