feat(references): 分项明细列表支持排序——budget标的金额大到小/time发布时间后到前;数据源侧先全局排序再截断(金额TopN是真TopN非局部排序);三个分项弹窗(招标/国内众包/海外)顶部加排序切换按钮(当前态primary高亮,点击重建弹窗带sort参数);弹窗标题回显当前排序;agent工具opp_category_references/opp_overseas_references加sort参数(白名单收敛,非法值回落time)

This commit is contained in:
yumoqing 2026-09-15 14:36:04 +08:00
parent 7a8afab054
commit 684ec3ba69
5 changed files with 129 additions and 27 deletions

View File

@ -49,9 +49,10 @@ OPP_TOOLS = [
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",
ToolDefinition(name="opp_category_references", description="查某软件主题的分项明细每条带公告原文URL+采集来源,供逐条查证/举例sort=budget 按标的金额大到小 / time 按发布时间后到前)。⚠️ 明细是「举例佐证」用的,不是「统计」用的——排名/数量/金额统计先用 opp_hot_software已返回 tender_count+total_budget_wan只在需要给2~5个样例链接时才调本工具且 limit≤10",
parameters={"category": "软件主题名(须与热点排名中的category一致)",
"days": "统计窗口天数(默认30)", "limit": "条数(默认20,最大50——只取样例不要拉全量)"}, category="data"),
"days": "统计窗口天数(默认30)", "limit": "条数(默认20,最大50——只取样例不要拉全量)",
"sort": "排序(可选: budget=标的金额大到小/time=时间后到前,默认time)"}, 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海外,不传=全部)",
@ -60,9 +61,10 @@ OPP_TOOLS = [
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",
ToolDefinition(name="opp_overseas_references", description="查某海外需求主题的分项明细每条带Freelancer/PeoplePerHour详情页URL供逐条查证/举例sort=budget 按赏金大到小 / time 按发布时间后到前)。⚠️ 统计先用 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"),
"days": "统计窗口天数(默认30)", "limit": "条数(默认20,最大50——只取样例不要拉全量)",
"sort": "排序(可选: budget=赏金大到小/time=时间后到前,默认time)"}, category="data"),
# ── 报告与审批(商机闭环)──
ToolDefinition(name="opp_create_report", description="创建软件研发报告草稿。从热门软件中选定的方向写入报告,数据基础章节自动从爬取平台拉取。正文必须按四节模板组织(## 研发场景 / ## 输入 / ## 输出 / ## 成果),商店详情页按节展示",
@ -337,13 +339,16 @@ async def _h_category_references(sor, p, ctx):
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))
limit=min(int(p.get("limit") or 200), 1000),
sort=str(p.get("sort") or "").strip())
if not ok:
return _fmt(False, res)
return json.dumps({
"category": res.get("category"),
"窗口": "%d" % res.get("window_days"),
"分项总数": res.get("count"),
"排序": {"budget": "标的金额大到小", "time": "发布时间后到前"}.get(
res.get("sort") or "time", "发布时间后到前"),
"分项": res.get("items"),
"说明": "每条分项的 url 为公告原文、source_url 为采集来源,均可点击查证",
}, ensure_ascii=False, default=str)
@ -393,7 +398,8 @@ async def _h_overseas_references(sor, p, ctx):
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))
limit=min(int(p.get("limit") or 200), 500),
sort=str(p.get("sort") or "").strip())
if not ok:
return _fmt(False, res)
return json.dumps(res, ensure_ascii=False, default=str)

View File

@ -55,17 +55,22 @@ async def hot_software(sor, days=30, top=10):
{"days": days, "top": top})
async def category_references(sor, category, days=30, limit=20, record_type="tender"):
async def category_references(sor, category, days=30, limit=20, record_type="tender",
sort=""):
"""某软件主题的分项明细references 可查证,举例佐证用)。
每条带公告原文/详情页 url + 采集来源 source_url供界面逐条查证
record_type: tender=招标(默认) / demand=众包需求发布(猪八戒/一品威客)
sort: ""=缺省(时间后到前) / time=发布时间后到前 / budget=标的金额大到小
排序在数据源侧全量执行后再截断金额前 N是全窗口真 TopN
limit 硬上限 _DETAIL_LIMIT_MAX(50)统计用 hot_software 聚合勿拉全量明细
"""
return await crawler_get(sor, "/api/category_references",
{"category": category, "days": days,
"limit": _clamp_detail_limit(limit),
"record_type": record_type})
params = {"category": category, "days": days,
"limit": _clamp_detail_limit(limit),
"record_type": record_type}
if sort:
params["sort"] = sort
return await crawler_get(sor, "/api/category_references", params)
async def crawler_stats(sor):
@ -99,15 +104,18 @@ async def hot_overseas(sor, days=30, top=10):
{"days": days, "top": top})
async def overseas_references(sor, category, days=30, limit=20):
async def overseas_references(sor, category, days=30, limit=20, sort=""):
"""某海外需求主题的分项明细references 可查证,举例佐证用)。
每条带海外平台详情页 url供界面逐条查证
sort: ""=缺省(时间后到前) / time=发布时间后到前 / budget=赏金(折人民币)大到小
limit 硬上限 _DETAIL_LIMIT_MAX(50)统计用 hot_overseas 聚合勿拉全量明细
"""
return await crawler_get(sor, "/api/overseas_references",
{"category": category, "days": days,
"limit": _clamp_detail_limit(limit)})
params = {"category": category, "days": days,
"limit": _clamp_detail_limit(limit)}
if sort:
params["sort"] = sort
return await crawler_get(sor, "/api/overseas_references", params)
async def query_tenders(sor, keyword="", region="", buyer="", notice_type="",

View File

@ -1,5 +1,7 @@
# opp_demand_items.dspy - 某软件主题的众包需求分项列表(国内猪八戒/一品威客)
# 入参category=<主题名>。交互2026-09-08 用户定夺):
# 入参category=<主题名>sort=time|budget2026-09-15 用户需求:支持按赏金
# 大到小 / 按时间后到前排序;排序在数据源侧全量执行后再截断,缺省 time
# 交互2026-09-08 用户定夺):
# 每条分项整行点击 → opp_item_detail.dspy 弹出具体内容(爬取平台数据+附件下载);
# 来源写进每条说明文字(来源:猪八戒/一品威客),不再渲染来源按钮。
@ -13,17 +15,44 @@ category = ((params_kw or {}).get("category") or "").strip()
days = int((params_kw or {}).get("days") or 30)
if not category:
return {"widgettype": "Message", "options": {"title": "缺少参数", "message": "缺少 category"}}
# 排序白名单收敛(非法/缺省一律 time=发布时间后到前,与历史行为一致)
sort = ((params_kw or {}).get("sort") or "time").strip()
if sort not in ("time", "budget"):
sort = "time"
dbname = get_module_dbname('pipeline-opportunity')
detail_url = entire_url("/pipeline-opportunity/api/opp_item_detail.dspy")
self_url = entire_url("/pipeline-opportunity/api/opp_demand_items.dspy")
async with DBPools().sqlorContext(dbname) as sor:
from pipeline_opportunity.opp_data_capability import category_references
from pipeline_opportunity.opp_common import source_text
ok, res = await category_references(sor, category, days=days, limit=500, record_type="demand")
ok, res = await category_references(sor, category, days=days, limit=500,
record_type="demand", sort=sort)
await sor.sqlExe("COMMIT", {})
# ── 排序切换按钮(点击重建弹窗带 sort 参数;对齐 opp_mining_popup 刷新范式)──
def _sort_btn(label, target_sort, active):
script = ("var old=bricks.getWidgetById('opp_demand_items_pw',bricks.app);"
"if(old&&old.destroy){old.destroy();}"
"var rp=await fetch(" + _json.dumps(self_url) + "+'?category='+encodeURIComponent("
+ _json.dumps(category) + ")+'&days=" + str(days) + "&sort=" + target_sort + "');"
"var d=await rp.json();"
"if(d){bricks.widgetBuild(d,bricks.app);}")
return {"widgettype": "Button",
"options": {"label": label, "css": ("primary" if active else "small")},
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self", "script": script}]}
sort_bar = {"widgettype": "HBox", "options": {"width": "100%", "gap": "8px",
"alignItems": "center", "margin": "0 0 8px 0"},
"subwidgets": [
_sort_btn("💰 赏金(大到小)", "budget", sort == "budget"),
_sort_btn("⏱ 发布时间(后到前)", "time", sort == "time"),
{"widgettype": "Filler"},
]}
rows = []
if not ok:
rows.append({"widgettype": "Text", "options": {
@ -79,16 +108,17 @@ else:
"subwidgets": row_subs})
total = res.get("count", len(rows)) if ok else 0
_sort_label = "赏金大到小" if sort == "budget" else "时间后到前"
return {
"widgettype": "PopupWindow",
"id": "opp_demand_items_pw",
"options": {"title": "众包需求分项:%s%d 条)" % (category, total),
"options": {"title": "众包需求分项:%s%d 条 · %s" % (category, total, _sort_label),
"width": "80%", "height": "82%", "auto_open": True, "resizable": True},
"subwidgets": [{
"widgettype": "VBox",
"options": {"css": "filler", "width": "100%", "height": "100%",
"padding": "12px 16px", "gap": "0px", "overflow": "auto"},
"subwidgets": [
"subwidgets": [sort_bar,
{"widgettype": "Text", "options": {
"otext": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。", "text": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。", "i18n": True,
"cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}

View File

@ -1,5 +1,7 @@
# opp_overseas_items.dspy - 某海外需求主题的分项列表references 可查证)
# 入参category=<海外主题名>。交互2026-09-08 用户定夺):
# 入参category=<海外主题名>sort=time|budget2026-09-15 用户需求:支持按赏金
# (折人民币)大到小 / 按时间后到前排序;排序在数据源侧全量执行后再截断,缺省 time
# 交互2026-09-08 用户定夺):
# 每条分项整行点击 → opp_item_detail.dspy 弹出具体内容(爬取平台数据+附件下载);
# 来源写进每条说明文字来源Freelancer.com / PeoplePerHour不再渲染来源按钮。
@ -13,17 +15,43 @@ category = ((params_kw or {}).get("category") or "").strip()
days = int((params_kw or {}).get("days") or 30)
if not category:
return {"widgettype": "Message", "options": {"title": "缺少参数", "message": "缺少 category"}}
# 排序白名单收敛(非法/缺省一律 time=发布时间后到前,与历史行为一致)
sort = ((params_kw or {}).get("sort") or "time").strip()
if sort not in ("time", "budget"):
sort = "time"
dbname = get_module_dbname('pipeline-opportunity')
detail_url = entire_url("/pipeline-opportunity/api/opp_item_detail.dspy")
self_url = entire_url("/pipeline-opportunity/api/opp_overseas_items.dspy")
async with DBPools().sqlorContext(dbname) as sor:
from pipeline_opportunity.opp_data_capability import overseas_references
from pipeline_opportunity.opp_common import source_text
ok, res = await overseas_references(sor, category, days=days, limit=500)
ok, res = await overseas_references(sor, category, days=days, limit=500, sort=sort)
await sor.sqlExe("COMMIT", {})
# ── 排序切换按钮(点击重建弹窗带 sort 参数;对齐 opp_mining_popup 刷新范式)──
def _sort_btn(label, target_sort, active):
script = ("var old=bricks.getWidgetById('opp_overseas_items_pw',bricks.app);"
"if(old&&old.destroy){old.destroy();}"
"var rp=await fetch(" + _json.dumps(self_url) + "+'?category='+encodeURIComponent("
+ _json.dumps(category) + ")+'&days=" + str(days) + "&sort=" + target_sort + "');"
"var d=await rp.json();"
"if(d){bricks.widgetBuild(d,bricks.app);}")
return {"widgettype": "Button",
"options": {"label": label, "css": ("primary" if active else "small")},
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self", "script": script}]}
sort_bar = {"widgettype": "HBox", "options": {"width": "100%", "gap": "8px",
"alignItems": "center", "margin": "0 0 8px 0"},
"subwidgets": [
_sort_btn("💰 赏金(大到小)", "budget", sort == "budget"),
_sort_btn("⏱ 发布时间(后到前)", "time", sort == "time"),
{"widgettype": "Filler"},
]}
rows = []
if not ok:
rows.append({"widgettype": "Text", "options": {
@ -79,16 +107,17 @@ else:
"subwidgets": row_subs})
total = res.get("matched", len(rows)) if ok else 0
_sort_label = "赏金大到小" if sort == "budget" else "时间后到前"
return {
"widgettype": "PopupWindow",
"id": "opp_overseas_items_pw",
"options": {"title": "海外需求分项:%s%d 条)" % (category, total),
"options": {"title": "海外需求分项:%s%d 条 · %s" % (category, total, _sort_label),
"width": "80%", "height": "82%", "auto_open": True, "resizable": True},
"subwidgets": [{
"widgettype": "VBox",
"options": {"css": "filler", "width": "100%", "height": "100%",
"padding": "12px 16px", "gap": "0px", "overflow": "auto"},
"subwidgets": [
"subwidgets": [sort_bar,
{"widgettype": "Text", "options": {
"otext": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。", "text": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。", "i18n": True,
"cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}

View File

@ -1,5 +1,7 @@
# opp_references_items.dspy - 某软件主题的分项列表references 可查证)
# 入参category=<主题名>。交互2026-09-08 用户定夺):
# 入参category=<主题名>sort=time|budget2026-09-15 用户需求:支持按标的金额
# 大到小 / 按时间后到前排序;排序在数据源侧全量执行后再截断,缺省 time
# 交互2026-09-08 用户定夺):
# 每条分项整行点击 → opp_item_detail.dspy 弹出具体内容(爬取平台全文+附件下载);
# 来源写进每条说明文字来源xxx不再渲染来源按钮。
@ -13,17 +15,43 @@ category = ((params_kw or {}).get("category") or "").strip()
days = int((params_kw or {}).get("days") or 30)
if not category:
return {"widgettype": "Message", "options": {"title": "缺少参数", "message": "缺少 category"}}
# 排序白名单收敛(非法/缺省一律 time=发布时间后到前,与历史行为一致)
sort = ((params_kw or {}).get("sort") or "time").strip()
if sort not in ("time", "budget"):
sort = "time"
dbname = get_module_dbname('pipeline-opportunity')
detail_url = entire_url("/pipeline-opportunity/api/opp_item_detail.dspy")
self_url = entire_url("/pipeline-opportunity/api/opp_references_items.dspy")
async with DBPools().sqlorContext(dbname) as sor:
from pipeline_opportunity.opp_data_capability import category_references
from pipeline_opportunity.opp_common import source_text
ok, res = await category_references(sor, category, days=days, limit=500)
ok, res = await category_references(sor, category, days=days, limit=500, sort=sort)
await sor.sqlExe("COMMIT", {})
# ── 排序切换按钮(点击重建弹窗带 sort 参数;对齐 opp_mining_popup 刷新范式)──
def _sort_btn(label, target_sort, active):
script = ("var old=bricks.getWidgetById('opp_refs_items_pw',bricks.app);"
"if(old&&old.destroy){old.destroy();}"
"var rp=await fetch(" + _json.dumps(self_url) + "+'?category='+encodeURIComponent("
+ _json.dumps(category) + ")+'&days=" + str(days) + "&sort=" + target_sort + "');"
"var d=await rp.json();"
"if(d){bricks.widgetBuild(d,bricks.app);}")
return {"widgettype": "Button",
"options": {"label": label, "css": ("primary" if active else "small")},
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self", "script": script}]}
sort_bar = {"widgettype": "HBox", "options": {"width": "100%", "gap": "8px",
"alignItems": "center", "margin": "0 0 8px 0"},
"subwidgets": [
_sort_btn("💰 标的金额(大到小)", "budget", sort == "budget"),
_sort_btn("⏱ 发布时间(后到前)", "time", sort == "time"),
{"widgettype": "Filler"},
]}
rows = []
if not ok:
rows.append({"widgettype": "Text", "options": {
@ -84,16 +112,17 @@ else:
"subwidgets": row_subs})
total = res.get("count", len(rows)) if ok else 0
_sort_label = "标的金额大到小" if sort == "budget" else "时间后到前"
return {
"widgettype": "PopupWindow",
"id": "opp_refs_items_pw",
"options": {"title": "分项明细:%s%d 条)" % (category, total),
"options": {"title": "分项明细:%s%d 条 · %s" % (category, total, _sort_label),
"width": "80%", "height": "82%", "auto_open": True, "resizable": True},
"subwidgets": [{
"widgettype": "VBox",
"options": {"css": "filler", "width": "100%", "height": "100%",
"padding": "12px 16px", "gap": "0px", "overflow": "auto"},
"subwidgets": [
"subwidgets": [sort_bar,
{"widgettype": "Text", "options": {
"otext": "点击任一条目弹出具体内容(公告全文来自数据爬取平台,有附件的可直接下载);每条说明中已标注数据来源。", "text": "点击任一条目弹出具体内容(公告全文来自数据爬取平台,有附件的可直接下载);每条说明中已标注数据来源。", "i18n": True,
"cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}