632 lines
36 KiB
Python
632 lines
36 KiB
Python
"""商机产线能力包(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()
|