pipeline-service/pipeline_service/retrospective_capability.py
ymq 655d50bde8 feat(skill): 技能提议实时生效——写机构技能目录orgs/{org_id}/按scope覆盖global,机构间隔离
用户需求变更(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
2026-09-08 23:59:24 +08:00

197 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""复盘能力 — 项目执行完后的问题收集 + 技能提议(通用、产线无关)。
职责(方案 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},转人工审核,暂不生效)"