"""复盘能力 — 项目执行完后的问题收集 + 技能提议(通用、产线无关)。 职责(方案 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): """提交技能提议(复盘产出)。落 skill_proposals,source=agent,status=pending。 提议只是草稿:进技能管理建议列表待人工审核(pending→testing→approved→published), 不直接改技能库。content 为 SKILL.md 草稿,头部应带目标定位注释 ( 或 )。 """ 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 '' pid = getID() await sor.C('skill_proposals', { 'id': pid, 'name': name[:100], 'description': (description or '')[:500], 'content': content, 'source': 'agent', 'status': 'pending', '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", name, project_id, who) return True, f"已提交技能提议 '{name}'(待审核,暂不生效)"