PlantAI-rag-new 首次提交:文献知识工作台

- BFF 薄适配层(FastAPI,8090):QA 代理/SSE 中继/Neo4j 直连/抽取代理/会话持久化
- Vue3 前端:五相位检索流水线、文献来源与证据链、知识图谱、提取任务四 tab
- 四档响应式(桌面/笔记本/平板/手机)、深浅主题
- 81 个 FR 全部实现,测试追溯矩阵 81/81,后端 121 测/前端 156 测
- 真实凭据已脱敏,运行时配置在 backend/.env(不入 git)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jerry
2026-08-25 20:47:52 +08:00
co-authored by Claude Fable 5
commit 1e94de7464
171 changed files with 26291 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# backend/.env.example —— BFF 全部配置键(pydantic-settings,键名大小写不敏感)
# 复制为 .env 后按需修改。以下默认值即 config.py 的默认值,本地开发可不改。
# ---- 应用 ----
# APP_NAME=plantai-rag-new-bff
# VERSION=0.1.0
# ---- 上游地址(只读消费)----
QA_BASE_URL=http://127.0.0.1:7920
QA_API_PATH=/api/v1/qa-v1-1-73
RAG_BASE_URL=http://127.0.0.1:8000
RAG_ALT_BASE_URL=http://127.0.0.1:7900
# ---- Neo4j 候选(顺序即回退顺序:18788 首选 → 19788 → 17687;凭据见 DISCOVERY.md §2)
# 注意:凭据只进 BFF 配置,绝不下发到浏览器。
NEO4J_BOLT_URIS=bolt://127.0.0.1:18788,bolt://127.0.0.1:19788,bolt://127.0.0.1:17687
NEO4J_USER=neo4j
NEO4J_PASSWORDS=<in backend/.env, 不入 git>
# ---- 超时阶梯(秒)----
TIMEOUT_HEALTH=1
TIMEOUT_QA_CHAT=300
TIMEOUT_QA_SEARCH=180
TIMEOUT_UPLOAD=600
TIMEOUT_EXTRACTION=15
# ---- 本地 ----
DB_PATH=backend/data/plantai.db
HEALTH_CACHE_TTL=5
# ---- 生产形态:BFF 静态托管 frontend/dist(M6 启用)----
FRONTEND_DIST_DIR=frontend/dist
+6
View File
@@ -0,0 +1,6 @@
# backend/.gitignore(最小版,各 agent 勿改,如需调整向 PM 提出)
__pycache__/
*.pyc
.pytest_cache/
.env
data/
View File
+65
View File
@@ -0,0 +1,65 @@
"""config.py —— 环境变量驱动的 BFF 配置(pydantic-settings)。
全部键见 backend/.env.example。凭据类键(Neo4j)只存在于本进程,
**绝不下发到浏览器**(不进入任何 API 响应)。
上游拓扑与默认值依据 docs/DISCOVERY.md:
- QA 7920:`/api/v1/qa-v1-1-73`(DISCOVERY §4)
- RAG 8000(Methodology API,DISCOVERY §3)/ ragAlt 7900(protected 容器,§3.7)
- Neo4j 候选顺序:18788(direct,首选)→ 19788(normalized)→ 17687(protected,空库)
凭据见 DISCOVERY §2.1,可由 BFF_NEO4J_* 环境变量覆盖(测试/部署)。
"""
from __future__ import annotations
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# ---- 应用 ----
app_name: str = "plantai-rag-new-bff"
version: str = "0.1.0"
# ---- 上游地址(只读消费,见 ARCHITECTURE.md §2)----
qa_base_url: str = "http://127.0.0.1:7920"
qa_api_path: str = "/api/v1/qa-v1-1-73"
rag_base_url: str = "http://127.0.0.1:8000"
rag_alt_base_url: str = "http://127.0.0.1:7900"
# ---- Neo4j 候选(顺序即回退顺序,首选 18788;凭据见 DISCOVERY §2.1)----
neo4j_bolt_uris: str = (
"bolt://127.0.0.1:18788,bolt://127.0.0.1:19788,bolt://127.0.0.1:17687"
)
neo4j_user: str = "neo4j"
neo4j_passwords: str = (
"" # 真实凭据在 backend/.env(不入 git),见 backend/.env.example
)
# ---- 超时阶梯(秒)----
timeout_health: float = 1.0 # 健康探测
timeout_qa_chat: float = 300.0 # qa chat(读超时)
timeout_qa_search: float = 180.0 # qa search(读超时)
timeout_upload: float = 600.0 # 抽取 upload(读超时)
timeout_extraction: float = 15.0 # 抽取其余接口
# ---- 本地 ----
db_path: str = "backend/data/plantai.db"
health_cache_ttl: float = 5.0
# ---- 生产形态:BFF 静态托管 frontend/dist(ARCHITECTURE.md §3,M6 挂载)----
frontend_dist_dir: str = "frontend/dist"
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()
settings = get_settings()
+163
View File
@@ -0,0 +1,163 @@
"""db.py —— SQLite 持久层(aiosqlite + WAL,无 ORM)。
Schema 与 API.md §5 对齐:
- sessions: id / title / status / meta / created_at / updated_at
- messages: id / session_id(FK CASCADE) / role / kind / status / blocks(JSON)
/ raw(JSON) / text / created_at / updated_at
启动时幂等迁移(CREATE TABLE IF NOT EXISTS)。时间一律 ISO 8601 UTC
(`2026-08-25T12:34:56Z`),由 BFF 侧统一生成(SQLite 不写 DEFAULT 时间戳)。
"""
from __future__ import annotations
import asyncio
import json
import time
from collections.abc import AsyncIterator, Iterable
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import aiosqlite
from .config import get_settings
_SCHEMA = """
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '新会话',
status TEXT NOT NULL DEFAULT 'idle',
meta TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
role TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'qa',
status TEXT NOT NULL DEFAULT 'done',
blocks TEXT NOT NULL DEFAULT '[]',
raw TEXT,
text TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at);
"""
def _now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _db_path() -> Path:
path = Path(get_settings().db_path)
path.parent.mkdir(parents=True, exist_ok=True)
return path
class Database:
"""aiosqlite 单连接封装。
SQLite 写并发不高,BFF 用单写连接(WAL 模式下读写不互斥);
依赖注入经 get_db() 提供本实例。
"""
def __init__(self, path: str | Path | None = None) -> None:
self._path: Path | None = Path(path) if path is not None else None
self._conn: aiosqlite.Connection | None = None
self._lock = asyncio.Lock()
@property
def path(self) -> Path:
if self._path is None:
self._path = _db_path()
return self._path
async def connect(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
self._conn = await aiosqlite.connect(self.path)
self._conn.row_factory = aiosqlite.Row
await self._conn.execute("PRAGMA journal_mode=WAL")
await self._conn.execute("PRAGMA foreign_keys=ON")
await self._conn.execute("PRAGMA busy_timeout=5000")
await self._conn.executescript(_SCHEMA)
await self._conn.commit()
async def close(self) -> None:
if self._conn is not None:
await self._conn.close()
self._conn = None
@property
def conn(self) -> aiosqlite.Connection:
if self._conn is None:
raise RuntimeError("Database 未初始化:先 await init_db()")
return self._conn
# ---- 通用查询(只读)----
async def fetchall(self, sql: str, params: Iterable[Any] = ()) -> list[aiosqlite.Row]:
async with self._conn.execute(sql, tuple(params)) as cursor:
return list(await cursor.fetchall())
async def fetchone(self, sql: str, params: Iterable[Any] = ()) -> aiosqlite.Row | None:
async with self._conn.execute(sql, tuple(params)) as cursor:
return await cursor.fetchone()
# ---- 写操作(串行化,提交后返回 lastrowid)----
async def execute(self, sql: str, params: Iterable[Any] = ()) -> int:
async with self._lock:
cur = await self._conn.execute(sql, tuple(params))
await self._conn.commit()
return cur.lastrowid
db = Database()
async def init_db(path: str | Path | None = None) -> Database:
"""app 启动钩子:创建并迁移数据库(幂等)。返回全局单例(或指定路径的实例)。"""
global db
if path is not None:
db = Database(path)
await db.connect()
return db
async def get_db() -> AsyncIterator[Database]:
"""FastAPI 依赖:每个请求交出同一数据库实例。"""
yield db
async def close_db() -> None:
"""app shutdown 钩子。"""
await db.close()
# ---- JSON 编解码(落库用;失败时按契约置 null/[] 而不吞请求)----
def dumps_json(value: Any, default: Any = None) -> str:
try:
return json.dumps(value, ensure_ascii=False)
except (TypeError, ValueError):
return json.dumps(default, ensure_ascii=False)
def loads_json(text: str | None, default: Any = None) -> Any:
if text is None:
return default
try:
return json.loads(text)
except (TypeError, ValueError):
return default
def monotonic() -> float:
"""测试/内部计时用(time.monotonic 别名,避免各处直接 import time)。"""
return time.monotonic()
+91
View File
@@ -0,0 +1,91 @@
"""main.py —— BFF 应用入口(ARCHITECTURE.md §2/§3)。
- 挂全部 5 个 router(qa/kg/extraction 为 A2/A3 实现,import 路径不变)。
- CORS:允许 http://localhost:5180 与 http://127.0.0.1:5180(Vite dev)。
- lifespan:init_db(幂等迁移);关闭回收 httpx 客户端与 Neo4j 连接。
- 全局 RequestValidationError → VALIDATION 信封(API.md §1,2026-08-25 PM 集成)。
- 生产形态(M6):frontend/dist 存在时静态托管(ARCHITECTURE.md §3)。
"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from .config import get_settings
from .db import close_db, init_db
from .routers import extraction, health, kg, qa, sessions
from .upstream import close_client
logging.basicConfig(level=logging.INFO)
_ALLOWED_ORIGINS = [
"http://localhost:5180",
"http://127.0.0.1:5180",
]
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(title=settings.app_name, version=settings.version)
app.add_middleware(
CORSMiddleware,
allow_origins=_ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(health.router)
app.include_router(sessions.router)
app.include_router(qa.router)
app.include_router(kg.router)
app.include_router(extraction.router)
@app.exception_handler(RequestValidationError)
async def _validation_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
# API.md §1 已裁决:参数校验错误收敛为统一 VALIDATION 信封(422)
return JSONResponse(
status_code=422,
content={
"ok": False,
"error": {"code": "VALIDATION", "message": "请求参数不合法", "degraded": False},
},
)
@asynccontextmanager
async def _lifespan(_app: FastAPI):
await init_db()
try:
yield
finally:
await close_client()
try:
from .services.neo4j import close_neo4j # A3 提供;缺失则跳过
await close_neo4j()
except ImportError:
pass
await close_db()
app.router.lifespan_context = _lifespan
# 生产形态:静态托管 frontend/dist(存在时挂载;本地开发期可无)。
# 2026-08-25 PM 修复:dist 路径必须基于项目根解析(BFF 工作目录是 backend/,
# 相对路径会解析成 backend/frontend/dist);sync.sh 排除 dist,需单独同步。
project_root = Path(__file__).resolve().parent.parent.parent
dist = project_root / settings.frontend_dist_dir if not Path(settings.frontend_dist_dir).is_absolute() else Path(settings.frontend_dist_dir)
if dist.is_dir():
app.mount("/", StaticFiles(directory=str(dist), html=True), name="frontend")
return app
app = create_app()
View File
+150
View File
@@ -0,0 +1,150 @@
"""routers/extraction.py —— 抽取任务代理(8000 主 + 7900 回落,API.md §10)。
- 上传仅限 PDF ≤50MB,否则 422 VALIDATION 信封。
- 双上游全不可达 → 503 UPSTREAM_DOWN 降级信封。
- 上游 5xx(如 MinerU 不可用)→ 502 UPSTREAM_ERROR。
错误信封与 mock_server 一致:直接返回 JSONResponse,信封在顶层。
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any, AsyncIterator
from fastapi import APIRouter, File, Form, Query, UploadFile
from fastapi.responses import JSONResponse
from ..services import extraction as _extraction
from ..services.extraction import (
MAX_PDF_BYTES,
ExtractionUnavailable,
ExtractionUpstreamError,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/extraction", tags=["extraction"])
_DOWN_MSG = "抽取服务当前不可用,请稍后重试"
_MINERU_MSG = "抽取任务提交失败:上游 MinerU 服务不可用,请检查抽取环境后重试"
def _envelope(code: str, message: str, degraded: bool, status: int) -> JSONResponse:
return JSONResponse(
status_code=status,
content={
"ok": False,
"error": {"code": code, "message": message, "degraded": degraded},
},
)
def _down(exc: BaseException | None = None) -> JSONResponse:
logger.warning("extraction: 上游不可用:%s", exc)
return _envelope("UPSTREAM_DOWN", _DOWN_MSG, True, 503)
def _upstream_error(message: str) -> JSONResponse:
logger.warning("extraction: 上游错误:%s", message)
return _envelope("UPSTREAM_ERROR", message, True, 502)
def _validation(message: str) -> JSONResponse:
return _envelope("VALIDATION", message, False, 422)
def _check_pdf_upload(filename: str, size: int) -> JSONResponse | None:
"""FR-EXTRACT-004:仅 PDF,单文件 ≤50MB。合法返回 None。"""
if size <= 0:
return _validation("上传文件为空")
if size > MAX_PDF_BYTES:
return _validation("文件超过 50MB 限制")
if not filename.lower().endswith(".pdf"):
return _validation("仅支持 PDF 文件")
return None
async def _stream_upload(file: UploadFile) -> AsyncIterator[bytes]:
try:
while chunk := await file.read(1024 * 1024): # 1MB
yield chunk
finally:
await file.close()
@router.get("/jobs")
async def jobs(limit: int = Query(20, ge=1, le=200)) -> dict[str, Any]:
try:
return await _extraction.service.list_jobs(limit=limit)
except ExtractionUnavailable as exc:
return _down(exc)
@router.get("/jobs/{job_id}")
async def job_detail(job_id: str) -> dict[str, Any]:
try:
return await _extraction.service.get_job(job_id)
except ExtractionUnavailable as exc:
return _down(exc)
@router.get("/processing-status")
async def processing_status() -> dict[str, Any]:
try:
return await _extraction.service._passthrough("/processing-status")
except ExtractionUnavailable as exc:
return _down(exc)
@router.get("/progress")
async def progress() -> dict[str, Any]:
try:
return await _extraction.service._passthrough("/progress")
except ExtractionUnavailable as exc:
return _down(exc)
@router.get("/summary")
async def summary() -> dict[str, Any]:
try:
return await _extraction.service._passthrough("/summary")
except ExtractionUnavailable as exc:
return _down(exc)
@router.get("/run-log")
async def run_log() -> dict[str, Any]:
try:
return await _extraction.service._passthrough("/run-log")
except ExtractionUnavailable as exc:
return _down(exc)
@router.post("/upload")
async def upload(
file: UploadFile = File(...),
paper_id: str = Form("uploaded_paper"),
family: str = Form(""),
output_name: str = Form(""),
) -> dict[str, Any]:
filename = file.filename or ""
size = file.size or 0
invalid = _check_pdf_upload(filename, size)
if invalid is not None:
return invalid
try:
return await _extraction.service.upload(
filename=Path(filename).name,
stream=_stream_upload(file),
content_type=file.content_type or "application/pdf",
paper_id=paper_id,
family=family,
output_name=output_name,
)
except ExtractionUnavailable as exc:
return _down(exc)
except ExtractionUpstreamError as exc:
return _upstream_error(f"{_MINERU_MSG}({exc})")
except Exception as exc:
logger.exception("extraction: 上传失败")
return _upstream_error(f"提交抽取任务失败:{exc}")
+35
View File
@@ -0,0 +1,35 @@
"""routers/health.py —— GET /api/health(API.md §3)。
并发探测 7920、8000、7900 与 Neo4j(1s 超时,5s 缓存),外加 db 状态。
返回 `{status, version, upstreams, db}`;健康接口不包错误信封(API.md §1 例外)。
"""
from __future__ import annotations
from fastapi import APIRouter
from ..config import get_settings
from ..db import db
from ..upstream import probe_all
router = APIRouter(prefix="/api", tags=["health"])
async def _db_status() -> dict:
try:
rows = await db.fetchall("SELECT COUNT(*) AS n FROM sessions")
return {"ok": True, "sessions": rows[0]["n"] if rows else 0}
except Exception:
return {"ok": False, "sessions": 0}
@router.get("/health")
async def health() -> dict:
upstreams = await probe_all()
db_status = await _db_status()
degraded = not all(u.get("ok") for u in upstreams.values()) or not db_status.get("ok")
return {
"status": "degraded" if degraded else "ok",
"version": get_settings().version,
"upstreams": upstreams,
"db": db_status,
}
+105
View File
@@ -0,0 +1,105 @@
"""routers/kg.py —— 知识图谱浏览(BFF Bolt 直连 Neo4j,API.md §6)。
Neo4j 不可用 → 503 UPSTREAM_DOWN 降级信封;未命中实体 → 404 NOT_FOUND。
错误信封与 mock_server 一致:直接返回 JSONResponse,信封在顶层
(不用 HTTPException,避免被 FastAPI 包成 {"detail": {...}})。
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Query
from fastapi.responses import JSONResponse
from ..services import neo4j as _neo4j
from ..services.neo4j import Neo4jUnavailable
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/kg", tags=["kg"])
_UPSTREAM_DOWN_MSG = "知识图谱服务当前不可用,请稍后重试"
def _down(exc: BaseException | None = None) -> JSONResponse:
logger.warning("kg: Neo4j 不可用:%s", exc)
return JSONResponse(
status_code=503,
content={
"ok": False,
"error": {
"code": "UPSTREAM_DOWN",
"message": _UPSTREAM_DOWN_MSG,
"degraded": True,
},
},
)
def _not_found() -> JSONResponse:
return JSONResponse(
status_code=404,
content={
"ok": False,
"error": {
"code": "NOT_FOUND",
"message": "未找到该实体",
"degraded": False,
},
},
)
@router.get("/status")
async def kg_status():
try:
return await _neo4j.service.kg_status()
except Neo4jUnavailable as exc:
return _down(exc)
@router.get("/overview")
async def kg_overview():
try:
return await _neo4j.service.kg_overview()
except Neo4jUnavailable as exc:
return _down(exc)
@router.get("/entities")
async def kg_entities(
q: str = Query("", description="实体名(name_norm 精确或前缀匹配)"),
limit: int = Query(20, ge=1, le=20),
) -> dict[str, Any]:
try:
results = await _neo4j.service.search_entities(q, limit=limit)
except Neo4jUnavailable as exc:
return _down(exc)
return {"query": q, "results": results}
@router.get("/entities/{entity_id}")
async def kg_entity(entity_id: str) -> dict[str, Any]:
try:
entity = await _neo4j.service.entity(entity_id)
except Neo4jUnavailable as exc:
return _down(exc)
if entity is None:
return _not_found()
return entity
@router.get("/entities/{entity_id}/neighborhood")
async def kg_neighborhood(
entity_id: str,
depth: int = Query(1, ge=1, le=2),
limit: int = Query(50, ge=1, le=200),
) -> dict[str, Any]:
try:
result = await _neo4j.service.neighborhood(entity_id, depth=depth, limit=limit)
except Neo4jUnavailable as exc:
return _down(exc)
if result.get("center") is None:
return _not_found()
return result
+211
View File
@@ -0,0 +1,211 @@
"""routers/qa.py —— /api/qa/* :7920 代理 + 归一化 + SSE 中继(A2 归属,API.md §3/§4)。
端点:
- GET /api/qa/info 上游 /info 精简(name/version/pipeline/config)
- POST /api/qa/chat 上游 /chat,300s 读超时,invalid_llm_citation 自动重试一次
- POST /api/qa/chat/stream SSE 中继(app.services.sse_relay.relay_stream)
- POST /api/qa/search 上游 /search,强制 use_llm:false,180s 读超时,不重试,
响应 mode:"search"、answer:""
请求体一律透传(前端字段集见 API.md §4,BFF 不增删字段)。
"""
from __future__ import annotations
import json
import logging
from typing import Any, Optional
import httpx
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse
from ..services import sse_relay
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/qa", tags=["qa"])
# 超时阶梯(TODO(A2→A1 对齐点):A1 的 app.config.settings 就绪后改为配置读取)
CHAT_READ_TIMEOUT = 300.0
SEARCH_READ_TIMEOUT = 180.0
INFO_READ_TIMEOUT = 5.0
CONNECT_TIMEOUT = 10.0
# ---------------------------------------------------------------------------
# 错误信封(TODO(A2→A1 对齐点):HTTP JSON 信封换用 app.upstream.envelope())
# ---------------------------------------------------------------------------
MESSAGES = {
"UPSTREAM_DOWN": "知识问答服务当前不可用,请稍后重试",
"UPSTREAM_ERROR": "知识问答服务返回错误",
"TIMEOUT": "检索超时",
"INVALID_CITATION": "引用校验失败,请重试",
"VALIDATION": "请求参数不合法",
"INTERNAL": "内部错误",
}
def _envelope(code: str, status: int, message: Optional[str] = None, degraded: Optional[bool] = None) -> JSONResponse:
if degraded is None:
degraded = code == "UPSTREAM_DOWN"
return JSONResponse(
status_code=status,
content={"ok": False, "error": {"code": code, "message": message or MESSAGES[code], "degraded": degraded}},
)
# ---------------------------------------------------------------------------
# 上游调用
# ---------------------------------------------------------------------------
def _upstream_path(endpoint: str) -> str:
return f"{sse_relay.QA_UPSTREAM_PREFIX}/{endpoint}"
def _read_timeout(seconds: float) -> httpx.Timeout:
return httpx.Timeout(connect=CONNECT_TIMEOUT, read=seconds, write=30.0, pool=CONNECT_TIMEOUT)
async def _parse_json(text: Optional[bytes]) -> Any:
if not text:
return None
try:
return json.loads(text.decode("utf-8", errors="replace"))
except Exception:
return None
def _error_from_status(status: int, payload: Any) -> tuple[str, int, Optional[str]]:
"""上游 HTTP≥400 → (code, bff_http, message)。HTTP 按 API.md §1 固定映射,不透传上游状态码。"""
if sse_relay.is_invalid_citation(payload):
return "INVALID_CITATION", 502, None
if status in (408, 504):
return "TIMEOUT", 504, None
detail = payload.get("detail") if isinstance(payload, dict) else None
if isinstance(detail, dict):
message = detail.get("message") or detail.get("error")
return "UPSTREAM_ERROR", 502, message
if isinstance(payload, dict):
message = payload.get("message") or payload.get("error")
return "UPSTREAM_ERROR", 502, message
return "UPSTREAM_ERROR", 502, None
async def _proxy(
endpoint: str,
body: Optional[dict] = None,
*,
read_timeout: float,
retry_invalid_citation: bool,
mode: str,
normalizer=None,
) -> Any:
"""非流式上游代理:重试、错误信封、归一化。返回 (JSONResponse)。"""
client = sse_relay.get_qa_client()
normalizer = normalizer or sse_relay.get_qa_normalizer()
url = f"{sse_relay.qa_base_url()}{_upstream_path(endpoint)}"
timeout = _read_timeout(read_timeout)
json_body = body if body is not None else {}
for attempt in (1, 2):
try:
resp = await client.request("POST", url, json=json_body, timeout=timeout)
except httpx.ReadTimeout:
return _envelope("TIMEOUT", 504)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout) as exc:
logger.warning("上游 %s 连接失败: %s", endpoint, exc)
return _envelope("UPSTREAM_DOWN", 503)
except httpx.HTTPError as exc:
logger.warning("上游 %s 请求失败: %s", endpoint, exc)
return _envelope("UPSTREAM_ERROR", 502)
payload = await _parse_json(resp.content)
if resp.status_code >= 400:
if (
retry_invalid_citation
and attempt == 1
and sse_relay.is_invalid_citation(payload)
):
logger.warning(
"上游 %s 返回 %d invalid_llm_citation,自动重试(第 1 次)", endpoint, resp.status_code
)
continue
code, bff_status, message = _error_from_status(resp.status_code, payload)
return _envelope(code, bff_status, message)
try:
normalized = normalizer(payload if isinstance(payload, dict) else {})
except Exception:
logger.exception("归一化上游 %s 响应失败", endpoint)
return _envelope("INTERNAL", 500)
normalized["mode"] = mode
if mode == "search":
normalized["answer"] = ""
return JSONResponse(content=normalized)
# 不可达(重试循环内必然 return);防御性兜底
return _envelope("UPSTREAM_ERROR", 502)
# ---------------------------------------------------------------------------
# 端点
# ---------------------------------------------------------------------------
@router.get("/info")
async def qa_info():
"""上游 /info 精简(API.md §3)。"""
client = sse_relay.get_qa_client()
url = f"{sse_relay.qa_base_url()}{_upstream_path('info')}"
try:
resp = await client.get(url, timeout=_read_timeout(INFO_READ_TIMEOUT))
except httpx.ReadTimeout:
return _envelope("TIMEOUT", 504)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout) as exc:
logger.warning("上游 /info 连接失败: %s", exc)
return _envelope("UPSTREAM_DOWN", 503)
except httpx.HTTPError as exc:
logger.warning("上游 /info 请求失败: %s", exc)
return _envelope("UPSTREAM_ERROR", 502)
payload = await _parse_json(resp.content)
if resp.status_code >= 400 or not isinstance(payload, dict):
return _envelope("UPSTREAM_ERROR", 502)
info = payload.get("info") if isinstance(payload.get("info"), dict) else payload
return JSONResponse(
content={
"name": info.get("name") or payload.get("name"),
"version": info.get("version") or payload.get("version"),
"pipeline": info.get("pipeline") or payload.get("pipeline") or [],
"config": info.get("config") or payload.get("config") or {},
}
)
@router.post("/chat")
async def qa_chat(request: Request):
"""非流式问答:上游 /chat + 归一化 + invalid_llm_citation 重试一次。"""
body = await request.json()
return await _proxy(
"chat", body, read_timeout=CHAT_READ_TIMEOUT, retry_invalid_citation=True, mode="chat"
)
@router.post("/chat/stream")
async def qa_chat_stream(request: Request):
"""SSE 中继(API.md §4)。"""
try:
body = await request.json()
except Exception:
return _envelope("VALIDATION", 422)
return StreamingResponse(
sse_relay.relay_stream(body),
media_type="text/event-stream",
headers=sse_relay.sse_headers,
)
@router.post("/search")
async def qa_search(request: Request):
"""检索模式:强制 use_llm:false,180s 读超时,不重试。"""
body = await request.json()
body = {**body, "use_llm": False}
return await _proxy(
"search", body, read_timeout=SEARCH_READ_TIMEOUT, retry_invalid_citation=False, mode="search"
)
+319
View File
@@ -0,0 +1,319 @@
"""routers/sessions.py —— 会话与消息 CRUD + 会话级来源聚合(API.md §5)。
- GET/POST /api/sessions —— 列表(updated_at 倒序)/ 创建(title 可空,默认「新会话」)
- GET/PATCH/DELETE /api/sessions/{id} —— 详情(含 messages)/ 更新(title|status)/ 删除(204,级联消息)
- POST /api/sessions/{id}/messages —— 用户消息({role:"user", text})或助手流式骨架
({role:"assistant", kind, status:"streaming", blocks:[…]})
- PATCH /api/sessions/{id}/messages/{mid} —— 局部更新 {status, blocks, raw}(流式检查点)
- GET /api/sessions/{id}/sources —— 全会话来源聚合:按 paper_id 去重(无 paper_id 按 id),
带 count(出现次数)。
404 → NOT_FOUND 信封;时间 ISO 8601 UTC。
"""
from __future__ import annotations
import logging
import uuid
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, Response, status as http_status
from fastapi.responses import JSONResponse
from ..db import Database, dumps_json, get_db, loads_json
from ..schemas import (
MessageCreate,
MessageOut,
MessagePatch,
SessionCreate,
SessionDetail,
SessionOut,
SessionPatch,
)
from ..upstream import envelope
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
_SESSION_SELECT = (
"SELECT s.id, s.title, s.status, s.meta, s.created_at, s.updated_at, "
"(SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) AS message_count "
"FROM sessions s"
)
_MESSAGE_SELECT = (
"SELECT id, session_id, role, kind, status, blocks, raw, text, created_at, updated_at "
"FROM messages"
)
def _db_now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _row_to_session(row: Any) -> SessionOut:
return SessionOut(
id=row["id"],
title=row["title"],
status=row["status"],
created_at=row["created_at"],
updated_at=row["updated_at"],
message_count=row["message_count"],
meta=loads_json(row["meta"], default={"title_manual": False}),
)
def _row_to_message(row: Any) -> MessageOut:
return MessageOut(
id=row["id"],
role=row["role"],
kind=row["kind"],
status=row["status"],
blocks=loads_json(row["blocks"], default=[]),
raw=loads_json(row["raw"], default=None),
text=row["text"],
created_at=row["created_at"],
updated_at=row["updated_at"],
)
async def _get_session_or_none(
db: Database, session_id: str, *, with_count: bool = True
) -> SessionOut | None:
sql = _SESSION_SELECT + " WHERE s.id = ?"
row = await db.fetchone(sql, (session_id,))
return _row_to_session(row) if row else None
async def _get_message_or_none(
db: Database, session_id: str, message_id: str
) -> MessageOut | None:
row = await db.fetchone(
_MESSAGE_SELECT + " WHERE id = ? AND session_id = ?", (message_id, session_id)
)
return _row_to_message(row) if row else None
# ---------------------------------------------------------------------------
# 会话
# ---------------------------------------------------------------------------
@router.get("", response_model=list[SessionOut])
async def list_sessions(db: Database = Depends(get_db)) -> list[SessionOut]:
rows = await db.fetchall(_SESSION_SELECT + " ORDER BY s.updated_at DESC")
return [_row_to_session(r) for r in rows]
@router.post("", response_model=SessionOut, status_code=http_status.HTTP_201_CREATED)
async def create_session(
payload: SessionCreate, db: Database = Depends(get_db)
) -> SessionOut:
now = _db_now()
session_id = str(uuid.uuid4())
title = payload.normalized_title()
await db.execute(
"INSERT INTO sessions (id, title, status, meta, created_at, updated_at) "
"VALUES (?, ?, 'idle', ?, ?, ?)",
(session_id, title, dumps_json({"title_manual": False}), now, now),
)
out = await _get_session_or_none(db, session_id)
assert out is not None # 刚插入必存在
return out
@router.get("/{session_id}", response_model=SessionDetail)
async def get_session(session_id: str, db: Database = Depends(get_db)):
session = await _get_session_or_none(db, session_id)
if session is None:
return _not_found(session_id)
rows = await db.fetchall(
_MESSAGE_SELECT + " WHERE session_id = ? ORDER BY created_at ASC", (session_id,)
)
return SessionDetail(**session.model_dump(), messages=[_row_to_message(r) for r in rows])
@router.patch("/{session_id}", response_model=SessionOut)
async def patch_session(
session_id: str, payload: SessionPatch, db: Database = Depends(get_db)
):
session = await _get_session_or_none(db, session_id)
if session is None:
return _not_found(session_id)
title = payload.title.strip() if payload.title is not None else None
status = payload.status
if title:
meta = session.meta.model_dump()
meta["title_manual"] = True
await db.execute(
"UPDATE sessions SET title = ?, meta = ?, updated_at = ? WHERE id = ?",
(title, dumps_json(meta), _db_now(), session_id),
)
if status is not None:
await db.execute(
"UPDATE sessions SET status = ?, updated_at = ? WHERE id = ?",
(status, _db_now(), session_id),
)
out = await _get_session_or_none(db, session_id)
assert out is not None
return out
@router.delete("/{session_id}", status_code=http_status.HTTP_204_NO_CONTENT)
async def delete_session(session_id: str, db: Database = Depends(get_db)) -> Response:
session = await _get_session_or_none(db, session_id)
if session is None:
return _not_found_response(session_id)
await db.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
return Response(status_code=http_status.HTTP_204_NO_CONTENT)
# ---------------------------------------------------------------------------
# 消息
# ---------------------------------------------------------------------------
@router.post("/{session_id}/messages", response_model=MessageOut, status_code=201)
async def create_message(
session_id: str, payload: MessageCreate, db: Database = Depends(get_db)
):
session = await _get_session_or_none(db, session_id)
if session is None:
return _not_found(session_id)
now = _db_now()
message_id = str(uuid.uuid4())
if payload.role == "user":
text = (payload.text or "").strip()
await db.execute(
"INSERT INTO messages (id, session_id, role, kind, status, blocks, raw, text, "
"created_at, updated_at) VALUES (?, ?, 'user', ?, 'done', '[]', NULL, ?, ?, ?)",
(message_id, session_id, payload.kind, text, now, now),
)
else:
# 助手流式骨架:text 置空,blocks 落库初始块
blocks = [b.model_dump() for b in payload.blocks]
await db.execute(
"INSERT INTO messages (id, session_id, role, kind, status, blocks, raw, text, "
"created_at, updated_at) VALUES (?, ?, 'assistant', ?, ?, ?, NULL, NULL, ?, ?)",
(message_id, session_id, payload.kind, payload.status or "streaming",
dumps_json(blocks), now, now),
)
# 消息活动 → 会话 updated_at 前移、idle → running
await db.execute(
"UPDATE sessions SET status = CASE WHEN status = 'idle' THEN 'running' ELSE status END, "
"updated_at = ? WHERE id = ?",
(now, session_id),
)
out = await _get_message_or_none(db, session_id, message_id)
assert out is not None
return out
@router.patch("/{session_id}/messages/{message_id}", response_model=MessageOut)
async def patch_message(
session_id: str,
message_id: str,
payload: MessagePatch,
db: Database = Depends(get_db),
):
message = await _get_message_or_none(db, session_id, message_id)
if message is None:
return _not_found(session_id, message_id)
sets: list[str] = []
params: list[Any] = []
if payload.status is not None:
sets.append("status = ?")
params.append(payload.status)
if payload.blocks is not None:
sets.append("blocks = ?")
params.append(dumps_json([b.model_dump() for b in payload.blocks]))
if payload.raw is not None:
sets.append("raw = ?")
params.append(dumps_json(payload.raw))
if sets:
sets.append("updated_at = ?")
params.append(_db_now())
params.append(message_id)
await db.execute(
f"UPDATE messages SET {', '.join(sets)} WHERE id = ?", params
)
# 同步前移会话 updated_at
await db.execute(
"UPDATE sessions SET updated_at = ? WHERE id = ?",
(_db_now(), session_id),
)
out = await _get_message_or_none(db, session_id, message_id)
assert out is not None
return out
# ---------------------------------------------------------------------------
# 会话来源聚合
# ---------------------------------------------------------------------------
@router.get("/{session_id}/sources")
async def get_session_sources(session_id: str, db: Database = Depends(get_db)):
"""按 paper_id 去重(无 paper_id 时按 id),带 count(出现次数)。
来源来自该会话全部助手消息 blocks 里的 sources 块(§2 块联合)。
"""
session = await _get_session_or_none(db, session_id)
if session is None:
return _not_found(session_id)
rows = await db.fetchall(
"SELECT blocks FROM messages WHERE session_id = ? AND role = 'assistant'",
(session_id,),
)
def is_sources_block(b: Any) -> bool:
return isinstance(b, dict) and b.get("type") == "sources" and isinstance(b.get("items"), list)
def key_of(item: dict[str, Any]) -> str:
return str(item.get("paper_id") or item.get("id") or "")
agg: dict[str, dict[str, Any]] = {}
order: list[str] = []
for row in rows:
blocks = loads_json(row["blocks"], default=[])
for block in blocks:
if not is_sources_block(block):
continue
for item in block["items"]:
if not isinstance(item, dict):
continue
key = key_of(item)
if not key:
continue
if key not in agg:
agg[key] = dict(item)
agg[key]["count"] = 0
order.append(key)
agg[key]["count"] += 1
sources = [agg[k] for k in order]
return {"session_id": session_id, "total": sum(s["count"] for s in sources), "sources": sources}
# ---------------------------------------------------------------------------
# 404 帮助
# ---------------------------------------------------------------------------
def _not_found(session_id: str, message_id: str | None = None) -> JSONResponse:
detail = f"会话 {session_id} 不存在" if message_id is None else f"消息 {message_id} 不存在"
body, status = envelope("NOT_FOUND", message=detail, status=404)
return JSONResponse(status_code=status, content=body)
def _not_found_response(session_id: str) -> JSONResponse:
return _not_found(session_id)
+207
View File
@@ -0,0 +1,207 @@
"""routers/test_kg.py —— kg 路由单元测试。
用假 Neo4jService 替换 services.neo4j.service(monkeypatch),覆盖:
五个端点成功形态、Neo4j 不可用 → 503 UPSTREAM_DOWN、未命中实体 → 404
NOT_FOUND、参数校验(limit ≤ 20、depth 1-2)。
"""
from __future__ import annotations
import httpx
import pytest
from fastapi import FastAPI
from app.routers import kg as kg_router
from app.services import neo4j as neo4j_mod
from app.services.neo4j import Neo4jUnavailable
class FakeService:
"""方法逐项可配的假服务。"""
def __init__(self, **overrides):
self.calls: dict[str, list] = {}
self.overrides = overrides
async def kg_status(self):
return self.overrides.get("kg_status", {"ok": True, "active": "x"})
async def kg_overview(self):
return self.overrides.get("kg_overview", {"entities_by_type": []})
async def search_entities(self, q, limit=20):
self.calls.setdefault("search_entities", []).append((q, limit))
return self.overrides.get(
"search_entities",
[{"id": "cent_ospl14", "name": "OsSPL14", "type": "gene", "support_count": 18}],
)
async def entity(self, entity_id):
self.calls.setdefault("entity", []).append(entity_id)
return self.overrides.get(
"entity",
{"id": "cent_ospl14", "name": "OsSPL14", "type": "gene", "properties": {}},
)
async def neighborhood(self, entity_id, depth=1, limit=200):
self.calls.setdefault("neighborhood", []).append((entity_id, depth, limit))
return self.overrides.get(
"neighborhood",
{
"center": "cent_ospl14",
"center_name": "OsSPL14",
"depth": depth,
"nodes": [{"id": "cent_ospl14", "label": "OsSPL14", "type": "gene"}],
"edges": [],
},
)
@pytest.fixture
def fake_service(monkeypatch):
fake = FakeService()
monkeypatch.setattr(neo4j_mod, "service", fake)
return fake
@pytest.fixture
def client():
# 用真实 FastAPI() 包装:自动获得 ExceptionMiddleware + AsyncExitStackMiddleware,
# 与生产挂载形态一致(生产 main.py 把 kg.router include 进 FastAPI 实例)。
app = FastAPI()
app.include_router(kg_router.router)
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=app, raise_app_exceptions=False),
base_url="http://test",
)
async def test_status_ok(fake_service, client):
fake_service.overrides["kg_status"] = {
"ok": True,
"active": "neo4j-plant-srt-direct-v1-full",
"schema": "canonical",
"labels": ["SRT2CanonicalEntity", "SRT2CanonicalAssertion", "SRT2Paper"],
"relationship_types": ["SRT2_CANONICAL_ASSERTS", "SRT2_SUPPORTED_BY"],
"entity_count": 3820996,
"assertion_count": 8246875,
"name_index": "srt2_canonical_entity_name_norm",
"fulltext_index": None,
}
resp = await client.get("/api/kg/status")
assert resp.status_code == 200
assert resp.json()["entity_count"] == 3820996
async def test_status_down_returns_503_envelope(fake_service, client):
async def boom():
raise Neo4jUnavailable("all down")
fake_service.kg_status = boom
resp = await client.get("/api/kg/status")
assert resp.status_code == 503
body = resp.json()
assert body["ok"] is False
assert body["error"]["code"] == "UPSTREAM_DOWN"
assert body["error"]["degraded"] is True
async def test_overview_ok(fake_service, client):
fake_service.overrides["kg_overview"] = {
"entities_by_type": [{"type": "gene", "count": 43210}],
"relations_by_category": [{"type": "characterized_in", "count": 12033}],
}
resp = await client.get("/api/kg/overview")
assert resp.status_code == 200
body = resp.json()
assert body["entities_by_type"][0]["type"] == "gene"
assert body["relations_by_category"][0]["count"] == 12033
async def test_overview_down_returns_503(fake_service, client):
async def boom():
raise Neo4jUnavailable("down")
fake_service.kg_overview = boom
resp = await client.get("/api/kg/overview")
assert resp.status_code == 503
assert resp.json()["error"]["code"] == "UPSTREAM_DOWN"
async def test_entities_ok_and_params_passed(fake_service, client):
resp = await client.get("/api/kg/entities", params={"q": "OsSPL14", "limit": 5})
assert resp.status_code == 200
body = resp.json()
assert body["query"] == "OsSPL14"
assert body["results"][0]["id"] == "cent_ospl14"
assert fake_service.calls["search_entities"] == [("OsSPL14", 5)]
async def test_entities_limit_over_20_rejected(client):
resp = await client.get("/api/kg/entities", params={"q": "OsSPL14", "limit": 21})
assert resp.status_code == 422 # FastAPI 参数校验(VALIDATION 语义由 A1 统一处理)
async def test_entities_down_returns_503(fake_service, client):
async def boom(q, limit=20):
raise Neo4jUnavailable("down")
fake_service.search_entities = boom
resp = await client.get("/api/kg/entities", params={"q": "x"})
assert resp.status_code == 503
assert resp.json()["error"]["code"] == "UPSTREAM_DOWN"
async def test_entity_ok(fake_service, client):
resp = await client.get("/api/kg/entities/cent_ospl14")
assert resp.status_code == 200
body = resp.json()
assert body["id"] == "cent_ospl14"
assert body["name"] == "OsSPL14"
assert fake_service.calls["entity"] == ["cent_ospl14"]
async def test_entity_miss_returns_404(fake_service, client):
fake_service.overrides["entity"] = None
resp = await client.get("/api/kg/entities/NoSuch")
assert resp.status_code == 404
body = resp.json()
assert body["ok"] is False
assert body["error"]["code"] == "NOT_FOUND"
assert body["error"]["degraded"] is False
async def test_entity_down_returns_503(fake_service, client):
async def boom(entity_id):
raise Neo4jUnavailable("down")
fake_service.entity = boom
resp = await client.get("/api/kg/entities/cent_ospl14")
assert resp.status_code == 503
assert resp.json()["error"]["code"] == "UPSTREAM_DOWN"
async def test_neighborhood_ok_and_params(fake_service, client):
resp = await client.get(
"/api/kg/entities/cent_ospl14/neighborhood", params={"depth": 2, "limit": 100}
)
assert resp.status_code == 200
body = resp.json()
assert body["center"] == "cent_ospl14"
assert body["center_name"] == "OsSPL14"
assert body["depth"] == 2
assert fake_service.calls["neighborhood"] == [("cent_ospl14", 2, 100)]
async def test_neighborhood_defaults_depth1_limit50(fake_service, client):
resp = await client.get("/api/kg/entities/cent_ospl14/neighborhood")
assert resp.status_code == 200
assert fake_service.calls["neighborhood"] == [("cent_ospl14", 1, 50)]
async def test_neighborhood_depth_out_of_range_rejected(client):
resp = await client.get("/api/kg/entities/cent_ospl14/neighborhood", params={"depth": 3})
assert resp.status_code == 422
async def test_neighborhood_miss_returns_404(fake_service, client):
fake_service.overrides["neighborhood"] = {
"center": None, "center_name": None, "depth": 1, "nodes": [], "edges": []
}
resp = await client.get("/api/kg/entities/NoSuch/neighborhood")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == "NOT_FOUND"
async def test_neighborhood_down_returns_503(fake_service, client):
async def boom(entity_id, depth=1, limit=200):
raise Neo4jUnavailable("down")
fake_service.neighborhood = boom
resp = await client.get("/api/kg/entities/cent_ospl14/neighborhood")
assert resp.status_code == 503
assert resp.json()["error"]["code"] == "UPSTREAM_DOWN"
+260
View File
@@ -0,0 +1,260 @@
"""test_qa.py —— /api/qa/* 路由单元测试(A2 归属)。
通过 httpx.MockTransport 注入假上游(把 sse_relay.get_qa_client 替换为指向
MockTransport 的 client),不依赖真实 7920。
"""
from __future__ import annotations
import json
import httpx
import pytest
from starlette.testclient import TestClient
from app.main import app
from app.services import sse_relay
def _transport_handler(handler) -> httpx.AsyncClient:
"""以 MockTransport 构造一个 client。"""
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
@pytest.fixture
def bff_client():
return TestClient(app)
UPSTREAM_INFO = {
"name": "QA V1.1.73 Assertion-Anchored Full Graph",
"version": "1.1.73-assertion-anchored",
"pipeline": ["question", "provisional answer"],
"config": {"max_evidence": 40, "bge_rerank": True},
}
def _json_response(payload, status=200) -> httpx.Response:
return httpx.Response(status, json=payload)
def test_info_passthrough_trimmed(bff_client, monkeypatch):
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "GET"
assert request.url.path.endswith("/api/v1/qa-v1-1-73/info")
return _json_response(UPSTREAM_INFO)
client = _transport_handler(handler)
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
resp = bff_client.get("/api/qa/info")
assert resp.status_code == 200
data = resp.json()
assert data == UPSTREAM_INFO
assert set(data.keys()) == {"name", "version", "pipeline", "config"}
def test_info_upstream_down_503(bff_client, monkeypatch):
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("down", request=request)
client = _transport_handler(handler)
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
resp = bff_client.get("/api/qa/info")
assert resp.status_code == 503
data = resp.json()
assert data["ok"] is False
assert data["error"]["code"] == "UPSTREAM_DOWN"
assert data["error"]["degraded"] is True
def test_info_wrapped_info_field_tolerated(bff_client, monkeypatch):
def handler(request: httpx.Request) -> httpx.Response:
return _json_response({"info": UPSTREAM_INFO})
client = _transport_handler(handler)
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
resp = bff_client.get("/api/qa/info")
assert resp.status_code == 200
assert resp.json()["name"] == UPSTREAM_INFO["name"]
def test_chat_ok_normalized(bff_client, monkeypatch):
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request)
assert request.url.path.endswith("/api/v1/qa-v1-1-73/chat")
return _json_response({"answer": "some answer", "sources": []})
client = _transport_handler(handler)
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
body = {"question": "水稻SPL?", "use_llm": True, "chain_top_k": 3}
resp = bff_client.post("/api/qa/chat", json=body)
assert resp.status_code == 200
data = resp.json()
assert data["mode"] == "chat"
assert data["answer"] == "some answer"
# 请求体透传,不增删字段
sent = json.loads(calls[0].content)
assert sent == body
def test_chat_invalid_citation_retries_once(bff_client, monkeypatch):
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request)
if len(calls) == 1:
return _json_response({"detail": {"error_code": "invalid_llm_citation"}}, status=402)
return _json_response({"answer": "retry ok", "sources": []})
client = _transport_handler(handler)
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
resp = bff_client.post("/api/qa/chat", json={"question": "q"})
assert resp.status_code == 200
assert resp.json()["answer"] == "retry ok"
assert len(calls) == 2
def test_chat_invalid_citation_retry_still_fails_502(bff_client, monkeypatch):
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request)
return _json_response({"detail": {"error_code": "invalid_llm_citation"}}, status=402)
client = _transport_handler(handler)
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
resp = bff_client.post("/api/qa/chat", json={"question": "q"})
assert resp.status_code == 502
data = resp.json()
assert data["error"]["code"] == "INVALID_CITATION"
assert data["error"]["message"] == "引用校验失败,请重试"
assert len(calls) == 2 # 重试仅一次
def test_chat_upstream_500_no_retry(bff_client, monkeypatch):
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request)
return _json_response({"detail": "boom"}, status=500)
client = _transport_handler(handler)
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
resp = bff_client.post("/api/qa/chat", json={"question": "q"})
assert resp.status_code == 502
assert resp.json()["error"]["code"] == "UPSTREAM_ERROR"
assert len(calls) == 1
def test_search_forces_use_llm_false_and_mode_search(bff_client, monkeypatch):
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request)
assert request.url.path.endswith("/api/v1/qa-v1-1-73/search")
return _json_response({"answer": "SHOULD BE WIPED", "sources": [{"title": "T"}]})
client = _transport_handler(handler)
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
resp = bff_client.post("/api/qa/search", json={"question": "q", "use_llm": True})
assert resp.status_code == 200
data = resp.json()
assert data["mode"] == "search"
assert data["answer"] == ""
sent = json.loads(calls[0].content)
assert sent["use_llm"] is False # 强制
assert sent["question"] == "q" # 其余透传
def test_search_no_retry_on_invalid_citation(bff_client, monkeypatch):
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request)
return _json_response({"detail": {"error_code": "invalid_llm_citation"}}, status=402)
client = _transport_handler(handler)
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
resp = bff_client.post("/api/qa/search", json={"question": "q"})
assert resp.status_code == 502
assert len(calls) == 1 # 不重试
def test_chat_upstream_down_503(bff_client, monkeypatch):
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("down", request=request)
client = _transport_handler(handler)
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
resp = bff_client.post("/api/qa/chat", json={"question": "q"})
assert resp.status_code == 503
assert resp.json()["error"]["code"] == "UPSTREAM_DOWN"
def test_chat_upstream_read_timeout_504(bff_client, monkeypatch):
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ReadTimeout("slow", request=request)
client = _transport_handler(handler)
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
resp = bff_client.post("/api/qa/chat", json={"question": "q"})
assert resp.status_code == 504
assert resp.json()["error"]["code"] == "TIMEOUT"
def test_chat_stream_headers_and_relay(bff_client, monkeypatch):
"""SSE 端到端:MockTransport 吐 SSE 字节,断言契约帧与响应头。"""
upstream_bytes = (
b'data: {"stage":"entities_identified","details":{"entities":["OsSPL14"]}}\n\n'
b'event: complete\ndata: {"answer":"A","sources":[]}\n\n'
)
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path.endswith("/api/v1/qa-v1-1-73/chat/stream")
return httpx.Response(200, stream=httpx.ByteStream(upstream_bytes))
client = _transport_handler(handler)
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
with bff_client.stream("POST", "/api/qa/chat/stream", json={"question": "q"}) as resp:
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/event-stream")
assert resp.headers["cache-control"] == "no-cache"
assert resp.headers["x-accel-buffering"] == "no"
body = "".join(resp.iter_text())
assert "event: progress" in body
assert '"phase": "planning"' in body
assert "event: complete" in body
assert "event: error" not in body
def test_chat_stream_invalid_citation_retry_then_error(bff_client, monkeypatch):
"""上游头阶段 402 → 重试一次 → 仍 402 → event: error。"""
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request)
return _json_response({"detail": {"error_code": "invalid_llm_citation"}}, status=402)
client = _transport_handler(handler)
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
with bff_client.stream("POST", "/api/qa/chat/stream", json={"question": "q"}) as resp:
body = "".join(resp.iter_text())
assert "event: error" in body
assert '"code": "INVALID_CITATION"' in body
assert len(calls) == 2
+255
View File
@@ -0,0 +1,255 @@
"""schemas.py —— Pydantic v2 模型(API.md 的契约形态)。
覆盖:块联合(§2)、SourceItem(§8)、GraphData / GraphNode / GraphEdge /
GraphChain(§9)、SessionOut / MessageOut(§5)、错误信封(§1)。
约定:模型 `extra="ignore"` + 全字段默认值,落库/返参前先经模型验证归一。
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Literal, Union
from pydantic import BaseModel, ConfigDict, Field
def utcnow_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
# ---------------------------------------------------------------------------
# §2 块联合(判别字段 type)
# ---------------------------------------------------------------------------
class TextBlock(BaseModel):
type: Literal["text"] = "text"
html: str
class TableBlock(BaseModel):
type: Literal["table"] = "table"
headers: list[str] = Field(default_factory=list)
rows: list[list[str]] = Field(default_factory=list)
class CodeBlock(BaseModel):
type: Literal["code"] = "code"
text: str
class StreamBlock(BaseModel):
"""瞬态块:只出现在流式过程中,绝不落库。"""
type: Literal["stream"] = "stream"
text: str
class PipelineStep(BaseModel):
name: str = ""
state: Literal["waiting", "running", "done"] = "waiting"
desc: str = ""
phase: str = "" # planning|retrieval|evidence|generation|done(API.md §4)
class PipelineBlock(BaseModel):
type: Literal["pipeline"] = "pipeline"
status: Literal["running", "done", "error"] = "running"
steps: list[PipelineStep] = Field(default_factory=list)
class ToolLog(BaseModel):
text: str = ""
cls: str = "log-ok" # log-ok|log-run
class ToolBlock(BaseModel):
type: Literal["tool"] = "tool"
icon: str = ""
name: str = ""
status: Literal["running", "done", "error"] = "running"
dur: str = ""
open: bool = True
params: str = ""
result: str = ""
logs: list[ToolLog] = Field(default_factory=list)
phase: str = "retrieval"
class SourcesBlock(BaseModel):
type: Literal["sources"] = "sources"
items: list["SourceItem"] = Field(default_factory=list)
class GraphBlock(BaseModel):
type: Literal["graph"] = "graph"
nodes: list["GraphNode"] = Field(default_factory=list)
edges: list["GraphEdge"] = Field(default_factory=list)
chains: list["GraphChain"] = Field(default_factory=list)
Block = Union[
TextBlock, TableBlock, CodeBlock, StreamBlock,
PipelineBlock, ToolBlock, SourcesBlock, GraphBlock,
]
# ---------------------------------------------------------------------------
# §8 SourceItem —— 所有字段键必须存在(缺失为 null)
# ---------------------------------------------------------------------------
class SourceItem(BaseModel):
model_config = ConfigDict(extra="ignore")
id: str | None = None
title: str | None = None
paper_id: str | None = None
chunk_id: str | None = None
pmid: str | None = None
pmcid: str | None = None
doi: str | None = None
url: str | None = None
snippet: str | None = None
score: float | None = None
locator: str | None = None
section: str | None = None
support_count: int | None = None
type: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
# ---------------------------------------------------------------------------
# §9 图谱结构
# ---------------------------------------------------------------------------
class GraphNode(BaseModel):
model_config = ConfigDict(extra="ignore")
id: str
label: str | None = None
type: str | None = None
class GraphEdge(BaseModel):
model_config = ConfigDict(extra="ignore")
source: str | None = None
target: str | None = None
relation: str | None = None
label: str | None = None
chain_id: str | None = None
hop: int | None = None
paper_id: str | None = None
pmid: str | None = None
pmcid: str | None = None
doi: str | None = None
class GraphHop(BaseModel):
model_config = ConfigDict(extra="ignore")
source: str | None = None
target: str | None = None
relation: str | None = None
source_type: str | None = None
target_type: str | None = None
paper_id: str | None = None
pmid: str | None = None
pmcid: str | None = None
doi: str | None = None
class GraphChain(BaseModel):
model_config = ConfigDict(extra="ignore")
chain_id: str | None = None
hops: list[GraphHop] = Field(default_factory=list)
class GraphData(BaseModel):
"""消息内 graph 块与 /api/kg/neighborhood 共用的 {nodes,edges,chains} 形态。"""
model_config = ConfigDict(extra="ignore")
nodes: list[GraphNode] = Field(default_factory=list)
edges: list[GraphEdge] = Field(default_factory=list)
chains: list[GraphChain] = Field(default_factory=list)
# ---------------------------------------------------------------------------
# §5 会话与消息
# ---------------------------------------------------------------------------
class SessionMeta(BaseModel):
title_manual: bool = False
class SessionCreate(BaseModel):
title: str = "新会话"
def normalized_title(self) -> str:
return self.title.strip() or "新会话"
class SessionPatch(BaseModel):
title: str | None = None
status: Literal["running", "done", "error", "idle"] | None = None
class SessionOut(BaseModel):
model_config = ConfigDict(extra="ignore")
id: str
title: str
status: str = "idle"
created_at: str = Field(default_factory=utcnow_iso)
updated_at: str = Field(default_factory=utcnow_iso)
message_count: int = 0
meta: SessionMeta = Field(default_factory=SessionMeta)
class SessionDetail(SessionOut):
messages: list["MessageOut"] = Field(default_factory=list)
class MessageCreate(BaseModel):
role: Literal["user", "assistant"]
text: str | None = None
kind: str = "qa"
status: str = "done"
blocks: list[Block] = Field(default_factory=list)
class MessagePatch(BaseModel):
"""流式检查点局部更新(PATCH /messages/{mid})。"""
status: str | None = None
blocks: list[Block] | None = None
raw: dict[str, Any] | None = None
class MessageOut(BaseModel):
model_config = ConfigDict(extra="ignore")
id: str
role: str
kind: str = "qa"
status: str = "done"
blocks: list[Block] = Field(default_factory=list)
raw: dict[str, Any] | None = None
text: str | None = None
created_at: str = Field(default_factory=utcnow_iso)
updated_at: str = Field(default_factory=utcnow_iso)
# ---------------------------------------------------------------------------
# §1 错误信封
# ---------------------------------------------------------------------------
class ErrorBody(BaseModel):
code: str
message: str
degraded: bool = False
class ErrorEnvelope(BaseModel):
ok: Literal[False] = False
error: ErrorBody
View File
+325
View File
@@ -0,0 +1,325 @@
"""services/extraction.py —— 抽取任务上游代理(8000 主 + 7900 回落,API.md §10)。
- 默认 `RAG_BASE_URL`(http://127.0.0.1:8000),每次请求失败回落
`RAG_ALT_BASE_URL`(http://127.0.0.1:7900)。
- 回落失败状态不缓存超过 30s。
- progress_pct 由 BFF 按 stage 派生:queued=5 / mineru=35 / llm=75 /
completed=100 / failed=null(上游没有进度数值字段)。
"""
from __future__ import annotations
import logging
import os
import tempfile
import time
from typing import Any, AsyncIterator
import anyio
import httpx
logger = logging.getLogger(__name__)
_UPSTREAM_PREFIX = "/api/v1/methodology"
_DEFAULT_PRIMARY = "http://127.0.0.1:8000"
_DEFAULT_ALT = "http://127.0.0.1:7900"
PRIMARY_BASE_URL = os.environ.get("RAG_BASE_URL", _DEFAULT_PRIMARY)
ALT_BASE_URL = os.environ.get("RAG_ALT_BASE_URL", _DEFAULT_ALT)
# 回落失败状态缓存上限(API.md §10:不缓存失败状态超过 30s)
ALT_FAILURE_TTL = 30.0
# 超时阶梯(API.md §10)
READ_TIMEOUT = 30.0
UPLOAD_READ_TIMEOUT = 600.0
# 上传限制(FR-EXTRACT-004)
MAX_PDF_BYTES = 50 * 1024 * 1024 # 50MB
_SPOOL_MAX_MEMORY = 5 * 1024 * 1024 # spool 驻内存上限,超出落临时文件
_UPLOAD_ROUTE = "/extract-upload"
# 上游成功上传形态:{success, job_id, status_url};message 由 BFF 附加
_SUCCESS_UPLOAD_MESSAGE = "已提交抽取任务"
_PROGRESS_BY_STAGE: dict[str, int | None] = {
"queued": 5,
"mineru": 35,
"llm": 75,
"completed": 100,
"failed": None,
}
def derive_progress_pct(stage: str | None) -> int | None:
"""按 stage 派生 progress_pct(API.md §10)。未知 stage 返回 None。"""
return _PROGRESS_BY_STAGE.get(stage if stage is not None else "")
class ExtractionUnavailable(Exception):
"""两个抽取上游(8000 + 7900)均不可达。"""
def __init__(self, message: str, *, cause: BaseException | None = None) -> None:
super().__init__(message)
self.cause = cause
class ExtractionUpstreamError(Exception):
"""两个上游均可连通但都返回了错误(5xx,如 MinerU 不可用)。"""
def __init__(self, message: str) -> None:
super().__init__(message)
class ExtractionService:
"""每次请求先打 primary(8000);连接失败时回落 alt(7900)。
失败回落状态记 `_alt_dead_until` 时间戳(30s 内不再试 alt),
任何 alt 成功都会清掉该状态。
"""
def __init__(
self,
primary_base_url: str = PRIMARY_BASE_URL,
alt_base_url: str = ALT_BASE_URL,
) -> None:
self._primary = primary_base_url.rstrip("/")
self._alt = alt_base_url.rstrip("/")
self._alt_dead_until: float = 0.0
self._client: httpx.AsyncClient | None = None
@property
def client(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(READ_TIMEOUT, connect=3.0)
)
return self._client
async def close(self) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None
# ---------- 回落机制 ----------
def _alt_usable(self) -> bool:
return time.monotonic() >= self._alt_dead_until
def _mark_alt_dead(self) -> None:
self._alt_dead_until = time.monotonic() + ALT_FAILURE_TTL
def _mark_alt_alive(self) -> None:
self._alt_dead_until = 0.0
async def _request(
self,
path: str,
*,
method: str = "GET",
read_timeout: float = READ_TIMEOUT,
**kwargs: Any,
) -> httpx.Response:
"""primary → alt 回落。连接失败回落 alt;5xx 时也回落 alt,
两个上游都 5xx 则抛 ExtractionUpstreamError(含最后一个 5xx 响应)。
"""
client = self.client
primary_failed: Exception | None = None
primary_status: int | None = None
try:
resp = await client.request(
method,
f"{self._primary}{_UPSTREAM_PREFIX}{path}",
timeout=httpx.Timeout(read_timeout, connect=3.0),
**kwargs,
)
if resp.status_code < 500:
return resp
primary_status = resp.status_code
primary_failed = RuntimeError(f"primary 返回 {resp.status_code}")
except (httpx.HTTPError, httpx.TimeoutException) as exc:
primary_failed = exc
# ---- 回落 alt ----
if not self._alt_usable():
raise ExtractionUnavailable(
"抽取服务(8000)不可用,且 7900 最近不可用,稍后自动重试",
cause=primary_failed,
)
try:
resp = await client.request(
method,
f"{self._alt}{_UPSTREAM_PREFIX}{path}",
timeout=httpx.Timeout(read_timeout, connect=3.0),
**kwargs,
)
if resp.status_code < 500:
self._mark_alt_alive()
return resp
self._mark_alt_dead()
raise ExtractionUpstreamError(
f"两个抽取上游均返回错误:8000 状态 {primary_status},"
f"7900 状态 {resp.status_code}"
)
except (httpx.HTTPError, httpx.TimeoutException) as exc:
self._mark_alt_dead()
raise ExtractionUnavailable(
"抽取服务不可用(8000 与 7900 均不可达)",
cause=exc,
) from exc
# ---------- 归一化 ----------
@staticmethod
def normalize_job(item: dict[str, Any]) -> dict[str, Any]:
"""列表 item 归一化(API.md §10 列表形态)。
title = source_filename 优先 → paper_id → job_id。
"""
job_id = item.get("job_id")
title = item.get("source_filename") or item.get("paper_id") or job_id
stage = item.get("stage")
return {
"job_id": job_id,
"title": title,
"status": item.get("status"),
"stage": stage,
"substage": item.get("substage"),
"progress_pct": derive_progress_pct(stage),
"message": item.get("message"),
"paper_id": item.get("paper_id"),
"family": item.get("family"),
"source_filename": item.get("source_filename"),
"stage_count": item.get("stage_count"),
"species_count": item.get("species_count"),
"figure_table_caption_count": item.get("figure_table_caption_count"),
"figure_table_stage_count": item.get("figure_table_stage_count"),
"updated_at": item.get("updated_at"),
"config_url": item.get("config_url"),
"methodology_plan_url": item.get("methodology_plan_url"),
}
# ---------- 业务操作 ----------
async def list_jobs(self, limit: int = 20) -> dict[str, Any]:
"""GET /extract-jobs(可带 ?limit=,默认 20)。"""
resp = await self._request(f"/extract-jobs?limit={limit}")
payload = resp.json()
return {
"jobs": [self.normalize_job(item) for item in payload.get("jobs", [])],
"count": len(payload.get("jobs", [])),
}
async def get_job(self, job_id: str) -> dict[str, Any]:
"""GET /extract-jobs/{id}:扁平详情归一化(API.md §10)。
plan:completed 时请求 /plan;未完成时上游 409 → 置 null 不报错。
"""
resp = await self._request(f"/extract-jobs/{job_id}")
item = resp.json()
status = item.get("status")
stage = item.get("stage")
job_id_val = item.get("job_id") or job_id
title = (
item.get("source_filename")
or item.get("paper_id")
or job_id_val
)
plan: dict[str, Any] | None = None
if status == "completed":
try:
plan_resp = await self._request(f"/extract-jobs/{job_id}/plan")
except ExtractionUnavailable as exc:
logger.warning(
"extraction: job %s 的 plan 请求失败(上游不可用):%s", job_id, exc
)
else:
if plan_resp.status_code == 409:
# 上游认为未完成(与扁平详情不一致),契约要求置 null 不报错
plan = None
else:
plan = plan_resp.json()
return {
"job_id": job_id_val,
"title": title,
"status": status,
"stage": stage,
"progress_pct": derive_progress_pct(stage),
"message": item.get("message"),
"paper_id": item.get("paper_id"),
"family": item.get("family"),
"stats": {
"stage_count": item.get("stage_count"),
"species_count": item.get("species_count"),
"figure_table_caption_count": item.get("figure_table_caption_count"),
"figure_table_stage_count": item.get("figure_table_stage_count"),
},
"detail": item,
"plan": plan,
}
async def _passthrough(self, path: str) -> dict[str, Any]:
"""processing-status / progress / summary / run-log:JSON 透传。
上游 404 视为空数据(rows 为空属正常现象,前端显示空态)。
"""
resp = await self._request(path)
return resp.json()
async def upload(
self,
*,
filename: str,
stream: AsyncIterator[bytes] | bytes,
content_type: str | None,
paper_id: str = "uploaded_paper",
family: str = "",
output_name: str = "",
) -> dict[str, Any]:
"""POST /extract-upload:multipart 转发(内存有界)。
httpx 的 multipart 编码只接受同步 file-like/bytes,异步流会先
spool 进 SpooledTemporaryFile(≤5MB 驻内存,超出落磁盘),
因此不会整文件读进内存。
返回上游形态 + message:"已提交抽取任务"。
"""
created = not isinstance(stream, (bytes, bytearray))
if created:
spooled: Any = tempfile.SpooledTemporaryFile(max_size=_SPOOL_MAX_MEMORY)
else:
spooled = stream
try:
if created:
async for chunk in stream:
await anyio.to_thread.run_sync(spooled.write, chunk)
spooled.seek(0)
resp = await self._request(
_UPLOAD_ROUTE,
method="POST",
read_timeout=UPLOAD_READ_TIMEOUT,
data={
"paper_id": paper_id,
"family": family,
"output_name": output_name,
},
files={"file": (filename, spooled, content_type)},
)
finally:
if created:
spooled.close()
payload = resp.json()
payload["message"] = _SUCCESS_UPLOAD_MESSAGE
return payload
# 模块级单例:路由层共享(app 关闭时 close)。
service = ExtractionService()
async def close_extraction() -> None:
"""app shutdown 钩子。"""
await service.close()
+646
View File
@@ -0,0 +1,646 @@
"""services/neo4j.py —— Neo4j Bolt 直连(官方 neo4j 驱动,全异步)。
选型与凭据依据 DISCOVERY.md §2:
- 首选 neo4j-plant-srt-direct-v1-full(bolt 127.0.0.1:18788,db `neo4j`,
用户 neo4j / 密码在 backend/.env),schema canonical,QA 7920 生产使用。
- 回退 neo4j-plant-srt-pc2025-v3-20260817-normalized(bolt 127.0.0.1:19788),
schema 不同(Entity/ASSERTS),本波未做 Cypher 适配(TODO)。
- 再次 plantai-protected-neo4j-1(bolt 127.0.0.1:17687),空库。
所有本模块异常统一抛 :class:`Neo4jUnavailable`,由路由层转 503 降级信封。
凭据只存在于本文件(或 env 覆盖),绝不下发浏览器。
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
from typing import Any
from neo4j import AsyncDriver, AsyncGraphDatabase, exceptions as neo4j_exc
logger = logging.getLogger(__name__)
# 可选 env 覆盖(测试/部署);未设置时使用 DISCOVERY.md 实测凭据。
_ENV_PREFIX = "BFF_NEO4J_"
PROBE_CACHE_TTL = 5.0
# 连接/查询超时(秒)。entity 详情与一跳邻域走全量属性,超时给得宽些。
CONNECT_TIMEOUT = 3.0
QUERY_TIMEOUT = 10.0
NEIGHBORHOOD_QUERY_TIMEOUT = 30.0
OVERVIEW_QUERY_TIMEOUT = 60.0 # 2026-08-25 PM 修复:1644 万边 COUNT 聚合冷缓存 >10s
NAME_INDEX = "srt2_canonical_entity_name_norm"
FULLTEXT_INDEX = None # DISCOVERY.md §2:任何实例都不存在全文索引,且不得创建
# status 展示名(须与 API.md §6 fixture 一致)
DIRECT_ACTIVE_NAME = "neo4j-plant-srt-direct-v1-full"
NORMALIZED_ACTIVE_NAME = "neo4j-plant-srt-pc2025-v3-20260817-normalized"
PROTECTED_ACTIVE_NAME = "plantai-protected-neo4j-1"
_CANONICAL_LABELS = ["SRT2CanonicalEntity", "SRT2CanonicalAssertion", "SRT2Paper"]
_CANONICAL_REL_TYPES = ["SRT2_CANONICAL_ASSERTS", "SRT2_SUPPORTED_BY"]
class Neo4jUnavailable(Exception):
"""Neo4j 上游不可用(连接失败/认证失败/超时/Cypher 错误统一转此异常)。"""
def __init__(self, message: str, *, cause: BaseException | None = None) -> None:
super().__init__(message)
self.cause = cause
class _Instance:
"""一个候选实例。本波只实现 canonical schema(direct 实例)的 Cypher。"""
__slots__ = ("name", "uri", "auth", "database", "schema", "label", "rel")
def __init__(
self,
name: str,
uri: str,
user: str,
password: str,
database: str,
schema: str,
label: str | None,
rel: str | None,
) -> None:
self.name = name
self.uri = uri
self.auth = (user, password)
self.database = database
self.schema = schema
self.label = label
self.rel = rel
def _env(name: str, default: str) -> str:
return os.environ.get(f"{_ENV_PREFIX}{name}", default)
_INSTANCES: list[_Instance] = [
_Instance(
DIRECT_ACTIVE_NAME,
_env("URI_DIRECT", "bolt://127.0.0.1:18788"),
"neo4j",
_env("PASSWORD_DIRECT", ""),
"neo4j",
"canonical",
"SRT2CanonicalEntity",
"SRT2_CANONICAL_ASSERTS",
),
_Instance(
NORMALIZED_ACTIVE_NAME,
_env("URI_NORMALIZED", "bolt://127.0.0.1:19788"),
"neo4j",
_env("PASSWORD_NORMALIZED", ""),
"neo4j",
"normalized",
None,
None,
),
_Instance(
PROTECTED_ACTIVE_NAME,
_env("URI_PROTECTED", "bolt://127.0.0.1:17687"),
"neo4j",
_env("PASSWORD_PROTECTED", ""),
"neo4j",
"empty",
None,
None,
),
]
class Neo4jService:
"""生命周期:app 启动时 create,关闭时 close()。health 路由与 kg 路由共用。
内部为每个候选实例缓存一个 AsyncDriver(懒建)。实例选择只发生在
显式调用 select()(probe/kg_status)或模块加载时:每个请求的处理函数
首先调用 select(),得到当前实例(未探测时默认首选,惰性回退)。
切换由下一次显式 probe 触发,探测结果缓存 5s。
"""
def __init__(self, instances: list[_Instance] | None = None) -> None:
self._instances = instances if instances is not None else _INSTANCES
self._drivers: dict[str, AsyncDriver] = {}
self._active: _Instance = self._instances[0]
self._selected = False # 是否已成功选出一个实例
self._last_probe: float = 0.0
self._last_probe_ok: bool = False
self._lock = asyncio.Lock()
# ---------- 生命周期 ----------
async def close(self) -> None:
for driver in self._drivers.values():
await driver.close()
self._drivers.clear()
def _driver_for(self, instance: _Instance) -> AsyncDriver:
driver = self._drivers.get(instance.name)
if driver is None:
driver = AsyncGraphDatabase.driver(
instance.uri,
auth=instance.auth,
connection_timeout=CONNECT_TIMEOUT,
connection_acquisition_timeout=CONNECT_TIMEOUT,
)
self._drivers[instance.name] = driver
return driver
# ---------- 实例选择 ----------
async def select(self) -> _Instance:
"""返回当前可用实例。
顺序探测(5s 结果缓存):direct → normalized → protected。
首次调用若未探测成功则依次尝试探测,全失败抛 Neo4jUnavailable。
"""
if self._selected:
return self._active
now = time.monotonic()
if now - self._last_probe < PROBE_CACHE_TTL:
if self._last_probe_ok:
return self._active
# 探测失败结果同样缓存 5s,避免每个请求都打 3 个实例
raise Neo4jUnavailable("Neo4j 不可用(最近探测失败,结果缓存 5s)")
await self._probe()
if self._selected:
return self._active
raise Neo4jUnavailable("所有 Neo4j 候选实例均不可达")
async def _probe(self) -> None:
async with self._lock:
now = time.monotonic()
if now - self._last_probe < PROBE_CACHE_TTL:
if not self._last_probe_ok:
raise Neo4jUnavailable("Neo4j 不可用(最近探测失败,结果缓存 5s)")
return
self._selected = False
last_error: BaseException | None = None
for instance in self._instances:
try:
await self._check_instance(instance)
except Neo4jUnavailable as exc:
last_error = exc
continue
self._active = instance
self._selected = True
break
self._last_probe = now
self._last_probe_ok = self._selected
if not self._selected:
raise Neo4jUnavailable(
f"所有 Neo4j 候选实例均不可达:{last_error}", cause=last_error
)
async def _check_instance(self, instance: _Instance) -> None:
"""连通性检查:TCP 预检 + `RETURN 1`,整体限时 CONNECT_TIMEOUT。
先做 TCP 连通预检(毫秒级):端口没开时直接判死,避免官方驱动
的指数退避重试拖慢健康探测。TCP 通但 Bolt 协议失败时才走
wait_for + 驱动(仍限时 3s)。
"""
if not await self._tcp_reachable(instance.uri):
raise Neo4jUnavailable(f"{instance.name} 连接失败(TCP 不可达)")
driver = self._driver_for(instance)
try:
await asyncio.wait_for(
driver.execute_query(
"RETURN 1 AS x", database_=instance.database, routing_="r"
),
timeout=CONNECT_TIMEOUT,
)
except asyncio.TimeoutError as exc:
raise Neo4jUnavailable(f"{instance.name} 连接超时", cause=exc) from exc
except neo4j_exc.DriverError as exc:
raise Neo4jUnavailable(
f"{instance.name} 连接失败:{exc.__class__.__name__}", cause=exc
) from exc
@staticmethod
async def _tcp_reachable(uri: str) -> bool:
"""bolt://host:port 的 TCP 连通预检(超时 0.5s)。"""
try:
host, port = uri.split("://", 1)[1].rsplit(":", 1)
except (ValueError, IndexError):
return False
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, int(port)), timeout=0.5
)
except (OSError, asyncio.TimeoutError, ValueError):
return False
writer.close()
try:
await writer.wait_closed()
except (OSError, AttributeError):
pass
return True
# ---------- 通用查询 ----------
async def _query(
self,
cypher: str,
params: dict[str, Any] | None = None,
*,
timeout: float = QUERY_TIMEOUT,
) -> list[dict[str, Any]]:
"""按当前实例执行查询,返回 records 列表(每条为 dict)。"""
instance = await self.select()
driver = self._driver_for(instance)
try:
records, _, _ = await asyncio.wait_for(
driver.execute_query(
cypher,
parameters_=params or {},
database_=instance.database,
routing_="r",
),
timeout=timeout,
)
except asyncio.TimeoutError as exc:
raise Neo4jUnavailable(
f"Neo4j 查询超时(>{timeout}s)", cause=exc
) from exc
except neo4j_exc.DriverError as exc:
raise Neo4jUnavailable(
f"Neo4j 查询失败:{exc.__class__.__name__}", cause=exc
) from exc
return [dict(record) for record in records]
@staticmethod
def _canonical(instance: _Instance) -> _Instance:
"""当前仅 canonical schema 的 Cypher 就绪。"""
if instance.schema != "canonical":
# TODO(A3):normalized 实例(Entity/ASSERTS,无 name_norm 索引,
# 等值 6.81s)与空库实例的 Cypher 适配层,需 PM 决策后实现。
raise Neo4jUnavailable(
f"Neo4j 实例 {instance.name} 已连通,但其 schema({instance.schema})"
"的 Cypher 适配尚未实现,暂不可用于图谱查询"
)
return instance
# ---------- 健康探测 ----------
async def probe(self) -> dict[str, Any]:
"""连通 + 计数 + 索引检查(/api/health 调用),结果缓存 5s。
返回 {"ok": bool, "active": str|null, "detail": str|null},
主动探测时即使失败也返回 dict(health 聚合不因此抛错)。
"""
now = time.monotonic()
if now - self._last_probe < PROBE_CACHE_TTL and self._last_probe_ok:
return {"ok": True, "active": self._active.name, "detail": None}
try:
await self._probe()
except Neo4jUnavailable as exc:
return {"ok": False, "active": None, "detail": str(exc)}
instance = self._active
detail: str | None = None
if instance.schema == "canonical":
try:
counts = await self._counts(instance)
indexes = await self._name_indexes(instance)
# 成员判断而非全等:同一标签上存在唯一约束索引等其它索引是正常
# 现象(DISCOVERY §2.2 实测有 srt2_canonical_entity_id),
# 只有 name_norm 索引真正不在列表里才报缺失。
if NAME_INDEX not in indexes:
detail = f"name_norm 索引缺失:{indexes or '(无任何索引)'}"
except Neo4jUnavailable as exc:
# 连通了但计数/索引检查失败:不视为整体失败,附 detail
detail = f"计数/索引检查失败:{exc}"
return {"ok": True, "active": instance.name, "detail": detail}
async def _counts(self, instance: _Instance) -> dict[str, int]:
rows = await self._query(
"""
CALL {
MATCH (e:SRT2CanonicalEntity) RETURN count(e) AS entity_count
}
CALL {
MATCH (a:SRT2CanonicalAssertion) RETURN count(a) AS assertion_count
}
RETURN entity_count, assertion_count
""",
timeout=QUERY_TIMEOUT,
)
row = rows[0] if rows else {}
return {
"entity_count": row.get("entity_count", 0),
"assertion_count": row.get("assertion_count", 0),
}
async def _name_indexes(self, instance: _Instance) -> list[str]:
"""该实体标签上的所有索引名(含约束索引),滤掉空名。
SHOW INDEXES 的 labelsOrTypes 可以是字符串或字符串列表,用
`ANY(x IN labelsOrTypes WHERE x = $label)` 兼容两者。
"""
rows = await self._query(
"SHOW INDEXES YIELD name, labelsOrTypes "
"WHERE ANY(x IN labelsOrTypes WHERE x = $label) "
"RETURN name",
{"label": instance.label},
timeout=QUERY_TIMEOUT,
)
return [r["name"] for r in rows if r.get("name")]
# ---------- /api/kg/status ----------
async def kg_status(self) -> dict[str, Any]:
"""labels / relationship_types / entity_count / assertion_count /
name_index / fulltext_index(null,实例无全文索引)。"""
instance = self._canonical(await self.select())
counts = await self._counts(instance)
indexes = await self._name_indexes(instance)
name_index = NAME_INDEX if NAME_INDEX in indexes else None
return {
"ok": True,
"active": instance.name,
"schema": instance.schema,
"labels": _CANONICAL_LABELS,
"relationship_types": _CANONICAL_REL_TYPES,
"entity_count": counts["entity_count"],
"assertion_count": counts["assertion_count"],
"name_index": name_index,
"fulltext_index": FULLTEXT_INDEX,
}
# ---------- /api/kg/overview ----------
async def kg_overview(self) -> dict[str, Any]:
"""按 entity_type 与 relation_category 计数。
注意 1644 万边:只做 COUNT 聚合,绝不拉取全量边。
"""
self._canonical(await self.select())
rows = await self._query(
"""
MATCH (e:SRT2CanonicalEntity)
WITH e.entity_type AS t, count(*) AS c
WHERE t IS NOT NULL
RETURN t AS type, c AS count
ORDER BY c DESC, t ASC
LIMIT 200
""",
timeout=OVERVIEW_QUERY_TIMEOUT,
)
entities = [{"type": r["type"], "count": r["count"]} for r in rows]
# 关系类别取自边上的 relation_category(属于关系属性,不在边集合里拉边)
rows = await self._query(
"""
MATCH ()-[r:SRT2_CANONICAL_ASSERTS]->()
WITH r.relation_category AS cat, count(*) AS c
WHERE cat IS NOT NULL
RETURN cat AS type, c AS count
ORDER BY c DESC, cat ASC
LIMIT 200
""",
timeout=OVERVIEW_QUERY_TIMEOUT,
)
relations = [{"type": r["type"], "count": r["count"]} for r in rows]
return {
"entities_by_type": entities,
"relations_by_category": relations,
}
# ---------- /api/kg/entities ----------
async def search_entities(
self, q: str, limit: int = 20
) -> list[dict[str, Any]]:
"""DISCOVERY.md §2.4 Cypher (a):name_norm = 或 STARTS WITH(toLower 归一化)。
走 name_norm RANGE 索引(实测 0.09-0.10s)。结果按 name ASC 排序保证稳定。
返回 [{id, name, type, support_count}]。
"""
self._canonical(await self.select())
limit = min(max(int(limit), 1), 20)
q_norm = q.lower()
rows = await self._query(
"""
MATCH (e:SRT2CanonicalEntity)
WHERE e.name_norm = $q_norm OR e.name_norm STARTS WITH $q_norm
RETURN e.canonical_id AS canonical_id, e.name AS name,
e.entity_type AS entity_type, e.support_count AS support_count
ORDER BY e.name ASC
LIMIT $limit
""",
{"q_norm": q_norm, "limit": limit},
timeout=QUERY_TIMEOUT,
)
return [
{
"id": r.get("canonical_id"),
"name": r.get("name"),
"type": r.get("entity_type"),
"support_count": r.get("support_count"),
}
for r in rows
]
async def _resolve_id(self, id_or_name: str) -> str | None:
"""canonical_id 或 name_norm 定位(DISCOVERY.md §2.4 Cypher b)。"""
if id_or_name.startswith("cent_"):
rows = await self._query(
"MATCH (e:SRT2CanonicalEntity {canonical_id: $id}) "
"RETURN e.canonical_id AS canonical_id",
{"id": id_or_name},
timeout=QUERY_TIMEOUT,
)
else:
rows = await self._query(
"MATCH (e:SRT2CanonicalEntity {name_norm: $name_norm}) "
"RETURN e.canonical_id AS canonical_id LIMIT 5",
{"name_norm": id_or_name.lower()},
timeout=QUERY_TIMEOUT,
)
return rows[0].get("canonical_id") if rows else None
# ---------- /api/kg/entities/{id} ----------
async def entity(self, id_or_name: str) -> dict[str, Any] | None:
"""按 canonical_id 或 name_norm 取实体全量属性;未命中返回 None。"""
canonical_id = await self._resolve_id(id_or_name)
if canonical_id is None:
return None
rows = await self._query(
"MATCH (e:SRT2CanonicalEntity {canonical_id: $id}) "
"RETURN e {.*} AS node",
{"id": canonical_id},
timeout=QUERY_TIMEOUT,
)
if not rows:
return None
props = rows[0].get("node") or {}
return {
"id": props.get("canonical_id"),
"name": props.get("name"),
"type": props.get("entity_type"),
"properties": props,
}
# ---------- /api/kg/entities/{id}/neighborhood ----------
async def neighborhood(
self, id_or_name: str, depth: int = 1, limit: int = 200
) -> dict[str, Any]:
"""有界邻域扩展(DISCOVERY.md §2.4 Cypher (c) 思路)。
- depth ∈ {1,2},默认 1;limit ≤ 200。
- 每跳按 paper_count/support_count 降序截断(防 hub 吃满预算)。
- OPTIONAL MATCH 保证孤立节点不丢。
- 无向匹配(-()-),entity 按 canonical_id 去重。
- depth=1 绝不执行第二跳(两跳实测约 13s,不默认开启)。
- 返回 API.md §6 的 {center, center_name, depth, nodes, edges}。
"""
self._canonical(await self.select()) # 非 canonical 实例在此抛 Neo4jUnavailable
depth = depth if depth in (1, 2) else 1
limit = min(max(int(limit), 1), 200)
canonical_id = await self._resolve_id(id_or_name)
if canonical_id is None:
return {"center": None, "center_name": None, "depth": depth, "nodes": [], "edges": []}
params = {"seed": canonical_id, "max_nodes": limit, "max_edges": limit}
if depth == 1:
cypher = """
MATCH (s:SRT2CanonicalEntity {canonical_id: $seed})
OPTIONAL MATCH (s)-[r1:SRT2_CANONICAL_ASSERTS]-(n1:SRT2CanonicalEntity)
WITH s, r1, n1
ORDER BY coalesce(r1.paper_count, 0) DESC, coalesce(r1.support_count, 0) DESC
WITH s,
collect(DISTINCT n1)[..$max_nodes] AS level1,
[r IN collect(DISTINCT r1)[..$max_edges] | r {.*,
source_canonical_id: CASE WHEN startNode(r).canonical_id = s.canonical_id
THEN s.canonical_id ELSE endNode(r).canonical_id END,
target_canonical_id: CASE WHEN startNode(r).canonical_id = s.canonical_id
THEN endNode(r).canonical_id ELSE s.canonical_id END}] AS edges1
RETURN s {.*} AS start,
[n IN level1 | n {.*}] AS nodes,
edges1 AS edges
"""
else:
cypher = """
MATCH (s:SRT2CanonicalEntity {canonical_id: $seed})
OPTIONAL MATCH (s)-[r1:SRT2_CANONICAL_ASSERTS]-(n1:SRT2CanonicalEntity)
WITH s, r1, n1
ORDER BY coalesce(r1.paper_count, 0) DESC, coalesce(r1.support_count, 0) DESC
WITH s,
collect(DISTINCT n1)[..$max_nodes] AS level1,
[r IN collect(DISTINCT r1)[..$max_edges] | r {.*,
source_canonical_id: CASE WHEN startNode(r).canonical_id = s.canonical_id
THEN s.canonical_id ELSE endNode(r).canonical_id END,
target_canonical_id: CASE WHEN startNode(r).canonical_id = s.canonical_id
THEN endNode(r).canonical_id ELSE s.canonical_id END}] AS edges1
OPTIONAL MATCH (n1)-[r2:SRT2_CANONICAL_ASSERTS]-(n2:SRT2CanonicalEntity)
WHERE n1 IN level1
WITH s, level1, edges1, r2, n2
ORDER BY coalesce(r2.paper_count, 0) DESC, coalesce(r2.support_count, 0) DESC
WITH s, level1, edges1,
collect(DISTINCT n2)[..$max_nodes] AS level2,
[r IN collect(DISTINCT r2)[..$max_edges] | r {.*,
source_canonical_id: startNode(r).canonical_id,
target_canonical_id: endNode(r).canonical_id}] AS edges2
RETURN s {.*} AS start,
[n IN (level1 + level2) | n {.*}] AS nodes,
(edges1 + edges2) AS edges
"""
rows = await self._query(
cypher, params, timeout=NEIGHBORHOOD_QUERY_TIMEOUT
)
if not rows:
return {"center": None, "center_name": None, "depth": depth, "nodes": [], "edges": []}
row = rows[0]
start = row.get("start") or {}
node_list: list[dict[str, Any]] = [
{
"id": start.get("canonical_id"),
"label": start.get("name"),
"type": start.get("entity_type"),
}
]
node_index: dict[str, int] = {}
if start.get("canonical_id") is not None:
node_index[start["canonical_id"]] = 0
for raw in row.get("nodes") or []:
nid = raw.get("canonical_id")
if nid is None or nid in node_index:
continue
node_index[nid] = len(node_list)
node_list.append(
{
"id": nid,
"label": raw.get("name"),
"type": raw.get("entity_type"),
}
)
edges: list[dict[str, Any]] = []
for raw in row.get("edges") or []:
src = raw.get("source_canonical_id")
tgt = raw.get("target_canonical_id")
if src is None or tgt is None:
continue
if src not in node_index or tgt not in node_index:
continue
# 自环边(实体自身关系,如 gene_structure)对可视化无意义,过滤
if src == tgt:
continue
edges.append(
{
"source": src,
"target": tgt,
"relation": raw.get("relation_category")
if raw.get("relation_category") is not None
else raw.get("relation_norm"),
"label": raw.get("relation_category")
if raw.get("relation_category") is not None
else raw.get("relation_norm"),
"assertion_id": raw.get("assertion_id"),
"paper_count": raw.get("paper_count"),
"support_count": raw.get("support_count"),
"title": raw.get("title"),
"doi": raw.get("doi"),
"pmid": raw.get("pmid"),
"chain_id": None,
"hop": None,
}
)
return {
"center": start.get("canonical_id"),
"center_name": start.get("name"),
"depth": depth,
"nodes": node_list,
"edges": edges,
}
# ---------- 调试/冒烟 ----------
async def raw_query(self, cypher: str, **params: Any) -> list[dict[str, Any]]:
"""只读原始查询(冒烟测试用)。"""
return await self._query(cypher, params, timeout=QUERY_TIMEOUT)
# 模块级单例:health 路由与 kg 路由共用同一连接池(app 关闭时 close)。
service = Neo4jService()
async def close_neo4j() -> None:
"""app shutdown 钩子。"""
await service.close()
async def probe_neo4j() -> dict[str, Any]:
"""/api/health 用的探测(不抛错)。"""
return await service.probe()
+268
View File
@@ -0,0 +1,268 @@
"""normalize.py —— 上游响应 → 契约形态的归一化器(API.md §7/§8/§9)。
真实上游键以 DISCOVERY.md §4.1 为准;别名规则按 API.md 逐条实现。
本模块是纯函数集合(不发起 IO),供 qa 路由(A2)与测试共用。
"""
from __future__ import annotations
from typing import Any
def pick(obj: dict, *names: str, default=None):
"""取第一个存在且非 None 的键(归一化核心工具)。保留 PM 脚手架语义。"""
for n in names:
if isinstance(obj, dict) and n in obj and obj[n] is not None:
return obj[n]
return default
# ---------------------------------------------------------------------------
# §8 Source 归一化(缺失字段键必须存在,值为 null)
# ---------------------------------------------------------------------------
def normalize_source(item: dict[str, Any] | None) -> dict[str, Any]:
"""单个 source item → API.md §8 形态(所有键存在,缺失为 null)。
别名映射(按 API.md §8 精确实现):
- content|evidence|text|snippet → snippet
- doc_id|paper_id|pubmed_id|id|chunk_id → paper_id(chunk_id 单独保留)
- final_score|score|confidence|rerank_score → score(真实键 final_score)
- title|paper_title|source → title(真实 item 的 source 是断言主体实体名;
title 存在时优先取 title,两者并存时 source 落入 metadata.source_entity)
- url|doi_url → url;只有 doi 时派生 url = https://doi.org/{doi}
- evidence_id|id → id;relation_id|assertion_id → metadata.assertion_id
- output_layer / claim_scope / cluster_id / support_count / section /
species / vector_score / rrf_score / rerank_score / retrieval_methods /
retrieval_queries / cluster_member_* 原样透传(或进 metadata)
"""
if not isinstance(item, dict):
item = {}
source = pick(item, "source") # 断言主体实体名(上游语义)
title = pick(item, "title", "paper_title")
doi = pick(item, "doi")
url = pick(item, "url", "doi_url")
if url is None and doi:
url = f"https://doi.org/{doi}"
metadata: dict[str, Any] = {}
if isinstance(pick(item, "metadata"), dict):
metadata.update(item["metadata"])
if source is not None and title is not None and source != title:
metadata.setdefault("source_entity", source)
for key in (
"vector_score", "rrf_score", "rerank_score", "kb",
"retrieval_methods", "retrieval_queries",
"cluster_member_count", "cluster_member_assertion_ids",
"cluster_member_paper_ids", "cluster_member_pmids",
"output_layer", "claim_scope", "species",
):
if key in item and item[key] is not None:
metadata.setdefault(key, item[key])
assertion_id = pick(item, "relation_id", "assertion_id")
if assertion_id is not None:
metadata.setdefault("assertion_id", assertion_id)
return {
"id": pick(item, "evidence_id", "id"),
"title": title,
"paper_id": pick(item, "doc_id", "paper_id", "pubmed_id", "id", "chunk_id"),
"chunk_id": pick(item, "chunk_id"),
"pmid": pick(item, "pmid"),
"pmcid": pick(item, "pmcid"),
"doi": doi,
"url": url,
"snippet": pick(item, "content", "evidence", "text", "snippet"),
"score": pick(item, "final_score", "score", "confidence", "rerank_score"),
"locator": pick(item, "locator"),
"section": pick(item, "section"),
"support_count": pick(item, "support_count"),
"type": pick(item, "type"),
"metadata": metadata,
}
# ---------------------------------------------------------------------------
# §9 图谱:hop / chain / {nodes,edges,chains}
# ---------------------------------------------------------------------------
def normalize_hop(hop: dict[str, Any]) -> dict[str, Any]:
"""hop 归一化:
from_name|source → source、to_name|target → target、
from_id|source_canonical_id → source 节点 id、to_id|target_canonical_id → target 节点 id、
relation 原样、source_type|target_type → source/target 节点 type、
paper_id / pmid / pmcid / doi 透传。
"""
if not isinstance(hop, dict):
hop = {}
return {
"source": pick(hop, "from_name", "source"),
"target": pick(hop, "to_name", "target"),
"source_id": pick(hop, "from_id", "source_canonical_id"),
"target_id": pick(hop, "to_id", "target_canonical_id"),
"source_type": pick(hop, "source_type"),
"target_type": pick(hop, "target_type"),
"relation": pick(hop, "relation"),
"assertion_id": pick(hop, "assertion_id"),
"paper_id": pick(hop, "paper_id"),
"pmid": pick(hop, "pmid"),
"pmcid": pick(hop, "pmcid"),
"doi": pick(hop, "doi"),
}
def normalize_chain_item(item: dict[str, Any]) -> dict[str, Any]:
"""单个 chain item(真实形态:chain_id / hops[] 等)→ {chain_id, hops}。"""
if not isinstance(item, dict):
item = {}
hops = [normalize_hop(h) for h in item.get("hops", []) if isinstance(h, dict)]
return {"chain_id": pick(item, "chain_id"), "hops": hops}
def extract_chains(raw: dict[str, Any] | None) -> list[dict[str, Any]]:
"""从可能的两类源提取 chain 列表:
- `native_graph_chains`:列表(真实形态,305 条)
- `graph_knowledge_chains`:dict 形态 `{mode, chains[], chain_count, …}`,取 `.chains`
两种形态都要能提取出 hop 列表。
"""
if not isinstance(raw, dict):
return []
chains = pick(raw, "native_graph_chains", "graph_knowledge_chains")
if isinstance(chains, dict):
chains = chains.get("chains")
if not isinstance(chains, list):
return []
return [normalize_chain_item(c) for c in chains]
def normalize_graph(raw: dict[str, Any] | None) -> dict[str, Any]:
"""从 `knowledge_graph` / `graph_edges` / `native_graph_chains`(列表形态)/
`graph_knowledge_chains`(dict 形态)提取并归一化为 {nodes, edges, chains}。
- 节点去重(按 id;id 缺失的 hop 跳过);label 取 from_name/to_name、type 取
source_type/target_type。
- 边携带 chain_id 与 hop 序号(hop_index 缺失时按列表序补)。
- 链保持 chain_id + hops 列表。
"""
raw = raw if isinstance(raw, dict) else {}
chains = extract_chains(raw)
nodes: dict[str, dict[str, Any]] = {}
edges: list[dict[str, Any]] = []
def add_node(node_id: Any, name: Any, typ: Any) -> None:
if not node_id:
return
key = str(node_id)
if key not in nodes:
nodes[key] = {"id": node_id, "label": name, "type": typ}
for chain in chains:
for idx, hop in enumerate(chain["hops"]):
hop_index = hop.get("hop_index")
if hop_index is None:
hop_index = idx
# 节点 id 优先 canonical id;label 优先名字,退化到 id
src_id = pick(hop, "source_id", "source") or hop.get("source")
tgt_id = pick(hop, "target_id", "target") or hop.get("target")
src_label = hop.get("source") or src_id
tgt_label = hop.get("target") or tgt_id
add_node(src_id, src_label, hop.get("source_type"))
add_node(tgt_id, tgt_label, hop.get("target_type"))
if src_id and tgt_id:
edges.append({
"source": src_id,
"target": tgt_id,
"relation": hop.get("relation"),
"label": hop.get("relation"),
"chain_id": chain.get("chain_id"),
"hop": hop_index,
"paper_id": hop.get("paper_id"),
"pmid": hop.get("pmid"),
"pmcid": hop.get("pmcid"),
"doi": hop.get("doi"),
})
# 兼容显式 {nodes, edges} 形态(knowledge_graph / graph_edges 直接给边列表时)
explicit_nodes = raw.get("nodes")
explicit_edges = raw.get("edges")
if isinstance(explicit_nodes, list):
for n in explicit_nodes:
if isinstance(n, dict) and n.get("id"):
key = str(n["id"])
if key not in nodes:
nodes[key] = {
"id": n["id"],
"label": pick(n, "label"),
"type": pick(n, "type"),
}
if isinstance(explicit_edges, list):
edges.extend(
{
"source": pick(e, "source"),
"target": pick(e, "target"),
"relation": pick(e, "relation"),
"label": pick(e, "label", "relation"),
"chain_id": pick(e, "chain_id"),
"hop": pick(e, "hop"),
"paper_id": pick(e, "paper_id"),
"pmid": pick(e, "pmid"),
"pmcid": pick(e, "pmcid"),
"doi": pick(e, "doi"),
}
for e in explicit_edges
if isinstance(e, dict)
)
return {
"nodes": list(nodes.values()),
"edges": edges,
"chains": chains,
}
# ---------------------------------------------------------------------------
# §7 归一化 QA 响应(/chat、/chat/stream complete、/search 共用)
# ---------------------------------------------------------------------------
def normalize_qa_response(raw: dict[str, Any] | None) -> dict[str, Any]:
"""上游 31 键响应 → API.md §7 形态。
别名(真实键优先):
- answer|response|message → answer
- sources|citations → sources;used_sources|references → used_sources
- query_plan|agentic_plan|retrieval_plan → retrieval_plan(真实键 query_plan)
- retrieval_notes|notes → retrieval_notes
- timings|timing → timing(真实键 timings)
- citation_validation|citation_map → citation_map(真实键 citation_validation)
- 图谱四源 → graph + chains(见 normalize_graph)
- success 透传;raw 保存原始完整响应
"""
raw = raw if isinstance(raw, dict) else {}
sources = [normalize_source(s) for s in (pick(raw, "sources", "citations") or [])]
used_sources = [
normalize_source(s) for s in (pick(raw, "used_sources", "references") or [])
]
graph = normalize_graph(raw)
retrieval_plan = pick(raw, "query_plan", "agentic_plan", "retrieval_plan")
return {
"query_id": pick(raw, "query_id"),
"version": pick(raw, "version"),
"mode": pick(raw, "mode", default="chat"),
"answer": pick(raw, "answer", "response", "message", default=""),
"source_count": pick(raw, "source_count", default=len(sources)),
"sources": sources,
"used_sources": used_sources,
"chains": graph["chains"],
"graph": {"nodes": graph["nodes"], "edges": graph["edges"], "chains": graph["chains"]},
"retrieval_plan": retrieval_plan,
"retrieval_notes": pick(raw, "retrieval_notes", "notes"),
"timing": pick(raw, "timings", "timing"),
"citation_map": pick(raw, "citation_validation", "citation_map"),
"success": pick(raw, "success", default=True),
"raw": raw,
}
+590
View File
@@ -0,0 +1,590 @@
"""services/sse_relay.py —— 上游 QA SSE → 前端契约帧中继(A2 归属)。
上游:127.0.0.1:7920 POST /api/v1/qa-v1-1-73/chat/stream
(真实帧序列见 DISCOVERY.md §4.2:`event: <name>` + `data: <JSON>` 两行一组,
data 可能多行,期间有 `: keepalive` 注释帧,终端为 `event: complete` + 完整 JSON。)
下游(前端契约帧,API.md §4):
event: progress data: {stage, phase, label, details, at} ← phase 由 BFF 计算
event: complete data: {<同 /chat 的归一化响应>}
event: raw data: {<无法分类的上游 JSON>} ← 绝不丢数据
event: error data: {code, message, retryable}
event: timeout data: {message}
: ping (15s 保活注释帧)
规则:
- 上游 HTTP 头阶段失败且 detail.error_code=="invalid_llm_citation" → 自动重试一次,
仍失败 → `event: error`(INVALID_CITATION)。
- 看门狗:15s 无输出发 `: ping`;240s 无任何上游事件 → `event: timeout` 并关闭。
- 客户端断开(StreamingResponse 取消 → CancelledError 传播)→ `async with` 退出,
上游流随之关闭。
"""
from __future__ import annotations
import json
import logging
import time
from collections import deque
from datetime import datetime, timezone
from typing import Any, AsyncIterator, Callable, Optional
import anyio
import httpx
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# 常量(TODO(A2→A1 对齐点):A1 的 app.config.settings 就绪后,
# 上游地址与「超时阶梯」改由 settings 提供,此处仅作兜底默认值)
# ---------------------------------------------------------------------------
QA_BASE_URL = "http://127.0.0.1:7920"
QA_UPSTREAM_PREFIX = "/api/v1/qa-v1-1-73"
CONNECT_TIMEOUT = 10.0
STREAM_IDLE_TIMEOUT = 240.0
STREAM_KEEPALIVE_INTERVAL = 15.0
INVALID_CITATION_ERROR_CODE = "invalid_llm_citation"
# API.md §4:SSE 响应头
sse_headers = {
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
}
# ---------------------------------------------------------------------------
# 阶段 → 相位映射(API.md §4 表;与 i18n/labels.js 同源,PM 归属)。
# 未知 stage → phase "retrieval",label = stage 原文。
# ---------------------------------------------------------------------------
STAGE_PHASE: dict[str, str] = {
"understanding_started": "planning",
"entities_identified": "planning",
"related_search_started": "planning",
"concepts_found": "retrieval",
"subquestion_retrieved": "retrieval",
"agent_review_started": "retrieval",
"subquestion_retry_started": "retrieval",
"evidence_accepted": "evidence",
"evidence_review_completed": "evidence",
"answer_generation_started": "generation",
"completed": "done",
}
STAGE_LABEL: dict[str, str] = {
"understanding_started": "开始理解问题",
"entities_identified": "已识别实体",
"related_search_started": "开始关联检索",
"concepts_found": "发现相关概念",
"subquestion_retrieved": "子问题召回完成",
"agent_review_started": "开始智能体审核",
"subquestion_retry_started": "子问题重试中",
"evidence_accepted": "证据已采纳",
"evidence_review_completed": "证据审核完成",
"answer_generation_started": "开始生成答案",
"completed": "检索完成",
}
UNKNOWN_STAGE_PHASE = "retrieval"
def phase_for(stage: str) -> tuple[str, str]:
"""stage → (phase, label);未知 stage 回落 retrieval / stage 原文。"""
return (
STAGE_PHASE.get(stage, UNKNOWN_STAGE_PHASE),
STAGE_LABEL.get(stage, stage),
)
# ---------------------------------------------------------------------------
# 错误文案与载荷(TODO(A2→A1 对齐点):HTTP JSON 信封换用 app.upstream.envelope())
# ---------------------------------------------------------------------------
ERROR_MESSAGES: dict[str, tuple[str, bool]] = {
"UPSTREAM_DOWN": ("知识问答服务当前不可用,请稍后重试", True),
"UPSTREAM_ERROR": ("知识问答服务返回错误", False),
"TIMEOUT": ("检索超时", False),
"INVALID_CITATION": ("引用校验失败,请重试", False),
"VALIDATION": ("请求参数不合法", False),
"INTERNAL": ("内部错误", False),
}
RETRYABLE_ERRORS = {"TIMEOUT", "INVALID_CITATION", "UPSTREAM_ERROR"}
def error_payload(code: str, message: Optional[str] = None) -> dict:
"""SSE `event: error` 的 data 载荷(API.md §4:{code, message, retryable})。"""
default_message, _degraded = ERROR_MESSAGES.get(code, ("内部错误", False))
return {
"code": code,
"message": message or default_message,
"retryable": code in RETRYABLE_ERRORS,
}
def is_invalid_citation(body: Any) -> bool:
"""上游错误体 `{"detail": {"error_code": "invalid_llm_citation"}}` 判定。"""
if not isinstance(body, dict):
return False
detail = body.get("detail")
if isinstance(detail, dict):
return detail.get("error_code") == INVALID_CITATION_ERROR_CODE
return body.get("error_code") == INVALID_CITATION_ERROR_CODE
def status_to_error(status: int, detail: Any) -> tuple[str, Optional[str]]:
"""HTTP≥400 头阶段失败 → (code, message)。"""
if is_invalid_citation(detail):
return "INVALID_CITATION", None
if isinstance(detail, dict):
d = detail.get("detail")
if isinstance(d, dict):
return "UPSTREAM_ERROR", d.get("message") or d.get("error")
return "UPSTREAM_ERROR", detail.get("message") or detail.get("error")
return "UPSTREAM_ERROR", None
# ---------------------------------------------------------------------------
# 上游 client 与地址(TODO(A2→A1 对齐点):A1 的 app.upstream client 单例 /
# settings 上游地址就绪后,以下两函数直接改为其代理)
# ---------------------------------------------------------------------------
_qa_client: Optional[httpx.AsyncClient] = None
def get_qa_client() -> httpx.AsyncClient:
"""QA 上游 httpx client。
注意(2026-08-25 PM 集成修复):**不复用 A1 upstream 单例**。
A1 的 get_client() 是 async 函数且默认 read=300s,而 QA 流式路径
需要 read=None + 应用层看门狗(长静默 SSE 会被 300s 读超时误杀)。
两个 client 语义不同,各自独立持有。
"""
global _qa_client
if _qa_client is None or _qa_client.is_closed:
_qa_client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=CONNECT_TIMEOUT, read=None, write=30.0, pool=CONNECT_TIMEOUT),
)
return _qa_client
def qa_base_url() -> str:
"""QA 上游 base URL。A1 settings 就绪后从配置读取。"""
try:
from ..config import settings # A1 归属
for attr in ("qa_base_url", "qa_url", "upstream_qa_url", "qa_upstream_base"):
value = getattr(settings, attr, None)
if value:
return str(value).rstrip("/")
except Exception:
pass
return QA_BASE_URL
# ---------------------------------------------------------------------------
# 归一化(TODO(A2→A1 对齐点):A1 的 app.services.normalize.normalize_qa_response
# 就绪后,get_qa_normalizer 直接返回之,以下 fallback 全部删除)
# ---------------------------------------------------------------------------
def get_qa_normalizer() -> Callable[[dict], dict]:
"""QA 响应归一化函数:优先 A1 实现,未就绪时用等价本地逻辑。"""
try:
from .normalize import normalize_qa_response # A1 归属
return normalize_qa_response
except (ImportError, AttributeError):
return _fallback_normalize_qa
def _fallback_normalize_qa(payload: dict) -> dict:
"""等价本地归一化(API.md §7 顶层别名 / §8 source / §9 图谱链条双形态容错)。"""
from .normalize import pick # A1 已提供
src = payload if isinstance(payload, dict) else {}
sources = [_fallback_source(i) for i in (pick(src, "sources", "citations", default=[]) or [])]
used = [_fallback_source(i) for i in (pick(src, "used_sources", "references", default=[]) or [])]
chains, graph = _fallback_graph_chains(src)
return {
"query_id": pick(src, "query_id"),
"version": pick(src, "version"),
"mode": "chat", # 调用方(qa.py)按端点覆盖 chat|search
"answer": pick(src, "answer", "response", "message", default=""),
"source_count": pick(src, "source_count", default=len(sources)),
"sources": sources,
"used_sources": used,
"chains": chains,
"graph": graph,
"retrieval_plan": pick(src, "retrieval_plan", "agentic_plan", "query_plan"),
"retrieval_notes": pick(src, "retrieval_notes", "notes"),
"timing": pick(src, "timing", "timings"),
"citation_map": pick(src, "citation_map", "citation_validation"),
"raw": src,
}
_SOURCE_PASSTHROUGH_KEYS = (
"output_layer", "claim_scope", "cluster_id", "support_count", "section", "species",
)
_SOURCE_METADATA_KEYS = (
"vector_score", "rrf_score", "rerank_score", "relation_id", "assertion_id",
"cluster_member_count", "cluster_member_assertion_ids", "cluster_member_paper_ids",
"cluster_member_pmids", "retrieval_methods", "retrieval_queries", "kb",
)
def _fallback_source(item: Any) -> dict:
"""§8 source 别名归一化;缺失字段键必须存在(值为 null)。"""
from .normalize import pick # A1 已提供
if not isinstance(item, dict):
item = {}
doi = pick(item, "doi")
url = pick(item, "url", "doi_url")
if url is None and doi:
url = f"https://doi.org/{doi}"
title = pick(item, "title", "paper_title")
metadata: dict[str, Any] = {}
for k in _SOURCE_PASSTHROUGH_KEYS:
if k in item:
metadata[k] = item[k]
for k in _SOURCE_METADATA_KEYS:
if k in item:
metadata[k] = item[k]
if "source" in item:
# 真实 item 的 source 是断言主体实体名而非论文标题
if title is None:
title = item["source"]
else:
metadata.setdefault("source_entity", item["source"])
return {
"id": pick(item, "evidence_id", "id"),
"title": title,
"paper_id": pick(item, "paper_id", "doc_id", "pubmed_id", "id", "chunk_id"),
"chunk_id": pick(item, "chunk_id"),
"pmid": pick(item, "pmid"),
"pmcid": pick(item, "pmcid"),
"doi": doi,
"url": url,
"snippet": pick(item, "content", "evidence", "text", "snippet"),
"score": pick(item, "final_score", "score", "confidence", "rerank_score"),
"locator": pick(item, "locator"),
"section": pick(item, "section"),
"support_count": pick(item, "support_count"),
"type": pick(item, "type"),
"metadata": metadata,
}
def _fallback_graph_chains(src: dict) -> tuple[list[dict], dict]:
"""§9 图谱与链条:native_graph_chains(list) / graph_knowledge_chains(dict) 双形态容错。
返回 (chains, graph);graph = {nodes, edges, chains}。
"""
from .normalize import pick # A1 已提供
raw_chains: list = pick(src, "native_graph_chains", default=[]) or []
if isinstance(raw_chains, dict): # 容错:意外为 dict
raw_chains = raw_chains.get("chains") or []
gkc = pick(src, "graph_knowledge_chains")
if isinstance(gkc, dict):
raw_chains = raw_chains + (gkc.get("chains") or [])
elif isinstance(gkc, list):
raw_chains = raw_chains + gkc
chains: list[dict] = []
nodes: dict[str, dict] = {}
edges: list[dict] = []
seen_chain: set = set()
for idx, chain in enumerate(raw_chains):
if not isinstance(chain, dict):
continue
chain_id = pick(chain, "chain_id", default=f"chain_{idx}")
if chain_id in seen_chain: # native 与 graph_knowledge 两形态可能镜像同数据
continue
seen_chain.add(chain_id)
hops_out: list[dict] = []
for hop in (pick(chain, "hops", default=[]) or []):
if not isinstance(hop, dict):
continue
source_name = pick(hop, "from_name", "source")
target_name = pick(hop, "to_name", "target")
src_id = pick(hop, "from_id", "source_canonical_id") or source_name
tgt_id = pick(hop, "to_id", "target_canonical_id") or target_name
hops_out.append({
"source": source_name,
"target": target_name,
"relation": pick(hop, "relation"),
"source_type": pick(hop, "source_type"),
"target_type": pick(hop, "target_type"),
"paper_id": pick(hop, "paper_id"),
"pmid": pick(hop, "pmid"),
"pmcid": pick(hop, "pmcid"),
"doi": pick(hop, "doi"),
})
nodes.setdefault(src_id, {"id": src_id, "label": source_name, "type": pick(hop, "source_type")})
nodes.setdefault(tgt_id, {"id": tgt_id, "label": target_name, "type": pick(hop, "target_type")})
edges.append({
"source": src_id,
"target": tgt_id,
"relation": pick(hop, "relation"),
"label": pick(hop, "relation"),
"chain_id": chain_id,
"hop": pick(hop, "hop_index"),
"paper_id": pick(hop, "paper_id"),
"pmid": pick(hop, "pmid"),
"pmcid": pick(hop, "pmcid"),
"doi": pick(hop, "doi"),
})
chains.append({"chain_id": chain_id, "hops": hops_out})
# knowledge_graph 直出形态(上游若给出现成 nodes/edges,直接用)
kg = pick(src, "knowledge_graph")
if isinstance(kg, dict):
for n in (kg.get("nodes") or []):
if isinstance(n, dict) and n.get("id") not in nodes:
nodes[n["id"]] = n
edges = (kg.get("edges") or []) + edges
elif not edges:
edges = pick(src, "graph_edges", default=[]) or []
graph = {"nodes": list(nodes.values()), "edges": edges, "chains": chains}
return chains, graph
# ---------------------------------------------------------------------------
# SSE 帧工具
# ---------------------------------------------------------------------------
def utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def sse_frame(event: str, data: Any = None) -> str:
text = data if isinstance(data, str) else json.dumps(data, ensure_ascii=False)
return f"event: {event}\ndata: {text}\n\n"
def _progress_payload(payload: dict, at: str) -> dict:
stage = payload.get("stage")
phase, label = phase_for(stage)
details = payload.get("details")
if not isinstance(details, dict):
details = {}
return {"stage": stage, "phase": phase, "label": label, "details": details, "at": at}
def _classify(event_name: Optional[str], data_text: str) -> Optional[tuple[str, Any]]:
"""一条上游事件 → (kind, payload);kind ∈ progress|complete|error|raw。
None 表示无内容可发(空帧)。无 event 名时按 payload 形态分类。
"""
if not data_text.strip():
if not event_name:
return None
return ("raw", {"payload": data_text, "event_name": event_name})
try:
payload = json.loads(data_text)
except (json.JSONDecodeError, ValueError):
# 不是 JSON —— 透传不丢数据
return ("raw", {"payload": data_text, "event_name": event_name or None})
if not isinstance(payload, dict):
return ("raw", payload)
if "stage" in payload:
return ("progress", payload)
if event_name == "error":
return ("error", _error_payload_from_upstream(payload))
if event_name == "complete" or "answer" in payload or "success" in payload:
return ("complete", payload)
return ("raw", payload)
def _error_payload_from_upstream(payload: dict) -> dict:
if is_invalid_citation(payload):
return error_payload("INVALID_CITATION")
detail = payload.get("detail")
if isinstance(detail, dict):
return error_payload("UPSTREAM_ERROR", detail.get("message") or detail.get("error"))
return error_payload("UPSTREAM_ERROR", payload.get("message") or payload.get("error"))
# ---------------------------------------------------------------------------
# 中继核心
# ---------------------------------------------------------------------------
async def relay_stream(
body: dict[str, Any],
*,
client: Optional[httpx.AsyncClient] = None,
base_url: Optional[str] = None,
normalizer: Optional[Callable[[dict], dict]] = None,
idle_timeout: Optional[float] = None,
keepalive_interval: Optional[float] = None,
now_fn: Callable[[], str] = utc_now_iso,
) -> AsyncIterator[str]:
"""POST 上游 /chat/stream 并把帧中继为前端契约帧(API.md §4)。
客户端断开 → StreamingResponse 取消本生成器 → CancelledError 传播 →
`async with client.stream(...)` 退出,上游流随之关闭。
"""
client = client or get_qa_client()
normalizer = normalizer or get_qa_normalizer()
idle_timeout = STREAM_IDLE_TIMEOUT if idle_timeout is None else idle_timeout
keepalive_interval = STREAM_KEEPALIVE_INTERVAL if keepalive_interval is None else keepalive_interval
url = f"{base_url or qa_base_url()}{QA_UPSTREAM_PREFIX}/chat/stream"
timeout = httpx.Timeout(connect=CONNECT_TIMEOUT, read=None, write=30.0, pool=CONNECT_TIMEOUT)
for attempt in (1, 2):
try:
async with client.stream(
"POST", url, json=body, timeout=timeout, headers={"Accept": "text/event-stream"}
) as resp:
if resp.status_code >= 400:
detail = await _safe_aread(resp)
if attempt == 1 and is_invalid_citation(detail):
logger.warning(
"上游 /chat/stream 返回 %d invalid_llm_citation,自动重试(第 1 次)",
resp.status_code,
)
continue
code, message = status_to_error(resp.status_code, detail)
yield sse_frame("error", error_payload(code, message))
return
async for frame_text in _pump_events(
resp, normalizer, idle_timeout, keepalive_interval, now_fn
):
yield frame_text
return
except httpx.ReadTimeout:
yield sse_frame("error", error_payload("TIMEOUT"))
return
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout) as exc:
logger.warning("上游 /chat/stream 连接失败: %s", exc)
yield sse_frame("error", error_payload("UPSTREAM_DOWN"))
return
except httpx.HTTPError as exc:
logger.warning("上游 /chat/stream 请求失败: %s", exc)
yield sse_frame("error", error_payload("UPSTREAM_ERROR"))
return
# 理论上不可达(attempt 循环内必然 return);防御性兜底
yield sse_frame("error", error_payload("UPSTREAM_ERROR"))
async def _safe_aread(resp: httpx.Response) -> Any:
try:
raw = await resp.aread()
if not raw:
return None
return json.loads(raw.decode("utf-8", errors="replace"))
except Exception:
return None
async def _pump_events(
resp: httpx.Response,
normalizer: Callable[[dict], dict],
idle_timeout: float,
keepalive_interval: float,
now_fn: Callable[[], str],
) -> AsyncIterator[str]:
"""消费上游 SSE 字节流并产出契约帧。
设计要点:上游读取放在独立的 producer task(一次 `anext` 可能挂数分钟——
上游逐字节慢速输出是常态)。看门狗只在 `Event.wait()` 上施加 `fail_after`,
避免取消传播进 httpx 的字节生成器(asyncio 后端下那会破坏后续所有 await,
使 timeout/ping 帧发不出去)。
看门狗:
- 上游字节静默 `keepalive_interval` → `: ping`;
- 自上次完整上游事件起 `idle_timeout` 无事件 → `event: timeout` 并关闭。
"""
chunks = resp.aiter_raw()
queue: deque[tuple[str, bool]] = deque() # (frame_text, is_terminal)
wakeup = anyio.Event() # 持久事件:consumer 等待前先 clear,避免残留 set 造成忙循环
def enqueue(frame_text: str, terminal: bool) -> None:
queue.append((frame_text, terminal))
wakeup.set()
async def producer() -> None:
buf = b""
event_name: Optional[str] = None
data_lines: list[str] = []
def dispatch_local() -> list[tuple[str, bool]]:
nonlocal event_name, data_lines
kind = _classify(event_name, "\n".join(data_lines))
event_name, data_lines = None, []
if kind is None:
return []
kind_name, payload = kind
if kind_name == "progress":
return [(sse_frame("progress", _progress_payload(payload, now_fn())), False)]
if kind_name == "complete":
try:
normalized = normalizer(payload)
except Exception:
logger.exception("归一化上游 complete 帧失败")
return [(sse_frame("error", error_payload("INTERNAL")), True)]
return [(sse_frame("complete", normalized), True)]
if kind_name == "error":
return [(sse_frame("error", payload), True)]
return [(sse_frame("raw", payload), False)]
try:
async for chunk in chunks:
buf += chunk
while b"\n" in buf:
raw_line, buf = buf.split(b"\n", 1)
line = raw_line.rstrip(b"\r").decode("utf-8", errors="replace")
if line == "":
for frame_text, terminal in dispatch_local():
enqueue(frame_text, terminal)
if queue and any(t for _, t in queue):
return # 终态已入队,停止读取,连接由 aclose 关闭
elif line.startswith(":"):
continue # 上游注释帧(keepalive)忽略;保活由本中继自己的 : ping 承担
elif line.startswith("event:"):
event_name = line[len("event:"):].strip() or None
elif line.startswith("data:"):
data_lines.append(line[len("data:"):].strip())
# 其他 SSE 字段(id:/retry:)忽略
# 上游流在终态帧之前关闭:残余帧照发(绝不丢数据),再报错
for frame_text, terminal in dispatch_local():
enqueue(frame_text, terminal)
enqueue(sse_frame("error", error_payload("UPSTREAM_ERROR", "知识问答服务连接意外关闭")), True)
except httpx.ReadTimeout:
enqueue(sse_frame("error", error_payload("TIMEOUT")), True)
except (httpx.ConnectError, httpx.ConnectTimeout):
enqueue(sse_frame("error", error_payload("UPSTREAM_DOWN")), True)
except httpx.HTTPError as exc:
logger.warning("上游 SSE 流中断: %s", exc)
enqueue(sse_frame("error", error_payload("UPSTREAM_ERROR")), True)
async with anyio.create_task_group() as tg:
tg.start_soon(producer)
last_upstream_event = time.monotonic()
while True:
if queue:
last_upstream_event = time.monotonic()
frame_text, terminal = queue.popleft()
yield frame_text
if terminal:
tg.cancel_scope.cancel()
return
continue
now = time.monotonic()
if now - last_upstream_event >= idle_timeout:
yield sse_frame("timeout", {"message": "检索超时"})
tg.cancel_scope.cancel()
return
wakeup = anyio.Event()
try:
with anyio.fail_after(min(keepalive_interval, idle_timeout - (now - last_upstream_event))):
await wakeup.wait()
except TimeoutError:
now = time.monotonic()
if now - last_upstream_event >= idle_timeout:
yield sse_frame("timeout", {"message": "检索超时"})
tg.cancel_scope.cancel()
return
yield ": ping\n\n"
+382
View File
@@ -0,0 +1,382 @@
"""services/test_extraction.py —— 抽取代理单元测试(respx mock HTTP)。
覆盖:progress_pct 派生(四状态 + failed=null)、8000 down → 7900 回落命中、
alt 失败缓存(30s 内不再试 alt)、jobs 列表归一化、job 详情归一化、
plan 请求 409 → null 不报错、上传流式转发 + message 附加。
"""
from __future__ import annotations
import httpx
import pytest
import respx
from app.services.extraction import (
ALT_FAILURE_TTL,
ExtractionService,
ExtractionUnavailable,
derive_progress_pct,
)
PRIMARY = "http://primary.test"
ALT = "http://alt.test"
PREFIX = "/api/v1/methodology"
def make_service(primary: str = PRIMARY, alt: str = ALT) -> ExtractionService:
return ExtractionService(primary_base_url=primary, alt_base_url=alt)
# ---------- progress_pct 派生(API.md §10) ----------
@pytest.mark.parametrize(
"stage,expected",
[
("queued", 5),
("mineru", 35),
("llm", 75),
("completed", 100),
("failed", None),
(None, None),
("unknown_stage", None),
],
)
def test_derive_progress_pct(stage, expected):
assert derive_progress_pct(stage) == expected
# ---------- 回落逻辑 ----------
async def test_primary_down_falls_back_to_alt(respx_mock):
# 8000 down(连接失败),7900 命中
respx_mock.get(f"{PRIMARY}{PREFIX}/extract-jobs?limit=20").mock(
side_effect=httpx.ConnectError("primary down")
)
respx_mock.get(f"{ALT}{PREFIX}/extract-jobs?limit=20").mock(
return_value=httpx.Response(
200,
json={
"jobs": [
{
"job_id": "2943168e9c48",
"status": "completed",
"stage": "completed",
"substage": "done",
"message": "MinerU+LLM 方法学提取完成",
"paper_id": "demo_paper",
"family": "",
"source_filename": "wang2019.pdf",
"stage_count": 6,
"species_count": 1,
"figure_table_caption_count": 9,
"figure_table_stage_count": 12,
"updated_at": "2026-08-07T09:42:17",
"config_url": "/api/v1/methodology/extract-jobs/2943168e9c48/bbav5-config",
"methodology_plan_url": "/api/v1/methodology/extract-jobs/2943168e9c48/plan",
}
],
"count": 1,
},
)
)
service = make_service()
result = await service.list_jobs(limit=20)
assert result["count"] == 1
job = result["jobs"][0]
assert job["title"] == "wang2019.pdf" # source_filename 优先
assert job["progress_pct"] == 100
assert job["status"] == "completed"
await service.close()
async def test_primary_5xx_falls_back_to_alt(respx_mock):
respx_mock.get(f"{PRIMARY}{PREFIX}/extract-jobs?limit=20").mock(
return_value=httpx.Response(500)
)
respx_mock.get(f"{ALT}{PREFIX}/extract-jobs?limit=20").mock(
return_value=httpx.Response(200, json={"jobs": [], "count": 0})
)
service = make_service()
result = await service.list_jobs(limit=20)
assert result == {"jobs": [], "count": 0}
await service.close()
async def test_both_down_raises_unavailable(respx_mock):
respx_mock.get(f"{PRIMARY}{PREFIX}/extract-jobs?limit=20").mock(
side_effect=httpx.ConnectError("down")
)
respx_mock.get(f"{ALT}{PREFIX}/extract-jobs?limit=20").mock(
side_effect=httpx.ConnectError("down too")
)
service = make_service()
with pytest.raises(ExtractionUnavailable):
await service.list_jobs(limit=20)
await service.close()
async def test_alt_failure_not_cached_over_30s(respx_mock):
"""alt 失败后 30s 内不再试 alt;超过 30s 恢复尝试。"""
respx_mock.get(f"{PRIMARY}{PREFIX}/extract-jobs?limit=20").mock(
side_effect=httpx.ConnectError("down")
)
alt_route = respx_mock.get(f"{ALT}{PREFIX}/extract-jobs?limit=20").mock(
side_effect=httpx.ConnectError("down too")
)
service = make_service()
with pytest.raises(ExtractionUnavailable):
await service.list_jobs(limit=20)
assert alt_route.called is True # 第一次尝试了 alt
# 立刻再请求:alt 在失败窗口内,不应再被尝试
before = alt_route.call_count
with pytest.raises(ExtractionUnavailable):
await service.list_jobs(limit=20)
assert alt_route.call_count == before # 没有新增 alt 请求
# 模拟时间流逝超过 ALT_FAILURE_TTL
service._alt_dead_until -= ALT_FAILURE_TTL + 1
alt_route.side_effect = None # 必须先清 side_effect,respx 优先走 side_effect
alt_route.return_value = httpx.Response(200, json={"jobs": [], "count": 0})
result = await service.list_jobs(limit=20)
assert result == {"jobs": [], "count": 0}
assert alt_route.call_count == before + 1 # 恢复后重新尝试 alt
await service.close()
# ---------- jobs 归一化 ----------
def _raw_job(**overrides):
base = {
"job_id": "a1b2c3d4e5f6",
"status": "running",
"stage": "llm",
"substage": None,
"message": "LLM 关系抽取中…",
"paper_id": "pecan_spl",
"family": "SPL",
"source_filename": "Pecan_SPL_2021.pdf",
"stage_count": 4,
"species_count": 1,
"figure_table_caption_count": 3,
"figure_table_stage_count": 4,
"updated_at": "2026-08-25T09:52:00",
"config_url": None,
"methodology_plan_url": None,
}
base.update(overrides)
return base
async def test_jobs_normalization_stages(respx_mock):
respx_mock.get(f"{PRIMARY}{PREFIX}/extract-jobs?limit=20").mock(
return_value=httpx.Response(
200,
json={
"jobs": [
_raw_job(stage="queued", status="queued", substage=None,
message="排队等待处理"),
_raw_job(stage="mineru", status="running"),
_raw_job(stage="failed", status="failed",
source_filename=None),
],
"count": 3,
},
)
)
service = make_service()
result = await service.list_jobs(limit=20)
jobs = result["jobs"]
assert [j["progress_pct"] for j in jobs] == [5, 35, None]
# failed 且无 source_filename:title 回退 paper_id
assert jobs[2]["title"] == "pecan_spl"
assert jobs[0]["stage"] == "queued"
await service.close()
async def test_jobs_title_fallback_chain(respx_mock):
respx_mock.get(f"{PRIMARY}{PREFIX}/extract-jobs?limit=20").mock(
return_value=httpx.Response(
200,
json={
"jobs": [
_raw_job(job_id="onlyjobid", source_filename=None, paper_id=None),
],
"count": 1,
},
)
)
service = make_service()
result = await service.list_jobs(limit=20)
assert result["jobs"][0]["title"] == "onlyjobid" # 最后回退 job_id
await service.close()
# ---------- job 详情与 plan ----------
def _detail_job(**overrides):
base = {
"updated_at": "2026-08-07T09:42:17",
"job_id": "2943168e9c48",
"status": "completed",
"stage": "completed",
"substage": "done",
"message": "MinerU+LLM 方法学提取完成",
"source_path": "/home/xzq/5090/PlantAI-rag/data/evaluation/upload/wang2019.pdf",
"content_list": "/home/xzq/5090/PlantAI-rag/.../wang2019_content_list.json",
"output_dir": "/home/xzq/5090/PlantAI-rag/methodology_batch_demo_paper_2943168e9c48",
"paper_id": "demo_paper",
"family": "",
"stage_count": 6,
"species_count": 1,
"figure_table_caption_count": 9,
"figure_table_stage_count": 12,
"figure_table_warning": "",
"source_filename": "wang2019.pdf",
"config_url": "/api/v1/methodology/extract-jobs/2943168e9c48/bbav5-config",
"methodology_plan_url": "/api/v1/methodology/extract-jobs/2943168e9c48/plan",
}
base.update(overrides)
return base
async def test_get_job_completed_fetches_plan(respx_mock):
respx_mock.get(f"{PRIMARY}{PREFIX}/extract-jobs/2943168e9c48").mock(
return_value=httpx.Response(200, json=_detail_job())
)
respx_mock.get(f"{PRIMARY}{PREFIX}/extract-jobs/2943168e9c48/plan").mock(
return_value=httpx.Response(
200,
json={
"plan_id": "plan_2943168e9c48",
"paper_id": "demo_paper",
"gene_family": "",
"stages": [],
},
)
)
service = make_service()
detail = await service.get_job("2943168e9c48")
assert detail["job_id"] == "2943168e9c48"
assert detail["title"] == "wang2019.pdf"
assert detail["progress_pct"] == 100
assert detail["stats"] == {
"stage_count": 6,
"species_count": 1,
"figure_table_caption_count": 9,
"figure_table_stage_count": 12,
}
assert detail["detail"]["status"] == "completed" # 上游原始全量
assert detail["plan"] == {
"plan_id": "plan_2943168e9c48",
"paper_id": "demo_paper",
"gene_family": "",
"stages": [],
}
await service.close()
async def test_get_job_incomplete_plan_409_is_null(respx_mock):
"""未完成时上游 plan 返回 409:BFF 置 null 不报错(API.md §10)。"""
respx_mock.get(f"{PRIMARY}{PREFIX}/extract-jobs/a1b2c3d4e5f6").mock(
return_value=httpx.Response(
200,
json=_detail_job(
job_id="a1b2c3d4e5f6",
status="running",
stage="llm",
substage=None,
message="LLM 关系抽取中…",
),
)
)
respx_mock.get(f"{PRIMARY}{PREFIX}/extract-jobs/a1b2c3d4e5f6/plan").mock(
return_value=httpx.Response(
409, json={"detail": "Extraction job is not completed"}
)
)
service = make_service()
detail = await service.get_job("a1b2c3d4e5f6")
assert detail["status"] == "running"
assert detail["progress_pct"] == 75
assert detail["plan"] is None # 409 被吞掉,不报错
await service.close()
async def test_get_job_plan_upstream_down_yields_null_plan(respx_mock):
respx_mock.get(f"{PRIMARY}{PREFIX}/extract-jobs/2943168e9c48").mock(
return_value=httpx.Response(200, json=_detail_job())
)
respx_mock.get(f"{PRIMARY}{PREFIX}/extract-jobs/2943168e9c48/plan").mock(
side_effect=httpx.ConnectError("plan service down")
)
respx_mock.get(f"{ALT}{PREFIX}/extract-jobs/2943168e9c48/plan").mock(
side_effect=httpx.ConnectError("alt down")
)
service = make_service()
detail = await service.get_job("2943168e9c48")
assert detail["plan"] is None # plan 拉不到不影响 job 详情
await service.close()
# ---------- 透传 ----------
async def test_passthrough_run_log(respx_mock):
respx_mock.get(f"{PRIMARY}{PREFIX}/run-log").mock(
return_value=httpx.Response(
200,
json={
"text": "2026-08-25 10:00:01 INFO MinerU 解析完成\n"
"2026-08-25 10:12:09 INFO 关系抽取 751 relations\n"
},
)
)
service = make_service()
result = await service._passthrough("/run-log")
assert result["text"].startswith("2026-08-25 10:00:01")
await service.close()
# ---------- 上传 ----------
async def _file_stream(chunks: list[bytes]):
for c in chunks:
yield c
async def test_upload_streams_and_appends_message(respx_mock):
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["body"] = request.content # 多部分请求体已完整接收
return httpx.Response(
200,
json={
"success": True,
"job_id": "d4e5f6a7b8c9",
"status_url": "/api/v1/methodology/extract-jobs/d4e5f6a7b8c9",
},
)
respx_mock.post(f"{PRIMARY}{PREFIX}/extract-upload").mock(side_effect=handler)
service = make_service()
result = await service.upload(
filename="wang2019.pdf",
stream=_file_stream([b"%PDF-1.4 mock bytes"]),
content_type="application/pdf",
paper_id="demo_paper",
family="SPL",
output_name="wang2019",
)
assert result == {
"success": True,
"job_id": "d4e5f6a7b8c9",
"status_url": "/api/v1/methodology/extract-jobs/d4e5f6a7b8c9",
"message": "已提交抽取任务",
}
# 请求被发到 primary(8000),multipart 含文件名、form 字段与文件字节
assert captured["url"] == f"{PRIMARY}{PREFIX}/extract-upload"
body = captured["body"]
assert b'name="file"' in body
assert b"wang2019.pdf" in body
assert b'name="paper_id"' in body and b"demo_paper" in body
assert b'name="family"' in body and b"SPL" in body
assert b"%PDF-1.4 mock bytes" in body
await service.close()
+588
View File
@@ -0,0 +1,588 @@
"""services/test_neo4j.py —— Neo4jService 单元测试(FakeDriver,不连真实实例)。
覆盖:实例选择与回退、5s 探测缓存、kg_status / kg_overview /
search_entities / entity / neighborhood 的 Cypher 与结果组装、
非 canonical 实例的 TODO 行为、超时与驱动错误 → Neo4jUnavailable。
"""
from __future__ import annotations
import asyncio
import pytest
from app.services.neo4j import (
CONNECT_TIMEOUT,
DIRECT_ACTIVE_NAME,
FULLTEXT_INDEX,
NAME_INDEX,
NORMALIZED_ACTIVE_NAME,
Neo4jService,
Neo4jUnavailable,
_Instance,
)
def inst(
name: str = DIRECT_ACTIVE_NAME,
uri: str = "bolt://fake-direct:18788",
schema: str = "canonical",
label: str | None = "SRT2CanonicalEntity",
rel: str | None = "SRT2_CANONICAL_ASSERTS",
) -> _Instance:
return _Instance(name, uri, "neo4j", "pw", "neo4j", schema, label, rel)
# ---------- FakeDriver ----------
class FakeResult:
"""模拟 neo4j AsyncResult:可迭代 record(dict 样式)。"""
def __init__(self, rows: list[dict]):
self.rows = rows
def __iter__(self):
return iter(self.rows)
def __len__(self):
return len(self.rows)
class FakeDriver:
"""内存脚本表:{(cypher 关键片段): rows}。匹配取最长注册片段(避免短键误配)。"""
_NO_SCRIPT = object()
def __init__(
self,
scripts: dict[str, list[dict]] | None = None,
delays: dict[str, float] | None = None,
errors: dict[str, Exception] | None = None,
connect_error: Exception | None = None,
):
self.scripts = scripts or {}
self.delays = delays or {}
self.errors = errors or {}
self.connect_error = connect_error
self.calls: list[tuple[str, dict]] = []
self.closed = False
def _match(self, cypher: str):
# 大小写不敏感 + 最长片段优先,避免 "canonical_id: $id" 误配邻域查询
lowered = cypher.lower()
matches = [k for k in self.scripts if k.lower() in lowered]
return max(matches, key=len) if matches else self._NO_SCRIPT
async def execute_query(
self, cypher: str, parameters_: dict | None = None, **kwargs
) -> tuple[FakeResult, object, object]:
self.calls.append((cypher, dict(parameters_ or {})))
if self.connect_error is not None:
raise self.connect_error
key = self._match(cypher)
if key is self._NO_SCRIPT:
raise AssertionError(
f"未注册的 Cypher:{cypher!r}(已注册 {list(self.scripts)})"
)
if key in self.errors:
raise self.errors[key]
if key in self.delays:
await asyncio.sleep(self.delays[key])
return FakeResult(self.scripts[key]), None, None
async def close(self):
self.closed = True
def _canonical_instance(
name: str = DIRECT_ACTIVE_NAME,
uri: str = "bolt://fake-direct:18788",
) -> _Instance:
return inst(name, uri, "canonical", "SRT2CanonicalEntity", "SRT2_CANONICAL_ASSERTS")
def make_service(
driver: FakeDriver,
instances: list[_Instance] | None = None,
) -> Neo4jService:
"""构建已选中实例(跳过探测)的服务:默认只有 canonical 实例,
连接指向测试 driver。"""
if instances is None:
instances = [_canonical_instance()]
service = Neo4jService(instances=instances)
service._drivers = {i.name: driver for i in instances}
service._selected = True
service._active = instances[0]
return service
COUNTS_SCRIPT = "RETURN entity_count, assertion_count"
INDEXES_SCRIPT = "SHOW INDEXES"
ENTITY_ROW = {
"canonical_id": "cent_ospl14",
"name": "OsSPL14",
"entity_type": "gene",
"support_count": 18,
}
def node_row(canonical_id: str, name: str, entity_type: str):
return {
"canonical_id": canonical_id,
"name": name,
"name_norm": name.lower(),
"entity_type": entity_type,
"support_count": 1,
}
def edge_row(src: str, tgt: str, category: str, *, title: str = "Paper"):
return {
"assertion_id": f"cass_{src}_{tgt}",
"source_canonical_id": src,
"target_canonical_id": tgt,
"relation_norm": category,
"relation_category": category,
"paper_count": 3,
"support_count": 2,
"title": title,
"doi": "10.1/x",
"pmid": "1",
}
# ---------- 实例选择与探测 ----------
@pytest.fixture
def tcp_ok(monkeypatch):
"""绕过 TCP 预检(fake URI 无法真实连接),让 FakeDriver 接管。"""
async def reachable(uri: str) -> bool:
return True
monkeypatch.setattr(Neo4jService, "_tcp_reachable", staticmethod(reachable))
async def test_select_defaults_to_direct():
service = make_service(FakeDriver({"RETURN 1": [{"x": 1}]}))
instance = await service.select()
assert instance.name == DIRECT_ACTIVE_NAME
assert instance.uri == "bolt://fake-direct:18788"
async def test_probe_falls_back_to_next_instance_on_connect_error(tcp_ok):
from neo4j import exceptions as neo4j_exc
direct = _canonical_instance()
failing = FakeDriver(
{"RETURN 1": [{"x": 1}], COUNTS_SCRIPT: [], INDEXES_SCRIPT: []},
connect_error=neo4j_exc.ServiceUnavailable("down"),
)
healthy = FakeDriver(
{"RETURN 1": [{"x": 1}], COUNTS_SCRIPT: [], INDEXES_SCRIPT: []}
)
normal = inst(NORMALIZED_ACTIVE_NAME, "bolt://fake-normalized:19788",
schema="normalized", label=None, rel=None)
service = Neo4jService([direct, normal])
service._drivers = {direct.name: failing, normal.name: healthy}
result = await service.probe()
assert result["ok"] is True
assert result["active"] == NORMALIZED_ACTIVE_NAME
async def test_probe_all_down_returns_ok_false(tcp_ok):
from neo4j import exceptions as neo4j_exc
direct = _canonical_instance()
failing = FakeDriver(
{"RETURN 1": [{"x": 1}]},
connect_error=neo4j_exc.ServiceUnavailable("down"),
)
service = Neo4jService([direct])
service._drivers = {direct.name: failing}
result = await service.probe()
assert result["ok"] is False
assert result["active"] is None
assert result["detail"]
# 失败结果缓存 5s:select() 也应抛
with pytest.raises(Neo4jUnavailable):
await service.select()
async def test_probe_result_cached_5s(tcp_ok):
direct = _canonical_instance()
driver = FakeDriver({"RETURN 1": [{"x": 1}], COUNTS_SCRIPT: [
{"entity_count": 1, "assertion_count": 2}
], INDEXES_SCRIPT: [{"name": NAME_INDEX}]})
service = Neo4jService([direct])
service._drivers = {direct.name: driver}
result1 = await service.probe()
assert result1["ok"] is True
assert result1["active"] == DIRECT_ACTIVE_NAME
assert result1["detail"] is None
n_calls = len(driver.calls)
result2 = await service.probe() # 缓存命中:不再打实例
assert result2 == result1
assert len(driver.calls) == n_calls
async def test_probe_extra_indexes_not_reported_as_missing(tcp_ok):
"""BUG-R1-08 回归:同一标签上存在唯一约束索引等其它索引是正常现象
(DISCOVERY §2.2 实测有 srt2_canonical_entity_id),probe 不得把
「索引列表不止 name_norm 一个」误判为缺失或异常。"""
direct = _canonical_instance()
driver = FakeDriver({
"RETURN 1": [{"x": 1}],
COUNTS_SCRIPT: [{"entity_count": 1, "assertion_count": 2}],
# 真实 SHOW INDEXES:name_norm RANGE 索引 + canonical_id 唯一约束索引
INDEXES_SCRIPT: [
{"name": NAME_INDEX},
{"name": "srt2_canonical_entity_id"},
],
})
service = Neo4jService([direct])
service._drivers = {direct.name: driver}
result = await service.probe()
assert result["ok"] is True
assert result["detail"] is None # 修复前此处误报「缺失或异常」
async def test_probe_missing_name_index_reports_detail(tcp_ok):
direct = _canonical_instance()
driver = FakeDriver({
"RETURN 1": [{"x": 1}],
COUNTS_SCRIPT: [{"entity_count": 1, "assertion_count": 2}],
# 只有约束索引,没有 name_norm 索引
INDEXES_SCRIPT: [{"name": "srt2_canonical_entity_id"}],
})
service = Neo4jService([direct])
service._drivers = {direct.name: driver}
result = await service.probe()
assert result["ok"] is True # 索引缺失不导致整体失败
assert result["detail"] is not None
assert "name_norm 索引缺失" in result["detail"]
# ---------- kg_status ----------
async def test_kg_status_shape():
driver = FakeDriver(
{
COUNTS_SCRIPT: [
{"entity_count": 3820996, "assertion_count": 8246875}
],
INDEXES_SCRIPT: [{"name": NAME_INDEX}],
}
)
service = make_service(driver)
status = await service.kg_status()
assert status == {
"ok": True,
"active": DIRECT_ACTIVE_NAME,
"schema": "canonical",
"labels": ["SRT2CanonicalEntity", "SRT2CanonicalAssertion", "SRT2Paper"],
"relationship_types": ["SRT2_CANONICAL_ASSERTS", "SRT2_SUPPORTED_BY"],
"entity_count": 3820996,
"assertion_count": 8246875,
"name_index": NAME_INDEX,
"fulltext_index": FULLTEXT_INDEX,
}
async def test_kg_status_missing_index_reports_null():
driver = FakeDriver(
{
COUNTS_SCRIPT: [{"entity_count": 1, "assertion_count": 2}],
INDEXES_SCRIPT: [],
}
)
service = make_service(driver)
status = await service.kg_status()
assert status["name_index"] is None
assert status["fulltext_index"] is None
async def test_non_canonical_instance_raises_unavailable():
normal = inst(NORMALIZED_ACTIVE_NAME, "bolt://fake-normalized:19788",
schema="normalized", label=None, rel=None)
driver = FakeDriver({"RETURN 1": [{"x": 1}]})
service = make_service(driver, [normal])
with pytest.raises(Neo4jUnavailable):
await service.kg_status()
with pytest.raises(Neo4jUnavailable):
await service.search_entities("OsSPL14")
# ---------- kg_overview ----------
async def test_kg_overview_counts():
driver = FakeDriver(
{
"WITH e.entity_type AS t, count(*) AS c": [
{"type": "gene", "count": 43210},
{"type": "gene_family", "count": 1288},
],
"WITH r.relation_category AS cat, count(*) AS c": [
{"type": "characterized_in", "count": 12033},
],
}
)
service = make_service(driver)
overview = await service.kg_overview()
assert overview["entities_by_type"] == [
{"type": "gene", "count": 43210},
{"type": "gene_family", "count": 1288},
]
assert overview["relations_by_category"] == [
{"type": "characterized_in", "count": 12033}
]
# 不能有拉全量边/实体的危险查询
for cypher, _ in driver.calls:
assert "RETURN e" not in cypher and "RETURN r" not in cypher
# ---------- search_entities ----------
async def test_search_entities_maps_fields_and_normalizes_query():
driver = FakeDriver(
{
"e.name_norm = $q_norm OR e.name_norm STARTS WITH $q_norm": [
ENTITY_ROW,
{**ENTITY_ROW, "canonical_id": "cent_ospl7", "name": "OsSPL7", "support_count": 9},
]
}
)
service = make_service(driver)
results = await service.search_entities("OsSPL14", limit=5)
assert results[0] == {
"id": "cent_ospl14",
"name": "OsSPL14",
"type": "gene",
"support_count": 18,
}
assert len(results) == 2
# toLower 归一化已传到 Cypher 参数
call_params = driver.calls[0][1]
assert call_params["q_norm"] == "osspl14"
assert call_params["limit"] == 5
async def test_search_entities_caps_limit_at_20():
driver = FakeDriver(
{"e.name_norm = $q_norm OR e.name_norm STARTS WITH $q_norm": [ENTITY_ROW]}
)
service = make_service(driver)
await service.search_entities("x", limit=999)
params = driver.calls[0][1]
assert params["limit"] == 20
# ---------- entity ----------
async def test_entity_by_canonical_id():
props = {**node_row("cent_ospl14", "OsSPL14", "gene"), "rule": "exact"}
driver = FakeDriver(
{
"canonical_id: $id": [{"canonical_id": "cent_ospl14"}],
"RETURN e {.*} AS node": [{"node": props}],
}
)
service = make_service(driver)
entity = await service.entity("cent_ospl14")
assert entity == {
"id": "cent_ospl14",
"name": "OsSPL14",
"type": "gene",
"properties": props,
}
async def test_entity_by_name_uses_name_norm():
driver = FakeDriver(
{
"name_norm: $name_norm": [{"canonical_id": "cent_ospl14"}],
"RETURN e {.*} AS node": [{"node": node_row("cent_ospl14", "OsSPL14", "gene")}],
}
)
service = make_service(driver)
entity = await service.entity("OsSPL14")
assert entity is not None and entity["id"] == "cent_ospl14"
assert driver.calls[0][1]["name_norm"] == "osspl14"
async def test_entity_miss_returns_none():
driver = FakeDriver({"name_norm: $name_norm": []})
service = make_service(driver)
assert await service.entity("NoSuchGene") is None
# ---------- neighborhood ----------
async def test_neighborhood_depth1_shape():
driver = FakeDriver(
{
COUNTS_SCRIPT: [],
INDEXES_SCRIPT: [],
"name_norm: $name_norm": [{"canonical_id": "cent_ospl14"}],
"OPTIONAL MATCH (s)-[r1:SRT2_CANONICAL_ASSERTS]": [
{
"start": node_row("cent_ospl14", "OsSPL14", "gene"),
"nodes": [
node_row("cent_panicle", "panicle", "experimental_material"),
node_row("cent_ospl14", "OsSPL14", "gene"), # 重复,应去重
],
"edges": [
edge_row("cent_ospl14", "cent_panicle", "expressed_in"),
],
}
],
}
)
service = make_service(driver)
result = await service.neighborhood("OsSPL14", depth=1, limit=50)
assert result["center"] == "cent_ospl14"
assert result["center_name"] == "OsSPL14"
assert result["depth"] == 1
assert result["nodes"] == [
{"id": "cent_ospl14", "label": "OsSPL14", "type": "gene"},
{"id": "cent_panicle", "label": "panicle", "type": "experimental_material"},
]
assert len(result["edges"]) == 1
edge = result["edges"][0]
assert edge["source"] == "cent_ospl14"
assert edge["target"] == "cent_panicle"
assert edge["relation"] == "expressed_in"
assert edge["label"] == "expressed_in"
assert edge["assertion_id"] == "cass_cent_ospl14_cent_panicle"
assert edge["paper_count"] == 3
assert edge["support_count"] == 2
assert edge["title"] == "Paper"
assert edge["doi"] == "10.1/x"
assert edge["pmid"] == "1"
assert edge["chain_id"] is None and edge["hop"] is None
# limit 已下压进 Cypher 参数
params = driver.calls[-1][1]
assert params["max_nodes"] == 50 and params["max_edges"] == 50
# depth=1 绝不能执行第二跳(两跳实测约 13s,不默认开启)
for cypher, _ in driver.calls:
assert "r2:SRT2_CANONICAL_ASSERTS" not in cypher
async def test_neighborhood_depth2_uses_second_hop():
driver = FakeDriver(
{
COUNTS_SCRIPT: [],
INDEXES_SCRIPT: [],
"canonical_id: $id": [{"canonical_id": "cent_ospl14"}],
"OPTIONAL MATCH (s)-[r1:SRT2_CANONICAL_ASSERTS]": [
{
"start": node_row("cent_ospl14", "OsSPL14", "gene"),
"nodes": [
node_row("cent_panicle", "panicle", "experimental_material"),
node_row("cent_branching", "panicle branching", "process"),
],
"edges": [edge_row("cent_ospl14", "cent_panicle", "expressed_in")],
}
],
"OPTIONAL MATCH (n1)-[r2:SRT2_CANONICAL_ASSERTS]": [
{
"start": node_row("cent_ospl14", "OsSPL14", "gene"),
"nodes": [
node_row("cent_panicle", "panicle", "experimental_material"),
node_row("cent_mir156", "miR156", "sequence_feature"),
],
"edges": [
edge_row("cent_ospl14", "cent_panicle", "expressed_in"),
edge_row("cent_panicle", "cent_mir156", "regulated_by"),
],
}
],
}
)
service = make_service(driver)
result = await service.neighborhood("cent_ospl14", depth=2, limit=50)
assert result["depth"] == 2
# 一跳 + 二跳节点合并去重
ids = [n["id"] for n in result["nodes"]]
assert "cent_mir156" in ids
assert len(ids) == len(set(ids))
# 二跳边也出现(最长匹配选中的是 r2 脚本)
relations = {e["relation"] for e in result["edges"]}
assert "regulated_by" in relations
# 确认深度 2 时执行了带 r2 的 Cypher
assert any("r2:SRT2_CANONICAL_ASSERTS" in c for c, _ in driver.calls)
async def test_neighborhood_isolated_node_keeps_center():
driver = FakeDriver(
{
"canonical_id: $id": [{"canonical_id": "cent_lonely"}],
"OPTIONAL MATCH (s)-[r1:SRT2_CANONICAL_ASSERTS]": [
{"start": node_row("cent_lonely", "lonely gene", "gene"),
"nodes": [], "edges": []}
],
}
)
service = make_service(driver)
result = await service.neighborhood("cent_lonely", depth=1, limit=50)
assert result["center"] == "cent_lonely"
assert result["nodes"] == [
{"id": "cent_lonely", "label": "lonely gene", "type": "gene"}
]
assert result["edges"] == []
async def test_neighborhood_miss_returns_empty_center():
driver = FakeDriver({"name_norm: $name_norm": []})
service = make_service(driver)
result = await service.neighborhood("NoSuch", depth=1, limit=50)
assert result["center"] is None
assert result["nodes"] == [] and result["edges"] == []
async def test_neighborhood_clamps_depth_and_limit():
driver = FakeDriver(
{
"canonical_id: $id": [{"canonical_id": "cent_x"}],
"OPTIONAL MATCH (s)-[r1:SRT2_CANONICAL_ASSERTS]": [
{"start": node_row("cent_x", "x", "gene"), "nodes": [], "edges": []}
],
}
)
service = make_service(driver)
result = await service.neighborhood("cent_x", depth=5, limit=9999)
assert result["depth"] == 1
params = driver.calls[-1][1]
assert params["max_nodes"] == 200
# ---------- 超时与驱动错误 ----------
async def test_query_timeout_raises_unavailable(monkeypatch):
import app.services.neo4j as neo4j_mod
# 调短查询超时以在单测内触发 asyncio.wait_for
monkeypatch.setattr(neo4j_mod, "QUERY_TIMEOUT", 0.05)
slow = FakeDriver(
{"name_norm: $name_norm": []},
delays={"name_norm: $name_norm": 0.5},
)
service = make_service(slow)
with pytest.raises(Neo4jUnavailable):
await service.entity("OsSPL14")
async def test_driver_error_raises_unavailable():
from neo4j import exceptions as neo4j_exc
failing = FakeDriver(
{"name_norm: $name_norm": []},
errors={"name_norm: $name_norm": neo4j_exc.ServiceUnavailable("boom")},
)
service = make_service(failing)
with pytest.raises(Neo4jUnavailable):
await service.entity("OsSPL14")
async def test_close_closes_all_drivers():
driver = FakeDriver({})
service = make_service(driver)
await service.close()
assert driver.closed is True
+205
View File
@@ -0,0 +1,205 @@
"""services/test_normalize.py —— 归一化器单测。
输入用 backend/mock/fixtures/upstream_real_shape_sample.json(真实上游形态样本,
DISCOVERY.md §4.1),断言 API.md §7/§8/§9 的归一化输出关键字段。
fixture 是单一事实源(ARCHITECTURE.md §6),本测试同时守护 mock/BFF 契约一致。
"""
from __future__ import annotations
import json
from pathlib import Path
from app.services.normalize import (
extract_chains,
normalize_graph,
normalize_hop,
normalize_qa_response,
normalize_source,
pick,
)
FIXTURE = (
Path(__file__).resolve().parents[2]
/ "mock" / "fixtures" / "upstream_real_shape_sample.json"
)
def load_raw() -> dict:
return json.loads(FIXTURE.read_text(encoding="utf-8"))
# ---------------------------------------------------------------------------
# pick
# ---------------------------------------------------------------------------
def test_pick_first_existing_key():
obj = {"b": 2, "c": 3}
assert pick(obj, "a", "b", "c") == 2
assert pick(obj, "a") is None
assert pick(obj, "a", default="x") == "x"
assert pick(obj, "n", "b", default="y") == 2
def test_pick_skips_none_values():
assert pick({"a": None, "b": 1}, "a", "b") == 1
# ---------------------------------------------------------------------------
# §8 SourceItem
# ---------------------------------------------------------------------------
def test_normalize_source_all_keys_present():
src = normalize_source(load_raw()["sources"][0])
expected_keys = {
"id", "title", "paper_id", "chunk_id", "pmid", "pmcid", "doi", "url",
"snippet", "score", "locator", "section", "support_count", "type", "metadata",
}
assert expected_keys <= set(src.keys()) # 所有字段键必须存在(缺失为 null)
assert src["id"] == "S1" # evidence_id → id
assert src["title"] == "Genome-wide analysis of the SPL gene family in pecan"
assert src["paper_id"] == "PMC5488377_12864_2017_Article_3882"
assert src["pmid"] == "28655295"
assert src["doi"] == "10.1186/s12864-017-3882-4"
assert src["score"] == 0.982 # final_score → score
assert src["snippet"].startswith("## Phylogenetic")
assert src["url"] == "https://doi.org/10.1186/s12864-017-3882-4"
# 上游 item 的 source 是断言主体实体名(非论文标题),落 metadata.source_entity
assert src["metadata"]["source_entity"] == "SPL gene family"
assert src["metadata"]["assertion_id"] == "cass_d12f62f9abffbd903b97"
assert src["metadata"]["vector_score"] == 0.714
assert src["metadata"]["rerank_score"] == 0.989
assert src["metadata"]["output_layer"] == "main_kg"
def test_normalize_source_title_preferred_over_source():
src = normalize_source({"title": "Real Title", "source": "Entity Name"})
assert src["title"] == "Real Title"
assert src["metadata"]["source_entity"] == "Entity Name"
def test_normalize_source_only_doi_derives_url():
src = normalize_source({"doi": "10.1000/xyz"})
assert src["url"] == "https://doi.org/10.1000/xyz"
assert src["title"] is None
assert src["metadata"] == {}
def test_normalize_source_alias_chains():
src = normalize_source({
"content": "snippet text",
"doc_id": "doc-1",
"confidence": 0.5,
"assertion_id": "cass_x",
})
assert src["snippet"] == "snippet text"
assert src["paper_id"] == "doc-1"
assert src["score"] == 0.5
assert src["metadata"]["assertion_id"] == "cass_x"
# ---------------------------------------------------------------------------
# §9 图谱
# ---------------------------------------------------------------------------
def test_normalize_hop_aliases():
hop = normalize_hop({
"from_name": "A", "from_id": "cent_a", "source_type": "gene",
"to_name": "B", "to_id": "cent_b", "target_type": "species",
"relation": "characterized_in", "paper_id": "P1", "pmid": "1",
"pmcid": None, "doi": "10.1/x",
})
assert hop["source"] == "A"
assert hop["target"] == "B"
assert hop["source_id"] == "cent_a"
assert hop["target_id"] == "cent_b"
assert hop["source_type"] == "gene"
assert hop["target_type"] == "species"
assert hop["relation"] == "characterized_in"
assert hop["paper_id"] == "P1"
def test_extract_chains_both_shapes():
raw = load_raw()
chains = extract_chains(raw) # 优先 native_graph_chains(列表形态)
assert len(chains) == 1
assert chains[0]["chain_id"] == "G1"
assert len(chains[0]["hops"]) == 1
# dict 形态(graph_knowledge_chains)→ 取 .chains
chains_dict = extract_chains({"graph_knowledge_chains": raw["graph_knowledge_chains"]})
assert len(chains_dict) == 1
assert chains_dict[0]["chain_id"] == "G1"
assert len(chains_dict[0]["hops"]) == 2
def test_normalize_graph_nodes_edges_chains():
g = normalize_graph(load_raw())
assert isinstance(g, dict) and set(g.keys()) == {"nodes", "edges", "chains"}
assert len(g["chains"]) == 1
assert g["chains"][0]["chain_id"] == "G1"
assert len(g["chains"][0]["hops"]) == 1 # native_graph_chains 里只有 1 跳
# 节点:按 id 去重,label 取名字、type 取 source_type/target_type
ids = [n["id"] for n in g["nodes"]]
assert "cent_spl" in ids and "cent_oryza" in ids
assert len(ids) == len(set(ids))
by_id = {n["id"]: n for n in g["nodes"]}
assert by_id["cent_spl"]["label"] == "SPL gene"
assert by_id["cent_spl"]["type"] == "gene_family"
assert by_id["cent_oryza"]["type"] == "species"
# 边:带 chain_id / hop / paper_id
assert len(g["edges"]) == 1
edge = g["edges"][0]
assert edge["source"] == "cent_spl"
assert edge["target"] == "cent_oryza"
assert edge["relation"] == "characterized_in"
assert edge["label"] == "characterized_in"
assert edge["chain_id"] == "G1"
assert edge["hop"] == 0
assert edge["paper_id"] == "PMC5488377_12864_2017_Article_3882"
# ---------------------------------------------------------------------------
# §7 归一化 QA 响应
# ---------------------------------------------------------------------------
def test_normalize_qa_response():
out = normalize_qa_response(load_raw())
assert out["query_id"] == "20260825_000715_49f801a9"
assert out["version"] == "1.1.73"
assert out["answer"] == "" # search 模式空
assert out["success"] is True
assert out["source_count"] == 1
assert isinstance(out["sources"], list) and len(out["sources"]) == 1
assert out["sources"][0]["id"] == "S1"
assert out["used_sources"] == []
# timing:真实键 timings → timing
assert out["timing"]["total"] == 91.9
# citation_validation → citation_map
assert out["citation_map"]["valid"] is True
# query_plan → retrieval_plan
assert "agentic_plan" in out["retrieval_plan"]
# graph 与 chains 同源
assert len(out["graph"]["chains"]) == 1
assert out["chains"][0]["chain_id"] == "G1"
# raw 保存原始完整响应
assert out["raw"]["question"] == "What is the SPL gene family?"
def test_normalize_qa_response_alt_aliases():
out = normalize_qa_response({
"response": "answer text",
"citations": [{"evidence_id": "E1", "paper_title": "T", "final_score": 0.9}],
"used_sources": [],
"agentic_plan": {"anchor": "x"},
"timing": {"total_s": 1.0},
"citation_map": {"valid": False},
"notes": "n",
})
assert out["answer"] == "answer text"
assert out["sources"][0]["title"] == "T"
assert out["retrieval_plan"] == {"anchor": "x"}
assert out["timing"] == {"total_s": 1.0}
assert out["citation_map"] == {"valid": False}
assert out["retrieval_notes"] == "n"
+498
View File
@@ -0,0 +1,498 @@
"""test_sse_relay.py —— sse_relay 单元测试(A2 归属)。
全部测试对注入的假数据/假 client 运行,不依赖真实上游。看门狗测试用缩短的
超时参数注入(idle_timeout / keepalive_interval),避免真实 240s。
环境无 respx/pytest-asyncio:异步测试用 anyio 插件(pytest.mark.anyio)。
"""
from __future__ import annotations
import json
import pytest
from app.services import sse_relay
from app.services.sse_relay import (
STAGE_LABEL,
STAGE_PHASE,
UNKNOWN_STAGE_PHASE,
_classify,
phase_for,
relay_stream,
)
def _frame_texts(frames: list[str]) -> list[str]:
return [f for f in frames if f.strip()]
def _event_data_map(frames: list[str]) -> dict[str, list[dict]]:
"""把 SSE 帧序列解析为 {event_name: [data_dict, ...]}。"""
out: dict[str, list[dict]] = {}
event = None
for frame in frames:
for line in frame.split("\n"):
if line.startswith("event:"):
event = line[len("event:"):].strip()
out.setdefault(event, [])
elif line.startswith("data:") and event:
out[event].append(json.loads(line[len("data:"):].strip()))
return out
def _sse_bytes(events: list[tuple[bytes, bytes]]) -> list[bytes]:
return [b"event: " + e + b"\ndata: " + d + b"\n\n" for e, d in events]
# ===========================================================================
# STAGE_PHASE / STAGE_LABEL 全覆盖
# ===========================================================================
def test_stage_phase_maps_all_11_known_stages():
expected = {
"understanding_started": ("planning", "开始理解问题"),
"entities_identified": ("planning", "已识别实体"),
"related_search_started": ("planning", "开始关联检索"),
"concepts_found": ("retrieval", "发现相关概念"),
"subquestion_retrieved": ("retrieval", "子问题召回完成"),
"agent_review_started": ("retrieval", "开始智能体审核"),
"subquestion_retry_started": ("retrieval", "子问题重试中"),
"evidence_accepted": ("evidence", "证据已采纳"),
"evidence_review_completed": ("evidence", "证据审核完成"),
"answer_generation_started": ("generation", "开始生成答案"),
"completed": ("done", "检索完成"),
}
assert len(STAGE_PHASE) == 11
assert len(STAGE_LABEL) == 11
for stage, (phase, label) in expected.items():
assert STAGE_PHASE[stage] == phase
assert STAGE_LABEL[stage] == label
assert phase_for("not_a_real_stage") == (UNKNOWN_STAGE_PHASE, "not_a_real_stage")
def test_unknown_stage_falls_back_to_retrieval():
assert STAGE_PHASE.get("future_stage_xyz") is None
phase, label = phase_for("future_stage_xyz")
assert phase == "retrieval"
assert label == "future_stage_xyz"
# ===========================================================================
# 帧分类:无 event 名按形态、多行 data、: 注释
# ===========================================================================
def test_classify_stage_payload():
kind, payload = _classify("stage", '{"stage":"entities_identified","details":{"entities":["OsSPL14"]}}')
assert kind == "progress"
assert payload["stage"] == "entities_identified"
def test_classify_terminal_complete():
kind, payload = _classify("complete", '{"answer":"xxx","success":true,"sources":[]}')
assert kind == "complete"
def test_classify_no_event_name_by_shape():
# 无 event 名:按 payload 形态分类
assert _classify(None, '{"stage":"concepts_found","details":{}}')[0] == "progress"
assert _classify(None, '{"answer":"...","sources":[]}')[0] == "complete"
assert _classify(None, '{"success":false,"detail":{"error_code":"x"}}')[0] == "complete" # 含 success 判终态
assert _classify(None, '{"foo":1}')[0] == "raw"
def test_classify_error_event():
kind, payload = _classify("error", '{"detail":{"error_code":"invalid_llm_citation"}}')
assert kind == "error"
assert payload["code"] == "INVALID_CITATION"
def test_classify_raw_never_drops():
# 非 JSON data
kind, payload = _classify(None, "not-json-at-all")
assert kind == "raw"
assert payload["payload"] == "not-json-at-all"
# 无法分类的 JSON
kind, payload = _classify("mystery", '{"unexpected":true}')
assert kind == "raw"
assert payload == {"unexpected": True}
# JSON 数组
kind, payload = _classify(None, "[1,2,3]")
assert kind == "raw"
assert payload == [1, 2, 3]
# 空 data 但有 event 名
kind, payload = _classify("some_event", "")
assert kind == "raw"
assert payload["event_name"] == "some_event"
# ===========================================================================
# 中继整体:mock client 注入
# ===========================================================================
class FakeUpstreamStream:
"""httpx stream 上下文的假实现:按给定 chunks 序列吐字节。
silent=True 时 aiter_raw 永久挂起(模拟上游连接保持但完全静默)。
"""
def __init__(self, chunks: list[bytes], status: int = 200, headers: dict | None = None,
json_body: dict | None = None, silent: bool = False):
self._chunks = list(chunks)
self.status_code = status
self.headers = headers or {}
self._json = json_body
self._silent = silent
async def aiter_raw(self):
if self._silent:
import anyio
await anyio.sleep_forever()
return # pragma: no cover
for c in self._chunks:
yield c
async def aread(self):
return json.dumps(self._json).encode() if self._json is not None else b""
class _StreamCtx:
def __init__(self, response: FakeUpstreamStream):
self._resp = response
async def __aenter__(self):
return self._resp
async def __aexit__(self, exc_type, exc, tb):
return False
class FakeClient:
"""按顺序弹出响应的假 httpx.AsyncClient;记录请求供断言。"""
def __init__(self, responses: list[FakeUpstreamStream], exc: Exception | None = None):
self._responses = list(responses)
self._exc = exc
self.calls: list[dict] = []
def stream(self, method, url, **kwargs):
self.calls.append({"method": method, "url": url, **kwargs})
if self._exc is not None:
exc, self._exc = self._exc, None
raise exc
resp = self._responses.pop(0)
return _StreamCtx(resp)
def _collect(agen) -> list[str]:
import asyncio
async def run():
return [f async for f in agen]
return asyncio.run(run())
def test_relay_emits_progress_complete_sequence():
import anyio
frames = [
("stage", '{"stage":"understanding_started","details":{}}'),
("stage", '{"stage":"entities_identified","details":{"entities":["OsSPL14"]}}'),
("stage", '{"stage":"completed","details":{"elapsed_seconds":38.3}}'),
("complete", '{"answer":"final answer","success":true,"sources":[{"title":"T","score":0.9}]}'),
]
client = FakeClient([FakeUpstreamStream(_sse_bytes([(e.encode(), d.encode()) for e, d in frames]))])
async def run():
out = [f async for f in relay_stream({"question": "q"}, client=client, base_url="http://up")]
return out
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert len(events["progress"]) == 3
assert events["progress"][0]["stage"] == "understanding_started"
assert events["progress"][0]["phase"] == "planning"
assert events["progress"][1]["phase"] == "planning"
assert events["progress"][2]["phase"] == "done"
assert events["complete"][0]["mode"] == "chat"
assert "at" in events["progress"][0]
assert client.calls[0]["url"] == "http://up/api/v1/qa-v1-1-73/chat/stream"
assert client.calls[0]["json"] == {"question": "q"}
def test_relay_multiline_data_and_comment_frames():
import anyio
comment = b": keepalive\n\n"
chunks = [
b"event: stage\ndata: {\"stage\":\"evidence_accepted\",\n",
b"data: \"details\":{\"accepted_count\":12}}\n\n",
comment,
b"event: complete\ndata: {\"answer\":\"A\"}\n\n",
]
client = FakeClient([FakeUpstreamStream(chunks)])
async def run():
return [f async for f in relay_stream({"question": "q"}, client=client, base_url="http://up")]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert "progress" in events
assert events["progress"][0]["details"] == {"accepted_count": 12}
assert "complete" in events
assert all(not f.startswith(": keepalive") for f in _frame_texts(out))
def test_relay_no_event_name_classified_by_shape():
import anyio
# 上游不带 event: 行,纯 data: JSON(模拟变体)
chunks = [
b'data: {"stage":"concepts_found","details":{}}\n\n',
b'data: {"answer":"A","success":true}\n\n',
]
client = FakeClient([FakeUpstreamStream(chunks)])
async def run():
return [f async for f in relay_stream({"q": 1}, client=client, base_url="http://up")]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert events["progress"][0]["phase"] == "retrieval"
assert events["complete"][0]["answer"] == "A"
def test_relay_unknown_json_goes_raw():
import anyio
chunks = [
b"data: {\"mystery\":\"payload\"}\n\n",
b"event: complete\ndata: {\"answer\":\"A\"}\n\n",
]
client = FakeClient([FakeUpstreamStream(chunks)])
async def run():
return [f async for f in relay_stream({}, client=client, base_url="http://up")]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert events["raw"] == [{"mystery": "payload"}]
assert "complete" in events
def test_relay_unknown_stage_maps_to_retrieval_with_raw_label():
import anyio
chunks = [b'data: {"stage":"brand_new_stage","details":{"x":1}}\n\n']
client = FakeClient([FakeUpstreamStream(chunks)])
async def run():
return [f async for f in relay_stream({}, client=client, base_url="http://up")]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert events["progress"][0]["phase"] == "retrieval"
assert events["progress"][0]["label"] == "brand_new_stage"
def test_relay_error_event_closes():
import anyio
chunks = [
b'event: error\ndata: {"detail":{"error_code":"invalid_llm_citation"}}\n\n',
b"data: {\"stage\":\"completed\"}\n\n", # 关闭后不应被处理
]
client = FakeClient([FakeUpstreamStream(chunks)])
async def run():
return [f async for f in relay_stream({}, client=client, base_url="http://up")]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert list(events.keys()) == ["error"]
assert events["error"][0]["code"] == "INVALID_CITATION"
def test_relay_upstream_close_before_terminal_reports_error():
import anyio
chunks = [b'data: {"stage":"concepts_found","details":{}}\n\n'] # 无 complete 即 EOF
client = FakeClient([FakeUpstreamStream(chunks)])
async def run():
return [f async for f in relay_stream({}, client=client, base_url="http://up")]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
# 残余帧先转发(绝不丢数据),再报错误
assert "progress" in events
assert events["error"][0]["code"] == "UPSTREAM_ERROR"
# ===========================================================================
# 看门狗(缩短超时注入)
# ===========================================================================
def test_watchdog_timeout_after_idle():
import anyio
# 上游连接保持但完全静默;idle_timeout=0.3s 注入
client = FakeClient([FakeUpstreamStream([], silent=True)])
async def run():
return [
f
async for f in relay_stream(
{}, client=client, base_url="http://up", idle_timeout=0.3, keepalive_interval=0.15
)
]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert list(events.keys()) == ["timeout"]
assert events["timeout"][0]["message"] == "检索超时"
def test_watchdog_ping_before_timeout():
import anyio
client = FakeClient([FakeUpstreamStream([], silent=True)])
async def run():
return [
f
async for f in relay_stream(
{}, client=client, base_url="http://up", idle_timeout=0.6, keepalive_interval=0.2
)
]
out = anyio.run(run)
pings = [f for f in _frame_texts(out) if f.startswith(": ping")]
assert len(pings) >= 1
events = _event_data_map(_frame_texts(out))
assert "timeout" in events
def test_watchdog_does_not_fire_when_events_keep_coming():
import anyio
# 上游在 idle_timeout 窗口内持续给事件(逐块喂,模拟慢速流)
chunks = [
b'data: {"stage":"understanding_started"}\n\n',
b'data: {"stage":"entities_identified"}\n\n',
b'event: complete\ndata: {"answer":"A"}\n\n',
]
client = FakeClient([FakeUpstreamStream(chunks)])
async def run():
return [
f
async for f in relay_stream(
{}, client=client, base_url="http://up", idle_timeout=10.0, keepalive_interval=0.05
)
]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert "complete" in events
assert "timeout" not in events
# ===========================================================================
# 上游 HTTP 头阶段失败:invalid_llm_citation 重试一次 → 仍失败 → error
# ===========================================================================
def _http_error_response(body: dict, status: int) -> FakeUpstreamStream:
return FakeUpstreamStream([], status=status, json_body=body)
def test_http_error_402_invalid_citation_retries_then_errors():
import anyio
client = FakeClient([
_http_error_response({"detail": {"error_code": "invalid_llm_citation"}}, 402),
_http_error_response({"detail": {"error_code": "invalid_llm_citation"}}, 402),
])
async def run():
return [f async for f in relay_stream({}, client=client, base_url="http://up")]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert list(events.keys()) == ["error"]
assert events["error"][0]["code"] == "INVALID_CITATION"
assert events["error"][0]["retryable"] is True
assert len(client.calls) == 2 # 重试一次 = 总共 2 次请求
def test_http_error_invalid_citation_retries_then_succeeds():
import anyio
good = FakeUpstreamStream(
[b'event: complete\ndata: {"answer":"ok"}\n\n'],
status=200,
)
client = FakeClient([
_http_error_response({"detail": {"error_code": "invalid_llm_citation"}}, 402),
good,
])
async def run():
return [f async for f in relay_stream({}, client=client, base_url="http://up")]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert "complete" in events
assert "error" not in events
assert len(client.calls) == 2
def test_http_error_other_status_no_retry():
import anyio
client = FakeClient([
_http_error_response({"detail": "boom"}, 500),
])
async def run():
return [f async for f in relay_stream({}, client=client, base_url="http://up")]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert events["error"][0]["code"] == "UPSTREAM_ERROR"
assert len(client.calls) == 1
def test_connect_error_emits_upstream_down():
import anyio
import httpx
client = FakeClient([], exc=httpx.ConnectError("conn refused", request=None))
async def run():
return [f async for f in relay_stream({}, client=client, base_url="http://up")]
out = anyio.run(run)
events = _event_data_map(_frame_texts(out))
assert events["error"][0]["code"] == "UPSTREAM_DOWN"
# ===========================================================================
# 工具函数
# ===========================================================================
def test_sse_headers_contract():
assert sse_relay.sse_headers["Cache-Control"] == "no-cache"
assert sse_relay.sse_headers["X-Accel-Buffering"] == "no"
def test_error_payload_shapes():
assert sse_relay.error_payload("TIMEOUT") == {"code": "TIMEOUT", "message": "检索超时", "retryable": True}
assert sse_relay.error_payload("UPSTREAM_DOWN")["retryable"] is False
assert sse_relay.error_payload("INVALID_CITATION")["retryable"] is True
+75
View File
@@ -0,0 +1,75 @@
"""app/test_db.py —— SQLite 层单测(aiosqlite + WAL,幂等迁移,JSON 编解码)。
覆盖:connect 建表/索引、重复 connect 幂等、外键级联删除、JSON 编解码容错。
"""
from __future__ import annotations
import pytest
from app.db import Database, dumps_json, loads_json
async def test_connect_creates_schema_and_wal(tmp_path):
db = Database(tmp_path / "t1" / "plantai.db")
await db.connect()
try:
# WAL 模式
row = await db.fetchone("PRAGMA journal_mode")
assert row[0].lower() == "wal"
# 表存在
tables = await db.fetchall("SELECT name FROM sqlite_master WHERE type='table'")
names = {t["name"] for t in tables}
assert {"sessions", "messages"} <= names
# 索引存在
indexes = await db.fetchall(
"SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'"
)
inames = {i["name"] for i in indexes}
assert "idx_messages_session" in inames
assert "idx_sessions_updated" in inames
finally:
await db.close()
async def test_connect_idempotent(tmp_path):
db = Database(tmp_path / "t2" / "plantai.db")
await db.connect()
await db.close()
await db.connect() # 二次迁移不报错
await db.close()
async def test_cascade_delete_messages(tmp_path):
db = Database(tmp_path / "t3" / "plantai.db")
await db.connect()
try:
await db.execute(
"INSERT INTO sessions (id, title, status, meta, created_at, updated_at) "
"VALUES ('s1', 't', 'idle', '{}', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')"
)
await db.execute(
"INSERT INTO messages (id, session_id, role, kind, status, blocks, raw, text, "
"created_at, updated_at) VALUES "
"('m1', 's1', 'user', 'qa', 'done', '[]', NULL, 'hi', "
"'2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')"
)
row = await db.fetchone("SELECT COUNT(*) AS n FROM messages WHERE session_id='s1'")
assert row["n"] == 1
await db.execute("DELETE FROM sessions WHERE id='s1'")
row = await db.fetchone("SELECT COUNT(*) AS n FROM messages WHERE session_id='s1'")
assert row["n"] == 0 # ON DELETE CASCADE
finally:
await db.close()
def test_dumps_loads_json_roundtrip():
payload = {"a": 1, "b": [1, 2], "c": {"d": "中文"}}
assert loads_json(dumps_json(payload)) == payload
assert loads_json(None, default=[]) == []
assert loads_json("not-json", default={}) == {}
def test_dumps_json_fallback_on_unserializable():
import threading
assert dumps_json({"lock": threading.Lock()}, default=None) == "null"
+243
View File
@@ -0,0 +1,243 @@
"""app/test_sessions_api.py —— 会话/消息路由集成单测(API.md §5 / §1 信封)。
TestClient + 临时 SQLite(dependency_overrides 指向临时 Database),
不触发 app lifespan(不跑 init_db/上游探测)。
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from app.db import Database, dumps_json, get_db
from app.main import app
from app.upstream import envelope
@pytest.fixture()
async def db(tmp_path):
database = Database(tmp_path / "api" / "plantai.db")
await database.connect()
yield database
await database.close()
@pytest.fixture()
def client(db):
app.dependency_overrides[get_db] = lambda: db
try:
yield TestClient(app)
finally:
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# 错误信封构造器(§1)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"code,expected_status",
[
("UPSTREAM_DOWN", 503),
("UPSTREAM_ERROR", 502),
("TIMEOUT", 504),
("INVALID_CITATION", 502),
("NOT_FOUND", 404),
("VALIDATION", 422),
("INTERNAL", 500),
],
)
def test_envelope_defaults(code, expected_status):
body, status = envelope(code)
assert status == expected_status
assert body["ok"] is False
assert body["error"]["code"] == code
assert isinstance(body["error"]["message"], str) and body["error"]["message"]
assert "degraded" in body["error"]
def test_envelope_custom():
body, status = envelope("UPSTREAM_DOWN", message="自定义", degraded=True, status=503)
assert body["error"]["message"] == "自定义"
assert body["error"]["degraded"] is True
# ---------------------------------------------------------------------------
# 会话 CRUD 全链路
# ---------------------------------------------------------------------------
def test_session_crud_full_flow(client):
# POST(默认标题 + 自定义标题)
r = client.post("/api/sessions", json={})
assert r.status_code == 201, r.text
sess = r.json()
assert sess["title"] == "新会话"
assert sess["status"] == "idle"
assert sess["message_count"] == 0
assert sess["meta"] == {"title_manual": False}
r2 = client.post("/api/sessions", json={"title": " 水稻SPL "})
sess2 = r2.json()
assert sess2["title"] == "水稻SPL"
# GET 列表(updated_at 倒序,最新在前)
r = client.get("/api/sessions")
assert r.status_code == 200
items = r.json()
assert [s["id"] for s in items][:2] == [sess2["id"], sess["id"]]
# POST user 消息
r = client.post(f"/api/sessions/{sess['id']}/messages", json={"role": "user", "text": "问题原文"})
assert r.status_code == 201, r.text
msg = r.json()
assert msg["role"] == "user" and msg["status"] == "done"
assert msg["text"] == "问题原文"
assert msg["blocks"] == [] and msg["raw"] is None
# POST assistant 流式骨架
r = client.post(
f"/api/sessions/{sess['id']}/messages",
json={
"role": "assistant", "kind": "qa", "status": "streaming",
"blocks": [{"type": "pipeline", "status": "running", "steps": []}],
},
)
assert r.status_code == 201, r.text
skeleton = r.json()
assert skeleton["status"] == "streaming"
assert skeleton["blocks"][0]["type"] == "pipeline"
assert "status" in skeleton["blocks"][0]
# 消息活动后会话状态 running、message_count 更新
r = client.get(f"/api/sessions/{sess['id']}")
detail = r.json()
assert detail["status"] == "running"
assert detail["message_count"] == 2
assert [m["role"] for m in detail["messages"]] == ["user", "assistant"]
# PATCH 消息(流式检查点:status/blocks/raw)
r = client.patch(
f"/api/sessions/{sess['id']}/messages/{skeleton['id']}",
json={
"status": "done",
"blocks": [{"type": "text", "html": "<p>最终答案</p>"}],
"raw": {"answer": "最终答案"},
},
)
assert r.status_code == 200, r.text
updated = r.json()
assert updated["status"] == "done"
assert updated["blocks"] == [{"type": "text", "html": "<p>最终答案</p>"}]
assert updated["raw"] == {"answer": "最终答案"}
# PATCH 会话标题 → title_manual=true
r = client.patch(f"/api/sessions/{sess['id']}", json={"title": "手工标题"})
assert r.status_code == 200
assert r.json()["meta"] == {"title_manual": True}
# PATCH 会话状态
r = client.patch(f"/api/sessions/{sess['id']}", json={"status": "done"})
assert r.status_code == 200
assert r.json()["status"] == "done"
# DELETE 204 + 级联
r = client.delete(f"/api/sessions/{sess['id']}")
assert r.status_code == 204
r = client.get(f"/api/sessions/{sess['id']}")
assert r.status_code == 404
body = r.json()
assert body["ok"] is False
assert body["error"]["code"] == "NOT_FOUND"
def test_not_found_envelopes(client):
cases = [
("get", "/api/sessions/nope", None),
("patch", "/api/sessions/nope", {"title": "x"}),
("delete", "/api/sessions/nope", None),
("post", "/api/sessions/nope/messages", {"role": "user", "text": "hi"}),
("get", "/api/sessions/nope/sources", None),
]
for method, url, body in cases:
kwargs = {"json": body} if body is not None else {}
r = getattr(client, method)(url, **kwargs)
assert r.status_code == 404, (method, url)
payload = r.json()
assert payload["ok"] is False
assert payload["error"]["code"] == "NOT_FOUND"
r = client.patch("/api/sessions/nope/messages/nope-msg", json={"status": "done"})
assert r.status_code == 404
assert r.json()["error"]["code"] == "NOT_FOUND"
def test_message_404_when_session_exists_but_message_missing(client):
r = client.post("/api/sessions", json={"title": "t"})
sid = r.json()["id"]
r = client.patch(f"/api/sessions/{sid}/messages/ghost", json={"status": "done"})
assert r.status_code == 404
assert r.json()["error"]["code"] == "NOT_FOUND"
def test_empty_title_falls_back_to_default(client):
r = client.post("/api/sessions", json={"title": " "})
assert r.json()["title"] == "新会话"
# ---------------------------------------------------------------------------
# 来源聚合(GET /api/sessions/{id}/sources)
# ---------------------------------------------------------------------------
def _source_item(i: str, paper: str | None, title: str) -> dict:
return {
"id": i, "title": title, "paper_id": paper, "chunk_id": None,
"pmid": None, "pmcid": None, "doi": None, "url": None,
"snippet": "…", "score": 0.9, "locator": None, "section": None,
"support_count": None, "type": "assertion", "metadata": {},
}
async def test_sources_aggregation(client, db):
r = client.post("/api/sessions", json={"title": "聚合"})
sid = r.json()["id"]
# 两个助手消息各带一个 sources 块
blocks1 = [
{
"type": "sources",
"items": [
_source_item("a1", "PAPER_A", "Paper A"),
_source_item("b1", "PAPER_B", "Paper B"),
_source_item("x1", None, "No paper id"),
],
}
]
blocks2 = [
{
"type": "sources",
"items": [
_source_item("a2", "PAPER_A", "Paper A"),
_source_item("x2", None, "No paper id"),
],
}
]
await db.execute(
"INSERT INTO messages (id, session_id, role, kind, status, blocks, raw, text, "
"created_at, updated_at) VALUES "
"('m1', ?, 'assistant', 'qa', 'done', ?, NULL, NULL, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'), "
"('m2', ?, 'assistant', 'qa', 'done', ?, NULL, NULL, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')",
(sid, dumps_json(blocks1), sid, dumps_json(blocks2)),
)
r = client.get(f"/api/sessions/{sid}/sources")
assert r.status_code == 200, r.text
body = r.json()
assert body["session_id"] == sid
# PAPER_A 出现 2 次(2 个消息各 1 条),PAPER_B 1 次;无 paper_id 的按 id 各 1 次
assert body["total"] == 5
by_key = {(s["paper_id"] or s["id"]): s for s in body["sources"]}
assert by_key["PAPER_A"]["count"] == 2
assert by_key["PAPER_A"]["title"] == "Paper A"
assert by_key["PAPER_B"]["count"] == 1
assert by_key["x1"]["count"] == 1
assert by_key["x2"]["count"] == 1
assert len(body["sources"]) == 4
+195
View File
@@ -0,0 +1,195 @@
"""upstream.py —— 上游健康探测(httpx 单例 + 5s TTL 缓存)与统一错误信封。
- `probe_all()`:并发探测 qa(/health 或 /info)、rag(/health)、ragAlt(/health)、
Neo4j(延迟导入 `app.services.neo4j.probe_neo4j()`,A3 提供;若模块缺失则置
`{ok:false}`)。探测超时 1s(settings.timeout_health),结果缓存 5s。
- `envelope(code, message, degraded, status)`:API.md §1 错误信封构造器,供所有 router 用。
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any, Awaitable, Callable
import httpx
from .config import get_settings
logger = logging.getLogger(__name__)
_client: httpx.AsyncClient | None = None
_httpx_lock = asyncio.Lock()
async def get_client() -> httpx.AsyncClient:
"""httpx.AsyncClient 单例(供 qa/kg/extraction 各代理层共用)。"""
global _client
if _client is None or _client.is_closed:
async with _httpx_lock:
if _client is None or _client.is_closed:
settings = get_settings()
_client = httpx.AsyncClient(
timeout=httpx.Timeout(
connect=settings.timeout_health,
read=settings.timeout_qa_chat,
write=10.0,
pool=5.0,
),
follow_redirects=True,
)
return _client
async def close_client() -> None:
"""app shutdown 钩子。"""
global _client
if _client is not None and not _client.is_closed:
await _client.aclose()
_client = None
# ---------------------------------------------------------------------------
# 错误信封(API.md §1)——供所有 router 用
# ---------------------------------------------------------------------------
_ERROR_MESSAGES: dict[str, str] = {
"UPSTREAM_DOWN": "知识问答服务当前不可用,请稍后重试",
"UPSTREAM_ERROR": "上游服务返回错误",
"TIMEOUT": "检索超时",
"INVALID_CITATION": "引用校验失败",
"NOT_FOUND": "资源不存在",
"VALIDATION": "请求参数不合法",
"INTERNAL": "服务内部错误",
}
def envelope(
code: str,
message: str | None = None,
degraded: bool = False,
status: int | None = None,
) -> tuple[dict[str, Any], int]:
"""构造 `{"ok": false, "error": {code, message, degraded}}` 信封 + HTTP 状态。
status 缺省时按 code 推导(API.md §1 表):
UPSTREAM_DOWN=503, UPSTREAM_ERROR=502, TIMEOUT=504, INVALID_CITATION=502,
NOT_FOUND=404, VALIDATION=422, INTERNAL=500。
"""
if status is None:
status = {
"UPSTREAM_DOWN": 503,
"UPSTREAM_ERROR": 502,
"TIMEOUT": 504,
"INVALID_CITATION": 502,
"NOT_FOUND": 404,
"VALIDATION": 422,
"INTERNAL": 500,
}.get(code, 500)
if message is None:
message = _ERROR_MESSAGES.get(code, "未知错误")
return {
"ok": False,
"error": {"code": code, "message": message, "degraded": degraded},
}, status
# ---------------------------------------------------------------------------
# 健康探测
# ---------------------------------------------------------------------------
_probe_lock = asyncio.Lock()
_probe_at: float = 0.0
_probe_result: dict[str, Any] | None = None
async def _get_json(url: str) -> tuple[bool, Any]:
"""1s 超时 GET + JSON 解析;任何失败返回 (False, None),不抛错。"""
try:
client = await get_client()
resp = await client.get(url, timeout=httpx.Timeout(get_settings().timeout_health))
if resp.status_code >= 400:
return False, None
return True, resp.json()
except Exception:
return False, None
async def _probe_qa() -> dict[str, Any]:
settings = get_settings()
base = settings.qa_base_url.rstrip("/")
# 按 DISCOVERY.md:health 为可靠探测端点;其不可用时回落 info
ok, data = await _get_json(f"{base}{settings.qa_api_path}/health")
if not ok:
ok, data = await _get_json(f"{base}{settings.qa_api_path}/info")
if not ok or not isinstance(data, dict):
return {"ok": False}
version = data.get("version") or data.get("app_version")
return {"ok": True, "version": version}
async def _probe_rag(base_url: str) -> dict[str, Any]:
ok, data = await _get_json(f"{base_url.rstrip('/')}/health")
if not ok:
return {"ok": False}
return {"ok": True}
async def _probe_neo4j() -> dict[str, Any]:
"""Neo4j 状态由 A3 提供(app.services.neo4j.probe_neo4j)。
延迟导入:A3 未实现时 catch ImportError,置 {ok:false}。
"""
try:
from .services.neo4j import probe_neo4j
except ImportError:
return {"ok": False}
try:
result = await probe_neo4j()
except Exception:
return {"ok": False}
if not isinstance(result, dict):
return {"ok": False}
out: dict[str, Any] = {"ok": bool(result.get("ok"))}
if result.get("active"):
out["active"] = result["active"]
if result.get("detail"):
out["detail"] = result["detail"]
return out
async def _probe_all_uncached() -> dict[str, Any]:
settings = get_settings()
qa_t, rag_t, alt_t, neo_t = await asyncio.gather(
_probe_qa(),
_probe_rag(settings.rag_base_url),
_probe_rag(settings.rag_alt_base_url),
_probe_neo4j(),
)
return {"qa": qa_t, "rag": rag_t, "ragAlt": alt_t, "neo4j": neo_t}
async def probe_all(force: bool = False) -> dict[str, Any]:
"""并发探测全部上游;结果缓存 settings.health_cache_ttl 秒(默认 5)。"""
global _probe_at, _probe_result
now = time.monotonic()
if not force and _probe_result is not None:
if now - _probe_at < get_settings().health_cache_ttl:
return _probe_result
async with _probe_lock:
now = time.monotonic()
if not force and _probe_result is not None:
if now - _probe_at < get_settings().health_cache_ttl:
return _probe_result
result = await _probe_all_uncached()
_probe_at = time.monotonic()
_probe_result = result
return result
async def probe_all_refresh() -> dict[str, Any]:
"""强制刷新(无缓存)。"""
return await probe_all(force=True)
# 供 A2/A3 复用的类型别名
UpstreamProbe = Callable[..., Awaitable[dict[str, Any]]]
+46
View File
@@ -0,0 +1,46 @@
{
"job_id": "2943168e9c48",
"title": "wang2019.pdf",
"status": "completed",
"stage": "completed",
"progress_pct": 100,
"message": "MinerU+LLM 方法学提取完成",
"paper_id": "demo_paper",
"family": "",
"stats": {
"stage_count": 6,
"species_count": 1,
"figure_table_caption_count": 9,
"figure_table_stage_count": 12
},
"detail": {
"updated_at": "2026-08-07T09:42:17",
"job_id": "2943168e9c48",
"status": "completed",
"stage": "completed",
"substage": "done",
"message": "MinerU+LLM 方法学提取完成",
"source_path": "/home/xzq/5090/PlantAI-rag/data/evaluation/methodology_upload_jobs/2943168e9c48/upload/wang2019.pdf",
"content_list": "/home/xzq/5090/PlantAI-rag/data/evaluation/methodology_upload_jobs/2943168e9c48/mineru/wang2019/txt/wang2019_content_list.json",
"output_dir": "/home/xzq/5090/PlantAI-rag/methodology_batch_demo_paper_2943168e9c48",
"figure_table_warning": ""
},
"plan": {
"plan_id": "plan_2943168e9c48",
"paper_id": "demo_paper",
"gene_family": "",
"source_batch_dir": "/home/xzq/5090/PlantAI-rag/methodology_batch_demo_paper_2943168e9c48",
"stages": [
{
"observation_id": "obs_1",
"literature_stage": "gene_family_identification",
"literature_tools": [
{ "tool_name": "hmmsearch", "version": "3.3.2", "role": "domain search", "url": "", "parameters": "E-value 1e-5" }
],
"method_components": [],
"literature_parameters": [],
"inputs": []
}
]
}
}
@@ -0,0 +1,81 @@
{
"jobs": [
{
"job_id": "2943168e9c48",
"title": "wang2019.pdf",
"status": "completed",
"stage": "completed",
"substage": "done",
"progress_pct": 100,
"message": "MinerU+LLM 方法学提取完成",
"paper_id": "demo_paper",
"family": "",
"source_filename": "wang2019.pdf",
"stage_count": 6,
"species_count": 1,
"figure_table_caption_count": 9,
"figure_table_stage_count": 12,
"updated_at": "2026-08-07T09:42:17",
"config_url": "/api/v1/methodology/extract-jobs/2943168e9c48/bbav5-config",
"methodology_plan_url": "/api/v1/methodology/extract-jobs/2943168e9c48/plan"
},
{
"job_id": "a1b2c3d4e5f6",
"title": "Pecan_SPL_2021.pdf",
"status": "running",
"stage": "llm",
"substage": null,
"progress_pct": 75,
"message": "LLM 关系抽取中…",
"paper_id": "pecan_spl",
"family": "SPL",
"source_filename": "Pecan_SPL_2021.pdf",
"stage_count": 4,
"species_count": 1,
"figure_table_caption_count": 3,
"figure_table_stage_count": 4,
"updated_at": "2026-08-25T09:52:00",
"config_url": null,
"methodology_plan_url": null
},
{
"job_id": "b2c3d4e5f6a7",
"title": "Rice_NAC_2020.pdf",
"status": "failed",
"stage": "failed",
"substage": null,
"progress_pct": null,
"message": "MinerU 解析失败:/home/xia-lab/miniconda3/envs/minerupy311/bin/mineru 不存在",
"paper_id": "rice_nac",
"family": "NAC",
"source_filename": "Rice_NAC_2020.pdf",
"stage_count": 0,
"species_count": 0,
"figure_table_caption_count": 0,
"figure_table_stage_count": 0,
"updated_at": "2026-08-24T14:38:00",
"config_url": null,
"methodology_plan_url": null
},
{
"job_id": "c3d4e5f6a7b8",
"title": "At_HDZIP_2018.pdf",
"status": "queued",
"stage": "queued",
"substage": null,
"progress_pct": 5,
"message": "排队等待处理",
"paper_id": "at_hdzip",
"family": "HD-ZIP",
"source_filename": "At_HDZIP_2018.pdf",
"stage_count": 0,
"species_count": 0,
"figure_table_caption_count": 0,
"figure_table_stage_count": 0,
"updated_at": "2026-08-24T16:00:00",
"config_url": null,
"methodology_plan_url": null
}
],
"count": 4
}
@@ -0,0 +1,3 @@
{
"rows": []
}
@@ -0,0 +1,3 @@
{
"rows": []
}
@@ -0,0 +1,6 @@
{
"success": true,
"job_id": "d4e5f6a7b8c9",
"status_url": "/api/v1/methodology/extract-jobs/d4e5f6a7b8c9",
"message": "已提交抽取任务"
}
+11
View File
@@ -0,0 +1,11 @@
{
"status": "ok",
"version": "0.1.0",
"upstreams": {
"qa": { "ok": true, "version": "1.1.73-assertion-anchored" },
"rag": { "ok": true },
"ragAlt": { "ok": true },
"neo4j": { "ok": true, "active": "neo4j-plant-srt-pc2025-v3-20260817-normalized" }
},
"db": { "ok": true, "sessions": 12 }
}
+10
View File
@@ -0,0 +1,10 @@
{
"query": "OsSPL",
"results": [
{ "id": "cent_ospl14", "name": "OsSPL14", "type": "gene", "support_count": 18 },
{ "id": "cent_ospl7", "name": "OsSPL7", "type": "gene", "support_count": 9 },
{ "id": "cent_ospl16", "name": "OsSPL16", "type": "gene", "support_count": 7 },
{ "id": "cent_ospl2", "name": "OsSPL2", "type": "gene", "support_count": 5 },
{ "id": "cent_spl_family", "name": "SPL gene family", "type": "gene_family", "support_count": 120 }
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"id": "cent_ospl14",
"name": "OsSPL14",
"type": "gene",
"properties": {
"canonical_id": "cent_ospl14",
"name": "OsSPL14",
"name_norm": "osspl14",
"entity_type": "gene",
"rule": "exact_name_norm_same_type",
"support_count": 18
}
}
@@ -0,0 +1,20 @@
{
"center": "cent_ospl14",
"center_name": "OsSPL14",
"depth": 1,
"nodes": [
{ "id": "cent_ospl14", "label": "OsSPL14", "type": "gene" },
{ "id": "cent_panicle", "label": "panicle", "type": "experimental_material" },
{ "id": "cent_branching", "label": "panicle branching", "type": "process" },
{ "id": "cent_mir156", "label": "miR156", "type": "sequence_feature" },
{ "id": "cent_ospl7", "label": "OsSPL7", "type": "gene" },
{ "id": "cent_ospl16", "label": "OsSPL16", "type": "gene" }
],
"edges": [
{ "source": "cent_ospl14", "target": "cent_panicle", "relation": "expressed_in", "label": "expressed_in", "assertion_id": "cass_aaaa01", "paper_count": 5, "support_count": 9, "title": "Regulation of OsSPL14 by OsmiR156 defines ideal plant architecture in rice", "doi": "10.1038/ng.591", "pmid": "20393464" },
{ "source": "cent_ospl14", "target": "cent_branching", "relation": "involved_in", "label": "involved_in", "assertion_id": "cass_aaaa02", "paper_count": 3, "support_count": 4, "title": "Regulation of OsSPL14 by OsmiR156 defines ideal plant architecture in rice", "doi": "10.1038/ng.591", "pmid": "20393464" },
{ "source": "cent_ospl14", "target": "cent_mir156", "relation": "regulated_by", "label": "regulated_by", "assertion_id": "cass_aaaa03", "paper_count": 2, "support_count": 2, "title": "Regulation of OsSPL14 by OsmiR156 defines ideal plant architecture in rice", "doi": "10.1038/ng.591", "pmid": "20393464" },
{ "source": "cent_ospl7", "target": "cent_branching", "relation": "involved_in", "label": "involved_in", "assertion_id": "cass_bbbb01", "paper_count": 1, "support_count": 1, "title": "OsSPL7 regulates panicle development in rice", "doi": null, "pmid": "22429691" },
{ "source": "cent_ospl16", "target": "cent_panicle", "relation": "expressed_in", "label": "expressed_in", "assertion_id": "cass_cccc01", "paper_count": 1, "support_count": 1, "title": "OsSPL16 controls grain size and quality via the GW8 pathway", "doi": "10.1038/ng.2327", "pmid": "22635119" }
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"entities_by_type": [
{ "type": "gene", "count": 43210 },
{ "type": "gene_family", "count": 1288 },
{ "type": "phenotype", "count": 1560 },
{ "type": "process", "count": 8912 },
{ "type": "trait", "count": 2341 },
{ "type": "experimental_material", "count": 340 },
{ "type": "method", "count": 1204 }
],
"relations_by_category": [
{ "type": "characterized_in", "count": 12033 },
{ "type": "involved_in", "count": 9812 },
{ "type": "regulated_by", "count": 7780 },
{ "type": "identified_in", "count": 5620 },
{ "type": "collinear_with", "count": 9350 }
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"ok": true,
"active": "neo4j-plant-srt-direct-v1-full",
"schema": "canonical",
"labels": ["SRT2CanonicalEntity", "SRT2CanonicalAssertion", "SRT2Paper"],
"relationship_types": ["SRT2_CANONICAL_ASSERTS", "SRT2_SUPPORTED_BY"],
"entity_count": 3820996,
"assertion_count": 8246875,
"name_index": "srt2_canonical_entity_name_norm",
"fulltext_index": null
}
+145
View File
@@ -0,0 +1,145 @@
{
"query_id": "q-mock-001",
"version": "1.1.73-assertion-anchored",
"mode": "chat",
"answer": "水稻 **SPL**(SQUAMOSA PROMOTER BINDING PROTEIN-LIKE)基因家族共有 **19 个成员**,主要功能包括:\n\n1. **穗部形态建成**:*OsSPL14*(IPA1/WFP)通过调控分蘖数与穗分枝,是理想株型的关键因子\n2. **生育期调控**:部分成员参与抽穗期的光周期响应\n3. **miR156 调控网络**:多数成员受 miR156 转录后调控,随植株发育表达量上升\n\n| 成员 | 关键功能 | 证据来源 |\n|------|---------|----------|\n| OsSPL14 | 穗分枝、株型建成 | PMID 20393464 |\n| OsSPL7 | 穗发育负调控 | PMID 22429691 |\n| OsSPL16 | 籽粒大小与品质 | PMID 22635119 |\n",
"source_count": 4,
"sources": [
{
"id": "src-1",
"title": "Regulation of OsSPL14 by OsmiR156 defines ideal plant architecture in rice",
"paper_id": "PMID20393464",
"chunk_id": "chunk_20393464_12",
"pmid": "20393464",
"pmcid": null,
"doi": "10.1038/ng.591",
"url": "https://doi.org/10.1038/ng.591",
"snippet": "OsSPL14 positively regulates panicle branching and grain yield in rice, acting as a key factor of ideal plant architecture.",
"score": 0.92,
"locator": "Abstract",
"section": "Results",
"support_count": 3,
"type": "assertion",
"metadata": { "evidence_confidence": "high" }
},
{
"id": "src-2",
"title": "Genome-wide identification of the SPL gene family in Tartary buckwheat",
"paper_id": "PMID31286919",
"chunk_id": "chunk_31286919_07",
"pmid": "31286919",
"pmcid": "PMC6612345",
"doi": "10.1186/s12870-019-1903-y",
"url": "https://doi.org/10.1186/s12870-019-1903-y",
"snippet": "A total of 28 SPL genes were identified in Fagopyrum tataricum genome and their expression profiles were analyzed across tissues.",
"score": 0.85,
"locator": "Results",
"section": "Results",
"support_count": 2,
"type": "assertion",
"metadata": {}
},
{
"id": "src-3",
"title": "OsSPL16 controls grain size and quality via the GW8 pathway",
"paper_id": "PMID22635119",
"chunk_id": "chunk_22635119_03",
"pmid": "22635119",
"pmcid": null,
"doi": "10.1038/ng.2327",
"url": "https://doi.org/10.1038/ng.2327",
"snippet": "OsSPL16 positively regulates cell proliferation and promotes grain width, improving yield and quality.",
"score": 0.81,
"locator": "Abstract",
"section": "Results",
"support_count": 1,
"type": "assertion",
"metadata": {}
},
{
"id": "src-4",
"title": "OsSPL7 regulates panicle development in rice",
"paper_id": "PMID22429691",
"chunk_id": "chunk_22429691_09",
"pmid": "22429691",
"pmcid": "PMC3301234",
"doi": null,
"url": "https://pubmed.ncbi.nlm.nih.gov/22429691/",
"snippet": "OsSPL7 acts as a negative regulator of panicle branching in rice.",
"score": 0.72,
"locator": "Results",
"section": "Results",
"support_count": 1,
"type": "assertion",
"metadata": {}
}
],
"used_sources": [
{
"id": "src-1",
"title": "Regulation of OsSPL14 by OsmiR156 defines ideal plant architecture in rice",
"paper_id": "PMID20393464",
"chunk_id": "chunk_20393464_12",
"pmid": "20393464",
"pmcid": null,
"doi": "10.1038/ng.591",
"url": "https://doi.org/10.1038/ng.591",
"snippet": "OsSPL14 positively regulates panicle branching and grain yield in rice.",
"score": 0.92,
"locator": "Abstract",
"section": "Results",
"support_count": 3,
"type": "assertion",
"metadata": {}
}
],
"chains": [
{
"chain_id": "c1",
"hops": [
{ "source": "OsSPL14", "target": "panicle", "relation": "expressed_in", "source_type": "Gene", "target_type": "Tissue", "paper_id": "PMID20393464", "pmid": "20393464", "pmcid": null, "doi": "10.1038/ng.591" },
{ "source": "OsSPL14", "target": "panicle branching", "relation": "involved_in", "source_type": "Gene", "target_type": "Process", "paper_id": "PMID20393464", "pmid": "20393464", "pmcid": null, "doi": "10.1038/ng.591" }
]
},
{
"chain_id": "c2",
"hops": [
{ "source": "OsSPL14", "target": "miR156", "relation": "regulated_by", "source_type": "Gene", "target_type": "Molecule", "paper_id": "PMID20393464", "pmid": "20393464", "pmcid": null, "doi": "10.1038/ng.591" }
]
}
],
"graph": {
"nodes": [
{ "id": "OsSPL14", "label": "OsSPL14", "type": "Gene" },
{ "id": "panicle", "label": "panicle", "type": "Tissue" },
{ "id": "panicle branching", "label": "panicle branching", "type": "Process" },
{ "id": "miR156", "label": "miR156", "type": "Molecule" },
{ "id": "OsSPL16", "label": "OsSPL16", "type": "Gene" },
{ "id": "grain width", "label": "grain width", "type": "Trait" }
],
"edges": [
{ "source": "OsSPL14", "target": "panicle", "relation": "expressed_in", "label": "expressed_in", "chain_id": "c1", "hop": 1, "paper_id": "PMID20393464", "pmid": "20393464", "pmcid": null, "doi": "10.1038/ng.591" },
{ "source": "OsSPL14", "target": "panicle branching", "relation": "involved_in", "label": "involved_in", "chain_id": "c1", "hop": 2, "paper_id": "PMID20393464", "pmid": "20393464", "pmcid": null, "doi": "10.1038/ng.591" },
{ "source": "OsSPL14", "target": "miR156", "relation": "regulated_by", "label": "regulated_by", "chain_id": "c2", "hop": 1, "paper_id": "PMID20393464", "pmid": "20393464", "pmcid": null, "doi": "10.1038/ng.591" },
{ "source": "OsSPL16", "target": "grain width", "relation": "involved_in", "label": "involved_in", "chain_id": "c3", "hop": 1, "paper_id": "PMID22635119", "pmid": "22635119", "pmcid": null, "doi": "10.1038/ng.2327" }
],
"chains": [
{
"chain_id": "c1",
"hops": [
{ "source": "OsSPL14", "target": "panicle", "relation": "expressed_in", "source_type": "Gene", "target_type": "Tissue", "paper_id": "PMID20393464", "pmid": "20393464", "pmcid": null, "doi": "10.1038/ng.591" },
{ "source": "OsSPL14", "target": "panicle branching", "relation": "involved_in", "source_type": "Gene", "target_type": "Process", "paper_id": "PMID20393464", "pmid": "20393464", "pmcid": null, "doi": "10.1038/ng.591" }
]
}
]
},
"retrieval_plan": {
"anchor_entity": "SPL gene family",
"relation_family": "function",
"preferred_papers": ["PMID20393464", "PMID31286919"]
},
"retrieval_notes": "graph-balanced top30 evidence chain, with 15 graph-derived items",
"timing": { "total_s": 41.2, "retrieval_s": 22.1, "generation_s": 19.1 },
"citation_map": { "1": "PMID20393464", "2": "PMID31286919", "3": "PMID22635119" },
"raw": {}
}
+16
View File
@@ -0,0 +1,16 @@
{
"query_id": "q-mock-empty-001",
"version": "1.1.73-assertion-anchored",
"mode": "chat",
"answer": "未找到与该问题直接相关的文献证据。建议调整关键词或换一个更具体的问法。",
"source_count": 0,
"sources": [],
"used_sources": [],
"chains": [],
"graph": { "nodes": [], "edges": [], "chains": [] },
"retrieval_plan": null,
"retrieval_notes": "zero evidence retained",
"timing": { "total_s": 8.1 },
"citation_map": {},
"raw": {}
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "QA V1.1.73 Assertion-Anchored Full Graph",
"version": "1.1.73-assertion-anchored",
"pipeline": [
"question",
"provisional answer",
"structured planner",
"direct Qdrant recall over V15/V16 canonical assertions",
"canonical seed extraction",
"Neo4j exact seed retrieval",
"Neo4j bounded graph expansion",
"BGE rerank",
"graph-balanced top30 evidence chain",
"BGE top10 Qdrant source seed selection",
"native Neo4j two-hop chain retrieval",
"final LLM answer composition"
],
"config": {
"max_rounds": 1,
"max_evidence": 40,
"provisional_draft": false,
"second_round": true,
"bge_rerank": true,
"graph_schema": "canonical",
"knowledge_chain_defaults": {
"chain_top_k": 3,
"chain_hops": 2,
"chain_max_edges": 12,
"chain_max_nodes": 10,
"chain_mode": "display",
"native_chain_max_hops": 3,
"native_chain_top_k": 5
}
}
}
+45
View File
@@ -0,0 +1,45 @@
{
"query_id": "q-mock-search-001",
"version": "1.1.73-assertion-anchored",
"mode": "search",
"answer": "",
"source_count": 4,
"sources": [
{
"id": "src-1", "title": "Regulation of OsSPL14 by OsmiR156 defines ideal plant architecture in rice",
"paper_id": "PMID20393464", "chunk_id": "chunk_20393464_12", "pmid": "20393464", "pmcid": null,
"doi": "10.1038/ng.591", "url": "https://doi.org/10.1038/ng.591",
"snippet": "OsSPL14 positively regulates panicle branching and grain yield in rice.", "score": 0.92,
"locator": "Abstract", "section": "Results", "support_count": 3, "type": "assertion", "metadata": {}
},
{
"id": "src-2", "title": "Genome-wide identification of the SPL gene family in Tartary buckwheat",
"paper_id": "PMID31286919", "chunk_id": "chunk_31286919_07", "pmid": "31286919", "pmcid": "PMC6612345",
"doi": "10.1186/s12870-019-1903-y", "url": "https://doi.org/10.1186/s12870-019-1903-y",
"snippet": "28 SPL genes identified in Fagopyrum tataricum.", "score": 0.85,
"locator": "Results", "section": "Results", "support_count": 2, "type": "assertion", "metadata": {}
},
{
"id": "src-3", "title": "OsSPL16 controls grain size and quality via the GW8 pathway",
"paper_id": "PMID22635119", "chunk_id": "chunk_22635119_03", "pmid": "22635119", "pmcid": null,
"doi": "10.1038/ng.2327", "url": "https://doi.org/10.1038/ng.2327",
"snippet": "OsSPL16 positively regulates cell proliferation.", "score": 0.81,
"locator": "Abstract", "section": "Results", "support_count": 1, "type": "assertion", "metadata": {}
},
{
"id": "src-4", "title": "OsSPL7 regulates panicle development in rice",
"paper_id": "PMID22429691", "chunk_id": "chunk_22429691_09", "pmid": "22429691", "pmcid": "PMC3301234",
"doi": null, "url": "https://pubmed.ncbi.nlm.nih.gov/22429691/",
"snippet": "OsSPL7 acts as a negative regulator of panicle branching.", "score": 0.72,
"locator": "Results", "section": "Results", "support_count": 1, "type": "assertion", "metadata": {}
}
],
"used_sources": [],
"chains": [],
"graph": { "nodes": [], "edges": [], "chains": [] },
"retrieval_plan": null,
"retrieval_notes": "retrieval only, no LLM answer",
"timing": { "total_s": 4.7 },
"citation_map": {},
"raw": {}
}
@@ -0,0 +1,9 @@
{
"id": "sess-new-mock",
"title": "新会话",
"status": "idle",
"created_at": "2026-08-25T12:00:00Z",
"updated_at": "2026-08-25T12:00:00Z",
"message_count": 0,
"meta": { "title_manual": false }
}
+62
View File
@@ -0,0 +1,62 @@
{
"id": "sess-003",
"title": "水稻SPL基因家族的功能研究有哪些关键进展?",
"status": "done",
"created_at": "2026-08-25T09:41:00Z",
"updated_at": "2026-08-25T10:02:00Z",
"meta": { "title_manual": false },
"messages": [
{
"id": "msg-001",
"role": "user",
"kind": "qa",
"status": "done",
"blocks": [],
"raw": null,
"text": "水稻SPL基因家族的功能研究有哪些关键进展?",
"created_at": "2026-08-25T09:41:00Z",
"updated_at": "2026-08-25T09:41:00Z"
},
{
"id": "msg-002",
"role": "assistant",
"kind": "qa",
"status": "done",
"blocks": [
{ "type": "text", "html": "<p>水稻 <strong>SPL</strong>(SQUAMOSA PROMOTER BINDING PROTEIN-LIKE)基因家族共有 <strong>19 个成员</strong>,主要功能包括:</p><ol><li><strong>穗部形态建成</strong>:<em>OsSPL14</em>(IPA1/WFP)通过调控分蘖数与穗分枝,是理想株型的关键因子</li><li><strong>生育期调控</strong>:部分成员参与抽穗期的光周期响应</li><li><strong>miR156 调控网络</strong>:多数成员受 miR156 转录后调控</li></ol>" },
{ "type": "table", "headers": ["成员", "关键功能", "证据来源"], "rows": [
["OsSPL14", "穗分枝、株型建成", "PMID 20393464"],
["OsSPL7", "穗发育负调控", "PMID 22429691"],
["OsSPL16", "籽粒大小与品质", "PMID 22635119"]
] },
{ "type": "pipeline", "status": "done", "steps": [
{ "name": "规划", "state": "done", "desc": "意图理解与实体识别", "phase": "planning" },
{ "name": "检索", "state": "done", "desc": "多路检索与子问题召回", "phase": "retrieval" },
{ "name": "证据", "state": "done", "desc": "证据筛选与交叉验证", "phase": "evidence" },
{ "name": "生成", "state": "done", "desc": "答案生成与引用锚定", "phase": "generation" }
] },
{ "type": "tool", "icon": "🧬", "name": "已识别实体", "status": "done", "dur": "0.8s", "open": false, "phase": "planning",
"params": "SPL\nOsSPL14\nrice\npanicle", "result": "4 个实体", "logs": [
{ "text": "> 实体识别完成: 4 entities", "cls": "log-ok" }
] },
{ "type": "tool", "icon": "🔬", "name": "证据筛选", "status": "done", "dur": "3.2s", "open": false, "phase": "evidence",
"params": "accepted_count: 4\npublication_count: 4", "result": "4 条证据通过审核", "logs": [
{ "text": "> 候选证据 18 条", "cls": "log-run" },
{ "text": "> 采纳 4 条(high confidence)", "cls": "log-ok" }
] },
{ "type": "sources", "items": [] },
{ "type": "graph", "nodes": [
{ "id": "OsSPL14", "label": "OsSPL14", "type": "Gene" },
{ "id": "panicle", "label": "panicle", "type": "Tissue" },
{ "id": "miR156", "label": "miR156", "type": "Molecule" }
], "edges": [
{ "source": "OsSPL14", "target": "panicle", "relation": "expressed_in", "label": "expressed_in", "chain_id": "c1", "hop": 1, "paper_id": "PMID20393464", "pmid": "20393464", "pmcid": null, "doi": "10.1038/ng.591" },
{ "source": "OsSPL14", "target": "miR156", "relation": "regulated_by", "label": "regulated_by", "chain_id": "c2", "hop": 1, "paper_id": "PMID20393464", "pmid": "20393464", "pmcid": null, "doi": "10.1038/ng.591" }
], "chains": [] }
],
"raw": {},
"created_at": "2026-08-25T09:41:00Z",
"updated_at": "2026-08-25T10:02:00Z"
}
]
}
+7
View File
@@ -0,0 +1,7 @@
[
{ "id": "sess-003", "title": "水稻SPL基因家族的功能研究有哪些关键进展?", "status": "done", "created_at": "2026-08-25T09:41:00Z", "updated_at": "2026-08-25T10:02:00Z", "message_count": 2, "meta": { "title_manual": false } },
{ "id": "sess-002", "title": "拟南芥HD-ZIP家族的生物学功能", "status": "done", "created_at": "2026-08-25T08:10:00Z", "updated_at": "2026-08-25T08:15:00Z", "message_count": 2, "meta": { "title_manual": false } },
{ "id": "sess-001", "title": "文献检索记录", "status": "error", "created_at": "2026-08-24T15:20:00Z", "updated_at": "2026-08-24T15:23:00Z", "message_count": 2, "meta": { "title_manual": true } },
{ "id": "sess-000", "title": "NAC 转录因子研究综述", "status": "done", "created_at": "2026-08-23T11:05:00Z", "updated_at": "2026-08-23T11:30:00Z", "message_count": 4, "meta": { "title_manual": false } },
{ "id": "sess-099", "title": "番茄TCP家族选择压力", "status": "idle", "created_at": "2026-08-20T09:00:00Z", "updated_at": "2026-08-20T09:00:00Z", "message_count": 0, "meta": { "title_manual": false } }
]
@@ -0,0 +1,38 @@
{
"session_id": "sess-003",
"total": 4,
"sources": [
{
"id": "src-1", "title": "Regulation of OsSPL14 by OsmiR156 defines ideal plant architecture in rice",
"paper_id": "PMID20393464", "chunk_id": "chunk_20393464_12", "pmid": "20393464", "pmcid": null,
"doi": "10.1038/ng.591", "url": "https://doi.org/10.1038/ng.591",
"snippet": "OsSPL14 positively regulates panicle branching and grain yield in rice.",
"score": 0.92, "locator": "Abstract", "section": "Results", "support_count": 3,
"type": "assertion", "metadata": {}, "count": 2
},
{
"id": "src-2", "title": "Genome-wide identification of the SPL gene family in Tartary buckwheat",
"paper_id": "PMID31286919", "chunk_id": "chunk_31286919_07", "pmid": "31286919", "pmcid": "PMC6612345",
"doi": "10.1186/s12870-019-1903-y", "url": "https://doi.org/10.1186/s12870-019-1903-y",
"snippet": "28 SPL genes identified in Fagopyrum tataricum.",
"score": 0.85, "locator": "Results", "section": "Results", "support_count": 2,
"type": "assertion", "metadata": {}, "count": 1
},
{
"id": "src-3", "title": "OsSPL16 controls grain size and quality via the GW8 pathway",
"paper_id": "PMID22635119", "chunk_id": "chunk_22635119_03", "pmid": "22635119", "pmcid": null,
"doi": "10.1038/ng.2327", "url": "https://doi.org/10.1038/ng.2327",
"snippet": "OsSPL16 positively regulates cell proliferation.",
"score": 0.81, "locator": "Abstract", "section": "Results", "support_count": 1,
"type": "assertion", "metadata": {}, "count": 1
},
{
"id": "src-4", "title": "OsSPL7 regulates panicle development in rice",
"paper_id": "PMID22429691", "chunk_id": "chunk_22429691_09", "pmid": "22429691", "pmcid": "PMC3301234",
"doi": null, "url": "https://pubmed.ncbi.nlm.nih.gov/22429691/",
"snippet": "OsSPL7 acts as a negative regulator of panicle branching.",
"score": 0.72, "locator": "Results", "section": "Results", "support_count": 1,
"type": "assertion", "metadata": {}, "count": 1
}
]
}
+14
View File
@@ -0,0 +1,14 @@
[
{ "stage": "understanding_started", "phase": "planning", "label": "开始理解问题", "details": {}, "delay_ms": 400 },
{ "stage": "entities_identified", "phase": "planning", "label": "已识别实体", "details": { "entities": ["SPL", "OsSPL14", "rice", "panicle"] }, "delay_ms": 800 },
{ "stage": "related_search_started", "phase": "planning", "label": "开始关联检索", "details": {}, "delay_ms": 600 },
{ "stage": "concepts_found", "phase": "retrieval", "label": "发现相关概念", "details": { "concepts": ["panicle branching", "grain yield", "miR156"], "concept_count": 3, "connection_count": 12 }, "delay_ms": 900 },
{ "stage": "subquestion_retrieved", "phase": "retrieval", "label": "子问题召回完成", "details": { "candidate_count": 18 }, "delay_ms": 900 },
{ "stage": "agent_review_started", "phase": "retrieval", "label": "开始智能体审核", "details": {}, "delay_ms": 600 },
{ "stage": "subquestion_retry_started", "phase": "retrieval", "label": "子问题重试中", "details": {}, "delay_ms": 600 },
{ "stage": "evidence_accepted", "phase": "evidence", "label": "证据已采纳", "details": { "accepted_count": 6 }, "delay_ms": 800 },
{ "stage": "evidence_review_completed", "phase": "evidence", "label": "证据审核完成", "details": { "accepted_count": 4, "publication_count": 4 }, "delay_ms": 700 },
{ "stage": "answer_generation_started", "phase": "generation", "label": "开始生成答案", "details": {}, "delay_ms": 600 },
{ "stage": "completed", "phase": "done", "label": "检索完成", "details": {}, "delay_ms": 400 },
"__complete__"
]
@@ -0,0 +1,145 @@
{
"_comment": "真实上游 7920 /api/v1/qa-v1-1-73/search 响应形态样本(DISCOVERY.md §4.1 结构,已截断)——供 normalize 单元测试,不直接作为 API 响应。",
"success": true,
"version": "1.1.73",
"query_id": "20260825_000715_49f801a9",
"question": "What is the SPL gene family?",
"answer": "",
"citation_validation": { "valid": true, "cited_pmids": ["28655295"], "unknown_pmids": [] },
"sources": [
{
"evidence_id": "S1",
"kb": "V16/V15",
"source": "SPL gene family",
"relation": "characterized_in",
"target": "Oryza sativa",
"evidence": "## Phylogenetic analysis of the PeSPL genes ...",
"paper_id": "PMC5488377_12864_2017_Article_3882",
"title": "Genome-wide analysis of the SPL gene family in pecan",
"pmid": "28655295",
"pmcid": "PMC5488377",
"doi": "10.1186/s12864-017-3882-4",
"species": "",
"section": "",
"output_layer": "main_kg",
"claim_scope": "",
"relation_id": "cass_d12f62f9abffbd903b97",
"retrieval_methods": ["qdrant_vector"],
"retrieval_queries": ["SPL gene family", "SBP box domain"],
"vector_score": 0.714,
"rrf_score": 1.114,
"rerank_score": 0.989,
"final_score": 0.982,
"cluster_id": "clu_0f8da21642adc4f0",
"cluster_member_count": 3,
"cluster_member_assertion_ids": ["cass_a", "cass_b", "cass_c"],
"cluster_member_paper_ids": ["PMC5488377_12864_2017_Article_3882"],
"cluster_member_pmids": ["28655295"],
"raw": {
"kb_version": "V16",
"source_system": "srt",
"assertion_id": "cass_d12f62f9abffbd903b97",
"source": "SPL gene family",
"relation": "characterized_in",
"target": "Oryza sativa",
"evidence": "## Phylogenetic analysis ...",
"best_evidence": "The SPL gene family is characterized in rice.",
"paper_id": "PMC5488377_12864_2017_Article_3882",
"support_count": 12,
"paper_count": 5,
"quality_score": 0.91,
"best_score": 0.95
}
}
],
"source_count": 1,
"evidence_chain_top30": [],
"provisional_answer": "",
"retrieval_hypothesis": { "question_type": "raw_question", "direct_question_entities": [], "hypothesized_entities": [], "relation_intents": [], "concept_queries": [], "answer_slots": [], "atomic_claims": [], "negative_constraints": [], "parse_status": "ok" },
"query_plan": {
"queries": {},
"alias_audit": {},
"agentic_plan": { "anchor_entity": "SPL gene family", "relation_family": "characterized_in" }
},
"retrieval_audit": { "qdrant": [], "seed_extraction": {}, "neo4j": [], "dedup": {} },
"graph_knowledge_chains": {
"mode": "neo4j_native_path",
"chain_policy": "display",
"chains": [
{
"chain_id": "G1",
"chain_source": "neo4j_native_path",
"hop_count": 2,
"path_score": 0.861,
"start": "SPL gene",
"end": "18 SPL genes in rice",
"bridge_entities": [],
"hops": [
{
"hop_index": 0,
"from_id": "cent_spl", "from_name": "SPL gene",
"to_id": "cent_oryza", "to_name": "Oryza sativa",
"source": "SPL gene", "relation": "characterized_in", "target": "Oryza sativa",
"source_type": "gene_family", "target_type": "species",
"relation_category": "characterized_in",
"assertion_id": "cass_d12f62f9abffbd903b97",
"paper_id": "PMC5488377_12864_2017_Article_3882",
"title": "Genome-wide analysis of the SPL gene family in pecan",
"pmid": "28655295", "pmcid": "PMC5488377", "doi": "10.1186/s12864-017-3882-4",
"evidence": "…", "support_count": 12, "paper_count": 5, "best_score": 0.95,
"edge_score": 0.861
},
{
"hop_index": 1,
"from_id": "cent_oryza", "from_name": "Oryza sativa",
"to_id": "cent_18spl", "to_name": "18 SPL genes in rice",
"source": "Oryza sativa", "relation": "has_gene_family_member", "target": "18 SPL genes in rice",
"source_type": "species", "target_type": "gene",
"relation_category": "has_member",
"assertion_id": "cass_e23a",
"paper_id": "PMC5488377_12864_2017_Article_3882",
"title": "Genome-wide analysis of the SPL gene family in pecan",
"pmid": "28655295", "pmcid": "PMC5488377", "doi": "10.1186/s12864-017-3882-4",
"support_count": 8, "paper_count": 3, "best_score": 0.88,
"edge_score": 0.79
}
],
"score_breakdown": { "path_score": 0.861 }
}
],
"chain_count": 1,
"audit": {},
"summary": { "note": "used_for_answer_generation", "used_for_answer_generation": true }
},
"native_graph_chains": [
{
"chain_id": "G1",
"chain_source": "neo4j_native_path",
"hop_count": 2,
"path_score": 0.861,
"start": "SPL gene",
"end": "18 SPL genes in rice",
"bridge_entities": [],
"hops": [
{
"hop_index": 0,
"from_id": "cent_spl", "from_name": "SPL gene",
"to_id": "cent_oryza", "to_name": "Oryza sativa",
"source": "SPL gene", "relation": "characterized_in", "target": "Oryza sativa",
"source_type": "gene_family", "target_type": "species",
"relation_category": "characterized_in",
"assertion_id": "cass_d12f62f9abffbd903b97",
"paper_id": "PMC5488377_12864_2017_Article_3882",
"pmid": "28655295", "pmcid": "PMC5488377", "doi": "10.1186/s12864-017-3882-4",
"support_count": 12, "paper_count": 5, "best_score": 0.95
}
]
}
],
"assertion_anchored_graph_chains": [],
"degraded_execution": false,
"subquestion_evidence_groups": [],
"rerank_audit": [],
"timings": { "provisional_answer": 0.5, "retrieval": 12.3, "native_graph_chain_retrieval": 70.1, "total": 91.9 },
"flow": ["question", "provisional answer", "structured planner", "retrieval", "native_graph_chain_retrieval"]
}
+224
View File
@@ -0,0 +1,224 @@
"""PlantAI-rag-new mock server —— 前端开发与 E2E 场景的固定数据源。
监听 127.0.0.1:8099。实现 API.md 的全部接口,返回 fixtures/ 中的固定数据。
场景切换:查询参数 ?scenario= 或请求头 X-Mock-Scenario:
normal(默认)| empty(空结果)| slow(慢响应 5s)| error(上游故障)| degraded(QA 不可用)
SSE 流按真实时序回放,让流式 UI 在没有上游时也能开发。
"""
from __future__ import annotations
import asyncio
import json
import time
from pathlib import Path
from fastapi import FastAPI, Header, Query, Request
from fastapi.responses import JSONResponse, StreamingResponse
FIXTURES = Path(__file__).parent / "fixtures"
app = FastAPI(title="PlantAI-rag-new mock")
def load(name: str):
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
def scenario_of(request: Request, q: str | None = None) -> str:
s = q or request.headers.get("X-Mock-Scenario") or "normal"
return s if s in {"normal", "empty", "slow", "error", "degraded"} else "normal"
def envelope(code: str, message: str, degraded: bool, status: int):
return JSONResponse(
status_code=status,
content={"ok": False, "error": {"code": code, "message": message, "degraded": degraded}},
)
# ---------- 健康与元信息 ----------
@app.get("/api/health")
async def health():
return load("health.json")
@app.get("/api/qa/info")
async def qa_info():
return load("qa_info.json")
# ---------- QA ----------
@app.post("/api/qa/chat")
async def qa_chat(request: Request):
sc = scenario_of(request)
if sc == "degraded":
return envelope("UPSTREAM_DOWN", "知识问答服务当前不可用,请稍后重试", True, 503)
if sc == "error":
return envelope("UPSTREAM_ERROR", "上游问答服务返回错误", True, 502)
if sc == "slow":
await asyncio.sleep(5)
if sc == "empty":
return load("qa_chat_empty.json")
return load("qa_chat.json")
@app.post("/api/qa/search")
async def qa_search(request: Request):
sc = scenario_of(request)
if sc in ("degraded",):
return envelope("UPSTREAM_DOWN", "知识问答服务当前不可用,请稍后重试", True, 503)
return load("qa_search.json")
@app.post("/api/qa/chat/stream")
async def qa_chat_stream(request: Request):
sc = scenario_of(request)
if sc == "degraded":
return envelope("UPSTREAM_DOWN", "知识问答服务当前不可用,请稍后重试", True, 503)
frames = load("sse_frames.json") # [{stage, phase, label, details, delay_ms}, ..., "__complete__"]
async def gen():
for f in frames:
if f == "__complete__":
yield "event: complete\n"
yield f"data: {json.dumps(load('qa_chat.json'), ensure_ascii=False)}\n\n"
break
await asyncio.sleep(f.get("delay_ms", 600) / 1000)
payload = {
"stage": f["stage"], "phase": f["phase"], "label": f["label"],
"details": f.get("details", {}), "at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
yield f"event: progress\n"
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
if int(f.get("delay_ms", 600)) > 3000: # 慢帧之间补 keepalive
yield ": ping\n\n"
return StreamingResponse(gen(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
# ---------- 会话与消息 ----------
_sessions = []
_messages = {}
_seq = 0
def _now():
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
@app.get("/api/sessions")
async def sessions():
return load("sessions.json")
@app.post("/api/sessions")
async def create_session(request: Request):
body = await request.json()
return {**load("session_created.json"), "title": body.get("title") or "新会话"}
@app.get("/api/sessions/{sid}")
async def session_detail(sid: str):
return load("session_detail.json")
@app.patch("/api/sessions/{sid}")
async def session_patch(sid: str, request: Request):
body = await request.json()
return {**load("session_created.json"), **body}
@app.delete("/api/sessions/{sid}")
async def session_delete(sid: str):
return JSONResponse(status_code=204, content=None)
@app.post("/api/sessions/{sid}/messages")
async def create_message(sid: str, request: Request):
body = await request.json()
return {"id": "msg-mock-001", "role": body.get("role", "user"),
"kind": body.get("kind", "qa"), "status": body.get("status", "done"),
"blocks": body.get("blocks", []), "raw": None,
"created_at": _now(), "updated_at": _now()}
@app.patch("/api/sessions/{sid}/messages/{mid}")
async def patch_message(sid: str, mid: str, request: Request):
body = await request.json()
return {"id": mid, "role": "assistant", "kind": "qa",
"status": body.get("status", "done"), "blocks": body.get("blocks", []),
"raw": body.get("raw"), "created_at": _now(), "updated_at": _now()}
@app.get("/api/sessions/{sid}/sources")
async def session_sources(sid: str):
return load("sources_aggregate.json")
# ---------- 知识图谱 ----------
@app.get("/api/kg/status")
async def kg_status():
return load("kg_status.json")
@app.get("/api/kg/overview")
async def kg_overview():
return load("kg_overview.json")
@app.get("/api/kg/entities")
async def kg_entities(q: str = "", limit: int = 20):
return load("kg_entities.json")
@app.get("/api/kg/entities/{name}")
async def kg_entity(name: str):
return load("kg_entity.json")
@app.get("/api/kg/entities/{name}/neighborhood")
async def kg_neighborhood(name: str, depth: int = 2, limit: int = 50):
return load("kg_neighborhood.json")
# ---------- 抽取任务 ----------
@app.get("/api/extraction/jobs")
async def extraction_jobs():
return load("extraction_jobs.json")
@app.get("/api/extraction/jobs/{job_id}")
async def extraction_job(job_id: str):
return load("extraction_job.json")
@app.get("/api/extraction/processing-status")
async def extraction_processing_status():
return load("extraction_processing_status.json")
@app.get("/api/extraction/run-log")
async def extraction_run_log():
return {"text": "2026-08-25 10:00:01 INFO MinerU 解析完成,11 sections\n2026-08-25 10:03:44 INFO 实体识别 712 entities\n2026-08-25 10:12:09 INFO 关系抽取 751 relations\n"}
@app.get("/api/extraction/summary")
async def extraction_summary():
return load("extraction_summary.json")
@app.post("/api/extraction/upload")
async def extraction_upload(request: Request):
return load("extraction_upload.json")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8099, log_level="info")
+30
View File
@@ -0,0 +1,30 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "plantai-bff"
version = "0.1.0"
description = "PlantAI-rag-new BFF:上游代理 + 归一化 + 会话持久化 + Neo4j 直连"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.110",
"uvicorn[standard]>=0.29",
"httpx>=0.27",
"aiosqlite>=0.20",
"neo4j>=5.18",
"pydantic>=2.6",
"pydantic-settings>=2.2",
"python-multipart>=0.0.9",
]
[project.optional-dependencies]
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "respx>=0.23"]
[tool.setuptools.packages.find]
include = ["app*"]
[tool.pytest.ini_options]
testpaths = ["app"]
python_files = ["test_*.py"]
asyncio_mode = "auto"
Executable
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# run.sh —— 本地起 BFF(ARCHITECTURE.md §3:127.0.0.1:8090,占用则退 8091)。
# 用法:bash backend/run.sh
set -euo pipefail
cd "$(dirname "$0")"
PORT="${PORT:-8090}"
if ! command -v conda >/dev/null 2>&1; then
echo "run.sh: 未找到 conda,尝试直接使用当前 python 运行" >&2
exec python -m uvicorn app.main:app --host 127.0.0.1 --port "$PORT"
fi
if ! conda env list | awk '{print $1}' | grep -qx "plantai-bff"; then
echo "run.sh: conda 环境 plantai-bff 不存在" >&2
echo " 创建:conda create -n plantai-bff python=3.11 -y && conda activate plantai-bff && pip install -e '.[dev]'" >&2
exit 1
fi
exec conda run --no-capture-output -n plantai-bff \
python -m uvicorn app.main:app --host 127.0.0.1 --port "$PORT"