632 lines
36 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""商机产线能力包(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()