339 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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_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()