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
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