306 lines
15 KiB
Python
306 lines
15 KiB
Python
# -*- coding:utf-8 -*-
|
||
"""P3 可行性研究引擎:FP 估算(规则引擎禁 LLM 报数)→ 八节可行性报告 → 评分 → 立项。
|
||
|
||
分工铁律(function-point-counting 技能):LLM 只做语义(功能识别/DET/FTR 提取),
|
||
复杂度判定与 FP 加权必须走规则引擎 fp_calc.py(skills/global 部署位,进程内 importlib 加载,
|
||
加载失败如实报错,绝不 LLM 心算 FP)。
|
||
|
||
八节可行性模板(report_type=feasibility):
|
||
## 市场需求 / ## 共性需求 / ## 需求规格 / ## 架构与可行性 /
|
||
## FP与成本 / ## 商业价值 / ## 风险 / ## 结论建议
|
||
"""
|
||
import importlib.util
|
||
import json
|
||
import logging
|
||
import os
|
||
|
||
from .opp_common import get_db, new_id, rows_to_dicts, get_param
|
||
from .opp_normalize import coverage_report, _llm
|
||
|
||
logger = logging.getLogger("pipeline.opp_feasibility")
|
||
|
||
# fp_calc.py 部署位(build.sh 6b 段复制 skills_library/all → skills/global)
|
||
FP_CALC_CANDIDATES = [
|
||
"/d/pipeline/pipeline-app/skills/global/function-point-counting/scripts/fp_calc.py",
|
||
os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||
"../../skills/global/function-point-counting/scripts/fp_calc.py"),
|
||
]
|
||
|
||
# 工作量/成本规则(人月/单价,params 可配;IFPUG FP→人月经验系数)
|
||
P = {
|
||
"opp_fp_pm_per_fp": "0.05", # 人月/FP(经验系数,可调)
|
||
"opp_cost_wan_per_pm": "3.0", # 万元/人月
|
||
}
|
||
|
||
|
||
def _load_fp_calc():
|
||
for path in FP_CALC_CANDIDATES:
|
||
p = os.path.abspath(path)
|
||
if os.path.isfile(p):
|
||
spec = importlib.util.spec_from_file_location("fp_calc_mod", p)
|
||
if not spec or not spec.loader:
|
||
continue
|
||
mod = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(mod)
|
||
return mod
|
||
return None
|
||
|
||
|
||
async def get_fp_params(sor):
|
||
cfg = {}
|
||
for k, dv in P.items():
|
||
cfg[k] = await get_param(sor, k, dv)
|
||
for k in P:
|
||
try:
|
||
cfg[k] = float(cfg[k])
|
||
except (TypeError, ValueError):
|
||
cfg[k] = float(P[k])
|
||
return cfg
|
||
|
||
|
||
async def fp_estimate(sor, functions_json, project_name=""):
|
||
"""functions_json: [{name,type(EI/EO/EQ/ILF/EIF),desc,evidence,det,ret,ftr,confidence}]
|
||
返回 (ok, dict|err)。复杂度与 FP 全部由 fp_calc 规则引擎算,LLM 只提供结构化输入。"""
|
||
try:
|
||
funcs = json.loads(functions_json) if isinstance(functions_json, str) else functions_json
|
||
except Exception:
|
||
return False, "functions_json 解析失败"
|
||
if not isinstance(funcs, list) or not funcs:
|
||
return False, "functions 必须是非空数组"
|
||
data = {"project": project_name or "可行性估算", "boundary": {"internal": [], "external": []},
|
||
"functions": [], "pending": [], "assumptions": []}
|
||
for f in funcs:
|
||
if not isinstance(f, dict):
|
||
continue
|
||
ft = str(f.get("type") or "").upper()
|
||
if ft not in ("EI", "EO", "EQ", "ILF", "EIF"):
|
||
data["pending"].append({"item": str(f.get("name") or "?"),
|
||
"reason": "类型非法: %s" % ft, "ask": "确认功能类型"})
|
||
continue
|
||
data["functions"].append({
|
||
"name": str(f.get("name") or "")[:80], "type": ft,
|
||
"desc": str(f.get("desc") or "")[:200],
|
||
"evidence": str(f.get("evidence") or "")[:200],
|
||
"det": int(f.get("det") or 0), "ret": int(f.get("ret") or (1 if ft in ("ILF", "EIF") else 0)),
|
||
"ftr": int(f.get("ftr") or 0),
|
||
"confidence": str(f.get("confidence") or "medium"),
|
||
"assumption": str(f.get("assumption") or "")[:200]})
|
||
mod = _load_fp_calc()
|
||
if not mod:
|
||
return False, "fp_calc.py 规则引擎未找到(skills/global 未部署 function-point-counting)"
|
||
try:
|
||
res = mod.calc(data)
|
||
except Exception as e:
|
||
return False, "fp_calc 计算失败: %s" % str(e)[:200]
|
||
# fp_calc.calc() 实际返回键(2026-09-14 核对源码):
|
||
# functions / counts / total_fp / errors / pending / assumptions / boundary
|
||
return True, {
|
||
"total_fp": res.get("total_fp") or 0,
|
||
"by_type": res.get("counts") or {},
|
||
"functions": res.get("functions") or data["functions"],
|
||
"pending": data["pending"],
|
||
"errors": res.get("errors") or [],
|
||
"markdown": mod.to_markdown(res, project_name) if hasattr(mod, "to_markdown") else "",
|
||
}
|
||
|
||
|
||
async def _cluster_ctx(sor, cluster_id, ctx):
|
||
"""取类别+批次+覆盖率,校验 org/项目可见性。返回 (ok, dict|err)。
|
||
|
||
项目隔离口径与 opp_mining_flow._check_batch_visible 一致:批次挂了项目的,
|
||
仅同项目会话可见;平台级批次(project_id 空)本机构可见。
|
||
"""
|
||
recs = await sor.sqlExe(
|
||
"SELECT c.*, b.org_id AS b_org, b.scope, b.project_id AS b_project "
|
||
"FROM opp_clusters c "
|
||
"LEFT JOIN opp_mining_batches b ON b.id=c.batch_id WHERE c.id=${c}$",
|
||
{"c": cluster_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if not recs:
|
||
return False, "类别不存在"
|
||
cl = rows_to_dicts(recs, limit=1)[0]
|
||
org_id = str(cl.get("org_id") or cl.get("b_org") or "")
|
||
if org_id and org_id != (ctx.get("org_id") or ""):
|
||
return False, "类别不属于当前机构"
|
||
bproj = str(cl.get("b_project") or cl.get("project_id") or "")
|
||
if bproj and bproj != str(ctx.get("project_id") or ""):
|
||
return False, "批次属于其他项目,当前会话不可见"
|
||
ok, cov = await coverage_report(sor, ctx, cluster_id)
|
||
if not ok:
|
||
return False, cov
|
||
return True, {"cluster": cl, "coverage": cov}
|
||
|
||
|
||
async def draft_feasibility(sor, cluster_id, ctx):
|
||
"""起草八节可行性研究报告(report_type=feasibility)。返回 (ok, msg|report_id)。"""
|
||
ok, info = await _cluster_ctx(sor, cluster_id, ctx)
|
||
if not ok:
|
||
return False, info
|
||
cl = info["cluster"]
|
||
cov = info["coverage"]
|
||
if cov["cluster"].get("normalize_status") != "done":
|
||
return False, "类别未完成共性提取,先 opp_normalize_cluster"
|
||
from .opp_report_capability import create_report
|
||
stds = cov["std_demands"]
|
||
core = [s for s in stds if s["tier"] == "core"]
|
||
ext = [s for s in stds if s["tier"] == "ext"]
|
||
if not core and not ext:
|
||
return False, ("类别「%s」无核心/扩展共性(全部标准需求覆盖率<40%%),"
|
||
"可行性样本不足——换一个共性更高的类别,"
|
||
"或改用 opp_create_report 写调研报告" % (cl.get("name") or ""))
|
||
|
||
# FP 输入:核心+扩展标准需求 → 规则映射功能点(每标准需求≈1 ILF + 2 EI + 1 EO/EQ,
|
||
# 保守经验映射;DET 按证据原子数粗估)——LLM 不参与 FP 数值
|
||
funcs = []
|
||
for s in core + ext:
|
||
ev = json.loads(s.get("evidence_json") or "{}")
|
||
n_atoms = len(ev.get("atoms") or []) or 1
|
||
funcs.append({"name": s["name"] + "档案", "type": "ILF", "desc": s["name"],
|
||
"evidence": "覆盖率%.0f%% 标准需求" % (float(s["coverage"] or 0) * 100),
|
||
"det": min(19, 5 + n_atoms), "ret": 1, "ftr": 0, "confidence": "medium"})
|
||
funcs.append({"name": s["name"] + "维护", "type": "EI", "desc": "新增/修改" + s["name"],
|
||
"evidence": "标准需求 CRUD", "det": min(15, 4 + n_atoms), "ret": 0,
|
||
"ftr": 1, "confidence": "medium"})
|
||
funcs.append({"name": s["name"] + "查询", "type": "EQ", "desc": "检索" + s["name"],
|
||
"evidence": "标准需求查询", "det": min(19, 4 + n_atoms), "ret": 0,
|
||
"ftr": 1, "confidence": "medium"})
|
||
ok_fp, fp = await fp_estimate(sor, funcs, project_name=cl.get("name") or "")
|
||
if not ok_fp:
|
||
return False, "FP 估算失败: %s" % fp
|
||
cfg = await get_fp_params(sor)
|
||
pm = round(float(fp["total_fp"]) * cfg["opp_fp_pm_per_fp"], 1)
|
||
cost = round(pm * cfg["opp_cost_wan_per_pm"], 1)
|
||
|
||
# LLM 写三节叙述(架构/商业价值/风险),其余节用真实数据拼装
|
||
arch = cov_txt = biz = risk = ""
|
||
try:
|
||
arch = await _llm(
|
||
"为「%s」软件写技术架构与可行性分析(≤200字):推荐技术栈、架构分层、"
|
||
"关键技术风险点。核心功能:%s。只输出正文。" % (
|
||
cl.get("name"), "、".join(s["name"] for s in core[:6]) or "、".join(
|
||
s["name"] for s in stds[:6])), ctx, timeout=90)
|
||
except Exception as e:
|
||
arch = "(架构分析生成失败:%s)" % str(e)[:60]
|
||
try:
|
||
biz = await _llm(
|
||
"为「%s」软件写商业价值分析(≤150字):目标客户、付费意愿依据(众包需求 %s 条、"
|
||
"核心共性 %d 项)、定价建议。只输出正文。" % (
|
||
cl.get("name"), cl.get("doc_count"), len(core)), ctx, timeout=90)
|
||
except Exception as e:
|
||
biz = "(商业价值分析生成失败:%s)" % str(e)[:60]
|
||
try:
|
||
risk = await _llm(
|
||
"为「%s」研发项目写风险清单(≤150字,3-5条,格式:- 风险:应对)。只输出正文。"
|
||
% cl.get("name"), ctx, timeout=90)
|
||
except Exception as e:
|
||
risk = "(风险分析生成失败:%s)" % str(e)[:60]
|
||
|
||
cov_txt = "\n".join("- %s(覆盖率 %.0f%%,%s,覆盖 %d 文档)" % (
|
||
s["name"], float(s["coverage"] or 0) * 100, s["tier"], s["doc_count"]) for s in stds[:20])
|
||
content = "\n\n".join([
|
||
"## 市场需求",
|
||
"类别「%s」来自需求挖掘批次 %s(scope=%s),类内众包需求 %s 条,占批次 %.1f%%。"
|
||
"数据来源:数据爬取平台众包需求(猪八戒/一品威客/Freelancer/PPH),明细可经"
|
||
" opp_cluster_detail 逐条查证。" % (
|
||
cl.get("name"), cl.get("batch_id"), cl.get("scope"), cl.get("doc_count"),
|
||
float(cl.get("share") or 0) * 100),
|
||
"## 共性需求",
|
||
cov_txt or "(无标准需求)",
|
||
"## 需求规格",
|
||
"核心共性(≥60%%):%s\n扩展共性(≥40%%):%s\n能力域:%s" % (
|
||
"、".join(s["name"] for s in core) or "无",
|
||
"、".join(s["name"] for s in ext) or "无",
|
||
"、".join(d["name"] for d in cov["domains"])),
|
||
"## 架构与可行性",
|
||
arch,
|
||
"## FP与成本",
|
||
"功能点合计 %s FP(规则引擎 fp_calc 计算,IFPUG 口径);按 %.2f 人月/FP 估 %s 人月;"
|
||
"按 %.1f 万元/人月估研发成本 %s 万元。\n%s" % (
|
||
fp["total_fp"], cfg["opp_fp_pm_per_fp"], pm, cfg["opp_cost_wan_per_pm"], cost,
|
||
(fp.get("markdown") or "")[:1500]),
|
||
"## 商业价值",
|
||
biz,
|
||
"## 风险",
|
||
risk,
|
||
"## 结论建议",
|
||
"建议立项优先级见评分(opp_score_candidate);本报告为草稿,需人工确认门禁后进入审批。",
|
||
])
|
||
ok_r, rid = await create_report(
|
||
ctx.get("project_id") or "", cl.get("name") or "未命名类别",
|
||
title="%s 研发可行性研究" % (cl.get("name") or ""), analysis=content,
|
||
created_by=ctx.get("user_id") or "agent.opportunity", report_type="feasibility")
|
||
if not ok_r:
|
||
return False, rid
|
||
# 评分落库到报告 content 前置 JSON?不——评分独立工具按需算。记录 FP 摘要进批次无关处:
|
||
return True, rid
|
||
|
||
|
||
async def score_candidate(sor, cluster_id, ctx):
|
||
"""规则评分:市场需求量×共性集中度×规模适配→优先级分。返回 (ok, dict|err)。"""
|
||
ok, info = await _cluster_ctx(sor, cluster_id, ctx)
|
||
if not ok:
|
||
return False, info
|
||
cl = info["cluster"]
|
||
cov = info["coverage"]
|
||
stds = cov["std_demands"]
|
||
n_docs = int(cl.get("doc_count") or 0)
|
||
core = [s for s in stds if s["tier"] == "core"]
|
||
ext = [s for s in stds if s["tier"] == "ext"]
|
||
# 需求热度分(log 规模,防大簇垄断)
|
||
import math
|
||
heat = min(100.0, 20 * math.log10(n_docs + 1))
|
||
# 共性集中度:核心+扩展覆盖文档占比
|
||
covered = sum(s["doc_count"] for s in core + ext)
|
||
conc = (covered / n_docs * 100) if n_docs else 0
|
||
# 规模适配:标准需求 8-25 个为最佳研发规模(过小不值得/过大需分期)
|
||
n_std = len(stds)
|
||
size_fit = 100.0 if 8 <= n_std <= 25 else (70.0 if 5 <= n_std < 8 or 25 < n_std <= 40 else 40.0)
|
||
score = round(heat * 0.4 + conc * 0.35 + size_fit * 0.25, 1)
|
||
stars = max(1, min(5, int(score // 20) + 1))
|
||
return True, {
|
||
"cluster": cl.get("name"), "score": score, "priority": "★" * stars,
|
||
"factors": {"需求热度(40%)": round(heat, 1), "共性集中度(35%)": round(conc, 1),
|
||
"规模适配(25%)": size_fit},
|
||
"detail": {"doc_count": n_docs, "core": len(core), "ext": len(ext), "std": n_std},
|
||
}
|
||
|
||
|
||
async def promote_to_project(sor, report_id, ctx):
|
||
"""审批通过的可行性报告 → 立项(sd_projects,pipeline=opportunity_general)。
|
||
门禁:报告必须 approved(人工审批回流),agent 不能自批自审。"""
|
||
recs = await sor.sqlExe(
|
||
"SELECT r.*, a.status AS appr_status FROM opp_reports r "
|
||
"LEFT JOIN opp_approvals a ON a.report_id=r.id "
|
||
"WHERE r.id=${r}$ ORDER BY a.created_at DESC LIMIT 1", {"r": report_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if not recs:
|
||
return False, "报告不存在"
|
||
rep = rows_to_dicts(recs, limit=1)[0]
|
||
if rep.get("report_type") != "feasibility":
|
||
return False, "仅可行性研究报告可立项"
|
||
# 门禁链:draft→confirmed(人工确认)→approval_initiated→approved(审批回流)。
|
||
# resolve_approval 会把报告 status 置 approved,所以 confirmed 只是中间态——
|
||
# 状态在 confirmed/approval_initiated/approved 都说明已过人工确认门禁。
|
||
if rep.get("status") not in ("confirmed", "approval_initiated", "approved"):
|
||
return False, "报告未过人工确认门禁(status=%s),不能立项" % (rep.get("status") or "draft")
|
||
if (rep.get("appr_status") or "") != "approved":
|
||
return False, "研发审批未通过(status=%s),不能立项" % (rep.get("appr_status") or "无审批单")
|
||
# 幂等守卫(2026-09-14 E2E 实测:重复调用会重复建项目):description 带
|
||
# report_id 精确标记,立项前先查——已立过则返回既有项目,不再新建。
|
||
desc = "由需求挖掘可行性报告立项:%s [report_id=%s]" % (rep.get("title"), report_id)
|
||
exist = await sor.sqlExe(
|
||
"SELECT id, name FROM sd_projects WHERE description=${d}$ LIMIT 1", {"d": desc})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if exist:
|
||
return True, {"project_id": getattr(exist[0], "id", ""),
|
||
"project_name": getattr(exist[0], "name", ""), "reused": True}
|
||
from pipeline_service.project_capability import create_project
|
||
ok, pid = await create_project(
|
||
name="%s 研发项目" % (rep.get("software") or "未命名"),
|
||
project_type="demand_mining", description=desc,
|
||
org_id=ctx.get("org_id") or "0", created_by=ctx.get("user_id") or "",
|
||
pipeline_id="opportunity_general")
|
||
if not ok:
|
||
return False, "立项失败: %s" % pid
|
||
return True, {"project_id": pid, "reused": False}
|