Files
qihuaiflow/backend/python/app/api/chat_skill.py
T

1596 lines
20 KiB
Python

# 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,
"orchestration": ag.orchestration,
})
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