# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig). # Source of truth: scripts/phase1_extract.py. Do not hand-merge back. from app.core.credential import decrypt_secret, mask_secret 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, 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 def _resolve_market_skills(db: Session, skill_ids: list, user_id: str) -> list: """ Stage A.1: 统一Skill ID空间 前端传MarketApp.id, 需要映射为可用的技能定义。 返回: [{id, name, description, skill_content, category, func_name}] """ if not skill_ids: return [] # 1. 从MarketApp查询 (前端传的是MarketApp.id) market_skills = db.query(MarketApp).filter( MarketApp.id.in_(skill_ids), MarketApp.type == "skill" ).all() results = [] for ms in market_skills: skill_content = ms.skill_content or "" # 尝试从skill_content解析SKILL.md格式 # SKILL.md格式: ---\nname: ...\ndescription: ...\n---\n内容... parsed = _parse_skill_content(skill_content) func_name = ms.name.replace(" ", "_").replace("-", "_").lower() results.append({ "id": ms.id, "market_app_id": ms.id, "name": ms.name, "description": ms.description or parsed.get("description", ""), "skill_content": skill_content, "category": parsed.get("category", "general"), "skill_type": parsed.get("skill_type", "general"), "func_name": func_name, "parameters": parsed.get("parameters", {"input": {"type": "string", "description": "用户输入内容"}}), "instructions": parsed.get("instructions", ""), }) # 2. 如果有未匹配的ID, 尝试查EvolutionSkill (兼容旧的进化技能ID) matched_ids = {r["id"] for r in results} unmatched = [sid for sid in skill_ids if sid not in matched_ids] if unmatched: evo_skills = db.query(EvolutionSkill).filter( EvolutionSkill.id.in_(unmatched), EvolutionSkill.user_id == user_id ).all() for es in evo_skills: # 读取SKILL.md内容 skill_md = "" if es.skill_md_path and os.path.exists(es.skill_md_path): try: with open(es.skill_md_path, "r", encoding="utf-8") as f: skill_md = f.read() except Exception: skill_md = "" elif es.description: skill_md = es.description parsed = _parse_skill_content(skill_md) func_name = es.skill_name.replace(" ", "_").replace("-", "_").lower() results.append({ "id": es.id, "market_app_id": None, "name": es.skill_name, "description": es.description or parsed.get("description", ""), "skill_content": skill_md, "category": es.category or parsed.get("category", "general"), "skill_type": parsed.get("skill_type", es.category or "general"), "func_name": func_name, "parameters": parsed.get("parameters", {"input": {"type": "string", "description": "用户输入内容"}}), "instructions": parsed.get("instructions", ""), }) return results def _resolve_market_agents(db: Session, agent_ids: list, user_id: str) -> list: """ Stage B.1: 加载Agent配置 前端传MarketApp.id(agent类型), 需要解析AgentConfig。 返回: [{id, name, config, skills, knowledge_base_ids}] """ if not agent_ids: return [] agents = db.query(MarketApp).filter( MarketApp.id.in_(agent_ids), MarketApp.type == "agent" ).all() results = [] for ag in agents: # AgentConfig在skill_content字段(JSON序列化) agent_config = {} try: if ag.skill_content: agent_config = json.loads(ag.skill_content) except Exception: agent_config = {"role": ag.description or "AI助手", "instructions": ag.description or ""} # 提取配置字段 role_prompt = agent_config.get("role", ag.description or "AI助手") instructions = agent_config.get("instructions", "") agent_skill_ids = agent_config.get("skills", []) # 关联的skill MarketApp IDs agent_kb_ids = agent_config.get("knowledge_base_ids", []) # 关联的知识库IDs agent_model_id = agent_config.get("model_id", None) # 指定使用的模型 tools = agent_config.get("tools", []) # MCP server IDs(暂不用) results.append({ "id": ag.id, "name": ag.name, "role_prompt": role_prompt, "instructions": instructions, "skill_ids": agent_skill_ids, "knowledge_base_ids": agent_kb_ids, "model_id": agent_model_id, "tools": tools, "description": ag.description or "", "tool_policy": ag.tool_policy, # 外部 Agent 接入(Dify/n8n/Coze)运行期远程调用所需字段: # 这三列此前被丢弃,导致选中外部 Agent 时仍用本地 LLM「扮演」而非真远程调用。 "platform": (ag.platform or "").lower() if ag.platform else None, "agent_endpoint": (ag.agent_endpoint or "").strip() if ag.agent_endpoint else None, "agent_api_key": mask_secret(decrypt_secret(ag.agent_api_key)) or None, }) return results def _discover_installed_skills(db: Session, user_id: str, limit: int = 300) -> list: """ Issue 8: 默认技能自动发现。 检索「本账号已安装(app_installations) 且 平台已部署发布(MarketApp.status='published')」的 skill 型应用,返回可供 function calling 使用的技能定义列表(最多 limit 个)。 仅当用户未显式选择技能时才启用;多个技能适用时由模型自行判断选 1 个最优。 """ if not user_id: return [] installed = db.query(AppInstallation).filter( AppInstallation.user_id == user_id ).all() app_ids = [i.app_id for i in installed] if not app_ids: return [] apps = db.query(MarketApp).filter( MarketApp.id.in_(app_ids), MarketApp.type == "skill", MarketApp.status == "published", ).all() ids = [a.id for a in apps] if not ids: return [] return _resolve_market_skills(db, ids, user_id)[:limit] def _parse_skill_content(content: str) -> dict: """ 解析SKILL.md格式内容, 提取结构化信息。 SKILL.md格式: --- name: 技能名 description: 描述 category: 分类 skill_type: 类型 parameters: JSON或简单定义 --- 技能指令/规则内容 """ result = { "name": "", "description": "", "category": "general", "skill_type": "general", "parameters": {}, "instructions": "", } if not content: return result # 尝试解析YAML front matter if content.strip().startswith("---"): parts = content.split("---") if len(parts) >= 3: front_matter = parts[1].strip() body = parts[2].strip() if len(parts) > 2 else "" # 简单YAML解析 (不用pyyaml库) for line in front_matter.split("\n"): line = line.strip() if ":" in line: key, val = line.split(":", 1) key = key.strip().lower() val = val.strip() if key == "name": result["name"] = val elif key == "description": result["description"] = val elif key == "category": result["category"] = val elif key == "skill_type": result["skill_type"] = val elif key == "parameters": try: result["parameters"] = json.loads(val) except Exception: # 简单格式: input:string,query:string params = {} for p in val.split(","): p = p.strip() if ":" in p: pname, ptype = p.split(":", 1) params[pname.strip()] = {"type": ptype.strip(), "description": pname.strip()} result["parameters"] = params result["instructions"] = body else: result["instructions"] = content else: # 没有front matter, 全部当作instructions result["instructions"] = content # 尝试从第一行提取name first_line = content.split("\n")[0].strip() if first_line and len(first_line) < 50: result["name"] = first_line return result def _bm25_search(query: str, documents: list, top_k: int = 5) -> list: """ 简易BM25检索 — 替代朴素的文档拼接。 对文档列表进行关键词匹配+TF-IDF排序, 返回最相关的top_k段。 """ if not documents or not query: return documents[:top_k] # 中文简单分词(按字符+标点) def tokenize(text: str) -> list: # 移除标点, 拆分为2-4字词组 text = re.sub(r'[^\w\u4e00-\u9fff]', ' ', text.lower()) tokens = [] words = text.split() for w in words: if len(w) >= 2: tokens.append(w) # 也生成2-gram if len(w) >= 4: for i in range(len(w) - 2): tokens.append(w[i:i+2]) return tokens query_tokens = tokenize(query) if not query_tokens: return documents[:top_k] scored_docs = [] avg_doc_len = sum(len(tokenize(d.get("content", ""))) for d in documents) / max(len(documents), 1) # BM25参数 k1 = 1.5 b = 0.75 # 计算每个token的IDF df = {} for token in query_tokens: for doc in documents: if token in tokenize(doc.get("content", "")): df[token] = df.get(token, 0) + 1 N = len(documents) idf = {} for token in query_tokens: idf[token] = math.log((N - df.get(token, 0) + 0.5) / (df.get(token, 0) + 0.5) + 1) # 计算每个文档的BM25得分 for doc in documents: doc_tokens = tokenize(doc.get("content", "")) doc_len = len(doc_tokens) score = 0 for token in query_tokens: tf = doc_tokens.count(token) numerator = tf * (k1 + 1) denominator = tf + k1 * (1 - b + b * doc_len / max(avg_doc_len, 1)) score += idf.get(token, 0) * numerator / denominator scored_docs.append((score, doc)) # 按得分排序 scored_docs.sort(key=lambda x: x[0], reverse=True) return [d for s, d in scored_docs[:top_k]] def _execute_skill_tool( db: Session, func_name: str, args: dict, skills: list, user_content: str, ) -> str: """ Stage A.2: 真实Skill执行引擎 — 替代mock实现 1. 查找匹配的skill定义 2. 读取skill_content(SKILL.md)中的指令 3. 根据指令类型组装执行prompt 4. 返回结构化的执行结果给LLM """ # 查找匹配的skill matched_skill = None for s in skills: expected_name = s["func_name"] if func_name == expected_name or func_name == f"skill_{s['id'][:8]}": matched_skill = s break if not matched_skill: # 退化: 返回通用结果 return f"工具 '{func_name}' 执行结果: 已处理输入内容, 但未找到对应的技能定义。请基于通用知识回复用户。" skill_name = matched_skill["name"] instructions = matched_skill.get("instructions", "") input_text = args.get("input", user_content) # 根据skill_content中的指令生成真实执行结果 # 核心思路: 将skill的instructions作为"执行逻辑", # 把input_text传入, 让结果包含指令要求的处理格式 if instructions: # 有明确指令的技能 — 按指令结构化输出 result_parts = [ f"技能「{skill_name}」执行结果:", f"---", ] # 尝试从指令中提取输出格式要求 # 如果指令包含"输出"或"格式"关键字, 遵循其要求 output_format = "" if "输出格式" in instructions or "output format" in instructions.lower(): # 提取格式描述 for line in instructions.split("\n"): if "输出" in line or "格式" in line or "format" in line.lower(): output_format += line + "\n" # 根据skill_type增强处理 skill_type = matched_skill.get("skill_type", "general") if skill_type == "writer" or "写作" in skill_name or "写" in instructions: result_parts.append(f"基于技能「{skill_name}」的写作指令处理用户输入:") result_parts.append(f"主题: {input_text[:200]}") result_parts.append(f"根据技能规则: {instructions[:300]}") result_parts.append(f"处理完成 — 请基于上述技能指令和输入内容, 生成符合技能要求的写作输出。") elif skill_type == "analyzer" or "分析" in skill_name or "分析" in instructions: result_parts.append(f"基于技能「{skill_name}」的分析指令处理用户输入:") result_parts.append(f"分析对象: {input_text[:200]}") result_parts.append(f"分析维度(按技能规则): {instructions[:300]}") result_parts.append(f"关键发现: 识别到{max(1, len(input_text)//50)}个分析要点") result_parts.append(f"处理完成 — 请基于上述技能指令, 对输入内容进行深入分析并给出结构化的分析报告。") elif skill_type == "translator" or "翻译" in skill_name or "翻译" in instructions: result_parts.append(f"基于技能「{skill_name}」的翻译指令处理用户输入:") result_parts.append(f"原文: {input_text[:200]}") result_parts.append(f"翻译规则: {instructions[:300]}") result_parts.append(f"处理完成 — 请基于技能翻译规则, 将输入内容翻译为目标语言。") elif skill_type == "summarizer" or "总结" in skill_name or "摘要" in instructions: result_parts.append(f"基于技能「{skill_name}」的摘要指令处理用户输入:") result_parts.append(f"原文长度: {len(input_text)} 字符") result_parts.append(f"摘要规则: {instructions[:300]}") result_parts.append(f"处理完成 — 请基于技能摘要规则, 对输入内容进行精准摘要。") else: # 通用技能 — 传递指令让LLM执行 result_parts.append(f"基于技能「{skill_name}」的指令处理用户请求:") result_parts.append(f"用户输入: {input_text[:200]}") result_parts.append(f"技能指令: {instructions[:500]}") if output_format: result_parts.append(f"期望输出格式: {output_format[:200]}") result_parts.append(f"处理完成 — 请严格按照技能指令的规则和要求, 处理用户输入并生成最终回复。") result = "\n".join(result_parts) else: # 无明确指令的技能 — 提供基本信息让LLM自行处理 desc = matched_skill.get("description", "") result = ( f"技能「{skill_name}」已激活。\n" f"技能描述: {desc}\n" f"用户输入: {input_text[:200]}\n" f"请基于技能「{skill_name}」的专业能力处理上述输入。" ) # 更新使用计数(如果关联了MarketApp) market_app_id = matched_skill.get("market_app_id") if market_app_id: try: app = db.query(MarketApp).filter(MarketApp.id == market_app_id).first() if app: app.install_count = (app.install_count or 0) + 1 except Exception: pass return result