306 lines
15 KiB
Python
Raw Permalink 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.

# -*- 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}