用户需求变更(2026-09-08):propose_skill从人工审核链(pending→published)改为实时生效,
但只写提议者所属机构的skills目录,靠org scope(优先级3)同名覆盖global(0),不影响其他机构。
- 新增 skill_live.py: publish_skill_live 路由决策+路径穿越清洗+frontmatter scope剥离+原子写
* 客户机构(org≠0)→orgs/{org_id}/{name}/实时生效
* org 0陷阱:get_merged里orgs/0/是全机构共享缺省——org0+有user_id降级users/{uid}/(仅本人);
org0无人值守(复盘poller)拒绝实时写,保持pending人工审核链(防agent自动影响全平台)
- v2 _t_propose_skill / v1 retrospective.propose_skill 接入:落库照写(成功=published+路径审计)
- v1 poller长进程配套:_reload_skills_throttled(30s TTL)接入_build_role_skills_block/_load_skill_by_name
(v2每条消息executor init已reload,无需改)
- 工具描述三处同步:core GENERAL_TOOLS/capability_tools TOOL_SCHEMAS/RETRO prompt
197 lines
9.0 KiB
Python
197 lines
9.0 KiB
Python
"""复盘能力 — 项目执行完后的问题收集 + 技能提议(通用、产线无关)。
|
||
|
||
职责(方案 P2/P3 的代码层):
|
||
- project_retrospective_data:四类问题源统一收集(冒泡问题/退回重做/编排缺口/Bug),
|
||
返回结构化素材给 PM 复盘任务,PM 不重复实现查询(「重复逻辑写 py」原则)。
|
||
- propose_skill:PM 复盘产出的技能提议落 skill_proposals(source=agent, status=pending),
|
||
进技能管理建议列表待人工审核,不直接改技能库(门禁守源头)。
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
|
||
from sqlor.dbpools import DBPools
|
||
from appPublic.uniqueID import getID
|
||
|
||
DBNAME = "pipeline"
|
||
logger = logging.getLogger("pipeline.retrospective_capability")
|
||
|
||
|
||
def _get_db():
|
||
db = DBPools()
|
||
if not db.databases:
|
||
from appPublic.jsonConfig import getConfig
|
||
config = getConfig()
|
||
if config and config.databases:
|
||
db.databases = config.databases
|
||
return db, DBNAME
|
||
|
||
|
||
def _rows_to_dicts(recs, limit=200):
|
||
out = []
|
||
for r in (recs or [])[:limit]:
|
||
# sqlor 行是 DictObject:dict(rec) 才能取全部列(vars() 只取内部属性 → 空字典,
|
||
# 2026-08-31 测试机冒烟实测:project 名空壳即此因)。照抄 feature_capability._rec_to_dict。
|
||
if isinstance(r, dict):
|
||
out.append(dict(r))
|
||
continue
|
||
try:
|
||
out.append(dict(r))
|
||
continue
|
||
except (TypeError, ValueError):
|
||
pass
|
||
if hasattr(r, "to_dict"):
|
||
try:
|
||
out.append(r.to_dict())
|
||
continue
|
||
except Exception:
|
||
pass
|
||
out.append({})
|
||
return out
|
||
|
||
|
||
async def project_retrospective_data(project_id, who=None, agent_id=None,
|
||
task_id=None, iteration_id=None):
|
||
"""收集本项目执行中的全部问题素材(四类源合并 + 统计)。返回结构化文本(JSON)。
|
||
|
||
四类问题源(全部已落库,本函数只查不写):
|
||
① 冒泡问题 pipeline_agent_questions:question + 解决记录(answer/answered_by)
|
||
② 退回重做 audit_log:qc_reject/reject 记录(detail=退回意见,who=被退角色)
|
||
③ 编排缺口 pipeline_pm_notices:gap_text
|
||
④ Bug sd_bugs:title/description/fix_description(按项目迭代关联)
|
||
"""
|
||
if not project_id:
|
||
return "FAIL: 缺少 project_id"
|
||
db, dbname = _get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
# ① 冒泡问题(含解决记录)
|
||
bubbles = _rows_to_dicts(await sor.sqlExe(
|
||
"SELECT problem_type, question, from_role, current_handler_role, status, "
|
||
"answer, answered_by, created_at FROM pipeline_agent_questions "
|
||
"WHERE tenant_id=${pid}$ ORDER BY created_at ASC LIMIT 100",
|
||
{"pid": project_id}))
|
||
# ② 退回重做(审计:谁被退、退回意见)
|
||
reworks = _rows_to_dicts(await sor.sqlExe(
|
||
"SELECT action, who, detail, created_at FROM audit_log "
|
||
"WHERE tenant_id=${pid}$ AND entity='pipeline_tasks' "
|
||
"AND action IN ('qc_reject','reject') ORDER BY created_at ASC LIMIT 100",
|
||
{"pid": project_id}))
|
||
# ③ 编排缺口
|
||
gaps = _rows_to_dicts(await sor.sqlExe(
|
||
"SELECT gap_text, status FROM pipeline_pm_notices "
|
||
"WHERE tenant_id=${pid}$ ORDER BY created_at ASC LIMIT 50",
|
||
{"pid": project_id}))
|
||
# ④ Bug(项目 → 迭代 → Bug)
|
||
iters = _rows_to_dicts(await sor.sqlExe(
|
||
"SELECT id, iteration_name FROM sd_iterations WHERE project_id=${pid}$",
|
||
{"pid": project_id}))
|
||
bugs = []
|
||
if iters:
|
||
ids = ",".join("'" + str(i.get('id', '')).replace("'", "") + "'" for i in iters)
|
||
bugs = _rows_to_dicts(await sor.sqlExe(
|
||
"SELECT title, description, severity, status, fix_description, created_at "
|
||
"FROM sd_bugs WHERE iteration_id IN (" + ids + ") "
|
||
"ORDER BY created_at ASC LIMIT 100", {}))
|
||
# 统计:工期
|
||
precs = await sor.sqlExe(
|
||
"SELECT name, created_at, status FROM sd_projects WHERE id=${pid}$",
|
||
{"pid": project_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
proj = _rows_to_dicts(precs)
|
||
proj = proj[0] if proj else {}
|
||
|
||
problems = []
|
||
for b in bubbles:
|
||
problems.append({
|
||
"type": "bubble", "problem": b.get('question', ''),
|
||
"solution": b.get('answer', '') or '(未记录解决方法)',
|
||
"problem_type": b.get('problem_type', ''),
|
||
"roles": "%s→%s" % (b.get('from_role', ''), b.get('current_handler_role', '')),
|
||
"time": str(b.get('created_at', ''))})
|
||
for r in reworks:
|
||
problems.append({
|
||
"type": "rework", "problem": r.get('detail', '') or '(无退回意见)',
|
||
"solution": '(重做后通过,对比新旧交付件可见改法)',
|
||
"role": r.get('who', ''), "via": r.get('action', ''),
|
||
"time": str(r.get('created_at', ''))})
|
||
for g in gaps:
|
||
problems.append({
|
||
"type": "gap", "problem": g.get('gap_text', ''),
|
||
"solution": '(PM 处置记录见任务链)', "time": ''})
|
||
for bug in bugs:
|
||
problems.append({
|
||
"type": "bug", "problem": "%s:%s" % (bug.get('title', ''), (bug.get('description', '') or '')[:300]),
|
||
"solution": bug.get('fix_description', '') or '(未记录修复说明)',
|
||
"severity": bug.get('severity', ''), "status": bug.get('status', ''),
|
||
"time": str(bug.get('created_at', ''))})
|
||
|
||
data = {
|
||
"project": proj.get('name', ''), "project_status": proj.get('status', ''),
|
||
"project_created_at": str(proj.get('created_at', '')),
|
||
"problems": problems,
|
||
"stats": {
|
||
"bubble_count": len(bubbles), "rework_count": len(reworks),
|
||
"gap_count": len(gaps), "bug_count": len(bugs),
|
||
"total": len(problems),
|
||
},
|
||
}
|
||
return json.dumps(data, ensure_ascii=False, default=str)
|
||
|
||
|
||
async def propose_skill(project_id, name, description="", content="",
|
||
who=None, agent_id=None, task_id=None, iteration_id=None):
|
||
"""提交技能提议(复盘产出)。
|
||
|
||
2026-09-08 用户拍板改为实时生效:写提议所属机构的技能目录
|
||
skills/orgs/{org_id}/{name}/(org scope 同名覆盖 global,机构内立即生效,
|
||
其他机构不受影响)。org 0(平台缺省机构,orgs/0/ 全机构共享)的无人值守
|
||
提议不允许实时发布——保持原 pending 人工审核链(pending→testing→approved
|
||
→published)。skill_proposals 照落一条留审计(实时发布=published+路径)。
|
||
|
||
content 为 SKILL.md 草稿,头部应带目标定位注释
|
||
(<!-- target: roles/agent.xxx/yyy(修改) --> 或 <!-- target: 新建 -->)。
|
||
"""
|
||
name = (name or '').strip()
|
||
content = (content or '').strip()
|
||
if not name or not content:
|
||
return False, "需要技能名和内容(content 为 SKILL.md 草稿正文)"
|
||
if len(content) < 80:
|
||
return False, "提议内容过短(<80字):SKILL.md 草稿须含触发条件/问题现象/根因/处理方法"
|
||
db, dbname = _get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
# org/pipeline 从项目取(提议按机构隔离 + 产线归属)
|
||
precs = await sor.sqlExe(
|
||
"SELECT org_id, pipeline_id FROM sd_projects WHERE id=${pid}$",
|
||
{"pid": project_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
org_id = '0'
|
||
pipeline_id = ''
|
||
if precs:
|
||
org_id = getattr(precs[0], 'org_id', '') or '0'
|
||
pipeline_id = getattr(precs[0], 'pipeline_id', '') or ''
|
||
|
||
# 实时发布(复盘是无人值守:who 是 agent.pm 不是人,无 user_id——
|
||
# org 0 项目会被 resolve_target 拒绝实时写,自动回落人工审核链)
|
||
from .skill_live import publish_skill_live
|
||
ok, msg, info = await publish_skill_live(
|
||
name, description, content, org_id=org_id, user_id='', who=who or 'agent.pm')
|
||
|
||
pid = getID()
|
||
await sor.C('skill_proposals', {
|
||
'id': pid,
|
||
'name': name[:100],
|
||
'description': (description or '')[:500],
|
||
'content': content,
|
||
'source': 'agent',
|
||
'status': 'published' if ok else 'pending',
|
||
'feedback': msg if ok else '',
|
||
'org_id': org_id,
|
||
'pipeline_id': pipeline_id,
|
||
'created_by': who or 'agent.pm',
|
||
})
|
||
await sor.sqlExe("COMMIT", {})
|
||
logger.info("propose_skill: %s project=%s by=%s live=%s", name, project_id, who, ok)
|
||
if ok:
|
||
return True, msg
|
||
return True, f"已提交技能提议 '{name}'({msg},转人工审核,暂不生效)"
|