feat(feasibility): P3可行性研究——opp_feasibility引擎(fp_estimate规则引擎FP/draft_feasibility八节报告/score_candidate评分/promote_to_project立项门禁)+report_type列(models/DDL/CRUD重生成)+详情页与PPT按类型选四节或八节模板(sections_for_type单一规则源)+列表类型徽标+prompt链路C+params/demand_mining字典种子+i18n词条

- fp_calc.calc()返回键实锤核对: total_fp/counts/errors(原total_ufp/by_type兜底猜错,已修)
- promote门禁修正: resolve_approval会把status置approved,confirmed只是中间态→门禁接受confirmed/approval_initiated/approved(原只认confirmed会导致审批通过后反而不能立项)
- _cluster_ctx补项目隔离(对齐_check_batch_visible口径)+全personal类别如实拒绝可行性样本不足
- 八节模板兜底节=需求规格;四节存量解析口径零变化
- opp_approvals CRUD手改(项目过滤)被xls2ui重生成吃掉→已checkout还原,只保留opp_reports新列部分
This commit is contained in:
yumoqing 2026-09-14 09:03:58 +08:00
parent ca921d3550
commit 4092304726
24 changed files with 617 additions and 73 deletions

View File

@ -151,6 +151,45 @@ CRUD 页(`opp_reports/` `opp_approvals/`)保留供后台运维直接编辑
- 验证脚本scripts/p0_probe_vdb_embed*.pyVDB/embed 协议探测)、
p1_cluster_analyze.py多τ对比+缓存调参用、p1_create_tables.py幂等建表
## 可行性研究P32026-09-14
挖掘类别 → 可行性研究 → 评分 → 立项report_type=feasibility 八节报告):
- **opp_feasibility.py**(引擎):
- `fp_estimate`功能点估算。LLM 只提供结构化功能清单
name/type=EI|EO|EQ|ILF|EIF/det/ret/ftr/evidence/confidence
复杂度判定与 FP 加权**全部走规则引擎 fp_calc.py**
pipeline-core `skills_library/all/function-point-counting/`,部署位
`skills/global/`,进程内 importlib 加载;加载失败如实报错,绝不 LLM 心算 FP
返回键对齐 fp_calc.calc() 实际输出total_fp/counts(→by_type)/errors/pending。
- `draft_feasibility(cluster_id)`类别须已完成共性提取normalize done且有
core/ext 标准需求(全 personal 时如实拒绝"可行性样本不足"。FP 输入=每条
核心/扩展标准需求映射 1 ILF+1 EI+1 EQDET 按证据原子数粗估);成本=FP×
opp_fp_pm_per_fp默认0.05人月/FP×opp_cost_wan_per_pm默认3.0万元/人月),
params 可配。LLM 只写三节叙述(架构与可行性/商业价值/风险),其余五节用
挖掘真实数据拼装。落库 opp_reportsreport_type=feasibility
- `score_candidate(cluster_id)`:规则评分=需求热度40%log 规模防大簇垄断)+
共性集中度35%core+ext 覆盖文档占比)+规模适配25%(标准需求 8-25 个最佳)。
- `promote_to_project(report_id)`:立项门禁——仅 report_type=feasibility 且
过人工确认status∈confirmed/approval_initiated/approved且审批单 approved
才可建 sd_projectspipeline=opportunity_generalproject_type=demand_mining
字典种子在 init/data.json。agent 不能自批自审。
- 项目隔离:`_cluster_ctx` 对齐 `_check_batch_visible` 口径(批次挂项目的仅
同项目会话可见,平台级批次本机构可见)。
- **八节模板**report_type=feasibility`## 市场需求 / ## 共性需求 / ## 需求规格 /
## 架构与可行性 / ## FP与成本 / ## 商业价值 / ## 风险 / ## 结论建议`。
`sections_for_type(report_type)` 是单一规则源详情页opp_report_detail.dspy
与 PPTopp_ppt.py FEASIBILITY_ORDER按类型选四节/八节渲染;
research 报告四节模板不变,存量报告解析口径零变化。
- **opp_reports.report_type 列**VARCHAR(16) DEFAULT 'research'
测试机/生产走 pipeline-app `deploy/migrations/m0030_opp_reports_report_type.json`
dmig apply幂等 ADD COLUMNmodels/opp_reports.json 已同步(新建表天然带列)。
- **报告列表标签**opp_reports_list.dspy 行内加类型徽标(调研=蓝/可行性研究=紫)。
- PPT 文件名区分feasibility_report_<software>_<rid8>.pptx。
- 4 个新工具注册进 OPP_HANDLERSopp_fp_estimate/opp_draft_feasibility/
opp_score_candidate/opp_promote_to_projectOPP_PROMPT 加「可行性研究与立项」
流程段opp-workflow 技能同步链路C
## 技能
产线技能在 pipeline-core 技能库:`skills_library/pipelines/opportunity_general/`

View File

@ -37,3 +37,14 @@
下载=Download
无权查看=No Access
暂无成果文件=No output files yet
可行性研究=Feasibility Study
调研=Research
需求挖掘立项=Demand-Mining Initiative
市场需求=Market Demand
共性需求=Common Requirements
需求规格=Requirement Spec
架构与可行性=Architecture & Feasibility
FP与成本=FP & Cost
商业价值=Business Value
风险=Risks
结论建议=Conclusion

View File

@ -37,3 +37,14 @@
下载=下载
无权查看=无权查看
暂无成果文件=暂无成果文件
可行性研究=可行性研究
调研=调研
需求挖掘立项=需求挖掘立项
市场需求=市场需求
共性需求=共性需求
需求规格=需求规格
架构与可行性=架构与可行性
FP与成本=FP与成本
商业价值=商业价值
风险=风险
结论建议=结论建议

View File

@ -29,6 +29,21 @@
{"k": "approved", "v": "审批通过"},
{"k": "rejected", "v": "审批驳回"}
]
},
{
"parentid": "sd_project_type",
"parentname": "项目类型",
"items": [
{"k": "demand_mining", "v": "需求挖掘立项"}
]
},
{
"parentid": "opp_report_type",
"parentname": "研发报告类型",
"items": [
{"k": "research", "v": "调研报告"},
{"k": "feasibility", "v": "可行性研究"}
]
}
],
"params": [
@ -43,6 +58,8 @@
{"name": "opp_norm_tau2", "value": "0.85", "desc": "归一化相似度触发阈值(只当触发器,LLM裁决)"},
{"name": "opp_norm_topk", "value": "10", "desc": "原子kNN邻居数"},
{"name": "opp_cov_core", "value": "0.6", "desc": "核心共性覆盖率阈值"},
{"name": "opp_cov_ext", "value": "0.4", "desc": "扩展共性覆盖率阈值"}
{"name": "opp_cov_ext", "value": "0.4", "desc": "扩展共性覆盖率阈值"},
{"name": "opp_fp_pm_per_fp", "value": "0.05", "desc": "FP→人月经验系数(可行性研究成本估算,P3)"},
{"name": "opp_cost_wan_per_pm", "value": "3.0", "desc": "人月单价万元(可行性研究成本估算,P3)"}
]
}

View File

@ -50,6 +50,13 @@
"nullable": "no",
"default": "draft"
},
{
"name": "report_type",
"title": "报告类型(research调研/feasibility可行性研究)",
"type": "str",
"length": 16,
"default": "research"
},
{
"name": "confirm_task_id",
"title": "确认任务ID",

View File

@ -17,6 +17,7 @@ CREATE TABLE opp_reports
`title` VARCHAR(255) NOT NULL comment '报告标题',
`content` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '报告正文',
`status` VARCHAR(24) NOT NULL DEFAULT 'draft' comment '报告状态',
`report_type` VARCHAR(16) DEFAULT 'research' comment '报告类型(research调研/feasibility可行性研究)',
`confirm_task_id` VARCHAR(32) comment '确认任务ID',
`ppt_path` VARCHAR(500) comment 'PPT文件路径',
`confirmed_by` VARCHAR(64) comment '确认人',

View File

@ -93,6 +93,14 @@ OPP_TOOLS = [
parameters={"cluster_id": "类别ID"}, category="mining"),
ToolDefinition(name="opp_coverage_report", description="查看某类别的共性需求覆盖率报告:核心共性(覆盖率≥60%)/扩展共性(≥40%)/个性需求三层 + 能力域分组 + 每条标准需求的覆盖文档数与证据",
parameters={"cluster_id": "类别ID"}, category="mining"),
ToolDefinition(name="opp_fp_estimate", description="软件功能点估算IFPUG传入结构化功能清单(name/type=EI|EO|EQ|ILF|EIF/det/ret/ftr/evidence/confidence)复杂度与FP由规则引擎fp_calc计算禁LLM心算返回总FP/分类型计数/待确认项/四件套markdown",
parameters={"functions_json": "功能清单JSON数组字符串", "project_name": "项目名(可选)"}, category="mining"),
ToolDefinition(name="opp_draft_feasibility", description="起草八节可行性研究报告report_type=feasibility市场需求/共性需求/需求规格/架构与可行性/FP与成本/商业价值/风险/结论建议。FP走规则引擎数据全部来自挖掘结果。返回 report_id",
parameters={"cluster_id": "类别ID(须已完成共性提取)"}, category="mining"),
ToolDefinition(name="opp_score_candidate", description="研发项目候选评分规则模型需求热度40%+共性集中度35%+规模适配25%→总分与★优先级",
parameters={"cluster_id": "类别ID"}, category="mining"),
ToolDefinition(name="opp_promote_to_project", description="可行性报告立项:仅 report_type=feasibility 且过人工确认+审批approved 的报告可建 sd_projects 研发项目门禁agent不能自批自审",
parameters={"report_id": "报告ID"}, category="mining"),
]
# ══════════════════ 产线 prompt 片段 ══════════════════
@ -149,10 +157,28 @@ opp_create_report / opp_update_report 的正文必须用以下四个二级标题
2. opp_mining_status 轮询进度每轮回复用户当前状态failed 时如实转述原因禁隐瞒
3. done opp_list_clusters 看类别排名热度/需求数/占比向用户呈现 TopX 表格
4. 用户选定类别后 opp_cluster_detail 取类内样例每条带来源URL结合数据写分析报告
需要立项走可行性研究时 opp_create_report 建报告数据基础引用挖掘批次与类别证据
需要立项走可行性研究时用 **opp_draft_feasibility**见下节不要用
opp_create_report 手写可行性报告
硬规则聚类结果只能来自 opp_* mining 工具返回禁止自己编类别/编排名/编数量
类别命名证据naming_evidence 样例要能支撑命名命名可疑时如实说明
## 可行性研究与立项P3report_type=feasibility
用户要可行性研究/评估立项/能不能做这个项目时走这条链
1. 类别先做共性提取opp_normalize_cluster未提取过时报错引导
opp_coverage_report 看核心/扩展/个性三层共性
2. opp_fp_estimate功能点估算LLM 只识别功能name/type=EI|EO|EQ|ILF|EIF/det/ftr
复杂度与 FP 加权由规则引擎 fp_calc 计算**禁止自己心算/编造 FP 数值**
3. opp_draft_feasibility(cluster_id)自动生成八节可行性研究报告草稿
市场需求/共性需求/需求规格/架构与可行性/FP与成本/商业价值/风险/结论建议
数据全部来自挖掘与覆盖率结果返回 report_id可用 opp_update_report 补写完善
4. opp_score_candidate(cluster_id)规则评分需求热度40%+共性集中度35%+规模适配25%
给出优先级向用户如实呈现评分因子
5. 立项门禁不可绕过agent 不能自批自审
opp_submit_report 提交人工确认 人工确认通过confirmed 发起研发审批
审批回流 approved 才可用 opp_promote_to_project(report_id) 立项
sd_projectspipeline=opportunity_generalproject_type=demand_mining
报告未过确认/审批时 promote 会被门禁拒绝如实转述拒绝原因禁止绕道
## 硬规则
- **统计优先用聚合工具明细只取样例2026-09-11 实测教训**
数量/排名/预算合计/平均价这类**统计**一律用聚合工具opp_hot_software招标侧
@ -532,6 +558,55 @@ async def _h_coverage_report(sor, p, ctx):
"标准需求": rows}, ensure_ascii=False, default=str)
async def _h_fp_estimate(sor, p, ctx):
from .opp_feasibility import fp_estimate
fj = p.get("functions_json") or ""
if not fj:
return _fmt(False, "functions_json 必填")
ok, res = await fp_estimate(sor, fj, project_name=p.get("project_name") or "")
if not ok:
return _fmt(False, res)
return json.dumps({"总FP": res["total_fp"], "分类型": res["by_type"],
"待确认": res["pending"],
"输入校验错误": res["errors"],
"四件套": res["markdown"][:2000]}, ensure_ascii=False, default=str)
async def _h_draft_feasibility(sor, p, ctx):
from .opp_feasibility import draft_feasibility
cid = (p.get("cluster_id") or "").strip()
if not cid:
return _fmt(False, "cluster_id 必填")
ok, rid = await draft_feasibility(sor, cid, ctx)
if not ok:
return _fmt(False, rid)
return _fmt(True, "可行性报告草稿已建 report_id=%s八节模板FP 由规则引擎计算)。"
"补写后可 opp_submit_report 提交人工确认" % rid)
async def _h_score_candidate(sor, p, ctx):
from .opp_feasibility import score_candidate
cid = (p.get("cluster_id") or "").strip()
if not cid:
return _fmt(False, "cluster_id 必填")
ok, res = await score_candidate(sor, cid, ctx)
if not ok:
return _fmt(False, res)
return json.dumps(res, ensure_ascii=False, default=str)
async def _h_promote_to_project(sor, p, ctx):
from .opp_feasibility import promote_to_project
rid = (p.get("report_id") or "").strip()
if not rid:
return _fmt(False, "report_id 必填")
ok, pid = await promote_to_project(sor, rid, ctx)
if not ok:
return _fmt(False, pid)
return _fmt(True, "已立项 project_id=%spipeline=opportunity_general"
"project_type=demand_mining" % pid)
OPP_HANDLERS = {
"opp_hot_software": _h_hot_software,
"opp_daily_ai_tenders": _h_daily_ai,
@ -559,6 +634,11 @@ OPP_HANDLERS = {
# 类内共性提取P2
"opp_normalize_cluster": _h_normalize_cluster,
"opp_coverage_report": _h_coverage_report,
# 可行性研究P3
"opp_fp_estimate": _h_fp_estimate,
"opp_draft_feasibility": _h_draft_feasibility,
"opp_score_candidate": _h_score_candidate,
"opp_promote_to_project": _h_promote_to_project,
}

View File

@ -0,0 +1,296 @@
# -*- coding:utf-8 -*-
"""P3 可行性研究引擎FP 估算(规则引擎禁 LLM 报数)→ 八节可行性报告 → 评分 → 立项。
分工铁律function-point-counting 技能LLM 只做语义功能识别/DET/FTR 提取
复杂度判定与 FP 加权必须走规则引擎 fp_calc.pyskills/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」来自需求挖掘批次 %sscope=%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_projectspipeline=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 "无审批单")
from pipeline_service.project_capability import create_project
ok, pid = await create_project(
name="%s 研发项目" % (rep.get("software") or "未命名"),
project_type="demand_mining", description="由需求挖掘可行性报告立项:%s" % rep.get("title"),
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, pid

View File

@ -41,6 +41,10 @@ MAX_BULLETS_PER_PAGE = 7
# 四节模板顺序(与 opp_report_capability.REPORT_SECTIONS 一致)
SECTION_ORDER = ("研发场景", "输入", "输出", "成果")
# 可行性研究八节模板(与 opp_report_capability.FEASIBILITY_SECTIONS 一致P3
FEASIBILITY_ORDER = ("市场需求", "共性需求", "需求规格", "架构与可行性",
"FP与成本", "商业价值", "风险", "结论建议")
def _rgb(t):
from pptx.dml.color import RGBColor
@ -140,18 +144,23 @@ def _split_lead(text):
return "", _clean_inline(text)
def _parse_report(content):
"""节模板解析(与详情页同口径):返回 [(节名, markdown)]。
def _parse_report(content, report_type="research"):
"""节模板解析(与详情页同口径):返回 [(节名, markdown)]。
无四节标题的旧报告 整篇归输出如实呈现不编造分节
research=四节研发场景/输入/输出/成果feasibility=八节
无节标题的旧报告 整篇归输出如实呈现不编造分节
"""
content = content or ""
is_feas = report_type == "feasibility"
names = FEASIBILITY_ORDER if is_feas else SECTION_ORDER
fallback = "需求规格" if is_feas else "输出"
pat = re.compile(
r"^##\s*(?:[一二三四五六七八九十\d]+[、.]?\s*)?[*]*\s*"
r"(研发场景|输入|输出|成果)\s*[*]*\s*[:]?\s*$", re.MULTILINE)
r"^##\s*(?:[一二三四五六七八九十\d]+[、.]?\s*)?[*]*\s*("
+ "|".join(re.escape(n) for n in names)
+ r")\s*[*]*\s*[:]?\s*$", re.MULTILINE)
marks = list(pat.finditer(content))
if not marks:
return [("输出", content.strip())]
return [(fallback, content.strip())]
out = []
preamble = content[:marks[0].start()].strip()
if preamble:
@ -161,7 +170,7 @@ def _parse_report(content):
body = content[m.end():end].strip()
if body:
out.append((m.group(1), body))
return out or [("输出", content.strip())]
return out or [(fallback, content.strip())]
# ══════════════════════ 版式基元 ══════════════════════
@ -432,10 +441,11 @@ def _closing(prs, page_no):
# ══════════════════════ 主入口 ══════════════════════
def build_report_ppt(title, content, software="", out_path=""):
def build_report_ppt(title, content, software="", out_path="", report_type="research"):
"""生成 PPT 并保存。返回 (True, 文件路径) 或 (False, 错误)。
签名保持不变opp_report_capability._build_report_ppt 调用方零改动
report_type: research=四节章节页 / feasibility=八节章节页P3
其余参数签名保持不变旧调用方零改动
"""
try:
from pptx import Presentation
@ -456,14 +466,15 @@ def build_report_ppt(title, content, software="", out_path=""):
# 预扫描:算出要渲染的页面,便于一次性算总页数(页脚 x/y
pages = [] # [('divider', idx, name) | ('bullets', title, kicker, items)
# | ('table', title, kicker, rows, extra)]
sections = _parse_report(content)
sections = _parse_report(content, report_type)
sec_names = FEASIBILITY_ORDER if report_type == "feasibility" else SECTION_ORDER
sec_idx = 0
for sec_name, sec_md in sections:
if sec_name in SECTION_ORDER:
if sec_name in sec_names:
sec_idx += 1
pages.append(("divider", sec_idx, sec_name))
blocks = _split_blocks(sec_md)
cur_title = sec_name if sec_name not in SECTION_ORDER else sec_name
cur_title = sec_name
buf_items = [] # [(lvl, lead, text)]
pending_table = None

View File

@ -28,12 +28,19 @@ logger = logging.getLogger("pipeline.opportunity.report")
async def create_report(project_id, software, title="", analysis="",
created_by="agent.opportunity"):
"""创建研发报告草稿。返回 (True, report_id) 或 (False, 错误)。"""
created_by="agent.opportunity", report_type="research"):
"""创建研发报告草稿。返回 (True, report_id) 或 (False, 错误)。
report_type2026-09-12 P3research=调研报告四节模板/
feasibility=可行性研究八节模板opp_feasibility 起草
"""
if not software:
return False, "缺少 software软件/主题名)"
if report_type not in ("research", "feasibility"):
return False, "report_type 必须是 research 或 feasibility"
if not title:
title = "%s 研发机会分析报告" % software
title = ("%s 研发可行性研究" % software) if report_type == "feasibility" \
else ("%s 研发机会分析报告" % software)
db, dbname = get_db()
rid = new_id()
async with db.sqlorContext(dbname) as sor:
@ -44,6 +51,7 @@ async def create_report(project_id, software, title="", analysis="",
"title": title,
"content": analysis or "",
"status": RP_DRAFT,
"report_type": report_type,
"created_by": created_by,
})
await sor.sqlExe("COMMIT", {})
@ -89,6 +97,16 @@ async def list_reports(project_id="", status=""):
# 报告正文四节模板二级标题agent 写报告必须按此结构;旧报告无节时全文归「输出」)
REPORT_SECTIONS = ("研发场景", "输入", "输出", "成果")
# 可行性研究八节模板report_type=feasibilityP3 2026-09-14
# draft_feasibility 起草,详情页/PPT 按此分节渲染)
FEASIBILITY_SECTIONS = ("市场需求", "共性需求", "需求规格", "架构与可行性",
"FP与成本", "商业价值", "风险", "结论建议")
def sections_for_type(report_type):
"""按报告类型选节模板(单一规则源:详情页/PPT/解析复用)。"""
return FEASIBILITY_SECTIONS if report_type == "feasibility" else REPORT_SECTIONS
async def _user_org(sor, user_id):
recs = await sor.sqlExe(
@ -114,7 +132,8 @@ async def list_visible_reports(sor, user_id):
# (实测 ValueError: unsupported format characterrag_client.py 的
# NOT LIKE 'agent.%' 是同一颗潜伏雷)。参数值里的 % 安全。
sql = (
"SELECT r.id, r.project_id, r.software, r.title, r.status, r.ppt_path, "
"SELECT r.id, r.project_id, r.software, r.title, r.status, r.report_type, "
"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 "
@ -164,27 +183,32 @@ async def check_report_visible(sor, report_id, user_id):
return False, rep
def parse_report_sections(content):
"""四节模板解析报告正文 → {'研发场景': md, '输入': md, '输出': md, '成果': md}。
def parse_report_sections(content, report_type="research"):
"""节模板解析报告正文 → {节名: markdown}。
模板agent 写报告约定二级标题 `## 研发场景` / `## 输入` / `## 输出` / `## 成果`。
research默认四节 `## 研发场景/输入/输出/成果`
feasibility八节 `## 市场需求/共性需求/需求规格/架构与可行性/FP与成本/商业价值/风险/结论建议`。
兼容标题带序号/冒号/加粗 `## 一、研发场景:`、`## **输出**`)。
旧报告无任何匹配节如实测的市场调研报告 全文归输出其余节为空
详情页对空节如实显示本报告未填写此节不编造
"""
import re as _re
content = content or ""
sec_names = sections_for_type(report_type)
# 节标题行:## [可选序号/标点] 节名 [可选冒号/加粗残留]
pattern = _re.compile(
r"^##\s*(?:[一二三四五六七八九十\d]+[、.]?\s*)?[**]*\s*"
r"(研发场景|输入|输出|成果)\s*[**]*\s*[:]?\s*$",
r"(" + "|".join(_re.escape(n) for n in sec_names) + r")\s*[**]*\s*[:]?\s*$",
_re.MULTILINE)
marks = list(pattern.finditer(content))
sections = {k: "" for k in REPORT_SECTIONS}
sections = {k: "" for k in sec_names}
if not marks:
sections["输出"] = content.strip()
# 无匹配节标题:四节模板归「输出」,八节模板归「需求规格」(承载正文主体)
fallback = "输出" if report_type != "feasibility" else "需求规格"
sections[fallback] = content.strip()
return sections
# 首个节标题之前的前言(如报告大标题/数据来源说明)归「输出」节头部展示
# 首个节标题之前的前言(如报告大标题/数据来源说明)归兜底节头部展示
fallback = "输出" if report_type != "feasibility" else "需求规格"
preamble = content[:marks[0].start()].strip()
for i, m in enumerate(marks):
name = m.group(1)
@ -193,7 +217,7 @@ def parse_report_sections(content):
# 同名节重复出现时拼接(容错,不丢内容)
sections[name] = (sections[name] + "\n\n" + body).strip() if sections[name] else body
if preamble:
sections["输出"] = (preamble + ("\n\n" + sections["输出"] if sections["输出"] else "")).strip()
sections[fallback] = (preamble + ("\n\n" + sections[fallback] if sections[fallback] else "")).strip()
return sections
@ -261,11 +285,13 @@ async def _build_report_ppt(sor, rep):
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])
prefix = "feasibility_report" if rep.get("report_type") == "feasibility" else "research_report"
fname = "%s_%s_%s.pptx" % (prefix, 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)
software=rep.get("software", ""), out_path=out_path,
report_type=rep.get("report_type") or "research")
if not ok:
logger.warning("build_report_ppt 失败: %s", str(res)[:200])
return ""

View File

@ -64,6 +64,16 @@ appbase `params` 表两个键(`opp_common.get_crawler_config` 读取,默认
| `opp_mining_status(batch_id)` | 批次状态轮询pulling/embedding/clustering/naming/done/failed+原因) |
| `opp_list_clusters(batch_id)` | 类别排名表(热度/需求数/占比/命名证据)= TopX 热门类别 |
| `opp_cluster_detail(cluster_id,limit)` | 类内需求明细样例每条带来源URL可查证 |
| `opp_normalize_cluster(cluster_id)` | 类内共性提取原子化→归一→能力域→覆盖率LLM多耗时正比文档数 |
| `opp_coverage_report(cluster_id)` | 共性覆盖率报告core≥60%/ext≥40%/个性三层+能力域分组) |
| `opp_fp_estimate(functions_json,project_name)` | 功能点估算LLM只给功能清单复杂度/FP加权走规则引擎fp_calc禁心算 |
| `opp_draft_feasibility(cluster_id)` | 起草八节可行性报告report_type=feasibility需先normalize且有core/ext共性 |
| `opp_score_candidate(cluster_id)` | 候选评分热度40%+集中度35%+规模适配25%→★优先级 |
| `opp_promote_to_project(report_id)` | 立项门禁仅feasibility+人工确认+审批approved→sd_projects(demand_mining) |
可行性报告八节模板(详情页/PPT 按 report_type 分节渲染,`sections_for_type` 单一规则源):
`市场需求/共性需求/需求规格/架构与可行性/FP与成本/商业价值/风险/结论建议`
调研报告research仍是四节模板存量报告解析口径不变。
角色:`agent.opp_analyst` / `agent.opp_writer`slash`/hot` `/ai` `/reports` `/oppdiag`

View File

@ -6,8 +6,8 @@
"widgettype": "HBox",
"options": {"width": "100%", "alignItems": "center", "padding": "16px 24px 8px 24px", "cheight": 6, "gap": "12px"},
"subwidgets": [
{"widgettype": "Title2", "options": {"text": "商机产线"}},
{"widgettype": "Text", "options": {"text": "热点软件 → 今日AI招标 → 检索 → 编写研发报告 → 人工确认 → 研发审批", "cfontsize": 0.9, "color": "#94a3b8"}},
{"widgettype": "Title2", "options": {"otext": "商机产线", "text": "商机产线", "i18n": true}},
{"widgettype": "Text", "options": {"otext": "热点软件 → 今日AI招标 → 检索 → 编写研发报告 → 人工确认 → 研发审批", "text": "热点软件 → 今日AI招标 → 检索 → 编写研发报告 → 人工确认 → 研发审批", "i18n": true, "cfontsize": 0.9, "color": "#94a3b8"}},
{"widgettype": "Filler"},
{
"widgettype": "Button",

View File

@ -27,13 +27,13 @@ async with DBPools().sqlorContext(dbname) as sor:
rows = []
if not ok:
rows.append({"widgettype": "Text", "options": {
"text": "查询失败:" + str(res)[:200],
"otext": "查询失败:${p0}", "text": "查询失败:${p0}", "i18n": True, "i18n_params": {"p0": str(str(res)[:200])},
"color": "#dc2626", "padding": "12px", "halign": "left"}})
else:
items = res.get("items") or []
if not items:
rows.append({"widgettype": "Text", "options": {
"text": "该主题近 %d 天无众包需求分项" % days, "color": "#64748b", "padding": "12px", "halign": "left"}})
"otext": "该主题近 ${p0} 天无众包需求分项", "text": "该主题近 ${p0} 天无众包需求分项", "i18n": True, "i18n_params": {"p0": str('%d' % (days,))}, "color": "#64748b", "padding": "12px", "halign": "left"}})
for it in items:
item_id = str(it.get("id") or "")
budget = it.get("budget_wan") or 0
@ -90,7 +90,7 @@ return {
"padding": "12px 16px", "gap": "0px", "overflow": "auto"},
"subwidgets": [
{"widgettype": "Text", "options": {
"text": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。",
"otext": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。", "text": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。", "i18n": True,
"cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}
] + rows
}]

View File

@ -27,7 +27,7 @@ dl_base = entire_url("/pipeline-opportunity/api/opp_attachment_dl.dspy")
body = []
if not ok or not isinstance(det, dict):
body.append({"widgettype": "Text", "options": {
"text": "获取详情失败:" + str(det)[:200],
"otext": "获取详情失败:${p0}", "text": "获取详情失败:${p0}", "i18n": True, "i18n_params": {"p0": str(str(det)[:200])},
"color": "#dc2626", "padding": "12px", "halign": "left"}})
else:
# ── 标题 ──
@ -111,7 +111,7 @@ else:
# ── 公告/需求全文markdown 渲染链接可点击2026-09-09 用户定夺改 MdWidget──
detail_text = str(det.get("detail_text") or "").strip()
body.append({"widgettype": "Text", "options": {
"text": "全文内容", "cfontsize": 0.95, "color": "#0f172a",
"otext": "全文内容", "text": "全文内容", "i18n": True, "cfontsize": 0.95, "color": "#0f172a",
"halign": "left", "margin": "0 0 4px 0"}})
if detail_text:
shown = detail_text[:20000]
@ -123,7 +123,7 @@ else:
"borderRadius": "8px", "margin": "0 0 10px 0", "bgcolor": "#ffffff"}})
else:
body.append({"widgettype": "Text", "options": {
"text": "爬取平台未保存该条全文,可点「打开原文页」查看。",
"otext": "爬取平台未保存该条全文,可点「打开原文页」查看。", "text": "爬取平台未保存该条全文,可点「打开原文页」查看。", "i18n": True,
"cfontsize": 0.82, "color": "#94a3b8", "halign": "left",
"margin": "0 0 10px 0"}})
@ -140,7 +140,7 @@ else:
"margin": "0 0 10px 0"}})
elif not atts:
body.append({"widgettype": "Text", "options": {
"text": "该条暂无附件", "cfontsize": 0.8, "color": "#94a3b8",
"otext": "该条暂无附件", "text": "该条暂无附件", "i18n": True, "cfontsize": 0.8, "color": "#94a3b8",
"halign": "left", "margin": "0 0 10px 0"}})
else:
for a in atts:

View File

@ -48,7 +48,7 @@ else:
clusters = _res if isinstance(_res, list) else []
if not clusters:
rows.append({"widgettype": "Text", "options": {
"text": "该批次没有聚类结果(可能范围内无需求数据)",
"otext": "该批次没有聚类结果(可能范围内无需求数据)", "text": "该批次没有聚类结果(可能范围内无需求数据)", "i18n": True,
"color": "#64748b", "padding": "12px", "halign": "left"}})
for c in clusters:
cid = str(c.get("id") or "")
@ -93,7 +93,7 @@ else:
"wrap": True}},
]},
{"widgettype": "Filler"},
{"widgettype": "Text", "options": {"text": "%d 条 %.1f%%" % (cnt, share),
{"widgettype": "Text", "options": {"otext": "${p0} 条 ${p1}%", "text": "${p0} 条 ${p1}%", "i18n": True, "i18n_params": {"p0": str('%d' % (cnt,)), "p1": str('%.1f' % (share,))},
"cfontsize": 0.85, "color": "#64748b"}},
{"widgettype": "Text", "options": {"text": "", "cfontsize": 1.1, "color": "#94a3b8"}},
]})
@ -109,7 +109,7 @@ return {
"gap": "4px", "overflow": "auto"},
"subwidgets": [
{"widgettype": "Text", "options": {
"text": "点击任一类别,查看类内需求明细(每条带来源链接可查证)。",
"otext": "点击任一类别,查看类内需求明细(每条带来源链接可查证)。", "text": "点击任一类别,查看类内需求明细(每条带来源链接可查证)。", "i18n": True,
"cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}},
] + rows,
}],

View File

@ -57,7 +57,7 @@ else:
items = _res.get("samples") or []
if not items:
rows.append({"widgettype": "Text", "options": {
"text": "该类别暂无明细", "color": "#64748b", "padding": "12px", "halign": "left"}})
"otext": "该类别暂无明细", "text": "该类别暂无明细", "i18n": True, "color": "#64748b", "padding": "12px", "halign": "left"}})
for it in items:
src_id = str(it.get("src_id") or "")
title = str(it.get("title") or "")
@ -113,7 +113,7 @@ return {
"padding": "12px 16px", "gap": "0px", "overflow": "auto"},
"subwidgets": [
{"widgettype": "Text", "options": {
"text": "点击任一条目弹出需求详情(数据来自数据爬取平台快照,每条标注来源平台)。",
"otext": "点击任一条目弹出需求详情(数据来自数据爬取平台快照,每条标注来源平台)。", "text": "点击任一条目弹出需求详情(数据来自数据爬取平台快照,每条标注来源平台)。", "i18n": True,
"cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}},
] + rows,
}],

View File

@ -77,8 +77,7 @@ def _start_script(scope):
rows = []
# ── 顶部:启动按钮 + 说明 ──
rows.append({"widgettype": "Text", "options": {
"text": "需求挖掘:把众包平台(猪八戒/一品威客/Freelancer/PPH的软件需求做语义聚类"
"发现市场热门软件类别TopX或分析你指定的需求类型。批次后台执行完成后点击行查看类别排名。",
"otext": "需求挖掘:把众包平台(猪八戒/一品威客/Freelancer/PPH的软件需求做语义聚类发现市场热门软件类别TopX或分析你指定的需求类型。批次后台执行完成后点击行查看类别排名。", "text": "需求挖掘:把众包平台(猪八戒/一品威客/Freelancer/PPH的软件需求做语义聚类发现市场热门软件类别TopX或分析你指定的需求类型。批次后台执行完成后点击行查看类别排名。", "i18n": True,
"cfontsize": 0.8, "color": "#64748b", "halign": "left", "wrap": True, "margin": "0 0 8px 0"}})
rows.append({"widgettype": "HBox", "options": {"width": "100%", "gap": "10px", "margin": "0 0 12px 0"},
"subwidgets": [
@ -96,13 +95,12 @@ rows.append({"widgettype": "HBox", "options": {"width": "100%", "gap": "10px", "
if not _ok:
rows.append({"widgettype": "Text", "options": {
"text": "查询失败:" + str(_res)[:200], "color": "#dc2626", "padding": "12px", "halign": "left"}})
"otext": "查询失败:${p0}", "text": "查询失败:${p0}", "i18n": True, "i18n_params": {"p0": str(str(_res)[:200])}, "color": "#dc2626", "padding": "12px", "halign": "left"}})
else:
batches = _res if isinstance(_res, list) else []
if not batches:
rows.append({"widgettype": "Text", "options": {
"text": "还没有挖掘批次。点击上方「⛏ 启动全量挖掘」开始首次全量约3-5分钟"
"embedding 有全机构共享缓存,二次批次显著更快)。",
"otext": "还没有挖掘批次。点击上方「⛏ 启动全量挖掘」开始首次全量约3-5分钟embedding 有全机构共享缓存,二次批次显著更快)。", "text": "还没有挖掘批次。点击上方「⛏ 启动全量挖掘」开始首次全量约3-5分钟embedding 有全机构共享缓存,二次批次显著更快)。", "i18n": True,
"color": "#64748b", "padding": "12px", "halign": "left", "wrap": True}})
for b in batches:
bid = str(b.get("id") or "")

View File

@ -27,13 +27,13 @@ async with DBPools().sqlorContext(dbname) as sor:
rows = []
if not ok:
rows.append({"widgettype": "Text", "options": {
"text": "查询失败:" + str(res)[:200],
"otext": "查询失败:${p0}", "text": "查询失败:${p0}", "i18n": True, "i18n_params": {"p0": str(str(res)[:200])},
"color": "#dc2626", "padding": "12px", "halign": "left"}})
else:
items = res.get("items") or []
if not items:
rows.append({"widgettype": "Text", "options": {
"text": "该主题近 %d 天无海外分项" % days, "color": "#64748b", "padding": "12px", "halign": "left"}})
"otext": "该主题近 ${p0} 天无海外分项", "text": "该主题近 ${p0} 天无海外分项", "i18n": True, "i18n_params": {"p0": str('%d' % (days,))}, "color": "#64748b", "padding": "12px", "halign": "left"}})
for it in items:
item_id = str(it.get("id") or "")
budget = it.get("budget_wan") or 0
@ -90,7 +90,7 @@ return {
"padding": "12px 16px", "gap": "0px", "overflow": "auto"},
"subwidgets": [
{"widgettype": "Text", "options": {
"text": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。",
"otext": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。", "text": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。", "i18n": True,
"cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}
] + rows
}]

View File

@ -27,13 +27,13 @@ async with DBPools().sqlorContext(dbname) as sor:
rows = []
if not ok:
rows.append({"widgettype": "Text", "options": {
"text": "查询失败:" + str(res)[:200],
"otext": "查询失败:${p0}", "text": "查询失败:${p0}", "i18n": True, "i18n_params": {"p0": str(str(res)[:200])},
"color": "#dc2626", "padding": "12px", "halign": "left"}})
else:
items = res.get("items") or []
if not items:
rows.append({"widgettype": "Text", "options": {
"text": "该主题近 %d 天无分项" % days, "color": "#64748b", "padding": "12px", "halign": "left"}})
"otext": "该主题近 ${p0} 天无分项", "text": "该主题近 ${p0} 天无分项", "i18n": True, "i18n_params": {"p0": str('%d' % (days,))}, "color": "#64748b", "padding": "12px", "halign": "left"}})
for it in items:
item_id = str(it.get("id") or "")
budget = it.get("budget_wan") or 0
@ -95,7 +95,7 @@ return {
"padding": "12px 16px", "gap": "0px", "overflow": "auto"},
"subwidgets": [
{"widgettype": "Text", "options": {
"text": "点击任一条目弹出具体内容(公告全文来自数据爬取平台,有附件的可直接下载);每条说明中已标注数据来源。",
"otext": "点击任一条目弹出具体内容(公告全文来自数据爬取平台,有附件的可直接下载);每条说明中已标注数据来源。", "text": "点击任一条目弹出具体内容(公告全文来自数据爬取平台,有附件的可直接下载);每条说明中已标注数据来源。", "i18n": True,
"cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}
] + rows
}]

View File

@ -8,7 +8,7 @@ import json as _json
uid = await get_user()
if not uid:
return {"widgettype": "Text", "options": {"text": "请先登录", "color": "#dc2626", "padding": "12px"}}
return {"widgettype": "Text", "options": {"otext": "请先登录", "text": "请先登录", "i18n": True, "color": "#dc2626", "padding": "12px"}}
dbname = get_module_dbname('pipeline-opportunity')
@ -37,7 +37,7 @@ def _topic_row(cat, cnt, budget, target_url, popup_id, extra_txt=""):
{"widgettype": "Text", "options": {"text": cat, "cfontsize": 1.0,
"color": "#0f172a", "halign": "left"}},
{"widgettype": "Filler"},
{"widgettype": "Text", "options": {"text": "%d 条" % cnt,
{"widgettype": "Text", "options": {"otext": "${p0} 条", "text": "${p0} 条", "i18n": True, "i18n_params": {"p0": str('%d' % (cnt,))},
"cfontsize": 0.85, "color": "#64748b"}},
]
if extra_txt:
@ -45,7 +45,7 @@ def _topic_row(cat, cnt, budget, target_url, popup_id, extra_txt=""):
"cfontsize": 0.75, "color": "#94a3b8"}})
else:
subs.append({"widgettype": "Text", "options": {
"text": "预算 %s 万" % ("{:,.0f}".format(budget) if budget else "—"),
"otext": "预算 ${p0} 万", "text": "预算 ${p0} 万", "i18n": True, "i18n_params": {"p0": str("{:,.0f}".format(budget) if budget else "—")},
"cfontsize": 0.85, "color": "#64748b"}})
subs.append({"widgettype": "Text", "options": {"text": "",
"cfontsize": 1.1, "color": "#94a3b8"}})
@ -70,7 +70,7 @@ else:
ranking = res.get("ranking") or []
if not ranking:
rows.append({"widgettype": "Text", "options": {
"text": "近30天暂无招标数据", "color": "#64748b", "padding": "12px", "halign": "left"}})
"otext": "近30天暂无招标数据", "text": "近30天暂无招标数据", "i18n": True, "color": "#64748b", "padding": "12px", "halign": "left"}})
for r in ranking:
rows.append(_topic_row(str(r.get("category", "")), r.get("tender_count", 0),
r.get("total_budget_wan", 0), items_url, "opp_refs_items_pw"))
@ -81,7 +81,7 @@ if ok_dm:
dm_ranking = res_dm.get("ranking") or []
if dm_ranking:
dm_rows.append({"widgettype": "Text", "options": {
"text": "国内众包软件需求(猪八戒/一品威客近30天",
"otext": "国内众包软件需求(猪八戒/一品威客近30天", "text": "国内众包软件需求(猪八戒/一品威客近30天", "i18n": True,
"cfontsize": 0.95, "color": "#0f172a", "halign": "left",
"margin": "14px 0 6px 0", "padding": "6px 0 0 0",
"border": "1px solid #f1f5f9", "borderRadius": "6px", "bgcolor": "#f8fafc"}})
@ -95,7 +95,7 @@ if ok_ov:
ov_ranking = res_ov.get("ranking") or []
if ov_ranking:
ov_rows.append({"widgettype": "Text", "options": {
"text": "海外众包软件需求Freelancer.com / PeoplePerHour近30天",
"otext": "海外众包软件需求Freelancer.com / PeoplePerHour近30天", "text": "海外众包软件需求Freelancer.com / PeoplePerHour近30天", "i18n": True,
"cfontsize": 0.95, "color": "#0f172a", "halign": "left",
"margin": "14px 0 6px 0", "padding": "6px 0 0 0",
"border": "1px solid #f1f5f9", "borderRadius": "6px", "bgcolor": "#f8fafc"}})
@ -112,7 +112,7 @@ return {
"gap": "6px", "overflow": "auto"},
"subwidgets": [
{"widgettype": "Text", "options": {
"text": "点击任一主题条目,弹出该主题的全部分项明细;明细中再点击任一条,弹出具体内容(数据来自数据爬取平台,含公告全文与附件下载)。",
"otext": "点击任一主题条目,弹出该主题的全部分项明细;明细中再点击任一条,弹出具体内容(数据来自数据爬取平台,含公告全文与附件下载)。", "text": "点击任一主题条目,弹出该主题的全部分项明细;明细中再点击任一条,弹出具体内容(数据来自数据爬取平台,含公告全文与附件下载)。", "i18n": True,
"cfontsize": 0.8, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}
] + rows + dm_rows + ov_rows
}

View File

@ -18,7 +18,8 @@ dbname = get_module_dbname('pipeline-opportunity')
async with DBPools().sqlorContext(dbname) as sor:
from pipeline_opportunity.opp_report_capability import (
check_report_visible, parse_report_sections, list_report_files, REPORT_SECTIONS)
check_report_visible, parse_report_sections, list_report_files,
sections_for_type)
visible, rep = await check_report_visible(sor, report_id, uid)
await sor.sqlExe("COMMIT", {})
if not visible:
@ -33,6 +34,8 @@ STATUS_TXT = {
"approved": "审批通过", "rejected": "已驳回",
}
TYPE_TXT = {"research": "调研", "feasibility": "可行性研究"}
body = []
# ── 标题 ──
@ -43,19 +46,22 @@ body.append({"widgettype": "Text", "options": {
# ── 元信息 ──
st = str(rep.get("status") or "")
rt = str(rep.get("report_type") or "research")
proj = str(rep.get("project_name") or "")
meta = "%s %s %s 创建人 %s" % (
meta = "%s %s %s %s 创建人 %s" % (
str(rep.get("created_at") or "")[:16],
("项目:" + proj) if proj else "平台级调研(未挂项目)",
TYPE_TXT.get(rt, rt or "调研"),
STATUS_TXT.get(st, st or "—"),
str(rep.get("created_by") or "—"))
body.append({"widgettype": "Text", "options": {
"text": meta, "cfontsize": 0.78, "color": "#64748b", "halign": "left",
"wrap": True, "margin": "0 0 10px 0"}})
# ── 四节:研发场景 / 输入 / 输出 / 成果(按节解析正文;空节如实标注,不编造)──
sections = parse_report_sections(str(rep.get("content") or ""))
for name in REPORT_SECTIONS:
# ── 分节渲染research 四节 / feasibility 八节(按 report_type 选模板;
# 空节如实标注,不编造)──
sections = parse_report_sections(str(rep.get("content") or ""), rt)
for name in sections_for_type(rt):
md = (sections.get(name) or "").strip()
body.append({"widgettype": "Text", "options": {
"text": name, "cfontsize": 0.95, "color": "#0f172a", "halign": "left",
@ -70,7 +76,7 @@ for name in REPORT_SECTIONS:
"margin": "0 0 10px 0", "bgcolor": "#ffffff"}})
else:
body.append({"widgettype": "Text", "options": {
"text": "(本报告未填写此节)", "cfontsize": 0.82, "color": "#94a3b8",
"otext": "(本报告未填写此节)", "text": "(本报告未填写此节)", "i18n": True, "cfontsize": 0.82, "color": "#94a3b8",
"halign": "left", "margin": "0 0 10px 0"}})
# ── 成果文件(多个列出,逐个下载)──
@ -79,7 +85,7 @@ body.append({"widgettype": "Text", "options": {
"halign": "left", "margin": "0 0 4px 0"}})
if not files:
body.append({"widgettype": "Text", "options": {
"text": "暂无成果文件(报告提交人工确认时会自动生成 PPT 存入项目 deliverables/",
"otext": "暂无成果文件(报告提交人工确认时会自动生成 PPT 存入项目 deliverables/", "text": "暂无成果文件(报告提交人工确认时会自动生成 PPT 存入项目 deliverables/", "i18n": True,
"cfontsize": 0.82, "color": "#94a3b8", "halign": "left", "margin": "0 0 10px 0"}})
for f in files:
size = int(f.get("size") or 0)

View File

@ -45,7 +45,7 @@ if not _cur_pid:
"options": {"width": "100%", "gap": "12px", "padding": "24px"},
"subwidgets": [
{"widgettype": "Text",
"options": {"text": "当前会话还没有项目,请先选择或新建项目",
"options": {"otext": "当前会话还没有项目,请先选择或新建项目", "text": "当前会话还没有项目,请先选择或新建项目", "i18n": True,
"cfontsize": 1, "color": "#64748b"}},
{"widgettype": "HBox",
"options": {"width": "100%", "gap": "10px"},
@ -72,24 +72,37 @@ STATUS_TXT = {
"rejected": ("已驳回", "#dc2626"),
}
TYPE_TXT = {
"research": ("调研", "#0369a1"),
"feasibility": ("可行性研究", "#7c3aed"),
}
rows = []
if not reports:
rows.append({"widgettype": "Text", "options": {
"text": "暂无可见的研发报告(列表按项目 owner 过滤:仅显示你 own 的项目及平台级调研报告)",
"otext": "暂无可见的研发报告(列表按项目 owner 过滤:仅显示你 own 的项目及平台级调研报告)", "text": "暂无可见的研发报告(列表按项目 owner 过滤:仅显示你 own 的项目及平台级调研报告)", "i18n": True,
"cfontsize": 0.85, "color": "#64748b", "padding": "12px", "halign": "left"}})
for r in reports:
rid = str(r.get("id") or "")
st = str(r.get("status") or "")
st_txt, st_color = STATUS_TXT.get(st, (st or "—", "#64748b"))
rt = str(r.get("report_type") or "research")
rt_txt, rt_color = TYPE_TXT.get(rt, ("调研", "#0369a1"))
proj = str(r.get("project_name") or "")
meta = "%s %s %s" % (
str(r.get("created_at") or "")[:16],
("项目:" + proj) if proj else "平台级调研(未挂项目)",
st_txt)
row_subs = [
{"widgettype": "Text", "options": {
"text": str(r.get("title") or r.get("software") or "(无标题)"),
"cfontsize": 0.95, "color": "#0f172a", "halign": "left", "wrap": True}},
{"widgettype": "HBox", "options": {"width": "100%", "gap": "8px",
"alignItems": "center"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": rt_txt,
"cfontsize": 0.7, "color": "#ffffff", "bgcolor": rt_color,
"padding": "1px 8px", "borderRadius": "10px"}},
{"widgettype": "Text", "options": {
"text": str(r.get("title") or r.get("software") or "(无标题)"),
"cfontsize": 0.95, "color": "#0f172a", "halign": "left", "wrap": True}}]},
{"widgettype": "HBox", "options": {"width": "100%", "gap": "10px",
"alignItems": "center"},
"subwidgets": [
@ -122,7 +135,7 @@ return {
"padding": "12px 16px", "gap": "0px", "overflow": "auto"},
"subwidgets": [
{"widgettype": "Text", "options": {
"text": "研发报告(%d 份)|按项目 owner 过滤;点击任一条查看研发场景/输入/输出/成果与成果文件下载" % len(reports),
"otext": "研发报告(${p0} 份)|按项目 owner 过滤;点击任一条查看研发场景/输入/输出/成果与成果文件下载", "text": "研发报告(${p0} 份)|按项目 owner 过滤;点击任一条查看研发场景/输入/输出/成果与成果文件下载", "i18n": True, "i18n_params": {"p0": str('%d' % (len(reports),))},
"cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}
] + rows
}

View File

@ -61,6 +61,13 @@ fields_str=r'''[
"nullable": "no",
"default": "draft"
},
{
"name": "report_type",
"title": "报告类型(research调研/feasibility可行性研究)",
"type": "str",
"length": 16,
"default": "research"
},
{
"name": "confirm_task_id",
"title": "确认任务ID",

View File

@ -121,6 +121,17 @@
"datatype": "str",
"label": "报告状态"
},
{
"name": "report_type",
"title": "报告类型(research调研/feasibility可行性研究)",
"type": "str",
"length": 16,
"default": "research",
"cwidth": 16,
"uitype": "str",
"datatype": "str",
"label": "报告类型(research调研/feasibility可行性研究)"
},
{
"name": "confirm_task_id",
"title": "确认任务ID",