feat(market): skills/mcp 能力分类 + AI验证期自动识别 + 导入自动入座

This commit is contained in:
QihuAI Design committed 2026-07-17 12:09:42 +08:00
1 parent 2ae5d9f9b5
commit 6f3fb400a4
7 files changed
+1407 -1140

No files matched your search

+80 -6
View File
@@ -14,10 +14,11 @@ import os
from ..database import get_db
from ..models import (
User, MarketApp, AppPurchase, AppReview, AppInstallation, Order,
AdBanner, MCPServer, gen_uuid
AdBanner, MCPServer, ModelProvider, gen_uuid
)
from ..auth import get_current_user, get_current_user_id, get_current_user_id_optional
from ..middleware.log_service import LogService
from ..capability_registry import CAPABILITY_TYPES, resolve_auto_model as _resolve_auto_model
from app.api.admin.mcp_admin import _test_mcp_server, parse_mcp_config
router = APIRouter()
@@ -206,6 +207,53 @@ def verify_agent_connectivity(platform: str, endpoint: str, api_key: str) -> dic
return {"ok": False, "verified": False, "detail": f"地址不可达:{_short_err(e)}"}
# ===== 能力分类(AI 自动归类,任何失败都返回 None,不阻断导入) =====
def _classify_capability(name: str, description: Optional[str], content: Optional[str], db: Session) -> Optional[str]:
"""用平台文本模型把应用归类到 11 种能力之一;失败返回 None(不阻断导入)。"""
try:
text_model = _resolve_auto_model("text", db)
if not text_model:
return None
provider = None
if getattr(text_model, "provider_id", None):
provider = db.query(ModelProvider).filter(ModelProvider.id == text_model.provider_id).first()
api_key = text_model.api_key or (provider.api_key if provider else None)
if not api_key:
return None
api_url = text_model.endpoint or (
f"{provider.api_base_url}/v1/chat/completions" if provider and provider.api_base_url else None
)
if not api_url:
return None
options = "、".join(f"{c['id']}({c['label']})" for c in CAPABILITY_TYPES)
sys_prompt = (
"你是应用市场的能力分类器。根据应用名称、描述与内容,把它精确归类到下列能力之一:"
f"{options}。\n只返回一个 JSON 对象:{{\"capability\": \"<id>\"}},不要任何额外解释或 Markdown。"
)
user_text = f"名称:{name}\n描述:{description or ''}\n内容摘要:{(content or '')[:2000]}"
messages = [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": user_text},
]
proxy = _skill_http_proxy()
with httpx.Client(timeout=25, follow_redirects=True, trust_env=False, proxy=proxy) as c:
r = c.post(
api_url,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={"model": text_model.name, "messages": messages, "max_tokens": 64, "temperature": 0, "enable_thinking": False},
)
if r.status_code != 200:
return None
data = r.json()
text_out = data.get("choices", [{}])[0].get("message", {}).get("content", "")
m = re.search(r'"capability"\s*:\s*"([a-z_]+)"', text_out)
if m and m.group(1) in {c["id"] for c in CAPABILITY_TYPES}:
return m.group(1)
return None
except Exception:
return None
class VerifyAgentRequest(BaseModel):
platform: Optional[str] = None
endpoint: str
@@ -222,6 +270,25 @@ def verify_agent(
return {"code": 0, "data": result, "message": "ok"}
class DetectCapabilityRequest(BaseModel):
name: str
description: Optional[str] = None
skill_content: Optional[str] = None
type: Optional[str] = None
@router.post("/apps/detect-capability")
def detect_capability(
req: DetectCapabilityRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""导入前 AI 验证阶段:根据名称/描述/内容识别能力分类,供前台预填并允许用户修改。"""
cap = _classify_capability(req.name, req.description, req.skill_content, db)
label = next((c["label"] for c in CAPABILITY_TYPES if c["id"] == cap), None)
return {"code": 0, "data": {"capability": cap, "label": label}, "message": "ok"}
@router.get("/ads")
def list_public_ads(db: Session = Depends(get_db)):
"""
@@ -260,6 +327,7 @@ class UploadAppRequest(BaseModel):
download_url: Optional[str] = None
skill_content: Optional[str] = None
category: Optional[str] = None
capability: Optional[str] = None
tags: Optional[str] = None
is_member_free: Optional[bool] = False
join_promotion: Optional[bool] = False
@@ -281,7 +349,7 @@ class ReviewAppRequest(BaseModel):
def list_apps(
type: Optional[str] = Query(None),
source: Optional[str] = Query(None),
category: Optional[str] = Query(None),
capability: Optional[str] = Query(None),
search: Optional[str] = Query(None),
sort: str = Query(default="latest"),
page: int = Query(default=1, ge=1),
@@ -295,8 +363,8 @@ def list_apps(
query = query.filter(MarketApp.type == type)
if source:
query = query.filter(MarketApp.source == source)
if category:
query = query.filter(MarketApp.type == category)
if capability:
query = query.filter(MarketApp.capability == capability)
if search:
query = query.filter(
and_(
@@ -346,6 +414,7 @@ def list_apps(
"rating_avg": float(app.rating_avg) if app.rating_avg else 0,
"review_count": app.review_count or 0,
"created_at": str(app.created_at) if app.created_at else None,
"capability": app.capability or None,
"installed": (app.id in installed_app_ids or is_user_owned) if user_id else False,
}
result.append(item)
@@ -400,8 +469,9 @@ def get_app(
"rating_avg": float(app.rating_avg) if app.rating_avg else 0,
"review_count": app.review_count or 0,
"publisher_id": app.publisher_id,
"download_url": app.download_url,
"detail": app.detail,
"download_url": getattr(app, "download_url", None),
"detail": getattr(app, "detail", None),
"capability": app.capability or None,
"created_at": str(app.created_at),
"purchased": purchased,
"installed": installed,
@@ -456,6 +526,9 @@ def upload_app(
created_at=datetime.now(),
updated_at=datetime.now(),
)
# 能力分类:前端显式指定优先;否则导入阶段由 AI 自动归类;都不行则留空(不阻断导入)
_classify_content = req.skill_content or req.download_url or ""
app.capability = req.capability or _classify_capability(req.name, req.description, _classify_content, db) or None
db.add(app)
db.commit()
db.refresh(app)
@@ -536,6 +609,7 @@ def upload_app(
"code": 0,
"data": {
"id": app.id, "name": app.name, "status": app.status,
"capability": app.capability,
"created_at": str(app.created_at),
},
"message": "提交成功,等待审核",