feat: 技能提议管理(skill_proposals表+管理API+列表UI+界面)+agent自省propose_skill工具

This commit is contained in:
yumoqing 2026-08-16 19:16:52 +08:00
parent f19562a360
commit 08f628dba1
5 changed files with 289 additions and 0 deletions

View File

@ -0,0 +1,29 @@
{
"summary": [
{
"name": "skill_proposals",
"title": "技能提议表agent自省提出skill化建议+人工创建,生命周期管理)",
"primary": ["id"],
"catelog": "entity"
}
],
"fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "name", "title": "技能名", "type": "str", "length": 100, "nullable": "no"},
{"name": "description", "title": "技能描述", "type": "str", "length": 500, "nullable": "yes"},
{"name": "content", "title": "SKILL.md草稿内容", "type": "text"},
{"name": "source", "title": "来源(agent/manual)", "type": "str", "length": 16, "nullable": "no", "default": "manual"},
{"name": "status", "title": "状态(pending/testing/approved/published/rejected)", "type": "str", "length": 20, "nullable": "no", "default": "pending"},
{"name": "org_id", "title": "机构ID", "type": "str", "length": 32, "nullable": "yes"},
{"name": "pipeline_id", "title": "产线ID", "type": "str", "length": 32, "nullable": "yes"},
{"name": "feedback", "title": "审核反馈", "type": "text"},
{"name": "created_by", "title": "创建人", "type": "str", "length": 64, "nullable": "yes"},
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"},
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"}
],
"indexes": [
{"name": "idx_skp_status", "idxtype": "index", "idxfields": ["status"]},
{"name": "idx_skp_org", "idxtype": "index", "idxfields": ["org_id"]},
{"name": "idx_skp_pipeline", "idxtype": "index", "idxfields": ["pipeline_id"]}
]
}

View File

@ -250,6 +250,12 @@ GENERAL_TOOLS = [
parameters={"pack": "技能集名称(如 ocai-h5-dev)"},
category="skill",
),
ToolDefinition(
name="propose_skill",
description="自省提出技能化建议:把值得沉淀的流程/经验/坑写成技能草稿提交审核(不自动生效)。用户要求沉淀经验、总结技能,或你发现反复出现的流程/坑/规范时调用",
parameters={"name": "技能名", "description": "技能描述", "content": "SKILL.md 草稿内容"},
category="skill",
),
ToolDefinition(
name="write_file",
description="写入文件到工作空间(自动创建父目录)",

View File

@ -0,0 +1,121 @@
# skill_proposals.dspy - 技能提议管理agent自省提议 + 人工创建生命周期pending→testing→approved→published
# 发布动作:把提议的 SKILL.md 写入 skills/global 并增量同步到所有机构,然后状态置 published
import json
import os
uid = await get_user()
if not uid:
uid = 'user-01'
org_id = await get_userorgid() or '0'
dbname = get_module_dbname('pipeline_core')
action = (params_kw or {}).get('action', 'list')
def _rec_to_dict(r):
out = {}
for k in ('id', 'name', 'description', 'content', 'source', 'status',
'org_id', 'pipeline_id', 'feedback', 'created_by'):
out[k] = getattr(r, k, '') or ''
out['created_at'] = str(getattr(r, 'created_at', '') or '')
out['updated_at'] = str(getattr(r, 'updated_at', '') or '')
return out
async with DBPools().sqlorContext(dbname) as sor:
if action == 'list':
status = (params_kw or {}).get('status', '')
where = "1=1"
p = {}
if status:
where = "status=${status}$"
p["status"] = status
recs = await sor.sqlExe(
"SELECT * FROM skill_proposals WHERE " + where +
" ORDER BY created_at DESC LIMIT 200", p)
proposals = [_rec_to_dict(r) for r in (recs or [])]
return json.dumps({"success": True, "proposals": proposals},
ensure_ascii=False, default=str)
elif action == 'create':
name = (params_kw or {}).get('name', '').strip()
description = (params_kw or {}).get('description', '').strip()
content = (params_kw or {}).get('content', '').strip()
if not name or not content:
return json.dumps({"success": False, "error": "需要 name 和 content"},
ensure_ascii=False)
from appPublic.uniqueID import getID
await sor.C('skill_proposals', {
'id': getID(),
'name': name,
'description': description,
'content': content,
'source': 'manual',
'status': 'pending',
'org_id': org_id,
'pipeline_id': '',
'created_by': uid,
})
return json.dumps({"success": True, "name": name}, ensure_ascii=False)
elif action == 'update_status':
qid = (params_kw or {}).get('id', '').strip()
status = (params_kw or {}).get('status', '').strip()
feedback = (params_kw or {}).get('feedback', '').strip()
if not qid or not status:
return json.dumps({"success": False, "error": "需要 id 和 status"},
ensure_ascii=False)
if status not in ('pending', 'testing', 'approved', 'published', 'rejected'):
return json.dumps({"success": False, "error": f"非法状态: {status}"},
ensure_ascii=False)
recs = await sor.R('skill_proposals', {'id': qid})
if not recs:
return json.dumps({"success": False, "error": "提议不存在"}, ensure_ascii=False)
await sor.U('skill_proposals', {'id': qid, 'status': status, 'feedback': feedback or ''})
return json.dumps({"success": True, "id": qid, "status": status}, ensure_ascii=False)
elif action == 'publish':
# 发布:读提议 → 写 SKILL.md 到 skills/global → 增量同步到所有机构 → 状态 published
qid = (params_kw or {}).get('id', '').strip()
if not qid:
return json.dumps({"success": False, "error": "需要提议ID"}, ensure_ascii=False)
recs = await sor.R('skill_proposals', {'id': qid})
if not recs:
return json.dumps({"success": False, "error": "提议不存在"}, ensure_ascii=False)
r = recs[0]
name = getattr(r, 'name', '') or ''
content = getattr(r, 'content', '') or ''
if not name or not content:
return json.dumps({"success": False, "error": "提议缺少 name 或 content"},
ensure_ascii=False)
# 写 SKILL.md 到全局模板 skills/global/{name}/
from pipeline_core.skill_pack import get_skills_base, ensure_org_skills
skill_dir = os.path.join(get_skills_base(), "global", name)
os.makedirs(skill_dir, exist_ok=True)
with open(os.path.join(skill_dir, "SKILL.md"), "w", encoding="utf-8") as f:
f.write(content)
# 增量同步到所有机构工作目录
from pipeline_service.workspace import get_workspace_base
ws_base = os.path.normpath(await get_workspace_base(sor))
synced = 0
if os.path.isdir(ws_base):
for org_dir in os.listdir(ws_base):
org_path = os.path.join(ws_base, org_dir)
if os.path.isdir(org_path) and not org_dir.startswith('.'):
try:
ensure_org_skills(ws_base, org_dir)
synced += 1
except Exception:
pass
await sor.U('skill_proposals', {'id': qid, 'status': 'published'})
return json.dumps({"success": True, "name": name, "synced_orgs": synced},
ensure_ascii=False)
else:
return json.dumps({"success": False, "error": "Unknown action: " + str(action)},
ensure_ascii=False)

View File

@ -0,0 +1,104 @@
# skill_proposals_list.dspy - 返回技能提议列表 widget卡片 + 状态流转/发布/拒绝按钮)
# 供技能管理界面 urlwidget 加载
import json as _json
uid = await get_user()
if not uid:
uid = 'user-01'
dbname = get_module_dbname('pipeline_core')
_STATUS_COLOR = {
'pending': '#f59e0b', 'testing': '#2563eb', 'approved': '#16a34a',
'published': '#7c3aed', 'rejected': '#dc2626',
}
_STATUS_LABEL = {
'pending': '待审核', 'testing': '测试中', 'approved': '已审批',
'published': '已发布', 'rejected': '已拒绝',
}
def _mk_btn(label, qid, act, color=''):
script = (
"fetch('/pipeline_core/api/skill_proposals.dspy',{method:'POST',"
"headers:{'Content-Type':'application/x-www-form-urlencoded'},"
"body:'action=" + act + "&id=" + qid + "'}).then(function(r){return r.json()})"
".then(function(d){if(d.success){location.reload();}else{alert(d.error||'操作失败');}});"
)
opts = {"label": label, "css": "small"}
if color:
opts["color"] = color
return {
"widgettype": "Button",
"options": opts,
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self", "script": script}],
}
async with DBPools().sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT id,name,description,source,status,created_by FROM skill_proposals "
"ORDER BY (status='pending') DESC, created_at DESC LIMIT 100")
if not recs:
return _json.dumps({
"widgettype": "Text",
"options": {"text": "暂无技能提议。agent 自省会在此提交技能化建议,也可手动新建。",
"cfontsize": 0.95, "color": "#94a3b8", "padding": "24px"}
}, ensure_ascii=False)
cards = []
for r in recs:
qid = getattr(r, 'id', '') or ''
name = getattr(r, 'name', '') or ''
desc = getattr(r, 'description', '') or ''
source = getattr(r, 'source', '') or ''
status = getattr(r, 'status', '') or 'pending'
color = _STATUS_COLOR.get(status, '#64748b')
slabel = _STATUS_LABEL.get(status, status)
# 行:名称 + 状态 + 来源 + 按钮组
btns = []
if status == 'pending':
btns.append(_mk_btn('→测试', qid, 'update_status', ''))
elif status == 'testing':
btns.append(_mk_btn('→审批', qid, 'update_status', ''))
elif status == 'approved':
btns.append(_mk_btn('发布', qid, 'publish', '#7c3aed'))
if status not in ('published', 'rejected'):
btns.append(_mk_btn('拒绝', qid, 'update_status', '#dc2626'))
row_children = [
{"widgettype": "Text", "options": {"text": name, "fontWeight": "bold", "cfontsize": 0.95}},
{"widgettype": "Text", "options": {"text": slabel, "color": color, "cfontsize": 0.8}},
]
if source == 'agent':
row_children.append({"widgettype": "Text",
"options": {"text": "agent自省", "color": "#2563eb", "cfontsize": 0.75}})
row_children.append({"widgettype": "Filler"})
for b in btns:
row_children.append(b)
card_children = [{
"widgettype": "HBox",
"options": {"gap": "8px", "alignItems": "center"},
"subwidgets": row_children,
}]
if desc:
card_children.append({"widgettype": "Text",
"options": {"text": desc, "cfontsize": 0.85, "color": "#64748b"}})
cards.append({
"widgettype": "VBox",
"options": {"css": "card", "bgcolor": "#fff", "border": "1px solid #e2e8f0",
"borderRadius": "8px", "padding": "10px 14px", "gap": "4px"},
"subwidgets": card_children,
})
return _json.dumps({
"widgettype": "VBox",
"options": {"padding": "16px 24px", "gap": "8px", "css": "filler"},
"subwidgets": cards,
}, ensure_ascii=False)

View File

@ -0,0 +1,29 @@
{
"widgettype": "VBox",
"options": {"width": "100%", "height": "100%", "padding": "0"},
"subwidgets": [
{
"widgettype": "HBox",
"options": {"width": "100%", "alignItems": "center", "padding": "16px 24px 8px 24px", "cheight": 6, "gap": "12px"},
"subwidgets": [
{"widgettype": "Title2", "options": {"text": "技能管理"}},
{"widgettype": "Text", "options": {"text": "技能生命周期:开发 → 测试 → 审批 → 发布(发布后同步到所有机构)", "cfontsize": 0.9, "color": "#94a3b8"}},
{"widgettype": "Filler"},
{
"widgettype": "Button",
"id": "btn_new",
"options": {"label": " 新建提议", "css": "small", "color": "#2563eb"},
"binds": [{
"wid": "self", "event": "click", "actiontype": "script", "target": "self",
"script": "var pw=new bricks.PopupWindow({title:'新建技能提议',cwidth:60,cheight:34,auto_open:true});var form=new bricks.Form({name:'skp',cols:1,fields:[{name:'name',uitype:'text',label:'技能名',placeholder:'如 question-escalation',required:true},{name:'description',uitype:'text',label:'描述',placeholder:'一句话说明触发条件'},{name:'content',uitype:'textarea',label:'SKILL.md 内容',cheight:12,required:true}]});var vb=new bricks.VBox({padding:'16px',gap:'12px'});vb.add_widget(form);var save=new bricks.Button({label:'提交',css:'primary'});save.set_click(function(){var v=form.getValue();if(!v||!v.name||!v.content){alert('请填写技能名和内容');return;}var body=new URLSearchParams();body.append('action','create');body.append('name',v.name);body.append('description',v.description||'');body.append('content',v.content);fetch('/pipeline_core/api/skill_proposals.dspy',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body}).then(function(r){return r.json()}).then(function(d){if(d.success){pw.destroy();location.reload();}else{alert(d.error||'创建失败');}});});vb.add_widget(save);pw.content_w.add_widget(vb);"
}]
}
]
},
{
"widgettype": "urlwidget",
"id": "proposal_list",
"options": {"url": "{{entire_url('/pipeline_core/api/skill_proposals_list.dspy')}}", "method": "GET"}
}
]
}