"""商机产线能力包(opportunity_general):主 agent(驾驶舱)工具 + 角色定义 + slash 命令。 与开发/投标产线同构,通过 pipeline-core 的 PipelineAbility 注册表挂载, 零侵入 core / AgentExecutor:项目 sd_projects.pipeline_id='opportunity_general' 时自动生效。 数据边界:招标数据只从数据爬取平台(内网 HTTP)读取,本产线不爬取、不存招标原始数据; 本产线自己的状态(研发报告/审批)落产线库的 opp_reports / opp_approvals 表。 商机闭环:热点软件推荐 → 选定方向 → 编写研发报告 → 人工确认(门禁)→ 发起研发审批 → 审批结论。 """ import json import logging from pipeline_core import ( ToolDefinition, PipelineAbility, RoleSpec, register_ability, SlashCommand, register_slash_command, ) logger = logging.getLogger("pipeline.opportunity.ability") PIPELINE_ID = "opportunity_general" # ══════════════════ 工具定义(主 agent / 驾驶舱)══════════════════ OPP_TOOLS = [ # ── 数据(来自数据爬取平台,只读)── ToolDefinition(name="opp_hot_software", description="热点软件推荐:近N天招标市场按软件主题聚合排名(招标量/预算/热点地区/样例项目),用于判断哪些软件方向最值得做", parameters={"days": "统计窗口天数(默认30)", "top": "返回前几名(默认10)"}, category="data"), ToolDefinition(name="opp_daily_ai_tenders", description="每日AI/Agent软件类产品或项目招标信息展示:指定日期的AI相关招标清单+预算统计", parameters={"date": "日期YYYY-MM-DD(默认今天)"}, category="data"), ToolDefinition(name="opp_search_tenders", description="按关键词搜索招标信息(IT项目)", parameters={"keyword": "标题关键词", "days": "回溯天数(默认7)", "source": "来源(可选 ccgp/ggzy)", "limit": "条数(默认50)"}, category="data"), ToolDefinition(name="opp_query_tenders", description="按用户口述条件检索/汇总招标数据:把自然语言条件映射为过滤参数(关键词/地区/采购人/公告类型/来源/预算区间/AI评分/时间窗),可返回明细或按维度汇总(条数/预算合计/平均预算/样例)。用户说『查一下XX地区预算大于XX的招标』『按地区统计XX』时用本工具", parameters={"keyword": "标题关键词(多词空格分隔AND,可选)", "region": "地区模糊(可选,如 浙江/北京)", "buyer": "采购人模糊(可选)", "notice_type": "公告类型精确(可选: 公开招标/中标公告/竞争性谈判/询价/单一来源)", "source": "来源精确(可选 ccgp/ggzy)", "min_budget": "预算下限(万元,可选)", "max_budget": "预算上限(万元,可选)", "min_ai": "AI评分下限(可选)", "days": "回溯天数(默认90)", "group_by": "汇总维度(可选: region/notice_type/category/source/month;不传=返回明细)", "limit": "明细条数(默认50)", "only_it": "只看IT项目(默认1,用户明确要全部时传0)"}, category="data"), ToolDefinition(name="opp_tender_detail", description="查看单条招标信息完整详情(含公告原文、预算、联系人、来源链接)", parameters={"tender_id": "招标信息ID"}, category="data"), ToolDefinition(name="opp_crawler_stats", description="查看数据爬取平台采集健康度(总量/24h新增/最近运行)", parameters={}, category="data"), ToolDefinition(name="opp_category_references", description="查某软件主题的分项明细(每条带公告原文URL+采集来源,供逐条查证/举例)。⚠️ 明细是「举例佐证」用的,不是「统计」用的——排名/数量/金额统计先用 opp_hot_software(已返回 tender_count+total_budget_wan),只在需要给2~5个样例链接时才调本工具且 limit≤10", parameters={"category": "软件主题名(须与热点排名中的category一致)", "days": "统计窗口天数(默认30)", "limit": "条数(默认20,最大50——只取样例不要拉全量)"}, category="data"), ToolDefinition(name="opp_demands", description="查众包平台需求明细列表(国内猪八戒zbj/一品威客epwk + 海外freelancer/pph,每条带详情页URL可查证)。⚠️ 主题排名/数量/金额统计先用 opp_hot_demands(国内)/opp_hot_overseas(海外)——它们已聚合好排名;本工具只用于按关键词找具体需求样例,limit≤10", parameters={"keyword": "标题关键词(建议必填以缩小范围)", "days": "统计窗口天数(默认30)", "source": "来源(可选: zbj猪八戒/epwk一品威客/freelancer海外/pph海外,不传=全部)", "limit": "条数(默认10,最大50)"}, category="data"), ToolDefinition(name="opp_hot_demands", description="【统计首选】国内众包需求热点主题排名——直接返回各主题需求数量(tender_count)与预算合计(total_budget_wan)+样例,无需拉明细自己数。与招标侧 opp_hot_software 互补", parameters={"days": "统计窗口天数(默认30)", "top": "Top条数(默认10)"}, category="data"), ToolDefinition(name="opp_hot_overseas", description="【统计首选】海外众包需求热点主题排名(Freelancer/PeoplePerHour,英文标题按8大主题归类)——直接返回各主题数量+预算合计+按来源分布,无需拉明细自己数。用户问国外/海外市场、中外众包排名时用本工具", parameters={"days": "统计窗口天数(默认30)", "top": "Top条数(默认10)"}, category="data"), ToolDefinition(name="opp_overseas_references", description="查某海外需求主题的分项明细(每条带Freelancer/PeoplePerHour详情页URL,供逐条查证/举例)。⚠️ 统计先用 opp_hot_overseas(已返回 tender_count+total_budget_wan+by_source),只在需要2~5个样例链接时才调本工具且 limit≤10", parameters={"category": "海外主题名(须与 opp_hot_overseas 排名中的category一致)", "days": "统计窗口天数(默认30)", "limit": "条数(默认20,最大50——只取样例不要拉全量)"}, category="data"), # ── 报告与审批(商机闭环)── ToolDefinition(name="opp_create_report", description="创建软件研发报告草稿。从热门软件中选定的方向写入报告,数据基础章节自动从爬取平台拉取。正文必须按四节模板组织(## 研发场景 / ## 输入 / ## 输出 / ## 成果),商店详情页按节展示", parameters={"software": "软件/主题名", "title": "报告标题(可选)", "analysis": "报告正文(可选,可先建骨架后补写;按四节模板:## 研发场景 / ## 输入 / ## 输出 / ## 成果)"}, category="report"), ToolDefinition(name="opp_update_report", description="更新草稿报告内容(仅草稿状态可改)。正文按四节模板组织(## 研发场景 / ## 输入 / ## 输出 / ## 成果)", parameters={"report_id": "报告ID", "content": "新的报告全文(含四节二级标题)"}, category="report"), ToolDefinition(name="opp_list_reports", description="列出研发报告及状态(draft/confirmed/approval_initiated/approved/rejected)", parameters={"status": "状态过滤(可选)"}, category="report"), ToolDefinition(name="opp_get_report", description="查看报告全文", parameters={"report_id": "报告ID"}, category="report"), ToolDefinition(name="opp_submit_report", description="报告写完后提交人工确认(创建确认任务,等人工决策)", parameters={"report_id": "报告ID", "note": "给确认人的说明(可选)"}, category="report"), ToolDefinition(name="opp_list_approvals", description="列出研发审批单及状态", parameters={"status": "状态过滤(可选)"}, category="report"), ToolDefinition(name="opp_diagnose", description="诊断商机产线:报告/审批分布、待办人工任务、爬取平台连通性", parameters={}, category="agent"), # ── 需求挖掘(众包需求→语义聚类→TopX/指定类型,P1)── ToolDefinition(name="opp_start_mining", description="启动需求挖掘批次(后台执行,立即返回batch_id):从众包需求池拉取→向量化→语义聚类→LLM命名类别。scope=top 全量聚类出TopX热门类别排名;scope=targeted 指定类型分析(必须带keyword如'合同管理',在范围内聚类找子型)。结果用 opp_mining_status 轮询,完成后用 opp_list_clusters 看类别排名", parameters={"scope": "top=全量TopX排名 / targeted=指定类型分析", "keyword": "targeted模式的类型关键词(如:合同管理)", "category": "targeted模式的站内类目过滤(可选)", "days": "需求时间窗天数(默认365)", "sources": "限定来源zbj/epwk/freelancer/pph(可选,默认全部)"}, category="mining"), ToolDefinition(name="opp_mining_status", description="查需求挖掘批次状态(pulling拉取/embedding向量化/clustering聚类/naming命名/done完成/failed失败+原因)。batch_id空=列本机构最近批次", parameters={"batch_id": "批次ID(空=最近批次列表)"}, category="mining"), ToolDefinition(name="opp_list_clusters", description="查看挖掘批次的类别排名表(热度排名/需求数/占比/命名证据),即TopX热门需求类别。批次必须done", parameters={"batch_id": "批次ID"}, category="mining"), ToolDefinition(name="opp_cluster_detail", description="查看某类别的类内需求明细样例(每条带来源URL可查证),用于写可行性报告时引用证据", parameters={"cluster_id": "类别ID", "limit": "样例条数(默认20)"}, category="mining"), ] # ══════════════════ 产线 prompt 片段 ══════════════════ OPP_PROMPT = """你是「商机产线」的驾驶舱 agent,负责在项目内把招标市场情报转化为软件研发商机。 ## 工作流程(先调研后报告,不要跳步) 项目创建后**不是直接写产研报告**,而是按四步推进: 1. 收集资料:用数据工具拉取与项目方向相关的招标情报(热点/每日/检索/条件查询)。 2. 数据分析:对口述条件做过滤或汇总(opp_query_tenders),看数量/预算/地区/类型分布。 3. 发现商机:基于真实数据指出值得做的软件方向(招标多、预算大、竞争未饱和)。 4. 产研报告:方向明确后才 opp_create_report 建草稿、补写分析、提交人工确认。 用户一上来就说「写报告」时,先确认是否已做过 1-3 步;没有就先补调研再写。 ## 口述条件检索(核心能力) 用户用自然语言描述检索/统计条件,你负责映射为 opp_query_tenders 参数: - 「查浙江预算100万以上的医院招标」→ region=浙江, min_budget=100, keyword=医院 - 「按地区统计AI招标」→ keyword=AI/agent, group_by=region - 「按月份看公开招标趋势」→ notice_type=公开招标, group_by=month - 「采购人含大学的中标」→ buyer=大学, notice_type=中标公告 映射原则:地区/采购人/关键词用模糊匹配;公告类型/来源用精确值; 预算单位是万元;用户没说的条件不要臆造(留空=不过滤)。 返回明细时每条带 url/source_url 可查证;返回汇总时每组带样例。 ## 数据来源(重要边界) 招标数据全部来自「数据爬取平台」(内网 HTTP,已采集入库、已去重、每条带来源链接)。 你只消费数据(opp_hot_software / opp_daily_ai_tenders / opp_search_tenders / opp_query_tenders / opp_tender_detail),**不要**自己去爬网站, 也不要怀疑数据新鲜度——先看 opp_crawler_stats 的采集时间。 ## 商机闭环与门禁(不要绕过) 1. 用 opp_hot_software / opp_query_tenders 看哪个软件方向招标多、预算大,给用户推荐。 2. 用户选定方向后:opp_create_report 建草稿 → 你基于真实招标数据补写分析 (市场空间/代表项目/竞争格局/研发建议)→ opp_update_report 更新全文。 3. 写完用 opp_submit_report 提交**人工确认**(门禁)。确认前不许发起审批。 4. 人工确认通过后,你才用(或由人工任务驱动)发起研发审批;审批结论由人工回流, **禁止**自己批准自己的审批。 ## 报告正文四节模板(硬要求,商店详情页按此分节展示) opp_create_report / opp_update_report 的正文必须用以下四个二级标题组织, 「📊 研发报告」详情页逐节渲染(研发场景/输入/输出/成果);缺节会在详情页如实显示 「本报告未填写此节」,所以四节都要写: - `## 研发场景`:这个软件方向解决什么业务问题、面向哪些行业/角色、典型使用场景 - `## 输入`:研发所需的输入(数据来源与招标情报、需求条件、既有资料/接口) - `## 输出`:分析产出(市场空间/代表项目/竞争格局/研发建议,带 references 来源链接) - `## 成果`:可交付成果(建议的产品形态、里程碑、预期成果文件如 PPT/方案) 旧格式(无四节标题)的存量报告仍可查看,全文归入「输出」节展示。 ## 需求挖掘(众包需求 → TopX 类别 / 指定类型分析) 用户问「市场上什么软件需求最多」「帮我分析XX类需求」「做需求挖掘」时走这条链: 1. opp_start_mining 启动批次——scope=top(全量聚类出热门类别排名)或 scope=targeted + keyword(用户指定类型,如「合同管理」「小程序」,在范围内聚类找子型)。 批次后台执行(拉取→向量化→聚类→命名),启动后**立即返回 batch_id**,不要傻等。 2. opp_mining_status 轮询进度(每轮回复用户当前状态;failed 时如实转述原因,禁隐瞒)。 3. done 后 opp_list_clusters 看类别排名(热度/需求数/占比),向用户呈现 TopX 表格。 4. 用户选定类别后 opp_cluster_detail 取类内样例(每条带来源URL),结合数据写分析报告; 需要立项走可行性研究时,用 opp_create_report 建报告(数据基础引用挖掘批次与类别证据)。 硬规则:聚类结果只能来自 opp_* mining 工具返回,禁止自己编类别/编排名/编数量; 类别命名证据(naming_evidence 样例)要能支撑命名,命名可疑时如实说明。 ## 硬规则 - **统计优先用聚合工具,明细只取样例(2026-09-11 实测教训)**: 数量/排名/预算合计/平均价这类**统计**一律用聚合工具——opp_hot_software(招标侧)、 opp_hot_demands(国内众包)、opp_hot_overseas(海外众包)、opp_query_tenders(group_by=...), 它们已直接返回 tender_count / total_budget_wan,**不要**拉全量明细自己数。 明细类工具(opp_category_references / opp_demands / opp_overseas_references)只用于 给 2~5 条代表性样例佐证,limit 取 10 以内(机制层有硬上限,拉全量会被截断)。 平均价格 = total_budget_wan / tender_count(聚合值自算,勿逐条累加)。 - 报告里的招标数量、预算、项目案例必须来自工具返回的真实数据,**禁止编造**; 查不到就如实写"数据未覆盖"。 - **所有分析必须带 references(可查证)**:给排名/统计/推荐结论时,先用聚合工具拿统计值, 再用明细工具取 **2~5 条代表性分项**(limit≤10)作样例佐证,回答末尾附 References 章节—— 列出自带的「📋 数据参考」菜单(招标/国内众包需求/海外需求三类主题:点主题条目弹出 全部分项,点分项条目弹出具体内容——公告/需求全文与附件下载,数据均来自爬取平台), 并在正文中给出 3-5 条代表性分项的标题+来源链接(markdown 链接,外链会自动新窗口打开)。 - **凡是回复中出现单个数据项的列表(招标/需求/样例),每一项都必须带来源 URL**: 用 markdown 链接把标题挂到该条的 url(公告原文/需求详情页)上,格式如 「[标题](url) | 来源:ccgp | 2026-08-30」;url 缺失时如实写「来源缺失」,禁止只贴标题。 - 每条引用的招标/需求信息带来源链接(爬虫平台已提供 url / source_url)。 - 报告没被人工确认,不许当作可发起审批;审批没过,不许当作可立项。 ## 典型场景 - 用户问「最近什么软件最火/最值得做」→ opp_hot_software(days=30) 后给结论, 并对推荐的主题调 opp_category_references 附 references。 - 用户口述检索/统计条件 → opp_query_tenders 映射参数,返回明细或汇总并附样例链接。 - 用户问「今天有什么AI招标」→ opp_daily_ai_tenders()。 - 用户问「市场真实需求/甲方都在找什么开发」→ opp_hot_demands 或 opp_demands (猪八戒/一品威客等众包平台的甲方需求发布,与招标互补,同样带 references)。 - 用户问「国外/海外市场软件需求」「国外软件需求前十」→ opp_hot_overseas (Freelancer.com/PeoplePerHour 海外众包需求,英文标题已按8大主题归类), 并对感兴趣的主题调 opp_overseas_references 附海外平台详情页 references。 注意:本产线已覆盖海外市场(freelancer/pph 两源),不许回答"没有海外数据"。 - 用户说「给XX写个研发报告」→ 先确认调研充分(1-3步),再 opp_create_report → 拉数据补写 → opp_submit_report。 - 用户问进展 → opp_diagnose(报告/审批分布 + 待办人工任务)。""" # ══════════════════ 角色集(商机产线轻量:分析+撰写两角色)══════════════════ OPP_ROLES = [ RoleSpec( name="agent.opp_analyst", description="商机分析师", aliases=["opp_analyst", "analyst"], system_prompt="你是商机分析师。先 load_skill 加载 role 技能,按其中的职责与规范执行任务。", next_role="", task_title="商机分析", ), RoleSpec( name="agent.opp_writer", description="研发报告撰写工程师", aliases=["opp_writer", "writer"], system_prompt="你是研发报告撰写工程师。先 load_skill 加载 role 技能,按其中的职责与规范执行任务。", next_role="", task_title="研发报告编写", ), ] # ══════════════════ handler(签名 async def handler(sor, params, ctx) -> str)══════════════════ def _fmt(ok, msg): return ("OK: " if ok else "ERROR: ") + str(msg) def _fmt_rows(rows, empty="(空)"): if not rows: return empty if isinstance(rows, str): return rows return "\n".join(json.dumps(r, ensure_ascii=False, default=str) for r in rows[:80]) async def _h_hot_software(sor, p, ctx): from .opp_data_capability import hot_software ok, res = await hot_software(sor, days=int(p.get("days") or 30), top=int(p.get("top") or 10)) if not ok: return _fmt(False, res) out = {"窗口": "%d天" % res.get("window_days"), "窗口内招标总数": res.get("tenders_in_window"), "排名": res.get("ranking")} return json.dumps(out, ensure_ascii=False, default=str) async def _h_daily_ai(sor, p, ctx): from .opp_data_capability import daily_ai_tenders ok, res = await daily_ai_tenders(sor, target_date=p.get("date") or "") if not ok: return _fmt(False, res) return json.dumps({ "date": res.get("date"), "count": res.get("count"), "有预算条数": res.get("with_budget"), "预算合计(万元)": res.get("total_budget_wan"), "items": res.get("items"), }, ensure_ascii=False, default=str) async def _h_search(sor, p, ctx): from .opp_data_capability import search_tenders ok, res = await search_tenders(sor, keyword=p.get("keyword") or "", days=int(p.get("days") or 7), source=p.get("source") or "", limit=int(p.get("limit") or 50)) if not ok: return _fmt(False, res) return json.dumps({"count": res.get("count"), "items": res.get("items")}, ensure_ascii=False, default=str) async def _h_query(sor, p, ctx): from .opp_data_capability import query_tenders ok, res = await query_tenders( sor, keyword=p.get("keyword") or "", region=p.get("region") or "", buyer=p.get("buyer") or "", notice_type=p.get("notice_type") or "", source=p.get("source") or "", min_budget=p.get("min_budget") or "", max_budget=p.get("max_budget") or "", min_ai=p.get("min_ai") or "", days=int(p.get("days") or 90), group_by=p.get("group_by") or "", limit=int(p.get("limit") or 50), only_it=str(p.get("only_it", "1")) != "0") if not ok or not isinstance(res, dict): return _fmt(False, res) if res.get("group_by"): return json.dumps({ "汇总维度": res.get("group_by"), "命中总数": res.get("matched"), "分组": res.get("groups"), "说明": "cnt=条数 with_budget=有预算条数 total/avg_budget_wan=预算合计/平均(万元),samples 带公告链接可查证", }, ensure_ascii=False, default=str) return json.dumps({ "命中总数": res.get("matched"), "返回条数": res.get("count"), "items": res.get("items"), "说明": "items 每条带 url(公告原文)/source_url(采集来源) 可查证;命中多于返回时用 limit 翻页或收紧条件", }, ensure_ascii=False, default=str) async def _h_detail(sor, p, ctx): from .opp_data_capability import tender_detail ok, res = await tender_detail(sor, p.get("tender_id") or "") if not ok: return _fmt(False, res) return json.dumps(res, ensure_ascii=False, default=str) async def _h_crawler_stats(sor, p, ctx): from .opp_data_capability import crawler_stats ok, res = await crawler_stats(sor) if not ok: return _fmt(False, res) return json.dumps(res, ensure_ascii=False, default=str) async def _h_category_references(sor, p, ctx): from .opp_data_capability import category_references category = (p.get("category") or "").strip() if not category: return _fmt(False, "缺少 category(软件主题名)") ok, res = await category_references( sor, category, days=int(p.get("days") or 30), limit=min(int(p.get("limit") or 200), 1000)) if not ok: return _fmt(False, res) return json.dumps({ "category": res.get("category"), "窗口": "%d天" % res.get("window_days"), "分项总数": res.get("count"), "分项": res.get("items"), "说明": "每条分项的 url 为公告原文、source_url 为采集来源,均可点击查证", }, ensure_ascii=False, default=str) async def _h_demands(sor, p, ctx): from .opp_data_capability import demands ok, res = await demands( sor, keyword=(p.get("keyword") or "").strip(), days=int(p.get("days") or 30), source=(p.get("source") or "").strip(), limit=min(int(p.get("limit") or 50), 200)) if not ok: return _fmt(False, res) return json.dumps({ "需求总数": res.get("matched"), "返回条数": res.get("count"), "需求": res.get("items"), "说明": "每条需求的 url 为众包平台详情页,可点击查证;source: zbj=猪八戒, epwk=一品威客", }, ensure_ascii=False, default=str) async def _h_hot_demands(sor, p, ctx): from .opp_data_capability import hot_demands ok, res = await hot_demands( sor, days=int(p.get("days") or 30), top=min(int(p.get("top") or 10), 30)) if not ok: return _fmt(False, res) return json.dumps(res, ensure_ascii=False, default=str) async def _h_hot_overseas(sor, p, ctx): from .opp_data_capability import hot_overseas ok, res = await hot_overseas( sor, days=int(p.get("days") or 30), top=min(int(p.get("top") or 10), 30)) if not ok: return _fmt(False, res) return json.dumps(res, ensure_ascii=False, default=str) async def _h_overseas_references(sor, p, ctx): from .opp_data_capability import overseas_references category = (p.get("category") or "").strip() if not category: return _fmt(False, {"error": "缺少 category 参数"}) ok, res = await overseas_references( sor, category=category, days=int(p.get("days") or 30), limit=min(int(p.get("limit") or 200), 500)) if not ok: return _fmt(False, res) return json.dumps(res, ensure_ascii=False, default=str) async def _h_create_report(sor, p, ctx): from .opp_report_capability import create_report ok, rid = await create_report( ctx.get("project_id") or "", p.get("software") or "", title=p.get("title") or "", analysis=p.get("analysis") or "", created_by="agent.main_agent") return _fmt(ok, ("report_id=%s(草稿,用 opp_update_report 补写,完成后 opp_submit_report)" % rid) if ok else rid) async def _h_update_report(sor, p, ctx): from .opp_report_capability import update_report ok, msg = await update_report(p.get("report_id") or "", p.get("content") or "") return _fmt(ok, msg) async def _h_list_reports(sor, p, ctx): from .opp_report_capability import list_reports rows = await list_reports(project_id=ctx.get("project_id") or "", status=p.get("status") or "") return _fmt_rows(rows, "尚无研发报告") async def _h_get_report(sor, p, ctx): from .opp_report_capability import get_report_full rep = await get_report_full(p.get("report_id") or "") if not rep: return _fmt(False, "报告不存在") return json.dumps(rep, ensure_ascii=False, default=str) async def _h_submit_report(sor, p, ctx): from .opp_report_capability import submit_for_confirmation ok, msg = await submit_for_confirmation(p.get("report_id") or "", note=p.get("note") or "") return _fmt(ok, msg) async def _h_list_approvals(sor, p, ctx): from .opp_report_capability import list_approvals rows = await list_approvals(project_id=ctx.get("project_id") or "", status=p.get("status") or "") return _fmt_rows(rows, "尚无审批单") async def _h_diagnose(sor, p, ctx): from .opp_report_capability import list_reports, list_approvals from .opp_data_capability import crawler_stats from .opp_common import find_human_task, HT_REPORT_CONFIRM, HT_DEV_APPROVAL reps = await list_reports() apps = await list_approvals() ok, cs = await crawler_stats(sor) dist = {} for r in reps: dist[r.get("status")] = dist.get(r.get("status"), 0) + 1 ht_conf = await find_human_task(sor, ctx.get("project_id") or "", HT_REPORT_CONFIRM, status="pending") ht_appr = await find_human_task(sor, ctx.get("project_id") or "", HT_DEV_APPROVAL, status="pending") return json.dumps({ "报告状态分布": dist, "审批单": len(apps), "待确认报告任务": ht_conf.get("title") if ht_conf else None, "待审批任务": ht_appr.get("title") if ht_appr else None, "爬取平台": ("连通,共 %s 条,24h 新增 %s" % ( cs.get("total"), cs.get("collected_24h"))) if ok else "不可达: %s" % cs, }, ensure_ascii=False, default=str) # ══════════════════ 需求挖掘 handler(P1)══════════════════ async def _h_start_mining(sor, p, ctx): from .opp_mining_flow import start_mining scope = (p.get("scope") or "top").strip() bid, err = await start_mining( sor, ctx, scope=scope, days=int(p.get("days") or 365), sources=(p.get("sources") or "").strip(), keyword=(p.get("keyword") or "").strip(), category=(p.get("category") or "").strip()) if err: return _fmt(False, err) return _fmt(True, "挖掘批次已启动 batch_id=%s(后台执行:%s)。用 opp_mining_status 轮询进度,done 后用 opp_list_clusters 看类别排名" % ( bid, "全量TopX" if scope == "top" else "指定类型[%s/%s]" % (p.get("keyword") or "", p.get("category") or ""))) async def _h_mining_status(sor, p, ctx): from .opp_mining_flow import mining_status ok, res = await mining_status(sor, ctx, batch_id=(p.get("batch_id") or "").strip()) if not ok: return _fmt(False, res) return json.dumps(res, ensure_ascii=False, default=str) async def _h_list_clusters(sor, p, ctx): from .opp_mining_flow import list_clusters bid = (p.get("batch_id") or "").strip() if not bid: return _fmt(False, "batch_id 必填(先用 opp_mining_status 查批次)") ok, res = await list_clusters(sor, ctx, bid) if not ok: return _fmt(False, res) rows = [{"排名": c["heat_rank"], "类别": c["name"], "需求数": c["doc_count"], "占比": "%.1f%%" % (float(c["share"] or 0) * 100), "cluster_id": c["id"], "命名证据": (json.loads(c["naming_evidence"] or "{}").get("samples") or [])[:3]} for c in res] return json.dumps({"batch_id": bid, "类别数": len(rows), "排名": rows}, ensure_ascii=False, default=str) async def _h_cluster_detail(sor, p, ctx): from .opp_mining_flow import cluster_detail cid = (p.get("cluster_id") or "").strip() if not cid: return _fmt(False, "cluster_id 必填") ok, res = await cluster_detail(sor, ctx, cid, limit=int(p.get("limit") or 20)) if not ok: return _fmt(False, res) cl = res["cluster"] samples = [{"标题": s["title"], "来源": s["source"], "预算(万)": s["budget_wan"], "url": s["url"], "发布": s["publish_time"]} for s in res["samples"]] return json.dumps({"类别": cl["name"], "需求数": cl["doc_count"], "占比": "%.1f%%" % (float(cl["share"] or 0) * 100), "样例(带来源可查证)": samples}, ensure_ascii=False, default=str) OPP_HANDLERS = { "opp_hot_software": _h_hot_software, "opp_daily_ai_tenders": _h_daily_ai, "opp_search_tenders": _h_search, "opp_query_tenders": _h_query, "opp_tender_detail": _h_detail, "opp_crawler_stats": _h_crawler_stats, "opp_category_references": _h_category_references, "opp_demands": _h_demands, "opp_hot_demands": _h_hot_demands, "opp_hot_overseas": _h_hot_overseas, "opp_overseas_references": _h_overseas_references, "opp_create_report": _h_create_report, "opp_update_report": _h_update_report, "opp_list_reports": _h_list_reports, "opp_get_report": _h_get_report, "opp_submit_report": _h_submit_report, "opp_list_approvals": _h_list_approvals, "opp_diagnose": _h_diagnose, # 需求挖掘(P1) "opp_start_mining": _h_start_mining, "opp_mining_status": _h_mining_status, "opp_list_clusters": _h_list_clusters, "opp_cluster_detail": _h_cluster_detail, } def _merge_shared(): """并入引擎级通用任务/交付件/问答/项目工具(不重复实现,见 pipeline_service.shared_ability)。""" try: from pipeline_service.shared_ability import SHARED_TOOLS, SHARED_HANDLERS except Exception as e: # pipeline_service 未安装/版本旧 → 只用商机专属工具,诚实降级 logger.warning("shared_ability 不可用,商机产线仅挂载专属工具: %s", str(e)[:120]) return list(OPP_TOOLS), dict(OPP_HANDLERS) names = {t.name for t in OPP_TOOLS} tools = list(OPP_TOOLS) + [t for t in SHARED_TOOLS if t.name not in names] handlers = dict(SHARED_HANDLERS) handlers.update(OPP_HANDLERS) # 同名以商机产线实现为准 return tools, handlers def register_opp_ability(): """注册商机产线能力包(幂等)。""" tools, handlers = _merge_shared() ability = PipelineAbility( pipeline_id=PIPELINE_ID, name="商机产线", tools=tools, system_prompt=OPP_PROMPT, handlers=handlers, roles=OPP_ROLES, menus=[ {"label": "📊 研发报告", "icon": "", "url": "/pipeline-opportunity/api/opp_reports_list.dspy", "type": "popup", "width": "88%", "height": "82%", "require_project": True}, {"label": "✅ 研发审批", "icon": "", "url": "/pipeline-opportunity/opp_approvals/index.ui", "type": "popup", "width": "88%", "height": "82%", "require_project": True}, {"label": "⛏ 需求挖掘", "icon": "", "url": "/pipeline-opportunity/api/opp_mining_popup.dspy", "type": "popup", "width": "76%", "height": "82%"}, {"label": "📋 数据参考", "icon": "", "url": "/pipeline-opportunity/api/opp_references_popup.dspy", "type": "popup", "width": "70%", "height": "76%", "require_project": True}, ], ) register_ability(ability) return ability # ══════════════════ slash 命令(产线专属)══════════════════ async def _slash_hot(args, ctx): ex = ctx.get("executor") if not ex: return "无执行器上下文" return await ex._execute_ability_tool("opp_hot_software", {"days": args or 30}) async def _slash_ai(args, ctx): ex = ctx.get("executor") if not ex: return "无执行器上下文" return await ex._execute_ability_tool("opp_daily_ai_tenders", {"date": args or ""}) async def _slash_reports(args, ctx): ex = ctx.get("executor") if not ex: return "无执行器上下文" return await ex._execute_ability_tool("opp_list_reports", {}) async def _slash_oppdiag(args, ctx): ex = ctx.get("executor") if not ex: return "无执行器上下文" return await ex._execute_ability_tool("opp_diagnose", {}) def register_opp_slash_commands(): for cmd in [ SlashCommand("hot", "热点软件推荐(近30天招标排名)", _slash_hot, "pipeline", PIPELINE_ID), SlashCommand("ai", "今日AI/Agent招标清单", _slash_ai, "pipeline", PIPELINE_ID), SlashCommand("reports", "列出研发报告", _slash_reports, "pipeline", PIPELINE_ID), SlashCommand("oppdiag", "商机产线诊断", _slash_oppdiag, "pipeline", PIPELINE_ID), ]: register_slash_command(cmd) OPP_DD_BIZ_TYPE = "opp_dev_approval" def register_opp_dingtalk_hook(): """把商机产线审批结论回流挂到钉钉审批回调上。 dingdingflow 已加载时注册成功;未加载(宿主没装钉钉模块)时静默跳过, 发起审批会走降级人工通道,闭环不受影响。 """ try: from ahserver.serverenv import ServerEnv env = ServerEnv() register_biz_handler = getattr(env, "register_biz_handler", None) if register_biz_handler is None: logger.info("[opp] dingdingflow 未加载,跳过钉钉审批钩子注册") return False from .opp_report_capability import on_dingtalk_approval_done register_biz_handler(OPP_DD_BIZ_TYPE, on_dingtalk_approval_done) logger.info("[opp] 钉钉审批钩子已注册 biz_type=%s", OPP_DD_BIZ_TYPE) return True except Exception as e: logger.warning("[opp] 钉钉审批钩子注册失败(不影响降级人工): %s", str(e)[:200]) return False # import 即注册(与投标产线一致:宿主 import pipeline_opportunity 即生效) register_opp_ability() register_opp_slash_commands() register_opp_dingtalk_hook()