c949204662
借鉴 odysseus 的能力设计,全程净室实现、零 AGPL 代码、不引入 AGPL 依赖。
T1 提示注入防护: pkg/promptguard 包裹外部/知识库内容为不可信数据,buildMessages 移出 system 指令区。 T2 安全 CI: .github/workflows(ci+security: govulncheck/gitleaks/actionlint/hadolint/trivy)+dependabot+.hadolint.yaml;go.mod 加 toolchain go1.25.11 修复 20 个 stdlib CVE。 T3 管理员 2FA: 迁移 000016 + RFC6238 TOTP/备份码(pkg/auth, 零依赖) + 登录流程集成(后端)。 T4 本地模型: LLM/embedding 支持本地 vLLM/Ollama(OpenAI 兼容, 鉴权头条件发送, NoAuth) + docs/local-deploy.md。 T6 深度研究: 迁移 000017 + Python research-worker(净室多步流水线, 检索避开 SearXNG) + Go research 服务/handler/路由。 T7 service 层: 新增 internal/service/{research,twofa}, 2FA 业务逻辑从胖 handler 下沉, 接口注入可单测。 T10 缓存/可观测性: internal/cache(Redis+内存, 优雅降级) 接入 store 热点列表; Prometheus 指标+/metrics; docs/openapi.yaml。 验证: go build/vet/test ./... 全绿(8 包); research-worker 12 单测过; 真实 PG 应用迁移并烟测。
64 lines
2.0 KiB
Python
64 lines
2.0 KiB
Python
"""可插拔检索 provider + 网页抓取。
|
|
|
|
刻意避开 AGPL 许可的 SearXNG:通过商用/宽松许可的检索 API(默认 Tavily)实现,
|
|
未配置时返回空结果(流水线降级为无来源报告)。抓取正文用标准库 HTML→文本。
|
|
"""
|
|
|
|
import httpx
|
|
|
|
from config import config
|
|
from htmltext import html_to_text
|
|
|
|
_client = httpx.Client(timeout=30.0, follow_redirects=True,
|
|
headers={"User-Agent": "GovAI-Research/1.0"})
|
|
|
|
|
|
def search(query: str, k: int) -> list[dict]:
|
|
"""返回 [{title, url, snippet, content}];未配置 provider 时返回 []。"""
|
|
provider = (config.SEARCH_PROVIDER or "").lower()
|
|
if provider == "tavily" and config.SEARCH_API_KEY:
|
|
return _tavily(query, k)
|
|
return []
|
|
|
|
|
|
def _tavily(query: str, k: int) -> list[dict]:
|
|
try:
|
|
resp = _client.post(
|
|
f"{config.SEARCH_BASE_URL.rstrip('/')}/search",
|
|
json={
|
|
"api_key": config.SEARCH_API_KEY,
|
|
"query": query,
|
|
"max_results": k,
|
|
"include_raw_content": True,
|
|
},
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
except Exception:
|
|
return []
|
|
|
|
out = []
|
|
for r in data.get("results", []) or []:
|
|
out.append({
|
|
"title": r.get("title") or r.get("url", ""),
|
|
"url": r.get("url", ""),
|
|
"snippet": r.get("content", "") or "",
|
|
"content": (r.get("raw_content") or r.get("content") or "")[:6000],
|
|
})
|
|
return out
|
|
|
|
|
|
def fetch_text(url: str) -> str:
|
|
"""抓取网页并抽取正文文本(失败返回空串)。"""
|
|
try:
|
|
resp = _client.get(url)
|
|
resp.raise_for_status()
|
|
ctype = resp.headers.get("content-type", "")
|
|
if "html" in ctype or ctype == "":
|
|
return html_to_text(resp.text)
|
|
if "text" in ctype or "json" in ctype:
|
|
return resp.text[:6000]
|
|
except Exception:
|
|
return ""
|
|
return ""
|