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

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

View File

@ -151,6 +151,45 @@ CRUD 页(`opp_reports/` `opp_approvals/`)保留供后台运维直接编辑
- 验证脚本:scripts/p0_probe_vdb_embed*.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/`

View File

@ -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

View File

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

View File

@ -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)"}
] ]
} }

View File

@ -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",

View File

@ -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 '确认人',

View File

@ -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,
} }

View 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

View File

@ -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

View File

@ -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 ""

View File

@ -64,6 +64,16 @@ appbase `params` 表两个键(`opp_common.get_crawler_config` 读取,默认
| `opp_mining_status(batch_id)` | 批次状态轮询(pulling/embedding/clustering/naming/done/failed+原因) | | `opp_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`。

View File

@ -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",

View File

@ -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
}] }]

View File

@ -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:

View File

@ -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,
}], }],

View File

@ -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,
}], }],

View File

@ -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 "")

View File

@ -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
}] }]

View File

@ -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
}] }]

View File

@ -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
} }

View File

@ -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)

View File

@ -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
} }

View File

@ -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",

View File

@ -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",