From 409230472647709d7c8a2cf8ae1afde20b69ab6f Mon Sep 17 00:00:00 2001 From: yumoqing Date: Mon, 14 Sep 2026 09:03:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(feasibility):=20P3=E5=8F=AF=E8=A1=8C?= =?UTF-8?q?=E6=80=A7=E7=A0=94=E7=A9=B6=E2=80=94=E2=80=94opp=5Ffeasibility?= =?UTF-8?q?=E5=BC=95=E6=93=8E(fp=5Festimate=E8=A7=84=E5=88=99=E5=BC=95?= =?UTF-8?q?=E6=93=8EFP/draft=5Ffeasibility=E5=85=AB=E8=8A=82=E6=8A=A5?= =?UTF-8?q?=E5=91=8A/score=5Fcandidate=E8=AF=84=E5=88=86/promote=5Fto=5Fpr?= =?UTF-8?q?oject=E7=AB=8B=E9=A1=B9=E9=97=A8=E7=A6=81)+report=5Ftype?= =?UTF-8?q?=E5=88=97(models/DDL/CRUD=E9=87=8D=E7=94=9F=E6=88=90)+=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=A1=B5=E4=B8=8EPPT=E6=8C=89=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E9=80=89=E5=9B=9B=E8=8A=82=E6=88=96=E5=85=AB=E8=8A=82=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF(sections=5Ffor=5Ftype=E5=8D=95=E4=B8=80=E8=A7=84?= =?UTF-8?q?=E5=88=99=E6=BA=90)+=E5=88=97=E8=A1=A8=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=BE=BD=E6=A0=87+prompt=E9=93=BE=E8=B7=AFC+params/demand=5Fmi?= =?UTF-8?q?ning=E5=AD=97=E5=85=B8=E7=A7=8D=E5=AD=90+i18n=E8=AF=8D=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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新列部分 --- README.md | 39 +++ i18n/en/msg.txt | 11 + i18n/zh/msg.txt | 11 + init/data.json | 19 +- models/opp_reports.json | 7 + mysql.ddl.sql | 1 + pipeline_opportunity/opp_ability.py | 82 ++++- pipeline_opportunity/opp_feasibility.py | 296 ++++++++++++++++++ pipeline_opportunity/opp_ppt.py | 35 ++- pipeline_opportunity/opp_report_capability.py | 54 +++- skill/SKILL.md | 10 + wwwroot/agent/index.ui | 4 +- wwwroot/api/opp_demand_items.dspy | 6 +- wwwroot/api/opp_item_detail.dspy | 8 +- wwwroot/api/opp_mining_clusters.dspy | 6 +- wwwroot/api/opp_mining_items.dspy | 4 +- wwwroot/api/opp_mining_popup.dspy | 8 +- wwwroot/api/opp_overseas_items.dspy | 6 +- wwwroot/api/opp_references_items.dspy | 6 +- wwwroot/api/opp_references_popup.dspy | 14 +- wwwroot/api/opp_report_detail.dspy | 20 +- wwwroot/api/opp_reports_list.dspy | 25 +- wwwroot/opp_reports/get_opp_reports.dspy | 7 + wwwroot/opp_reports/index.ui | 11 + 24 files changed, 617 insertions(+), 73 deletions(-) create mode 100644 pipeline_opportunity/opp_feasibility.py diff --git a/README.md b/README.md index 71e94f8..817a47e 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,45 @@ CRUD 页(`opp_reports/` `opp_approvals/`)保留供后台运维直接编辑 - 验证脚本:scripts/p0_probe_vdb_embed*.py(VDB/embed 协议探测)、 p1_cluster_analyze.py(多τ对比+缓存,调参用)、p1_create_tables.py(幂等建表)。 +## 可行性研究(P3,2026-09-14) + +挖掘类别 → 可行性研究 → 评分 → 立项(report_type=feasibility 八节报告): + +- **opp_feasibility.py**(引擎): + - `fp_estimate`:功能点估算。LLM 只提供结构化功能清单 + (name/type=EI|EO|EQ|ILF|EIF/det/ret/ftr/evidence/confidence), + 复杂度判定与 FP 加权**全部走规则引擎 fp_calc.py** + (pipeline-core `skills_library/all/function-point-counting/`,部署位 + `skills/global/`,进程内 importlib 加载;加载失败如实报错,绝不 LLM 心算 FP)。 + 返回键对齐 fp_calc.calc() 实际输出:total_fp/counts(→by_type)/errors/pending。 + - `draft_feasibility(cluster_id)`:类别须已完成共性提取(normalize done)且有 + core/ext 标准需求(全 personal 时如实拒绝"可行性样本不足")。FP 输入=每条 + 核心/扩展标准需求映射 1 ILF+1 EI+1 EQ(DET 按证据原子数粗估);成本=FP× + opp_fp_pm_per_fp(默认0.05人月/FP)×opp_cost_wan_per_pm(默认3.0万元/人月), + params 可配。LLM 只写三节叙述(架构与可行性/商业价值/风险),其余五节用 + 挖掘真实数据拼装。落库 opp_reports(report_type=feasibility)。 + - `score_candidate(cluster_id)`:规则评分=需求热度40%(log 规模防大簇垄断)+ + 共性集中度35%(core+ext 覆盖文档占比)+规模适配25%(标准需求 8-25 个最佳)。 + - `promote_to_project(report_id)`:立项门禁——仅 report_type=feasibility 且 + 过人工确认(status∈confirmed/approval_initiated/approved)且审批单 approved + 才可建 sd_projects(pipeline=opportunity_general,project_type=demand_mining, + 字典种子在 init/data.json)。agent 不能自批自审。 + - 项目隔离:`_cluster_ctx` 对齐 `_check_batch_visible` 口径(批次挂项目的仅 + 同项目会话可见,平台级批次本机构可见)。 +- **八节模板**(report_type=feasibility):`## 市场需求 / ## 共性需求 / ## 需求规格 / + ## 架构与可行性 / ## FP与成本 / ## 商业价值 / ## 风险 / ## 结论建议`。 + `sections_for_type(report_type)` 是单一规则源:详情页(opp_report_detail.dspy) + 与 PPT(opp_ppt.py FEASIBILITY_ORDER)按类型选四节/八节渲染; + research 报告四节模板不变,存量报告解析口径零变化。 +- **opp_reports.report_type 列**(VARCHAR(16) DEFAULT 'research'): + 测试机/生产走 pipeline-app `deploy/migrations/m0030_opp_reports_report_type.json` + (dmig apply,幂等 ADD COLUMN);models/opp_reports.json 已同步(新建表天然带列)。 +- **报告列表标签**:opp_reports_list.dspy 行内加类型徽标(调研=蓝/可行性研究=紫)。 +- PPT 文件名区分:feasibility_report__.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/` diff --git a/i18n/en/msg.txt b/i18n/en/msg.txt index 64e56e4..b628621 100644 --- a/i18n/en/msg.txt +++ b/i18n/en/msg.txt @@ -37,3 +37,14 @@ 下载=Download 无权查看=No Access 暂无成果文件=No output files yet +可行性研究=Feasibility Study +调研=Research +需求挖掘立项=Demand-Mining Initiative +市场需求=Market Demand +共性需求=Common Requirements +需求规格=Requirement Spec +架构与可行性=Architecture & Feasibility +FP与成本=FP & Cost +商业价值=Business Value +风险=Risks +结论建议=Conclusion diff --git a/i18n/zh/msg.txt b/i18n/zh/msg.txt index 1171ee7..3715842 100644 --- a/i18n/zh/msg.txt +++ b/i18n/zh/msg.txt @@ -37,3 +37,14 @@ 下载=下载 无权查看=无权查看 暂无成果文件=暂无成果文件 +可行性研究=可行性研究 +调研=调研 +需求挖掘立项=需求挖掘立项 +市场需求=市场需求 +共性需求=共性需求 +需求规格=需求规格 +架构与可行性=架构与可行性 +FP与成本=FP与成本 +商业价值=商业价值 +风险=风险 +结论建议=结论建议 diff --git a/init/data.json b/init/data.json index b193b8e..86f07b7 100644 --- a/init/data.json +++ b/init/data.json @@ -29,6 +29,21 @@ {"k": "approved", "v": "审批通过"}, {"k": "rejected", "v": "审批驳回"} ] + }, + { + "parentid": "sd_project_type", + "parentname": "项目类型", + "items": [ + {"k": "demand_mining", "v": "需求挖掘立项"} + ] + }, + { + "parentid": "opp_report_type", + "parentname": "研发报告类型", + "items": [ + {"k": "research", "v": "调研报告"}, + {"k": "feasibility", "v": "可行性研究"} + ] } ], "params": [ @@ -43,6 +58,8 @@ {"name": "opp_norm_tau2", "value": "0.85", "desc": "归一化相似度触发阈值(只当触发器,LLM裁决)"}, {"name": "opp_norm_topk", "value": "10", "desc": "原子kNN邻居数"}, {"name": "opp_cov_core", "value": "0.6", "desc": "核心共性覆盖率阈值"}, - {"name": "opp_cov_ext", "value": "0.4", "desc": "扩展共性覆盖率阈值"} + {"name": "opp_cov_ext", "value": "0.4", "desc": "扩展共性覆盖率阈值"}, + {"name": "opp_fp_pm_per_fp", "value": "0.05", "desc": "FP→人月经验系数(可行性研究成本估算,P3)"}, + {"name": "opp_cost_wan_per_pm", "value": "3.0", "desc": "人月单价万元(可行性研究成本估算,P3)"} ] } diff --git a/models/opp_reports.json b/models/opp_reports.json index faa5c29..ad09f4e 100644 --- a/models/opp_reports.json +++ b/models/opp_reports.json @@ -50,6 +50,13 @@ "nullable": "no", "default": "draft" }, + { + "name": "report_type", + "title": "报告类型(research调研/feasibility可行性研究)", + "type": "str", + "length": 16, + "default": "research" + }, { "name": "confirm_task_id", "title": "确认任务ID", diff --git a/mysql.ddl.sql b/mysql.ddl.sql index bb04588..2ca3108 100644 --- a/mysql.ddl.sql +++ b/mysql.ddl.sql @@ -17,6 +17,7 @@ CREATE TABLE opp_reports `title` VARCHAR(255) NOT NULL comment '报告标题', `content` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci comment '报告正文', `status` VARCHAR(24) NOT NULL DEFAULT 'draft' comment '报告状态', + `report_type` VARCHAR(16) DEFAULT 'research' comment '报告类型(research调研/feasibility可行性研究)', `confirm_task_id` VARCHAR(32) comment '确认任务ID', `ppt_path` VARCHAR(500) comment 'PPT文件路径', `confirmed_by` VARCHAR(64) comment '确认人', diff --git a/pipeline_opportunity/opp_ability.py b/pipeline_opportunity/opp_ability.py index a225213..2dc5b38 100644 --- a/pipeline_opportunity/opp_ability.py +++ b/pipeline_opportunity/opp_ability.py @@ -93,6 +93,14 @@ OPP_TOOLS = [ parameters={"cluster_id": "类别ID"}, category="mining"), ToolDefinition(name="opp_coverage_report", description="查看某类别的共性需求覆盖率报告:核心共性(覆盖率≥60%)/扩展共性(≥40%)/个性需求三层 + 能力域分组 + 每条标准需求的覆盖文档数与证据", parameters={"cluster_id": "类别ID"}, category="mining"), + ToolDefinition(name="opp_fp_estimate", description="软件功能点估算(IFPUG):传入结构化功能清单(name/type=EI|EO|EQ|ILF|EIF/det/ret/ftr/evidence/confidence),复杂度与FP由规则引擎fp_calc计算(禁LLM心算),返回总FP/分类型计数/待确认项/四件套markdown", + parameters={"functions_json": "功能清单JSON数组字符串", "project_name": "项目名(可选)"}, category="mining"), + ToolDefinition(name="opp_draft_feasibility", description="起草八节可行性研究报告(report_type=feasibility):市场需求/共性需求/需求规格/架构与可行性/FP与成本/商业价值/风险/结论建议。FP走规则引擎,数据全部来自挖掘结果。返回 report_id", + parameters={"cluster_id": "类别ID(须已完成共性提取)"}, category="mining"), + ToolDefinition(name="opp_score_candidate", description="研发项目候选评分(规则模型):需求热度40%+共性集中度35%+规模适配25%→总分与★优先级", + parameters={"cluster_id": "类别ID"}, category="mining"), + ToolDefinition(name="opp_promote_to_project", description="可行性报告立项:仅 report_type=feasibility 且过人工确认+审批approved 的报告可建 sd_projects 研发项目(门禁:agent不能自批自审)", + parameters={"report_id": "报告ID"}, category="mining"), ] # ══════════════════ 产线 prompt 片段 ══════════════════ @@ -149,10 +157,28 @@ opp_create_report / opp_update_report 的正文必须用以下四个二级标题 2. opp_mining_status 轮询进度(每轮回复用户当前状态;failed 时如实转述原因,禁隐瞒)。 3. done 后 opp_list_clusters 看类别排名(热度/需求数/占比),向用户呈现 TopX 表格。 4. 用户选定类别后 opp_cluster_detail 取类内样例(每条带来源URL),结合数据写分析报告; - 需要立项走可行性研究时,用 opp_create_report 建报告(数据基础引用挖掘批次与类别证据)。 + 需要立项走可行性研究时用 **opp_draft_feasibility**(见下节),不要用 + opp_create_report 手写可行性报告。 硬规则:聚类结果只能来自 opp_* mining 工具返回,禁止自己编类别/编排名/编数量; 类别命名证据(naming_evidence 样例)要能支撑命名,命名可疑时如实说明。 +## 可行性研究与立项(P3,report_type=feasibility) +用户要「可行性研究/评估立项/能不能做这个项目」时走这条链: +1. 类别先做共性提取:opp_normalize_cluster(未提取过时报错引导), + opp_coverage_report 看核心/扩展/个性三层共性。 +2. opp_fp_estimate——功能点估算:LLM 只识别功能(name/type=EI|EO|EQ|ILF|EIF/det/ftr), + 复杂度与 FP 加权由规则引擎 fp_calc 计算,**禁止自己心算/编造 FP 数值**。 +3. opp_draft_feasibility(cluster_id)——自动生成八节可行性研究报告草稿 + (市场需求/共性需求/需求规格/架构与可行性/FP与成本/商业价值/风险/结论建议), + 数据全部来自挖掘与覆盖率结果,返回 report_id。可用 opp_update_report 补写完善。 +4. opp_score_candidate(cluster_id)——规则评分(需求热度40%+共性集中度35%+规模适配25%) + 给出★优先级,向用户如实呈现评分因子。 +5. 立项门禁(不可绕过,agent 不能自批自审): + opp_submit_report 提交人工确认 → 人工确认通过(confirmed)→ 发起研发审批 → + 审批回流 approved 后,才可用 opp_promote_to_project(report_id) 立项 + (sd_projects,pipeline=opportunity_general,project_type=demand_mining)。 + 报告未过确认/审批时 promote 会被门禁拒绝——如实转述拒绝原因,禁止绕道。 + ## 硬规则 - **统计优先用聚合工具,明细只取样例(2026-09-11 实测教训)**: 数量/排名/预算合计/平均价这类**统计**一律用聚合工具——opp_hot_software(招标侧)、 @@ -532,6 +558,55 @@ async def _h_coverage_report(sor, p, ctx): "标准需求": rows}, ensure_ascii=False, default=str) +async def _h_fp_estimate(sor, p, ctx): + from .opp_feasibility import fp_estimate + fj = p.get("functions_json") or "" + if not fj: + return _fmt(False, "functions_json 必填") + ok, res = await fp_estimate(sor, fj, project_name=p.get("project_name") or "") + if not ok: + return _fmt(False, res) + return json.dumps({"总FP": res["total_fp"], "分类型": res["by_type"], + "待确认": res["pending"], + "输入校验错误": res["errors"], + "四件套": res["markdown"][:2000]}, ensure_ascii=False, default=str) + + +async def _h_draft_feasibility(sor, p, ctx): + from .opp_feasibility import draft_feasibility + cid = (p.get("cluster_id") or "").strip() + if not cid: + return _fmt(False, "cluster_id 必填") + ok, rid = await draft_feasibility(sor, cid, ctx) + if not ok: + return _fmt(False, rid) + return _fmt(True, "可行性报告草稿已建 report_id=%s(八节模板,FP 由规则引擎计算)。" + "补写后可 opp_submit_report 提交人工确认" % rid) + + +async def _h_score_candidate(sor, p, ctx): + from .opp_feasibility import score_candidate + cid = (p.get("cluster_id") or "").strip() + if not cid: + return _fmt(False, "cluster_id 必填") + ok, res = await score_candidate(sor, cid, ctx) + if not ok: + return _fmt(False, res) + return json.dumps(res, ensure_ascii=False, default=str) + + +async def _h_promote_to_project(sor, p, ctx): + from .opp_feasibility import promote_to_project + rid = (p.get("report_id") or "").strip() + if not rid: + return _fmt(False, "report_id 必填") + ok, pid = await promote_to_project(sor, rid, ctx) + if not ok: + return _fmt(False, pid) + return _fmt(True, "已立项 project_id=%s(pipeline=opportunity_general," + "project_type=demand_mining)" % pid) + + OPP_HANDLERS = { "opp_hot_software": _h_hot_software, "opp_daily_ai_tenders": _h_daily_ai, @@ -559,6 +634,11 @@ OPP_HANDLERS = { # 类内共性提取(P2) "opp_normalize_cluster": _h_normalize_cluster, "opp_coverage_report": _h_coverage_report, + # 可行性研究(P3) + "opp_fp_estimate": _h_fp_estimate, + "opp_draft_feasibility": _h_draft_feasibility, + "opp_score_candidate": _h_score_candidate, + "opp_promote_to_project": _h_promote_to_project, } diff --git a/pipeline_opportunity/opp_feasibility.py b/pipeline_opportunity/opp_feasibility.py new file mode 100644 index 0000000..03cb37a --- /dev/null +++ b/pipeline_opportunity/opp_feasibility.py @@ -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 diff --git a/pipeline_opportunity/opp_ppt.py b/pipeline_opportunity/opp_ppt.py index ba3ef05..fa2eb1e 100644 --- a/pipeline_opportunity/opp_ppt.py +++ b/pipeline_opportunity/opp_ppt.py @@ -41,6 +41,10 @@ MAX_BULLETS_PER_PAGE = 7 # 四节模板顺序(与 opp_report_capability.REPORT_SECTIONS 一致) SECTION_ORDER = ("研发场景", "输入", "输出", "成果") +# 可行性研究八节模板(与 opp_report_capability.FEASIBILITY_SECTIONS 一致,P3) +FEASIBILITY_ORDER = ("市场需求", "共性需求", "需求规格", "架构与可行性", + "FP与成本", "商业价值", "风险", "结论建议") + def _rgb(t): from pptx.dml.color import RGBColor @@ -140,18 +144,23 @@ def _split_lead(text): return "", _clean_inline(text) -def _parse_report(content): - """按四节模板解析(与详情页同口径):返回 [(节名, markdown)]。 +def _parse_report(content, report_type="research"): + """按节模板解析(与详情页同口径):返回 [(节名, markdown)]。 - 无四节标题的旧报告 → 整篇归「输出」节(如实呈现,不编造分节)。 + research=四节(研发场景/输入/输出/成果);feasibility=八节。 + 无节标题的旧报告 → 整篇归「输出」节(如实呈现,不编造分节)。 """ content = content or "" + is_feas = report_type == "feasibility" + names = FEASIBILITY_ORDER if is_feas else SECTION_ORDER + fallback = "需求规格" if is_feas else "输出" pat = re.compile( - r"^##\s*(?:[一二三四五六七八九十\d]+[、..]?\s*)?[*]*\s*" - r"(研发场景|输入|输出|成果)\s*[*]*\s*[::]?\s*$", re.MULTILINE) + r"^##\s*(?:[一二三四五六七八九十\d]+[、..]?\s*)?[*]*\s*(" + + "|".join(re.escape(n) for n in names) + + r")\s*[*]*\s*[::]?\s*$", re.MULTILINE) marks = list(pat.finditer(content)) if not marks: - return [("输出", content.strip())] + return [(fallback, content.strip())] out = [] preamble = content[:marks[0].start()].strip() if preamble: @@ -161,7 +170,7 @@ def _parse_report(content): body = content[m.end():end].strip() if body: out.append((m.group(1), body)) - return out or [("输出", content.strip())] + return out or [(fallback, content.strip())] # ══════════════════════ 版式基元 ══════════════════════ @@ -432,10 +441,11 @@ def _closing(prs, page_no): # ══════════════════════ 主入口 ══════════════════════ -def build_report_ppt(title, content, software="", out_path=""): +def build_report_ppt(title, content, software="", out_path="", report_type="research"): """生成 PPT 并保存。返回 (True, 文件路径) 或 (False, 错误)。 - 签名保持不变(opp_report_capability._build_report_ppt 调用方零改动)。 + report_type: research=四节章节页 / feasibility=八节章节页(P3)。 + 其余参数签名保持不变(旧调用方零改动)。 """ try: from pptx import Presentation @@ -456,14 +466,15 @@ def build_report_ppt(title, content, software="", out_path=""): # 预扫描:算出要渲染的页面,便于一次性算总页数(页脚 x/y) pages = [] # [('divider', idx, name) | ('bullets', title, kicker, items) # | ('table', title, kicker, rows, extra)] - sections = _parse_report(content) + sections = _parse_report(content, report_type) + sec_names = FEASIBILITY_ORDER if report_type == "feasibility" else SECTION_ORDER sec_idx = 0 for sec_name, sec_md in sections: - if sec_name in SECTION_ORDER: + if sec_name in sec_names: sec_idx += 1 pages.append(("divider", sec_idx, sec_name)) blocks = _split_blocks(sec_md) - cur_title = sec_name if sec_name not in SECTION_ORDER else sec_name + cur_title = sec_name buf_items = [] # [(lvl, lead, text)] pending_table = None diff --git a/pipeline_opportunity/opp_report_capability.py b/pipeline_opportunity/opp_report_capability.py index 3f25460..680ffa5 100644 --- a/pipeline_opportunity/opp_report_capability.py +++ b/pipeline_opportunity/opp_report_capability.py @@ -28,12 +28,19 @@ logger = logging.getLogger("pipeline.opportunity.report") async def create_report(project_id, software, title="", analysis="", - created_by="agent.opportunity"): - """创建研发报告草稿。返回 (True, report_id) 或 (False, 错误)。""" + created_by="agent.opportunity", report_type="research"): + """创建研发报告草稿。返回 (True, report_id) 或 (False, 错误)。 + + report_type(2026-09-12 P3):research=调研报告(四节模板)/ + feasibility=可行性研究(八节模板,opp_feasibility 起草)。 + """ if not software: return False, "缺少 software(软件/主题名)" + if report_type not in ("research", "feasibility"): + return False, "report_type 必须是 research 或 feasibility" if not title: - title = "%s 研发机会分析报告" % software + title = ("%s 研发可行性研究" % software) if report_type == "feasibility" \ + else ("%s 研发机会分析报告" % software) db, dbname = get_db() rid = new_id() async with db.sqlorContext(dbname) as sor: @@ -44,6 +51,7 @@ async def create_report(project_id, software, title="", analysis="", "title": title, "content": analysis or "", "status": RP_DRAFT, + "report_type": report_type, "created_by": created_by, }) await sor.sqlExe("COMMIT", {}) @@ -89,6 +97,16 @@ async def list_reports(project_id="", status=""): # 报告正文四节模板(二级标题,agent 写报告必须按此结构;旧报告无节时全文归「输出」) REPORT_SECTIONS = ("研发场景", "输入", "输出", "成果") +# 可行性研究八节模板(report_type=feasibility,P3 2026-09-14; +# draft_feasibility 起草,详情页/PPT 按此分节渲染) +FEASIBILITY_SECTIONS = ("市场需求", "共性需求", "需求规格", "架构与可行性", + "FP与成本", "商业价值", "风险", "结论建议") + + +def sections_for_type(report_type): + """按报告类型选节模板(单一规则源:详情页/PPT/解析复用)。""" + return FEASIBILITY_SECTIONS if report_type == "feasibility" else REPORT_SECTIONS + async def _user_org(sor, user_id): recs = await sor.sqlExe( @@ -114,7 +132,8 @@ async def list_visible_reports(sor, user_id): # (实测 ValueError: unsupported format character;rag_client.py 的 # NOT LIKE 'agent.%' 是同一颗潜伏雷)。参数值里的 % 安全。 sql = ( - "SELECT r.id, r.project_id, r.software, r.title, r.status, r.ppt_path, " + "SELECT r.id, r.project_id, r.software, r.title, r.status, r.report_type, " + "r.ppt_path, " "r.created_by, r.created_at, r.updated_at, " "p.name AS project_name, p.created_by AS project_owner " "FROM opp_reports r LEFT JOIN sd_projects p ON r.project_id = p.id " @@ -164,27 +183,32 @@ async def check_report_visible(sor, report_id, user_id): return False, rep -def parse_report_sections(content): - """按四节模板解析报告正文 → {'研发场景': md, '输入': md, '输出': md, '成果': md}。 +def parse_report_sections(content, report_type="research"): + """按节模板解析报告正文 → {节名: markdown}。 - 模板(agent 写报告约定):二级标题 `## 研发场景` / `## 输入` / `## 输出` / `## 成果`。 + research(默认):四节 `## 研发场景/输入/输出/成果`; + feasibility:八节 `## 市场需求/共性需求/需求规格/架构与可行性/FP与成本/商业价值/风险/结论建议`。 兼容标题带序号/冒号/加粗(如 `## 一、研发场景:`、`## **输出**`)。 旧报告(无任何匹配节,如实测的市场调研报告)→ 全文归「输出」节,其余节为空, 详情页对空节如实显示「(本报告未填写此节)」,不编造。 """ import re as _re content = content or "" + sec_names = sections_for_type(report_type) # 节标题行:## [可选序号/标点] 节名 [可选冒号/加粗残留] pattern = _re.compile( r"^##\s*(?:[一二三四五六七八九十\d]+[、..]?\s*)?[**]*\s*" - r"(研发场景|输入|输出|成果)\s*[**]*\s*[::]?\s*$", + r"(" + "|".join(_re.escape(n) for n in sec_names) + r")\s*[**]*\s*[::]?\s*$", _re.MULTILINE) marks = list(pattern.finditer(content)) - sections = {k: "" for k in REPORT_SECTIONS} + sections = {k: "" for k in sec_names} if not marks: - sections["输出"] = content.strip() + # 无匹配节标题:四节模板归「输出」,八节模板归「需求规格」(承载正文主体) + fallback = "输出" if report_type != "feasibility" else "需求规格" + sections[fallback] = content.strip() return sections - # 首个节标题之前的前言(如报告大标题/数据来源说明)归「输出」节头部展示 + # 首个节标题之前的前言(如报告大标题/数据来源说明)归兜底节头部展示 + fallback = "输出" if report_type != "feasibility" else "需求规格" preamble = content[:marks[0].start()].strip() for i, m in enumerate(marks): name = m.group(1) @@ -193,7 +217,7 @@ def parse_report_sections(content): # 同名节重复出现时拼接(容错,不丢内容) sections[name] = (sections[name] + "\n\n" + body).strip() if sections[name] else body if preamble: - sections["输出"] = (preamble + ("\n\n" + sections["输出"] if sections["输出"] else "")).strip() + sections[fallback] = (preamble + ("\n\n" + sections[fallback] if sections[fallback] else "")).strip() return sections @@ -261,11 +285,13 @@ async def _build_report_ppt(sor, rep): project_dir = os.path.join(os.getcwd(), "files", "opp_reports") rid = rep.get("id", "") software = re.sub(r"[^\w\u4e00-\u9fff-]", "_", rep.get("software", "") or "report")[:40] - fname = "research_report_%s_%s.pptx" % (software, rid[:8]) + prefix = "feasibility_report" if rep.get("report_type") == "feasibility" else "research_report" + fname = "%s_%s_%s.pptx" % (prefix, software, rid[:8]) out_path = os.path.join(project_dir, "deliverables", fname) ok, res = build_report_ppt( rep.get("title", ""), rep.get("content", ""), - software=rep.get("software", ""), out_path=out_path) + software=rep.get("software", ""), out_path=out_path, + report_type=rep.get("report_type") or "research") if not ok: logger.warning("build_report_ppt 失败: %s", str(res)[:200]) return "" diff --git a/skill/SKILL.md b/skill/SKILL.md index b4a50e8..a9db495 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -64,6 +64,16 @@ appbase `params` 表两个键(`opp_common.get_crawler_config` 读取,默认 | `opp_mining_status(batch_id)` | 批次状态轮询(pulling/embedding/clustering/naming/done/failed+原因) | | `opp_list_clusters(batch_id)` | 类别排名表(热度/需求数/占比/命名证据)= TopX 热门类别 | | `opp_cluster_detail(cluster_id,limit)` | 类内需求明细样例(每条带来源URL可查证) | +| `opp_normalize_cluster(cluster_id)` | 类内共性提取(原子化→归一→能力域→覆盖率,LLM多耗时正比文档数) | +| `opp_coverage_report(cluster_id)` | 共性覆盖率报告(core≥60%/ext≥40%/个性三层+能力域分组) | +| `opp_fp_estimate(functions_json,project_name)` | 功能点估算:LLM只给功能清单,复杂度/FP加权走规则引擎fp_calc(禁心算) | +| `opp_draft_feasibility(cluster_id)` | 起草八节可行性报告(report_type=feasibility;需先normalize且有core/ext共性) | +| `opp_score_candidate(cluster_id)` | 候选评分:热度40%+集中度35%+规模适配25%→★优先级 | +| `opp_promote_to_project(report_id)` | 立项门禁:仅feasibility+人工确认+审批approved→sd_projects(demand_mining) | + +可行性报告八节模板(详情页/PPT 按 report_type 分节渲染,`sections_for_type` 单一规则源): +`市场需求/共性需求/需求规格/架构与可行性/FP与成本/商业价值/风险/结论建议`; +调研报告(research)仍是四节模板,存量报告解析口径不变。 角色:`agent.opp_analyst` / `agent.opp_writer`;slash:`/hot` `/ai` `/reports` `/oppdiag`。 diff --git a/wwwroot/agent/index.ui b/wwwroot/agent/index.ui index 6e3db49..e4bfbaf 100644 --- a/wwwroot/agent/index.ui +++ b/wwwroot/agent/index.ui @@ -6,8 +6,8 @@ "widgettype": "HBox", "options": {"width": "100%", "alignItems": "center", "padding": "16px 24px 8px 24px", "cheight": 6, "gap": "12px"}, "subwidgets": [ - {"widgettype": "Title2", "options": {"text": "商机产线"}}, - {"widgettype": "Text", "options": {"text": "热点软件 → 今日AI招标 → 检索 → 编写研发报告 → 人工确认 → 研发审批", "cfontsize": 0.9, "color": "#94a3b8"}}, + {"widgettype": "Title2", "options": {"otext": "商机产线", "text": "商机产线", "i18n": true}}, + {"widgettype": "Text", "options": {"otext": "热点软件 → 今日AI招标 → 检索 → 编写研发报告 → 人工确认 → 研发审批", "text": "热点软件 → 今日AI招标 → 检索 → 编写研发报告 → 人工确认 → 研发审批", "i18n": true, "cfontsize": 0.9, "color": "#94a3b8"}}, {"widgettype": "Filler"}, { "widgettype": "Button", diff --git a/wwwroot/api/opp_demand_items.dspy b/wwwroot/api/opp_demand_items.dspy index 5936204..10e4f0e 100644 --- a/wwwroot/api/opp_demand_items.dspy +++ b/wwwroot/api/opp_demand_items.dspy @@ -27,13 +27,13 @@ async with DBPools().sqlorContext(dbname) as sor: rows = [] if not ok: rows.append({"widgettype": "Text", "options": { - "text": "查询失败:" + str(res)[:200], + "otext": "查询失败:${p0}", "text": "查询失败:${p0}", "i18n": True, "i18n_params": {"p0": str(str(res)[:200])}, "color": "#dc2626", "padding": "12px", "halign": "left"}}) else: items = res.get("items") or [] if not items: rows.append({"widgettype": "Text", "options": { - "text": "该主题近 %d 天无众包需求分项" % days, "color": "#64748b", "padding": "12px", "halign": "left"}}) + "otext": "该主题近 ${p0} 天无众包需求分项", "text": "该主题近 ${p0} 天无众包需求分项", "i18n": True, "i18n_params": {"p0": str('%d' % (days,))}, "color": "#64748b", "padding": "12px", "halign": "left"}}) for it in items: item_id = str(it.get("id") or "") budget = it.get("budget_wan") or 0 @@ -90,7 +90,7 @@ return { "padding": "12px 16px", "gap": "0px", "overflow": "auto"}, "subwidgets": [ {"widgettype": "Text", "options": { - "text": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。", + "otext": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。", "text": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。", "i18n": True, "cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}} ] + rows }] diff --git a/wwwroot/api/opp_item_detail.dspy b/wwwroot/api/opp_item_detail.dspy index 4089717..9ce431e 100644 --- a/wwwroot/api/opp_item_detail.dspy +++ b/wwwroot/api/opp_item_detail.dspy @@ -27,7 +27,7 @@ dl_base = entire_url("/pipeline-opportunity/api/opp_attachment_dl.dspy") body = [] if not ok or not isinstance(det, dict): body.append({"widgettype": "Text", "options": { - "text": "获取详情失败:" + str(det)[:200], + "otext": "获取详情失败:${p0}", "text": "获取详情失败:${p0}", "i18n": True, "i18n_params": {"p0": str(str(det)[:200])}, "color": "#dc2626", "padding": "12px", "halign": "left"}}) else: # ── 标题 ── @@ -111,7 +111,7 @@ else: # ── 公告/需求全文(markdown 渲染,链接可点击;2026-09-09 用户定夺改 MdWidget)── detail_text = str(det.get("detail_text") or "").strip() body.append({"widgettype": "Text", "options": { - "text": "全文内容", "cfontsize": 0.95, "color": "#0f172a", + "otext": "全文内容", "text": "全文内容", "i18n": True, "cfontsize": 0.95, "color": "#0f172a", "halign": "left", "margin": "0 0 4px 0"}}) if detail_text: shown = detail_text[:20000] @@ -123,7 +123,7 @@ else: "borderRadius": "8px", "margin": "0 0 10px 0", "bgcolor": "#ffffff"}}) else: body.append({"widgettype": "Text", "options": { - "text": "爬取平台未保存该条全文,可点「打开原文页」查看。", + "otext": "爬取平台未保存该条全文,可点「打开原文页」查看。", "text": "爬取平台未保存该条全文,可点「打开原文页」查看。", "i18n": True, "cfontsize": 0.82, "color": "#94a3b8", "halign": "left", "margin": "0 0 10px 0"}}) @@ -140,7 +140,7 @@ else: "margin": "0 0 10px 0"}}) elif not atts: body.append({"widgettype": "Text", "options": { - "text": "该条暂无附件", "cfontsize": 0.8, "color": "#94a3b8", + "otext": "该条暂无附件", "text": "该条暂无附件", "i18n": True, "cfontsize": 0.8, "color": "#94a3b8", "halign": "left", "margin": "0 0 10px 0"}}) else: for a in atts: diff --git a/wwwroot/api/opp_mining_clusters.dspy b/wwwroot/api/opp_mining_clusters.dspy index ddb02f2..7c54294 100644 --- a/wwwroot/api/opp_mining_clusters.dspy +++ b/wwwroot/api/opp_mining_clusters.dspy @@ -48,7 +48,7 @@ else: clusters = _res if isinstance(_res, list) else [] if not clusters: rows.append({"widgettype": "Text", "options": { - "text": "该批次没有聚类结果(可能范围内无需求数据)", + "otext": "该批次没有聚类结果(可能范围内无需求数据)", "text": "该批次没有聚类结果(可能范围内无需求数据)", "i18n": True, "color": "#64748b", "padding": "12px", "halign": "left"}}) for c in clusters: cid = str(c.get("id") or "") @@ -93,7 +93,7 @@ else: "wrap": True}}, ]}, {"widgettype": "Filler"}, - {"widgettype": "Text", "options": {"text": "%d 条 | %.1f%%" % (cnt, share), + {"widgettype": "Text", "options": {"otext": "${p0} 条 | ${p1}%", "text": "${p0} 条 | ${p1}%", "i18n": True, "i18n_params": {"p0": str('%d' % (cnt,)), "p1": str('%.1f' % (share,))}, "cfontsize": 0.85, "color": "#64748b"}}, {"widgettype": "Text", "options": {"text": "›", "cfontsize": 1.1, "color": "#94a3b8"}}, ]}) @@ -109,7 +109,7 @@ return { "gap": "4px", "overflow": "auto"}, "subwidgets": [ {"widgettype": "Text", "options": { - "text": "点击任一类别,查看类内需求明细(每条带来源链接可查证)。", + "otext": "点击任一类别,查看类内需求明细(每条带来源链接可查证)。", "text": "点击任一类别,查看类内需求明细(每条带来源链接可查证)。", "i18n": True, "cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}, ] + rows, }], diff --git a/wwwroot/api/opp_mining_items.dspy b/wwwroot/api/opp_mining_items.dspy index 407bef6..31542e2 100644 --- a/wwwroot/api/opp_mining_items.dspy +++ b/wwwroot/api/opp_mining_items.dspy @@ -57,7 +57,7 @@ else: items = _res.get("samples") or [] if not items: rows.append({"widgettype": "Text", "options": { - "text": "该类别暂无明细", "color": "#64748b", "padding": "12px", "halign": "left"}}) + "otext": "该类别暂无明细", "text": "该类别暂无明细", "i18n": True, "color": "#64748b", "padding": "12px", "halign": "left"}}) for it in items: src_id = str(it.get("src_id") or "") title = str(it.get("title") or "") @@ -113,7 +113,7 @@ return { "padding": "12px 16px", "gap": "0px", "overflow": "auto"}, "subwidgets": [ {"widgettype": "Text", "options": { - "text": "点击任一条目弹出需求详情(数据来自数据爬取平台快照,每条标注来源平台)。", + "otext": "点击任一条目弹出需求详情(数据来自数据爬取平台快照,每条标注来源平台)。", "text": "点击任一条目弹出需求详情(数据来自数据爬取平台快照,每条标注来源平台)。", "i18n": True, "cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}, ] + rows, }], diff --git a/wwwroot/api/opp_mining_popup.dspy b/wwwroot/api/opp_mining_popup.dspy index d4fdcec..8c33a46 100644 --- a/wwwroot/api/opp_mining_popup.dspy +++ b/wwwroot/api/opp_mining_popup.dspy @@ -77,8 +77,7 @@ def _start_script(scope): rows = [] # ── 顶部:启动按钮 + 说明 ── rows.append({"widgettype": "Text", "options": { - "text": "需求挖掘:把众包平台(猪八戒/一品威客/Freelancer/PPH)的软件需求做语义聚类," - "发现市场热门软件类别(TopX)或分析你指定的需求类型。批次后台执行,完成后点击行查看类别排名。", + "otext": "需求挖掘:把众包平台(猪八戒/一品威客/Freelancer/PPH)的软件需求做语义聚类,发现市场热门软件类别(TopX)或分析你指定的需求类型。批次后台执行,完成后点击行查看类别排名。", "text": "需求挖掘:把众包平台(猪八戒/一品威客/Freelancer/PPH)的软件需求做语义聚类,发现市场热门软件类别(TopX)或分析你指定的需求类型。批次后台执行,完成后点击行查看类别排名。", "i18n": True, "cfontsize": 0.8, "color": "#64748b", "halign": "left", "wrap": True, "margin": "0 0 8px 0"}}) rows.append({"widgettype": "HBox", "options": {"width": "100%", "gap": "10px", "margin": "0 0 12px 0"}, "subwidgets": [ @@ -96,13 +95,12 @@ rows.append({"widgettype": "HBox", "options": {"width": "100%", "gap": "10px", " if not _ok: rows.append({"widgettype": "Text", "options": { - "text": "查询失败:" + str(_res)[:200], "color": "#dc2626", "padding": "12px", "halign": "left"}}) + "otext": "查询失败:${p0}", "text": "查询失败:${p0}", "i18n": True, "i18n_params": {"p0": str(str(_res)[:200])}, "color": "#dc2626", "padding": "12px", "halign": "left"}}) else: batches = _res if isinstance(_res, list) else [] if not batches: rows.append({"widgettype": "Text", "options": { - "text": "还没有挖掘批次。点击上方「⛏ 启动全量挖掘」开始(首次全量约3-5分钟;" - "embedding 有全机构共享缓存,二次批次显著更快)。", + "otext": "还没有挖掘批次。点击上方「⛏ 启动全量挖掘」开始(首次全量约3-5分钟;embedding 有全机构共享缓存,二次批次显著更快)。", "text": "还没有挖掘批次。点击上方「⛏ 启动全量挖掘」开始(首次全量约3-5分钟;embedding 有全机构共享缓存,二次批次显著更快)。", "i18n": True, "color": "#64748b", "padding": "12px", "halign": "left", "wrap": True}}) for b in batches: bid = str(b.get("id") or "") diff --git a/wwwroot/api/opp_overseas_items.dspy b/wwwroot/api/opp_overseas_items.dspy index 361e683..4abe9a2 100644 --- a/wwwroot/api/opp_overseas_items.dspy +++ b/wwwroot/api/opp_overseas_items.dspy @@ -27,13 +27,13 @@ async with DBPools().sqlorContext(dbname) as sor: rows = [] if not ok: rows.append({"widgettype": "Text", "options": { - "text": "查询失败:" + str(res)[:200], + "otext": "查询失败:${p0}", "text": "查询失败:${p0}", "i18n": True, "i18n_params": {"p0": str(str(res)[:200])}, "color": "#dc2626", "padding": "12px", "halign": "left"}}) else: items = res.get("items") or [] if not items: rows.append({"widgettype": "Text", "options": { - "text": "该主题近 %d 天无海外分项" % days, "color": "#64748b", "padding": "12px", "halign": "left"}}) + "otext": "该主题近 ${p0} 天无海外分项", "text": "该主题近 ${p0} 天无海外分项", "i18n": True, "i18n_params": {"p0": str('%d' % (days,))}, "color": "#64748b", "padding": "12px", "halign": "left"}}) for it in items: item_id = str(it.get("id") or "") budget = it.get("budget_wan") or 0 @@ -90,7 +90,7 @@ return { "padding": "12px 16px", "gap": "0px", "overflow": "auto"}, "subwidgets": [ {"widgettype": "Text", "options": { - "text": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。", + "otext": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。", "text": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。", "i18n": True, "cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}} ] + rows }] diff --git a/wwwroot/api/opp_references_items.dspy b/wwwroot/api/opp_references_items.dspy index 490ad7a..5577fa5 100644 --- a/wwwroot/api/opp_references_items.dspy +++ b/wwwroot/api/opp_references_items.dspy @@ -27,13 +27,13 @@ async with DBPools().sqlorContext(dbname) as sor: rows = [] if not ok: rows.append({"widgettype": "Text", "options": { - "text": "查询失败:" + str(res)[:200], + "otext": "查询失败:${p0}", "text": "查询失败:${p0}", "i18n": True, "i18n_params": {"p0": str(str(res)[:200])}, "color": "#dc2626", "padding": "12px", "halign": "left"}}) else: items = res.get("items") or [] if not items: rows.append({"widgettype": "Text", "options": { - "text": "该主题近 %d 天无分项" % days, "color": "#64748b", "padding": "12px", "halign": "left"}}) + "otext": "该主题近 ${p0} 天无分项", "text": "该主题近 ${p0} 天无分项", "i18n": True, "i18n_params": {"p0": str('%d' % (days,))}, "color": "#64748b", "padding": "12px", "halign": "left"}}) for it in items: item_id = str(it.get("id") or "") budget = it.get("budget_wan") or 0 @@ -95,7 +95,7 @@ return { "padding": "12px 16px", "gap": "0px", "overflow": "auto"}, "subwidgets": [ {"widgettype": "Text", "options": { - "text": "点击任一条目弹出具体内容(公告全文来自数据爬取平台,有附件的可直接下载);每条说明中已标注数据来源。", + "otext": "点击任一条目弹出具体内容(公告全文来自数据爬取平台,有附件的可直接下载);每条说明中已标注数据来源。", "text": "点击任一条目弹出具体内容(公告全文来自数据爬取平台,有附件的可直接下载);每条说明中已标注数据来源。", "i18n": True, "cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}} ] + rows }] diff --git a/wwwroot/api/opp_references_popup.dspy b/wwwroot/api/opp_references_popup.dspy index 6a08691..4b858b2 100644 --- a/wwwroot/api/opp_references_popup.dspy +++ b/wwwroot/api/opp_references_popup.dspy @@ -8,7 +8,7 @@ import json as _json uid = await get_user() if not uid: - return {"widgettype": "Text", "options": {"text": "请先登录", "color": "#dc2626", "padding": "12px"}} + return {"widgettype": "Text", "options": {"otext": "请先登录", "text": "请先登录", "i18n": True, "color": "#dc2626", "padding": "12px"}} dbname = get_module_dbname('pipeline-opportunity') @@ -37,7 +37,7 @@ def _topic_row(cat, cnt, budget, target_url, popup_id, extra_txt=""): {"widgettype": "Text", "options": {"text": cat, "cfontsize": 1.0, "color": "#0f172a", "halign": "left"}}, {"widgettype": "Filler"}, - {"widgettype": "Text", "options": {"text": "%d 条" % cnt, + {"widgettype": "Text", "options": {"otext": "${p0} 条", "text": "${p0} 条", "i18n": True, "i18n_params": {"p0": str('%d' % (cnt,))}, "cfontsize": 0.85, "color": "#64748b"}}, ] if extra_txt: @@ -45,7 +45,7 @@ def _topic_row(cat, cnt, budget, target_url, popup_id, extra_txt=""): "cfontsize": 0.75, "color": "#94a3b8"}}) else: subs.append({"widgettype": "Text", "options": { - "text": "预算 %s 万" % ("{:,.0f}".format(budget) if budget else "—"), + "otext": "预算 ${p0} 万", "text": "预算 ${p0} 万", "i18n": True, "i18n_params": {"p0": str("{:,.0f}".format(budget) if budget else "—")}, "cfontsize": 0.85, "color": "#64748b"}}) subs.append({"widgettype": "Text", "options": {"text": "›", "cfontsize": 1.1, "color": "#94a3b8"}}) @@ -70,7 +70,7 @@ else: ranking = res.get("ranking") or [] if not ranking: rows.append({"widgettype": "Text", "options": { - "text": "近30天暂无招标数据", "color": "#64748b", "padding": "12px", "halign": "left"}}) + "otext": "近30天暂无招标数据", "text": "近30天暂无招标数据", "i18n": True, "color": "#64748b", "padding": "12px", "halign": "left"}}) for r in ranking: rows.append(_topic_row(str(r.get("category", "")), r.get("tender_count", 0), r.get("total_budget_wan", 0), items_url, "opp_refs_items_pw")) @@ -81,7 +81,7 @@ if ok_dm: dm_ranking = res_dm.get("ranking") or [] if dm_ranking: dm_rows.append({"widgettype": "Text", "options": { - "text": "国内众包软件需求(猪八戒/一品威客,近30天)", + "otext": "国内众包软件需求(猪八戒/一品威客,近30天)", "text": "国内众包软件需求(猪八戒/一品威客,近30天)", "i18n": True, "cfontsize": 0.95, "color": "#0f172a", "halign": "left", "margin": "14px 0 6px 0", "padding": "6px 0 0 0", "border": "1px solid #f1f5f9", "borderRadius": "6px", "bgcolor": "#f8fafc"}}) @@ -95,7 +95,7 @@ if ok_ov: ov_ranking = res_ov.get("ranking") or [] if ov_ranking: ov_rows.append({"widgettype": "Text", "options": { - "text": "海外众包软件需求(Freelancer.com / PeoplePerHour,近30天)", + "otext": "海外众包软件需求(Freelancer.com / PeoplePerHour,近30天)", "text": "海外众包软件需求(Freelancer.com / PeoplePerHour,近30天)", "i18n": True, "cfontsize": 0.95, "color": "#0f172a", "halign": "left", "margin": "14px 0 6px 0", "padding": "6px 0 0 0", "border": "1px solid #f1f5f9", "borderRadius": "6px", "bgcolor": "#f8fafc"}}) @@ -112,7 +112,7 @@ return { "gap": "6px", "overflow": "auto"}, "subwidgets": [ {"widgettype": "Text", "options": { - "text": "点击任一主题条目,弹出该主题的全部分项明细;明细中再点击任一条,弹出具体内容(数据来自数据爬取平台,含公告全文与附件下载)。", + "otext": "点击任一主题条目,弹出该主题的全部分项明细;明细中再点击任一条,弹出具体内容(数据来自数据爬取平台,含公告全文与附件下载)。", "text": "点击任一主题条目,弹出该主题的全部分项明细;明细中再点击任一条,弹出具体内容(数据来自数据爬取平台,含公告全文与附件下载)。", "i18n": True, "cfontsize": 0.8, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}} ] + rows + dm_rows + ov_rows } diff --git a/wwwroot/api/opp_report_detail.dspy b/wwwroot/api/opp_report_detail.dspy index 68ecf6e..9d4595c 100644 --- a/wwwroot/api/opp_report_detail.dspy +++ b/wwwroot/api/opp_report_detail.dspy @@ -18,7 +18,8 @@ dbname = get_module_dbname('pipeline-opportunity') async with DBPools().sqlorContext(dbname) as sor: from pipeline_opportunity.opp_report_capability import ( - check_report_visible, parse_report_sections, list_report_files, REPORT_SECTIONS) + check_report_visible, parse_report_sections, list_report_files, + sections_for_type) visible, rep = await check_report_visible(sor, report_id, uid) await sor.sqlExe("COMMIT", {}) if not visible: @@ -33,6 +34,8 @@ STATUS_TXT = { "approved": "审批通过", "rejected": "已驳回", } +TYPE_TXT = {"research": "调研", "feasibility": "可行性研究"} + body = [] # ── 标题 ── @@ -43,19 +46,22 @@ body.append({"widgettype": "Text", "options": { # ── 元信息 ── st = str(rep.get("status") or "") +rt = str(rep.get("report_type") or "research") proj = str(rep.get("project_name") or "") -meta = "%s | %s | %s | 创建人 %s" % ( +meta = "%s | %s | %s | %s | 创建人 %s" % ( str(rep.get("created_at") or "")[:16], ("项目:" + proj) if proj else "平台级调研(未挂项目)", + TYPE_TXT.get(rt, rt or "调研"), STATUS_TXT.get(st, st or "—"), str(rep.get("created_by") or "—")) body.append({"widgettype": "Text", "options": { "text": meta, "cfontsize": 0.78, "color": "#64748b", "halign": "left", "wrap": True, "margin": "0 0 10px 0"}}) -# ── 四节:研发场景 / 输入 / 输出 / 成果(按节解析正文;空节如实标注,不编造)── -sections = parse_report_sections(str(rep.get("content") or "")) -for name in REPORT_SECTIONS: +# ── 分节渲染:research 四节 / feasibility 八节(按 report_type 选模板; +# 空节如实标注,不编造)── +sections = parse_report_sections(str(rep.get("content") or ""), rt) +for name in sections_for_type(rt): md = (sections.get(name) or "").strip() body.append({"widgettype": "Text", "options": { "text": name, "cfontsize": 0.95, "color": "#0f172a", "halign": "left", @@ -70,7 +76,7 @@ for name in REPORT_SECTIONS: "margin": "0 0 10px 0", "bgcolor": "#ffffff"}}) else: body.append({"widgettype": "Text", "options": { - "text": "(本报告未填写此节)", "cfontsize": 0.82, "color": "#94a3b8", + "otext": "(本报告未填写此节)", "text": "(本报告未填写此节)", "i18n": True, "cfontsize": 0.82, "color": "#94a3b8", "halign": "left", "margin": "0 0 10px 0"}}) # ── 成果文件(多个列出,逐个下载)── @@ -79,7 +85,7 @@ body.append({"widgettype": "Text", "options": { "halign": "left", "margin": "0 0 4px 0"}}) if not files: body.append({"widgettype": "Text", "options": { - "text": "暂无成果文件(报告提交人工确认时会自动生成 PPT 存入项目 deliverables/)", + "otext": "暂无成果文件(报告提交人工确认时会自动生成 PPT 存入项目 deliverables/)", "text": "暂无成果文件(报告提交人工确认时会自动生成 PPT 存入项目 deliverables/)", "i18n": True, "cfontsize": 0.82, "color": "#94a3b8", "halign": "left", "margin": "0 0 10px 0"}}) for f in files: size = int(f.get("size") or 0) diff --git a/wwwroot/api/opp_reports_list.dspy b/wwwroot/api/opp_reports_list.dspy index 813666b..6d8cece 100644 --- a/wwwroot/api/opp_reports_list.dspy +++ b/wwwroot/api/opp_reports_list.dspy @@ -45,7 +45,7 @@ if not _cur_pid: "options": {"width": "100%", "gap": "12px", "padding": "24px"}, "subwidgets": [ {"widgettype": "Text", - "options": {"text": "当前会话还没有项目,请先选择或新建项目", + "options": {"otext": "当前会话还没有项目,请先选择或新建项目", "text": "当前会话还没有项目,请先选择或新建项目", "i18n": True, "cfontsize": 1, "color": "#64748b"}}, {"widgettype": "HBox", "options": {"width": "100%", "gap": "10px"}, @@ -72,24 +72,37 @@ STATUS_TXT = { "rejected": ("已驳回", "#dc2626"), } +TYPE_TXT = { + "research": ("调研", "#0369a1"), + "feasibility": ("可行性研究", "#7c3aed"), +} + rows = [] if not reports: rows.append({"widgettype": "Text", "options": { - "text": "暂无可见的研发报告(列表按项目 owner 过滤:仅显示你 own 的项目及平台级调研报告)", + "otext": "暂无可见的研发报告(列表按项目 owner 过滤:仅显示你 own 的项目及平台级调研报告)", "text": "暂无可见的研发报告(列表按项目 owner 过滤:仅显示你 own 的项目及平台级调研报告)", "i18n": True, "cfontsize": 0.85, "color": "#64748b", "padding": "12px", "halign": "left"}}) for r in reports: rid = str(r.get("id") or "") st = str(r.get("status") or "") st_txt, st_color = STATUS_TXT.get(st, (st or "—", "#64748b")) + rt = str(r.get("report_type") or "research") + rt_txt, rt_color = TYPE_TXT.get(rt, ("调研", "#0369a1")) proj = str(r.get("project_name") or "") meta = "%s | %s | %s" % ( str(r.get("created_at") or "")[:16], ("项目:" + proj) if proj else "平台级调研(未挂项目)", st_txt) row_subs = [ - {"widgettype": "Text", "options": { - "text": str(r.get("title") or r.get("software") or "(无标题)"), - "cfontsize": 0.95, "color": "#0f172a", "halign": "left", "wrap": True}}, + {"widgettype": "HBox", "options": {"width": "100%", "gap": "8px", + "alignItems": "center"}, + "subwidgets": [ + {"widgettype": "Text", "options": {"text": rt_txt, + "cfontsize": 0.7, "color": "#ffffff", "bgcolor": rt_color, + "padding": "1px 8px", "borderRadius": "10px"}}, + {"widgettype": "Text", "options": { + "text": str(r.get("title") or r.get("software") or "(无标题)"), + "cfontsize": 0.95, "color": "#0f172a", "halign": "left", "wrap": True}}]}, {"widgettype": "HBox", "options": {"width": "100%", "gap": "10px", "alignItems": "center"}, "subwidgets": [ @@ -122,7 +135,7 @@ return { "padding": "12px 16px", "gap": "0px", "overflow": "auto"}, "subwidgets": [ {"widgettype": "Text", "options": { - "text": "研发报告(%d 份)|按项目 owner 过滤;点击任一条查看研发场景/输入/输出/成果与成果文件下载" % len(reports), + "otext": "研发报告(${p0} 份)|按项目 owner 过滤;点击任一条查看研发场景/输入/输出/成果与成果文件下载", "text": "研发报告(${p0} 份)|按项目 owner 过滤;点击任一条查看研发场景/输入/输出/成果与成果文件下载", "i18n": True, "i18n_params": {"p0": str('%d' % (len(reports),))}, "cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}} ] + rows } diff --git a/wwwroot/opp_reports/get_opp_reports.dspy b/wwwroot/opp_reports/get_opp_reports.dspy index d3aa216..3bbc914 100644 --- a/wwwroot/opp_reports/get_opp_reports.dspy +++ b/wwwroot/opp_reports/get_opp_reports.dspy @@ -61,6 +61,13 @@ fields_str=r'''[ "nullable": "no", "default": "draft" }, + { + "name": "report_type", + "title": "报告类型(research调研/feasibility可行性研究)", + "type": "str", + "length": 16, + "default": "research" + }, { "name": "confirm_task_id", "title": "确认任务ID", diff --git a/wwwroot/opp_reports/index.ui b/wwwroot/opp_reports/index.ui index 81a30c4..c123ad3 100644 --- a/wwwroot/opp_reports/index.ui +++ b/wwwroot/opp_reports/index.ui @@ -121,6 +121,17 @@ "datatype": "str", "label": "报告状态" }, + { + "name": "report_type", + "title": "报告类型(research调研/feasibility可行性研究)", + "type": "str", + "length": 16, + "default": "research", + "cwidth": 16, + "uitype": "str", + "datatype": "str", + "label": "报告类型(research调研/feasibility可行性研究)" + }, { "name": "confirm_task_id", "title": "确认任务ID",