feat: 技能提议管理(skill_proposals表+管理API+列表UI+界面)+agent自省propose_skill工具
This commit is contained in:
parent
f19562a360
commit
08f628dba1
29
models/skill_proposals.json
Normal file
29
models/skill_proposals.json
Normal 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"]}
|
||||
]
|
||||
}
|
||||
@ -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="写入文件到工作空间(自动创建父目录)",
|
||||
|
||||
121
wwwroot/api/skill_proposals.dspy
Normal file
121
wwwroot/api/skill_proposals.dspy
Normal 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)
|
||||
104
wwwroot/api/skill_proposals_list.dspy
Normal file
104
wwwroot/api/skill_proposals_list.dspy
Normal 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)
|
||||
29
wwwroot/skill_proposals/index.ui
Normal file
29
wwwroot/skill_proposals/index.ui
Normal 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"}
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user