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:
parent
ca921d3550
commit
4092304726
39
README.md
39
README.md
@ -151,6 +151,45 @@ CRUD 页(`opp_reports/` `opp_approvals/`)保留供后台运维直接编辑
|
||||
- 验证脚本:scripts/p0_probe_vdb_embed*.py(VDB/embed 协议探测)、
|
||||
p1_cluster_analyze.py(多τ对比+缓存,调参用)、p1_create_tables.py(幂等建表)。
|
||||
|
||||
## 可行性研究(P3,2026-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 EQ(DET 按证据原子数粗估);成本=FP×
|
||||
opp_fp_pm_per_fp(默认0.05人月/FP)×opp_cost_wan_per_pm(默认3.0万元/人月),
|
||||
params 可配。LLM 只写三节叙述(架构与可行性/商业价值/风险),其余五节用
|
||||
挖掘真实数据拼装。落库 opp_reports(report_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_projects(pipeline=opportunity_general,project_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)
|
||||
与 PPT(opp_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 COLUMN);models/opp_reports.json 已同步(新建表天然带列)。
|
||||
- **报告列表标签**:opp_reports_list.dspy 行内加类型徽标(调研=蓝/可行性研究=紫)。
|
||||
- PPT 文件名区分:feasibility_report_<software>_<rid8>.pptx。
|
||||
- 4 个新工具注册进 OPP_HANDLERS(opp_fp_estimate/opp_draft_feasibility/
|
||||
opp_score_candidate/opp_promote_to_project),OPP_PROMPT 加「可行性研究与立项」
|
||||
流程段;opp-workflow 技能同步(链路C)。
|
||||
|
||||
## 技能
|
||||
|
||||
产线技能在 pipeline-core 技能库:`skills_library/pipelines/opportunity_general/`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -37,3 +37,14 @@
|
||||
下载=下载
|
||||
无权查看=无权查看
|
||||
暂无成果文件=暂无成果文件
|
||||
可行性研究=可行性研究
|
||||
调研=调研
|
||||
需求挖掘立项=需求挖掘立项
|
||||
市场需求=市场需求
|
||||
共性需求=共性需求
|
||||
需求规格=需求规格
|
||||
架构与可行性=架构与可行性
|
||||
FP与成本=FP与成本
|
||||
商业价值=商业价值
|
||||
风险=风险
|
||||
结论建议=结论建议
|
||||
|
||||
@ -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)"}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 '确认人',
|
||||
|
||||
@ -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 样例)要能支撑命名,命名可疑时如实说明。
|
||||
|
||||
## 可行性研究与立项(P3,report_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_projects,pipeline=opportunity_general,project_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=%s(pipeline=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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
296
pipeline_opportunity/opp_feasibility.py
Normal file
296
pipeline_opportunity/opp_feasibility.py
Normal file
@ -0,0 +1,296 @@
|
||||
# -*- 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 "无审批单")
|
||||
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
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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_type(2026-09-12 P3):research=调研报告(四节模板)/
|
||||
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=feasibility,P3 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 character;rag_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 ""
|
||||
|
||||
@ -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`。
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
}]
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
}],
|
||||
|
||||
@ -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,
|
||||
}],
|
||||
|
||||
@ -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 "")
|
||||
|
||||
@ -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
|
||||
}]
|
||||
|
||||
@ -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
|
||||
}]
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user