# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig). # Source of truth: scripts/phase1_extract.py. Do not hand-merge back. from fastapi import APIRouter, Depends, HTTPException, Query, Response from sqlalchemy.orm import Session from sqlalchemy import and_, desc, asc, func from pydantic import BaseModel, Field from typing import Optional import uuid from datetime import datetime, timezone, timedelta import re import json import asyncio import math import httpx import os import logging from ..database import get_db, SessionLocal from ..models import ( User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, ConversationSummary, KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill, EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation, MCPServer, gen_uuid ) from ..auth import get_current_user from ..platform_knowledge import get_platform_knowledge from ..mcp_client import list_mcp_tools, call_mcp_tool from ..middleware.log_service import LogService from ..capability_registry import resolve_auto_model from app.api.chat_skill import (_bm25_search) from app.kb_router import kb_router_search from app.api.chat_utils import (logger) async def _retrieve_kb_context(db: Session, kb_ids: list, query: str, max_tokens: int = 1000) -> str: """ Stage C: 增强版知识库检索 — 使用BM25排序替代朴素拼接 1. 获取每个KB的文档 2. 构建文档列表(content+title+metadata) 3. BM25检索最相关的段落 4. 拼接为上下文注入system prompt 同时叠加 Dify 外部知识库(RAG)检索结果。 """ local_context = "" if kb_ids: kbs = db.query(KnowledgeBase).filter(KnowledgeBase.id.in_(kb_ids)).all() if kbs: # 收集所有文档段落 # Stage C.1: 优先使用 KnowledgeDocumentChunk 表的预分段数据 all_segments = [] for kb in kbs: # 先查chunk表 chunks = db.query(KnowledgeDocumentChunk).filter( KnowledgeDocumentChunk.knowledge_base_id == kb.id ).order_by(KnowledgeDocumentChunk.chunk_index).limit(30).all() if chunks: # 使用预分段数据 for chunk in chunks: if chunk.content and len(chunk.content.strip()) >= 20: all_segments.append({ "content": "[{}/{}/段{}]: {}".format( kb.name, chunk.document_id[:8], chunk.chunk_index, chunk.content[:500] ), "raw_content": chunk.content, "kb_name": kb.name, }) else: # 退化: 使用原始文档分段 docs = db.query(KnowledgeDocument).filter( KnowledgeDocument.knowledge_base_id == kb.id, KnowledgeDocument.status == "completed" ).order_by(KnowledgeDocument.updated_at.desc()).limit(20).all() for doc in docs: content = doc.content or "" if not content: continue # 如果文档较长, 分段处理 (每段~800字) if len(content) > 800: paragraphs = re.split(r'\n\n+', content) for pi, para in enumerate(paragraphs): if len(para.strip()) >= 20: all_segments.append({ "content": "[{}/{}/段{}]: {}".format( kb.name, doc.name, pi+1, para.strip()[:500] ), "raw_content": para.strip(), "kb_name": kb.name, "doc_name": doc.name, }) else: all_segments.append({ "content": "[{}/{}]: {}".format(kb.name, doc.name, content[:500]), "raw_content": content, "kb_name": kb.name, "doc_name": doc.name, }) if not all_segments: # 退化: 使用原始朴素拼接 kb_names = ", ".join([k.name for k in kbs]) local_context = f"参考知识库: {kb_names}" else: # BM25检索最相关的段落 # 混合检索:BM25(关键词) + 向量语义 + RRF 融合;失败回退 BM25 bm25_segs = _bm25_search(query, all_segments, top_k=8) bm25_for_router = [ {"content": seg.get("raw_content", seg.get("content", "")), "metadata": {"label": seg.get("content", "")}} for seg in bm25_segs ] merged = None try: merged = await kb_router_search( query, kb_ids=kb_ids, top_k=8, bm25_results=bm25_for_router, db_session=db, use_vector=True, use_external=False, ) except Exception as e: logger.warning(f"[kb] 混合检索失败, 回退 BM25: {e}") if merged: ctx_parts = [] used = 0 for seg in merged: label = (seg.get("metadata") or {}).get("label") c = label if label else seg.get("content", "") t = _estimate_tokens(c) if ctx_parts and used + t > max_tokens: break ctx_parts.append(c) used += t local_context = "\n".join(ctx_parts) else: ctx_parts = [seg["content"] for seg in bm25_segs] joined = "\n".join(ctx_parts) if _estimate_tokens(joined) > max_tokens: trunc, u = [], 0 for c in ctx_parts: if u + _estimate_tokens(c) > max_tokens: break trunc.append(c) u += _estimate_tokens(c) joined = "\n".join(trunc) + "\n...(内容截断)" local_context = joined # 叠加 Dify 外部知识库(RAG)检索 dify_context = "" try: from app.dify_kb import get_dify_kb_context dify_context = get_dify_kb_context(db, query, top_k=5) except Exception as e: logger.warning(f"Dify KB context retrieval failed: {e}") parts = [] if local_context: parts.append(local_context) if dify_context: parts.append("【Dify 知识库检索结果】\n" + dify_context) return "\n\n".join(parts) async def _ingest_answer_to_dify(conv_id: str, msg_id: str, content: str): """后台任务:将助手回答入库到 Dify 知识库(量化后可供 RAG 检索复用)。""" try: from app.dify_kb import aingest_text from app.database import SessionLocal from sqlalchemy import text as _t db = SessionLocal() try: row = db.execute(_t( "SELECT api_url, api_key, config FROM external_kb_services " "WHERE service_type='dify' AND is_enabled=1 LIMIT 1" )).fetchone() finally: db.close() if not row: return api_url, api_key, config_raw = row dataset_name = None if config_raw: try: import json cfg = config_raw if isinstance(config_raw, dict) else json.loads(config_raw) dataset_name = cfg.get("dataset_name") except Exception: pass await aingest_text(api_url, api_key, content, name=f"chat_{conv_id}_{msg_id}", dataset_name=dataset_name) except Exception as e: logger.warning(f"后台入库 Dify 失败: {e}") def _build_system_prompt( skills: list, agents: list, kb_context: str, user_content: str, mcp: list = None, mcp_functions: list = None, auto_discovered: bool = False, all_mcp: list = None, ) -> tuple: """ 动态组装system prompt + function definitions。 返回: (system_prompt_str, function_definitions_list) """ system_parts = ["你是AIFlow平台的AI助手,具备专业的知识储备和工具调用能力。"] # 平台知识库注入(始终开启):确保AI优先使用平台已有能力 try: pk = get_platform_knowledge() if pk: system_parts.append(f"【平台知识库——优先使用以下知识回答用户问题】\n{pk}") except Exception: pass # 回复格式要求:强制使用Markdown system_parts.append("回复格式要求:使用 Markdown 格式,层次分明即可。对于简短问题,用 1-2 段自然文字直接回答,不必强行分点、加标题或表格;仅在确实需要分步骤或列举时才使用列表,代码/配置用 ``` 代码块包裹。避免冗余空行,保持紧凑、美观、易读。") function_definitions = [] # Stage B.1: Agent角色注入(Issue 2:让用户询问 agent 能力时,AI 能“看到”该 agent 资料) if agents: for agent in agents: name = agent.get("name", "") role = agent.get("role_prompt", "") instructions = agent.get("instructions", "") desc = agent.get("description", "") block = [] if name: block.append( f"用户已选择智能体「{name}」。当用户询问该智能体的功能、能力、用法或相关问题时," f"请基于以下资料准确作答,不要说“不知道这个智能体”。" ) if role: block.append(f"该智能体的角色设定: {role}") if desc: block.append(f"该智能体的简介: {desc}") if instructions: block.append(f"该智能体的行为指引: {instructions}") if block: system_parts.append("\n".join(block)) # Stage A.2: Skill工具注入 if skills: skill_descs = ", ".join([f"{s['name']}({s.get('category', 'general')})" for s in skills]) system_parts.append(f"可用技能工具: {skill_descs}") if auto_discovered: # Issue 8:每轮默认先寻找本账号已安装且平台已部署的技能,多个适用时 AI 自行判断选 1 个最优 system_parts.append( "重要规则: 以上技能为本账号已安装且平台已部署的技能。每轮对话请先判断用户请求是否可由其中某个技能处理;" "若多个技能都适用,请自行判断并选择最贴切的 1 个优先调用,不要一次性全部调用。" "调用对应工具后将获得执行结果,基于结果生成最终回复。若这些技能均不适用,则直接基于你的知识回答。" ) else: system_parts.append( "重要规则:\n" "1) 当用户请求需要由该技能【执行】某项任务时,优先通过 function calling 调用对应工具;调用后基于工具返回结果生成回复。\n" "2) 当用户询问所选技能【本身的介绍、功能、用途、用法、特点】(例如“介绍一下这个技能”“它是什么”“怎么用”“能做什么”)时," "必须基于下方【已选技能资料】中的名称/描述/指令直接作答,不要泛泛介绍平台整体,也不要说“不知道这个技能”。\n" "3) 若用户问题既不执行也不询问该技能本身,则按常规方式回答。" ) # 为每个skill构建function definition for s in skills: # 从skill_content提取指令注入system prompt instructions = s.get("instructions", "") name = s.get("name", "") if not auto_discovered: # 显式选中技能:注入可引用的技能资料,便于“介绍/说明”类问题据此作答 ref_parts = [f"【已选技能资料】名称:{name}"] desc = s.get("description", "") if desc: ref_parts.append(f"简介:{desc}") if instructions and len(instructions) < 1200: ref_parts.append(f"使用指令:{instructions}") system_parts.append("\n".join(ref_parts)) elif instructions and len(instructions) < 800: system_parts.append(f"技能「{name}」的详细指令: {instructions}") # 构建参数schema params = s.get("parameters", {}) if isinstance(params, dict) and len(params) > 0: properties = {} required = [] for pname, pdef in params.items(): if isinstance(pdef, dict): properties[pname] = { "type": pdef.get("type", "string"), "description": pdef.get("description", f"传递给{s['name']}的{pname}参数"), } if pdef.get("required", True): required.append(pname) else: properties[pname] = {"type": "string", "description": f"传递给{s['name']}的{pname}参数"} required.append(pname) if not properties: properties = {"input": {"type": "string", "description": f"需要传递给{s['name']}的完整用户输入"}} required = ["input"] else: properties = {"input": {"type": "string", "description": f"需要传递给{s['name']}的完整用户输入"}} required = ["input"] desc = s.get("description", "") category = s.get("category", "general") func_desc = f"调用技能「{s['name']}」处理用户请求。使用场景: {desc}。当用户请求涉及{category}类任务时优先调用此工具。" function_definitions.append({ "name": s["func_name"], "description": func_desc, "parameters": { "type": "object", "properties": properties, "required": required, }, }) # KB context injection if kb_context: system_parts.append(f"参考以下知识库内容回答用户问题:\n{kb_context}") # 平台全量 MCP 清单(Issue:让用户询问"平台有哪些 MCP"时 AI 能准确列举,而非凭空编造) if all_mcp: _mcp_inventory = "\n".join([f"- {m.get('name','')}: {m.get('description','') or '(未填写描述)'}" for m in all_mcp]) system_parts.append( f"【平台已安装的 MCP 应用清单(共 {len(all_mcp)} 个,均为平台真实接入并启用)】\n{_mcp_inventory}\n" "当用户询问“平台有哪些 MCP / 接入了哪些 MCP / 有哪些工具服务”时,请严格依据以上清单准确列举名称与功能," "不要编造或遗漏,也不要把未列出的服务说成已接入。" ) # MCP 应用注入(Issue 3:让用户询问 MCP 能力时,AI 能“看到”该 MCP 的资料与地址) if mcp: mcp_lines = [] for m in mcp: name = m.get("name", "") desc = m.get("description", "") transport = m.get("transport", "stdio") endpoint = m.get("endpoint", "") line = f"用户已选择 MCP 应用「{name}」(传输方式: {transport})" if desc: line += f",功能简介: {desc}" if endpoint: line += f",服务地址: {endpoint}" mcp_lines.append(line) system_parts.append("\n".join(mcp_lines)) system_parts.append( "用户已选择上述 MCP 应用。当请求需要借助外部工具/系统(如查询数据、调用 API、操作第三方服务)时," "请优先参考这些 MCP 应用的能力来回答;若平台已接入对应工具,优先通过工具调用完成;" "不要说“不了解该 MCP”。" ) # 高优先级覆盖规则:显式选中工具时,回答必须基于该工具资料 if (skills and not auto_discovered) or (mcp): system_parts.append( "【最高优先级覆盖规则】\n" "用户在本轮已明确选择了工具(见上文【已选技能资料】或【用户已选择 MCP 应用】)。\n" "如果用户的问题是在询问这些已选工具/技能/应用本身的介绍、功能、能做什么、怎么用、特点或用法," "你必须只基于上文提供的该工具具体资料作答;\n" "不要引用《平台知识库》中关于技能市场、MCP 协议或平台整体能力的通用描述;" "不要列举其它未选中的技能或 MCP 作为示例;也不要说“不知道”或“无法回答”。\n" "若用户问题与已选工具本身无关,则按常规方式回答。" ) system_prompt = "\n".join(system_parts) # MCP function definitions 注入(让 LLM 可通过 function calling 真实调用 MCP 工具) if mcp_functions: function_definitions.extend(mcp_functions) return system_prompt, function_definitions def _get_conversation_context(db: Session, conv_id: str) -> dict: """ Stage B.2: 获取对话的持久化上下文 对话级别的agent/skill/kb选择保存在Conversation字段中。 """ conv = db.query(Conversation).filter(Conversation.id == conv_id).first() if not conv: return {} # Conversation目前没有存储agent/skill/kb的JSON字段 # 我们从第一条消息中获取(如果存在), 或返回空 # 后续可通过数据库迁移添加Conversation.context字段 # 查询该对话中最早一条含有skill_ids/agent_ids/knowledge_ids的消息 first_tool_msg = db.query(Message).filter( Message.conversation_id == conv_id, Message.role == "user", ).order_by(asc(Message.created_at)).first() context = {} if first_tool_msg: if first_tool_msg.skill_ids: context["skill_ids"] = first_tool_msg.skill_ids if first_tool_msg.agent_ids: context["agent_ids"] = first_tool_msg.agent_ids if first_tool_msg.knowledge_ids: context["knowledge_base_ids"] = first_tool_msg.knowledge_ids if getattr(first_tool_msg, "mcp_ids", None): context["mcp_ids"] = first_tool_msg.mcp_ids return context # ===== Phase 2 上下文引擎升级:分层记忆 + 压缩 + 隔离 ===== logger = logging.getLogger(__name__) _tiktoken_enc = None def _estimate_tokens(text): """精确 token 估算:优先 tiktoken(cl100k_base),失败回退 2 字符/token。""" if not text: return 0 global _tiktoken_enc if _tiktoken_enc is None: try: import tiktoken _tiktoken_enc = tiktoken.get_encoding("cl100k_base") except Exception: _tiktoken_enc = False if _tiktoken_enc: try: return len(_tiktoken_enc.encode(text)) except Exception: pass return max(1, len(text) // 2) def _build_chat_history(db, conv_id, max_tokens=6000, max_messages=40, exclude_latest_user=True, include_summary=True): """会话层上下文引擎:预算感知的历史组装 + 可选压缩摘要前置。 - 若 conversation_summaries 存在摘要,以 system 角色「对话摘要」前置(压缩层)。 - 按 token 估算从新到旧累加,超预算即截断(隔离:不溢出上下文窗口)。 - exclude_latest_user=True 时丢弃最新一条 user 消息(当前轮次另行发送)。 """ summary_block = None if include_summary: try: summ = db.query(ConversationSummary).filter( ConversationSummary.conversation_id == conv_id ).first() if summ and summ.summary_text: summary_block = { "role": "system", "content": "【对话历史摘要,请据此理解用户此前的意图与已确认结论】\n" + summ.summary_text, } except Exception: pass rows = db.query(Message).filter( Message.conversation_id == conv_id, Message.role.in_(["user", "assistant"]), ).order_by(desc(Message.created_at)).limit(max_messages).all() if exclude_latest_user: for i, m in enumerate(rows): if m.role == "user": rows = rows[:i] + rows[i + 1:] break kept = [] used = 0 for m in rows: # 新->旧 t = _estimate_tokens(m.content or "") if kept and used + t > max_tokens: break kept.append({"role": m.role, "content": m.content}) used += t kept.reverse() # 时间正序 out = [] if summary_block: out.append(summary_block) out.extend(kept) return out async def _summarize_via_llm(prompt): """调用默认文本模型生成对话摘要;失败返回 None(压缩为可选项,不阻塞)。""" try: from ..capability_registry import resolve_auto_model from app.api.chat_utils import _proxy_for_url sdb = SessionLocal() try: ai_model = resolve_auto_model("text", sdb) if not ai_model: return None provider = sdb.query(ModelProvider).filter( ModelProvider.id == ai_model.provider_id ).first() api_key = ai_model.api_key or (provider.api_key if provider else None) if not provider or not provider.api_base_url or not api_key: return None api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions" body = { "model": ai_model.name, "messages": [ {"role": "system", "content": "你是专业的对话摘要助手,输出简洁中文摘要。"}, {"role": "user", "content": prompt}, ], "max_tokens": 512, "temperature": 0.2, } async with httpx.AsyncClient(proxy=_proxy_for_url(api_url), trust_env=False, timeout=60) as client: resp = await client.post( api_url, headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, json=body, ) if resp.status_code != 200: return None data = resp.json() return data.get("choices", [{}])[0].get("message", {}).get("content", "").strip() finally: sdb.close() except Exception as e: logger.warning(f"[context] summarize llm failed: {e}") return None async def _maybe_compress_history(conv_id): """后台异步压缩:将受保护窗口外的旧消息摘要落库 conversation_summaries。 失败静默回滚,绝不阻塞用户本次响应(fire-and-forget)。 """ sdb = None try: sdb = SessionLocal() all_msgs = sdb.query(Message).filter( Message.conversation_id == conv_id, Message.role.in_(["user", "assistant"]), ).order_by(asc(Message.created_at)).all() PROTECTED = 20 if len(all_msgs) <= PROTECTED: return compressible = all_msgs[: len(all_msgs) - PROTECTED] if len(compressible) < 8: return chunk = compressible[:30] text = "\n".join(f"{m.role}: {m.content}" for m in chunk) existing = sdb.query(ConversationSummary).filter( ConversationSummary.conversation_id == conv_id ).first() prior = existing.summary_text if existing else "" prompt = ( "请把以下对话片段浓缩为简明摘要,保留用户核心诉求、已确认结论、关键事实与待办。" "若提供『已有摘要』,请合并而非重复。\n\n" f"已有摘要:\n{prior}\n\n待摘要片段:\n{text}" ) summary = await _summarize_via_llm(prompt) if not summary: return if existing: existing.summary_text = summary existing.token_count = _estimate_tokens(summary) existing.updated_at = datetime.now() else: sdb.add(ConversationSummary( conversation_id=conv_id, summary_text=summary, token_count=_estimate_tokens(summary), updated_at=datetime.now(), )) sdb.commit() except Exception as e: logger.warning(f"[context] compress history failed conv={conv_id}: {e}") if sdb is not None: try: sdb.rollback() except Exception: pass finally: if sdb is not None: try: sdb.close() except Exception: pass