- paper_writer_service(8097):FastAPI + MCP streamable-http 单端点,9 工具
(propose_paper_outline/write_paper_section/get_paper_progress/search_refs/
revise_paper_section/revise_paper/get_paper/complete_paper/cancel_paper),
SQLite 三表(papers/paper_sections/citations,WAL),GB/T 7714 引用协议
- BFF:services/paper/{mcp_client,agent}.py(裸 JSON-RPC 客户端 + 规则状态机)、
routers/paper.py、schemas.py 4 个 Paper*Block + ArtifactBlock 加 paper_id/citation_count、
config.py 开关、main.py 条件注册、docgen.py 抽 insert_artifact 公共 helper
- 前端:intent.js paperIntent 纯函数、Composer docgen 分支 sub-route、sendPaper/轮询、
PaperBriefCard/PaperOutlineCard/PaperProgressCard、DocPane cite-chip
- deploy/paper_restart.sh + toggle_paper.sh;compile_sdk.sh 白名单追加 paper 文件
- tests/paper 26 条(工具逻辑 + MCP 协议 + BFF 状态机 + 块 schema + 前端 intent 单测)
102 lines
3.6 KiB
Python
102 lines
3.6 KiB
Python
"""search.py —— vstore 8094 知识库检索客户端(search_refs 后端)。
|
|
|
|
- httpx 裸调 GET /api/vstore/search-chunks
|
|
- 失败不抛异常:返回 {"ok": False, "error": {...}},工具层降级为空结果
|
|
- vstore chunk 条目无 doi/pmid/journal/year(仅 doc_id/doc_name/snippet/score),
|
|
故 search_refs 的 meta 为尽力而为(通常为空)→ 引用多为 title kind → 降级格式 + disclaimer
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
DEFAULT_VSTORE_BASE_URL = "http://127.0.0.1:8094"
|
|
RETRY_BACKOFF = (0.5, 1.0, 2.0)
|
|
MAX_RETRIES = 2
|
|
DEFAULT_TIMEOUT = 30.0
|
|
|
|
_client: httpx.AsyncClient | None = None
|
|
|
|
|
|
def _base_url() -> str:
|
|
return (os.environ.get("VSTORE_BASE_URL") or DEFAULT_VSTORE_BASE_URL).rstrip("/")
|
|
|
|
|
|
def get_client() -> httpx.AsyncClient:
|
|
global _client
|
|
if _client is None:
|
|
_client = httpx.AsyncClient(
|
|
base_url=_base_url(), timeout=httpx.Timeout(DEFAULT_TIMEOUT, connect=5.0)
|
|
)
|
|
return _client
|
|
|
|
|
|
def set_client(client: httpx.AsyncClient) -> None:
|
|
"""注入 client(测试用 MockTransport)。"""
|
|
global _client
|
|
_client = client
|
|
|
|
|
|
async def close_client() -> None:
|
|
global _client
|
|
if _client is not None:
|
|
await _client.aclose()
|
|
_client = None
|
|
|
|
|
|
async def search_chunks(
|
|
query: str,
|
|
*,
|
|
top_k: int = 8,
|
|
min_score: float = 0.30,
|
|
timeout: float = 12.0,
|
|
) -> dict[str, Any]:
|
|
"""GET /api/vstore/search-chunks。失败返回 {ok:False, error:{...}}(不抛)。"""
|
|
client = get_client()
|
|
last_exc: BaseException | None = None
|
|
for attempt in range(MAX_RETRIES + 1):
|
|
try:
|
|
resp = await client.get(
|
|
"/api/vstore/search-chunks",
|
|
params={"q": query, "top_k": top_k, "min_score": min_score},
|
|
timeout=timeout,
|
|
)
|
|
if resp.status_code < 500:
|
|
try:
|
|
payload = resp.json()
|
|
except ValueError:
|
|
return {"ok": False, "error": {"code": "BAD_RESPONSE", "message": "vstore 响应非 JSON"}}
|
|
if not isinstance(payload, dict):
|
|
return {"ok": False, "error": {"code": "BAD_RESPONSE", "message": "vstore 响应形态异常"}}
|
|
return payload
|
|
last_exc = RuntimeError(f"vstore {resp.status_code}")
|
|
except (httpx.HTTPError, httpx.TimeoutException) as exc:
|
|
last_exc = exc
|
|
if attempt < MAX_RETRIES:
|
|
await asyncio.sleep(RETRY_BACKOFF[min(attempt, len(RETRY_BACKOFF) - 1)])
|
|
return {"ok": False, "error": {"code": "UPSTREAM_DOWN", "message": f"vstore 不可达:{last_exc}"}}
|
|
|
|
|
|
async def search_refs(paper_id: str = "", query: str = "", top_k: int = 8) -> dict[str, Any]:
|
|
"""search_refs 工具:透传 vstore 检索,映射为 {doc_id,title,snippet,score,meta}。"""
|
|
if not query:
|
|
return {"ok": False, "code": "BAD_REQUEST", "message": "缺少检索词 query"}
|
|
r = await search_chunks(query, top_k=max(1, min(int(top_k), 20)))
|
|
if not r.get("ok"):
|
|
return {"ok": True, "items": [], "warning": (r.get("error") or {}).get("message", "")}
|
|
items: list[dict[str, Any]] = []
|
|
for it in r.get("items") or []:
|
|
if not isinstance(it, dict):
|
|
continue
|
|
items.append({
|
|
"doc_id": it.get("doc_id") or "",
|
|
"title": it.get("doc_name") or it.get("doc_id") or "",
|
|
"snippet": it.get("snippet") or "",
|
|
"score": it.get("score"),
|
|
"meta": {}, # vstore chunk 无 doi/pmid/journal/year,尽力为空
|
|
})
|
|
return {"ok": True, "items": items}
|