pipeline-opportunity/pipeline_opportunity/opp_report_capability.py

471 lines
20 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.

"""商机产线报告/审批能力:研发报告编写 → 人工确认 → 研发审批流程。
状态机(REPORT_TRANSITIONS,opp_common):
draft → confirmed → approval_initiated → approved / rejected
└ rejected → draft(可重来)
门禁纪律:
- confirm 只能 draft → confirmed,且是人工决策(由人工任务回流驱动)
- initiate_approval 只能 confirmed → approval_initiated(未经确认禁止发起审批)
- 审批结论由人工决策回流(approve/reject),agent 不得自批自审
"""
import logging
import os
import re
from ahserver.serverenv import ServerEnv
from .opp_common import (
get_db, new_id, rec_to_dict, rows_to_dicts, can_transition,
RP_DRAFT, RP_CONFIRMED, RP_APPROVAL_INITIATED, RP_APPROVED, RP_REJECTED,
AP_INITIATED, AP_APPROVED, AP_REJECTED,
HT_REPORT_CONFIRM, HT_DEV_APPROVAL,
find_human_task, create_human_task, get_report,
)
logger = logging.getLogger("pipeline.opportunity.report")
async def create_report(project_id, software, title="", analysis="",
created_by="agent.opportunity"):
"""创建研发报告草稿。返回 (True, report_id) 或 (False, 错误)。"""
if not software:
return False, "缺少 software(软件/主题名)"
if not title:
title = "%s 研发机会分析报告" % software
db, dbname = get_db()
rid = new_id()
async with db.sqlorContext(dbname) as sor:
await sor.C("opp_reports", {
"id": rid,
"project_id": project_id or "",
"software": software,
"title": title,
"content": analysis or "",
"status": RP_DRAFT,
"created_by": created_by,
})
await sor.sqlExe("COMMIT", {})
return True, rid
async def update_report(report_id, content):
"""更新草稿报告内容(仅 draft 可改)。"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
rep = await get_report(sor, report_id)
if not rep:
return False, "报告不存在: %s" % report_id
if rep.get("status") != RP_DRAFT:
return False, "状态 %s 不可修改(仅草稿可编辑)" % rep.get("status")
await sor.U("opp_reports", {"id": report_id, "content": content})
await sor.sqlExe("COMMIT", {})
return True, "已更新"
async def list_reports(project_id="", status=""):
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
sql = ("SELECT id, project_id, software, title, status, created_by, "
"created_at, updated_at FROM opp_reports WHERE 1=1")
args = {}
if project_id:
sql += " AND project_id=${pid}$"
args["pid"] = project_id
if status:
sql += " AND status=${st}$"
args["st"] = status
sql += " ORDER BY created_at DESC LIMIT 50"
recs = await sor.sqlExe(sql, args)
await sor.sqlExe("COMMIT", {})
return rows_to_dicts(recs)
# ══════════════════ 报告可见性 / 四节模板 / 成果文件(2026-09-09 用户需求)══════════════════
# 「研发报告」按钮改造:按项目 owner 过滤列表;详情按四节(研发场景/输入/输出/成果)
# 展示;成果文件多个列出、逐个下载。
# 报告正文四节模板(二级标题,agent 写报告必须按此结构;旧报告无节时全文归「输出」)
REPORT_SECTIONS = ("研发场景", "输入", "输出", "成果")
async def _user_org(sor, user_id):
recs = await sor.sqlExe(
"SELECT orgid FROM users WHERE id=${u}$", {"u": user_id})
await sor.sqlExe("COMMIT", {})
return str(getattr(recs[0], "orgid", "") or "") if recs else ""
async def list_visible_reports(sor, user_id):
"""按项目 owner 过滤的研发报告列表(2026-09-09 用户要求)。
可见性规则(用户确认口径):
- 挂项目的报告:当前用户是项目 owner(sd_projects.created_by==user_id)可见;
agent 创建的项目(created_by LIKE 'agent.%')降级为同机构成员可见
(对齐 check_project_owner 的既有降级语义,不重复发明)
- 未挂项目的报告(平台级调研,现有数据大多数是这类):登录用户可见
- 项目已删除的孤儿报告:owner 无法验证 → 不可见(宁缺勿漏,LEFT JOIN 自然排除)
"""
if not user_id:
return []
uorg = await _user_org(sor, user_id)
# ⚠️ LIKE 模式必须走 ${kw}$ 参数化——SQL 字面量里的裸 % 会被驱动当格式化符
# (实测 ValueError: unsupported format character;rag_client.py 的
# NOT LIKE 'agent.%' 是同一颗潜伏雷)。参数值里的 % 安全。
sql = (
"SELECT r.id, r.project_id, r.software, r.title, r.status, r.ppt_path, "
"r.created_by, r.created_at, r.updated_at, "
"p.name AS project_name, p.created_by AS project_owner "
"FROM opp_reports r LEFT JOIN sd_projects p ON r.project_id = p.id "
"WHERE (r.project_id IS NULL OR r.project_id = '') "
" OR p.created_by = ${uid}$ "
" OR (p.created_by LIKE ${agent_pat}$ "
" AND p.org_id = ${uorg}$ AND ${uorg}$ <> '') "
"ORDER BY r.created_at DESC LIMIT 200")
recs = await sor.sqlExe(sql, {"uid": user_id, "uorg": uorg,
"agent_pat": "agent.%"})
await sor.sqlExe("COMMIT", {})
return rows_to_dicts(recs)
async def check_report_visible(sor, report_id, user_id):
"""单报告可见性校验(详情/下载端点复用列表同一规则,语义不漂移)。
返回 (visible: bool, report: dict|None)。"""
if not report_id or not user_id:
return False, None
uorg = await _user_org(sor, user_id)
sql = (
"SELECT r.*, p.name AS project_name, p.created_by AS project_owner, "
"p.workspace_dir AS project_dir "
"FROM opp_reports r LEFT JOIN sd_projects p ON r.project_id = p.id "
"WHERE r.id = ${rid}$")
recs = await sor.sqlExe(sql, {"rid": report_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return False, None
rep = rec_to_dict(recs[0])
pid = str(rep.get("project_id") or "")
if not pid:
return True, rep # 未挂项目:登录可见
owner = str(rep.get("project_owner") or "")
if not owner:
return False, rep # 孤儿报告(项目已删):owner 无法验证
if owner == user_id:
return True, rep
if owner.startswith("agent.") and uorg:
# agent 建的项目降级同机构(对齐 check_project_owner 既有语义)
recs2 = await sor.sqlExe(
"SELECT org_id FROM sd_projects WHERE id=${p}$", {"p": pid})
await sor.sqlExe("COMMIT", {})
proj_org = str(getattr(recs2[0], "org_id", "") or "") if recs2 else ""
if proj_org and proj_org == uorg:
return True, rep
return False, rep
def parse_report_sections(content):
"""按四节模板解析报告正文 → {'研发场景': md, '输入': md, '输出': md, '成果': md}。
模板(agent 写报告约定):二级标题 `## 研发场景` / `## 输入` / `## 输出` / `## 成果`。
兼容标题带序号/冒号/加粗(如 `## 一、研发场景:`、`## **输出**`)。
旧报告(无任何匹配节,如实测的市场调研报告)→ 全文归「输出」节,其余节为空,
详情页对空节如实显示「(本报告未填写此节)」,不编造。
"""
import re as _re
content = content or ""
# 节标题行:## [可选序号/标点] 节名 [可选冒号/加粗残留]
pattern = _re.compile(
r"^##\s*(?:[一二三四五六七八九十\d]+[、..]?\s*)?[**]*\s*"
r"(研发场景|输入|输出|成果)\s*[**]*\s*[::]?\s*$",
_re.MULTILINE)
marks = list(pattern.finditer(content))
sections = {k: "" for k in REPORT_SECTIONS}
if not marks:
sections["输出"] = content.strip()
return sections
# 首个节标题之前的前言(如报告大标题/数据来源说明)归「输出」节头部展示
preamble = content[:marks[0].start()].strip()
for i, m in enumerate(marks):
name = m.group(1)
end = marks[i + 1].start() if i + 1 < len(marks) else len(content)
body = content[m.end():end].strip()
# 同名节重复出现时拼接(容错,不丢内容)
sections[name] = (sections[name] + "\n\n" + body).strip() if sections[name] else body
if preamble:
sections["输出"] = (preamble + ("\n\n" + sections["输出"] if sections["输出"] else "")).strip()
return sections
async def list_report_files(sor, rep):
"""报告的成果文件清单(2026-09-09 用户要求:多个文件列出、点击下载)。
范围(用户确认口径):项目工作空间 deliverables/ 目录全部文件 + 报告 PPT
(ppt_path 可能就在 deliverables/ 里,按 realpath 去重)。
未挂项目的报告:只有 PPT(若有)。
返回 [{'name','path','size','mtime'}],path 为服务端绝对路径——
下载端点以本清单为权威(对齐 opp_attachment_dl 的防穿越模式:
用户只传文件名,服务端回查清单命中才给文件,绝不拿用户输入拼路径)。
"""
import os as _os
files = {}
def _add(p):
try:
rp = _os.path.realpath(p)
if _os.path.isfile(rp) and rp not in files:
st = _os.stat(rp)
files[rp] = {"name": _os.path.basename(rp), "path": rp,
"size": st.st_size, "mtime": st.st_mtime}
except Exception:
pass
proj_dir = str((rep or {}).get("project_dir") or "")
if proj_dir:
deliv = _os.path.join(proj_dir, "deliverables")
try:
if _os.path.isdir(deliv):
for fn in sorted(_os.listdir(deliv)):
_add(_os.path.join(deliv, fn))
except Exception:
pass
_add(str((rep or {}).get("ppt_path") or ""))
out = sorted(files.values(), key=lambda f: -f["mtime"])
return out
async def get_report_full(report_id):
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
rep = await get_report(sor, report_id)
return rep
async def _build_report_ppt(sor, rep):
"""为报告生成 PPT,存到项目工作空间 deliverables/ 目录。返回文件路径(失败 '')。"""
from .opp_ppt import build_report_ppt
# 解析项目目录({workspace}/{org}/{space}/projects/{项目}/)
project_id = rep.get("project_id", "") or ""
project_dir = ""
if project_id:
try:
recs = await sor.sqlExe(
"SELECT workspace_dir FROM sd_projects WHERE id=${p}$",
{"p": project_id})
if recs:
project_dir = getattr(recs[0], "workspace_dir", "") or ""
except Exception:
project_dir = ""
if not project_dir:
# 无项目/无目录:兜底放平台 files 目录
project_dir = os.path.join(os.getcwd(), "files", "opp_reports")
rid = rep.get("id", "")
software = re.sub(r"[^\w\u4e00-\u9fff-]", "_", rep.get("software", "") or "report")[:40]
fname = "research_report_%s_%s.pptx" % (software, rid[:8])
out_path = os.path.join(project_dir, "deliverables", fname)
ok, res = build_report_ppt(
rep.get("title", ""), rep.get("content", ""),
software=rep.get("software", ""), out_path=out_path)
if not ok:
logger.warning("build_report_ppt 失败: %s", str(res)[:200])
return ""
return out_path
async def submit_for_confirmation(report_id, note=""):
"""agent 完成报告后提交待人工确认:发人工确认任务(门禁)。
报告保持 draft,创建/复用 pending 人工任务。人工确认后才进 confirmed。
同时生成报告 PPT(存项目工作空间 deliverables/),供确认人下载审阅。
"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
rep = await get_report(sor, report_id)
if not rep:
return False, "报告不存在: %s" % report_id
if rep.get("status") != RP_DRAFT:
return False, "状态 %s 不可提交确认" % rep.get("status")
# 生成/更新报告 PPT(幂等:重复提交覆盖旧文件)
ppt_path = ""
try:
ppt_path = await _build_report_ppt(sor, rep)
except Exception as e:
logger.warning("报告PPT生成失败(不阻塞提交): %s", str(e)[:200])
if ppt_path:
await sor.U("opp_reports", {"id": report_id, "ppt_path": ppt_path})
# 幂等:本报告已有 pending 确认任务则复用(按 confirm_task_id 绑定,
# 不复用其他报告的任务——确认是逐份报告的独立门禁,
# 且待办页按 confirm_task_id 反查报告正文,绑定丢失则报告无法被确认)
exist_id = rep.get("confirm_task_id") or ""
if exist_id:
t_recs = await sor.sqlExe(
"SELECT id, status FROM pipeline_human_tasks WHERE id=${i}$",
{"i": exist_id})
if t_recs and getattr(t_recs[0], "status", "") == "pending":
return True, "已有待确认任务: %s" % exist_id
hid = await create_human_task(
sor, rep.get("project_id", ""), HT_REPORT_CONFIRM,
"确认研发报告:%s" % rep.get("title", ""),
note or "请审阅研发报告《%s》,确认后发起研发审批。" % rep.get("title", ""))
await sor.U("opp_reports", {
"id": report_id, "confirm_task_id": hid})
await sor.sqlExe("COMMIT", {})
return True, "已提交人工确认任务: %s" % hid
async def confirm_report(report_id, ok, operator=""):
"""人工确认回流(门禁)。ok=True → confirmed;ok=False → 退回草稿待改。"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
rep = await get_report(sor, report_id)
if not rep:
return False, "报告不存在: %s" % report_id
if rep.get("status") != RP_DRAFT:
return False, "状态 %s 无法确认(仅草稿可确认)" % rep.get("status")
new_status = RP_CONFIRMED if ok else RP_DRAFT
await sor.U("opp_reports", {
"id": report_id, "status": new_status,
"confirmed_by": operator or "human"})
# 完结对应的人工任务
hid = rep.get("confirm_task_id")
if hid:
await sor.U("pipeline_human_tasks", {
"id": hid, "status": "done" if ok else "rejected"})
await sor.sqlExe("COMMIT", {})
return True, "已确认" if ok else "已退回草稿"
async def initiate_approval(report_id, note="", created_by="agent.opportunity",
applicant_id=""):
"""发起研发审批:要求报告已 confirmed(人工确认门禁)。
审批通道优先级:
1. 钉钉审批——dda_approval_configs 配了 biz_type=opp_dev_approval 且启用时,
调 dingdingflow.submit_approval 发起,审批人节点链由配置的
approvers_config 决定;结果经回调写回(见 opp_common 注册的钩子)。
2. 降级人工——无钉钉配置/发起失败时,落平台内人工审批任务兜底,
保证产线闭环不因缺少钉钉环境而卡死。
"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
rep = await get_report(sor, report_id)
if not rep:
return False, "报告不存在: %s" % report_id
if not await can_transition(rep.get("status"), RP_APPROVAL_INITIATED):
return False, "状态 %s 不可发起审批(须先人工确认)" % rep.get("status")
aid = new_id()
await sor.C("opp_approvals", {
"id": aid,
"report_id": report_id,
"project_id": rep.get("project_id", ""),
"status": AP_INITIATED,
"note": note,
"created_by": created_by,
})
await sor.U("opp_reports", {
"id": report_id, "status": RP_APPROVAL_INITIATED})
await sor.sqlExe("COMMIT", {})
# 尝试钉钉审批通道
dd_ok = False
env = ServerEnv()
submit_approval = getattr(env, "submit_approval", None)
if submit_approval is not None:
try:
org_id = rep.get("org_id", "") or "0"
r = await submit_approval(
"opp_dev_approval", aid,
"研发立项审批:%s" % rep.get("title", ""),
applicant_id or created_by, org_id)
if isinstance(r, dict) and r.get("success") and r.get("instance_id"):
dd_ok = True
logger.info("研发审批已推钉钉: report=%s instance=%s",
report_id, r.get("instance_id"))
else:
logger.warning("钉钉审批发起未成功(降级人工): report=%s msg=%s",
report_id, (r or {}).get("message", ""))
except Exception as e:
logger.warning("钉钉审批发起异常(降级人工): report=%s err=%s",
report_id, str(e)[:200])
if not dd_ok:
# 降级:平台内人工审批任务
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
await create_human_task(
sor, rep.get("project_id", ""), HT_DEV_APPROVAL,
"研发审批:%s" % rep.get("title", ""),
note or "请审批《%s》的研发立项。" % rep.get("title", ""))
await sor.sqlExe("COMMIT", {})
return True, aid
return True, aid
async def resolve_approval(approval_id, result, operator=""):
"""审批结论回流(人工决策)。result=approved/rejected。"""
if result not in (AP_APPROVED, AP_REJECTED):
return False, "result 必须是 approved 或 rejected"
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT id, report_id, project_id, status FROM opp_approvals WHERE id=${i}$",
{"i": approval_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return False, "审批单不存在: %s" % approval_id
rec = rec_to_dict(recs[0])
if rec.get("status") != AP_INITIATED:
return False, "审批单状态 %s 无法结论" % rec.get("status")
report_status = RP_APPROVED if result == AP_APPROVED else RP_REJECTED
await sor.U("opp_approvals", {
"id": approval_id, "status": result,
"resolved_by": operator or "human"})
await sor.U("opp_reports", {
"id": rec.get("report_id"), "status": report_status})
# 完结人工审批任务
ht = await find_human_task(sor, rec.get("project_id", ""),
HT_DEV_APPROVAL, status="pending")
if ht:
await sor.U("pipeline_human_tasks", {
"id": ht.get("id"),
"status": "done" if result == AP_APPROVED else "rejected"})
await sor.sqlExe("COMMIT", {})
return True, result
async def on_dingtalk_approval_done(biz_id, status, approval_id, comment):
"""钉钉审批结果回调钩子(dingdingflow register_biz_handler 签名)。
biz_id = opp_approvals.id(发起审批时传入)。
status ∈ approved / rejected / cancelled,cancelled 按驳回处理
(产线不能停在 approval_initiated 永久卡死)。
"""
if status == "approved":
result = AP_APPROVED
else:
result = AP_REJECTED
ok, msg = await resolve_approval(
biz_id, result, operator="dingtalk:%s" % (approval_id or ""))
if ok:
logger.info("钉钉审批结论已回流: opp_approval=%s -> %s", biz_id, result)
else:
logger.warning("钉钉审批结论回流失败: opp_approval=%s msg=%s", biz_id, msg)
async def list_approvals(project_id="", status=""):
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
sql = ("SELECT id, report_id, project_id, status, note, created_by, "
"created_at, resolved_at FROM opp_approvals WHERE 1=1")
args = {}
if project_id:
sql += " AND project_id=${pid}$"
args["pid"] = project_id
if status:
sql += " AND status=${st}$"
args["st"] = status
sql += " ORDER BY created_at DESC LIMIT 50"
recs = await sor.sqlExe(sql, args)
await sor.sqlExe("COMMIT", {})
return rows_to_dicts(recs)