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 协议探测)、
|
- 验证脚本:scripts/p0_probe_vdb_embed*.py(VDB/embed 协议探测)、
|
||||||
p1_cluster_analyze.py(多τ对比+缓存,调参用)、p1_create_tables.py(幂等建表)。
|
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/`
|
产线技能在 pipeline-core 技能库:`skills_library/pipelines/opportunity_general/`
|
||||||
|
|||||||
@ -37,3 +37,14 @@
|
|||||||
下载=Download
|
下载=Download
|
||||||
无权查看=No Access
|
无权查看=No Access
|
||||||
暂无成果文件=No output files yet
|
暂无成果文件=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": "approved", "v": "审批通过"},
|
||||||
{"k": "rejected", "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": [
|
"params": [
|
||||||
@ -43,6 +58,8 @@
|
|||||||
{"name": "opp_norm_tau2", "value": "0.85", "desc": "归一化相似度触发阈值(只当触发器,LLM裁决)"},
|
{"name": "opp_norm_tau2", "value": "0.85", "desc": "归一化相似度触发阈值(只当触发器,LLM裁决)"},
|
||||||
{"name": "opp_norm_topk", "value": "10", "desc": "原子kNN邻居数"},
|
{"name": "opp_norm_topk", "value": "10", "desc": "原子kNN邻居数"},
|
||||||
{"name": "opp_cov_core", "value": "0.6", "desc": "核心共性覆盖率阈值"},
|
{"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",
|
"nullable": "no",
|
||||||
"default": "draft"
|
"default": "draft"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "report_type",
|
||||||
|
"title": "报告类型(research调研/feasibility可行性研究)",
|
||||||
|
"type": "str",
|
||||||
|
"length": 16,
|
||||||
|
"default": "research"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "confirm_task_id",
|
"name": "confirm_task_id",
|
||||||
"title": "确认任务ID",
|
"title": "确认任务ID",
|
||||||
|
|||||||
@ -17,6 +17,7 @@ CREATE TABLE opp_reports
|
|||||||
`title` VARCHAR(255) NOT NULL comment '报告标题',
|
`title` VARCHAR(255) NOT NULL comment '报告标题',
|
||||||
`content` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '报告正文',
|
`content` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '报告正文',
|
||||||
`status` VARCHAR(24) NOT NULL DEFAULT 'draft' 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',
|
`confirm_task_id` VARCHAR(32) comment '确认任务ID',
|
||||||
`ppt_path` VARCHAR(500) comment 'PPT文件路径',
|
`ppt_path` VARCHAR(500) comment 'PPT文件路径',
|
||||||
`confirmed_by` VARCHAR(64) comment '确认人',
|
`confirmed_by` VARCHAR(64) comment '确认人',
|
||||||
|
|||||||
@ -93,6 +93,14 @@ OPP_TOOLS = [
|
|||||||
parameters={"cluster_id": "类别ID"}, category="mining"),
|
parameters={"cluster_id": "类别ID"}, category="mining"),
|
||||||
ToolDefinition(name="opp_coverage_report", description="查看某类别的共性需求覆盖率报告:核心共性(覆盖率≥60%)/扩展共性(≥40%)/个性需求三层 + 能力域分组 + 每条标准需求的覆盖文档数与证据",
|
ToolDefinition(name="opp_coverage_report", description="查看某类别的共性需求覆盖率报告:核心共性(覆盖率≥60%)/扩展共性(≥40%)/个性需求三层 + 能力域分组 + 每条标准需求的覆盖文档数与证据",
|
||||||
parameters={"cluster_id": "类别ID"}, category="mining"),
|
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 片段 ══════════════════
|
# ══════════════════ 产线 prompt 片段 ══════════════════
|
||||||
@ -149,10 +157,28 @@ opp_create_report / opp_update_report 的正文必须用以下四个二级标题
|
|||||||
2. opp_mining_status 轮询进度(每轮回复用户当前状态;failed 时如实转述原因,禁隐瞒)。
|
2. opp_mining_status 轮询进度(每轮回复用户当前状态;failed 时如实转述原因,禁隐瞒)。
|
||||||
3. done 后 opp_list_clusters 看类别排名(热度/需求数/占比),向用户呈现 TopX 表格。
|
3. done 后 opp_list_clusters 看类别排名(热度/需求数/占比),向用户呈现 TopX 表格。
|
||||||
4. 用户选定类别后 opp_cluster_detail 取类内样例(每条带来源URL),结合数据写分析报告;
|
4. 用户选定类别后 opp_cluster_detail 取类内样例(每条带来源URL),结合数据写分析报告;
|
||||||
需要立项走可行性研究时,用 opp_create_report 建报告(数据基础引用挖掘批次与类别证据)。
|
需要立项走可行性研究时用 **opp_draft_feasibility**(见下节),不要用
|
||||||
|
opp_create_report 手写可行性报告。
|
||||||
硬规则:聚类结果只能来自 opp_* mining 工具返回,禁止自己编类别/编排名/编数量;
|
硬规则:聚类结果只能来自 opp_* mining 工具返回,禁止自己编类别/编排名/编数量;
|
||||||
类别命名证据(naming_evidence 样例)要能支撑命名,命名可疑时如实说明。
|
类别命名证据(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 实测教训)**:
|
- **统计优先用聚合工具,明细只取样例(2026-09-11 实测教训)**:
|
||||||
数量/排名/预算合计/平均价这类**统计**一律用聚合工具——opp_hot_software(招标侧)、
|
数量/排名/预算合计/平均价这类**统计**一律用聚合工具——opp_hot_software(招标侧)、
|
||||||
@ -532,6 +558,55 @@ async def _h_coverage_report(sor, p, ctx):
|
|||||||
"标准需求": rows}, ensure_ascii=False, default=str)
|
"标准需求": 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_HANDLERS = {
|
||||||
"opp_hot_software": _h_hot_software,
|
"opp_hot_software": _h_hot_software,
|
||||||
"opp_daily_ai_tenders": _h_daily_ai,
|
"opp_daily_ai_tenders": _h_daily_ai,
|
||||||
@ -559,6 +634,11 @@ OPP_HANDLERS = {
|
|||||||
# 类内共性提取(P2)
|
# 类内共性提取(P2)
|
||||||
"opp_normalize_cluster": _h_normalize_cluster,
|
"opp_normalize_cluster": _h_normalize_cluster,
|
||||||
"opp_coverage_report": _h_coverage_report,
|
"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 一致)
|
# 四节模板顺序(与 opp_report_capability.REPORT_SECTIONS 一致)
|
||||||
SECTION_ORDER = ("研发场景", "输入", "输出", "成果")
|
SECTION_ORDER = ("研发场景", "输入", "输出", "成果")
|
||||||
|
|
||||||
|
# 可行性研究八节模板(与 opp_report_capability.FEASIBILITY_SECTIONS 一致,P3)
|
||||||
|
FEASIBILITY_ORDER = ("市场需求", "共性需求", "需求规格", "架构与可行性",
|
||||||
|
"FP与成本", "商业价值", "风险", "结论建议")
|
||||||
|
|
||||||
|
|
||||||
def _rgb(t):
|
def _rgb(t):
|
||||||
from pptx.dml.color import RGBColor
|
from pptx.dml.color import RGBColor
|
||||||
@ -140,18 +144,23 @@ def _split_lead(text):
|
|||||||
return "", _clean_inline(text)
|
return "", _clean_inline(text)
|
||||||
|
|
||||||
|
|
||||||
def _parse_report(content):
|
def _parse_report(content, report_type="research"):
|
||||||
"""按四节模板解析(与详情页同口径):返回 [(节名, markdown)]。
|
"""按节模板解析(与详情页同口径):返回 [(节名, markdown)]。
|
||||||
|
|
||||||
无四节标题的旧报告 → 整篇归「输出」节(如实呈现,不编造分节)。
|
research=四节(研发场景/输入/输出/成果);feasibility=八节。
|
||||||
|
无节标题的旧报告 → 整篇归「输出」节(如实呈现,不编造分节)。
|
||||||
"""
|
"""
|
||||||
content = content or ""
|
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(
|
pat = re.compile(
|
||||||
r"^##\s*(?:[一二三四五六七八九十\d]+[、..]?\s*)?[*]*\s*"
|
r"^##\s*(?:[一二三四五六七八九十\d]+[、..]?\s*)?[*]*\s*("
|
||||||
r"(研发场景|输入|输出|成果)\s*[*]*\s*[::]?\s*$", re.MULTILINE)
|
+ "|".join(re.escape(n) for n in names)
|
||||||
|
+ r")\s*[*]*\s*[::]?\s*$", re.MULTILINE)
|
||||||
marks = list(pat.finditer(content))
|
marks = list(pat.finditer(content))
|
||||||
if not marks:
|
if not marks:
|
||||||
return [("输出", content.strip())]
|
return [(fallback, content.strip())]
|
||||||
out = []
|
out = []
|
||||||
preamble = content[:marks[0].start()].strip()
|
preamble = content[:marks[0].start()].strip()
|
||||||
if preamble:
|
if preamble:
|
||||||
@ -161,7 +170,7 @@ def _parse_report(content):
|
|||||||
body = content[m.end():end].strip()
|
body = content[m.end():end].strip()
|
||||||
if body:
|
if body:
|
||||||
out.append((m.group(1), 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, 错误)。
|
"""生成 PPT 并保存。返回 (True, 文件路径) 或 (False, 错误)。
|
||||||
|
|
||||||
签名保持不变(opp_report_capability._build_report_ppt 调用方零改动)。
|
report_type: research=四节章节页 / feasibility=八节章节页(P3)。
|
||||||
|
其余参数签名保持不变(旧调用方零改动)。
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from pptx import Presentation
|
from pptx import Presentation
|
||||||
@ -456,14 +466,15 @@ def build_report_ppt(title, content, software="", out_path=""):
|
|||||||
# 预扫描:算出要渲染的页面,便于一次性算总页数(页脚 x/y)
|
# 预扫描:算出要渲染的页面,便于一次性算总页数(页脚 x/y)
|
||||||
pages = [] # [('divider', idx, name) | ('bullets', title, kicker, items)
|
pages = [] # [('divider', idx, name) | ('bullets', title, kicker, items)
|
||||||
# | ('table', title, kicker, rows, extra)]
|
# | ('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
|
sec_idx = 0
|
||||||
for sec_name, sec_md in sections:
|
for sec_name, sec_md in sections:
|
||||||
if sec_name in SECTION_ORDER:
|
if sec_name in sec_names:
|
||||||
sec_idx += 1
|
sec_idx += 1
|
||||||
pages.append(("divider", sec_idx, sec_name))
|
pages.append(("divider", sec_idx, sec_name))
|
||||||
blocks = _split_blocks(sec_md)
|
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)]
|
buf_items = [] # [(lvl, lead, text)]
|
||||||
pending_table = None
|
pending_table = None
|
||||||
|
|
||||||
|
|||||||
@ -28,12 +28,19 @@ logger = logging.getLogger("pipeline.opportunity.report")
|
|||||||
|
|
||||||
|
|
||||||
async def create_report(project_id, software, title="", analysis="",
|
async def create_report(project_id, software, title="", analysis="",
|
||||||
created_by="agent.opportunity"):
|
created_by="agent.opportunity", report_type="research"):
|
||||||
"""创建研发报告草稿。返回 (True, report_id) 或 (False, 错误)。"""
|
"""创建研发报告草稿。返回 (True, report_id) 或 (False, 错误)。
|
||||||
|
|
||||||
|
report_type(2026-09-12 P3):research=调研报告(四节模板)/
|
||||||
|
feasibility=可行性研究(八节模板,opp_feasibility 起草)。
|
||||||
|
"""
|
||||||
if not software:
|
if not software:
|
||||||
return False, "缺少 software(软件/主题名)"
|
return False, "缺少 software(软件/主题名)"
|
||||||
|
if report_type not in ("research", "feasibility"):
|
||||||
|
return False, "report_type 必须是 research 或 feasibility"
|
||||||
if not title:
|
if not title:
|
||||||
title = "%s 研发机会分析报告" % software
|
title = ("%s 研发可行性研究" % software) if report_type == "feasibility" \
|
||||||
|
else ("%s 研发机会分析报告" % software)
|
||||||
db, dbname = get_db()
|
db, dbname = get_db()
|
||||||
rid = new_id()
|
rid = new_id()
|
||||||
async with db.sqlorContext(dbname) as sor:
|
async with db.sqlorContext(dbname) as sor:
|
||||||
@ -44,6 +51,7 @@ async def create_report(project_id, software, title="", analysis="",
|
|||||||
"title": title,
|
"title": title,
|
||||||
"content": analysis or "",
|
"content": analysis or "",
|
||||||
"status": RP_DRAFT,
|
"status": RP_DRAFT,
|
||||||
|
"report_type": report_type,
|
||||||
"created_by": created_by,
|
"created_by": created_by,
|
||||||
})
|
})
|
||||||
await sor.sqlExe("COMMIT", {})
|
await sor.sqlExe("COMMIT", {})
|
||||||
@ -89,6 +97,16 @@ async def list_reports(project_id="", status=""):
|
|||||||
# 报告正文四节模板(二级标题,agent 写报告必须按此结构;旧报告无节时全文归「输出」)
|
# 报告正文四节模板(二级标题,agent 写报告必须按此结构;旧报告无节时全文归「输出」)
|
||||||
REPORT_SECTIONS = ("研发场景", "输入", "输出", "成果")
|
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):
|
async def _user_org(sor, user_id):
|
||||||
recs = await sor.sqlExe(
|
recs = await sor.sqlExe(
|
||||||
@ -114,7 +132,8 @@ async def list_visible_reports(sor, user_id):
|
|||||||
# (实测 ValueError: unsupported format character;rag_client.py 的
|
# (实测 ValueError: unsupported format character;rag_client.py 的
|
||||||
# NOT LIKE 'agent.%' 是同一颗潜伏雷)。参数值里的 % 安全。
|
# NOT LIKE 'agent.%' 是同一颗潜伏雷)。参数值里的 % 安全。
|
||||||
sql = (
|
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, "
|
"r.created_by, r.created_at, r.updated_at, "
|
||||||
"p.name AS project_name, p.created_by AS project_owner "
|
"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 "
|
"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
|
return False, rep
|
||||||
|
|
||||||
|
|
||||||
def parse_report_sections(content):
|
def parse_report_sections(content, report_type="research"):
|
||||||
"""按四节模板解析报告正文 → {'研发场景': md, '输入': md, '输出': md, '成果': md}。
|
"""按节模板解析报告正文 → {节名: markdown}。
|
||||||
|
|
||||||
模板(agent 写报告约定):二级标题 `## 研发场景` / `## 输入` / `## 输出` / `## 成果`。
|
research(默认):四节 `## 研发场景/输入/输出/成果`;
|
||||||
|
feasibility:八节 `## 市场需求/共性需求/需求规格/架构与可行性/FP与成本/商业价值/风险/结论建议`。
|
||||||
兼容标题带序号/冒号/加粗(如 `## 一、研发场景:`、`## **输出**`)。
|
兼容标题带序号/冒号/加粗(如 `## 一、研发场景:`、`## **输出**`)。
|
||||||
旧报告(无任何匹配节,如实测的市场调研报告)→ 全文归「输出」节,其余节为空,
|
旧报告(无任何匹配节,如实测的市场调研报告)→ 全文归「输出」节,其余节为空,
|
||||||
详情页对空节如实显示「(本报告未填写此节)」,不编造。
|
详情页对空节如实显示「(本报告未填写此节)」,不编造。
|
||||||
"""
|
"""
|
||||||
import re as _re
|
import re as _re
|
||||||
content = content or ""
|
content = content or ""
|
||||||
|
sec_names = sections_for_type(report_type)
|
||||||
# 节标题行:## [可选序号/标点] 节名 [可选冒号/加粗残留]
|
# 节标题行:## [可选序号/标点] 节名 [可选冒号/加粗残留]
|
||||||
pattern = _re.compile(
|
pattern = _re.compile(
|
||||||
r"^##\s*(?:[一二三四五六七八九十\d]+[、..]?\s*)?[**]*\s*"
|
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)
|
_re.MULTILINE)
|
||||||
marks = list(pattern.finditer(content))
|
marks = list(pattern.finditer(content))
|
||||||
sections = {k: "" for k in REPORT_SECTIONS}
|
sections = {k: "" for k in sec_names}
|
||||||
if not marks:
|
if not marks:
|
||||||
sections["输出"] = content.strip()
|
# 无匹配节标题:四节模板归「输出」,八节模板归「需求规格」(承载正文主体)
|
||||||
|
fallback = "输出" if report_type != "feasibility" else "需求规格"
|
||||||
|
sections[fallback] = content.strip()
|
||||||
return sections
|
return sections
|
||||||
# 首个节标题之前的前言(如报告大标题/数据来源说明)归「输出」节头部展示
|
# 首个节标题之前的前言(如报告大标题/数据来源说明)归兜底节头部展示
|
||||||
|
fallback = "输出" if report_type != "feasibility" else "需求规格"
|
||||||
preamble = content[:marks[0].start()].strip()
|
preamble = content[:marks[0].start()].strip()
|
||||||
for i, m in enumerate(marks):
|
for i, m in enumerate(marks):
|
||||||
name = m.group(1)
|
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
|
sections[name] = (sections[name] + "\n\n" + body).strip() if sections[name] else body
|
||||||
if preamble:
|
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
|
return sections
|
||||||
|
|
||||||
|
|
||||||
@ -261,11 +285,13 @@ async def _build_report_ppt(sor, rep):
|
|||||||
project_dir = os.path.join(os.getcwd(), "files", "opp_reports")
|
project_dir = os.path.join(os.getcwd(), "files", "opp_reports")
|
||||||
rid = rep.get("id", "")
|
rid = rep.get("id", "")
|
||||||
software = re.sub(r"[^\w\u4e00-\u9fff-]", "_", rep.get("software", "") or "report")[:40]
|
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)
|
out_path = os.path.join(project_dir, "deliverables", fname)
|
||||||
ok, res = build_report_ppt(
|
ok, res = build_report_ppt(
|
||||||
rep.get("title", ""), rep.get("content", ""),
|
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:
|
if not ok:
|
||||||
logger.warning("build_report_ppt 失败: %s", str(res)[:200])
|
logger.warning("build_report_ppt 失败: %s", str(res)[:200])
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@ -64,6 +64,16 @@ appbase `params` 表两个键(`opp_common.get_crawler_config` 读取,默认
|
|||||||
| `opp_mining_status(batch_id)` | 批次状态轮询(pulling/embedding/clustering/naming/done/failed+原因) |
|
| `opp_mining_status(batch_id)` | 批次状态轮询(pulling/embedding/clustering/naming/done/failed+原因) |
|
||||||
| `opp_list_clusters(batch_id)` | 类别排名表(热度/需求数/占比/命名证据)= TopX 热门类别 |
|
| `opp_list_clusters(batch_id)` | 类别排名表(热度/需求数/占比/命名证据)= TopX 热门类别 |
|
||||||
| `opp_cluster_detail(cluster_id,limit)` | 类内需求明细样例(每条带来源URL可查证) |
|
| `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`。
|
角色:`agent.opp_analyst` / `agent.opp_writer`;slash:`/hot` `/ai` `/reports` `/oppdiag`。
|
||||||
|
|
||||||
|
|||||||
@ -6,8 +6,8 @@
|
|||||||
"widgettype": "HBox",
|
"widgettype": "HBox",
|
||||||
"options": {"width": "100%", "alignItems": "center", "padding": "16px 24px 8px 24px", "cheight": 6, "gap": "12px"},
|
"options": {"width": "100%", "alignItems": "center", "padding": "16px 24px 8px 24px", "cheight": 6, "gap": "12px"},
|
||||||
"subwidgets": [
|
"subwidgets": [
|
||||||
{"widgettype": "Title2", "options": {"text": "商机产线"}},
|
{"widgettype": "Title2", "options": {"otext": "商机产线", "text": "商机产线", "i18n": true}},
|
||||||
{"widgettype": "Text", "options": {"text": "热点软件 → 今日AI招标 → 检索 → 编写研发报告 → 人工确认 → 研发审批", "cfontsize": 0.9, "color": "#94a3b8"}},
|
{"widgettype": "Text", "options": {"otext": "热点软件 → 今日AI招标 → 检索 → 编写研发报告 → 人工确认 → 研发审批", "text": "热点软件 → 今日AI招标 → 检索 → 编写研发报告 → 人工确认 → 研发审批", "i18n": true, "cfontsize": 0.9, "color": "#94a3b8"}},
|
||||||
{"widgettype": "Filler"},
|
{"widgettype": "Filler"},
|
||||||
{
|
{
|
||||||
"widgettype": "Button",
|
"widgettype": "Button",
|
||||||
|
|||||||
@ -27,13 +27,13 @@ async with DBPools().sqlorContext(dbname) as sor:
|
|||||||
rows = []
|
rows = []
|
||||||
if not ok:
|
if not ok:
|
||||||
rows.append({"widgettype": "Text", "options": {
|
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"}})
|
"color": "#dc2626", "padding": "12px", "halign": "left"}})
|
||||||
else:
|
else:
|
||||||
items = res.get("items") or []
|
items = res.get("items") or []
|
||||||
if not items:
|
if not items:
|
||||||
rows.append({"widgettype": "Text", "options": {
|
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:
|
for it in items:
|
||||||
item_id = str(it.get("id") or "")
|
item_id = str(it.get("id") or "")
|
||||||
budget = it.get("budget_wan") or 0
|
budget = it.get("budget_wan") or 0
|
||||||
@ -90,7 +90,7 @@ return {
|
|||||||
"padding": "12px 16px", "gap": "0px", "overflow": "auto"},
|
"padding": "12px 16px", "gap": "0px", "overflow": "auto"},
|
||||||
"subwidgets": [
|
"subwidgets": [
|
||||||
{"widgettype": "Text", "options": {
|
{"widgettype": "Text", "options": {
|
||||||
"text": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。",
|
"otext": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。", "text": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。", "i18n": True,
|
||||||
"cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}
|
"cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}
|
||||||
] + rows
|
] + rows
|
||||||
}]
|
}]
|
||||||
|
|||||||
@ -27,7 +27,7 @@ dl_base = entire_url("/pipeline-opportunity/api/opp_attachment_dl.dspy")
|
|||||||
body = []
|
body = []
|
||||||
if not ok or not isinstance(det, dict):
|
if not ok or not isinstance(det, dict):
|
||||||
body.append({"widgettype": "Text", "options": {
|
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"}})
|
"color": "#dc2626", "padding": "12px", "halign": "left"}})
|
||||||
else:
|
else:
|
||||||
# ── 标题 ──
|
# ── 标题 ──
|
||||||
@ -111,7 +111,7 @@ else:
|
|||||||
# ── 公告/需求全文(markdown 渲染,链接可点击;2026-09-09 用户定夺改 MdWidget)──
|
# ── 公告/需求全文(markdown 渲染,链接可点击;2026-09-09 用户定夺改 MdWidget)──
|
||||||
detail_text = str(det.get("detail_text") or "").strip()
|
detail_text = str(det.get("detail_text") or "").strip()
|
||||||
body.append({"widgettype": "Text", "options": {
|
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"}})
|
"halign": "left", "margin": "0 0 4px 0"}})
|
||||||
if detail_text:
|
if detail_text:
|
||||||
shown = detail_text[:20000]
|
shown = detail_text[:20000]
|
||||||
@ -123,7 +123,7 @@ else:
|
|||||||
"borderRadius": "8px", "margin": "0 0 10px 0", "bgcolor": "#ffffff"}})
|
"borderRadius": "8px", "margin": "0 0 10px 0", "bgcolor": "#ffffff"}})
|
||||||
else:
|
else:
|
||||||
body.append({"widgettype": "Text", "options": {
|
body.append({"widgettype": "Text", "options": {
|
||||||
"text": "爬取平台未保存该条全文,可点「打开原文页」查看。",
|
"otext": "爬取平台未保存该条全文,可点「打开原文页」查看。", "text": "爬取平台未保存该条全文,可点「打开原文页」查看。", "i18n": True,
|
||||||
"cfontsize": 0.82, "color": "#94a3b8", "halign": "left",
|
"cfontsize": 0.82, "color": "#94a3b8", "halign": "left",
|
||||||
"margin": "0 0 10px 0"}})
|
"margin": "0 0 10px 0"}})
|
||||||
|
|
||||||
@ -140,7 +140,7 @@ else:
|
|||||||
"margin": "0 0 10px 0"}})
|
"margin": "0 0 10px 0"}})
|
||||||
elif not atts:
|
elif not atts:
|
||||||
body.append({"widgettype": "Text", "options": {
|
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"}})
|
"halign": "left", "margin": "0 0 10px 0"}})
|
||||||
else:
|
else:
|
||||||
for a in atts:
|
for a in atts:
|
||||||
|
|||||||
@ -48,7 +48,7 @@ else:
|
|||||||
clusters = _res if isinstance(_res, list) else []
|
clusters = _res if isinstance(_res, list) else []
|
||||||
if not clusters:
|
if not clusters:
|
||||||
rows.append({"widgettype": "Text", "options": {
|
rows.append({"widgettype": "Text", "options": {
|
||||||
"text": "该批次没有聚类结果(可能范围内无需求数据)",
|
"otext": "该批次没有聚类结果(可能范围内无需求数据)", "text": "该批次没有聚类结果(可能范围内无需求数据)", "i18n": True,
|
||||||
"color": "#64748b", "padding": "12px", "halign": "left"}})
|
"color": "#64748b", "padding": "12px", "halign": "left"}})
|
||||||
for c in clusters:
|
for c in clusters:
|
||||||
cid = str(c.get("id") or "")
|
cid = str(c.get("id") or "")
|
||||||
@ -93,7 +93,7 @@ else:
|
|||||||
"wrap": True}},
|
"wrap": True}},
|
||||||
]},
|
]},
|
||||||
{"widgettype": "Filler"},
|
{"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"}},
|
"cfontsize": 0.85, "color": "#64748b"}},
|
||||||
{"widgettype": "Text", "options": {"text": "›", "cfontsize": 1.1, "color": "#94a3b8"}},
|
{"widgettype": "Text", "options": {"text": "›", "cfontsize": 1.1, "color": "#94a3b8"}},
|
||||||
]})
|
]})
|
||||||
@ -109,7 +109,7 @@ return {
|
|||||||
"gap": "4px", "overflow": "auto"},
|
"gap": "4px", "overflow": "auto"},
|
||||||
"subwidgets": [
|
"subwidgets": [
|
||||||
{"widgettype": "Text", "options": {
|
{"widgettype": "Text", "options": {
|
||||||
"text": "点击任一类别,查看类内需求明细(每条带来源链接可查证)。",
|
"otext": "点击任一类别,查看类内需求明细(每条带来源链接可查证)。", "text": "点击任一类别,查看类内需求明细(每条带来源链接可查证)。", "i18n": True,
|
||||||
"cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}},
|
"cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}},
|
||||||
] + rows,
|
] + rows,
|
||||||
}],
|
}],
|
||||||
|
|||||||
@ -57,7 +57,7 @@ else:
|
|||||||
items = _res.get("samples") or []
|
items = _res.get("samples") or []
|
||||||
if not items:
|
if not items:
|
||||||
rows.append({"widgettype": "Text", "options": {
|
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:
|
for it in items:
|
||||||
src_id = str(it.get("src_id") or "")
|
src_id = str(it.get("src_id") or "")
|
||||||
title = str(it.get("title") or "")
|
title = str(it.get("title") or "")
|
||||||
@ -113,7 +113,7 @@ return {
|
|||||||
"padding": "12px 16px", "gap": "0px", "overflow": "auto"},
|
"padding": "12px 16px", "gap": "0px", "overflow": "auto"},
|
||||||
"subwidgets": [
|
"subwidgets": [
|
||||||
{"widgettype": "Text", "options": {
|
{"widgettype": "Text", "options": {
|
||||||
"text": "点击任一条目弹出需求详情(数据来自数据爬取平台快照,每条标注来源平台)。",
|
"otext": "点击任一条目弹出需求详情(数据来自数据爬取平台快照,每条标注来源平台)。", "text": "点击任一条目弹出需求详情(数据来自数据爬取平台快照,每条标注来源平台)。", "i18n": True,
|
||||||
"cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}},
|
"cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}},
|
||||||
] + rows,
|
] + rows,
|
||||||
}],
|
}],
|
||||||
|
|||||||
@ -77,8 +77,7 @@ def _start_script(scope):
|
|||||||
rows = []
|
rows = []
|
||||||
# ── 顶部:启动按钮 + 说明 ──
|
# ── 顶部:启动按钮 + 说明 ──
|
||||||
rows.append({"widgettype": "Text", "options": {
|
rows.append({"widgettype": "Text", "options": {
|
||||||
"text": "需求挖掘:把众包平台(猪八戒/一品威客/Freelancer/PPH)的软件需求做语义聚类,"
|
"otext": "需求挖掘:把众包平台(猪八戒/一品威客/Freelancer/PPH)的软件需求做语义聚类,发现市场热门软件类别(TopX)或分析你指定的需求类型。批次后台执行,完成后点击行查看类别排名。", "text": "需求挖掘:把众包平台(猪八戒/一品威客/Freelancer/PPH)的软件需求做语义聚类,发现市场热门软件类别(TopX)或分析你指定的需求类型。批次后台执行,完成后点击行查看类别排名。", "i18n": True,
|
||||||
"发现市场热门软件类别(TopX)或分析你指定的需求类型。批次后台执行,完成后点击行查看类别排名。",
|
|
||||||
"cfontsize": 0.8, "color": "#64748b", "halign": "left", "wrap": True, "margin": "0 0 8px 0"}})
|
"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"},
|
rows.append({"widgettype": "HBox", "options": {"width": "100%", "gap": "10px", "margin": "0 0 12px 0"},
|
||||||
"subwidgets": [
|
"subwidgets": [
|
||||||
@ -96,13 +95,12 @@ rows.append({"widgettype": "HBox", "options": {"width": "100%", "gap": "10px", "
|
|||||||
|
|
||||||
if not _ok:
|
if not _ok:
|
||||||
rows.append({"widgettype": "Text", "options": {
|
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:
|
else:
|
||||||
batches = _res if isinstance(_res, list) else []
|
batches = _res if isinstance(_res, list) else []
|
||||||
if not batches:
|
if not batches:
|
||||||
rows.append({"widgettype": "Text", "options": {
|
rows.append({"widgettype": "Text", "options": {
|
||||||
"text": "还没有挖掘批次。点击上方「⛏ 启动全量挖掘」开始(首次全量约3-5分钟;"
|
"otext": "还没有挖掘批次。点击上方「⛏ 启动全量挖掘」开始(首次全量约3-5分钟;embedding 有全机构共享缓存,二次批次显著更快)。", "text": "还没有挖掘批次。点击上方「⛏ 启动全量挖掘」开始(首次全量约3-5分钟;embedding 有全机构共享缓存,二次批次显著更快)。", "i18n": True,
|
||||||
"embedding 有全机构共享缓存,二次批次显著更快)。",
|
|
||||||
"color": "#64748b", "padding": "12px", "halign": "left", "wrap": True}})
|
"color": "#64748b", "padding": "12px", "halign": "left", "wrap": True}})
|
||||||
for b in batches:
|
for b in batches:
|
||||||
bid = str(b.get("id") or "")
|
bid = str(b.get("id") or "")
|
||||||
|
|||||||
@ -27,13 +27,13 @@ async with DBPools().sqlorContext(dbname) as sor:
|
|||||||
rows = []
|
rows = []
|
||||||
if not ok:
|
if not ok:
|
||||||
rows.append({"widgettype": "Text", "options": {
|
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"}})
|
"color": "#dc2626", "padding": "12px", "halign": "left"}})
|
||||||
else:
|
else:
|
||||||
items = res.get("items") or []
|
items = res.get("items") or []
|
||||||
if not items:
|
if not items:
|
||||||
rows.append({"widgettype": "Text", "options": {
|
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:
|
for it in items:
|
||||||
item_id = str(it.get("id") or "")
|
item_id = str(it.get("id") or "")
|
||||||
budget = it.get("budget_wan") or 0
|
budget = it.get("budget_wan") or 0
|
||||||
@ -90,7 +90,7 @@ return {
|
|||||||
"padding": "12px 16px", "gap": "0px", "overflow": "auto"},
|
"padding": "12px 16px", "gap": "0px", "overflow": "auto"},
|
||||||
"subwidgets": [
|
"subwidgets": [
|
||||||
{"widgettype": "Text", "options": {
|
{"widgettype": "Text", "options": {
|
||||||
"text": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。",
|
"otext": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。", "text": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。", "i18n": True,
|
||||||
"cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}
|
"cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}
|
||||||
] + rows
|
] + rows
|
||||||
}]
|
}]
|
||||||
|
|||||||
@ -27,13 +27,13 @@ async with DBPools().sqlorContext(dbname) as sor:
|
|||||||
rows = []
|
rows = []
|
||||||
if not ok:
|
if not ok:
|
||||||
rows.append({"widgettype": "Text", "options": {
|
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"}})
|
"color": "#dc2626", "padding": "12px", "halign": "left"}})
|
||||||
else:
|
else:
|
||||||
items = res.get("items") or []
|
items = res.get("items") or []
|
||||||
if not items:
|
if not items:
|
||||||
rows.append({"widgettype": "Text", "options": {
|
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:
|
for it in items:
|
||||||
item_id = str(it.get("id") or "")
|
item_id = str(it.get("id") or "")
|
||||||
budget = it.get("budget_wan") or 0
|
budget = it.get("budget_wan") or 0
|
||||||
@ -95,7 +95,7 @@ return {
|
|||||||
"padding": "12px 16px", "gap": "0px", "overflow": "auto"},
|
"padding": "12px 16px", "gap": "0px", "overflow": "auto"},
|
||||||
"subwidgets": [
|
"subwidgets": [
|
||||||
{"widgettype": "Text", "options": {
|
{"widgettype": "Text", "options": {
|
||||||
"text": "点击任一条目弹出具体内容(公告全文来自数据爬取平台,有附件的可直接下载);每条说明中已标注数据来源。",
|
"otext": "点击任一条目弹出具体内容(公告全文来自数据爬取平台,有附件的可直接下载);每条说明中已标注数据来源。", "text": "点击任一条目弹出具体内容(公告全文来自数据爬取平台,有附件的可直接下载);每条说明中已标注数据来源。", "i18n": True,
|
||||||
"cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}
|
"cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}
|
||||||
] + rows
|
] + rows
|
||||||
}]
|
}]
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import json as _json
|
|||||||
|
|
||||||
uid = await get_user()
|
uid = await get_user()
|
||||||
if not uid:
|
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')
|
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,
|
{"widgettype": "Text", "options": {"text": cat, "cfontsize": 1.0,
|
||||||
"color": "#0f172a", "halign": "left"}},
|
"color": "#0f172a", "halign": "left"}},
|
||||||
{"widgettype": "Filler"},
|
{"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"}},
|
"cfontsize": 0.85, "color": "#64748b"}},
|
||||||
]
|
]
|
||||||
if extra_txt:
|
if extra_txt:
|
||||||
@ -45,7 +45,7 @@ def _topic_row(cat, cnt, budget, target_url, popup_id, extra_txt=""):
|
|||||||
"cfontsize": 0.75, "color": "#94a3b8"}})
|
"cfontsize": 0.75, "color": "#94a3b8"}})
|
||||||
else:
|
else:
|
||||||
subs.append({"widgettype": "Text", "options": {
|
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"}})
|
"cfontsize": 0.85, "color": "#64748b"}})
|
||||||
subs.append({"widgettype": "Text", "options": {"text": "›",
|
subs.append({"widgettype": "Text", "options": {"text": "›",
|
||||||
"cfontsize": 1.1, "color": "#94a3b8"}})
|
"cfontsize": 1.1, "color": "#94a3b8"}})
|
||||||
@ -70,7 +70,7 @@ else:
|
|||||||
ranking = res.get("ranking") or []
|
ranking = res.get("ranking") or []
|
||||||
if not ranking:
|
if not ranking:
|
||||||
rows.append({"widgettype": "Text", "options": {
|
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:
|
for r in ranking:
|
||||||
rows.append(_topic_row(str(r.get("category", "")), r.get("tender_count", 0),
|
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"))
|
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 []
|
dm_ranking = res_dm.get("ranking") or []
|
||||||
if dm_ranking:
|
if dm_ranking:
|
||||||
dm_rows.append({"widgettype": "Text", "options": {
|
dm_rows.append({"widgettype": "Text", "options": {
|
||||||
"text": "国内众包软件需求(猪八戒/一品威客,近30天)",
|
"otext": "国内众包软件需求(猪八戒/一品威客,近30天)", "text": "国内众包软件需求(猪八戒/一品威客,近30天)", "i18n": True,
|
||||||
"cfontsize": 0.95, "color": "#0f172a", "halign": "left",
|
"cfontsize": 0.95, "color": "#0f172a", "halign": "left",
|
||||||
"margin": "14px 0 6px 0", "padding": "6px 0 0 0",
|
"margin": "14px 0 6px 0", "padding": "6px 0 0 0",
|
||||||
"border": "1px solid #f1f5f9", "borderRadius": "6px", "bgcolor": "#f8fafc"}})
|
"border": "1px solid #f1f5f9", "borderRadius": "6px", "bgcolor": "#f8fafc"}})
|
||||||
@ -95,7 +95,7 @@ if ok_ov:
|
|||||||
ov_ranking = res_ov.get("ranking") or []
|
ov_ranking = res_ov.get("ranking") or []
|
||||||
if ov_ranking:
|
if ov_ranking:
|
||||||
ov_rows.append({"widgettype": "Text", "options": {
|
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",
|
"cfontsize": 0.95, "color": "#0f172a", "halign": "left",
|
||||||
"margin": "14px 0 6px 0", "padding": "6px 0 0 0",
|
"margin": "14px 0 6px 0", "padding": "6px 0 0 0",
|
||||||
"border": "1px solid #f1f5f9", "borderRadius": "6px", "bgcolor": "#f8fafc"}})
|
"border": "1px solid #f1f5f9", "borderRadius": "6px", "bgcolor": "#f8fafc"}})
|
||||||
@ -112,7 +112,7 @@ return {
|
|||||||
"gap": "6px", "overflow": "auto"},
|
"gap": "6px", "overflow": "auto"},
|
||||||
"subwidgets": [
|
"subwidgets": [
|
||||||
{"widgettype": "Text", "options": {
|
{"widgettype": "Text", "options": {
|
||||||
"text": "点击任一主题条目,弹出该主题的全部分项明细;明细中再点击任一条,弹出具体内容(数据来自数据爬取平台,含公告全文与附件下载)。",
|
"otext": "点击任一主题条目,弹出该主题的全部分项明细;明细中再点击任一条,弹出具体内容(数据来自数据爬取平台,含公告全文与附件下载)。", "text": "点击任一主题条目,弹出该主题的全部分项明细;明细中再点击任一条,弹出具体内容(数据来自数据爬取平台,含公告全文与附件下载)。", "i18n": True,
|
||||||
"cfontsize": 0.8, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}
|
"cfontsize": 0.8, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}
|
||||||
] + rows + dm_rows + ov_rows
|
] + rows + dm_rows + ov_rows
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,8 @@ dbname = get_module_dbname('pipeline-opportunity')
|
|||||||
|
|
||||||
async with DBPools().sqlorContext(dbname) as sor:
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
from pipeline_opportunity.opp_report_capability import (
|
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)
|
visible, rep = await check_report_visible(sor, report_id, uid)
|
||||||
await sor.sqlExe("COMMIT", {})
|
await sor.sqlExe("COMMIT", {})
|
||||||
if not visible:
|
if not visible:
|
||||||
@ -33,6 +34,8 @@ STATUS_TXT = {
|
|||||||
"approved": "审批通过", "rejected": "已驳回",
|
"approved": "审批通过", "rejected": "已驳回",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TYPE_TXT = {"research": "调研", "feasibility": "可行性研究"}
|
||||||
|
|
||||||
body = []
|
body = []
|
||||||
|
|
||||||
# ── 标题 ──
|
# ── 标题 ──
|
||||||
@ -43,19 +46,22 @@ body.append({"widgettype": "Text", "options": {
|
|||||||
|
|
||||||
# ── 元信息 ──
|
# ── 元信息 ──
|
||||||
st = str(rep.get("status") or "")
|
st = str(rep.get("status") or "")
|
||||||
|
rt = str(rep.get("report_type") or "research")
|
||||||
proj = str(rep.get("project_name") or "")
|
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],
|
str(rep.get("created_at") or "")[:16],
|
||||||
("项目:" + proj) if proj else "平台级调研(未挂项目)",
|
("项目:" + proj) if proj else "平台级调研(未挂项目)",
|
||||||
|
TYPE_TXT.get(rt, rt or "调研"),
|
||||||
STATUS_TXT.get(st, st or "—"),
|
STATUS_TXT.get(st, st or "—"),
|
||||||
str(rep.get("created_by") or "—"))
|
str(rep.get("created_by") or "—"))
|
||||||
body.append({"widgettype": "Text", "options": {
|
body.append({"widgettype": "Text", "options": {
|
||||||
"text": meta, "cfontsize": 0.78, "color": "#64748b", "halign": "left",
|
"text": meta, "cfontsize": 0.78, "color": "#64748b", "halign": "left",
|
||||||
"wrap": True, "margin": "0 0 10px 0"}})
|
"wrap": True, "margin": "0 0 10px 0"}})
|
||||||
|
|
||||||
# ── 四节:研发场景 / 输入 / 输出 / 成果(按节解析正文;空节如实标注,不编造)──
|
# ── 分节渲染:research 四节 / feasibility 八节(按 report_type 选模板;
|
||||||
sections = parse_report_sections(str(rep.get("content") or ""))
|
# 空节如实标注,不编造)──
|
||||||
for name in REPORT_SECTIONS:
|
sections = parse_report_sections(str(rep.get("content") or ""), rt)
|
||||||
|
for name in sections_for_type(rt):
|
||||||
md = (sections.get(name) or "").strip()
|
md = (sections.get(name) or "").strip()
|
||||||
body.append({"widgettype": "Text", "options": {
|
body.append({"widgettype": "Text", "options": {
|
||||||
"text": name, "cfontsize": 0.95, "color": "#0f172a", "halign": "left",
|
"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"}})
|
"margin": "0 0 10px 0", "bgcolor": "#ffffff"}})
|
||||||
else:
|
else:
|
||||||
body.append({"widgettype": "Text", "options": {
|
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"}})
|
"halign": "left", "margin": "0 0 10px 0"}})
|
||||||
|
|
||||||
# ── 成果文件(多个列出,逐个下载)──
|
# ── 成果文件(多个列出,逐个下载)──
|
||||||
@ -79,7 +85,7 @@ body.append({"widgettype": "Text", "options": {
|
|||||||
"halign": "left", "margin": "0 0 4px 0"}})
|
"halign": "left", "margin": "0 0 4px 0"}})
|
||||||
if not files:
|
if not files:
|
||||||
body.append({"widgettype": "Text", "options": {
|
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"}})
|
"cfontsize": 0.82, "color": "#94a3b8", "halign": "left", "margin": "0 0 10px 0"}})
|
||||||
for f in files:
|
for f in files:
|
||||||
size = int(f.get("size") or 0)
|
size = int(f.get("size") or 0)
|
||||||
|
|||||||
@ -45,7 +45,7 @@ if not _cur_pid:
|
|||||||
"options": {"width": "100%", "gap": "12px", "padding": "24px"},
|
"options": {"width": "100%", "gap": "12px", "padding": "24px"},
|
||||||
"subwidgets": [
|
"subwidgets": [
|
||||||
{"widgettype": "Text",
|
{"widgettype": "Text",
|
||||||
"options": {"text": "当前会话还没有项目,请先选择或新建项目",
|
"options": {"otext": "当前会话还没有项目,请先选择或新建项目", "text": "当前会话还没有项目,请先选择或新建项目", "i18n": True,
|
||||||
"cfontsize": 1, "color": "#64748b"}},
|
"cfontsize": 1, "color": "#64748b"}},
|
||||||
{"widgettype": "HBox",
|
{"widgettype": "HBox",
|
||||||
"options": {"width": "100%", "gap": "10px"},
|
"options": {"width": "100%", "gap": "10px"},
|
||||||
@ -72,24 +72,37 @@ STATUS_TXT = {
|
|||||||
"rejected": ("已驳回", "#dc2626"),
|
"rejected": ("已驳回", "#dc2626"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TYPE_TXT = {
|
||||||
|
"research": ("调研", "#0369a1"),
|
||||||
|
"feasibility": ("可行性研究", "#7c3aed"),
|
||||||
|
}
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
if not reports:
|
if not reports:
|
||||||
rows.append({"widgettype": "Text", "options": {
|
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"}})
|
"cfontsize": 0.85, "color": "#64748b", "padding": "12px", "halign": "left"}})
|
||||||
for r in reports:
|
for r in reports:
|
||||||
rid = str(r.get("id") or "")
|
rid = str(r.get("id") or "")
|
||||||
st = str(r.get("status") or "")
|
st = str(r.get("status") or "")
|
||||||
st_txt, st_color = STATUS_TXT.get(st, (st or "—", "#64748b"))
|
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 "")
|
proj = str(r.get("project_name") or "")
|
||||||
meta = "%s | %s | %s" % (
|
meta = "%s | %s | %s" % (
|
||||||
str(r.get("created_at") or "")[:16],
|
str(r.get("created_at") or "")[:16],
|
||||||
("项目:" + proj) if proj else "平台级调研(未挂项目)",
|
("项目:" + proj) if proj else "平台级调研(未挂项目)",
|
||||||
st_txt)
|
st_txt)
|
||||||
row_subs = [
|
row_subs = [
|
||||||
{"widgettype": "Text", "options": {
|
{"widgettype": "HBox", "options": {"width": "100%", "gap": "8px",
|
||||||
"text": str(r.get("title") or r.get("software") or "(无标题)"),
|
"alignItems": "center"},
|
||||||
"cfontsize": 0.95, "color": "#0f172a", "halign": "left", "wrap": True}},
|
"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",
|
{"widgettype": "HBox", "options": {"width": "100%", "gap": "10px",
|
||||||
"alignItems": "center"},
|
"alignItems": "center"},
|
||||||
"subwidgets": [
|
"subwidgets": [
|
||||||
@ -122,7 +135,7 @@ return {
|
|||||||
"padding": "12px 16px", "gap": "0px", "overflow": "auto"},
|
"padding": "12px 16px", "gap": "0px", "overflow": "auto"},
|
||||||
"subwidgets": [
|
"subwidgets": [
|
||||||
{"widgettype": "Text", "options": {
|
{"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"}}
|
"cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}
|
||||||
] + rows
|
] + rows
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,6 +61,13 @@ fields_str=r'''[
|
|||||||
"nullable": "no",
|
"nullable": "no",
|
||||||
"default": "draft"
|
"default": "draft"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "report_type",
|
||||||
|
"title": "报告类型(research调研/feasibility可行性研究)",
|
||||||
|
"type": "str",
|
||||||
|
"length": 16,
|
||||||
|
"default": "research"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "confirm_task_id",
|
"name": "confirm_task_id",
|
||||||
"title": "确认任务ID",
|
"title": "确认任务ID",
|
||||||
|
|||||||
@ -121,6 +121,17 @@
|
|||||||
"datatype": "str",
|
"datatype": "str",
|
||||||
"label": "报告状态"
|
"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",
|
"name": "confirm_task_id",
|
||||||
"title": "确认任务ID",
|
"title": "确认任务ID",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user