530 lines
28 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_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="查某软件主题的全部分项明细(references 可查证:每条带公告原文URL+采集来源)。回答热点/排名类问题时用本工具取分项并附来源链接",
parameters={"category": "软件主题名(须与热点排名中的category一致)",
"days": "统计窗口天数(默认30)", "limit": "条数(默认200)"}, category="data"),
ToolDefinition(name="opp_demands", description="查众包平台软件需求发布列表——甲方直接发布的开发需求,覆盖国内(猪八戒zbj/一品威客epwk)与海外(Freelancer.com freelancer/PeoplePerHour pph)。每条带详情页URL可查证",
parameters={"keyword": "标题关键词(可选)", "days": "统计窗口天数(默认30)",
"source": "来源(可选: zbj猪八戒/epwk一品威客/freelancer海外/pph海外,不传=全部)",
"limit": "条数(默认50)"}, category="data"),
ToolDefinition(name="opp_hot_demands", description="国内众包需求热点主题排名(需求侧热度,与招标侧的 opp_hot_software 互补)",
parameters={"days": "统计窗口天数(默认30)", "top": "Top条数(默认10)"}, category="data"),
ToolDefinition(name="opp_hot_overseas", description="海外软件需求热点主题排名(Freelancer.com/PeoplePerHour 众包需求,英文标题按8大主题归类:AI智能体/Web/移动/桌面企业软件/数据集成/自动化/DevOps云/游戏)。用户问国外/海外市场、国外软件需求前十时用本工具",
parameters={"days": "统计窗口天数(默认30)", "top": "Top条数(默认10)"}, category="data"),
ToolDefinition(name="opp_overseas_references", description="查某海外需求主题的全部分项明细(references 可查证:每条带Freelancer/PeoplePerHour详情页URL)。回答海外热点问题时用本工具取分项并附来源链接",
parameters={"category": "海外主题名(须与 opp_hot_overseas 排名中的category一致)",
"days": "统计窗口天数(默认30)", "limit": "条数(默认200)"}, 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,负责在项目内把招标市场情报转化为软件研发商机。
## 工作流程(先调研后报告,不要跳步)
项目创建后**不是直接写产研报告**,而是按四步推进:
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. 人工确认通过后,你才用(或由人工任务驱动)发起研发审批;审批结论由人工回流,
**禁止**自己批准自己的审批。
## 硬规则
- 报告里的招标数量、预算、项目案例必须来自工具返回的真实数据,**禁止编造**;
查不到就如实写"数据未覆盖"。
- **所有分析必须带 references(可查证)**:给排名/统计/推荐结论时,调
opp_category_references / opp_query_tenders / opp_demands / opp_overseas_references
取分项明细,回答末尾附 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)
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,
}
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%"},
{"label": "📋 数据参考", "icon": "", "url": "/pipeline-opportunity/api/opp_references_popup.dspy",
"type": "popup", "width": "70%", "height": "76%"},
],
)
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()