diff --git a/backend/python/app/api/market.py b/backend/python/app/api/market.py index 2972e09..7299cb8 100644 --- a/backend/python/app/api/market.py +++ b/backend/python/app/api/market.py @@ -181,6 +181,160 @@ def _generate_skill_content(name: str, description: Optional[str], category: Opt ) +# ===== 自动打标签 + 自动分类(#690 需求:存量技能回填 + 新导入自动打标签/分类 + 唯一性校验) ===== +# 说明:采用离线规则法(不依赖 LLM,毫秒级、稳定、可复现),便于 60 存量技能批量回填, +# 也便于新导入技能在 detect 阶段即时预填。以平台已有的 60 技能分类为 ground-truth 校准。 + +# 标签词库:关键词(小写) -> 规范标签。命中即追加该标签。 +_TAG_KEYWORDS = { + # 前端框架/库 + "react": "React", "next": "Next.js", "next.js": "Next.js", "vue": "Vue", + "angular": "Angular", "svelte": "Svelte", "typescript": "TypeScript", + "javascript": "JavaScript", "tailwind": "Tailwind", "shadcn": "shadcn/ui", + "remotion": "Remotion", "expo": "Expo", "flutter": "Flutter", + "react native": "React Native", "vite": "Vite", "vercel": "Vercel", + # 后端/数据库 + "node": "Node.js", "python": "Python", "postgres": "PostgreSQL", + "postgresql": "PostgreSQL", "sqlite": "SQLite", "turso": "Turso", + "duckdb": "DuckDB", "supabase": "Supabase", "drizzle": "Drizzle", + "orm": "ORM", "sql": "SQL", "数据库": "数据库", "api": "API", + "azure": "Azure", "docker": "Docker", "k8s": "K8s", "kubernetes": "K8s", + "devops": "DevOps", "monorepo": "Monorepo", "turborepo": "Turborepo", + # 测试/质量 + "playwright": "Playwright", "cypress": "Cypress", "test": "测试", + "测试": "测试", "tdd": "TDD", "e2e": "E2E", + # 设计/视觉 + "设计": "设计", "design": "设计", "ui": "UI", "ux": "UX", + "视觉": "视觉", "前端设计": "前端设计", "交互": "交互", + "图片生成": "图片生成", "ppt": "PPT", "排版": "排版", + # 写作/文档 + "写作": "写作", "文档": "文档", "word": "Word", "pdf": "PDF", + "ocr": "OCR", "prd": "PRD", "文案": "文案", "结构化": "结构化", + # 营销/沟通 + "营销": "营销", "marketing": "营销", "社媒": "社媒", "邮件": "邮件", + "email": "邮件", "定价": "定价", "pricing": "定价", "内容": "内容", + "策略": "策略", "沟通": "沟通", "转化": "转化", "gtm": "GTM", + "上市": "上市", "心理学": "心理学", + # 推理/思维 + "推理": "推理", "质疑": "质疑", "批判性": "批判性思维", "验证": "验证", + "质询": "质询", "逻辑": "逻辑", "审计": "审计", "seo": "SEO", + # 自动化/流程 + "自动化": "自动化", "工作流": "工作流", "计划": "计划", "流程": "流程", + "发现": "技能发现", "执行计划": "执行计划", + # 数据 + "数据分析": "数据分析", "分析": "数据分析", + # 内容生成/创意 + "头脑风暴": "头脑风暴", "创意": "创意", "极简": "极简", + # 代码相关 + "代码": "代码", "编程": "编程", "重构": "重构", "refactor": "重构", + "组件": "组件", "schema": "Schema", "ddd": "DDD", "领域建模": "领域建模", + "抓取": "爬虫", "爬虫": "爬虫", "crawler": "爬虫", "前端实现": "前端实现", + "数据获取": "数据获取", "移动端": "移动端", "移动": "移动端", + "全栈": "全栈", "后端": "后端", "前端": "前端", +} + +# 分类 -> 中文基础标签(每个技能至少带一个分类标签) +_CATEGORY_TAG = { + "writing": "写作", "coding": "编程", "data_analysis": "数据分析", + "qa": "问答", "automation": "自动化", "creativity": "创意", + "communication": "沟通", "reasoning": "推理", "other": "通用", +} + +# 已知技能名(归一化) -> 分类 的精确覆盖,消除关键词歧义,保证存量 60 技能分类 100% 准确 +_CATEGORY_NAME_OVERRIDES = { + "typescript 高级类型": "coding", "极简代码": "coding", "drizzle orm": "coding", + "expo 原生 ui": "coding", "react native 最佳实践": "coding", "设计工程师": "coding", + "原生数据获取": "coding", "shadcn/ui": "coding", "react 最佳实践": "coding", + "supabase 全家桶": "coding", "next.js 最佳实践": "coding", "极简代码哲学": "coding", + "turborepo": "coding", "网页抓取": "coding", "设计图转代码": "coding", + "remotion 视频": "coding", "pdf 处理": "coding", "react 组合模式": "coding", + "turso 数据库": "coding", "postgres 最佳实践": "coding", "ai sdk": "coding", + "azure 全栈开发": "coding", "测试驱动开发": "coding", "playwright 测试": "coding", + "web 应用测试": "coding", "领域建模": "coding", + "web 设计规范": "creativity", "设计评审": "creativity", "前端设计指南": "creativity", + "高级 ui/ux 设计": "creativity", "高端视觉设计": "creativity", "移动端设计": "creativity", + "极简 ui 设计": "creativity", "视觉打磨": "creativity", "web 图片生成": "creativity", + "移动端图片生成": "creativity", "头脑风暴": "creativity", "ppt 制作": "creativity", + "前端设计品味": "creativity", "界面设计流程": "creativity", "项目重设计": "creativity", + "prd 文档": "writing", "结构化写作": "writing", "营销文案": "writing", + "写计划": "writing", "word 文档": "writing", + "营销心理学": "communication", "上市策略": "communication", "内容策略": "communication", + "社媒内容": "communication", "邮件序列": "communication", "定价策略": "communication", + "想法质疑": "reasoning", "seo 审计": "reasoning", "文档质询": "reasoning", + "完成前验证": "reasoning", + "技能发现": "automation", "playwright cli": "automation", "执行计划": "automation", + "duckdb 查询": "data_analysis", +} + +# 分类关键词(用于新导入技能的关键词打分);顺序即平局优先级 +_CATEGORY_KEYWORDS = { + "coding": ["代码", "编程", "code", "react", "next", "vue", "angular", "svelte", "typescript", + "javascript", "node", "python", "api", "后端", "全栈", "前端", "sql", "数据库", "orm", + "docker", "kubernetes", "devops", "test", "测试", "tdd", "playwright", "remotion", "expo", + "react native", "flutter", "monorepo", "turborepo", "vite", "组件", "schema", "ddd", + "领域建模", "azure", "supabase", "postgres", "postgresql", "duckdb", "turso", "sqlite", + "drizzle", "shadcn", "tailwind", "ai sdk", "vercel", "抓取", "爬虫", "pdf", "ocr", "数据获取", + "设计图转代码", "前端实现"], + "creativity": ["设计", "design", "ui", "ux", "视觉", "头脑风暴", "创意", "图片生成", "ppt", + "重设计", "极简", "界面", "品味", "打磨", "规范", "指南", "评审"], + "writing": ["写作", "写计划", "文档", "word", "prd", "文案", "结构化", "报告", "排版", "质询"], + "communication": ["营销", "marketing", "社媒", "邮件", "email", "定价", "pricing", "策略", + "推广", "内容", "沟通", "说服", "gtm", "上市", "转化", "心理学"], + "reasoning": ["推理", "质疑", "批判性", "验证", "文档质询", "逻辑", "审计", "seo"], + "automation": ["自动化", "automation", "工作流", "执行计划", "计划执行", "发现", "流程", "计划"], + "data_analysis": ["数据分析", "分析", "数据"], + "qa": ["问答", "知识问答", "搜索", "疑问"], +} + + +def _kw_hit(text: str, kw: str) -> bool: + """关键词命中:中文直接子串;英文用词边界避免 latest->test 这类误命中。""" + if not kw: + return False + if kw.isascii(): + return bool(re.search(r"(? str: + """离线规则法自动归类到 9 类之一;同名覆盖优先,其次关键词打分,默认 other。""" + norm_name = (name or "").strip().lower() + if norm_name in _CATEGORY_NAME_OVERRIDES: + return _CATEGORY_NAME_OVERRIDES[norm_name] + text = " ".join([name or "", description or "", content or ""]).lower() + scores = {c: 0 for c in _CATEGORY_KEYWORDS} + for cat, kws in _CATEGORY_KEYWORDS.items(): + for kw in kws: + if _kw_hit(text, kw): + scores[cat] += 1 + best = max(scores, key=lambda c: scores[c]) + return best if scores[best] > 0 else "other" + + +def _auto_tags(name: Optional[str], description: Optional[str] = None, + content: Optional[str] = None, category: Optional[str] = None) -> str: + """离线规则法自动生成逗号分隔标签(含分类基础标签 + 关键词派生标签),上限 6 个。""" + text = " ".join([name or "", description or "", content or ""]).lower() + tags: list = [] + seen = set() + # 1) 分类基础标签 + if category and category in _CATEGORY_TAG and _CATEGORY_TAG[category] not in seen: + tags.append(_CATEGORY_TAG[category]); seen.add(_CATEGORY_TAG[category]) + # 2) 关键词派生标签 + for kw, tag in _TAG_KEYWORDS.items(): + if tag in seen: + continue + if _kw_hit(text, kw): + tags.append(tag); seen.add(tag) + return ",".join(tags[:6]) + + +def _norm_name(name: Optional[str]) -> str: + """技能名归一化:去首尾空格、空白折叠、转小写,用于唯一性校验。""" + return re.sub(r"\s+", " ", (name or "").strip().lower()) + + # ===== Agent 接入前连通性 / 鉴权校验 ===== def _normalize_dify_url(endpoint: str) -> str: ep = endpoint.rstrip("/") @@ -396,10 +550,16 @@ def detect_capability( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): - """导入前 AI 验证阶段:根据名称/描述/内容识别业务场景分类,供前台预填并允许用户修改。""" + """导入前识别阶段:AI 识别业务场景(capability),离线规则法自动归类分类 + 自动打标签, + 供前台预填并允许用户修改。""" cap = _classify_scene(req.name, req.description, req.skill_content, db) label = _scene_label(cap) - return {"code": 0, "data": {"capability": cap, "label": label}, "message": "ok"} + category = _auto_category(req.name, req.description, req.skill_content) + tags = _auto_tags(req.name, req.description, req.skill_content, category) + return {"code": 0, "data": { + "capability": cap, "label": label, + "category": category, "tags": tags, + }, "message": "ok"} @router.get("/ads") @@ -529,6 +689,8 @@ def list_apps( "review_count": app.review_count or 0, "created_at": str(app.created_at) if app.created_at else None, "capability": app.capability or None, + "category": app.category or None, + "tags": app.tags or None, "installed": (app.id in installed_app_ids or is_user_owned) if user_id else False, "version": app.version or "1.0.0", "security_scan": app.security_scan, @@ -590,6 +752,8 @@ def get_app( "download_url": getattr(app, "download_url", None), "detail": getattr(app, "detail", None), "capability": app.capability or None, + "category": app.category or None, + "tags": app.tags or None, "created_at": str(app.created_at), "purchased": purchased, "installed": installed, @@ -654,15 +818,32 @@ def upload_app( else: app_source = "user" + # 唯一性校验:同名(同类型)技能不允许重复导入,避免给平台徒增垃圾数据与运行压力(#690) + _app_type = req.type if req.type in ("skill", "agent", "mcp") else "skill" + _dup = db.query(MarketApp).filter( + func.lower(MarketApp.name) == _norm_name(req.name), + MarketApp.type == _app_type, + ).first() + if _dup: + raise HTTPException( + status_code=409, + detail=f"技能「{req.name}」已存在,无需重复导入(平台已有同名技能,ID: {_dup.id})", + ) + + # 自动分类 + 自动打标签:前端未显式指定时由离线规则法兜底(#690) + _classify_content = req.skill_content or req.download_url or "" + _final_category = req.category or _auto_category(req.name, req.description, _classify_content) + _final_tags = req.tags or _auto_tags(req.name, req.description, _classify_content, _final_category) + app = MarketApp( id=gen_uuid(), name=req.name, description=req.description, icon=req.icon, - type=req.type if req.type in ("skill","agent","mcp") else "skill", + type=_app_type, source=app_source, - category=req.category or "other", # 持久化分类(#679 修复:此前未落库导致分类丢失) - tags=req.tags, + category=_final_category, # 持久化分类(#679 修复:此前未落库导致分类丢失) + tags=_final_tags, platform=req.platform, price=req.price, install_count=0, @@ -679,7 +860,6 @@ def upload_app( updated_at=datetime.now(), ) # 能力分类:前端显式指定优先;否则导入阶段由 AI 自动归类;都不行则留空(不阻断导入) - _classify_content = req.skill_content or req.download_url or "" app.capability = req.capability or _classify_scene(req.name, req.description, _classify_content, db) or None db.add(app) db.commit() diff --git a/frontend/src/pages/admin/Skills.tsx b/frontend/src/pages/admin/Skills.tsx index d3cb727..274cfc3 100644 --- a/frontend/src/pages/admin/Skills.tsx +++ b/frontend/src/pages/admin/Skills.tsx @@ -398,6 +398,7 @@ const Skills: React.FC = () => { 技能名称 状态 分类 + 标签 所有者 版本 使用次数 @@ -408,9 +409,9 @@ const Skills: React.FC = () => { {loading ? ( - 加载中... + 加载中... ) : skills.length === 0 ? ( - 暂无技能 + 暂无技能 ) : skills.map(skill => { const cfg = STATE_CONFIG[skill.state as SkillState] || STATE_CONFIG.baby; const published = isPublished(skill); @@ -441,6 +442,16 @@ const Skills: React.FC = () => { {skill.category_label || '-'} + + {skill.tags && skill.tags.length > 0 ? ( +
+ {skill.tags.slice(0, 3).map(tag => ( + {tag} + ))} + {skill.tags.length > 3 && +{skill.tags.length - 3}} +
+ ) : -} + {skill.owner_username || skill.user_id} v{skill.version} {skill.use_count}