339 lines
16 KiB
Python
339 lines
16 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_tender_detail", description="查看单条招标信息完整详情(含公告原文、预算、联系人、来源链接)",
|
||
parameters={"tender_id": "招标信息ID"}, category="data"),
|
||
ToolDefinition(name="opp_crawler_stats", description="查看数据爬取平台采集健康度(总量/24h新增/最近运行)",
|
||
parameters={}, 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"),
|
||
]
|
||
|
||
# ══════════════════ 产线 prompt 片段 ══════════════════
|
||
|
||
OPP_PROMPT = """你是「商机产线」的驾驶舱 agent,负责把招标市场情报转化为软件研发商机:
|
||
热点软件推荐 → 选定方向 → 编写软件研发报告 → 人工确认 → 发起研发审批 → 跟踪审批结论。
|
||
|
||
## 数据来源(重要边界)
|
||
招标数据全部来自「数据爬取平台」(内网 HTTP,已采集入库、已去重、每条带来源链接)。
|
||
你只消费数据(opp_hot_software / opp_daily_ai_tenders / opp_search_tenders / opp_tender_detail),
|
||
**不要**自己去爬网站,也不要怀疑数据新鲜度——先看 opp_crawler_stats 的采集时间。
|
||
|
||
## 商机闭环与门禁(不要绕过)
|
||
1. 用 opp_hot_software 看哪个软件方向招标多、预算大,给用户推荐。
|
||
2. 用户选定方向后:opp_create_report 建草稿 → 你基于真实招标数据补写分析
|
||
(市场空间/代表项目/竞争格局/研发建议)→ opp_update_report 更新全文。
|
||
3. 写完用 opp_submit_report 提交**人工确认**(门禁)。确认前不许发起审批。
|
||
4. 人工确认通过后,你才用(或由人工任务驱动)发起研发审批;审批结论由人工回流,
|
||
**禁止**自己批准自己的审批。
|
||
|
||
## 硬规则
|
||
- 报告里的招标数量、预算、项目案例必须来自工具返回的真实数据,**禁止编造**;
|
||
查不到就如实写"数据未覆盖"。
|
||
- 每条引用的招标信息带来源链接(爬虫平台已提供 url / source_url)。
|
||
- 报告没被人工确认,不许当作可发起审批;审批没过,不许当作可立项。
|
||
|
||
## 典型场景
|
||
- 用户问「最近什么软件最火/最值得做」→ opp_hot_software(days=30) 后给结论。
|
||
- 用户问「今天有什么AI招标」→ opp_daily_ai_tenders()。
|
||
- 用户说「给XX写个研发报告」→ 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_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_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)
|
||
|
||
|
||
OPP_HANDLERS = {
|
||
"opp_hot_software": _h_hot_software,
|
||
"opp_daily_ai_tenders": _h_daily_ai,
|
||
"opp_search_tenders": _h_search,
|
||
"opp_tender_detail": _h_detail,
|
||
"opp_crawler_stats": _h_crawler_stats,
|
||
"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,
|
||
}
|
||
|
||
|
||
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/opp_reports/index.ui",
|
||
"type": "popup", "width": "88%", "height": "82%"},
|
||
{"label": "✅ 研发审批", "icon": "", "url": "/pipeline-opportunity/opp_approvals/index.ui",
|
||
"type": "popup", "width": "88%", "height": "82%"},
|
||
],
|
||
)
|
||
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)
|
||
|
||
|
||
# import 即注册(与投标产线一致:宿主 import pipeline_opportunity 即生效)
|
||
register_opp_ability()
|
||
register_opp_slash_commands()
|