261 lines
10 KiB
Python
261 lines
10 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
联网检索
|
||
"""
|
||
from datetime import datetime
|
||
from html import escape
|
||
from typing import Any, AsyncGenerator
|
||
|
||
from httpx import AsyncClient, HTTPStatusError
|
||
from pydantic import BaseModel, Field
|
||
from pydantic_ai import Agent, ModelMessage
|
||
from pydantic_ai.messages import AgentStreamEvent
|
||
from pydantic_ai.run import AgentRunResultEvent
|
||
from pydantic_ai.toolsets import FunctionToolset
|
||
from pydantic_ai.usage import RunUsage
|
||
|
||
from application.workshop.database import connection_pool
|
||
from application.workshop.models import DEEPSEEK_V4_FLASH_RESPONSE_MODEL
|
||
|
||
"""
|
||
联网检索缓存表建表SQL
|
||
CREATE TABLE IF NOT EXISTS web_retrieve_caches (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
query TEXT NOT NULL,
|
||
results TEXT NOT NULL
|
||
);
|
||
CREATE INDEX idx_web_retrieve_caches_query ON web_retrieve_caches (query);
|
||
"""
|
||
|
||
|
||
class Deps(BaseModel):
|
||
"""
|
||
依赖类
|
||
"""
|
||
|
||
...
|
||
|
||
|
||
class Args(BaseModel):
|
||
query: str = Field(..., description="查询语句")
|
||
|
||
|
||
# 实例化工具集
|
||
toolset = FunctionToolset()
|
||
|
||
|
||
@toolset.tool_plain()
|
||
async def web_retrieve(args: Args) -> str:
|
||
"""
|
||
联网检索:
|
||
用于基于互联网检索实时信息、新闻、政策法规、行业报告、企业公告、最新数据。
|
||
"""
|
||
if connection_pool is None:
|
||
return "数据库连接池未初始化"
|
||
|
||
# 查询
|
||
query = args.query
|
||
|
||
# 查询联网检索缓存
|
||
async with connection_pool.acquire() as connection:
|
||
results = await connection.fetchval(
|
||
"SELECT results FROM web_retrieve_caches WHERE query = $1",
|
||
query,
|
||
)
|
||
# 若命中缓存则返回结果,否则先召回、重排再返回重构结果
|
||
if results is not None:
|
||
return results
|
||
|
||
# --- 召回阶段 --- #
|
||
try:
|
||
async with AsyncClient(timeout=60) as client:
|
||
response = await client.post(
|
||
"https://api.bocha.cn/v1/web-search",
|
||
headers={
|
||
"Authorization": "Bearer sk-a9ed73d8b26745799cf970f84d70f512",
|
||
"Content-Type": "application/json",
|
||
},
|
||
json={
|
||
"query": query,
|
||
"summary": True, # 显示文本摘要
|
||
"count": 20, # 返回结果数
|
||
},
|
||
)
|
||
# 检查响应状态
|
||
response.raise_for_status()
|
||
# 解析响应载荷
|
||
response_payload = response.json()
|
||
|
||
except HTTPStatusError as exception:
|
||
return f"请求失败 {exception.response.status_code}: {exception.response.text}"
|
||
|
||
except Exception as exception:
|
||
return f"请求异常: {str(exception)}"
|
||
|
||
if (code := response_payload.get("code")) != 200:
|
||
return f"业务失败 {code}: {response_payload.get("msg", "未知业务错误")}"
|
||
|
||
# 召回网页
|
||
webpages = response_payload["data"]["webPages"]["value"]
|
||
if not webpages:
|
||
return "未检索到相关结果"
|
||
|
||
# --- 重排阶段 --- #
|
||
try:
|
||
async with AsyncClient(timeout=60) as client:
|
||
response = await client.post(
|
||
"https://api.siliconflow.cn/v1/rerank",
|
||
headers={
|
||
"Authorization": "Bearer sk-xsnuwirjjphhfdbvznfdfjqlinfdlrnlxuhkbbqynfnbhiqz",
|
||
"Content-Type": "application/json",
|
||
},
|
||
json={
|
||
"model": "BAAI/bge-reranker-v2-m3",
|
||
"query": query,
|
||
"documents": [
|
||
called_webpage["summary"] or called_webpage["snippet"]
|
||
for called_webpage in webpages
|
||
],
|
||
"return_documents": True, # 返回文档内容
|
||
"top_n": 10, # 返回结果数
|
||
},
|
||
)
|
||
# 检查响应状态
|
||
response.raise_for_status()
|
||
# 解析响应载荷
|
||
response_payload = response.json()
|
||
|
||
except HTTPStatusError as exception:
|
||
return f"请求失败 {exception.response.status_code}: {exception.response.text}"
|
||
|
||
except Exception as exception:
|
||
return f"请求失败: {str(exception)}"
|
||
|
||
# 重排后网页
|
||
reranked_webpages = [
|
||
(result["index"], result["document"]["text"])
|
||
for result in response_payload["results"]
|
||
]
|
||
|
||
# --- 重构检索结果 --- #
|
||
results = []
|
||
results.append(f"检索内容:**{query}**,检索结果:\n")
|
||
for index, summary in reranked_webpages:
|
||
# 根据重排后网页索引获取网页
|
||
webpage = webpages[index]
|
||
# 解析网页的标题
|
||
name = escape(webpage["name"])
|
||
# 解析网页的URL
|
||
url = escape(webpage["url"])
|
||
# 解析网页的网站名称
|
||
site_name = escape(webpage["siteName"])
|
||
# 解析网页的发布时间
|
||
date_published = escape(
|
||
datetime.fromisoformat(webpage["datePublished"]).date().isoformat()
|
||
)
|
||
|
||
results.append(
|
||
f"- <a href='{url}'>**{name}**</a>({site_name}・{date_published}):{escape(summary)}"
|
||
)
|
||
|
||
results = "\n".join(results)
|
||
|
||
# 创建联网检索缓存
|
||
async with connection_pool.acquire() as connection:
|
||
await connection.execute(
|
||
"INSERT INTO web_retrieve_caches (query, results) VALUES ($1, $2)",
|
||
query,
|
||
results,
|
||
)
|
||
|
||
return results
|
||
|
||
|
||
instruction = """
|
||
你是研究助手,可调用联网检索工具。
|
||
## 一、联网判断
|
||
需要联网:时效类信息、新闻、声明公告、政策法规、最新数据,以及模型静态知识库无法可靠回答的问题。
|
||
无需联网:基础常识、数学计算、代码编写与解释、用户已提供完整材料的分析任务、无时间依赖的静态问题。
|
||
|
||
【检索硬约束】
|
||
1. 最多**2轮联网检索**,严禁发起第3轮。一轮定义:单次思考决策发起1条检索Query;**单次思考步骤,最多只能生成1条检索query,禁止同一思考周期并行发起多条联网检索调用**。必须串行执行:执行1次检索,拿到结果评估之后,才允许在后续思考中决定是否发起下一轮检索。不允许一步同时调用多次web_retrieve。
|
||
2. 发起新一轮检索前,必须评估已获取的检索摘要,现有信息足以回答问题则立即停止调用工具。
|
||
3. 禁止仅改写同义词、做语义几乎等价的重复检索;若存在局部信息缺失,优先从已有结果中提取可用内容,不要直接发起新检索。
|
||
4. 默认只执行1次真实联网检索;拿到检索结果后评估证据,满足以下任意条件,必须停止检索:
|
||
- 现有信息足以作答;官方一手资料已给出答案;多个可靠来源结论一致;继续检索仅起到重复确认的作用。
|
||
❌禁止以“再次确认、查找更多来源、补充更多资料”等理由发起额外检索。
|
||
✅仅在以下4种场景,才允许执行第2轮检索:
|
||
①第一轮无有效检索结果;②回答问题必需的关键事实完全缺失;③权威信源之间事实存在明显冲突;④第一轮检索结果严重偏题。
|
||
第二轮检索必须瞄准明确的信息缺口,禁止全盘复刻上一轮查询。
|
||
> 重要:第二轮检索执行完成后,无论信息是否补齐,都必须终止检索,不再发起任何web_retrieve调用。
|
||
|
||
## 二、时间与Query规则
|
||
时效判断严格以运行时传入的当前日期、年份、时区为准,禁止依靠模型内置记忆猜测时间。
|
||
- 用户未指定年份:**禁止自行向检索Query中追加年份**。
|
||
- 用户明确给出年份:检索Query必须完整保留用户指定的年份。
|
||
- 若检索到网页发布时间晚于运行时当前日期,直接舍弃该结果,不采信。
|
||
|
||
## 三、信源优先级
|
||
1级(最高):官网、官方文档、官方公告、一手原始资料
|
||
2级:权威媒体、专业机构、科研机构
|
||
3级:其他可信赖网页
|
||
> 媒体转述不等于官方原文,若官方原文存在,优先采信官方原文,不采用媒体转述版本。
|
||
官方资料已经可以完整回答问题时,无需再检索其他来源做重复验证。
|
||
自媒体、论坛、转载内容,不可作为重大事实的唯一证据。
|
||
|
||
## 四、最新内容校验
|
||
查询“最新”类问题,不可仅凭网页排序判断时效性;必须核对资料的**发布时间、版本时间,结合运行时提供的当前日期综合判断**。
|
||
当存在多份结果时,优先选用发布时间更近、信源等级更高的资料。
|
||
|
||
## 五、事实规范
|
||
- 【事实】:检索返回资料原文直接支持的内容
|
||
- 【推论】:基于已确认事实做合理推导
|
||
- 【推测】:证据不足前提下的主观判断
|
||
严禁编造URL、日期、新闻事件、数据、官方声明等任何信息;证据不足时直接如实说明,不得强行得出结论。
|
||
> 若2轮检索结束,关键信息仍然缺失,直接告知用户:现有检索资料无法获取目标信息,不编造内容。
|
||
|
||
## 六、引用规范
|
||
引用编号区分检索轮次:S1‑1、S1‑2 代表第1轮检索结果;S2‑1、S2‑2 代表第2轮检索结果。
|
||
只引用回答中实际用到的资料。
|
||
引用格式:`[参考 S1‑1] https://xxx`,链接必须来源于 **web_retrieve** 工具返回结果,禁止编造链接。
|
||
|
||
## 七、输出规则
|
||
简单事实类问题:优先给出结论,附带关键证据与来源,回答简洁,避免冗余。
|
||
复杂研究类问题,按「关键证据 → 分析 → 结论 → 参考来源」结构组织内容;先陈列证据再输出总结,结论不能超出现有资料可支撑的范围。
|
||
"""
|
||
|
||
# 智能体
|
||
agent = Agent[Deps, str](
|
||
model=DEEPSEEK_V4_FLASH_RESPONSE_MODEL,
|
||
deps_type=Deps,
|
||
output_type=str, # 若输出类型为 str,则智能体输出结果时不进行输出类型校验。若定义输出类型,需考虑处理相应事件
|
||
instructions=instruction,
|
||
toolsets=[toolset],
|
||
)
|
||
|
||
|
||
async def run_stream_events(
|
||
deps: Any,
|
||
user_prompt: str | None,
|
||
message_history: list[ModelMessage],
|
||
usage: RunUsage,
|
||
) -> AsyncGenerator[AgentStreamEvent | AgentRunResultEvent, None]:
|
||
"""
|
||
运行并流式输出事件
|
||
:param user_prompt: 用户提示词
|
||
:param message_history: 消息历史
|
||
:param usage: 使用量
|
||
:return: AsyncGenerator
|
||
"""
|
||
if not isinstance(deps, Deps):
|
||
deps = Deps.model_validate(deps)
|
||
|
||
async with agent.run_stream_events(
|
||
deps=deps,
|
||
user_prompt=user_prompt,
|
||
message_history=message_history,
|
||
usage=usage,
|
||
) as events:
|
||
async for event in events:
|
||
yield event
|