feat(opp): 口述条件检索/汇总——opp_query_tenders+先调研后报告流程

- 新增 opp_query_tenders: 用户口述条件(关键词/地区/采购人/公告类型/来源/
  预算区间/AI评分/时间窗)→LLM映射参数,返回明细或按维度汇总(条数/预算/样例)
- 数据层 query_tenders 透传爬虫平台 /api/tenders_query
- prompt 改四步流程: 收集资料→数据分析→发现商机→产研报告(不直接写报告)
This commit is contained in:
yumoqing 2026-08-30 08:51:55 +08:00
parent b2c87edf74
commit d777f0ac33
2 changed files with 87 additions and 7 deletions

View File

@ -32,6 +32,19 @@ OPP_TOOLS = [
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新增/最近运行)",
@ -60,16 +73,34 @@ OPP_TOOLS = [
# ══════════════════ 产线 prompt 片段 ══════════════════
OPP_PROMPT = """你是「商机产线」的驾驶舱 agent,负责把招标市场情报转化为软件研发商机:
热点软件推荐 → 选定方向 → 编写软件研发报告 → 人工确认 → 发起研发审批 → 跟踪审批结论。
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_tender_detail),
**不要**自己去爬网站,也不要怀疑数据新鲜度——先看 opp_crawler_stats 的采集时间。
你只消费数据(opp_hot_software / opp_daily_ai_tenders / opp_search_tenders /
opp_query_tenders / opp_tender_detail),**不要**自己去爬网站,
也不要怀疑数据新鲜度——先看 opp_crawler_stats 的采集时间。
## 商机闭环与门禁(不要绕过)
1. 用 opp_hot_software 看哪个软件方向招标多、预算大,给用户推荐。
1. 用 opp_hot_software / opp_query_tenders 看哪个软件方向招标多、预算大,给用户推荐。
2. 用户选定方向后:opp_create_report 建草稿 → 你基于真实招标数据补写分析
(市场空间/代表项目/竞争格局/研发建议)→ opp_update_report 更新全文。
3. 写完用 opp_submit_report 提交**人工确认**(门禁)。确认前不许发起审批。
@ -80,7 +111,7 @@ OPP_PROMPT = """你是「商机产线」的驾驶舱 agent,负责把招标市
- 报告里的招标数量、预算、项目案例必须来自工具返回的真实数据,**禁止编造**;
查不到就如实写"数据未覆盖"。
- **所有分析必须带 references(可查证)**:给排名/统计/推荐结论时,调
opp_category_references 取该主题的分项明细,回答末尾附 References 章节——
opp_category_references 或 opp_query_tenders 取分项明细,回答末尾附 References 章节——
列出自带的「📋 数据参考」菜单(用户可逐条点开查公告原文),
并在正文中给出 3-5 条代表性分项的标题+公告链接(markdown 链接,外链会自动新窗口打开)。
- 每条引用的招标信息带来源链接(爬虫平台已提供 url / source_url)。
@ -89,8 +120,10 @@ OPP_PROMPT = """你是「商机产线」的驾驶舱 agent,负责把招标市
## 典型场景
- 用户问「最近什么软件最火/最值得做」→ opp_hot_software(days=30) 后给结论,
并对推荐的主题调 opp_category_references 附 references。
- 用户口述检索/统计条件 → opp_query_tenders 映射参数,返回明细或汇总并附样例链接。
- 用户问「今天有什么AI招标」→ opp_daily_ai_tenders()。
- 用户说「给XX写个研发报告」→ opp_create_report → 拉数据补写 → opp_submit_report。
- 用户说「给XX写个研发报告」→ 先确认调研充分(1-3步),再
opp_create_report → 拉数据补写 → opp_submit_report。
- 用户问进展 → opp_diagnose(报告/审批分布 + 待办人工任务)。"""
# ══════════════════ 角色集(商机产线轻量:分析+撰写两角色)══════════════════
@ -166,6 +199,32 @@ async def _h_search(sor, p, ctx):
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 "")
@ -273,6 +332,7 @@ 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,

View File

@ -49,3 +49,23 @@ async def category_references(sor, category, days=30, limit=500):
async def crawler_stats(sor):
"""爬虫平台采集健康度。"""
return await crawler_get(sor, "/api/stats")
async def query_tenders(sor, keyword="", region="", buyer="", notice_type="",
source="", min_budget="", max_budget="", min_ai="",
days=90, group_by="", limit=50, only_it=True):
"""条件检索 + 汇总(用户口述条件 → LLM 映射为参数)。
过滤:keyword(标题多词AND)/region/buyer 模糊,notice_type/source 精确,
min_budget/max_budget(万元)区间,min_ai 评分下限,days 回溯窗口,only_it。
汇总:group_by=region|notice_type|category|source|month → 分组统计+样例。
"""
params: dict = {"days": days, "limit": limit}
for k, v in (("keyword", keyword), ("region", region), ("buyer", buyer),
("notice_type", notice_type), ("source", source),
("min_budget", min_budget), ("max_budget", max_budget),
("min_ai", min_ai), ("group_by", group_by)):
if v not in (None, ""):
params[k] = v
params["only_it"] = "1" if only_it else "0"
return await crawler_get(sor, "/api/tenders_query", params)