forked from admin/PlantAI-rag-new
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:
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "plantai-rag-new",
|
||||
"url": "http://localhost:5180",
|
||||
"runtimeExecutable": "true",
|
||||
"runtimeArgs": [],
|
||||
"port": 5180
|
||||
}
|
||||
]
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# 运行时产物与环境
|
||||
node_modules
|
||||
dist/
|
||||
.venv/
|
||||
.venv-backend/
|
||||
.env
|
||||
backend/data/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
artifacts/
|
||||
test-results/
|
||||
playwright-report/
|
||||
coverage/
|
||||
**/data/
|
||||
@@ -0,0 +1,78 @@
|
||||
# PlantAI-rag-new —— 植物基因家族文献知识工作台
|
||||
|
||||
海古纪 PlantAI 的知识问答工作台:基于文献知识库(100 篇论文 / 44,595 条关系断言 / 382 万实体)的
|
||||
基因家族问答系统。采用 `plantai-agent-ui-prototype` 的视觉设计(深色默认、青紫渐变、Agent 五相位时间线),
|
||||
支持桌面 / 平板 / 手机四档响应式。
|
||||
|
||||
**架构**:薄适配层(BFF)+ 全新前端,不重写 RAG 算法。上游为现有运行中的服务,本项目只读消费。
|
||||
|
||||
```
|
||||
浏览器 ──> BFF (FastAPI, 127.0.0.1:8090) ──> QA 7920(问答引擎)
|
||||
│ ├─> RAG 8000/7900(论文抽取)
|
||||
│ └─> Neo4j 18788(知识图谱,Bolt 直连)
|
||||
└─> SQLite(会话历史,WAL)
|
||||
```
|
||||
|
||||
## 目录
|
||||
|
||||
```
|
||||
backend/ BFF(app/ 应用代码,mock/ 前端开发用 mock server)
|
||||
frontend/ Vue 3 + Vite 前端(src/ 源码)
|
||||
docs/ 全部文档:PRD / API / UI_SPEC / ARCHITECTURE / TEST_PLAN / TEST_CASES / DISCOVERY / 测试报告
|
||||
deploy/ 部署脚本:sync.sh(本地→远端)、bff_restart.sh、start-dev.sh、集成检查、E2E 验收
|
||||
tests/ 测试(backend 接口、frontend 单元、e2e)
|
||||
```
|
||||
|
||||
## 快速启动(远端)
|
||||
|
||||
```bash
|
||||
# 1. 启动 BFF(自动激活 conda plantai-bff)
|
||||
ssh 100.109.52.26 'bash ~/5090/teamWorkspace/zh/PlantAI-rag-new/deploy/bff_restart.sh'
|
||||
|
||||
# 2. 启动 Vite 开发服务器(开发模式)
|
||||
ssh 100.109.52.26 'cd ~/5090/teamWorkspace/zh/PlantAI-rag-new/frontend && \
|
||||
VITE_BFF_TARGET=http://127.0.0.1:8090 setsid nohup npm run dev > /tmp/plantai-vite.log 2>&1 < /dev/null & disown'
|
||||
|
||||
# 3. Mac 侧访问隧道
|
||||
ssh -N -L 5180:127.0.0.1:5180 -L 8090:127.0.0.1:8090 100.109.52.26
|
||||
# 浏览器打开 http://localhost:5180
|
||||
```
|
||||
|
||||
生产形态:`npm run build` 产出 `frontend/dist`,BFF 启动时自动静态托管,单进程从 `:8090` 直接服务。
|
||||
|
||||
## 开发工作流
|
||||
|
||||
- **本地编辑** `/Users/jerry/haiguji/PlantAI-rag-new/`,**远端运行** `/home/xzq/5090/teamWorkspace/zh/PlantAI-rag-new/`
|
||||
- 同步(PM 执行):`bash deploy/sync.sh`(rsync,排除 node_modules/venv/data/dist)
|
||||
- 前端开发期 API 指向 mock(8099,`scenario=normal|empty|slow|error|degraded` 经 `X-Mock-Scenario` 头切换);
|
||||
集成期 `VITE_BFF_TARGET=http://127.0.0.1:8090` 指向真实 BFF
|
||||
- 契约文档(docs/)是开发与测试的唯一依据,变更需 PM 裁决
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
# 后端(conda plantai-bff 或本地 .venv-backend)
|
||||
cd backend && python -m pytest . -q # 单元 + 接口
|
||||
# 前端
|
||||
cd frontend && npx vitest run --config vitest.config.js # 单元 + 组件
|
||||
cd frontend && npx playwright test # E2E 三档视口(mobile/tablet/desktop)
|
||||
# 上游连通冒烟
|
||||
bash tests/smoke_upstreams.sh
|
||||
```
|
||||
|
||||
## 上游与端口
|
||||
|
||||
| 服务 | 端口 | 说明 |
|
||||
|---|---|---|
|
||||
| QA V1.1.73(问答引擎) | 7920 | 只读消费;LLM 依赖 DeepSeek 余额 |
|
||||
| Gene Family RAG System(抽取) | 8000 | 回落 7900 |
|
||||
| Neo4j(知识图谱) | 18788 | canonical schema,Bolt 直连 |
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 上游 QA 无 token 级流式(答案在终态一次返回),打字机为客户端揭示动画
|
||||
- 每次提问独立检索(`max_rounds:1`),无多轮上下文
|
||||
- DeepSeek 余额用尽时答案退化为证据列表(检索/图谱/来源不受影响)
|
||||
|
||||
详细设计见 `docs/`:PRD.md(81 个功能点)、API.md(BFF 契约)、UI_SPEC.md(设计令牌)、
|
||||
ARCHITECTURE.md(分工与协作约定)、DISCOVERY.md(上游探测事实)、TEST_PLAN.md(测试纪律)。
|
||||
@@ -0,0 +1,32 @@
|
||||
# backend/.env.example —— BFF 全部配置键(pydantic-settings,键名大小写不敏感)
|
||||
# 复制为 .env 后按需修改。以下默认值即 config.py 的默认值,本地开发可不改。
|
||||
|
||||
# ---- 应用 ----
|
||||
# APP_NAME=plantai-rag-new-bff
|
||||
# VERSION=0.1.0
|
||||
|
||||
# ---- 上游地址(只读消费)----
|
||||
QA_BASE_URL=http://127.0.0.1:7920
|
||||
QA_API_PATH=/api/v1/qa-v1-1-73
|
||||
RAG_BASE_URL=http://127.0.0.1:8000
|
||||
RAG_ALT_BASE_URL=http://127.0.0.1:7900
|
||||
|
||||
# ---- Neo4j 候选(顺序即回退顺序:18788 首选 → 19788 → 17687;凭据见 DISCOVERY.md §2)
|
||||
# 注意:凭据只进 BFF 配置,绝不下发到浏览器。
|
||||
NEO4J_BOLT_URIS=bolt://127.0.0.1:18788,bolt://127.0.0.1:19788,bolt://127.0.0.1:17687
|
||||
NEO4J_USER=neo4j
|
||||
NEO4J_PASSWORDS=<in backend/.env, 不入 git>
|
||||
|
||||
# ---- 超时阶梯(秒)----
|
||||
TIMEOUT_HEALTH=1
|
||||
TIMEOUT_QA_CHAT=300
|
||||
TIMEOUT_QA_SEARCH=180
|
||||
TIMEOUT_UPLOAD=600
|
||||
TIMEOUT_EXTRACTION=15
|
||||
|
||||
# ---- 本地 ----
|
||||
DB_PATH=backend/data/plantai.db
|
||||
HEALTH_CACHE_TTL=5
|
||||
|
||||
# ---- 生产形态:BFF 静态托管 frontend/dist(M6 启用)----
|
||||
FRONTEND_DIST_DIR=frontend/dist
|
||||
@@ -0,0 +1,6 @@
|
||||
# backend/.gitignore(最小版,各 agent 勿改,如需调整向 PM 提出)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.env
|
||||
data/
|
||||
@@ -0,0 +1,65 @@
|
||||
"""config.py —— 环境变量驱动的 BFF 配置(pydantic-settings)。
|
||||
|
||||
全部键见 backend/.env.example。凭据类键(Neo4j)只存在于本进程,
|
||||
**绝不下发到浏览器**(不进入任何 API 响应)。
|
||||
|
||||
上游拓扑与默认值依据 docs/DISCOVERY.md:
|
||||
- QA 7920:`/api/v1/qa-v1-1-73`(DISCOVERY §4)
|
||||
- RAG 8000(Methodology API,DISCOVERY §3)/ ragAlt 7900(protected 容器,§3.7)
|
||||
- Neo4j 候选顺序:18788(direct,首选)→ 19788(normalized)→ 17687(protected,空库)
|
||||
凭据见 DISCOVERY §2.1,可由 BFF_NEO4J_* 环境变量覆盖(测试/部署)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# ---- 应用 ----
|
||||
app_name: str = "plantai-rag-new-bff"
|
||||
version: str = "0.1.0"
|
||||
|
||||
# ---- 上游地址(只读消费,见 ARCHITECTURE.md §2)----
|
||||
qa_base_url: str = "http://127.0.0.1:7920"
|
||||
qa_api_path: str = "/api/v1/qa-v1-1-73"
|
||||
rag_base_url: str = "http://127.0.0.1:8000"
|
||||
rag_alt_base_url: str = "http://127.0.0.1:7900"
|
||||
|
||||
# ---- Neo4j 候选(顺序即回退顺序,首选 18788;凭据见 DISCOVERY §2.1)----
|
||||
neo4j_bolt_uris: str = (
|
||||
"bolt://127.0.0.1:18788,bolt://127.0.0.1:19788,bolt://127.0.0.1:17687"
|
||||
)
|
||||
neo4j_user: str = "neo4j"
|
||||
neo4j_passwords: str = (
|
||||
"" # 真实凭据在 backend/.env(不入 git),见 backend/.env.example
|
||||
)
|
||||
|
||||
# ---- 超时阶梯(秒)----
|
||||
timeout_health: float = 1.0 # 健康探测
|
||||
timeout_qa_chat: float = 300.0 # qa chat(读超时)
|
||||
timeout_qa_search: float = 180.0 # qa search(读超时)
|
||||
timeout_upload: float = 600.0 # 抽取 upload(读超时)
|
||||
timeout_extraction: float = 15.0 # 抽取其余接口
|
||||
|
||||
# ---- 本地 ----
|
||||
db_path: str = "backend/data/plantai.db"
|
||||
health_cache_ttl: float = 5.0
|
||||
|
||||
# ---- 生产形态:BFF 静态托管 frontend/dist(ARCHITECTURE.md §3,M6 挂载)----
|
||||
frontend_dist_dir: str = "frontend/dist"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,163 @@
|
||||
"""db.py —— SQLite 持久层(aiosqlite + WAL,无 ORM)。
|
||||
|
||||
Schema 与 API.md §5 对齐:
|
||||
- sessions: id / title / status / meta / created_at / updated_at
|
||||
- messages: id / session_id(FK CASCADE) / role / kind / status / blocks(JSON)
|
||||
/ raw(JSON) / text / created_at / updated_at
|
||||
|
||||
启动时幂等迁移(CREATE TABLE IF NOT EXISTS)。时间一律 ISO 8601 UTC
|
||||
(`2026-08-25T12:34:56Z`),由 BFF 侧统一生成(SQLite 不写 DEFAULT 时间戳)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from collections.abc import AsyncIterator, Iterable
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT '新会话',
|
||||
status TEXT NOT NULL DEFAULT 'idle',
|
||||
meta TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'qa',
|
||||
status TEXT NOT NULL DEFAULT 'done',
|
||||
blocks TEXT NOT NULL DEFAULT '[]',
|
||||
raw TEXT,
|
||||
text TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at);
|
||||
"""
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _db_path() -> Path:
|
||||
path = Path(get_settings().db_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
class Database:
|
||||
"""aiosqlite 单连接封装。
|
||||
|
||||
SQLite 写并发不高,BFF 用单写连接(WAL 模式下读写不互斥);
|
||||
依赖注入经 get_db() 提供本实例。
|
||||
"""
|
||||
|
||||
def __init__(self, path: str | Path | None = None) -> None:
|
||||
self._path: Path | None = Path(path) if path is not None else None
|
||||
self._conn: aiosqlite.Connection | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
if self._path is None:
|
||||
self._path = _db_path()
|
||||
return self._path
|
||||
|
||||
async def connect(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._conn = await aiosqlite.connect(self.path)
|
||||
self._conn.row_factory = aiosqlite.Row
|
||||
await self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
await self._conn.execute("PRAGMA foreign_keys=ON")
|
||||
await self._conn.execute("PRAGMA busy_timeout=5000")
|
||||
await self._conn.executescript(_SCHEMA)
|
||||
await self._conn.commit()
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._conn is not None:
|
||||
await self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
@property
|
||||
def conn(self) -> aiosqlite.Connection:
|
||||
if self._conn is None:
|
||||
raise RuntimeError("Database 未初始化:先 await init_db()")
|
||||
return self._conn
|
||||
|
||||
# ---- 通用查询(只读)----
|
||||
|
||||
async def fetchall(self, sql: str, params: Iterable[Any] = ()) -> list[aiosqlite.Row]:
|
||||
async with self._conn.execute(sql, tuple(params)) as cursor:
|
||||
return list(await cursor.fetchall())
|
||||
|
||||
async def fetchone(self, sql: str, params: Iterable[Any] = ()) -> aiosqlite.Row | None:
|
||||
async with self._conn.execute(sql, tuple(params)) as cursor:
|
||||
return await cursor.fetchone()
|
||||
|
||||
# ---- 写操作(串行化,提交后返回 lastrowid)----
|
||||
|
||||
async def execute(self, sql: str, params: Iterable[Any] = ()) -> int:
|
||||
async with self._lock:
|
||||
cur = await self._conn.execute(sql, tuple(params))
|
||||
await self._conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
db = Database()
|
||||
|
||||
|
||||
async def init_db(path: str | Path | None = None) -> Database:
|
||||
"""app 启动钩子:创建并迁移数据库(幂等)。返回全局单例(或指定路径的实例)。"""
|
||||
global db
|
||||
if path is not None:
|
||||
db = Database(path)
|
||||
await db.connect()
|
||||
return db
|
||||
|
||||
|
||||
async def get_db() -> AsyncIterator[Database]:
|
||||
"""FastAPI 依赖:每个请求交出同一数据库实例。"""
|
||||
yield db
|
||||
|
||||
|
||||
async def close_db() -> None:
|
||||
"""app shutdown 钩子。"""
|
||||
await db.close()
|
||||
|
||||
|
||||
# ---- JSON 编解码(落库用;失败时按契约置 null/[] 而不吞请求)----
|
||||
|
||||
def dumps_json(value: Any, default: Any = None) -> str:
|
||||
try:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
except (TypeError, ValueError):
|
||||
return json.dumps(default, ensure_ascii=False)
|
||||
|
||||
|
||||
def loads_json(text: str | None, default: Any = None) -> Any:
|
||||
if text is None:
|
||||
return default
|
||||
try:
|
||||
return json.loads(text)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def monotonic() -> float:
|
||||
"""测试/内部计时用(time.monotonic 别名,避免各处直接 import time)。"""
|
||||
return time.monotonic()
|
||||
@@ -0,0 +1,91 @@
|
||||
"""main.py —— BFF 应用入口(ARCHITECTURE.md §2/§3)。
|
||||
|
||||
- 挂全部 5 个 router(qa/kg/extraction 为 A2/A3 实现,import 路径不变)。
|
||||
- CORS:允许 http://localhost:5180 与 http://127.0.0.1:5180(Vite dev)。
|
||||
- lifespan:init_db(幂等迁移);关闭回收 httpx 客户端与 Neo4j 连接。
|
||||
- 全局 RequestValidationError → VALIDATION 信封(API.md §1,2026-08-25 PM 集成)。
|
||||
- 生产形态(M6):frontend/dist 存在时静态托管(ARCHITECTURE.md §3)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .config import get_settings
|
||||
from .db import close_db, init_db
|
||||
from .routers import extraction, health, kg, qa, sessions
|
||||
from .upstream import close_client
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
_ALLOWED_ORIGINS = [
|
||||
"http://localhost:5180",
|
||||
"http://127.0.0.1:5180",
|
||||
]
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
app = FastAPI(title=settings.app_name, version=settings.version)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_ALLOWED_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(sessions.router)
|
||||
app.include_router(qa.router)
|
||||
app.include_router(kg.router)
|
||||
app.include_router(extraction.router)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def _validation_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
# API.md §1 已裁决:参数校验错误收敛为统一 VALIDATION 信封(422)
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={
|
||||
"ok": False,
|
||||
"error": {"code": "VALIDATION", "message": "请求参数不合法", "degraded": False},
|
||||
},
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(_app: FastAPI):
|
||||
await init_db()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await close_client()
|
||||
try:
|
||||
from .services.neo4j import close_neo4j # A3 提供;缺失则跳过
|
||||
|
||||
await close_neo4j()
|
||||
except ImportError:
|
||||
pass
|
||||
await close_db()
|
||||
|
||||
app.router.lifespan_context = _lifespan
|
||||
|
||||
# 生产形态:静态托管 frontend/dist(存在时挂载;本地开发期可无)。
|
||||
# 2026-08-25 PM 修复:dist 路径必须基于项目根解析(BFF 工作目录是 backend/,
|
||||
# 相对路径会解析成 backend/frontend/dist);sync.sh 排除 dist,需单独同步。
|
||||
project_root = Path(__file__).resolve().parent.parent.parent
|
||||
dist = project_root / settings.frontend_dist_dir if not Path(settings.frontend_dist_dir).is_absolute() else Path(settings.frontend_dist_dir)
|
||||
if dist.is_dir():
|
||||
app.mount("/", StaticFiles(directory=str(dist), html=True), name="frontend")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,150 @@
|
||||
"""routers/extraction.py —— 抽取任务代理(8000 主 + 7900 回落,API.md §10)。
|
||||
|
||||
- 上传仅限 PDF ≤50MB,否则 422 VALIDATION 信封。
|
||||
- 双上游全不可达 → 503 UPSTREAM_DOWN 降级信封。
|
||||
- 上游 5xx(如 MinerU 不可用)→ 502 UPSTREAM_ERROR。
|
||||
错误信封与 mock_server 一致:直接返回 JSONResponse,信封在顶层。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
from fastapi import APIRouter, File, Form, Query, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from ..services import extraction as _extraction
|
||||
from ..services.extraction import (
|
||||
MAX_PDF_BYTES,
|
||||
ExtractionUnavailable,
|
||||
ExtractionUpstreamError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/extraction", tags=["extraction"])
|
||||
|
||||
_DOWN_MSG = "抽取服务当前不可用,请稍后重试"
|
||||
_MINERU_MSG = "抽取任务提交失败:上游 MinerU 服务不可用,请检查抽取环境后重试"
|
||||
|
||||
|
||||
def _envelope(code: str, message: str, degraded: bool, status: int) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=status,
|
||||
content={
|
||||
"ok": False,
|
||||
"error": {"code": code, "message": message, "degraded": degraded},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _down(exc: BaseException | None = None) -> JSONResponse:
|
||||
logger.warning("extraction: 上游不可用:%s", exc)
|
||||
return _envelope("UPSTREAM_DOWN", _DOWN_MSG, True, 503)
|
||||
|
||||
|
||||
def _upstream_error(message: str) -> JSONResponse:
|
||||
logger.warning("extraction: 上游错误:%s", message)
|
||||
return _envelope("UPSTREAM_ERROR", message, True, 502)
|
||||
|
||||
|
||||
def _validation(message: str) -> JSONResponse:
|
||||
return _envelope("VALIDATION", message, False, 422)
|
||||
|
||||
|
||||
def _check_pdf_upload(filename: str, size: int) -> JSONResponse | None:
|
||||
"""FR-EXTRACT-004:仅 PDF,单文件 ≤50MB。合法返回 None。"""
|
||||
if size <= 0:
|
||||
return _validation("上传文件为空")
|
||||
if size > MAX_PDF_BYTES:
|
||||
return _validation("文件超过 50MB 限制")
|
||||
if not filename.lower().endswith(".pdf"):
|
||||
return _validation("仅支持 PDF 文件")
|
||||
return None
|
||||
|
||||
|
||||
async def _stream_upload(file: UploadFile) -> AsyncIterator[bytes]:
|
||||
try:
|
||||
while chunk := await file.read(1024 * 1024): # 1MB
|
||||
yield chunk
|
||||
finally:
|
||||
await file.close()
|
||||
|
||||
|
||||
@router.get("/jobs")
|
||||
async def jobs(limit: int = Query(20, ge=1, le=200)) -> dict[str, Any]:
|
||||
try:
|
||||
return await _extraction.service.list_jobs(limit=limit)
|
||||
except ExtractionUnavailable as exc:
|
||||
return _down(exc)
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}")
|
||||
async def job_detail(job_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
return await _extraction.service.get_job(job_id)
|
||||
except ExtractionUnavailable as exc:
|
||||
return _down(exc)
|
||||
|
||||
|
||||
@router.get("/processing-status")
|
||||
async def processing_status() -> dict[str, Any]:
|
||||
try:
|
||||
return await _extraction.service._passthrough("/processing-status")
|
||||
except ExtractionUnavailable as exc:
|
||||
return _down(exc)
|
||||
|
||||
|
||||
@router.get("/progress")
|
||||
async def progress() -> dict[str, Any]:
|
||||
try:
|
||||
return await _extraction.service._passthrough("/progress")
|
||||
except ExtractionUnavailable as exc:
|
||||
return _down(exc)
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
async def summary() -> dict[str, Any]:
|
||||
try:
|
||||
return await _extraction.service._passthrough("/summary")
|
||||
except ExtractionUnavailable as exc:
|
||||
return _down(exc)
|
||||
|
||||
|
||||
@router.get("/run-log")
|
||||
async def run_log() -> dict[str, Any]:
|
||||
try:
|
||||
return await _extraction.service._passthrough("/run-log")
|
||||
except ExtractionUnavailable as exc:
|
||||
return _down(exc)
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload(
|
||||
file: UploadFile = File(...),
|
||||
paper_id: str = Form("uploaded_paper"),
|
||||
family: str = Form(""),
|
||||
output_name: str = Form(""),
|
||||
) -> dict[str, Any]:
|
||||
filename = file.filename or ""
|
||||
size = file.size or 0
|
||||
invalid = _check_pdf_upload(filename, size)
|
||||
if invalid is not None:
|
||||
return invalid
|
||||
try:
|
||||
return await _extraction.service.upload(
|
||||
filename=Path(filename).name,
|
||||
stream=_stream_upload(file),
|
||||
content_type=file.content_type or "application/pdf",
|
||||
paper_id=paper_id,
|
||||
family=family,
|
||||
output_name=output_name,
|
||||
)
|
||||
except ExtractionUnavailable as exc:
|
||||
return _down(exc)
|
||||
except ExtractionUpstreamError as exc:
|
||||
return _upstream_error(f"{_MINERU_MSG}({exc})")
|
||||
except Exception as exc:
|
||||
logger.exception("extraction: 上传失败")
|
||||
return _upstream_error(f"提交抽取任务失败:{exc}")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""routers/health.py —— GET /api/health(API.md §3)。
|
||||
|
||||
并发探测 7920、8000、7900 与 Neo4j(1s 超时,5s 缓存),外加 db 状态。
|
||||
返回 `{status, version, upstreams, db}`;健康接口不包错误信封(API.md §1 例外)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from ..config import get_settings
|
||||
from ..db import db
|
||||
from ..upstream import probe_all
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["health"])
|
||||
|
||||
|
||||
async def _db_status() -> dict:
|
||||
try:
|
||||
rows = await db.fetchall("SELECT COUNT(*) AS n FROM sessions")
|
||||
return {"ok": True, "sessions": rows[0]["n"] if rows else 0}
|
||||
except Exception:
|
||||
return {"ok": False, "sessions": 0}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health() -> dict:
|
||||
upstreams = await probe_all()
|
||||
db_status = await _db_status()
|
||||
degraded = not all(u.get("ok") for u in upstreams.values()) or not db_status.get("ok")
|
||||
return {
|
||||
"status": "degraded" if degraded else "ok",
|
||||
"version": get_settings().version,
|
||||
"upstreams": upstreams,
|
||||
"db": db_status,
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"""routers/kg.py —— 知识图谱浏览(BFF Bolt 直连 Neo4j,API.md §6)。
|
||||
|
||||
Neo4j 不可用 → 503 UPSTREAM_DOWN 降级信封;未命中实体 → 404 NOT_FOUND。
|
||||
错误信封与 mock_server 一致:直接返回 JSONResponse,信封在顶层
|
||||
(不用 HTTPException,避免被 FastAPI 包成 {"detail": {...}})。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from ..services import neo4j as _neo4j
|
||||
from ..services.neo4j import Neo4jUnavailable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/kg", tags=["kg"])
|
||||
|
||||
_UPSTREAM_DOWN_MSG = "知识图谱服务当前不可用,请稍后重试"
|
||||
|
||||
|
||||
def _down(exc: BaseException | None = None) -> JSONResponse:
|
||||
logger.warning("kg: Neo4j 不可用:%s", exc)
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": "UPSTREAM_DOWN",
|
||||
"message": _UPSTREAM_DOWN_MSG,
|
||||
"degraded": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _not_found() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": "NOT_FOUND",
|
||||
"message": "未找到该实体",
|
||||
"degraded": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def kg_status():
|
||||
try:
|
||||
return await _neo4j.service.kg_status()
|
||||
except Neo4jUnavailable as exc:
|
||||
return _down(exc)
|
||||
|
||||
|
||||
@router.get("/overview")
|
||||
async def kg_overview():
|
||||
try:
|
||||
return await _neo4j.service.kg_overview()
|
||||
except Neo4jUnavailable as exc:
|
||||
return _down(exc)
|
||||
|
||||
|
||||
@router.get("/entities")
|
||||
async def kg_entities(
|
||||
q: str = Query("", description="实体名(name_norm 精确或前缀匹配)"),
|
||||
limit: int = Query(20, ge=1, le=20),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
results = await _neo4j.service.search_entities(q, limit=limit)
|
||||
except Neo4jUnavailable as exc:
|
||||
return _down(exc)
|
||||
return {"query": q, "results": results}
|
||||
|
||||
|
||||
@router.get("/entities/{entity_id}")
|
||||
async def kg_entity(entity_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
entity = await _neo4j.service.entity(entity_id)
|
||||
except Neo4jUnavailable as exc:
|
||||
return _down(exc)
|
||||
if entity is None:
|
||||
return _not_found()
|
||||
return entity
|
||||
|
||||
|
||||
@router.get("/entities/{entity_id}/neighborhood")
|
||||
async def kg_neighborhood(
|
||||
entity_id: str,
|
||||
depth: int = Query(1, ge=1, le=2),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
result = await _neo4j.service.neighborhood(entity_id, depth=depth, limit=limit)
|
||||
except Neo4jUnavailable as exc:
|
||||
return _down(exc)
|
||||
if result.get("center") is None:
|
||||
return _not_found()
|
||||
return result
|
||||
@@ -0,0 +1,211 @@
|
||||
"""routers/qa.py —— /api/qa/* :7920 代理 + 归一化 + SSE 中继(A2 归属,API.md §3/§4)。
|
||||
|
||||
端点:
|
||||
- GET /api/qa/info 上游 /info 精简(name/version/pipeline/config)
|
||||
- POST /api/qa/chat 上游 /chat,300s 读超时,invalid_llm_citation 自动重试一次
|
||||
- POST /api/qa/chat/stream SSE 中继(app.services.sse_relay.relay_stream)
|
||||
- POST /api/qa/search 上游 /search,强制 use_llm:false,180s 读超时,不重试,
|
||||
响应 mode:"search"、answer:""
|
||||
|
||||
请求体一律透传(前端字段集见 API.md §4,BFF 不增删字段)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from ..services import sse_relay
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/qa", tags=["qa"])
|
||||
|
||||
# 超时阶梯(TODO(A2→A1 对齐点):A1 的 app.config.settings 就绪后改为配置读取)
|
||||
CHAT_READ_TIMEOUT = 300.0
|
||||
SEARCH_READ_TIMEOUT = 180.0
|
||||
INFO_READ_TIMEOUT = 5.0
|
||||
CONNECT_TIMEOUT = 10.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 错误信封(TODO(A2→A1 对齐点):HTTP JSON 信封换用 app.upstream.envelope())
|
||||
# ---------------------------------------------------------------------------
|
||||
MESSAGES = {
|
||||
"UPSTREAM_DOWN": "知识问答服务当前不可用,请稍后重试",
|
||||
"UPSTREAM_ERROR": "知识问答服务返回错误",
|
||||
"TIMEOUT": "检索超时",
|
||||
"INVALID_CITATION": "引用校验失败,请重试",
|
||||
"VALIDATION": "请求参数不合法",
|
||||
"INTERNAL": "内部错误",
|
||||
}
|
||||
|
||||
|
||||
def _envelope(code: str, status: int, message: Optional[str] = None, degraded: Optional[bool] = None) -> JSONResponse:
|
||||
if degraded is None:
|
||||
degraded = code == "UPSTREAM_DOWN"
|
||||
return JSONResponse(
|
||||
status_code=status,
|
||||
content={"ok": False, "error": {"code": code, "message": message or MESSAGES[code], "degraded": degraded}},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 上游调用
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _upstream_path(endpoint: str) -> str:
|
||||
return f"{sse_relay.QA_UPSTREAM_PREFIX}/{endpoint}"
|
||||
|
||||
|
||||
def _read_timeout(seconds: float) -> httpx.Timeout:
|
||||
return httpx.Timeout(connect=CONNECT_TIMEOUT, read=seconds, write=30.0, pool=CONNECT_TIMEOUT)
|
||||
|
||||
|
||||
async def _parse_json(text: Optional[bytes]) -> Any:
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return json.loads(text.decode("utf-8", errors="replace"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _error_from_status(status: int, payload: Any) -> tuple[str, int, Optional[str]]:
|
||||
"""上游 HTTP≥400 → (code, bff_http, message)。HTTP 按 API.md §1 固定映射,不透传上游状态码。"""
|
||||
if sse_relay.is_invalid_citation(payload):
|
||||
return "INVALID_CITATION", 502, None
|
||||
if status in (408, 504):
|
||||
return "TIMEOUT", 504, None
|
||||
detail = payload.get("detail") if isinstance(payload, dict) else None
|
||||
if isinstance(detail, dict):
|
||||
message = detail.get("message") or detail.get("error")
|
||||
return "UPSTREAM_ERROR", 502, message
|
||||
if isinstance(payload, dict):
|
||||
message = payload.get("message") or payload.get("error")
|
||||
return "UPSTREAM_ERROR", 502, message
|
||||
return "UPSTREAM_ERROR", 502, None
|
||||
|
||||
|
||||
async def _proxy(
|
||||
endpoint: str,
|
||||
body: Optional[dict] = None,
|
||||
*,
|
||||
read_timeout: float,
|
||||
retry_invalid_citation: bool,
|
||||
mode: str,
|
||||
normalizer=None,
|
||||
) -> Any:
|
||||
"""非流式上游代理:重试、错误信封、归一化。返回 (JSONResponse)。"""
|
||||
client = sse_relay.get_qa_client()
|
||||
normalizer = normalizer or sse_relay.get_qa_normalizer()
|
||||
url = f"{sse_relay.qa_base_url()}{_upstream_path(endpoint)}"
|
||||
timeout = _read_timeout(read_timeout)
|
||||
json_body = body if body is not None else {}
|
||||
|
||||
for attempt in (1, 2):
|
||||
try:
|
||||
resp = await client.request("POST", url, json=json_body, timeout=timeout)
|
||||
except httpx.ReadTimeout:
|
||||
return _envelope("TIMEOUT", 504)
|
||||
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout) as exc:
|
||||
logger.warning("上游 %s 连接失败: %s", endpoint, exc)
|
||||
return _envelope("UPSTREAM_DOWN", 503)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("上游 %s 请求失败: %s", endpoint, exc)
|
||||
return _envelope("UPSTREAM_ERROR", 502)
|
||||
payload = await _parse_json(resp.content)
|
||||
if resp.status_code >= 400:
|
||||
if (
|
||||
retry_invalid_citation
|
||||
and attempt == 1
|
||||
and sse_relay.is_invalid_citation(payload)
|
||||
):
|
||||
logger.warning(
|
||||
"上游 %s 返回 %d invalid_llm_citation,自动重试(第 1 次)", endpoint, resp.status_code
|
||||
)
|
||||
continue
|
||||
code, bff_status, message = _error_from_status(resp.status_code, payload)
|
||||
return _envelope(code, bff_status, message)
|
||||
try:
|
||||
normalized = normalizer(payload if isinstance(payload, dict) else {})
|
||||
except Exception:
|
||||
logger.exception("归一化上游 %s 响应失败", endpoint)
|
||||
return _envelope("INTERNAL", 500)
|
||||
normalized["mode"] = mode
|
||||
if mode == "search":
|
||||
normalized["answer"] = ""
|
||||
return JSONResponse(content=normalized)
|
||||
# 不可达(重试循环内必然 return);防御性兜底
|
||||
return _envelope("UPSTREAM_ERROR", 502)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 端点
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/info")
|
||||
async def qa_info():
|
||||
"""上游 /info 精简(API.md §3)。"""
|
||||
client = sse_relay.get_qa_client()
|
||||
url = f"{sse_relay.qa_base_url()}{_upstream_path('info')}"
|
||||
try:
|
||||
resp = await client.get(url, timeout=_read_timeout(INFO_READ_TIMEOUT))
|
||||
except httpx.ReadTimeout:
|
||||
return _envelope("TIMEOUT", 504)
|
||||
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout) as exc:
|
||||
logger.warning("上游 /info 连接失败: %s", exc)
|
||||
return _envelope("UPSTREAM_DOWN", 503)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("上游 /info 请求失败: %s", exc)
|
||||
return _envelope("UPSTREAM_ERROR", 502)
|
||||
payload = await _parse_json(resp.content)
|
||||
if resp.status_code >= 400 or not isinstance(payload, dict):
|
||||
return _envelope("UPSTREAM_ERROR", 502)
|
||||
info = payload.get("info") if isinstance(payload.get("info"), dict) else payload
|
||||
return JSONResponse(
|
||||
content={
|
||||
"name": info.get("name") or payload.get("name"),
|
||||
"version": info.get("version") or payload.get("version"),
|
||||
"pipeline": info.get("pipeline") or payload.get("pipeline") or [],
|
||||
"config": info.get("config") or payload.get("config") or {},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/chat")
|
||||
async def qa_chat(request: Request):
|
||||
"""非流式问答:上游 /chat + 归一化 + invalid_llm_citation 重试一次。"""
|
||||
body = await request.json()
|
||||
return await _proxy(
|
||||
"chat", body, read_timeout=CHAT_READ_TIMEOUT, retry_invalid_citation=True, mode="chat"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/chat/stream")
|
||||
async def qa_chat_stream(request: Request):
|
||||
"""SSE 中继(API.md §4)。"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return _envelope("VALIDATION", 422)
|
||||
return StreamingResponse(
|
||||
sse_relay.relay_stream(body),
|
||||
media_type="text/event-stream",
|
||||
headers=sse_relay.sse_headers,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/search")
|
||||
async def qa_search(request: Request):
|
||||
"""检索模式:强制 use_llm:false,180s 读超时,不重试。"""
|
||||
body = await request.json()
|
||||
body = {**body, "use_llm": False}
|
||||
return await _proxy(
|
||||
"search", body, read_timeout=SEARCH_READ_TIMEOUT, retry_invalid_citation=False, mode="search"
|
||||
)
|
||||
@@ -0,0 +1,319 @@
|
||||
"""routers/sessions.py —— 会话与消息 CRUD + 会话级来源聚合(API.md §5)。
|
||||
|
||||
- GET/POST /api/sessions —— 列表(updated_at 倒序)/ 创建(title 可空,默认「新会话」)
|
||||
- GET/PATCH/DELETE /api/sessions/{id} —— 详情(含 messages)/ 更新(title|status)/ 删除(204,级联消息)
|
||||
- POST /api/sessions/{id}/messages —— 用户消息({role:"user", text})或助手流式骨架
|
||||
({role:"assistant", kind, status:"streaming", blocks:[…]})
|
||||
- PATCH /api/sessions/{id}/messages/{mid} —— 局部更新 {status, blocks, raw}(流式检查点)
|
||||
- GET /api/sessions/{id}/sources —— 全会话来源聚合:按 paper_id 去重(无 paper_id 按 id),
|
||||
带 count(出现次数)。
|
||||
|
||||
404 → NOT_FOUND 信封;时间 ISO 8601 UTC。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Response, status as http_status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from ..db import Database, dumps_json, get_db, loads_json
|
||||
from ..schemas import (
|
||||
MessageCreate,
|
||||
MessageOut,
|
||||
MessagePatch,
|
||||
SessionCreate,
|
||||
SessionDetail,
|
||||
SessionOut,
|
||||
SessionPatch,
|
||||
)
|
||||
from ..upstream import envelope
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
||||
|
||||
_SESSION_SELECT = (
|
||||
"SELECT s.id, s.title, s.status, s.meta, s.created_at, s.updated_at, "
|
||||
"(SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) AS message_count "
|
||||
"FROM sessions s"
|
||||
)
|
||||
|
||||
_MESSAGE_SELECT = (
|
||||
"SELECT id, session_id, role, kind, status, blocks, raw, text, created_at, updated_at "
|
||||
"FROM messages"
|
||||
)
|
||||
|
||||
|
||||
def _db_now() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _row_to_session(row: Any) -> SessionOut:
|
||||
return SessionOut(
|
||||
id=row["id"],
|
||||
title=row["title"],
|
||||
status=row["status"],
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
message_count=row["message_count"],
|
||||
meta=loads_json(row["meta"], default={"title_manual": False}),
|
||||
)
|
||||
|
||||
|
||||
def _row_to_message(row: Any) -> MessageOut:
|
||||
return MessageOut(
|
||||
id=row["id"],
|
||||
role=row["role"],
|
||||
kind=row["kind"],
|
||||
status=row["status"],
|
||||
blocks=loads_json(row["blocks"], default=[]),
|
||||
raw=loads_json(row["raw"], default=None),
|
||||
text=row["text"],
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
|
||||
|
||||
async def _get_session_or_none(
|
||||
db: Database, session_id: str, *, with_count: bool = True
|
||||
) -> SessionOut | None:
|
||||
sql = _SESSION_SELECT + " WHERE s.id = ?"
|
||||
row = await db.fetchone(sql, (session_id,))
|
||||
return _row_to_session(row) if row else None
|
||||
|
||||
|
||||
async def _get_message_or_none(
|
||||
db: Database, session_id: str, message_id: str
|
||||
) -> MessageOut | None:
|
||||
row = await db.fetchone(
|
||||
_MESSAGE_SELECT + " WHERE id = ? AND session_id = ?", (message_id, session_id)
|
||||
)
|
||||
return _row_to_message(row) if row else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 会话
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("", response_model=list[SessionOut])
|
||||
async def list_sessions(db: Database = Depends(get_db)) -> list[SessionOut]:
|
||||
rows = await db.fetchall(_SESSION_SELECT + " ORDER BY s.updated_at DESC")
|
||||
return [_row_to_session(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=SessionOut, status_code=http_status.HTTP_201_CREATED)
|
||||
async def create_session(
|
||||
payload: SessionCreate, db: Database = Depends(get_db)
|
||||
) -> SessionOut:
|
||||
now = _db_now()
|
||||
session_id = str(uuid.uuid4())
|
||||
title = payload.normalized_title()
|
||||
await db.execute(
|
||||
"INSERT INTO sessions (id, title, status, meta, created_at, updated_at) "
|
||||
"VALUES (?, ?, 'idle', ?, ?, ?)",
|
||||
(session_id, title, dumps_json({"title_manual": False}), now, now),
|
||||
)
|
||||
out = await _get_session_or_none(db, session_id)
|
||||
assert out is not None # 刚插入必存在
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/{session_id}", response_model=SessionDetail)
|
||||
async def get_session(session_id: str, db: Database = Depends(get_db)):
|
||||
session = await _get_session_or_none(db, session_id)
|
||||
if session is None:
|
||||
return _not_found(session_id)
|
||||
rows = await db.fetchall(
|
||||
_MESSAGE_SELECT + " WHERE session_id = ? ORDER BY created_at ASC", (session_id,)
|
||||
)
|
||||
return SessionDetail(**session.model_dump(), messages=[_row_to_message(r) for r in rows])
|
||||
|
||||
|
||||
@router.patch("/{session_id}", response_model=SessionOut)
|
||||
async def patch_session(
|
||||
session_id: str, payload: SessionPatch, db: Database = Depends(get_db)
|
||||
):
|
||||
session = await _get_session_or_none(db, session_id)
|
||||
if session is None:
|
||||
return _not_found(session_id)
|
||||
|
||||
title = payload.title.strip() if payload.title is not None else None
|
||||
status = payload.status
|
||||
|
||||
if title:
|
||||
meta = session.meta.model_dump()
|
||||
meta["title_manual"] = True
|
||||
await db.execute(
|
||||
"UPDATE sessions SET title = ?, meta = ?, updated_at = ? WHERE id = ?",
|
||||
(title, dumps_json(meta), _db_now(), session_id),
|
||||
)
|
||||
if status is not None:
|
||||
await db.execute(
|
||||
"UPDATE sessions SET status = ?, updated_at = ? WHERE id = ?",
|
||||
(status, _db_now(), session_id),
|
||||
)
|
||||
|
||||
out = await _get_session_or_none(db, session_id)
|
||||
assert out is not None
|
||||
return out
|
||||
|
||||
|
||||
@router.delete("/{session_id}", status_code=http_status.HTTP_204_NO_CONTENT)
|
||||
async def delete_session(session_id: str, db: Database = Depends(get_db)) -> Response:
|
||||
session = await _get_session_or_none(db, session_id)
|
||||
if session is None:
|
||||
return _not_found_response(session_id)
|
||||
await db.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
|
||||
return Response(status_code=http_status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 消息
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/{session_id}/messages", response_model=MessageOut, status_code=201)
|
||||
async def create_message(
|
||||
session_id: str, payload: MessageCreate, db: Database = Depends(get_db)
|
||||
):
|
||||
session = await _get_session_or_none(db, session_id)
|
||||
if session is None:
|
||||
return _not_found(session_id)
|
||||
|
||||
now = _db_now()
|
||||
message_id = str(uuid.uuid4())
|
||||
|
||||
if payload.role == "user":
|
||||
text = (payload.text or "").strip()
|
||||
await db.execute(
|
||||
"INSERT INTO messages (id, session_id, role, kind, status, blocks, raw, text, "
|
||||
"created_at, updated_at) VALUES (?, ?, 'user', ?, 'done', '[]', NULL, ?, ?, ?)",
|
||||
(message_id, session_id, payload.kind, text, now, now),
|
||||
)
|
||||
else:
|
||||
# 助手流式骨架:text 置空,blocks 落库初始块
|
||||
blocks = [b.model_dump() for b in payload.blocks]
|
||||
await db.execute(
|
||||
"INSERT INTO messages (id, session_id, role, kind, status, blocks, raw, text, "
|
||||
"created_at, updated_at) VALUES (?, ?, 'assistant', ?, ?, ?, NULL, NULL, ?, ?)",
|
||||
(message_id, session_id, payload.kind, payload.status or "streaming",
|
||||
dumps_json(blocks), now, now),
|
||||
)
|
||||
|
||||
# 消息活动 → 会话 updated_at 前移、idle → running
|
||||
await db.execute(
|
||||
"UPDATE sessions SET status = CASE WHEN status = 'idle' THEN 'running' ELSE status END, "
|
||||
"updated_at = ? WHERE id = ?",
|
||||
(now, session_id),
|
||||
)
|
||||
|
||||
out = await _get_message_or_none(db, session_id, message_id)
|
||||
assert out is not None
|
||||
return out
|
||||
|
||||
|
||||
@router.patch("/{session_id}/messages/{message_id}", response_model=MessageOut)
|
||||
async def patch_message(
|
||||
session_id: str,
|
||||
message_id: str,
|
||||
payload: MessagePatch,
|
||||
db: Database = Depends(get_db),
|
||||
):
|
||||
message = await _get_message_or_none(db, session_id, message_id)
|
||||
if message is None:
|
||||
return _not_found(session_id, message_id)
|
||||
|
||||
sets: list[str] = []
|
||||
params: list[Any] = []
|
||||
|
||||
if payload.status is not None:
|
||||
sets.append("status = ?")
|
||||
params.append(payload.status)
|
||||
if payload.blocks is not None:
|
||||
sets.append("blocks = ?")
|
||||
params.append(dumps_json([b.model_dump() for b in payload.blocks]))
|
||||
if payload.raw is not None:
|
||||
sets.append("raw = ?")
|
||||
params.append(dumps_json(payload.raw))
|
||||
|
||||
if sets:
|
||||
sets.append("updated_at = ?")
|
||||
params.append(_db_now())
|
||||
params.append(message_id)
|
||||
await db.execute(
|
||||
f"UPDATE messages SET {', '.join(sets)} WHERE id = ?", params
|
||||
)
|
||||
# 同步前移会话 updated_at
|
||||
await db.execute(
|
||||
"UPDATE sessions SET updated_at = ? WHERE id = ?",
|
||||
(_db_now(), session_id),
|
||||
)
|
||||
|
||||
out = await _get_message_or_none(db, session_id, message_id)
|
||||
assert out is not None
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 会话来源聚合
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/{session_id}/sources")
|
||||
async def get_session_sources(session_id: str, db: Database = Depends(get_db)):
|
||||
"""按 paper_id 去重(无 paper_id 时按 id),带 count(出现次数)。
|
||||
|
||||
来源来自该会话全部助手消息 blocks 里的 sources 块(§2 块联合)。
|
||||
"""
|
||||
session = await _get_session_or_none(db, session_id)
|
||||
if session is None:
|
||||
return _not_found(session_id)
|
||||
|
||||
rows = await db.fetchall(
|
||||
"SELECT blocks FROM messages WHERE session_id = ? AND role = 'assistant'",
|
||||
(session_id,),
|
||||
)
|
||||
|
||||
def is_sources_block(b: Any) -> bool:
|
||||
return isinstance(b, dict) and b.get("type") == "sources" and isinstance(b.get("items"), list)
|
||||
|
||||
def key_of(item: dict[str, Any]) -> str:
|
||||
return str(item.get("paper_id") or item.get("id") or "")
|
||||
|
||||
agg: dict[str, dict[str, Any]] = {}
|
||||
order: list[str] = []
|
||||
for row in rows:
|
||||
blocks = loads_json(row["blocks"], default=[])
|
||||
for block in blocks:
|
||||
if not is_sources_block(block):
|
||||
continue
|
||||
for item in block["items"]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
key = key_of(item)
|
||||
if not key:
|
||||
continue
|
||||
if key not in agg:
|
||||
agg[key] = dict(item)
|
||||
agg[key]["count"] = 0
|
||||
order.append(key)
|
||||
agg[key]["count"] += 1
|
||||
|
||||
sources = [agg[k] for k in order]
|
||||
return {"session_id": session_id, "total": sum(s["count"] for s in sources), "sources": sources}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 404 帮助
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _not_found(session_id: str, message_id: str | None = None) -> JSONResponse:
|
||||
detail = f"会话 {session_id} 不存在" if message_id is None else f"消息 {message_id} 不存在"
|
||||
body, status = envelope("NOT_FOUND", message=detail, status=404)
|
||||
return JSONResponse(status_code=status, content=body)
|
||||
|
||||
|
||||
def _not_found_response(session_id: str) -> JSONResponse:
|
||||
return _not_found(session_id)
|
||||
@@ -0,0 +1,207 @@
|
||||
"""routers/test_kg.py —— kg 路由单元测试。
|
||||
|
||||
用假 Neo4jService 替换 services.neo4j.service(monkeypatch),覆盖:
|
||||
五个端点成功形态、Neo4j 不可用 → 503 UPSTREAM_DOWN、未命中实体 → 404
|
||||
NOT_FOUND、参数校验(limit ≤ 20、depth 1-2)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.routers import kg as kg_router
|
||||
from app.services import neo4j as neo4j_mod
|
||||
from app.services.neo4j import Neo4jUnavailable
|
||||
|
||||
|
||||
class FakeService:
|
||||
"""方法逐项可配的假服务。"""
|
||||
|
||||
def __init__(self, **overrides):
|
||||
self.calls: dict[str, list] = {}
|
||||
self.overrides = overrides
|
||||
|
||||
async def kg_status(self):
|
||||
return self.overrides.get("kg_status", {"ok": True, "active": "x"})
|
||||
|
||||
async def kg_overview(self):
|
||||
return self.overrides.get("kg_overview", {"entities_by_type": []})
|
||||
|
||||
async def search_entities(self, q, limit=20):
|
||||
self.calls.setdefault("search_entities", []).append((q, limit))
|
||||
return self.overrides.get(
|
||||
"search_entities",
|
||||
[{"id": "cent_ospl14", "name": "OsSPL14", "type": "gene", "support_count": 18}],
|
||||
)
|
||||
|
||||
async def entity(self, entity_id):
|
||||
self.calls.setdefault("entity", []).append(entity_id)
|
||||
return self.overrides.get(
|
||||
"entity",
|
||||
{"id": "cent_ospl14", "name": "OsSPL14", "type": "gene", "properties": {}},
|
||||
)
|
||||
|
||||
async def neighborhood(self, entity_id, depth=1, limit=200):
|
||||
self.calls.setdefault("neighborhood", []).append((entity_id, depth, limit))
|
||||
return self.overrides.get(
|
||||
"neighborhood",
|
||||
{
|
||||
"center": "cent_ospl14",
|
||||
"center_name": "OsSPL14",
|
||||
"depth": depth,
|
||||
"nodes": [{"id": "cent_ospl14", "label": "OsSPL14", "type": "gene"}],
|
||||
"edges": [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_service(monkeypatch):
|
||||
fake = FakeService()
|
||||
monkeypatch.setattr(neo4j_mod, "service", fake)
|
||||
return fake
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
# 用真实 FastAPI() 包装:自动获得 ExceptionMiddleware + AsyncExitStackMiddleware,
|
||||
# 与生产挂载形态一致(生产 main.py 把 kg.router include 进 FastAPI 实例)。
|
||||
app = FastAPI()
|
||||
app.include_router(kg_router.router)
|
||||
return httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app, raise_app_exceptions=False),
|
||||
base_url="http://test",
|
||||
)
|
||||
|
||||
async def test_status_ok(fake_service, client):
|
||||
fake_service.overrides["kg_status"] = {
|
||||
"ok": True,
|
||||
"active": "neo4j-plant-srt-direct-v1-full",
|
||||
"schema": "canonical",
|
||||
"labels": ["SRT2CanonicalEntity", "SRT2CanonicalAssertion", "SRT2Paper"],
|
||||
"relationship_types": ["SRT2_CANONICAL_ASSERTS", "SRT2_SUPPORTED_BY"],
|
||||
"entity_count": 3820996,
|
||||
"assertion_count": 8246875,
|
||||
"name_index": "srt2_canonical_entity_name_norm",
|
||||
"fulltext_index": None,
|
||||
}
|
||||
resp = await client.get("/api/kg/status")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["entity_count"] == 3820996
|
||||
|
||||
async def test_status_down_returns_503_envelope(fake_service, client):
|
||||
async def boom():
|
||||
raise Neo4jUnavailable("all down")
|
||||
|
||||
fake_service.kg_status = boom
|
||||
resp = await client.get("/api/kg/status")
|
||||
assert resp.status_code == 503
|
||||
body = resp.json()
|
||||
assert body["ok"] is False
|
||||
assert body["error"]["code"] == "UPSTREAM_DOWN"
|
||||
assert body["error"]["degraded"] is True
|
||||
|
||||
async def test_overview_ok(fake_service, client):
|
||||
fake_service.overrides["kg_overview"] = {
|
||||
"entities_by_type": [{"type": "gene", "count": 43210}],
|
||||
"relations_by_category": [{"type": "characterized_in", "count": 12033}],
|
||||
}
|
||||
resp = await client.get("/api/kg/overview")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["entities_by_type"][0]["type"] == "gene"
|
||||
assert body["relations_by_category"][0]["count"] == 12033
|
||||
|
||||
async def test_overview_down_returns_503(fake_service, client):
|
||||
async def boom():
|
||||
raise Neo4jUnavailable("down")
|
||||
|
||||
fake_service.kg_overview = boom
|
||||
resp = await client.get("/api/kg/overview")
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["error"]["code"] == "UPSTREAM_DOWN"
|
||||
|
||||
async def test_entities_ok_and_params_passed(fake_service, client):
|
||||
resp = await client.get("/api/kg/entities", params={"q": "OsSPL14", "limit": 5})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["query"] == "OsSPL14"
|
||||
assert body["results"][0]["id"] == "cent_ospl14"
|
||||
assert fake_service.calls["search_entities"] == [("OsSPL14", 5)]
|
||||
|
||||
async def test_entities_limit_over_20_rejected(client):
|
||||
resp = await client.get("/api/kg/entities", params={"q": "OsSPL14", "limit": 21})
|
||||
assert resp.status_code == 422 # FastAPI 参数校验(VALIDATION 语义由 A1 统一处理)
|
||||
|
||||
async def test_entities_down_returns_503(fake_service, client):
|
||||
async def boom(q, limit=20):
|
||||
raise Neo4jUnavailable("down")
|
||||
|
||||
fake_service.search_entities = boom
|
||||
resp = await client.get("/api/kg/entities", params={"q": "x"})
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["error"]["code"] == "UPSTREAM_DOWN"
|
||||
|
||||
async def test_entity_ok(fake_service, client):
|
||||
resp = await client.get("/api/kg/entities/cent_ospl14")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["id"] == "cent_ospl14"
|
||||
assert body["name"] == "OsSPL14"
|
||||
assert fake_service.calls["entity"] == ["cent_ospl14"]
|
||||
|
||||
async def test_entity_miss_returns_404(fake_service, client):
|
||||
fake_service.overrides["entity"] = None
|
||||
resp = await client.get("/api/kg/entities/NoSuch")
|
||||
assert resp.status_code == 404
|
||||
body = resp.json()
|
||||
assert body["ok"] is False
|
||||
assert body["error"]["code"] == "NOT_FOUND"
|
||||
assert body["error"]["degraded"] is False
|
||||
|
||||
async def test_entity_down_returns_503(fake_service, client):
|
||||
async def boom(entity_id):
|
||||
raise Neo4jUnavailable("down")
|
||||
|
||||
fake_service.entity = boom
|
||||
resp = await client.get("/api/kg/entities/cent_ospl14")
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["error"]["code"] == "UPSTREAM_DOWN"
|
||||
|
||||
async def test_neighborhood_ok_and_params(fake_service, client):
|
||||
resp = await client.get(
|
||||
"/api/kg/entities/cent_ospl14/neighborhood", params={"depth": 2, "limit": 100}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["center"] == "cent_ospl14"
|
||||
assert body["center_name"] == "OsSPL14"
|
||||
assert body["depth"] == 2
|
||||
assert fake_service.calls["neighborhood"] == [("cent_ospl14", 2, 100)]
|
||||
|
||||
async def test_neighborhood_defaults_depth1_limit50(fake_service, client):
|
||||
resp = await client.get("/api/kg/entities/cent_ospl14/neighborhood")
|
||||
assert resp.status_code == 200
|
||||
assert fake_service.calls["neighborhood"] == [("cent_ospl14", 1, 50)]
|
||||
|
||||
async def test_neighborhood_depth_out_of_range_rejected(client):
|
||||
resp = await client.get("/api/kg/entities/cent_ospl14/neighborhood", params={"depth": 3})
|
||||
assert resp.status_code == 422
|
||||
|
||||
async def test_neighborhood_miss_returns_404(fake_service, client):
|
||||
fake_service.overrides["neighborhood"] = {
|
||||
"center": None, "center_name": None, "depth": 1, "nodes": [], "edges": []
|
||||
}
|
||||
resp = await client.get("/api/kg/entities/NoSuch/neighborhood")
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
async def test_neighborhood_down_returns_503(fake_service, client):
|
||||
async def boom(entity_id, depth=1, limit=200):
|
||||
raise Neo4jUnavailable("down")
|
||||
|
||||
fake_service.neighborhood = boom
|
||||
resp = await client.get("/api/kg/entities/cent_ospl14/neighborhood")
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["error"]["code"] == "UPSTREAM_DOWN"
|
||||
@@ -0,0 +1,260 @@
|
||||
"""test_qa.py —— /api/qa/* 路由单元测试(A2 归属)。
|
||||
|
||||
通过 httpx.MockTransport 注入假上游(把 sse_relay.get_qa_client 替换为指向
|
||||
MockTransport 的 client),不依赖真实 7920。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.services import sse_relay
|
||||
|
||||
|
||||
def _transport_handler(handler) -> httpx.AsyncClient:
|
||||
"""以 MockTransport 构造一个 client。"""
|
||||
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bff_client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
UPSTREAM_INFO = {
|
||||
"name": "QA V1.1.73 Assertion-Anchored Full Graph",
|
||||
"version": "1.1.73-assertion-anchored",
|
||||
"pipeline": ["question", "provisional answer"],
|
||||
"config": {"max_evidence": 40, "bge_rerank": True},
|
||||
}
|
||||
|
||||
|
||||
def _json_response(payload, status=200) -> httpx.Response:
|
||||
return httpx.Response(status, json=payload)
|
||||
|
||||
|
||||
def test_info_passthrough_trimmed(bff_client, monkeypatch):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "GET"
|
||||
assert request.url.path.endswith("/api/v1/qa-v1-1-73/info")
|
||||
return _json_response(UPSTREAM_INFO)
|
||||
|
||||
client = _transport_handler(handler)
|
||||
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
|
||||
|
||||
resp = bff_client.get("/api/qa/info")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data == UPSTREAM_INFO
|
||||
assert set(data.keys()) == {"name", "version", "pipeline", "config"}
|
||||
|
||||
|
||||
def test_info_upstream_down_503(bff_client, monkeypatch):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("down", request=request)
|
||||
|
||||
client = _transport_handler(handler)
|
||||
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
|
||||
|
||||
resp = bff_client.get("/api/qa/info")
|
||||
assert resp.status_code == 503
|
||||
data = resp.json()
|
||||
assert data["ok"] is False
|
||||
assert data["error"]["code"] == "UPSTREAM_DOWN"
|
||||
assert data["error"]["degraded"] is True
|
||||
|
||||
|
||||
def test_info_wrapped_info_field_tolerated(bff_client, monkeypatch):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return _json_response({"info": UPSTREAM_INFO})
|
||||
|
||||
client = _transport_handler(handler)
|
||||
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
|
||||
|
||||
resp = bff_client.get("/api/qa/info")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == UPSTREAM_INFO["name"]
|
||||
|
||||
|
||||
def test_chat_ok_normalized(bff_client, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(request)
|
||||
assert request.url.path.endswith("/api/v1/qa-v1-1-73/chat")
|
||||
return _json_response({"answer": "some answer", "sources": []})
|
||||
|
||||
client = _transport_handler(handler)
|
||||
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
|
||||
|
||||
body = {"question": "水稻SPL?", "use_llm": True, "chain_top_k": 3}
|
||||
resp = bff_client.post("/api/qa/chat", json=body)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["mode"] == "chat"
|
||||
assert data["answer"] == "some answer"
|
||||
# 请求体透传,不增删字段
|
||||
sent = json.loads(calls[0].content)
|
||||
assert sent == body
|
||||
|
||||
|
||||
def test_chat_invalid_citation_retries_once(bff_client, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(request)
|
||||
if len(calls) == 1:
|
||||
return _json_response({"detail": {"error_code": "invalid_llm_citation"}}, status=402)
|
||||
return _json_response({"answer": "retry ok", "sources": []})
|
||||
|
||||
client = _transport_handler(handler)
|
||||
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
|
||||
|
||||
resp = bff_client.post("/api/qa/chat", json={"question": "q"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["answer"] == "retry ok"
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_chat_invalid_citation_retry_still_fails_502(bff_client, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(request)
|
||||
return _json_response({"detail": {"error_code": "invalid_llm_citation"}}, status=402)
|
||||
|
||||
client = _transport_handler(handler)
|
||||
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
|
||||
|
||||
resp = bff_client.post("/api/qa/chat", json={"question": "q"})
|
||||
assert resp.status_code == 502
|
||||
data = resp.json()
|
||||
assert data["error"]["code"] == "INVALID_CITATION"
|
||||
assert data["error"]["message"] == "引用校验失败,请重试"
|
||||
assert len(calls) == 2 # 重试仅一次
|
||||
|
||||
|
||||
def test_chat_upstream_500_no_retry(bff_client, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(request)
|
||||
return _json_response({"detail": "boom"}, status=500)
|
||||
|
||||
client = _transport_handler(handler)
|
||||
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
|
||||
|
||||
resp = bff_client.post("/api/qa/chat", json={"question": "q"})
|
||||
assert resp.status_code == 502
|
||||
assert resp.json()["error"]["code"] == "UPSTREAM_ERROR"
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_search_forces_use_llm_false_and_mode_search(bff_client, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(request)
|
||||
assert request.url.path.endswith("/api/v1/qa-v1-1-73/search")
|
||||
return _json_response({"answer": "SHOULD BE WIPED", "sources": [{"title": "T"}]})
|
||||
|
||||
client = _transport_handler(handler)
|
||||
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
|
||||
|
||||
resp = bff_client.post("/api/qa/search", json={"question": "q", "use_llm": True})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["mode"] == "search"
|
||||
assert data["answer"] == ""
|
||||
sent = json.loads(calls[0].content)
|
||||
assert sent["use_llm"] is False # 强制
|
||||
assert sent["question"] == "q" # 其余透传
|
||||
|
||||
|
||||
def test_search_no_retry_on_invalid_citation(bff_client, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(request)
|
||||
return _json_response({"detail": {"error_code": "invalid_llm_citation"}}, status=402)
|
||||
|
||||
client = _transport_handler(handler)
|
||||
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
|
||||
|
||||
resp = bff_client.post("/api/qa/search", json={"question": "q"})
|
||||
assert resp.status_code == 502
|
||||
assert len(calls) == 1 # 不重试
|
||||
|
||||
|
||||
def test_chat_upstream_down_503(bff_client, monkeypatch):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("down", request=request)
|
||||
|
||||
client = _transport_handler(handler)
|
||||
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
|
||||
|
||||
resp = bff_client.post("/api/qa/chat", json={"question": "q"})
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["error"]["code"] == "UPSTREAM_DOWN"
|
||||
|
||||
|
||||
def test_chat_upstream_read_timeout_504(bff_client, monkeypatch):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ReadTimeout("slow", request=request)
|
||||
|
||||
client = _transport_handler(handler)
|
||||
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
|
||||
|
||||
resp = bff_client.post("/api/qa/chat", json={"question": "q"})
|
||||
assert resp.status_code == 504
|
||||
assert resp.json()["error"]["code"] == "TIMEOUT"
|
||||
|
||||
|
||||
def test_chat_stream_headers_and_relay(bff_client, monkeypatch):
|
||||
"""SSE 端到端:MockTransport 吐 SSE 字节,断言契约帧与响应头。"""
|
||||
upstream_bytes = (
|
||||
b'data: {"stage":"entities_identified","details":{"entities":["OsSPL14"]}}\n\n'
|
||||
b'event: complete\ndata: {"answer":"A","sources":[]}\n\n'
|
||||
)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path.endswith("/api/v1/qa-v1-1-73/chat/stream")
|
||||
return httpx.Response(200, stream=httpx.ByteStream(upstream_bytes))
|
||||
|
||||
client = _transport_handler(handler)
|
||||
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
|
||||
|
||||
with bff_client.stream("POST", "/api/qa/chat/stream", json={"question": "q"}) as resp:
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("text/event-stream")
|
||||
assert resp.headers["cache-control"] == "no-cache"
|
||||
assert resp.headers["x-accel-buffering"] == "no"
|
||||
body = "".join(resp.iter_text())
|
||||
|
||||
assert "event: progress" in body
|
||||
assert '"phase": "planning"' in body
|
||||
assert "event: complete" in body
|
||||
assert "event: error" not in body
|
||||
|
||||
|
||||
def test_chat_stream_invalid_citation_retry_then_error(bff_client, monkeypatch):
|
||||
"""上游头阶段 402 → 重试一次 → 仍 402 → event: error。"""
|
||||
calls = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(request)
|
||||
return _json_response({"detail": {"error_code": "invalid_llm_citation"}}, status=402)
|
||||
|
||||
client = _transport_handler(handler)
|
||||
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
|
||||
|
||||
with bff_client.stream("POST", "/api/qa/chat/stream", json={"question": "q"}) as resp:
|
||||
body = "".join(resp.iter_text())
|
||||
|
||||
assert "event: error" in body
|
||||
assert '"code": "INVALID_CITATION"' in body
|
||||
assert len(calls) == 2
|
||||
@@ -0,0 +1,255 @@
|
||||
"""schemas.py —— Pydantic v2 模型(API.md 的契约形态)。
|
||||
|
||||
覆盖:块联合(§2)、SourceItem(§8)、GraphData / GraphNode / GraphEdge /
|
||||
GraphChain(§9)、SessionOut / MessageOut(§5)、错误信封(§1)。
|
||||
约定:模型 `extra="ignore"` + 全字段默认值,落库/返参前先经模型验证归一。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
def utcnow_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §2 块联合(判别字段 type)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TextBlock(BaseModel):
|
||||
type: Literal["text"] = "text"
|
||||
html: str
|
||||
|
||||
|
||||
class TableBlock(BaseModel):
|
||||
type: Literal["table"] = "table"
|
||||
headers: list[str] = Field(default_factory=list)
|
||||
rows: list[list[str]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CodeBlock(BaseModel):
|
||||
type: Literal["code"] = "code"
|
||||
text: str
|
||||
|
||||
|
||||
class StreamBlock(BaseModel):
|
||||
"""瞬态块:只出现在流式过程中,绝不落库。"""
|
||||
|
||||
type: Literal["stream"] = "stream"
|
||||
text: str
|
||||
|
||||
|
||||
class PipelineStep(BaseModel):
|
||||
name: str = ""
|
||||
state: Literal["waiting", "running", "done"] = "waiting"
|
||||
desc: str = ""
|
||||
phase: str = "" # planning|retrieval|evidence|generation|done(API.md §4)
|
||||
|
||||
|
||||
class PipelineBlock(BaseModel):
|
||||
type: Literal["pipeline"] = "pipeline"
|
||||
status: Literal["running", "done", "error"] = "running"
|
||||
steps: list[PipelineStep] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ToolLog(BaseModel):
|
||||
text: str = ""
|
||||
cls: str = "log-ok" # log-ok|log-run
|
||||
|
||||
|
||||
class ToolBlock(BaseModel):
|
||||
type: Literal["tool"] = "tool"
|
||||
icon: str = ""
|
||||
name: str = ""
|
||||
status: Literal["running", "done", "error"] = "running"
|
||||
dur: str = ""
|
||||
open: bool = True
|
||||
params: str = ""
|
||||
result: str = ""
|
||||
logs: list[ToolLog] = Field(default_factory=list)
|
||||
phase: str = "retrieval"
|
||||
|
||||
|
||||
class SourcesBlock(BaseModel):
|
||||
type: Literal["sources"] = "sources"
|
||||
items: list["SourceItem"] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GraphBlock(BaseModel):
|
||||
type: Literal["graph"] = "graph"
|
||||
nodes: list["GraphNode"] = Field(default_factory=list)
|
||||
edges: list["GraphEdge"] = Field(default_factory=list)
|
||||
chains: list["GraphChain"] = Field(default_factory=list)
|
||||
|
||||
|
||||
Block = Union[
|
||||
TextBlock, TableBlock, CodeBlock, StreamBlock,
|
||||
PipelineBlock, ToolBlock, SourcesBlock, GraphBlock,
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §8 SourceItem —— 所有字段键必须存在(缺失为 null)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SourceItem(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
id: str | None = None
|
||||
title: str | None = None
|
||||
paper_id: str | None = None
|
||||
chunk_id: str | None = None
|
||||
pmid: str | None = None
|
||||
pmcid: str | None = None
|
||||
doi: str | None = None
|
||||
url: str | None = None
|
||||
snippet: str | None = None
|
||||
score: float | None = None
|
||||
locator: str | None = None
|
||||
section: str | None = None
|
||||
support_count: int | None = None
|
||||
type: str | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §9 图谱结构
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class GraphNode(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
id: str
|
||||
label: str | None = None
|
||||
type: str | None = None
|
||||
|
||||
|
||||
class GraphEdge(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
source: str | None = None
|
||||
target: str | None = None
|
||||
relation: str | None = None
|
||||
label: str | None = None
|
||||
chain_id: str | None = None
|
||||
hop: int | None = None
|
||||
paper_id: str | None = None
|
||||
pmid: str | None = None
|
||||
pmcid: str | None = None
|
||||
doi: str | None = None
|
||||
|
||||
|
||||
class GraphHop(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
source: str | None = None
|
||||
target: str | None = None
|
||||
relation: str | None = None
|
||||
source_type: str | None = None
|
||||
target_type: str | None = None
|
||||
paper_id: str | None = None
|
||||
pmid: str | None = None
|
||||
pmcid: str | None = None
|
||||
doi: str | None = None
|
||||
|
||||
|
||||
class GraphChain(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
chain_id: str | None = None
|
||||
hops: list[GraphHop] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GraphData(BaseModel):
|
||||
"""消息内 graph 块与 /api/kg/neighborhood 共用的 {nodes,edges,chains} 形态。"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
nodes: list[GraphNode] = Field(default_factory=list)
|
||||
edges: list[GraphEdge] = Field(default_factory=list)
|
||||
chains: list[GraphChain] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §5 会话与消息
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SessionMeta(BaseModel):
|
||||
title_manual: bool = False
|
||||
|
||||
|
||||
class SessionCreate(BaseModel):
|
||||
title: str = "新会话"
|
||||
|
||||
def normalized_title(self) -> str:
|
||||
return self.title.strip() or "新会话"
|
||||
|
||||
|
||||
class SessionPatch(BaseModel):
|
||||
title: str | None = None
|
||||
status: Literal["running", "done", "error", "idle"] | None = None
|
||||
|
||||
|
||||
class SessionOut(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
id: str
|
||||
title: str
|
||||
status: str = "idle"
|
||||
created_at: str = Field(default_factory=utcnow_iso)
|
||||
updated_at: str = Field(default_factory=utcnow_iso)
|
||||
message_count: int = 0
|
||||
meta: SessionMeta = Field(default_factory=SessionMeta)
|
||||
|
||||
|
||||
class SessionDetail(SessionOut):
|
||||
messages: list["MessageOut"] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MessageCreate(BaseModel):
|
||||
role: Literal["user", "assistant"]
|
||||
text: str | None = None
|
||||
kind: str = "qa"
|
||||
status: str = "done"
|
||||
blocks: list[Block] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MessagePatch(BaseModel):
|
||||
"""流式检查点局部更新(PATCH /messages/{mid})。"""
|
||||
|
||||
status: str | None = None
|
||||
blocks: list[Block] | None = None
|
||||
raw: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class MessageOut(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
id: str
|
||||
role: str
|
||||
kind: str = "qa"
|
||||
status: str = "done"
|
||||
blocks: list[Block] = Field(default_factory=list)
|
||||
raw: dict[str, Any] | None = None
|
||||
text: str | None = None
|
||||
created_at: str = Field(default_factory=utcnow_iso)
|
||||
updated_at: str = Field(default_factory=utcnow_iso)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §1 错误信封
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ErrorBody(BaseModel):
|
||||
code: str
|
||||
message: str
|
||||
degraded: bool = False
|
||||
|
||||
|
||||
class ErrorEnvelope(BaseModel):
|
||||
ok: Literal[False] = False
|
||||
error: ErrorBody
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1,75 @@
|
||||
"""app/test_db.py —— SQLite 层单测(aiosqlite + WAL,幂等迁移,JSON 编解码)。
|
||||
|
||||
覆盖:connect 建表/索引、重复 connect 幂等、外键级联删除、JSON 编解码容错。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db import Database, dumps_json, loads_json
|
||||
|
||||
|
||||
async def test_connect_creates_schema_and_wal(tmp_path):
|
||||
db = Database(tmp_path / "t1" / "plantai.db")
|
||||
await db.connect()
|
||||
try:
|
||||
# WAL 模式
|
||||
row = await db.fetchone("PRAGMA journal_mode")
|
||||
assert row[0].lower() == "wal"
|
||||
# 表存在
|
||||
tables = await db.fetchall("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
names = {t["name"] for t in tables}
|
||||
assert {"sessions", "messages"} <= names
|
||||
# 索引存在
|
||||
indexes = await db.fetchall(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'"
|
||||
)
|
||||
inames = {i["name"] for i in indexes}
|
||||
assert "idx_messages_session" in inames
|
||||
assert "idx_sessions_updated" in inames
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def test_connect_idempotent(tmp_path):
|
||||
db = Database(tmp_path / "t2" / "plantai.db")
|
||||
await db.connect()
|
||||
await db.close()
|
||||
await db.connect() # 二次迁移不报错
|
||||
await db.close()
|
||||
|
||||
|
||||
async def test_cascade_delete_messages(tmp_path):
|
||||
db = Database(tmp_path / "t3" / "plantai.db")
|
||||
await db.connect()
|
||||
try:
|
||||
await db.execute(
|
||||
"INSERT INTO sessions (id, title, status, meta, created_at, updated_at) "
|
||||
"VALUES ('s1', 't', 'idle', '{}', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')"
|
||||
)
|
||||
await db.execute(
|
||||
"INSERT INTO messages (id, session_id, role, kind, status, blocks, raw, text, "
|
||||
"created_at, updated_at) VALUES "
|
||||
"('m1', 's1', 'user', 'qa', 'done', '[]', NULL, 'hi', "
|
||||
"'2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')"
|
||||
)
|
||||
row = await db.fetchone("SELECT COUNT(*) AS n FROM messages WHERE session_id='s1'")
|
||||
assert row["n"] == 1
|
||||
await db.execute("DELETE FROM sessions WHERE id='s1'")
|
||||
row = await db.fetchone("SELECT COUNT(*) AS n FROM messages WHERE session_id='s1'")
|
||||
assert row["n"] == 0 # ON DELETE CASCADE
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
def test_dumps_loads_json_roundtrip():
|
||||
payload = {"a": 1, "b": [1, 2], "c": {"d": "中文"}}
|
||||
assert loads_json(dumps_json(payload)) == payload
|
||||
assert loads_json(None, default=[]) == []
|
||||
assert loads_json("not-json", default={}) == {}
|
||||
|
||||
|
||||
def test_dumps_json_fallback_on_unserializable():
|
||||
import threading
|
||||
|
||||
assert dumps_json({"lock": threading.Lock()}, default=None) == "null"
|
||||
@@ -0,0 +1,243 @@
|
||||
"""app/test_sessions_api.py —— 会话/消息路由集成单测(API.md §5 / §1 信封)。
|
||||
|
||||
TestClient + 临时 SQLite(dependency_overrides 指向临时 Database),
|
||||
不触发 app lifespan(不跑 init_db/上游探测)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.db import Database, dumps_json, get_db
|
||||
from app.main import app
|
||||
from app.upstream import envelope
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
async def db(tmp_path):
|
||||
database = Database(tmp_path / "api" / "plantai.db")
|
||||
await database.connect()
|
||||
yield database
|
||||
await database.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(db):
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
try:
|
||||
yield TestClient(app)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 错误信封构造器(§1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code,expected_status",
|
||||
[
|
||||
("UPSTREAM_DOWN", 503),
|
||||
("UPSTREAM_ERROR", 502),
|
||||
("TIMEOUT", 504),
|
||||
("INVALID_CITATION", 502),
|
||||
("NOT_FOUND", 404),
|
||||
("VALIDATION", 422),
|
||||
("INTERNAL", 500),
|
||||
],
|
||||
)
|
||||
def test_envelope_defaults(code, expected_status):
|
||||
body, status = envelope(code)
|
||||
assert status == expected_status
|
||||
assert body["ok"] is False
|
||||
assert body["error"]["code"] == code
|
||||
assert isinstance(body["error"]["message"], str) and body["error"]["message"]
|
||||
assert "degraded" in body["error"]
|
||||
|
||||
|
||||
def test_envelope_custom():
|
||||
body, status = envelope("UPSTREAM_DOWN", message="自定义", degraded=True, status=503)
|
||||
assert body["error"]["message"] == "自定义"
|
||||
assert body["error"]["degraded"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 会话 CRUD 全链路
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_session_crud_full_flow(client):
|
||||
# POST(默认标题 + 自定义标题)
|
||||
r = client.post("/api/sessions", json={})
|
||||
assert r.status_code == 201, r.text
|
||||
sess = r.json()
|
||||
assert sess["title"] == "新会话"
|
||||
assert sess["status"] == "idle"
|
||||
assert sess["message_count"] == 0
|
||||
assert sess["meta"] == {"title_manual": False}
|
||||
|
||||
r2 = client.post("/api/sessions", json={"title": " 水稻SPL "})
|
||||
sess2 = r2.json()
|
||||
assert sess2["title"] == "水稻SPL"
|
||||
|
||||
# GET 列表(updated_at 倒序,最新在前)
|
||||
r = client.get("/api/sessions")
|
||||
assert r.status_code == 200
|
||||
items = r.json()
|
||||
assert [s["id"] for s in items][:2] == [sess2["id"], sess["id"]]
|
||||
|
||||
# POST user 消息
|
||||
r = client.post(f"/api/sessions/{sess['id']}/messages", json={"role": "user", "text": "问题原文"})
|
||||
assert r.status_code == 201, r.text
|
||||
msg = r.json()
|
||||
assert msg["role"] == "user" and msg["status"] == "done"
|
||||
assert msg["text"] == "问题原文"
|
||||
assert msg["blocks"] == [] and msg["raw"] is None
|
||||
|
||||
# POST assistant 流式骨架
|
||||
r = client.post(
|
||||
f"/api/sessions/{sess['id']}/messages",
|
||||
json={
|
||||
"role": "assistant", "kind": "qa", "status": "streaming",
|
||||
"blocks": [{"type": "pipeline", "status": "running", "steps": []}],
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
skeleton = r.json()
|
||||
assert skeleton["status"] == "streaming"
|
||||
assert skeleton["blocks"][0]["type"] == "pipeline"
|
||||
assert "status" in skeleton["blocks"][0]
|
||||
|
||||
# 消息活动后会话状态 running、message_count 更新
|
||||
r = client.get(f"/api/sessions/{sess['id']}")
|
||||
detail = r.json()
|
||||
assert detail["status"] == "running"
|
||||
assert detail["message_count"] == 2
|
||||
assert [m["role"] for m in detail["messages"]] == ["user", "assistant"]
|
||||
|
||||
# PATCH 消息(流式检查点:status/blocks/raw)
|
||||
r = client.patch(
|
||||
f"/api/sessions/{sess['id']}/messages/{skeleton['id']}",
|
||||
json={
|
||||
"status": "done",
|
||||
"blocks": [{"type": "text", "html": "<p>最终答案</p>"}],
|
||||
"raw": {"answer": "最终答案"},
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
updated = r.json()
|
||||
assert updated["status"] == "done"
|
||||
assert updated["blocks"] == [{"type": "text", "html": "<p>最终答案</p>"}]
|
||||
assert updated["raw"] == {"answer": "最终答案"}
|
||||
|
||||
# PATCH 会话标题 → title_manual=true
|
||||
r = client.patch(f"/api/sessions/{sess['id']}", json={"title": "手工标题"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["meta"] == {"title_manual": True}
|
||||
|
||||
# PATCH 会话状态
|
||||
r = client.patch(f"/api/sessions/{sess['id']}", json={"status": "done"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "done"
|
||||
|
||||
# DELETE 204 + 级联
|
||||
r = client.delete(f"/api/sessions/{sess['id']}")
|
||||
assert r.status_code == 204
|
||||
r = client.get(f"/api/sessions/{sess['id']}")
|
||||
assert r.status_code == 404
|
||||
body = r.json()
|
||||
assert body["ok"] is False
|
||||
assert body["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
|
||||
def test_not_found_envelopes(client):
|
||||
cases = [
|
||||
("get", "/api/sessions/nope", None),
|
||||
("patch", "/api/sessions/nope", {"title": "x"}),
|
||||
("delete", "/api/sessions/nope", None),
|
||||
("post", "/api/sessions/nope/messages", {"role": "user", "text": "hi"}),
|
||||
("get", "/api/sessions/nope/sources", None),
|
||||
]
|
||||
for method, url, body in cases:
|
||||
kwargs = {"json": body} if body is not None else {}
|
||||
r = getattr(client, method)(url, **kwargs)
|
||||
assert r.status_code == 404, (method, url)
|
||||
payload = r.json()
|
||||
assert payload["ok"] is False
|
||||
assert payload["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
r = client.patch("/api/sessions/nope/messages/nope-msg", json={"status": "done"})
|
||||
assert r.status_code == 404
|
||||
assert r.json()["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
|
||||
def test_message_404_when_session_exists_but_message_missing(client):
|
||||
r = client.post("/api/sessions", json={"title": "t"})
|
||||
sid = r.json()["id"]
|
||||
r = client.patch(f"/api/sessions/{sid}/messages/ghost", json={"status": "done"})
|
||||
assert r.status_code == 404
|
||||
assert r.json()["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
|
||||
def test_empty_title_falls_back_to_default(client):
|
||||
r = client.post("/api/sessions", json={"title": " "})
|
||||
assert r.json()["title"] == "新会话"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 来源聚合(GET /api/sessions/{id}/sources)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _source_item(i: str, paper: str | None, title: str) -> dict:
|
||||
return {
|
||||
"id": i, "title": title, "paper_id": paper, "chunk_id": None,
|
||||
"pmid": None, "pmcid": None, "doi": None, "url": None,
|
||||
"snippet": "…", "score": 0.9, "locator": None, "section": None,
|
||||
"support_count": None, "type": "assertion", "metadata": {},
|
||||
}
|
||||
|
||||
|
||||
async def test_sources_aggregation(client, db):
|
||||
r = client.post("/api/sessions", json={"title": "聚合"})
|
||||
sid = r.json()["id"]
|
||||
|
||||
# 两个助手消息各带一个 sources 块
|
||||
blocks1 = [
|
||||
{
|
||||
"type": "sources",
|
||||
"items": [
|
||||
_source_item("a1", "PAPER_A", "Paper A"),
|
||||
_source_item("b1", "PAPER_B", "Paper B"),
|
||||
_source_item("x1", None, "No paper id"),
|
||||
],
|
||||
}
|
||||
]
|
||||
blocks2 = [
|
||||
{
|
||||
"type": "sources",
|
||||
"items": [
|
||||
_source_item("a2", "PAPER_A", "Paper A"),
|
||||
_source_item("x2", None, "No paper id"),
|
||||
],
|
||||
}
|
||||
]
|
||||
await db.execute(
|
||||
"INSERT INTO messages (id, session_id, role, kind, status, blocks, raw, text, "
|
||||
"created_at, updated_at) VALUES "
|
||||
"('m1', ?, 'assistant', 'qa', 'done', ?, NULL, NULL, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'), "
|
||||
"('m2', ?, 'assistant', 'qa', 'done', ?, NULL, NULL, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')",
|
||||
(sid, dumps_json(blocks1), sid, dumps_json(blocks2)),
|
||||
)
|
||||
|
||||
r = client.get(f"/api/sessions/{sid}/sources")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["session_id"] == sid
|
||||
# PAPER_A 出现 2 次(2 个消息各 1 条),PAPER_B 1 次;无 paper_id 的按 id 各 1 次
|
||||
assert body["total"] == 5
|
||||
by_key = {(s["paper_id"] or s["id"]): s for s in body["sources"]}
|
||||
assert by_key["PAPER_A"]["count"] == 2
|
||||
assert by_key["PAPER_A"]["title"] == "Paper A"
|
||||
assert by_key["PAPER_B"]["count"] == 1
|
||||
assert by_key["x1"]["count"] == 1
|
||||
assert by_key["x2"]["count"] == 1
|
||||
assert len(body["sources"]) == 4
|
||||
@@ -0,0 +1,195 @@
|
||||
"""upstream.py —— 上游健康探测(httpx 单例 + 5s TTL 缓存)与统一错误信封。
|
||||
|
||||
- `probe_all()`:并发探测 qa(/health 或 /info)、rag(/health)、ragAlt(/health)、
|
||||
Neo4j(延迟导入 `app.services.neo4j.probe_neo4j()`,A3 提供;若模块缺失则置
|
||||
`{ok:false}`)。探测超时 1s(settings.timeout_health),结果缓存 5s。
|
||||
- `envelope(code, message, degraded, status)`:API.md §1 错误信封构造器,供所有 router 用。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_client: httpx.AsyncClient | None = None
|
||||
_httpx_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def get_client() -> httpx.AsyncClient:
|
||||
"""httpx.AsyncClient 单例(供 qa/kg/extraction 各代理层共用)。"""
|
||||
global _client
|
||||
if _client is None or _client.is_closed:
|
||||
async with _httpx_lock:
|
||||
if _client is None or _client.is_closed:
|
||||
settings = get_settings()
|
||||
_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(
|
||||
connect=settings.timeout_health,
|
||||
read=settings.timeout_qa_chat,
|
||||
write=10.0,
|
||||
pool=5.0,
|
||||
),
|
||||
follow_redirects=True,
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
async def close_client() -> None:
|
||||
"""app shutdown 钩子。"""
|
||||
global _client
|
||||
if _client is not None and not _client.is_closed:
|
||||
await _client.aclose()
|
||||
_client = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 错误信封(API.md §1)——供所有 router 用
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ERROR_MESSAGES: dict[str, str] = {
|
||||
"UPSTREAM_DOWN": "知识问答服务当前不可用,请稍后重试",
|
||||
"UPSTREAM_ERROR": "上游服务返回错误",
|
||||
"TIMEOUT": "检索超时",
|
||||
"INVALID_CITATION": "引用校验失败",
|
||||
"NOT_FOUND": "资源不存在",
|
||||
"VALIDATION": "请求参数不合法",
|
||||
"INTERNAL": "服务内部错误",
|
||||
}
|
||||
|
||||
|
||||
def envelope(
|
||||
code: str,
|
||||
message: str | None = None,
|
||||
degraded: bool = False,
|
||||
status: int | None = None,
|
||||
) -> tuple[dict[str, Any], int]:
|
||||
"""构造 `{"ok": false, "error": {code, message, degraded}}` 信封 + HTTP 状态。
|
||||
|
||||
status 缺省时按 code 推导(API.md §1 表):
|
||||
UPSTREAM_DOWN=503, UPSTREAM_ERROR=502, TIMEOUT=504, INVALID_CITATION=502,
|
||||
NOT_FOUND=404, VALIDATION=422, INTERNAL=500。
|
||||
"""
|
||||
if status is None:
|
||||
status = {
|
||||
"UPSTREAM_DOWN": 503,
|
||||
"UPSTREAM_ERROR": 502,
|
||||
"TIMEOUT": 504,
|
||||
"INVALID_CITATION": 502,
|
||||
"NOT_FOUND": 404,
|
||||
"VALIDATION": 422,
|
||||
"INTERNAL": 500,
|
||||
}.get(code, 500)
|
||||
if message is None:
|
||||
message = _ERROR_MESSAGES.get(code, "未知错误")
|
||||
return {
|
||||
"ok": False,
|
||||
"error": {"code": code, "message": message, "degraded": degraded},
|
||||
}, status
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 健康探测
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_probe_lock = asyncio.Lock()
|
||||
_probe_at: float = 0.0
|
||||
_probe_result: dict[str, Any] | None = None
|
||||
|
||||
|
||||
async def _get_json(url: str) -> tuple[bool, Any]:
|
||||
"""1s 超时 GET + JSON 解析;任何失败返回 (False, None),不抛错。"""
|
||||
try:
|
||||
client = await get_client()
|
||||
resp = await client.get(url, timeout=httpx.Timeout(get_settings().timeout_health))
|
||||
if resp.status_code >= 400:
|
||||
return False, None
|
||||
return True, resp.json()
|
||||
except Exception:
|
||||
return False, None
|
||||
|
||||
|
||||
async def _probe_qa() -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
base = settings.qa_base_url.rstrip("/")
|
||||
# 按 DISCOVERY.md:health 为可靠探测端点;其不可用时回落 info
|
||||
ok, data = await _get_json(f"{base}{settings.qa_api_path}/health")
|
||||
if not ok:
|
||||
ok, data = await _get_json(f"{base}{settings.qa_api_path}/info")
|
||||
if not ok or not isinstance(data, dict):
|
||||
return {"ok": False}
|
||||
version = data.get("version") or data.get("app_version")
|
||||
return {"ok": True, "version": version}
|
||||
|
||||
|
||||
async def _probe_rag(base_url: str) -> dict[str, Any]:
|
||||
ok, data = await _get_json(f"{base_url.rstrip('/')}/health")
|
||||
if not ok:
|
||||
return {"ok": False}
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
async def _probe_neo4j() -> dict[str, Any]:
|
||||
"""Neo4j 状态由 A3 提供(app.services.neo4j.probe_neo4j)。
|
||||
延迟导入:A3 未实现时 catch ImportError,置 {ok:false}。
|
||||
"""
|
||||
try:
|
||||
from .services.neo4j import probe_neo4j
|
||||
except ImportError:
|
||||
return {"ok": False}
|
||||
try:
|
||||
result = await probe_neo4j()
|
||||
except Exception:
|
||||
return {"ok": False}
|
||||
if not isinstance(result, dict):
|
||||
return {"ok": False}
|
||||
out: dict[str, Any] = {"ok": bool(result.get("ok"))}
|
||||
if result.get("active"):
|
||||
out["active"] = result["active"]
|
||||
if result.get("detail"):
|
||||
out["detail"] = result["detail"]
|
||||
return out
|
||||
|
||||
|
||||
async def _probe_all_uncached() -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
qa_t, rag_t, alt_t, neo_t = await asyncio.gather(
|
||||
_probe_qa(),
|
||||
_probe_rag(settings.rag_base_url),
|
||||
_probe_rag(settings.rag_alt_base_url),
|
||||
_probe_neo4j(),
|
||||
)
|
||||
return {"qa": qa_t, "rag": rag_t, "ragAlt": alt_t, "neo4j": neo_t}
|
||||
|
||||
|
||||
async def probe_all(force: bool = False) -> dict[str, Any]:
|
||||
"""并发探测全部上游;结果缓存 settings.health_cache_ttl 秒(默认 5)。"""
|
||||
global _probe_at, _probe_result
|
||||
now = time.monotonic()
|
||||
if not force and _probe_result is not None:
|
||||
if now - _probe_at < get_settings().health_cache_ttl:
|
||||
return _probe_result
|
||||
async with _probe_lock:
|
||||
now = time.monotonic()
|
||||
if not force and _probe_result is not None:
|
||||
if now - _probe_at < get_settings().health_cache_ttl:
|
||||
return _probe_result
|
||||
result = await _probe_all_uncached()
|
||||
_probe_at = time.monotonic()
|
||||
_probe_result = result
|
||||
return result
|
||||
|
||||
|
||||
async def probe_all_refresh() -> dict[str, Any]:
|
||||
"""强制刷新(无缓存)。"""
|
||||
return await probe_all(force=True)
|
||||
|
||||
|
||||
# 供 A2/A3 复用的类型别名
|
||||
UpstreamProbe = Callable[..., Awaitable[dict[str, Any]]]
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"job_id": "2943168e9c48",
|
||||
"title": "wang2019.pdf",
|
||||
"status": "completed",
|
||||
"stage": "completed",
|
||||
"progress_pct": 100,
|
||||
"message": "MinerU+LLM 方法学提取完成",
|
||||
"paper_id": "demo_paper",
|
||||
"family": "",
|
||||
"stats": {
|
||||
"stage_count": 6,
|
||||
"species_count": 1,
|
||||
"figure_table_caption_count": 9,
|
||||
"figure_table_stage_count": 12
|
||||
},
|
||||
"detail": {
|
||||
"updated_at": "2026-08-07T09:42:17",
|
||||
"job_id": "2943168e9c48",
|
||||
"status": "completed",
|
||||
"stage": "completed",
|
||||
"substage": "done",
|
||||
"message": "MinerU+LLM 方法学提取完成",
|
||||
"source_path": "/home/xzq/5090/PlantAI-rag/data/evaluation/methodology_upload_jobs/2943168e9c48/upload/wang2019.pdf",
|
||||
"content_list": "/home/xzq/5090/PlantAI-rag/data/evaluation/methodology_upload_jobs/2943168e9c48/mineru/wang2019/txt/wang2019_content_list.json",
|
||||
"output_dir": "/home/xzq/5090/PlantAI-rag/methodology_batch_demo_paper_2943168e9c48",
|
||||
"figure_table_warning": ""
|
||||
},
|
||||
"plan": {
|
||||
"plan_id": "plan_2943168e9c48",
|
||||
"paper_id": "demo_paper",
|
||||
"gene_family": "",
|
||||
"source_batch_dir": "/home/xzq/5090/PlantAI-rag/methodology_batch_demo_paper_2943168e9c48",
|
||||
"stages": [
|
||||
{
|
||||
"observation_id": "obs_1",
|
||||
"literature_stage": "gene_family_identification",
|
||||
"literature_tools": [
|
||||
{ "tool_name": "hmmsearch", "version": "3.3.2", "role": "domain search", "url": "", "parameters": "E-value 1e-5" }
|
||||
],
|
||||
"method_components": [],
|
||||
"literature_parameters": [],
|
||||
"inputs": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"jobs": [
|
||||
{
|
||||
"job_id": "2943168e9c48",
|
||||
"title": "wang2019.pdf",
|
||||
"status": "completed",
|
||||
"stage": "completed",
|
||||
"substage": "done",
|
||||
"progress_pct": 100,
|
||||
"message": "MinerU+LLM 方法学提取完成",
|
||||
"paper_id": "demo_paper",
|
||||
"family": "",
|
||||
"source_filename": "wang2019.pdf",
|
||||
"stage_count": 6,
|
||||
"species_count": 1,
|
||||
"figure_table_caption_count": 9,
|
||||
"figure_table_stage_count": 12,
|
||||
"updated_at": "2026-08-07T09:42:17",
|
||||
"config_url": "/api/v1/methodology/extract-jobs/2943168e9c48/bbav5-config",
|
||||
"methodology_plan_url": "/api/v1/methodology/extract-jobs/2943168e9c48/plan"
|
||||
},
|
||||
{
|
||||
"job_id": "a1b2c3d4e5f6",
|
||||
"title": "Pecan_SPL_2021.pdf",
|
||||
"status": "running",
|
||||
"stage": "llm",
|
||||
"substage": null,
|
||||
"progress_pct": 75,
|
||||
"message": "LLM 关系抽取中…",
|
||||
"paper_id": "pecan_spl",
|
||||
"family": "SPL",
|
||||
"source_filename": "Pecan_SPL_2021.pdf",
|
||||
"stage_count": 4,
|
||||
"species_count": 1,
|
||||
"figure_table_caption_count": 3,
|
||||
"figure_table_stage_count": 4,
|
||||
"updated_at": "2026-08-25T09:52:00",
|
||||
"config_url": null,
|
||||
"methodology_plan_url": null
|
||||
},
|
||||
{
|
||||
"job_id": "b2c3d4e5f6a7",
|
||||
"title": "Rice_NAC_2020.pdf",
|
||||
"status": "failed",
|
||||
"stage": "failed",
|
||||
"substage": null,
|
||||
"progress_pct": null,
|
||||
"message": "MinerU 解析失败:/home/xia-lab/miniconda3/envs/minerupy311/bin/mineru 不存在",
|
||||
"paper_id": "rice_nac",
|
||||
"family": "NAC",
|
||||
"source_filename": "Rice_NAC_2020.pdf",
|
||||
"stage_count": 0,
|
||||
"species_count": 0,
|
||||
"figure_table_caption_count": 0,
|
||||
"figure_table_stage_count": 0,
|
||||
"updated_at": "2026-08-24T14:38:00",
|
||||
"config_url": null,
|
||||
"methodology_plan_url": null
|
||||
},
|
||||
{
|
||||
"job_id": "c3d4e5f6a7b8",
|
||||
"title": "At_HDZIP_2018.pdf",
|
||||
"status": "queued",
|
||||
"stage": "queued",
|
||||
"substage": null,
|
||||
"progress_pct": 5,
|
||||
"message": "排队等待处理",
|
||||
"paper_id": "at_hdzip",
|
||||
"family": "HD-ZIP",
|
||||
"source_filename": "At_HDZIP_2018.pdf",
|
||||
"stage_count": 0,
|
||||
"species_count": 0,
|
||||
"figure_table_caption_count": 0,
|
||||
"figure_table_stage_count": 0,
|
||||
"updated_at": "2026-08-24T16:00:00",
|
||||
"config_url": null,
|
||||
"methodology_plan_url": null
|
||||
}
|
||||
],
|
||||
"count": 4
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"rows": []
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"rows": []
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"success": true,
|
||||
"job_id": "d4e5f6a7b8c9",
|
||||
"status_url": "/api/v1/methodology/extract-jobs/d4e5f6a7b8c9",
|
||||
"message": "已提交抽取任务"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "0.1.0",
|
||||
"upstreams": {
|
||||
"qa": { "ok": true, "version": "1.1.73-assertion-anchored" },
|
||||
"rag": { "ok": true },
|
||||
"ragAlt": { "ok": true },
|
||||
"neo4j": { "ok": true, "active": "neo4j-plant-srt-pc2025-v3-20260817-normalized" }
|
||||
},
|
||||
"db": { "ok": true, "sessions": 12 }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"query": "OsSPL",
|
||||
"results": [
|
||||
{ "id": "cent_ospl14", "name": "OsSPL14", "type": "gene", "support_count": 18 },
|
||||
{ "id": "cent_ospl7", "name": "OsSPL7", "type": "gene", "support_count": 9 },
|
||||
{ "id": "cent_ospl16", "name": "OsSPL16", "type": "gene", "support_count": 7 },
|
||||
{ "id": "cent_ospl2", "name": "OsSPL2", "type": "gene", "support_count": 5 },
|
||||
{ "id": "cent_spl_family", "name": "SPL gene family", "type": "gene_family", "support_count": 120 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"id": "cent_ospl14",
|
||||
"name": "OsSPL14",
|
||||
"type": "gene",
|
||||
"properties": {
|
||||
"canonical_id": "cent_ospl14",
|
||||
"name": "OsSPL14",
|
||||
"name_norm": "osspl14",
|
||||
"entity_type": "gene",
|
||||
"rule": "exact_name_norm_same_type",
|
||||
"support_count": 18
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"center": "cent_ospl14",
|
||||
"center_name": "OsSPL14",
|
||||
"depth": 1,
|
||||
"nodes": [
|
||||
{ "id": "cent_ospl14", "label": "OsSPL14", "type": "gene" },
|
||||
{ "id": "cent_panicle", "label": "panicle", "type": "experimental_material" },
|
||||
{ "id": "cent_branching", "label": "panicle branching", "type": "process" },
|
||||
{ "id": "cent_mir156", "label": "miR156", "type": "sequence_feature" },
|
||||
{ "id": "cent_ospl7", "label": "OsSPL7", "type": "gene" },
|
||||
{ "id": "cent_ospl16", "label": "OsSPL16", "type": "gene" }
|
||||
],
|
||||
"edges": [
|
||||
{ "source": "cent_ospl14", "target": "cent_panicle", "relation": "expressed_in", "label": "expressed_in", "assertion_id": "cass_aaaa01", "paper_count": 5, "support_count": 9, "title": "Regulation of OsSPL14 by OsmiR156 defines ideal plant architecture in rice", "doi": "10.1038/ng.591", "pmid": "20393464" },
|
||||
{ "source": "cent_ospl14", "target": "cent_branching", "relation": "involved_in", "label": "involved_in", "assertion_id": "cass_aaaa02", "paper_count": 3, "support_count": 4, "title": "Regulation of OsSPL14 by OsmiR156 defines ideal plant architecture in rice", "doi": "10.1038/ng.591", "pmid": "20393464" },
|
||||
{ "source": "cent_ospl14", "target": "cent_mir156", "relation": "regulated_by", "label": "regulated_by", "assertion_id": "cass_aaaa03", "paper_count": 2, "support_count": 2, "title": "Regulation of OsSPL14 by OsmiR156 defines ideal plant architecture in rice", "doi": "10.1038/ng.591", "pmid": "20393464" },
|
||||
{ "source": "cent_ospl7", "target": "cent_branching", "relation": "involved_in", "label": "involved_in", "assertion_id": "cass_bbbb01", "paper_count": 1, "support_count": 1, "title": "OsSPL7 regulates panicle development in rice", "doi": null, "pmid": "22429691" },
|
||||
{ "source": "cent_ospl16", "target": "cent_panicle", "relation": "expressed_in", "label": "expressed_in", "assertion_id": "cass_cccc01", "paper_count": 1, "support_count": 1, "title": "OsSPL16 controls grain size and quality via the GW8 pathway", "doi": "10.1038/ng.2327", "pmid": "22635119" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"entities_by_type": [
|
||||
{ "type": "gene", "count": 43210 },
|
||||
{ "type": "gene_family", "count": 1288 },
|
||||
{ "type": "phenotype", "count": 1560 },
|
||||
{ "type": "process", "count": 8912 },
|
||||
{ "type": "trait", "count": 2341 },
|
||||
{ "type": "experimental_material", "count": 340 },
|
||||
{ "type": "method", "count": 1204 }
|
||||
],
|
||||
"relations_by_category": [
|
||||
{ "type": "characterized_in", "count": 12033 },
|
||||
{ "type": "involved_in", "count": 9812 },
|
||||
{ "type": "regulated_by", "count": 7780 },
|
||||
{ "type": "identified_in", "count": 5620 },
|
||||
{ "type": "collinear_with", "count": 9350 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"ok": true,
|
||||
"active": "neo4j-plant-srt-direct-v1-full",
|
||||
"schema": "canonical",
|
||||
"labels": ["SRT2CanonicalEntity", "SRT2CanonicalAssertion", "SRT2Paper"],
|
||||
"relationship_types": ["SRT2_CANONICAL_ASSERTS", "SRT2_SUPPORTED_BY"],
|
||||
"entity_count": 3820996,
|
||||
"assertion_count": 8246875,
|
||||
"name_index": "srt2_canonical_entity_name_norm",
|
||||
"fulltext_index": null
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
{
|
||||
"query_id": "q-mock-001",
|
||||
"version": "1.1.73-assertion-anchored",
|
||||
"mode": "chat",
|
||||
"answer": "水稻 **SPL**(SQUAMOSA PROMOTER BINDING PROTEIN-LIKE)基因家族共有 **19 个成员**,主要功能包括:\n\n1. **穗部形态建成**:*OsSPL14*(IPA1/WFP)通过调控分蘖数与穗分枝,是理想株型的关键因子\n2. **生育期调控**:部分成员参与抽穗期的光周期响应\n3. **miR156 调控网络**:多数成员受 miR156 转录后调控,随植株发育表达量上升\n\n| 成员 | 关键功能 | 证据来源 |\n|------|---------|----------|\n| OsSPL14 | 穗分枝、株型建成 | PMID 20393464 |\n| OsSPL7 | 穗发育负调控 | PMID 22429691 |\n| OsSPL16 | 籽粒大小与品质 | PMID 22635119 |\n",
|
||||
"source_count": 4,
|
||||
"sources": [
|
||||
{
|
||||
"id": "src-1",
|
||||
"title": "Regulation of OsSPL14 by OsmiR156 defines ideal plant architecture in rice",
|
||||
"paper_id": "PMID20393464",
|
||||
"chunk_id": "chunk_20393464_12",
|
||||
"pmid": "20393464",
|
||||
"pmcid": null,
|
||||
"doi": "10.1038/ng.591",
|
||||
"url": "https://doi.org/10.1038/ng.591",
|
||||
"snippet": "OsSPL14 positively regulates panicle branching and grain yield in rice, acting as a key factor of ideal plant architecture.",
|
||||
"score": 0.92,
|
||||
"locator": "Abstract",
|
||||
"section": "Results",
|
||||
"support_count": 3,
|
||||
"type": "assertion",
|
||||
"metadata": { "evidence_confidence": "high" }
|
||||
},
|
||||
{
|
||||
"id": "src-2",
|
||||
"title": "Genome-wide identification of the SPL gene family in Tartary buckwheat",
|
||||
"paper_id": "PMID31286919",
|
||||
"chunk_id": "chunk_31286919_07",
|
||||
"pmid": "31286919",
|
||||
"pmcid": "PMC6612345",
|
||||
"doi": "10.1186/s12870-019-1903-y",
|
||||
"url": "https://doi.org/10.1186/s12870-019-1903-y",
|
||||
"snippet": "A total of 28 SPL genes were identified in Fagopyrum tataricum genome and their expression profiles were analyzed across tissues.",
|
||||
"score": 0.85,
|
||||
"locator": "Results",
|
||||
"section": "Results",
|
||||
"support_count": 2,
|
||||
"type": "assertion",
|
||||
"metadata": {}
|
||||
},
|
||||
{
|
||||
"id": "src-3",
|
||||
"title": "OsSPL16 controls grain size and quality via the GW8 pathway",
|
||||
"paper_id": "PMID22635119",
|
||||
"chunk_id": "chunk_22635119_03",
|
||||
"pmid": "22635119",
|
||||
"pmcid": null,
|
||||
"doi": "10.1038/ng.2327",
|
||||
"url": "https://doi.org/10.1038/ng.2327",
|
||||
"snippet": "OsSPL16 positively regulates cell proliferation and promotes grain width, improving yield and quality.",
|
||||
"score": 0.81,
|
||||
"locator": "Abstract",
|
||||
"section": "Results",
|
||||
"support_count": 1,
|
||||
"type": "assertion",
|
||||
"metadata": {}
|
||||
},
|
||||
{
|
||||
"id": "src-4",
|
||||
"title": "OsSPL7 regulates panicle development in rice",
|
||||
"paper_id": "PMID22429691",
|
||||
"chunk_id": "chunk_22429691_09",
|
||||
"pmid": "22429691",
|
||||
"pmcid": "PMC3301234",
|
||||
"doi": null,
|
||||
"url": "https://pubmed.ncbi.nlm.nih.gov/22429691/",
|
||||
"snippet": "OsSPL7 acts as a negative regulator of panicle branching in rice.",
|
||||
"score": 0.72,
|
||||
"locator": "Results",
|
||||
"section": "Results",
|
||||
"support_count": 1,
|
||||
"type": "assertion",
|
||||
"metadata": {}
|
||||
}
|
||||
],
|
||||
"used_sources": [
|
||||
{
|
||||
"id": "src-1",
|
||||
"title": "Regulation of OsSPL14 by OsmiR156 defines ideal plant architecture in rice",
|
||||
"paper_id": "PMID20393464",
|
||||
"chunk_id": "chunk_20393464_12",
|
||||
"pmid": "20393464",
|
||||
"pmcid": null,
|
||||
"doi": "10.1038/ng.591",
|
||||
"url": "https://doi.org/10.1038/ng.591",
|
||||
"snippet": "OsSPL14 positively regulates panicle branching and grain yield in rice.",
|
||||
"score": 0.92,
|
||||
"locator": "Abstract",
|
||||
"section": "Results",
|
||||
"support_count": 3,
|
||||
"type": "assertion",
|
||||
"metadata": {}
|
||||
}
|
||||
],
|
||||
"chains": [
|
||||
{
|
||||
"chain_id": "c1",
|
||||
"hops": [
|
||||
{ "source": "OsSPL14", "target": "panicle", "relation": "expressed_in", "source_type": "Gene", "target_type": "Tissue", "paper_id": "PMID20393464", "pmid": "20393464", "pmcid": null, "doi": "10.1038/ng.591" },
|
||||
{ "source": "OsSPL14", "target": "panicle branching", "relation": "involved_in", "source_type": "Gene", "target_type": "Process", "paper_id": "PMID20393464", "pmid": "20393464", "pmcid": null, "doi": "10.1038/ng.591" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"chain_id": "c2",
|
||||
"hops": [
|
||||
{ "source": "OsSPL14", "target": "miR156", "relation": "regulated_by", "source_type": "Gene", "target_type": "Molecule", "paper_id": "PMID20393464", "pmid": "20393464", "pmcid": null, "doi": "10.1038/ng.591" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"graph": {
|
||||
"nodes": [
|
||||
{ "id": "OsSPL14", "label": "OsSPL14", "type": "Gene" },
|
||||
{ "id": "panicle", "label": "panicle", "type": "Tissue" },
|
||||
{ "id": "panicle branching", "label": "panicle branching", "type": "Process" },
|
||||
{ "id": "miR156", "label": "miR156", "type": "Molecule" },
|
||||
{ "id": "OsSPL16", "label": "OsSPL16", "type": "Gene" },
|
||||
{ "id": "grain width", "label": "grain width", "type": "Trait" }
|
||||
],
|
||||
"edges": [
|
||||
{ "source": "OsSPL14", "target": "panicle", "relation": "expressed_in", "label": "expressed_in", "chain_id": "c1", "hop": 1, "paper_id": "PMID20393464", "pmid": "20393464", "pmcid": null, "doi": "10.1038/ng.591" },
|
||||
{ "source": "OsSPL14", "target": "panicle branching", "relation": "involved_in", "label": "involved_in", "chain_id": "c1", "hop": 2, "paper_id": "PMID20393464", "pmid": "20393464", "pmcid": null, "doi": "10.1038/ng.591" },
|
||||
{ "source": "OsSPL14", "target": "miR156", "relation": "regulated_by", "label": "regulated_by", "chain_id": "c2", "hop": 1, "paper_id": "PMID20393464", "pmid": "20393464", "pmcid": null, "doi": "10.1038/ng.591" },
|
||||
{ "source": "OsSPL16", "target": "grain width", "relation": "involved_in", "label": "involved_in", "chain_id": "c3", "hop": 1, "paper_id": "PMID22635119", "pmid": "22635119", "pmcid": null, "doi": "10.1038/ng.2327" }
|
||||
],
|
||||
"chains": [
|
||||
{
|
||||
"chain_id": "c1",
|
||||
"hops": [
|
||||
{ "source": "OsSPL14", "target": "panicle", "relation": "expressed_in", "source_type": "Gene", "target_type": "Tissue", "paper_id": "PMID20393464", "pmid": "20393464", "pmcid": null, "doi": "10.1038/ng.591" },
|
||||
{ "source": "OsSPL14", "target": "panicle branching", "relation": "involved_in", "source_type": "Gene", "target_type": "Process", "paper_id": "PMID20393464", "pmid": "20393464", "pmcid": null, "doi": "10.1038/ng.591" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"retrieval_plan": {
|
||||
"anchor_entity": "SPL gene family",
|
||||
"relation_family": "function",
|
||||
"preferred_papers": ["PMID20393464", "PMID31286919"]
|
||||
},
|
||||
"retrieval_notes": "graph-balanced top30 evidence chain, with 15 graph-derived items",
|
||||
"timing": { "total_s": 41.2, "retrieval_s": 22.1, "generation_s": 19.1 },
|
||||
"citation_map": { "1": "PMID20393464", "2": "PMID31286919", "3": "PMID22635119" },
|
||||
"raw": {}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"query_id": "q-mock-empty-001",
|
||||
"version": "1.1.73-assertion-anchored",
|
||||
"mode": "chat",
|
||||
"answer": "未找到与该问题直接相关的文献证据。建议调整关键词或换一个更具体的问法。",
|
||||
"source_count": 0,
|
||||
"sources": [],
|
||||
"used_sources": [],
|
||||
"chains": [],
|
||||
"graph": { "nodes": [], "edges": [], "chains": [] },
|
||||
"retrieval_plan": null,
|
||||
"retrieval_notes": "zero evidence retained",
|
||||
"timing": { "total_s": 8.1 },
|
||||
"citation_map": {},
|
||||
"raw": {}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "QA V1.1.73 Assertion-Anchored Full Graph",
|
||||
"version": "1.1.73-assertion-anchored",
|
||||
"pipeline": [
|
||||
"question",
|
||||
"provisional answer",
|
||||
"structured planner",
|
||||
"direct Qdrant recall over V15/V16 canonical assertions",
|
||||
"canonical seed extraction",
|
||||
"Neo4j exact seed retrieval",
|
||||
"Neo4j bounded graph expansion",
|
||||
"BGE rerank",
|
||||
"graph-balanced top30 evidence chain",
|
||||
"BGE top10 Qdrant source seed selection",
|
||||
"native Neo4j two-hop chain retrieval",
|
||||
"final LLM answer composition"
|
||||
],
|
||||
"config": {
|
||||
"max_rounds": 1,
|
||||
"max_evidence": 40,
|
||||
"provisional_draft": false,
|
||||
"second_round": true,
|
||||
"bge_rerank": true,
|
||||
"graph_schema": "canonical",
|
||||
"knowledge_chain_defaults": {
|
||||
"chain_top_k": 3,
|
||||
"chain_hops": 2,
|
||||
"chain_max_edges": 12,
|
||||
"chain_max_nodes": 10,
|
||||
"chain_mode": "display",
|
||||
"native_chain_max_hops": 3,
|
||||
"native_chain_top_k": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"query_id": "q-mock-search-001",
|
||||
"version": "1.1.73-assertion-anchored",
|
||||
"mode": "search",
|
||||
"answer": "",
|
||||
"source_count": 4,
|
||||
"sources": [
|
||||
{
|
||||
"id": "src-1", "title": "Regulation of OsSPL14 by OsmiR156 defines ideal plant architecture in rice",
|
||||
"paper_id": "PMID20393464", "chunk_id": "chunk_20393464_12", "pmid": "20393464", "pmcid": null,
|
||||
"doi": "10.1038/ng.591", "url": "https://doi.org/10.1038/ng.591",
|
||||
"snippet": "OsSPL14 positively regulates panicle branching and grain yield in rice.", "score": 0.92,
|
||||
"locator": "Abstract", "section": "Results", "support_count": 3, "type": "assertion", "metadata": {}
|
||||
},
|
||||
{
|
||||
"id": "src-2", "title": "Genome-wide identification of the SPL gene family in Tartary buckwheat",
|
||||
"paper_id": "PMID31286919", "chunk_id": "chunk_31286919_07", "pmid": "31286919", "pmcid": "PMC6612345",
|
||||
"doi": "10.1186/s12870-019-1903-y", "url": "https://doi.org/10.1186/s12870-019-1903-y",
|
||||
"snippet": "28 SPL genes identified in Fagopyrum tataricum.", "score": 0.85,
|
||||
"locator": "Results", "section": "Results", "support_count": 2, "type": "assertion", "metadata": {}
|
||||
},
|
||||
{
|
||||
"id": "src-3", "title": "OsSPL16 controls grain size and quality via the GW8 pathway",
|
||||
"paper_id": "PMID22635119", "chunk_id": "chunk_22635119_03", "pmid": "22635119", "pmcid": null,
|
||||
"doi": "10.1038/ng.2327", "url": "https://doi.org/10.1038/ng.2327",
|
||||
"snippet": "OsSPL16 positively regulates cell proliferation.", "score": 0.81,
|
||||
"locator": "Abstract", "section": "Results", "support_count": 1, "type": "assertion", "metadata": {}
|
||||
},
|
||||
{
|
||||
"id": "src-4", "title": "OsSPL7 regulates panicle development in rice",
|
||||
"paper_id": "PMID22429691", "chunk_id": "chunk_22429691_09", "pmid": "22429691", "pmcid": "PMC3301234",
|
||||
"doi": null, "url": "https://pubmed.ncbi.nlm.nih.gov/22429691/",
|
||||
"snippet": "OsSPL7 acts as a negative regulator of panicle branching.", "score": 0.72,
|
||||
"locator": "Results", "section": "Results", "support_count": 1, "type": "assertion", "metadata": {}
|
||||
}
|
||||
],
|
||||
"used_sources": [],
|
||||
"chains": [],
|
||||
"graph": { "nodes": [], "edges": [], "chains": [] },
|
||||
"retrieval_plan": null,
|
||||
"retrieval_notes": "retrieval only, no LLM answer",
|
||||
"timing": { "total_s": 4.7 },
|
||||
"citation_map": {},
|
||||
"raw": {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "sess-new-mock",
|
||||
"title": "新会话",
|
||||
"status": "idle",
|
||||
"created_at": "2026-08-25T12:00:00Z",
|
||||
"updated_at": "2026-08-25T12:00:00Z",
|
||||
"message_count": 0,
|
||||
"meta": { "title_manual": false }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"id": "sess-003",
|
||||
"title": "水稻SPL基因家族的功能研究有哪些关键进展?",
|
||||
"status": "done",
|
||||
"created_at": "2026-08-25T09:41:00Z",
|
||||
"updated_at": "2026-08-25T10:02:00Z",
|
||||
"meta": { "title_manual": false },
|
||||
"messages": [
|
||||
{
|
||||
"id": "msg-001",
|
||||
"role": "user",
|
||||
"kind": "qa",
|
||||
"status": "done",
|
||||
"blocks": [],
|
||||
"raw": null,
|
||||
"text": "水稻SPL基因家族的功能研究有哪些关键进展?",
|
||||
"created_at": "2026-08-25T09:41:00Z",
|
||||
"updated_at": "2026-08-25T09:41:00Z"
|
||||
},
|
||||
{
|
||||
"id": "msg-002",
|
||||
"role": "assistant",
|
||||
"kind": "qa",
|
||||
"status": "done",
|
||||
"blocks": [
|
||||
{ "type": "text", "html": "<p>水稻 <strong>SPL</strong>(SQUAMOSA PROMOTER BINDING PROTEIN-LIKE)基因家族共有 <strong>19 个成员</strong>,主要功能包括:</p><ol><li><strong>穗部形态建成</strong>:<em>OsSPL14</em>(IPA1/WFP)通过调控分蘖数与穗分枝,是理想株型的关键因子</li><li><strong>生育期调控</strong>:部分成员参与抽穗期的光周期响应</li><li><strong>miR156 调控网络</strong>:多数成员受 miR156 转录后调控</li></ol>" },
|
||||
{ "type": "table", "headers": ["成员", "关键功能", "证据来源"], "rows": [
|
||||
["OsSPL14", "穗分枝、株型建成", "PMID 20393464"],
|
||||
["OsSPL7", "穗发育负调控", "PMID 22429691"],
|
||||
["OsSPL16", "籽粒大小与品质", "PMID 22635119"]
|
||||
] },
|
||||
{ "type": "pipeline", "status": "done", "steps": [
|
||||
{ "name": "规划", "state": "done", "desc": "意图理解与实体识别", "phase": "planning" },
|
||||
{ "name": "检索", "state": "done", "desc": "多路检索与子问题召回", "phase": "retrieval" },
|
||||
{ "name": "证据", "state": "done", "desc": "证据筛选与交叉验证", "phase": "evidence" },
|
||||
{ "name": "生成", "state": "done", "desc": "答案生成与引用锚定", "phase": "generation" }
|
||||
] },
|
||||
{ "type": "tool", "icon": "🧬", "name": "已识别实体", "status": "done", "dur": "0.8s", "open": false, "phase": "planning",
|
||||
"params": "SPL\nOsSPL14\nrice\npanicle", "result": "4 个实体", "logs": [
|
||||
{ "text": "> 实体识别完成: 4 entities", "cls": "log-ok" }
|
||||
] },
|
||||
{ "type": "tool", "icon": "🔬", "name": "证据筛选", "status": "done", "dur": "3.2s", "open": false, "phase": "evidence",
|
||||
"params": "accepted_count: 4\npublication_count: 4", "result": "4 条证据通过审核", "logs": [
|
||||
{ "text": "> 候选证据 18 条", "cls": "log-run" },
|
||||
{ "text": "> 采纳 4 条(high confidence)", "cls": "log-ok" }
|
||||
] },
|
||||
{ "type": "sources", "items": [] },
|
||||
{ "type": "graph", "nodes": [
|
||||
{ "id": "OsSPL14", "label": "OsSPL14", "type": "Gene" },
|
||||
{ "id": "panicle", "label": "panicle", "type": "Tissue" },
|
||||
{ "id": "miR156", "label": "miR156", "type": "Molecule" }
|
||||
], "edges": [
|
||||
{ "source": "OsSPL14", "target": "panicle", "relation": "expressed_in", "label": "expressed_in", "chain_id": "c1", "hop": 1, "paper_id": "PMID20393464", "pmid": "20393464", "pmcid": null, "doi": "10.1038/ng.591" },
|
||||
{ "source": "OsSPL14", "target": "miR156", "relation": "regulated_by", "label": "regulated_by", "chain_id": "c2", "hop": 1, "paper_id": "PMID20393464", "pmid": "20393464", "pmcid": null, "doi": "10.1038/ng.591" }
|
||||
], "chains": [] }
|
||||
],
|
||||
"raw": {},
|
||||
"created_at": "2026-08-25T09:41:00Z",
|
||||
"updated_at": "2026-08-25T10:02:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{ "id": "sess-003", "title": "水稻SPL基因家族的功能研究有哪些关键进展?", "status": "done", "created_at": "2026-08-25T09:41:00Z", "updated_at": "2026-08-25T10:02:00Z", "message_count": 2, "meta": { "title_manual": false } },
|
||||
{ "id": "sess-002", "title": "拟南芥HD-ZIP家族的生物学功能", "status": "done", "created_at": "2026-08-25T08:10:00Z", "updated_at": "2026-08-25T08:15:00Z", "message_count": 2, "meta": { "title_manual": false } },
|
||||
{ "id": "sess-001", "title": "文献检索记录", "status": "error", "created_at": "2026-08-24T15:20:00Z", "updated_at": "2026-08-24T15:23:00Z", "message_count": 2, "meta": { "title_manual": true } },
|
||||
{ "id": "sess-000", "title": "NAC 转录因子研究综述", "status": "done", "created_at": "2026-08-23T11:05:00Z", "updated_at": "2026-08-23T11:30:00Z", "message_count": 4, "meta": { "title_manual": false } },
|
||||
{ "id": "sess-099", "title": "番茄TCP家族选择压力", "status": "idle", "created_at": "2026-08-20T09:00:00Z", "updated_at": "2026-08-20T09:00:00Z", "message_count": 0, "meta": { "title_manual": false } }
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"session_id": "sess-003",
|
||||
"total": 4,
|
||||
"sources": [
|
||||
{
|
||||
"id": "src-1", "title": "Regulation of OsSPL14 by OsmiR156 defines ideal plant architecture in rice",
|
||||
"paper_id": "PMID20393464", "chunk_id": "chunk_20393464_12", "pmid": "20393464", "pmcid": null,
|
||||
"doi": "10.1038/ng.591", "url": "https://doi.org/10.1038/ng.591",
|
||||
"snippet": "OsSPL14 positively regulates panicle branching and grain yield in rice.",
|
||||
"score": 0.92, "locator": "Abstract", "section": "Results", "support_count": 3,
|
||||
"type": "assertion", "metadata": {}, "count": 2
|
||||
},
|
||||
{
|
||||
"id": "src-2", "title": "Genome-wide identification of the SPL gene family in Tartary buckwheat",
|
||||
"paper_id": "PMID31286919", "chunk_id": "chunk_31286919_07", "pmid": "31286919", "pmcid": "PMC6612345",
|
||||
"doi": "10.1186/s12870-019-1903-y", "url": "https://doi.org/10.1186/s12870-019-1903-y",
|
||||
"snippet": "28 SPL genes identified in Fagopyrum tataricum.",
|
||||
"score": 0.85, "locator": "Results", "section": "Results", "support_count": 2,
|
||||
"type": "assertion", "metadata": {}, "count": 1
|
||||
},
|
||||
{
|
||||
"id": "src-3", "title": "OsSPL16 controls grain size and quality via the GW8 pathway",
|
||||
"paper_id": "PMID22635119", "chunk_id": "chunk_22635119_03", "pmid": "22635119", "pmcid": null,
|
||||
"doi": "10.1038/ng.2327", "url": "https://doi.org/10.1038/ng.2327",
|
||||
"snippet": "OsSPL16 positively regulates cell proliferation.",
|
||||
"score": 0.81, "locator": "Abstract", "section": "Results", "support_count": 1,
|
||||
"type": "assertion", "metadata": {}, "count": 1
|
||||
},
|
||||
{
|
||||
"id": "src-4", "title": "OsSPL7 regulates panicle development in rice",
|
||||
"paper_id": "PMID22429691", "chunk_id": "chunk_22429691_09", "pmid": "22429691", "pmcid": "PMC3301234",
|
||||
"doi": null, "url": "https://pubmed.ncbi.nlm.nih.gov/22429691/",
|
||||
"snippet": "OsSPL7 acts as a negative regulator of panicle branching.",
|
||||
"score": 0.72, "locator": "Results", "section": "Results", "support_count": 1,
|
||||
"type": "assertion", "metadata": {}, "count": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{ "stage": "understanding_started", "phase": "planning", "label": "开始理解问题", "details": {}, "delay_ms": 400 },
|
||||
{ "stage": "entities_identified", "phase": "planning", "label": "已识别实体", "details": { "entities": ["SPL", "OsSPL14", "rice", "panicle"] }, "delay_ms": 800 },
|
||||
{ "stage": "related_search_started", "phase": "planning", "label": "开始关联检索", "details": {}, "delay_ms": 600 },
|
||||
{ "stage": "concepts_found", "phase": "retrieval", "label": "发现相关概念", "details": { "concepts": ["panicle branching", "grain yield", "miR156"], "concept_count": 3, "connection_count": 12 }, "delay_ms": 900 },
|
||||
{ "stage": "subquestion_retrieved", "phase": "retrieval", "label": "子问题召回完成", "details": { "candidate_count": 18 }, "delay_ms": 900 },
|
||||
{ "stage": "agent_review_started", "phase": "retrieval", "label": "开始智能体审核", "details": {}, "delay_ms": 600 },
|
||||
{ "stage": "subquestion_retry_started", "phase": "retrieval", "label": "子问题重试中", "details": {}, "delay_ms": 600 },
|
||||
{ "stage": "evidence_accepted", "phase": "evidence", "label": "证据已采纳", "details": { "accepted_count": 6 }, "delay_ms": 800 },
|
||||
{ "stage": "evidence_review_completed", "phase": "evidence", "label": "证据审核完成", "details": { "accepted_count": 4, "publication_count": 4 }, "delay_ms": 700 },
|
||||
{ "stage": "answer_generation_started", "phase": "generation", "label": "开始生成答案", "details": {}, "delay_ms": 600 },
|
||||
{ "stage": "completed", "phase": "done", "label": "检索完成", "details": {}, "delay_ms": 400 },
|
||||
"__complete__"
|
||||
]
|
||||
@@ -0,0 +1,145 @@
|
||||
{
|
||||
"_comment": "真实上游 7920 /api/v1/qa-v1-1-73/search 响应形态样本(DISCOVERY.md §4.1 结构,已截断)——供 normalize 单元测试,不直接作为 API 响应。",
|
||||
"success": true,
|
||||
"version": "1.1.73",
|
||||
"query_id": "20260825_000715_49f801a9",
|
||||
"question": "What is the SPL gene family?",
|
||||
"answer": "",
|
||||
"citation_validation": { "valid": true, "cited_pmids": ["28655295"], "unknown_pmids": [] },
|
||||
"sources": [
|
||||
{
|
||||
"evidence_id": "S1",
|
||||
"kb": "V16/V15",
|
||||
"source": "SPL gene family",
|
||||
"relation": "characterized_in",
|
||||
"target": "Oryza sativa",
|
||||
"evidence": "## Phylogenetic analysis of the PeSPL genes ...",
|
||||
"paper_id": "PMC5488377_12864_2017_Article_3882",
|
||||
"title": "Genome-wide analysis of the SPL gene family in pecan",
|
||||
"pmid": "28655295",
|
||||
"pmcid": "PMC5488377",
|
||||
"doi": "10.1186/s12864-017-3882-4",
|
||||
"species": "",
|
||||
"section": "",
|
||||
"output_layer": "main_kg",
|
||||
"claim_scope": "",
|
||||
"relation_id": "cass_d12f62f9abffbd903b97",
|
||||
"retrieval_methods": ["qdrant_vector"],
|
||||
"retrieval_queries": ["SPL gene family", "SBP box domain"],
|
||||
"vector_score": 0.714,
|
||||
"rrf_score": 1.114,
|
||||
"rerank_score": 0.989,
|
||||
"final_score": 0.982,
|
||||
"cluster_id": "clu_0f8da21642adc4f0",
|
||||
"cluster_member_count": 3,
|
||||
"cluster_member_assertion_ids": ["cass_a", "cass_b", "cass_c"],
|
||||
"cluster_member_paper_ids": ["PMC5488377_12864_2017_Article_3882"],
|
||||
"cluster_member_pmids": ["28655295"],
|
||||
"raw": {
|
||||
"kb_version": "V16",
|
||||
"source_system": "srt",
|
||||
"assertion_id": "cass_d12f62f9abffbd903b97",
|
||||
"source": "SPL gene family",
|
||||
"relation": "characterized_in",
|
||||
"target": "Oryza sativa",
|
||||
"evidence": "## Phylogenetic analysis ...",
|
||||
"best_evidence": "The SPL gene family is characterized in rice.",
|
||||
"paper_id": "PMC5488377_12864_2017_Article_3882",
|
||||
"support_count": 12,
|
||||
"paper_count": 5,
|
||||
"quality_score": 0.91,
|
||||
"best_score": 0.95
|
||||
}
|
||||
}
|
||||
],
|
||||
"source_count": 1,
|
||||
"evidence_chain_top30": [],
|
||||
"provisional_answer": "",
|
||||
"retrieval_hypothesis": { "question_type": "raw_question", "direct_question_entities": [], "hypothesized_entities": [], "relation_intents": [], "concept_queries": [], "answer_slots": [], "atomic_claims": [], "negative_constraints": [], "parse_status": "ok" },
|
||||
"query_plan": {
|
||||
"queries": {},
|
||||
"alias_audit": {},
|
||||
"agentic_plan": { "anchor_entity": "SPL gene family", "relation_family": "characterized_in" }
|
||||
},
|
||||
"retrieval_audit": { "qdrant": [], "seed_extraction": {}, "neo4j": [], "dedup": {} },
|
||||
"graph_knowledge_chains": {
|
||||
"mode": "neo4j_native_path",
|
||||
"chain_policy": "display",
|
||||
"chains": [
|
||||
{
|
||||
"chain_id": "G1",
|
||||
"chain_source": "neo4j_native_path",
|
||||
"hop_count": 2,
|
||||
"path_score": 0.861,
|
||||
"start": "SPL gene",
|
||||
"end": "18 SPL genes in rice",
|
||||
"bridge_entities": [],
|
||||
"hops": [
|
||||
{
|
||||
"hop_index": 0,
|
||||
"from_id": "cent_spl", "from_name": "SPL gene",
|
||||
"to_id": "cent_oryza", "to_name": "Oryza sativa",
|
||||
"source": "SPL gene", "relation": "characterized_in", "target": "Oryza sativa",
|
||||
"source_type": "gene_family", "target_type": "species",
|
||||
"relation_category": "characterized_in",
|
||||
"assertion_id": "cass_d12f62f9abffbd903b97",
|
||||
"paper_id": "PMC5488377_12864_2017_Article_3882",
|
||||
"title": "Genome-wide analysis of the SPL gene family in pecan",
|
||||
"pmid": "28655295", "pmcid": "PMC5488377", "doi": "10.1186/s12864-017-3882-4",
|
||||
"evidence": "…", "support_count": 12, "paper_count": 5, "best_score": 0.95,
|
||||
"edge_score": 0.861
|
||||
},
|
||||
{
|
||||
"hop_index": 1,
|
||||
"from_id": "cent_oryza", "from_name": "Oryza sativa",
|
||||
"to_id": "cent_18spl", "to_name": "18 SPL genes in rice",
|
||||
"source": "Oryza sativa", "relation": "has_gene_family_member", "target": "18 SPL genes in rice",
|
||||
"source_type": "species", "target_type": "gene",
|
||||
"relation_category": "has_member",
|
||||
"assertion_id": "cass_e23a",
|
||||
"paper_id": "PMC5488377_12864_2017_Article_3882",
|
||||
"title": "Genome-wide analysis of the SPL gene family in pecan",
|
||||
"pmid": "28655295", "pmcid": "PMC5488377", "doi": "10.1186/s12864-017-3882-4",
|
||||
"support_count": 8, "paper_count": 3, "best_score": 0.88,
|
||||
"edge_score": 0.79
|
||||
}
|
||||
],
|
||||
"score_breakdown": { "path_score": 0.861 }
|
||||
}
|
||||
],
|
||||
"chain_count": 1,
|
||||
"audit": {},
|
||||
"summary": { "note": "used_for_answer_generation", "used_for_answer_generation": true }
|
||||
},
|
||||
"native_graph_chains": [
|
||||
{
|
||||
"chain_id": "G1",
|
||||
"chain_source": "neo4j_native_path",
|
||||
"hop_count": 2,
|
||||
"path_score": 0.861,
|
||||
"start": "SPL gene",
|
||||
"end": "18 SPL genes in rice",
|
||||
"bridge_entities": [],
|
||||
"hops": [
|
||||
{
|
||||
"hop_index": 0,
|
||||
"from_id": "cent_spl", "from_name": "SPL gene",
|
||||
"to_id": "cent_oryza", "to_name": "Oryza sativa",
|
||||
"source": "SPL gene", "relation": "characterized_in", "target": "Oryza sativa",
|
||||
"source_type": "gene_family", "target_type": "species",
|
||||
"relation_category": "characterized_in",
|
||||
"assertion_id": "cass_d12f62f9abffbd903b97",
|
||||
"paper_id": "PMC5488377_12864_2017_Article_3882",
|
||||
"pmid": "28655295", "pmcid": "PMC5488377", "doi": "10.1186/s12864-017-3882-4",
|
||||
"support_count": 12, "paper_count": 5, "best_score": 0.95
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"assertion_anchored_graph_chains": [],
|
||||
"degraded_execution": false,
|
||||
"subquestion_evidence_groups": [],
|
||||
"rerank_audit": [],
|
||||
"timings": { "provisional_answer": 0.5, "retrieval": 12.3, "native_graph_chain_retrieval": 70.1, "total": 91.9 },
|
||||
"flow": ["question", "provisional answer", "structured planner", "retrieval", "native_graph_chain_retrieval"]
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
"""PlantAI-rag-new mock server —— 前端开发与 E2E 场景的固定数据源。
|
||||
|
||||
监听 127.0.0.1:8099。实现 API.md 的全部接口,返回 fixtures/ 中的固定数据。
|
||||
场景切换:查询参数 ?scenario= 或请求头 X-Mock-Scenario:
|
||||
normal(默认)| empty(空结果)| slow(慢响应 5s)| error(上游故障)| degraded(QA 不可用)
|
||||
SSE 流按真实时序回放,让流式 UI 在没有上游时也能开发。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Header, Query, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
app = FastAPI(title="PlantAI-rag-new mock")
|
||||
|
||||
|
||||
def load(name: str):
|
||||
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def scenario_of(request: Request, q: str | None = None) -> str:
|
||||
s = q or request.headers.get("X-Mock-Scenario") or "normal"
|
||||
return s if s in {"normal", "empty", "slow", "error", "degraded"} else "normal"
|
||||
|
||||
|
||||
def envelope(code: str, message: str, degraded: bool, status: int):
|
||||
return JSONResponse(
|
||||
status_code=status,
|
||||
content={"ok": False, "error": {"code": code, "message": message, "degraded": degraded}},
|
||||
)
|
||||
|
||||
|
||||
# ---------- 健康与元信息 ----------
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return load("health.json")
|
||||
|
||||
|
||||
@app.get("/api/qa/info")
|
||||
async def qa_info():
|
||||
return load("qa_info.json")
|
||||
|
||||
|
||||
# ---------- QA ----------
|
||||
|
||||
@app.post("/api/qa/chat")
|
||||
async def qa_chat(request: Request):
|
||||
sc = scenario_of(request)
|
||||
if sc == "degraded":
|
||||
return envelope("UPSTREAM_DOWN", "知识问答服务当前不可用,请稍后重试", True, 503)
|
||||
if sc == "error":
|
||||
return envelope("UPSTREAM_ERROR", "上游问答服务返回错误", True, 502)
|
||||
if sc == "slow":
|
||||
await asyncio.sleep(5)
|
||||
if sc == "empty":
|
||||
return load("qa_chat_empty.json")
|
||||
return load("qa_chat.json")
|
||||
|
||||
|
||||
@app.post("/api/qa/search")
|
||||
async def qa_search(request: Request):
|
||||
sc = scenario_of(request)
|
||||
if sc in ("degraded",):
|
||||
return envelope("UPSTREAM_DOWN", "知识问答服务当前不可用,请稍后重试", True, 503)
|
||||
return load("qa_search.json")
|
||||
|
||||
|
||||
@app.post("/api/qa/chat/stream")
|
||||
async def qa_chat_stream(request: Request):
|
||||
sc = scenario_of(request)
|
||||
if sc == "degraded":
|
||||
return envelope("UPSTREAM_DOWN", "知识问答服务当前不可用,请稍后重试", True, 503)
|
||||
|
||||
frames = load("sse_frames.json") # [{stage, phase, label, details, delay_ms}, ..., "__complete__"]
|
||||
|
||||
async def gen():
|
||||
for f in frames:
|
||||
if f == "__complete__":
|
||||
yield "event: complete\n"
|
||||
yield f"data: {json.dumps(load('qa_chat.json'), ensure_ascii=False)}\n\n"
|
||||
break
|
||||
await asyncio.sleep(f.get("delay_ms", 600) / 1000)
|
||||
payload = {
|
||||
"stage": f["stage"], "phase": f["phase"], "label": f["label"],
|
||||
"details": f.get("details", {}), "at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
}
|
||||
yield f"event: progress\n"
|
||||
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
if int(f.get("delay_ms", 600)) > 3000: # 慢帧之间补 keepalive
|
||||
yield ": ping\n\n"
|
||||
|
||||
return StreamingResponse(gen(), media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
||||
|
||||
|
||||
# ---------- 会话与消息 ----------
|
||||
|
||||
_sessions = []
|
||||
_messages = {}
|
||||
_seq = 0
|
||||
|
||||
|
||||
def _now():
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
|
||||
@app.get("/api/sessions")
|
||||
async def sessions():
|
||||
return load("sessions.json")
|
||||
|
||||
|
||||
@app.post("/api/sessions")
|
||||
async def create_session(request: Request):
|
||||
body = await request.json()
|
||||
return {**load("session_created.json"), "title": body.get("title") or "新会话"}
|
||||
|
||||
|
||||
@app.get("/api/sessions/{sid}")
|
||||
async def session_detail(sid: str):
|
||||
return load("session_detail.json")
|
||||
|
||||
|
||||
@app.patch("/api/sessions/{sid}")
|
||||
async def session_patch(sid: str, request: Request):
|
||||
body = await request.json()
|
||||
return {**load("session_created.json"), **body}
|
||||
|
||||
|
||||
@app.delete("/api/sessions/{sid}")
|
||||
async def session_delete(sid: str):
|
||||
return JSONResponse(status_code=204, content=None)
|
||||
|
||||
|
||||
@app.post("/api/sessions/{sid}/messages")
|
||||
async def create_message(sid: str, request: Request):
|
||||
body = await request.json()
|
||||
return {"id": "msg-mock-001", "role": body.get("role", "user"),
|
||||
"kind": body.get("kind", "qa"), "status": body.get("status", "done"),
|
||||
"blocks": body.get("blocks", []), "raw": None,
|
||||
"created_at": _now(), "updated_at": _now()}
|
||||
|
||||
|
||||
@app.patch("/api/sessions/{sid}/messages/{mid}")
|
||||
async def patch_message(sid: str, mid: str, request: Request):
|
||||
body = await request.json()
|
||||
return {"id": mid, "role": "assistant", "kind": "qa",
|
||||
"status": body.get("status", "done"), "blocks": body.get("blocks", []),
|
||||
"raw": body.get("raw"), "created_at": _now(), "updated_at": _now()}
|
||||
|
||||
|
||||
@app.get("/api/sessions/{sid}/sources")
|
||||
async def session_sources(sid: str):
|
||||
return load("sources_aggregate.json")
|
||||
|
||||
|
||||
# ---------- 知识图谱 ----------
|
||||
|
||||
@app.get("/api/kg/status")
|
||||
async def kg_status():
|
||||
return load("kg_status.json")
|
||||
|
||||
|
||||
@app.get("/api/kg/overview")
|
||||
async def kg_overview():
|
||||
return load("kg_overview.json")
|
||||
|
||||
|
||||
@app.get("/api/kg/entities")
|
||||
async def kg_entities(q: str = "", limit: int = 20):
|
||||
return load("kg_entities.json")
|
||||
|
||||
|
||||
@app.get("/api/kg/entities/{name}")
|
||||
async def kg_entity(name: str):
|
||||
return load("kg_entity.json")
|
||||
|
||||
|
||||
@app.get("/api/kg/entities/{name}/neighborhood")
|
||||
async def kg_neighborhood(name: str, depth: int = 2, limit: int = 50):
|
||||
return load("kg_neighborhood.json")
|
||||
|
||||
|
||||
# ---------- 抽取任务 ----------
|
||||
|
||||
@app.get("/api/extraction/jobs")
|
||||
async def extraction_jobs():
|
||||
return load("extraction_jobs.json")
|
||||
|
||||
|
||||
@app.get("/api/extraction/jobs/{job_id}")
|
||||
async def extraction_job(job_id: str):
|
||||
return load("extraction_job.json")
|
||||
|
||||
|
||||
@app.get("/api/extraction/processing-status")
|
||||
async def extraction_processing_status():
|
||||
return load("extraction_processing_status.json")
|
||||
|
||||
|
||||
@app.get("/api/extraction/run-log")
|
||||
async def extraction_run_log():
|
||||
return {"text": "2026-08-25 10:00:01 INFO MinerU 解析完成,11 sections\n2026-08-25 10:03:44 INFO 实体识别 712 entities\n2026-08-25 10:12:09 INFO 关系抽取 751 relations\n"}
|
||||
|
||||
|
||||
@app.get("/api/extraction/summary")
|
||||
async def extraction_summary():
|
||||
return load("extraction_summary.json")
|
||||
|
||||
|
||||
@app.post("/api/extraction/upload")
|
||||
async def extraction_upload(request: Request):
|
||||
return load("extraction_upload.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="127.0.0.1", port=8099, log_level="info")
|
||||
@@ -0,0 +1,30 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "plantai-bff"
|
||||
version = "0.1.0"
|
||||
description = "PlantAI-rag-new BFF:上游代理 + 归一化 + 会话持久化 + Neo4j 直连"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.110",
|
||||
"uvicorn[standard]>=0.29",
|
||||
"httpx>=0.27",
|
||||
"aiosqlite>=0.20",
|
||||
"neo4j>=5.18",
|
||||
"pydantic>=2.6",
|
||||
"pydantic-settings>=2.2",
|
||||
"python-multipart>=0.0.9",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "respx>=0.23"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["app*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["app"]
|
||||
python_files = ["test_*.py"]
|
||||
asyncio_mode = "auto"
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# run.sh —— 本地起 BFF(ARCHITECTURE.md §3:127.0.0.1:8090,占用则退 8091)。
|
||||
# 用法:bash backend/run.sh
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
PORT="${PORT:-8090}"
|
||||
|
||||
if ! command -v conda >/dev/null 2>&1; then
|
||||
echo "run.sh: 未找到 conda,尝试直接使用当前 python 运行" >&2
|
||||
exec python -m uvicorn app.main:app --host 127.0.0.1 --port "$PORT"
|
||||
fi
|
||||
|
||||
if ! conda env list | awk '{print $1}' | grep -qx "plantai-bff"; then
|
||||
echo "run.sh: conda 环境 plantai-bff 不存在" >&2
|
||||
echo " 创建:conda create -n plantai-bff python=3.11 -y && conda activate plantai-bff && pip install -e '.[dev]'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec conda run --no-capture-output -n plantai-bff \
|
||||
python -m uvicorn app.main:app --host 127.0.0.1 --port "$PORT"
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
# deploy/bff_restart.sh —— 远端 BFF 重启(PM 使用,在远端执行)
|
||||
# 用法:ssh 192.168.0.175 -p 26518 'bash ~/5090/teamWorkspace/zh/PlantAI-rag-new/deploy/bff_restart.sh [bind_addr]'
|
||||
# 参数:可选绑定地址(默认 127.0.0.1;内网演示传 0.0.0.0)
|
||||
# 要点:用 setsid 完全脱离 SSH 会话,stdout/stderr 全部落日志。
|
||||
set -uo pipefail
|
||||
ROOT="/home/xzq/5090/teamWorkspace/zh/PlantAI-rag-new"
|
||||
BIND="${1:-127.0.0.1}"
|
||||
source ~/miniconda3/etc/profile.d/conda.sh
|
||||
conda activate plantai-bff
|
||||
|
||||
# 杀旧进程([u] 正则技巧防 pkill 自匹配)
|
||||
pkill -f "[u]vicorn app.main:app" 2>/dev/null
|
||||
sleep 2
|
||||
|
||||
cd "$ROOT/backend"
|
||||
# 加载运行时凭据(backend/.env,不入 git)并导出给应用进程。
|
||||
# pydantic-settings 会把 .env 载入 settings 对象,但 services/neo4j.py 的
|
||||
# _env() 直接读 os.environ,故需显式 set -a 导出。
|
||||
set -a
|
||||
[ -f .env ] && source .env
|
||||
set +a
|
||||
setsid python -m uvicorn app.main:app --host "$BIND" --port 8090 \
|
||||
> /tmp/plantai-bff.log 2>&1 < /dev/null &
|
||||
disown
|
||||
|
||||
sleep 5
|
||||
if ss -tln | grep -q ":8090 "; then
|
||||
echo "BFF UP ($BIND:8090)"
|
||||
curl -s --max-time 20 http://127.0.0.1:8090/api/health | head -c 200
|
||||
echo
|
||||
# 生产形态:静态托管 frontend/dist(存在时 BFF 单进程服务整个前端)
|
||||
curl -s --max-time 10 http://127.0.0.1:8090/ | grep -o "<title>[^<]*" || echo "(静态托管未生效,需同步 dist)"
|
||||
else
|
||||
echo "BFF FAIL —— 日志尾部:"
|
||||
tail -15 /tmp/plantai-bff.log
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
# deploy/integration_check.sh —— M2 集成检查(PM 使用,在远端运行)
|
||||
# 用途:真实 BFF(8090)接真实上游后的端到端冒烟。
|
||||
# 前置:BFF 已在 8090 运行(deploy/start-dev.sh),远端可直接访问 127.0.0.1。
|
||||
set -uo pipefail
|
||||
BFF="http://127.0.0.1:8090"
|
||||
PASS=0; FAIL=0
|
||||
ok() { echo " ✅ $1"; PASS=$((PASS+1)); }
|
||||
bad() { echo " ❌ $1"; FAIL=$((FAIL+1)); }
|
||||
check() { # $1 名称 $2 实际输出 $3 期望子串
|
||||
if echo "$2" | grep -q "$3"; then ok "$1"; else bad "$1(输出: $(echo "$2" | head -c 200))"; fi
|
||||
}
|
||||
|
||||
echo "== 1. 健康 =="
|
||||
H=$(curl -s --max-time 10 "$BFF/api/health")
|
||||
check "health 返回 status" "$H" '"status"'
|
||||
check "qa 上游探测存在" "$H" '"qa"'
|
||||
check "neo4j 探测存在" "$H" '"neo4j"'
|
||||
|
||||
echo "== 2. QA =="
|
||||
QI=$(curl -s --max-time 10 "$BFF/api/qa/info")
|
||||
check "qa info 版本" "$QI" '1.1.73'
|
||||
|
||||
echo "== 3. 会话 =="
|
||||
S=$(curl -s -X POST "$BFF/api/sessions" -H 'Content-Type: application/json' -d '{"title":"集成冒烟"}')
|
||||
SID=$(echo "$S" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
|
||||
[ -n "$SID" ] && ok "创建会话 ($SID)" || bad "创建会话(输出: $S)"
|
||||
L=$(curl -s "$BFF/api/sessions")
|
||||
check "会话列表包含新会话" "$L" "$SID"
|
||||
|
||||
echo "== 4. 知识图谱(Neo4j 直连)=="
|
||||
KS=$(curl -s --max-time 10 "$BFF/api/kg/status")
|
||||
check "kg status schema" "$KS" 'canonical'
|
||||
E=$(curl -s --max-time 10 "$BFF/api/kg/entities?q=SPL&limit=5")
|
||||
check "实体搜索" "$E" '"results"'
|
||||
|
||||
echo "== 5. 抽取任务 =="
|
||||
J=$(curl -s --max-time 15 "$BFF/api/extraction/jobs")
|
||||
check "任务列表" "$J" '"jobs"'
|
||||
|
||||
echo "== 6. QA 检索(真实上游,约 90s,可选)=="
|
||||
if [ "${SKIP_QA:-0}" = "1" ]; then echo " ⏭ 跳过(SKIP_QA=1)"; else
|
||||
Q=$(curl -s --max-time 200 -X POST "$BFF/api/qa/search" -H 'Content-Type: application/json' \
|
||||
-d '{"question":"SPL gene family in rice","use_llm":false,"debug":false,"chain_top_k":3,"chain_hops":2,"chain_max_edges":12,"chain_max_nodes":10,"native_chain_enabled":true,"native_chain_max_hops":3,"native_chain_top_k":5}')
|
||||
check "search 返回 sources" "$Q" '"sources"'
|
||||
fi
|
||||
|
||||
[ -n "$SID" ] && curl -s -X DELETE "$BFF/api/sessions/$SID" > /dev/null && ok "清理冒烟会话" || true
|
||||
|
||||
echo
|
||||
echo "结果:$PASS 通过 / $FAIL 失败"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -0,0 +1,138 @@
|
||||
// deploy/m2_e2e.js —— M2 集成验收脚本(PM 使用,在远端运行)
|
||||
// 真实栈端到端:浏览器 → Vite dev(5180) → 真实 BFF(8090) → 真实上游(7920/8000/Neo4j)
|
||||
// 流程:新建会话 → 问答提问 → 流水线相位推进 → 答案揭示 → 来源 tab → 图谱 tab → 刷新恢复历史
|
||||
// 运行:cd ~/5090/teamWorkspace/zh/PlantAI-rag-new && node deploy/m2_e2e.js
|
||||
// 注意:QA 上游 LLM 当前 402 降级,答案内容断言只验「有内容」不断言自然语言质量。
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = 'http://127.0.0.1:5180';
|
||||
const QUESTION = '水稻SPL基因家族的功能研究有哪些关键进展?';
|
||||
const OUT_DIR = `${__dirname}/../artifacts/m2`;
|
||||
|
||||
const results = [];
|
||||
function check(name, ok, extra = '') {
|
||||
results.push({ name, ok });
|
||||
console.log(`${ok ? ' ✅' : ' ❌'} ${name}${extra ? ' —— ' + extra : ''}`);
|
||||
}
|
||||
|
||||
async function waitFor(fn, timeoutMs, intervalMs = 800, label = '') {
|
||||
const start = Date.now();
|
||||
let lastErr = null;
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const v = await fn();
|
||||
if (v) return v;
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
throw new Error(`等待超时(${timeoutMs}ms): ${label}${lastErr ? ' last=' + lastErr.message : ''}`);
|
||||
}
|
||||
|
||||
async function stepStates(page) {
|
||||
return page.$$eval('.pipeline-card .p-step', (els) =>
|
||||
els.map((el) => ({
|
||||
name: el.querySelector('.p-name')?.textContent?.trim() || '',
|
||||
state: el.className.includes('done') ? 'done' : el.className.includes('running') ? 'running' : 'waiting',
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const fs = require('fs');
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error') errors.push('console: ' + m.text());
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 加载
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await waitFor(() => page.locator('.app-shell').count(), 15000, 500, 'AppShell 渲染');
|
||||
check('页面加载:AppShell 渲染', true);
|
||||
await page.screenshot({ path: `${OUT_DIR}/01-loaded.png` });
|
||||
|
||||
// 2. 新建会话
|
||||
await page.click('.new-task-btn');
|
||||
await waitFor(() => page.locator('.composer textarea').count(), 15000, 500, '作曲器出现');
|
||||
check('新建会话:作曲器出现', true);
|
||||
|
||||
// 3. 提问(问答模式为默认)
|
||||
await page.fill('.composer textarea', QUESTION);
|
||||
await page.click('.send-btn');
|
||||
await waitFor(() => page.locator('.pipeline-card').count(), 20000, 500, '流水线卡出现');
|
||||
check('提问:流水线卡出现', true);
|
||||
|
||||
// 4. 相位推进(真实 SSE,QA 上游约 40s 完成)
|
||||
const statesSeen = [];
|
||||
const t0 = Date.now();
|
||||
let allDone = false;
|
||||
while (Date.now() - t0 < 240000) {
|
||||
const st = await stepStates(page);
|
||||
const key = JSON.stringify(st);
|
||||
if (!statesSeen.includes(key)) statesSeen.push(key);
|
||||
if (st.length && st.every((s) => s.state === 'done')) { allDone = true; break; }
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
}
|
||||
check('相位推进:观察到多次状态变化', statesSeen.length >= 3, `${statesSeen.length} 个不同状态快照`);
|
||||
check('相位推进:全部完成', allDone, `${Math.round((Date.now() - t0) / 1000)}s`);
|
||||
await page.screenshot({ path: `${OUT_DIR}/02-pipeline.png` });
|
||||
|
||||
// 5. 答案揭示(complete 后揭示动画,最长 8s 加速)
|
||||
await waitFor(async () => {
|
||||
const txt = await page.locator('.msg.assistant .msg-body').last().innerText().catch(() => '');
|
||||
return txt.length > 30;
|
||||
}, 60000, 1000, '助手答案出现');
|
||||
const answerText = await page.locator('.msg.assistant .msg-body').last().innerText();
|
||||
check('答案渲染:有内容', answerText.length > 30, `长度 ${answerText.length}`);
|
||||
check('答案渲染:含文献证据(PMID/DOI 或降级提示)', /PMID|doi|evidence|证据/i.test(answerText));
|
||||
await page.screenshot({ path: `${OUT_DIR}/03-answer.png` });
|
||||
|
||||
// 6. 文献来源 tab(真实 sources)
|
||||
await page.click('.bench-tab:has-text("文献来源")');
|
||||
await waitFor(async () => page.locator('.source-row').count(), 30000, 800, '来源条目出现');
|
||||
const srcCount = await page.locator('.source-row').count();
|
||||
check('文献来源:来源条目渲染', srcCount >= 1, `${srcCount} 条`);
|
||||
await page.screenshot({ path: `${OUT_DIR}/04-sources.png` });
|
||||
|
||||
// 7. 知识图谱 tab(答案图谱)
|
||||
await page.click('.bench-tab:has-text("知识图谱")');
|
||||
await waitFor(async () => {
|
||||
const n = await page.locator('.bench-body svg, .bench-body .kg-list, .bench-body .empty-note').count();
|
||||
return n > 0;
|
||||
}, 15000, 800, '图谱 tab 内容');
|
||||
check('知识图谱:tab 有内容(图/列表/空态之一)', true);
|
||||
await page.screenshot({ path: `${OUT_DIR}/05-kg.png` });
|
||||
|
||||
// 8. 刷新恢复(真实 BFF SQLite 持久化)
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await waitFor(() => page.locator('.session-item').count(), 15000, 500, '会话列表加载');
|
||||
check('刷新:会话列表恢复(SQLite)', true);
|
||||
await page.click('.session-item:has-text("水稻SPL")');
|
||||
await waitFor(async () => {
|
||||
const n = await page.locator('.msg.assistant').count();
|
||||
return n > 0;
|
||||
}, 20000, 800, '历史消息恢复');
|
||||
check('刷新:助手消息历史恢复', true);
|
||||
await page.screenshot({ path: `${OUT_DIR}/06-restored.png` });
|
||||
|
||||
// 9. 浏览器错误
|
||||
const realErrors = errors.filter((e) => !e.includes('favicon'));
|
||||
check('无页面级 JS 错误', realErrors.length === 0, realErrors.slice(0, 3).join(' | '));
|
||||
} catch (e) {
|
||||
check('流程异常中断', false, e.message);
|
||||
await page.screenshot({ path: `${OUT_DIR}/99-failure.png` }).catch(() => {});
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const passed = results.filter((r) => r.ok).length;
|
||||
console.log(`\nM2 验收结果:${passed}/${results.length} 通过`);
|
||||
process.exit(passed === results.length ? 0 : 1);
|
||||
})();
|
||||
@@ -0,0 +1,68 @@
|
||||
// deploy/m2_probe.js —— 诊断探针(PM 使用,在远端运行)
|
||||
// 目的:定位真实浏览器中 SSE progress 未推进流水线相位的原因
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
const logs = [];
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error' || m.type() === 'warning') logs.push(`[${m.type()}] ${m.text().slice(0, 200)}`);
|
||||
});
|
||||
page.on('pageerror', (e) => logs.push('[pageerror] ' + e.message.slice(0, 200)));
|
||||
|
||||
await page.goto('http://127.0.0.1:5180', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.app-shell', { timeout: 15000 });
|
||||
await page.click('.new-task-btn');
|
||||
await page.waitForSelector('.composer textarea', { timeout: 15000 });
|
||||
|
||||
// 用「检索」模式(use_llm:false,走 /api/qa/search,更快的真实链路)排除揭示动画干扰
|
||||
// 但 search 无 SSE——这里仍用默认问答走 SSE,只是探针只观察 60s
|
||||
await page.fill('.composer textarea', 'SPL gene family in rice');
|
||||
await page.click('.send-btn');
|
||||
|
||||
const t0 = Date.now();
|
||||
const timeline = [];
|
||||
let pipelineSeen = false;
|
||||
while (Date.now() - t0 < 90000) {
|
||||
const snap = await page.evaluate(() => {
|
||||
const card = document.querySelector('.pipeline-card');
|
||||
const steps = Array.from(document.querySelectorAll('.pipeline-card .p-step'));
|
||||
const msgs = Array.from(document.querySelectorAll('.msg.assistant'));
|
||||
const stream = document.querySelector('.stream-text')?.textContent?.length || 0;
|
||||
const body = document.querySelector('.msg.assistant .msg-body')?.textContent?.length || 0;
|
||||
// 检查正在进行的 fetch 到 stream 端点
|
||||
const perf = performance.getEntriesByType('resource').filter((r) => r.name.includes('chat/stream'));
|
||||
return {
|
||||
card: !!card,
|
||||
stepCount: steps.length,
|
||||
states: steps.map((s) => (s.className.includes('done') ? 'D' : s.className.includes('running') ? 'R' : 'W')).join(''),
|
||||
descs: steps.map((s) => s.querySelector('.p-desc')?.textContent?.slice(0, 20)).join('|'),
|
||||
msgCount: msgs.length,
|
||||
streamLen: stream,
|
||||
bodyLen: body,
|
||||
streamReqs: perf.length,
|
||||
};
|
||||
});
|
||||
const key = JSON.stringify(snap);
|
||||
if (!timeline.length || timeline[timeline.length - 1].key !== key) {
|
||||
timeline.push({ t: Math.round((Date.now() - t0) / 1000), key });
|
||||
console.log(`T+${Math.round((Date.now() - t0) / 1000)}s`, JSON.stringify(snap));
|
||||
}
|
||||
if (snap.card) pipelineSeen = true;
|
||||
if (snap.bodyLen > 30 && snap.streamLen === 0 && snap.states.includes('D')) break;
|
||||
if (snap.bodyLen > 50) break; // 答案已出现,看相位是否 done
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
|
||||
console.log('\n--- 浏览器日志(error/warning)---');
|
||||
logs.slice(0, 10).forEach((l) => console.log(l));
|
||||
console.log('\n--- 最终 DOM 摘要 ---');
|
||||
const fin = await page.evaluate(() => {
|
||||
const card = document.querySelector('.pipeline-card');
|
||||
return card ? card.outerHTML.slice(0, 1200) : '无 pipeline-card';
|
||||
});
|
||||
console.log(fin);
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -0,0 +1,82 @@
|
||||
// deploy/m2_probe2.js —— 精确诊断探针(PM 使用,在远端运行)
|
||||
// 观察完整流式生命周期:网络请求 + SSE 帧消费 + 流水线状态 + 答案块
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
const netLog = [];
|
||||
page.on('request', (r) => {
|
||||
if (r.url().includes('/api/')) netLog.push(`REQ ${r.method()} ${r.url()}`);
|
||||
});
|
||||
page.on('response', (r) => {
|
||||
if (r.url().includes('/api/')) netLog.push(`RES ${r.status()} ${r.url()}`);
|
||||
});
|
||||
const jsErrors = [];
|
||||
page.on('pageerror', (e) => jsErrors.push(e.message.slice(0, 300)));
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error') jsErrors.push('[console] ' + m.text().slice(0, 300));
|
||||
});
|
||||
|
||||
await page.goto('http://127.0.0.1:5180', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.app-shell', { timeout: 15000 });
|
||||
await page.click('.new-task-btn');
|
||||
await page.waitForSelector('.composer textarea', { timeout: 15000 });
|
||||
await page.fill('.composer textarea', 'SPL gene family in rice');
|
||||
await page.click('.send-btn');
|
||||
|
||||
const t0 = Date.now();
|
||||
let lastKey = '';
|
||||
let lastBlockCount = -1;
|
||||
while (Date.now() - t0 < 150000) {
|
||||
const snap = await page.evaluate(() => {
|
||||
const cards = Array.from(document.querySelectorAll('.pipeline-card'));
|
||||
const card = cards[0];
|
||||
const steps = card ? Array.from(card.querySelectorAll('.p-step')) : [];
|
||||
const msg = document.querySelector('.msg.assistant');
|
||||
// 只统计 blocks 区域,排除流水线卡文本
|
||||
const blocks = msg ? Array.from(msg.querySelectorAll('.msg-body > *')) : [];
|
||||
const nonPipelineText = blocks
|
||||
.filter((b) => !b.classList.contains('pipeline-card'))
|
||||
.map((b) => b.textContent || '')
|
||||
.join(' ');
|
||||
return {
|
||||
cards: cards.length,
|
||||
states: steps.map((s) => (s.className.includes('done') ? 'D' : s.className.includes('running') ? 'R' : 'W')).join(''),
|
||||
stepCount: steps.length,
|
||||
blockCount: blocks.length,
|
||||
blockTypes: blocks.map((b) => b.className || b.tagName).join(','),
|
||||
nonPipelineLen: nonPipelineText.length,
|
||||
streamCursor: !!document.querySelector('.stream-cursor'),
|
||||
};
|
||||
});
|
||||
const key = `${snap.cards}|${snap.states}|${snap.blockCount}|${snap.nonPipelineLen}|${snap.streamCursor}`;
|
||||
if (key !== lastKey) {
|
||||
lastKey = key;
|
||||
console.log(`T+${Math.round((Date.now() - t0) / 1000)}s`, JSON.stringify(snap));
|
||||
}
|
||||
if (snap.states === 'DDDDD' && !snap.streamCursor && snap.nonPipelineLen > 100) break;
|
||||
if (snap.states === 'DDDDD' && snap.blockCount > 1 && snap.nonPipelineLen > 100) break;
|
||||
if (snap.blockCount > 3 && snap.nonPipelineLen > 100 && !snap.streamCursor) break;
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
|
||||
console.log('\n--- 网络日志 ---');
|
||||
netLog.forEach((l) => console.log(l));
|
||||
console.log('\n--- JS 错误 ---');
|
||||
(jsErrors.length ? jsErrors : ['无']).forEach((l) => console.log(l.slice(0, 400)));
|
||||
|
||||
// 最终:检查 store 状态(Vue app 无法直接访问,检查 DOM 证据)
|
||||
const fin = await page.evaluate(() => {
|
||||
const msg = document.querySelector('.msg.assistant');
|
||||
const blocks = msg ? Array.from(msg.querySelectorAll('.msg-body > *')) : [];
|
||||
return {
|
||||
blockClasses: blocks.map((b) => (b.className || b.tagName).slice(0, 40)),
|
||||
text: (msg ? msg.textContent : '').slice(0, 400),
|
||||
};
|
||||
});
|
||||
console.log('\n--- 最终块结构 ---');
|
||||
console.log(JSON.stringify(fin, null, 2));
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -0,0 +1,50 @@
|
||||
// deploy/m2_probe3.js —— 浏览器 fetch chunk 级探针(PM 使用,在远端运行)
|
||||
// 目的:确定 progress 帧丢失发生在哪一层(Vite proxy 缓冲?fetch 读取?parser?)
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
// 在页面上下文里直接发起 fetch 并记录 chunk 到达时间线与内容
|
||||
const result = await page.evaluate(async () => {
|
||||
const t0 = Date.now();
|
||||
const log = [];
|
||||
const ctrl = new AbortController();
|
||||
const timeout = setTimeout(() => ctrl.abort(), 90000);
|
||||
try {
|
||||
const resp = await fetch('http://127.0.0.1:5180/api/qa/chat/stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
question: 'SPL gene family in rice',
|
||||
use_llm: true, debug: false,
|
||||
chain_top_k: 3, chain_hops: 2, chain_max_edges: 12, chain_max_nodes: 10,
|
||||
native_chain_enabled: true, native_chain_max_hops: 3, native_chain_top_k: 5,
|
||||
}),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
log.push(`T+${Math.round((Date.now() - t0) / 1000)}s headers: status=${resp.status} ct=${resp.headers.get('content-type')}`);
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let nChunks = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
nChunks++;
|
||||
const text = decoder.decode(value, { stream: true });
|
||||
const events = (text.match(/event: \w+/g) || []).join(',');
|
||||
log.push(`T+${Math.round((Date.now() - t0) / 1000)}s chunk#${nChunks} len=${value.length} [${events}] preview=${text.slice(0, 80).replace(/\n/g, '↵')}`);
|
||||
}
|
||||
log.push(`T+${Math.round((Date.now() - t0) / 1000)}s stream done, ${nChunks} chunks total`);
|
||||
} catch (e) {
|
||||
log.push(`ERROR: ${e.name}: ${e.message}`);
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
return log;
|
||||
});
|
||||
|
||||
result.forEach((l) => console.log(l));
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -0,0 +1,83 @@
|
||||
// deploy/m2_probe4.js —— Vue store 内部状态探针(PM 使用,在远端运行)
|
||||
// 通过 __vueParentComponent 链拿到 pinia,直读 chat store 的消息状态,
|
||||
// 区分「store 未更新」(handler 问题)与「store 已更新但 DOM 未渲染」(响应式问题)
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
async function dumpStore(page) {
|
||||
return page.evaluate(() => {
|
||||
const findRoot = (el) => {
|
||||
let node = el;
|
||||
while (node) {
|
||||
if (node.__vue_app__) return node.__vue_app__;
|
||||
node = node.__vueParentComponent?.parent || null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const rootEl = document.querySelector('#app') || document.querySelector('.app-shell');
|
||||
const app = rootEl ? findRoot(rootEl) : null;
|
||||
if (!app) return { err: '未找到 vue app' };
|
||||
const pinia = app.config.globalProperties.$pinia;
|
||||
if (!pinia) return { err: '未找到 pinia' };
|
||||
const chat = pinia._s.get('chat');
|
||||
if (!chat) return { err: '未找到 chat store' };
|
||||
const msgs = chat.messages.map((m) => ({
|
||||
role: m.role,
|
||||
status: m.status,
|
||||
id: m.id,
|
||||
blocks: (m.blocks || []).map((b) =>
|
||||
b.type === 'pipeline'
|
||||
? { type: 'pipeline', status: b.status, steps: b.steps.map((s) => `${s.phase}:${s.state}`) }
|
||||
: { type: b.type, len: b.type === 'text' ? (b.html || '').length : b.type === 'stream' ? (b.text || '').length : undefined },
|
||||
),
|
||||
}));
|
||||
return {
|
||||
busy: chat.busy,
|
||||
completedOnce: chat.completedOnce,
|
||||
currentEvents: chat.currentEvents.map((e) => e.stage),
|
||||
sessionId: chat.sessionId,
|
||||
msgs,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
await page.goto('http://127.0.0.1:5180', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.app-shell', { timeout: 15000 });
|
||||
await page.click('.new-task-btn');
|
||||
await page.waitForSelector('.composer textarea', { timeout: 15000 });
|
||||
await page.fill('.composer textarea', 'SPL gene family in rice');
|
||||
await page.click('.send-btn');
|
||||
|
||||
const t0 = Date.now();
|
||||
let last = '';
|
||||
while (Date.now() - t0 < 150000) {
|
||||
const snap = await dumpStore(page);
|
||||
const msgs = snap.msgs || [];
|
||||
const lastAssist = msgs.filter((m) => m.role === 'assistant').pop();
|
||||
const pipe = lastAssist?.blocks?.find((b) => b.type === 'pipeline');
|
||||
const key = JSON.stringify({ busy: snap.busy, done: snap.completedOnce, steps: pipe?.steps, nEv: snap.currentEvents?.length });
|
||||
if (key !== last) {
|
||||
last = key;
|
||||
const el = Math.round((Date.now() - t0) / 1000);
|
||||
console.log(`T+${el}s busy=${snap.busy} completedOnce=${snap.completedOnce} events=${snap.currentEvents?.length} 步骤=${JSON.stringify(pipe?.steps)}`);
|
||||
console.log(` 事件序列: ${(snap.currentEvents || []).slice(-5).join(', ')}`);
|
||||
}
|
||||
if (!snap.busy && snap.completedOnce && Date.now() - t0 > 30000) break;
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
|
||||
console.log('\n=== 最终 store 状态 ===');
|
||||
console.log(JSON.stringify(await dumpStore(page), null, 2));
|
||||
|
||||
console.log('\n=== 最终 DOM 流水线 ===');
|
||||
const dom = await page.evaluate(() => {
|
||||
const card = document.querySelector('.pipeline-card');
|
||||
if (!card) return '无卡片';
|
||||
return card.textContent.slice(0, 300);
|
||||
});
|
||||
console.log(dom);
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -0,0 +1,75 @@
|
||||
// deploy/m2_probe5.js —— PipelineCard props 与渲染链探针(PM 使用,在远端运行)
|
||||
// 读组件实例 props(vnode.props.block)并比较与 pinia store 的引用/状态。
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
await page.goto('http://127.0.0.1:5180', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.app-shell', { timeout: 15000 });
|
||||
await page.click('.new-task-btn');
|
||||
await page.waitForSelector('.composer textarea', { timeout: 15000 });
|
||||
await page.fill('.composer textarea', 'SPL gene family in rice');
|
||||
await page.click('.send-btn');
|
||||
|
||||
const t0 = Date.now();
|
||||
let last = '';
|
||||
while (Date.now() - t0 < 150000) {
|
||||
const snap = await page.evaluate(() => {
|
||||
const cardEl = document.querySelector('.pipeline-card');
|
||||
if (!cardEl) return { noCard: true };
|
||||
// 沿 vnode 链找组件实例
|
||||
let vnode = cardEl.__vueParentComponent;
|
||||
const found = [];
|
||||
for (let i = 0; i < 6 && vnode; i++) {
|
||||
const p = vnode.props;
|
||||
if (p && p.block) {
|
||||
const b = p.block;
|
||||
found.push({
|
||||
comp: vnode.type?.name || vnode.type?.__name || 'anon',
|
||||
blockStatus: b.status,
|
||||
steps: (b.steps || []).map((s) => `${s.phase}:${s.state}`).join(','),
|
||||
isReactiveProxy: !!b.__v_raw || typeof b.status !== 'string' || false,
|
||||
});
|
||||
// 读原始 store 对象比较(raw)
|
||||
const raw = b.__v_raw || b;
|
||||
found[found.length - 1].rawStatus = raw.status;
|
||||
found[found.length - 1].rawSteps = (raw.steps || []).map((s) => s.state).join(',');
|
||||
break;
|
||||
}
|
||||
vnode = vnode.parent;
|
||||
}
|
||||
// pinia 直接读
|
||||
let storeBlock = null;
|
||||
const rootEl = document.querySelector('#app') || document.querySelector('.app-shell');
|
||||
let n = rootEl;
|
||||
let app = null;
|
||||
while (n && !app) { if (n.__vue_app__) app = n.__vue_app__; n = n.__vueParentComponent?.parent || null; }
|
||||
if (app) {
|
||||
const pinia = app.config.globalProperties.$pinia;
|
||||
const chat = pinia._s.get('chat');
|
||||
const aMsg = [...chat.messages].reverse().find((m) => m.role === 'assistant');
|
||||
const sb = aMsg?.blocks?.[0];
|
||||
if (sb) storeBlock = { status: sb.status, steps: sb.steps.map((s) => s.state).join(',') };
|
||||
}
|
||||
return { found, storeBlock, domText: cardEl.textContent.slice(0, 60) };
|
||||
});
|
||||
const key = JSON.stringify(snap);
|
||||
if (key !== last) {
|
||||
last = key;
|
||||
console.log(`T+${Math.round((Date.now() - t0) / 1000)}s`, JSON.stringify(snap, null, 1));
|
||||
}
|
||||
// 流结束后看最终状态
|
||||
const doneNow = await page.evaluate(() => {
|
||||
const rootEl = document.querySelector('#app');
|
||||
let n = rootEl; let app = null;
|
||||
while (n && !app) { if (n.__vue_app__) app = n.__vue_app__; n = n.__vueParentComponent?.parent || null; }
|
||||
if (!app) return false;
|
||||
return !app.config.globalProperties.$pinia._s.get('chat').busy;
|
||||
});
|
||||
if (doneNow && Date.now() - t0 > 30000) break;
|
||||
await new Promise((r) => setTimeout(r, 2500));
|
||||
}
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -0,0 +1,67 @@
|
||||
// deploy/m2_probe6.js —— 卡片枚举探针(PM 使用,在远端运行)
|
||||
// 枚举文档中全部 .pipeline-card:props 状态 / textContent / 可见性 / 所属容器 / 挂载状态
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
await page.goto('http://127.0.0.1:5180', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.app-shell', { timeout: 15000 });
|
||||
await page.click('.new-task-btn');
|
||||
await page.waitForSelector('.composer textarea', { timeout: 15000 });
|
||||
await page.fill('.composer textarea', 'SPL gene family in rice');
|
||||
await page.click('.send-btn');
|
||||
|
||||
// 等流结束
|
||||
await page.waitForFunction(() => {
|
||||
const rootEl = document.querySelector('#app');
|
||||
let n = rootEl; let app = null;
|
||||
while (n && !app) { if (n.__vue_app__) app = n.__vue_app__; n = n.__vueParentComponent?.parent || null; }
|
||||
if (!app) return false;
|
||||
const chat = app.config.globalProperties.$pinia._s.get('chat');
|
||||
return chat && !chat.busy && chat.completedOnce;
|
||||
}, null, { timeout: 180000 });
|
||||
await new Promise((r) => setTimeout(r, 3000)); // 揭示动画收尾
|
||||
|
||||
const report = await page.evaluate(() => {
|
||||
const cards = Array.from(document.querySelectorAll('.pipeline-card'));
|
||||
return cards.map((el, i) => {
|
||||
let vnode = el.__vueParentComponent;
|
||||
let props = null, compName = null;
|
||||
for (let d = 0; d < 6 && vnode; d++) {
|
||||
if (vnode.props && vnode.props.block) {
|
||||
props = { status: vnode.props.block.status, steps: vnode.props.block.steps.map((s) => `${s.phase}:${s.state}`).join(',') };
|
||||
compName = vnode.type?.name || vnode.type?.__name || 'anon';
|
||||
break;
|
||||
}
|
||||
vnode = vnode.parent;
|
||||
}
|
||||
const msgEl = el.closest('.msg');
|
||||
const chatScroll = el.closest('.chat-scroll');
|
||||
const style = getComputedStyle(el);
|
||||
const inDrawer = !!el.closest('.drawer');
|
||||
const inOverlay = !!el.closest('.bench-overlay');
|
||||
// 每个元素与文档根的连接性 + 是否在视口
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
i,
|
||||
props,
|
||||
compName,
|
||||
text: el.textContent.slice(0, 70),
|
||||
display: style.display,
|
||||
visibility: style.visibility,
|
||||
opacity: style.opacity,
|
||||
rect: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) },
|
||||
inMsg: !!msgEl,
|
||||
msgText: msgEl ? msgEl.textContent.slice(0, 30) : null,
|
||||
inChatScroll: !!chatScroll,
|
||||
inDrawer,
|
||||
inOverlay,
|
||||
isConnected: el.isConnected,
|
||||
};
|
||||
});
|
||||
});
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -0,0 +1,146 @@
|
||||
// deploy/m3_e2e.js —— M3 综合验收(PM 使用,在远端运行)
|
||||
// 覆盖:KG 自由浏览(真实 Neo4j)、抽取任务 tab(真实 8000)、三档视口布局、主题切换
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = 'http://127.0.0.1:5180';
|
||||
const OUT_DIR = `${__dirname}/../artifacts/m3`;
|
||||
const fs = require('fs');
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
const results = [];
|
||||
function check(name, ok, extra = '') {
|
||||
results.push({ name, ok });
|
||||
console.log(`${ok ? ' ✅' : ' ❌'} ${name}${extra ? ' —— ' + extra : ''}`);
|
||||
}
|
||||
|
||||
async function waitFor(fn, timeoutMs, intervalMs = 700, label = '') {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const v = await fn();
|
||||
if (v) return v;
|
||||
} catch {}
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
throw new Error(`等待超时(${timeoutMs}ms): ${label}`);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
|
||||
// ============ 桌面档:KG 浏览 + 抽取 ============
|
||||
{
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await waitFor(() => page.locator('.app-shell').count(), 15000, 500, 'AppShell');
|
||||
|
||||
// KG tab:实体搜索 + 邻域
|
||||
await page.click('.bench-tab:has-text("知识图谱")');
|
||||
await waitFor(() => page.locator('.bench-body input[type="text"], .bench-body input').count(), 10000, 500, 'KG 搜索框');
|
||||
await page.fill('.bench-body input', 'OsSPL14');
|
||||
await page.press('.bench-body input', 'Enter');
|
||||
await waitFor(async () => {
|
||||
const n = await page.locator('.bench-body .kg-result, .bench-body [class*="result"], .bench-body [class*="entity"]').count();
|
||||
return n > 0;
|
||||
}, 20000, 800, '实体搜索结果');
|
||||
check('KG:实体搜索有结果(真实 Neo4j fulltext 替代)', true);
|
||||
await page.screenshot({ path: `${OUT_DIR}/01-kg-search.png` });
|
||||
|
||||
// 邻域渲染(点击第一个结果或通过已选中实体)
|
||||
const clicked = await page.evaluate(async () => {
|
||||
const items = document.querySelectorAll('.bench-body [class*="result"] button, .bench-body [class*="entity"] button, .bench-body [class*="result"] [class*="item"]');
|
||||
if (items[0]) { items[0].click(); return true; }
|
||||
return false;
|
||||
});
|
||||
await waitFor(async () => {
|
||||
const svg = await page.locator('.bench-body svg').count();
|
||||
const nodes = await page.locator('.bench-body svg circle, .bench-body svg [class*="node"]').count();
|
||||
return svg > 0 && nodes > 0;
|
||||
}, 30000, 800, 'KG 图谱渲染');
|
||||
check('KG:邻域图谱渲染(节点+边)', true);
|
||||
await page.screenshot({ path: `${OUT_DIR}/02-kg-graph.png` });
|
||||
|
||||
// 抽取任务 tab
|
||||
await page.click('.bench-tab:has-text("提取任务")');
|
||||
await waitFor(async () => {
|
||||
const n = await page.locator('.bench-body [class*="job"], .bench-body [class*="prog"]').count();
|
||||
return n > 0;
|
||||
}, 20000, 800, '抽取任务列表');
|
||||
check('抽取:真实任务列表渲染', true);
|
||||
const jobText = await page.locator('.bench-body').textContent();
|
||||
check('抽取:含真实任务名或状态', /MinerU|completed|failed|running|queued|wang|Pecan/i.test(jobText));
|
||||
await page.screenshot({ path: `${OUT_DIR}/03-extraction.png` });
|
||||
|
||||
check('桌面档无 JS 错误', errors.length === 0, errors.slice(0, 2).join('|'));
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// ============ 平板档:抽屉布局 ============
|
||||
{
|
||||
const ctx = await browser.newContext({ viewport: { width: 768, height: 1024 } });
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await waitFor(() => page.locator('.app-shell.drawers-mode').count(), 15000, 500, 'drawers-mode');
|
||||
check('平板:drawers-mode 生效(单栏)', true);
|
||||
// 汉堡开左抽屉
|
||||
const hamburger = await page.locator('.hamburger-btn').count();
|
||||
check('平板:顶栏汉堡按钮出现', hamburger > 0);
|
||||
if (hamburger > 0) {
|
||||
await page.click('.hamburger-btn');
|
||||
await waitFor(() => page.locator('.drawer').count(), 5000, 400, '左抽屉');
|
||||
const drawerVisible = await page.locator('.drawer').first().isVisible();
|
||||
check('平板:左抽屉打开', drawerVisible);
|
||||
await page.screenshot({ path: `${OUT_DIR}/04-tablet-drawer.png` });
|
||||
// Esc 关闭(抽屉仍挂载但移出屏幕——断言 open class 而非 isHidden)
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(500);
|
||||
const openClass = await page.locator('.drawer.left').first().evaluate((el) => el.classList.contains('open')).catch(() => false);
|
||||
check('平板:Esc 关闭抽屉', !openClass);
|
||||
}
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// ============ 手机档:全屏覆盖层 + 主题 ============
|
||||
{
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } });
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await waitFor(() => page.locator('.app-shell.mobile').count(), 15000, 500, 'mobile 档');
|
||||
check('手机:mobile 档生效', true);
|
||||
const sb = await page.locator('.status-bar').isHidden().catch(() => true);
|
||||
check('手机:底栏隐藏(FR-RESP-004)', sb);
|
||||
const benchBtn = await page.locator('.bench-btn').count();
|
||||
check('手机:工作台按钮出现', benchBtn > 0);
|
||||
if (benchBtn > 0) {
|
||||
await page.click('.bench-btn');
|
||||
await waitFor(() => page.locator('.bench-overlay').count(), 5000, 400, '工作台覆盖层');
|
||||
check('手机:工作台全屏覆盖层打开', true);
|
||||
await page.screenshot({ path: `${OUT_DIR}/05-mobile-overlay.png` });
|
||||
// tab 切换(PM 修复的接缝)。注意覆盖层内有两套 tab(外壳 tab 条 + BenchPanel 内部 tab),
|
||||
// 用外壳 tab 条(.bench-overlay-tabs 内)点击,用最后匹配的 on 断言
|
||||
await page.click('.bench-overlay-tabs .bench-tab:has-text("文献来源")');
|
||||
const activeTab = await page.locator('.bench-overlay-tabs .bench-tab.on').first().textContent();
|
||||
check('手机:覆盖层 tab 切换(PM 接线修复)', activeTab.includes('文献来源'), activeTab.trim());
|
||||
await page.click('.bench-overlay .bench-close-btn, .bench-overlay [class*="close"]').catch(async () => {
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
}
|
||||
// 主题切换(浅色)
|
||||
await page.click('.top-bar .icon-btn:last-child');
|
||||
await page.waitForTimeout(300);
|
||||
const theme = await page.evaluate(() => document.documentElement.dataset.theme);
|
||||
check('主题:切换为浅色(dataset.theme)', theme === 'light');
|
||||
const canvasBg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor);
|
||||
check('主题:浅色 canvas 生效', canvasBg === 'rgb(246, 248, 252)', canvasBg);
|
||||
await page.screenshot({ path: `${OUT_DIR}/06-mobile-light.png` });
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
const passed = results.filter((r) => r.ok).length;
|
||||
console.log(`\nM3 验收结果:${passed}/${results.length} 通过`);
|
||||
process.exit(passed === results.length ? 0 : 1);
|
||||
})();
|
||||
@@ -0,0 +1,138 @@
|
||||
// deploy/m5_acceptance.js —— M5 PM 最终验收脚本(在远端运行)
|
||||
// 与 M2/M3 验收互补:本轮跑「生产形态静态托管」+「加固后的 Vite」双链路,
|
||||
// 并在三档视口执行 PRD 验收脚本的核心交互(PM 人工走查的自动化部分)。
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const results = [];
|
||||
function check(name, ok, extra = '') {
|
||||
results.push({ name, ok });
|
||||
console.log(`${ok ? ' ✅' : ' ❌'} ${name}${extra ? ' —— ' + extra : ''}`);
|
||||
}
|
||||
async function waitFor(fn, ms, iv = 700, label = '') {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < ms) {
|
||||
try { const v = await fn(); if (v) return v; } catch {}
|
||||
await new Promise((r) => setTimeout(r, iv));
|
||||
}
|
||||
throw new Error(`等待超时(${ms}ms): ${label}`);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
|
||||
// ============ 0. 生产形态静态托管(BFF 8090 直接服务 dist)============
|
||||
{
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
// BFF 静态托管需 frontend/dist 存在且 BFF 重启——先探测是否已挂载
|
||||
const prod = await page.goto('http://127.0.0.1:8090/', { waitUntil: 'domcontentloaded' }).then(() => true).catch(() => false);
|
||||
if (prod) {
|
||||
await waitFor(() => page.locator('.app-shell').count(), 15000, 500, '生产形态 AppShell');
|
||||
check('生产形态:BFF 静态托管渲染 AppShell', true);
|
||||
const health = await page.evaluate(async () => (await fetch('/api/health')).status);
|
||||
check('生产形态:同源 /api/health 可达', health === 200);
|
||||
} else {
|
||||
// dist 未同步或 BFF 未带静态挂载重启——记失败但继续
|
||||
check('生产形态:BFF 静态托管', false, '8090 根路径不可达(dist 未部署?)');
|
||||
}
|
||||
check('生产形态无 JS 错误', errors.length === 0, errors.slice(0, 2).join('|'));
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// ============ 1. Desktop 验收脚本(PRD 核心交互)============
|
||||
{
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
await page.goto('http://127.0.0.1:5180', { waitUntil: 'domcontentloaded' });
|
||||
await waitFor(() => page.locator('.app-shell').count(), 15000, 500, 'AppShell');
|
||||
|
||||
// 会话列表来自真实 SQLite
|
||||
const sessCount = await page.locator('.session-item').count();
|
||||
check('桌面:会话列表从真实 SQLite 加载', sessCount >= 1, `${sessCount} 条`);
|
||||
|
||||
// 打开一个有 done 状态的会话验证历史恢复(含 markdown 渲染)
|
||||
const doneItem = page.locator('.session-item').filter({ has: page.locator('.status-dot.done') }).first();
|
||||
if (await doneItem.count()) {
|
||||
await doneItem.click();
|
||||
await waitFor(() => page.locator('.msg.assistant').count(), 15000, 500, '历史消息');
|
||||
const hasMd = await page.locator('.msg.assistant .md-text strong, .msg.assistant .md-text li, .msg.assistant .md-text h1, .msg.assistant .md-text h2').count();
|
||||
const hasPlain = await page.locator('.msg.assistant .md-text').count();
|
||||
check('桌面:历史答案渲染(markdown 块存在)', hasPlain > 0, `md-text ${hasPlain} / 加粗列表 ${hasMd}`);
|
||||
// 相位卡恢复为 done
|
||||
const steps = await page.locator('.pipeline-card .p-step.done').count();
|
||||
check('桌面:历史相位卡恢复 done', steps >= 4, `${steps}/5`);
|
||||
}
|
||||
|
||||
// 引用 chip 高亮链(FR-CHAT-015 补全的验收)
|
||||
const chip = page.locator('.msg.assistant .inline-sources .src-chip').first();
|
||||
if (await chip.count()) {
|
||||
await chip.click();
|
||||
await waitFor(() => page.locator('.bench-tab.on').filter({ hasText: '文献来源' }).count(), 5000, 400, '来源 tab 切换');
|
||||
await waitFor(() => page.locator('.source-row.hl-active').count(), 5000, 400, '来源高亮');
|
||||
check('桌面:引用 chip → 来源 tab 高亮定位', true);
|
||||
} else {
|
||||
check('桌面:引用 chip → 来源 tab 高亮定位', false, '当前会话无引用 chip(可接受,需人工复核一次问答)');
|
||||
}
|
||||
|
||||
check('桌面验收无 JS 错误', errors.length === 0, errors.slice(0, 3).join('|'));
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// ============ 2. 完整真实问答(桌面,加固后链路)============
|
||||
{
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
await page.goto('http://127.0.0.1:5180', { waitUntil: 'domcontentloaded' });
|
||||
await waitFor(() => page.locator('.app-shell').count(), 15000, 500, 'AppShell');
|
||||
await page.click('.new-task-btn');
|
||||
await waitFor(() => page.locator('.composer textarea').count(), 15000, 500, '作曲器');
|
||||
await page.fill('.composer textarea', '拟南芥HD-ZIP转录因子的生物学功能?');
|
||||
await page.click('.send-btn');
|
||||
// 等流结束(真实 QA 最长 92s,终态等待上限 360s 已加固)
|
||||
await waitFor(async () => {
|
||||
const ok = await page.evaluate(() => {
|
||||
const rootEl = document.querySelector('#app');
|
||||
let n = rootEl; let app = null;
|
||||
while (n && !app) { if (n.__vue_app__) app = n.__vue_app__; n = n.__vueParentComponent?.parent || null; }
|
||||
if (!app) return false;
|
||||
const chat = app.config.globalProperties.$pinia._s.get('chat');
|
||||
return chat && !chat.busy && chat.completedOnce;
|
||||
});
|
||||
return ok;
|
||||
}, 360000, 1500, '真实问答流结束');
|
||||
await waitFor(async () => {
|
||||
const n = await page.locator('.msg.assistant .md-text').count();
|
||||
return n > 0;
|
||||
}, 30000, 800, '最终答案出现');
|
||||
const answerLen = (await page.locator('.msg.assistant .md-text').last().textContent()).length;
|
||||
check('真实问答:五相位终态 + 答案渲染', answerLen > 20, `答案 ${answerLen} 字`);
|
||||
const allDone = (await page.locator('.pipeline-card .p-step.done').count()) >= 4;
|
||||
check('真实问答:相位卡全部 done', allDone);
|
||||
const srcChip = await page.locator('.msg.assistant .inline-sources .src-chip').count();
|
||||
check('真实问答:内联引用 chip 出现', srcChip > 0, `${srcChip} 个`);
|
||||
check('真实问答无 JS 错误', errors.length === 0, errors.slice(0, 3).join('|'));
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// ============ 3. 三档视口布局终检 ============
|
||||
for (const [name, vp] of [['mobile', { width: 390, height: 844 }], ['tablet', { width: 768, height: 1024 }], ['desktop', { width: 1440, height: 900 }]]) {
|
||||
const ctx = await browser.newContext({ viewport: vp });
|
||||
const page = await ctx.newPage();
|
||||
await page.goto('http://127.0.0.1:5180', { waitUntil: 'domcontentloaded' });
|
||||
await waitFor(() => page.locator('.app-shell').count(), 15000, 500, `${name} AppShell`);
|
||||
const tier = await page.evaluate(() => document.querySelector('.app-shell').className);
|
||||
check(`${name}:档位 class 正确`, tier.includes(name === 'desktop' ? 'app-shell' : name === 'tablet' ? 'drawers-mode' : 'mobile'), tier.slice(0, 60));
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
const passed = results.filter((r) => r.ok).length;
|
||||
console.log(`\nM5 验收结果:${passed}/${results.length} 通过`);
|
||||
process.exit(passed === results.length ? 0 : 1);
|
||||
})();
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
# deploy/start-dev.sh —— 远端启动 BFF + vite dev(在远端 tmux 中运行)
|
||||
set -euo pipefail
|
||||
ROOT="/home/xzq/5090/teamWorkspace/zh/PlantAI-rag-new"
|
||||
echo "== 启动 BFF (127.0.0.1:8090) =="
|
||||
(cd "$ROOT/backend" && source ~/miniconda3/etc/profile.d/conda.sh && conda activate plantai-bff && \
|
||||
nohup uvicorn app.main:app --host 127.0.0.1 --port 8090 > /tmp/plantai-bff.log 2>&1 &)
|
||||
echo "== 启动 Vite dev (127.0.0.1:5180) =="
|
||||
(cd "$ROOT/frontend" && nohup npm run dev > /tmp/plantai-vite.log 2>&1 &)
|
||||
sleep 3
|
||||
curl -s http://127.0.0.1:8090/api/health > /dev/null && echo "BFF OK" || echo "BFF 未就绪,见 /tmp/plantai-bff.log"
|
||||
curl -s http://127.0.0.1:5180 > /dev/null && echo "Vite OK" || echo "Vite 未就绪,见 /tmp/plantai-vite.log"
|
||||
echo
|
||||
echo "== Mac 侧访问隧道 =="
|
||||
echo "ssh -N -L 5180:127.0.0.1:5180 -L 8090:127.0.0.1:8090 100.109.52.26"
|
||||
echo "然后浏览器打开 http://localhost:5180"
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
# deploy/sync.sh —— 本地 → 远端 rsync(PM 独占执行,agent 不自行运行)
|
||||
set -euo pipefail
|
||||
REMOTE_HOST="100.109.52.26"
|
||||
REMOTE_DIR="/home/xzq/5090/teamWorkspace/zh/PlantAI-rag-new"
|
||||
LOCAL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
rsync -az --delete \
|
||||
--exclude 'node_modules' --exclude '__pycache__' --exclude '.venv' \
|
||||
--exclude '.venv-backend' \
|
||||
--exclude 'backend/data' --exclude 'frontend/dist' --exclude '.pytest_cache' \
|
||||
--exclude '.env' \
|
||||
--exclude '.git' \
|
||||
"$LOCAL_DIR/" "$REMOTE_HOST:$REMOTE_DIR/" 2>&1 | grep -v "remote port forwarding" || true
|
||||
echo "SYNC OK → $REMOTE_HOST:$REMOTE_DIR"
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
# deploy/vite_restart.sh —— 远端 Vite dev 重启(PM 使用,在远端执行)
|
||||
# 用法:ssh 100.109.52.26 'bash ~/5090/teamWorkspace/zh/PlantAI-rag-new/deploy/vite_restart.sh [8099|8090]'
|
||||
# 参数:可选 VITE_BFF_TARGET(默认 8090 = 真实 BFF;传 mock 用 8099)
|
||||
# 要点:与 bff_restart.sh 同模式——setsid 脱离 SSH、[v]ite 正则防自匹配、日志落盘。
|
||||
set -uo pipefail
|
||||
ROOT="/home/xzq/5090/teamWorkspace/zh/PlantAI-rag-new"
|
||||
TARGET="${1:-8090}"
|
||||
|
||||
# 杀旧进程。注意:本脚本名含 "vite"(vite_restart.sh),pkill "[v]ite" 会自匹配杀掉本脚本!
|
||||
# 用 vite 真实二进制路径做模式(脚本命令行不含 node_modules/.bin)。
|
||||
pkill -f "node_modules/.bin/[v]ite" 2>/dev/null
|
||||
sleep 2
|
||||
|
||||
cd "$ROOT/frontend"
|
||||
VITE_BFF_TARGET="http://127.0.0.1:${TARGET}" \
|
||||
setsid npm run dev > /tmp/plantai-vite.log 2>&1 < /dev/null &
|
||||
disown
|
||||
|
||||
sleep 6
|
||||
if ss -tln | grep -q ":5180 "; then
|
||||
echo "VITE UP (127.0.0.1:5180 → ${TARGET})"
|
||||
else
|
||||
echo "VITE FAIL —— 日志尾部:"
|
||||
tail -15 /tmp/plantai-vite.log
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,52 @@
|
||||
# ACCEPTANCE.md —— M5 最终验收记录
|
||||
|
||||
> 验收人:PM | 日期:2026-08-25 | 结论:**通过**
|
||||
|
||||
## 1. 验收范围与结果
|
||||
|
||||
| 验收项 | 结果 | 证据 |
|
||||
|---|---|---|
|
||||
| 生产形态(BFF 静态托管单进程) | ✅ | `:8090/` 渲染 AppShell、同源 `/api/health` 200、无 JS 错误 |
|
||||
| 桌面:真实 SQLite 会话列表 | ✅ | 7 条会话(T 已清理测试污染数据) |
|
||||
| 桌面:历史恢复(markdown/相位卡/引用) | ✅ | 相位卡 5/5 done 恢复、md-text 渲染、引用 chip → 来源 tab 高亮定位 |
|
||||
| 真实问答全链路 | ✅ | 五相位终态全 done、答案 698 字、内联引用 chip 4 个、无 JS 错误 |
|
||||
| 三档视口档位 | ✅ | mobile(drawers-mode+mobile+has-tools)/ tablet(drawers-mode)/ desktop 全正确 |
|
||||
| 自动化验收脚本 | ✅ | `deploy/m5_acceptance.js` **15/15 通过** |
|
||||
|
||||
**历史验收基线**(前序里程碑,全部通过):
|
||||
- M2 `deploy/m2_e2e.js` 12/12(真实端到端 + 刷新恢复 + 相位推进)
|
||||
- M3 `deploy/m3_e2e.js` 16/16(真实 KG 浏览 + 抽取 + 三档交互 + 主题)
|
||||
- M4 R2 全量:后端 121/121(行覆盖 87%)、前端 156/156(80.59%)、Playwright 48 条(35 pass/12 skip 依赖充值/1 环境性 flake 复跑通过)、上游 smoke 7/7、**FR 追溯 81/81 双向零失配**
|
||||
|
||||
## 2. 测试门禁终态
|
||||
|
||||
| 门禁 | 要求 | 实际 | 判定 |
|
||||
|---|---|---|---|
|
||||
| FR→TC 追溯矩阵 | 81/81 双向完整 | 81/81 零失配 | ✅ |
|
||||
| 后端行覆盖 | ≥85% | 87% | ✅ |
|
||||
| 前端行覆盖 | ≥80% | 80.59% | ✅ |
|
||||
| 产品缺陷 | 0 未修复 | R1 的 8 项全部修复并在 R2 确认 | ✅ |
|
||||
|
||||
## 3. 已知限制(验收时明确,不视为缺陷)
|
||||
|
||||
1. ~~DeepSeek LLM 余额用尽(402)~~ **已于 2026-08-25 解决**:换用 PlantAI-dev 的 NEW_API_KEY(余额充足),QA 服务经官方脚本 `start_plantAI_RAG.sh` 重启后自然语言答案完全恢复(实测:PlantAI-rag-new 答案 1379 字 + 15 来源;PlantAI-dev 答案 2671 字 + 14 来源,超时配置提至 600s)。详见 CHANGELOG「DeepSeek 恢复与 QA 修复」。
|
||||
2. 上游 QA 无 token 级流式:打字机为客户端揭示动画(设计内既定)。
|
||||
3. 每次提问独立检索(`max_rounds:1`),无多轮上下文(设计内既定)。
|
||||
4. 移动端覆盖层内 BenchPanel 自带 tab 已 CSS 隐藏,以外壳 tab 条为准(已验收)。
|
||||
5. Laptop 档(1024–1279px)无独立 Playwright project(T 遗留建议,不影响验收;该档行为已由 CSS + AppShell 逻辑覆盖并经人工走查)。
|
||||
|
||||
## 4. 验收执行记录
|
||||
|
||||
- 验收脚本:`deploy/m5_acceptance.js`(15 断言,chromium headless)
|
||||
- 运行位置:远端(真实 BFF 8090 生产形态 + Vite 5180 开发形态双链路)
|
||||
- 用时:约 3 分钟(含一次真实 QA ≈ 50s)
|
||||
- 期间修复:BFF 静态托管 dist 路径解析(`backend/frontend/dist` → 项目根解析,2026-08-25 PM)
|
||||
|
||||
## 5. 结论
|
||||
|
||||
**PlantAI-rag-new 通过最终验收。** 所有需求功能点(PRD 81 FR)有测试覆盖且通过;生产形态可部署;三档视口可用;外部依赖(DeepSeek 余额)恢复后无需任何代码改动即可获得完整体验。
|
||||
|
||||
下一步建议(用户决策,不在本次范围):
|
||||
- DeepSeek 充值 → 验证自然语言答案质量
|
||||
- 长期运行监控(/api/health 轮询、上游探活)
|
||||
- 可选:laptop 档 E2E project 补齐、tests/backend L2 respx 独立测试
|
||||
+418
@@ -0,0 +1,418 @@
|
||||
# API.md —— BFF 接口契约
|
||||
|
||||
> 版本 1.0 | 作者 PM | 本文件由 PM 独占写入,所有开发/测试 agent **只读**
|
||||
>
|
||||
> BFF 是前端唯一的数据来源。前端**禁止**直连任何上游。
|
||||
> 上游真实响应结构以 `DISCOVERY.md` 为准;本文件定义 BFF 暴露给前端的契约。
|
||||
> 契约变更必须由 PM 在本文件修改并广播,任何人不得私自改接口形态。
|
||||
|
||||
---
|
||||
|
||||
## 1. 通用约定
|
||||
|
||||
- Base:`/api`
|
||||
- 编码:UTF-8,JSON;时间一律 ISO 8601 UTC(`2026-08-25T12:34:56Z`)
|
||||
- ID:UUID4 字符串
|
||||
- **统一错误信封**(所有非 2xx 与降级响应):
|
||||
|
||||
```json
|
||||
{ "ok": false, "error": { "code": "UPSTREAM_DOWN", "message": "知识问答服务当前不可用,请稍后重试", "degraded": true } }
|
||||
```
|
||||
|
||||
| code | HTTP | 含义 | 前端行为 |
|
||||
|---|---|---|---|
|
||||
| `UPSTREAM_DOWN` | 503 | 依赖的上游不可用 | 降级提示(禁用对应功能),不弹窗 |
|
||||
| `UPSTREAM_ERROR` | 502 | 上游返回了错误 | 显示 message + 重试按钮 |
|
||||
| `TIMEOUT` | 504 | 上游超时 | 显示「检索超时」+ 重试按钮 |
|
||||
| `INVALID_CITATION` | 502 | 引用校验失败(已自动重试一次仍失败) | 显示「引用校验失败」+ 重试按钮 |
|
||||
| `NOT_FOUND` | 404 | 会话/消息不存在 | 回列表 |
|
||||
| `VALIDATION` | 422 | 请求参数不合法 | 展示 message |
|
||||
| `INTERNAL` | 500 | BFF 自身错误 | 显示 message |
|
||||
|
||||
**`degraded` 语义裁决(2026-08-25,PM)**:`degraded:true` **仅**用于「上游服务整体不可用」(`UPSTREAM_DOWN`),对应前端的降级横幅/功能禁用;请求级失败(`UPSTREAM_ERROR`/`TIMEOUT`/`INVALID_CITATION`/`NOT_FOUND`/`VALIDATION`/`INTERNAL`)一律 `degraded:false`,对应前端的内联错误 + 重试。两者的 UI 处置不同,不得混用。
|
||||
|
||||
- 成功响应**不包** `{ok:true}` 信封,直接返回业务 JSON。
|
||||
- 健康接口(`/api/health`)例外:返回 `{status, ...}` 结构(见 §3)。
|
||||
- **请求参数校验错误(422)例外**:FastAPI 原生 `{"detail":[...]}` 形态,**不包统一信封**(集成清单:M2 由全局异常处理器收敛为 `VALIDATION` 信封,前端 http.js 在收敛前需兼容两种形态)。
|
||||
- **已裁决的偏差(2026-08-25,A3)**:抽取上传用 `SpooledTemporaryFile` 中间落盘(≤5MB 驻内存),语义为「不整文件读进内存」;kg/extraction 信封用顶层 JSONResponse(与 mock 一致);`/api/extraction/jobs/{id}` 的 plan 拉取失败(含双上游不可达)置 `plan:null` 不报错(job 详情可用性优先)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 块联合(消息内容单元)
|
||||
|
||||
助手消息的 `blocks` 数组元素为判别式联合:
|
||||
|
||||
```jsonc
|
||||
{ "type": "text", "html": "<p>…</p>" } // 已消毒的 HTML
|
||||
{ "type": "table", "headers": ["…"], "rows": [["…"]] } // 二维数组
|
||||
{ "type": "code", "text": "…" }
|
||||
{ "type": "stream", "text": "…" } // 瞬态,绝不落库
|
||||
{ "type": "pipeline", "status": "running|done|error",
|
||||
"steps": [ { "name": "规划", "state": "waiting|running|done", "desc": "…", "phase": "planning" } ] }
|
||||
{ "type": "tool", "icon": "🔬", "name": "…", "status": "running|done|error",
|
||||
"dur": "42s", "open": true, "params": "…", "result": "…",
|
||||
"logs": [ { "text": "…", "cls": "log-ok|log-run" } ], "phase": "retrieval" }
|
||||
{ "type": "sources", "items": [ <source> ] } // 见 §8
|
||||
{ "type": "graph", "nodes": [ <node> ], "edges": [ <edge> ], "chains": [ <chain> ] } // 见 §9
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 健康与元信息
|
||||
|
||||
### GET /api/health
|
||||
|
||||
并发探测 7920、8000、7900 与 Neo4j(1s 超时,5s 缓存)。
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok|degraded",
|
||||
"version": "0.1.0",
|
||||
"upstreams": {
|
||||
"qa": { "ok": true, "version": "1.1.73-assertion-anchored" },
|
||||
"rag": { "ok": true },
|
||||
"ragAlt":{ "ok": true },
|
||||
"neo4j": { "ok": true, "active": "neo4j-plant-srt-pc2025-v3-20260817-normalized" }
|
||||
},
|
||||
"db": { "ok": true, "sessions": 12 }
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/qa/info
|
||||
|
||||
透传上游 `/info` 并精简:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "QA V1.1.73 Assertion-Anchored Full Graph",
|
||||
"version": "1.1.73-assertion-anchored",
|
||||
"pipeline": [ "question", "provisional answer", "…共 12 段" ],
|
||||
"config": { "max_evidence": 40, "bge_rerank": true, "knowledge_chain_defaults": { "…": "…" } }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. QA 问答
|
||||
|
||||
### POST /api/qa/chat
|
||||
|
||||
请求(`mode` 由前端从 `config/modes.js` 拼出):
|
||||
|
||||
```json
|
||||
{
|
||||
"question": "水稻SPL基因家族的功能研究有哪些关键进展?",
|
||||
"use_llm": true,
|
||||
"debug": false,
|
||||
"chain_top_k": 3, "chain_hops": 2, "chain_max_edges": 12, "chain_max_nodes": 10,
|
||||
"native_chain_enabled": true, "native_chain_max_hops": 3, "native_chain_top_k": 5
|
||||
}
|
||||
```
|
||||
|
||||
响应:归一化 QA 响应(**完整结构见 §7**)。读超时 300s。`invalid_llm_citation` 自动重试一次。
|
||||
|
||||
### POST /api/qa/chat/stream(SSE)
|
||||
|
||||
请求同 `/chat`。响应 `Content-Type: text/event-stream`,帧序列:
|
||||
|
||||
```
|
||||
event: progress data: {"stage":"entities_identified","phase":"planning","label":"已识别实体","details":{"entities":["OsSPL14",…]},"at":"2026-08-25T…Z"}
|
||||
event: progress data: {"stage":"evidence_review_completed","phase":"evidence","label":"证据审核完成","details":{"accepted_count":12,"publication_count":5},"at":"…"}
|
||||
event: complete data: {<同 /chat 的归一化响应>}
|
||||
event: error data: {"code":"TIMEOUT","message":"检索超时","retryable":true}
|
||||
event: timeout data: {"message":"检索超时"}
|
||||
event: raw data: {<无法分类的上游原始 JSON>}
|
||||
: ping ← 15s 保活注释帧
|
||||
```
|
||||
|
||||
规则:
|
||||
- **`phase` 由 BFF 计算**,值域 `planning|retrieval|evidence|generation|done`。前端绝不解析上游 stage 名。
|
||||
- `label` 为该事件的中文名(BFF 内置映射,与 `i18n/labels.js` 一致)。
|
||||
- `complete` 之后流关闭。上游 240s 无事件 → `event: timeout` 并关闭。
|
||||
- 前端 `AbortController` 断开 → BFF 取消上游请求。
|
||||
|
||||
**阶段→相位映射**(BFF 与前端共用,见 `i18n/labels.js`):
|
||||
|
||||
| phase | stage | label |
|
||||
|---|---|---|
|
||||
| planning | `understanding_started` | 开始理解问题 |
|
||||
| planning | `entities_identified` | 已识别实体 |
|
||||
| planning | `related_search_started` | 开始关联检索 |
|
||||
| retrieval | `concepts_found` | 发现相关概念 |
|
||||
| retrieval | `subquestion_retrieved` | 子问题召回完成 |
|
||||
| retrieval | `agent_review_started` | 开始智能体审核 |
|
||||
| retrieval | `subquestion_retry_started` | 子问题重试中 |
|
||||
| evidence | `evidence_accepted` | 证据已采纳 |
|
||||
| evidence | `evidence_review_completed` | 证据审核完成 |
|
||||
| generation | `answer_generation_started` | 开始生成答案 |
|
||||
| done | `completed` | 检索完成 |
|
||||
|
||||
未知 stage → `phase:"retrieval"`,`label` = stage 原文。
|
||||
|
||||
### POST /api/qa/search
|
||||
|
||||
请求同 `/chat` 但 BFF **强制 `use_llm:false`**。响应:归一化响应但 `answer` 为空、`mode:"search"`。读超时 180s(实测单次检索约 92s,主要耗时在 native graph chain),不重试。
|
||||
|
||||
---
|
||||
|
||||
## 5. 会话与消息
|
||||
|
||||
### GET /api/sessions
|
||||
|
||||
```json
|
||||
[ { "id": "uuid", "title": "水稻SPL…", "status": "done",
|
||||
"created_at": "…", "updated_at": "…", "message_count": 2,
|
||||
"meta": {"title_manual": false} } ]
|
||||
```
|
||||
按 `updated_at` 倒序。
|
||||
|
||||
### POST /api/sessions
|
||||
|
||||
请求 `{ "title": "新会话" }`(可空,默认「新会话」)。返回会话对象。
|
||||
|
||||
### GET /api/sessions/{id}
|
||||
|
||||
返回会话对象 + `messages`:
|
||||
|
||||
```json
|
||||
{ "id": "…", "title": "…", "status": "done", "created_at": "…", "updated_at": "…",
|
||||
"meta": {"title_manual": false},
|
||||
"messages": [
|
||||
{ "id": "…", "role": "user", "kind": "qa", "status": "done",
|
||||
"blocks": [], "raw": null,
|
||||
"text": "问题原文", ← 仅 role=user 有
|
||||
"created_at": "…", "updated_at": "…" },
|
||||
{ "id": "…", "role": "assistant", "kind": "qa", "status": "done",
|
||||
"blocks": [ …§2 块联合… ], "raw": {…},
|
||||
"created_at": "…", "updated_at": "…" }
|
||||
] }
|
||||
```
|
||||
|
||||
### PATCH /api/sessions/{id}
|
||||
|
||||
`{ "title": "…" }` 或 `{ "status": "running|done|error" }`。
|
||||
|
||||
### DELETE /api/sessions/{id}
|
||||
|
||||
204。级联删除消息。
|
||||
|
||||
### POST /api/sessions/{id}/messages
|
||||
|
||||
`{ "role": "user", "text": "…" }` → 返回消息对象。
|
||||
`{ "role": "assistant", "kind": "qa", "status": "streaming", "blocks": [<初始 blocks>] }` → 返回消息对象(流式骨架)。
|
||||
|
||||
### PATCH /api/sessions/{id}/messages/{mid}
|
||||
|
||||
`{ "status": "…", "blocks": [<检查点 blocks>], "raw": {…} }` → 返回更新后的消息。流式检查点每 ~3s 调用一次。
|
||||
|
||||
### GET /api/sessions/{id}/sources
|
||||
|
||||
全会话来源聚合:按 `paper_id` 去重(无 paper_id 时按 `id`),带 `count`(出现次数):
|
||||
|
||||
```json
|
||||
{ "session_id": "…", "total": 12, "sources": [ { <source 字段见 §8>, "count": 3 } ] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 知识图谱浏览(BFF 直连 Neo4j)
|
||||
|
||||
> 真实 schema 见 `DISCOVERY.md` §2。**当选实例 `neo4j-plant-srt-direct-v1-full`**(bolt 18788,db `neo4j`)。
|
||||
> schema 为 **canonical**:节点 `SRT2CanonicalEntity`(`entity_type`/`name`/`name_norm`/`canonical_id`/`support_count`)、`SRT2CanonicalAssertion`(证据断言,含 `source`/`relation_norm`/`target`/`best_evidence`/`title`/`doi`/`pmid`/`publication_year` 等)、`SRT2Paper`;关系 `SRT2_CANONICAL_ASSERTS` / `SRT2_SUPPORTED_BY`。
|
||||
> **没有全文索引**;名字检索走 `name_norm` RANGE 索引(`=` 或 `STARTS WITH`,0.09–0.10s)。**不得创建索引修改共享实例**(团队决策前)。
|
||||
> 回退顺序:18788 → 19788(normalized 实例,schema 不同需单独 Cypher 适配)→ 17687(空库)。
|
||||
|
||||
### GET /api/kg/status
|
||||
|
||||
```json
|
||||
{ "ok": true, "active": "neo4j-plant-srt-direct-v1-full",
|
||||
"schema": "canonical",
|
||||
"labels": ["SRT2CanonicalEntity","SRT2CanonicalAssertion","SRT2Paper"],
|
||||
"relationship_types": ["SRT2_CANONICAL_ASSERTS","SRT2_SUPPORTED_BY"],
|
||||
"entity_count": 3820996, "assertion_count": 8246875,
|
||||
"name_index": "srt2_canonical_entity_name_norm", "fulltext_index": null }
|
||||
```
|
||||
Neo4j 不可用 → 503 降级信封(`UPSTREAM_DOWN`)。
|
||||
|
||||
### GET /api/kg/overview
|
||||
|
||||
按 `entity_type` 与 `relation_category` 统计:
|
||||
|
||||
```json
|
||||
{ "entities_by_type": [ {"type": "gene", "count": 43210}, … ],
|
||||
"relations_by_category": [ {"type": "characterized_in", "count": 12033}, … ] }
|
||||
```
|
||||
|
||||
### GET /api/kg/entities?q=OsSPL14&limit=20
|
||||
|
||||
`name_norm = toLower(q)` **或** `STARTS WITH toLower(q)`,走 name_norm 索引:
|
||||
|
||||
```json
|
||||
{ "query": "OsSPL14", "results": [ { "id": "cent_xxx", "name": "OsSPL14", "type": "gene", "support_count": 12 } ] }
|
||||
```
|
||||
|
||||
### GET /api/kg/entities/{id}
|
||||
|
||||
`id` 为 `canonical_id`(`cent_*`)或名字(先按 `name_norm` 定位):
|
||||
|
||||
```json
|
||||
{ "id": "cent_xxx", "name": "OsSPL14", "type": "gene", "properties": { …节点全量属性… } }
|
||||
```
|
||||
未命中 → 404 信封(`NOT_FOUND`)。
|
||||
|
||||
### GET /api/kg/entities/{id}/neighborhood?depth=1&limit=50
|
||||
|
||||
depth 1–2(默认 1,**两跳约 13s 不默认开启**),limit ≤200。每跳按 `paper_count`/`support_count` 降序截断(防 hub 吃满预算):
|
||||
|
||||
```json
|
||||
{ "center": "cent_xxx", "center_name": "OsSPL14", "depth": 1,
|
||||
"nodes": [ {"id":"cent_xxx","label":"OsSPL14","type":"gene"}, … ],
|
||||
"edges": [ {"source":"cent_xxx","target":"cent_yyy","relation":"characterized_in","label":"characterized_in",
|
||||
"assertion_id":"cass_…","paper_count":5,"support_count":9,"title":"…","doi":"…","pmid":"…"} ] }
|
||||
```
|
||||
|
||||
**`/api/kg/*` 与消息内 `graph` 块使用同一 `{nodes,edges}` 形态**——前端只有一个图渲染器。字段差异由 BFF 在 kg 路由处补齐(`relation`/`label` 同值,`chain_id`/`hop` 为 null 的边照常渲染)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 归一化 QA 响应(/chat、/chat/stream 的 complete、/search 共用)
|
||||
|
||||
```json
|
||||
{
|
||||
"query_id": "…", "version": "1.1.73-assertion-anchored",
|
||||
"mode": "chat|search",
|
||||
"answer": "…markdown 文本…", ← search 模式为 ""
|
||||
"source_count": 12,
|
||||
"sources": [ <source,见 §8> ],
|
||||
"used_sources": [ <source> ],
|
||||
"chains": [ <chain,见 §9> ],
|
||||
"graph": { "nodes": [<node>], "edges": [<edge>], "chains": [<chain>] },
|
||||
"retrieval_plan": {…} , "retrieval_notes": "…",
|
||||
"timing": { "total_s": 41.2, "…": "…" },
|
||||
"citation_map": {…},
|
||||
"raw": { …上游原始完整响应… }
|
||||
}
|
||||
```
|
||||
|
||||
归一化别名规则(`pick(obj, *names)` 取第一个存在的键;**真实字段名以 `DISCOVERY.md` §4.1 为准**):
|
||||
- `answer | response | message` → `answer`
|
||||
- `sources | citations` → `sources`;`used_sources | references` → `used_sources`
|
||||
- `retrieval_plan | agentic_plan | query_plan` → `retrieval_plan`(真实上游的键是 `query_plan`,其内嵌套 `agentic_plan`);`retrieval_notes | notes` → `retrieval_notes`
|
||||
- `timing | timings` → `timing`(真实键是 `timings`)
|
||||
- `citation_map | citation_validation` → `citation_map`(真实键是 `citation_validation`)
|
||||
- `knowledge_graph` / `graph_edges` / `native_graph_chains` / `graph_knowledge_chains` → `graph`(链拆分进 `chains`)
|
||||
|
||||
**链条容错**:`native_graph_chains` 可能是**列表**(真实形态,305 条),`graph_knowledge_chains` 可能是 **dict**(`{mode, chains[], chain_count, audit, summary}`)——两种形态都要能提取出 hop 列表。真实 chain item:`{chain_id, chain_source, hop_count, path_score, start, end, bridge_entities, hops[], score_breakdown}`;真实 hop:`{hop_index, from_id, from_name, to_id, to_name, source, relation, target, source_canonical_id, target_canonical_id, source_type, target_type, relation_category, assertion_id, paper_id, title, pmid, pmcid, doi, species, evidence, support_count, paper_count, best_score, edge_score, …}`。归一化时:`from_name|source → source`、`to_name|target → target`、`from_id|source_canonical_id → source 节点 id`、`to_id|target_canonical_id → target 节点 id`、`from_name/to_name → 节点 label`、`source_type/target_type → 节点 type`、`paper_id→paper_id`、`assertion_id → assertion_id`。
|
||||
|
||||
**已裁决的补充(2026-08-25,A1 偏差裁决通过)**:
|
||||
- hop 归一化中间产物额外携带 `source_id/target_id`(内部派生用,最终 graph/chains 输出仍与 §9 一致)。
|
||||
- 来源聚合(`GET /sessions/{id}/sources`)去重键为 `paper_id`(无则 `id`),聚合值取首次出现的完整 source item,`count` 为出现次数。
|
||||
- `search` 模式 `answer` 恒为空串(而非缺键)。
|
||||
- PATCH 消息/会话空 body(三个字段全缺)按幂等成功返回当前状态。
|
||||
- PATCH 会话 `title` 时置 `meta.title_manual=true`(对应 PRD FR-SESSION-008 的手动改名保护)。
|
||||
|
||||
---
|
||||
|
||||
## 8. Source(来源)结构
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "…", "title": "Tartary buckwheat SPL…",
|
||||
"paper_id": "PMID12345|paper_xxx", "chunk_id": "…",
|
||||
"pmid": "31286919", "pmcid": "PMC…", "doi": "10.xxx/…", "url": "https://doi.org/…",
|
||||
"snippet": "…证据片段…", "score": 0.87,
|
||||
"locator": "…", "section": "Results", "support_count": 2,
|
||||
"type": "…", "metadata": {…}
|
||||
}
|
||||
```
|
||||
|
||||
别名映射(真实 source item 为 26 键,见 `DISCOVERY.md` §4.1):
|
||||
`content|evidence|text|snippet → snippet`;
|
||||
`doc_id|paper_id|pubmed_id|id|chunk_id → paper_id`(chunk_id 单独保留;真实键是 `paper_id`);
|
||||
`final_score|score|confidence|rerank_score → score`(**真实键是 `final_score`**,另有 `vector_score`/`rrf_score`/`rerank_score` 保留进 `metadata`);
|
||||
`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` 原样透传。
|
||||
**缺失字段键必须存在(值为 null),前端不得渲染 `undefined`。**
|
||||
|
||||
---
|
||||
|
||||
## 9. 图谱结构(Node / Edge / Chain)
|
||||
|
||||
```json
|
||||
{ "node": { "id": "OsSPL14", "label": "OsSPL14", "type": "Gene" } }
|
||||
{ "edge": { "source": "OsSPL14", "target": "panicle", "relation": "expressed_in",
|
||||
"label": "expressed_in", "chain_id": "c1", "hop": 1,
|
||||
"paper_id": "…", "pmid": "…", "pmcid": "…", "doi": "…" } }
|
||||
{ "chain": { "chain_id": "c1",
|
||||
"hops": [ { "source": "OsSPL14", "target": "panicle", "relation": "expressed_in",
|
||||
"source_type": "Gene", "target_type": "Tissue",
|
||||
"paper_id": "…", "pmid": "…", "pmcid": "…", "doi": "…" } ] } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 抽取任务
|
||||
|
||||
> 上游真实结构见 `DISCOVERY.md` §3。上游**没有 progress 数值字段**,BFF 必须按 `stage` 派生 `progress_pct`。
|
||||
|
||||
### GET /api/extraction/jobs?limit=20
|
||||
|
||||
上游响应 `{"jobs":[...], "count":N}`,BFF 归一化为(**`count` 为归一化后 jobs 数组的长度**,与上游「截断后条数」语义一致):
|
||||
|
||||
```json
|
||||
{ "jobs": [
|
||||
{ "job_id": "2943168e9c48", "title": "wang2019.pdf",
|
||||
"status": "queued|running|completed|failed",
|
||||
"stage": "queued|mineru|llm|completed|failed",
|
||||
"substage": "done|null",
|
||||
"progress_pct": 100, ← BFF 派生:queued=5, mineru=35, llm=75, completed=100, failed=null
|
||||
"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/{id}/bbav5-config",
|
||||
"methodology_plan_url": "/api/v1/methodology/extract-jobs/{id}/plan"
|
||||
} ],
|
||||
"count": 49 }
|
||||
```
|
||||
|
||||
`title` 派生规则:`source_filename` 优先,缺失时用 `paper_id`,再缺用 `job_id`。
|
||||
|
||||
### GET /api/extraction/jobs/{job_id}
|
||||
|
||||
上游返回扁平 JSON(与列表 item 同字段 + `content_list`、`figure_table_files{...}`、`figure_table_warning`、`figure_table_inventory_url`、`reproduction_plan_preview_url`、`log_path`)。BFF 归一化为:
|
||||
|
||||
```json
|
||||
{ "job_id": "…", "title": "…", "status": "…", "stage": "…", "progress_pct": 100,
|
||||
"message": "…", "paper_id": "…", "family": "…",
|
||||
"stats": { "stage_count": 6, "species_count": 1, "figure_table_caption_count": 9, "figure_table_stage_count": 12 },
|
||||
"detail": { …上游原始扁平 JSON 全量… },
|
||||
"plan": {…} | null }
|
||||
```
|
||||
|
||||
未完成时 `plan` 为 null(上游返回 409,BFF 吞掉并置 null,**不报错**)。
|
||||
|
||||
### GET /api/extraction/processing-status · /progress · /summary · /run-log
|
||||
|
||||
透传上游(**全部是 JSON**:前三者 `{"rows":[...]}`,`run-log` 为 `{"text":"..."}`,**不是纯文本**)。rows 为空属正常现象(上游批次目录未配置),前端显示空态而非错误。
|
||||
|
||||
### POST /api/extraction/upload
|
||||
|
||||
multipart:`file`(**必填**,PDF 或 `*_content_list.json`)+ 可选 `paper_id`(默认 `uploaded_paper`)、`family`、`output_name`、`llm_model`、`sandbox`。BFF 仅限 PDF(≤50MB)。
|
||||
成功(上游形态):`{ "success": true, "job_id": "…", "status_url": "…", "message": "已提交抽取任务" }`。
|
||||
MinerU 不可用:502 降级信封,message 说明原因。读超时 600s。
|
||||
|
||||
**上游选择**:默认 8000;8000 不可达时自动回落 7900(每次请求探测,不缓存失败状态超过 30s)。注意 7900 的数据目录独立(任务列表为空属正常)。
|
||||
|
||||
---
|
||||
|
||||
## 11. 前端消费要点(给 B2/B3)
|
||||
|
||||
- `/api/qa/*` 与 `/api/qa/search` 的请求 body 由 `config/modes.js` 拼装,`question` 之外字段前端不可增删。
|
||||
- SSE 消费用 `fetch` + `ReadableStream`(**禁止 EventSource**),解析器处理 `event:`/`data:`/`:` 三种行。
|
||||
- 流式持久化节奏:骨架 `POST /messages` → 每 3s `PATCH` 检查点 → 终态 `PATCH`。刷新中断恢复依赖此节奏。
|
||||
- 所有列表请求失败都渲染空态/离线态,**禁止**无限 loading 转圈。
|
||||
@@ -0,0 +1,231 @@
|
||||
# ARCHITECTURE.md —— PlantAI-rag-new 架构与协作约定
|
||||
|
||||
> 版本 1.0 | 作者 PM | 本文件由 PM 独占写入,所有开发/测试 agent **只读**
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目定位
|
||||
|
||||
`PlantAI-rag-new` 是植物基因家族文献 RAG 知识系统的**新一代工作台**。
|
||||
|
||||
它**不重写** RAG 算法、检索引擎与论文抽取流水线——那些是现有 `PlantAI-rag` 工程中经真实论文验证的资产。本项目做两件事:
|
||||
|
||||
1. **一层薄适配(BFF, Backend-For-Frontend)**:代理并归一化现有运行中的上游服务,补齐上游缺失的能力(会话持久化、图谱直连、统一错误信封)。
|
||||
2. **一套全新的响应式前端**:严格实现 `plantai-agent-ui-prototype.html` 的视觉系统,并扩展为支持手机的四档自适应布局。
|
||||
|
||||
**红线**:不修改 `PlantAI-rag`、`PlantAI-dev`、`rag-qa-v1_1_73` 的任何源文件;不占用其端口;不切换其流量。
|
||||
|
||||
---
|
||||
|
||||
## 2. 系统拓扑
|
||||
|
||||
```
|
||||
┌────────── 用户浏览器(桌面 / 平板 / 手机)──────────┐
|
||||
│ │
|
||||
│ Vue 3 SPA ← 开发期 Vite dev :5180 │
|
||||
│ 生产期由 BFF 静态托管 │
|
||||
└───────────────────────┬──────────────────────────────┘
|
||||
│ /api/* (同源,JSON + SSE)
|
||||
┌───────────────────────▼──────────────────────────────┐
|
||||
│ BFF FastAPI + Uvicorn 127.0.0.1:8090 │
|
||||
│ ┌────────────┬────────────┬───────────┬──────────┐ │
|
||||
│ │ sessions │ qa │ kg │extraction│ │
|
||||
│ │ (SQLite) │ (代理+SSE) │(Bolt直连) │ (代理) │ │
|
||||
│ └────────────┴─────┬──────┴─────┬─────┴────┬─────┘ │
|
||||
└────────────────────┬┴────────────┴──────────┴────────┘
|
||||
│ │ │
|
||||
┌────────────▼──┐ ┌───────▼─────┐ ┌─▼──────────────┐
|
||||
│ QA V1.1.73 │ │ Neo4j Bolt │ │ Gene Family │
|
||||
│ 127.0.0.1:7920│ │ 19788/18788 │ │ RAG :8000 │
|
||||
│ (只读消费) │ │ /17687 │ │ 回落 :7900 │
|
||||
└───────────────┘ └─────────────┘ └────────────────┘
|
||||
```
|
||||
|
||||
**BFF 的存在理由**(不是无谓的中间层):
|
||||
|
||||
| 职责 | 为什么前端不能直连上游 |
|
||||
|---|---|
|
||||
| 会话持久化 | 上游 QA 完全无状态,没有会话概念,UI 的历史列表必须有人存 |
|
||||
| 别名归一化 | 上游同一语义有多个字段名(`answer`/`response`/`message` 等),归一化放前端会污染每个组件 |
|
||||
| SSE 阶段映射 | 上游推十余个细粒度 stage,UI 只需五阶段;映射放 BFF,前端不需要知道上游阶段名 |
|
||||
| 图谱直连 | 上游 `/kg/*` 路由指向已死的 7687 端口,必须由 BFF 用 Bolt 驱动绕过 |
|
||||
| 统一错误信封 | 三个上游三套错误格式,前端只应处理一种 |
|
||||
| 跨域与凭据 | 上游只绑回环且 Neo4j 需要密码,凭据不能进浏览器 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 运行时约定
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| BFF 监听 | `127.0.0.1:8090`(占用则退 8091) |
|
||||
| Vite dev | `127.0.0.1:5180`(占用则退 5181) |
|
||||
| BFF conda 环境 | `plantai-bff`(Python 3.11,新建,**不复用** `gfpy312`) |
|
||||
| SQLite 路径 | `backend/data/plantai.db`(WAL 模式) |
|
||||
| 前端工具链 | Vite **5.4.x** + @vitejs/plugin-vue **5.2.x** + Vue **3.5.x** + Pinia **2.3.x** |
|
||||
| Node | v18.19.1(已 EOL)——**禁止升级 Vite 至 7**(需 Node 20+),**禁止用 `npm create vite`** |
|
||||
|
||||
生产形态:`npm run build` 产出 `frontend/dist`,BFF 用 `StaticFiles` 挂载,单进程从 `:8090` 提供服务。
|
||||
|
||||
---
|
||||
|
||||
## 4. 开发工作流:本地 staging + rsync
|
||||
|
||||
远端机器是**运行与测试环境**,本地是**编辑环境**。原因:agent 在本地能用完整的读/写/编辑工具迭代,通过 SSH 逐行改远端文件既慢又易错。
|
||||
|
||||
| 角色 | 路径 |
|
||||
|---|---|
|
||||
| 本地编辑(权威源) | `/Users/jerry/haiguji/PlantAI-rag-new/` |
|
||||
| 远端运行(部署副本) | `/home/xzq/5090/teamWorkspace/zh/PlantAI-rag-new/` |
|
||||
|
||||
同步命令(由 `deploy/sync.sh` 封装):
|
||||
|
||||
```bash
|
||||
rsync -az --delete \
|
||||
--exclude 'node_modules' --exclude '__pycache__' --exclude '.venv' \
|
||||
--exclude 'backend/data' --exclude 'frontend/dist' --exclude '.pytest_cache' \
|
||||
/Users/jerry/haiguji/PlantAI-rag-new/ \
|
||||
100.109.52.26:/home/xzq/5090/teamWorkspace/zh/PlantAI-rag-new/
|
||||
```
|
||||
|
||||
**规则**:
|
||||
- 所有 agent **只在本地路径写文件**,绝不用 SSH heredoc 改代码。
|
||||
- 同步由 **PM 统一执行**,agent 不自行 rsync(避免半成品覆盖远端)。
|
||||
- `backend/data`(数据库)、`node_modules`、`dist` 不同步,属远端运行时产物。
|
||||
- 文档最终必须在远端 `docs/` 下可查——每次里程碑结束由 PM 同步一次。
|
||||
|
||||
---
|
||||
|
||||
## 5. Agent 分工与文件归属
|
||||
|
||||
**铁律:文件归属严格互斥。任何 agent 不得写入不属于自己的文件。** 需要跨归属改动时,向 PM 提出,由 PM 单点裁决修改。
|
||||
|
||||
| Agent | 职责 | 独占路径 |
|
||||
|---|---|---|
|
||||
| **PM** | 契约文档、集成、缺陷派发、验收、rsync | `docs/{PRD,API,UI_SPEC,ARCHITECTURE,TEST_PLAN,ACCEPTANCE,CHANGELOG}.md`、`deploy/**`、`src/i18n/labels.js`、`src/config/modes.js`、`backend/mock/**` |
|
||||
| **D0 探测** | 上游探测与选型 | `docs/DISCOVERY.md` |
|
||||
| **A1 后端·骨架** | app 工厂、配置、SQLite、会话与消息 CRUD、归一化器、错误信封 | `backend/pyproject.toml`、`backend/run.sh`、`backend/.env.example`、`backend/app/{main,config,db,schemas,upstream}.py`、`backend/app/routers/{health,sessions}.py`、`backend/app/services/normalize.py` |
|
||||
| **A2 后端·QA** | QA 代理、SSE 中继、阶段映射、重试与看门狗 | `backend/app/routers/qa.py`、`backend/app/services/sse_relay.py` |
|
||||
| **A3 后端·图谱与抽取** | Neo4j Bolt 直连、抽取代理与回落 | `backend/app/routers/{kg,extraction}.py`、`backend/app/services/{neo4j,extraction}.py` |
|
||||
| **B1 前端·基础** | 令牌层、AppShell、四档响应式框架、主题、顶栏/底栏、通用 UI、健康轮询 | `frontend/{package.json,vite.config.js,vitest.config.js,index.html}`、`frontend/src/{main.js,App.vue}`、`frontend/src/styles/**`、`frontend/src/components/{layout,ui}/**`、`frontend/src/stores/{theme,health}.js`、`frontend/src/api/http.js`、`tests/frontend/unit/b1/**` |
|
||||
| **B2 前端·会话与对话** | 会话列表与抽屉、对话区、作曲器、流水线卡、SSE 客户端 | `frontend/src/components/{session,chat}/**`、`frontend/src/stores/{sessions,chat}.js`、`frontend/src/sse/**`、`frontend/src/api/{qa,sessions}.js`、`tests/frontend/unit/b2/**` |
|
||||
| **B3 前端·工作台** | 右侧四个 tab:检索过程、文献来源、提取任务、知识图谱 | `frontend/src/components/bench/**`、`frontend/src/stores/bench.js`、`frontend/src/api/{kg,extraction}.js`、`tests/frontend/unit/b3/**` |
|
||||
| **T 测试** | 用例编写与执行、缺陷报告 | `tests/backend/api/**`、`tests/e2e/**`、`playwright.config.js`、`docs/TEST_CASES.md`、`docs/TEST_REPORT_*.md` |
|
||||
|
||||
后端各 agent 的单测放在各自归属子树内(`backend/app/**/test_*.py`,随代码一起交付),运行 `pytest backend/app -q`;T 的 L2 接口测试与 L5 e2e 放在 `tests/`。
|
||||
|
||||
### 跨归属的接缝
|
||||
|
||||
以下文件被多方读取但只有一个写入方,接缝处必须按契约对齐:
|
||||
|
||||
| 接缝 | 写入方 | 读取方 | 契约依据 |
|
||||
|---|---|---|---|
|
||||
| BFF 接口形态 | A1/A2/A3 | B1/B2/B3、T | `API.md` |
|
||||
| 设计令牌 CSS 变量 | B1 (`styles/tokens.css`) | B2、B3 | `UI_SPEC.md` §令牌 |
|
||||
| 组件类名 | B1 定义基础类,B2/B3 复用 | 互相 | `UI_SPEC.md` §组件 |
|
||||
| 五阶段标签与颜色 | PM (`i18n/labels.js`) | A2(计算 phase)、B2、B3 | `PRD.md` FR-CHAT-009 |
|
||||
| 模式参数集 | PM (`config/modes.js`) | B2 | `PRD.md` FR-CHAT-006 |
|
||||
| 块联合结构 | A1(落库)、B2(渲染) | T | `API.md` §块联合 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 并行解耦:mock server
|
||||
|
||||
前端三个 agent **不等待后端**。M0 阶段 PM 产出 `backend/mock/mock_server.py`:
|
||||
|
||||
- FastAPI,监听 `127.0.0.1:8099`
|
||||
- 实现 `API.md` 中**全部**接口,返回 `backend/mock/fixtures/*.json` 里的固定数据
|
||||
- 包含一条**可回放的 SSE 事件序列**(按真实时序 sleep),让流式 UI 能在无上游时开发
|
||||
- 支持 `?scenario=` 查询参数切换场景:`normal` / `empty`(空结果)/ `slow`(慢响应)/ `error`(上游故障)/ `degraded`(部分降级)
|
||||
|
||||
B 组开发时把 `vite.config.js` 的 proxy 指向 8099;集成阶段由 PM 切回 8090。
|
||||
|
||||
**fixtures 是单一事实源**:mock 与真实 BFF 的单元测试共用同一批 fixture 文件,保证契约不漂移。
|
||||
|
||||
---
|
||||
|
||||
## 7. 集成节奏
|
||||
|
||||
```
|
||||
M0 契约锁定 ──┬─→ A1 A2 A3(对真实上游)──┐
|
||||
├─→ B1 B2 B3(对 mock) ─┤→ M2 PM 集成 → M3 补齐 → M4 测试循环 → M5 验收
|
||||
└─→ T 写用例(对 PRD) ─┘
|
||||
```
|
||||
|
||||
- 每一波结束,agent 向 PM 汇报「完成项 + 未决问题 + 对契约的偏差建议」,**不直接改契约**。
|
||||
- PM 在集成点统一处理契约偏差,必要时更新 `API.md` 并广播给受影响的 agent。
|
||||
- 缺陷修复一律**批量派发**,禁止边测边改(见 `TEST_PLAN.md` 轮次纪律)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 目录结构
|
||||
|
||||
```
|
||||
PlantAI-rag-new/
|
||||
├── docs/ # 全部文档,见第 5 节归属
|
||||
├── deploy/
|
||||
│ ├── sync.sh # 本地 → 远端 rsync
|
||||
│ └── start-dev.sh # 拉起 BFF + vite,打印隧道命令
|
||||
├── backend/
|
||||
│ ├── pyproject.toml run.sh .env.example
|
||||
│ ├── data/ # plantai.db(不入库、不同步)
|
||||
│ ├── mock/
|
||||
│ │ ├── mock_server.py
|
||||
│ │ └── fixtures/*.json
|
||||
│ └── app/
|
||||
│ ├── main.py config.py db.py schemas.py upstream.py
|
||||
│ ├── routers/ health.py sessions.py qa.py kg.py extraction.py
|
||||
│ └── services/ normalize.py sse_relay.py neo4j.py extraction.py
|
||||
├── frontend/
|
||||
│ ├── package.json vite.config.js index.html vitest.config.js
|
||||
│ └── src/
|
||||
│ ├── main.js App.vue
|
||||
│ ├── styles/ tokens.css base.css animations.css responsive.css
|
||||
│ ├── i18n/labels.js config/modes.js
|
||||
│ ├── api/ http.js qa.js sessions.js kg.js extraction.js
|
||||
│ ├── sse/ parser.js
|
||||
│ ├── stores/ theme.js health.js sessions.js chat.js bench.js
|
||||
│ └── components/
|
||||
│ ├── layout/ AppShell 相关、TopBar、StatusBar、AuroraBg、Drawer
|
||||
│ ├── ui/ StatusBadge、StatusDot、IconButton、Skeleton、EmptyNotice
|
||||
│ ├── session/ SessionPanel、SessionItem、SessionRail、SearchBox
|
||||
│ ├── chat/ ChatPanel、MessageList、Composer、PipelineCard、ToolCard …
|
||||
│ └── bench/ BenchPanel、RetrievalPane、SourcesPane、ExtractionPane、KgPane …
|
||||
├── tests/
|
||||
│ ├── backend/ # pytest
|
||||
│ ├── frontend/ # vitest
|
||||
│ └── e2e/ # playwright
|
||||
└── playwright.config.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 编码约定
|
||||
|
||||
**后端**
|
||||
- 全异步(`async def` + httpx.AsyncClient + aiosqlite);不在请求路径上做阻塞 IO。
|
||||
- 所有对上游的调用集中在 `services/`,`routers/` 只做参数校验与响应组装。
|
||||
- 不吞异常:上游故障必须转成带 `code` 的错误信封,并在日志里保留原始异常。
|
||||
- 类型标注齐全,Pydantic v2 模型定义在 `schemas.py`。
|
||||
|
||||
**前端**
|
||||
- Vue 3 `<script setup>` + Composition API;组件单一职责,超过 ~250 行考虑拆分。
|
||||
- **样式一律用 `--pa-*` 令牌**,禁止硬编码颜色值(令牌表见 `UI_SPEC.md`)。
|
||||
- 组件内 `<style scoped>`;跨组件复用的类放 `styles/base.css`(B1 归属)。
|
||||
- 状态跨组件共享一律走 Pinia store,禁止用 provide/inject 传业务状态。
|
||||
- 所有用户可见文案走 `i18n/labels.js`,组件内不写死中文字符串(错误提示除外,见 `PRD.md` §全局约定)。
|
||||
|
||||
**通用**
|
||||
- 文件编码 UTF-8,LF 换行。
|
||||
- 提交粒度:一个 FR 一次提交,提交信息含 FR 编号。
|
||||
|
||||
---
|
||||
|
||||
## 10. 已知的既存故障(不修,仅规避)
|
||||
|
||||
| 现象 | 影响 | 我们的对策 |
|
||||
|---|---|---|
|
||||
| 默认端口 7687 无 Neo4j,`:8000/api/v1/kg/*` 与 `/v15-graph-qa/*` 报错 | 上游图谱路由不可用 | BFF 用 Bolt 直连非标准端口实例,见 `DISCOVERY.md` |
|
||||
| PlantAI-dev `.env` 指向 `7925`(空端口),实际 QA 在 `7920` | PlantAI-dev 知识问答当前是坏的 | 与本项目无关,仅记录;本项目指向 7920 |
|
||||
| 上游 QA 无 token 级流式,答案在终态一次返回 | 无法做真流式打字 | 客户端揭示动画,视觉一致但不伪造数据流 |
|
||||
| 上游 `max_rounds: 1`,每个提问独立 | 无多轮上下文 | UI 明确提示「每次提问独立检索」,不假装有上下文记忆 |
|
||||
@@ -0,0 +1,74 @@
|
||||
# CHANGELOG.md —— 进度与决策记录(PM 独占写入)
|
||||
|
||||
## 2026-08-25
|
||||
|
||||
- **计划批准**:新前端 + 薄适配层方案获批。端口 8090(BFF)/ 5180(Vite dev),并行部署不切换。
|
||||
- **M0 启动**:远端目录树建立;8090/5180 端口复核空闲;本地 staging 目录 `/Users/jerry/haiguji/PlantAI-rag-new/` 建立。
|
||||
- **契约文档定稿**:`PRD.md`(81 个 FR)、`API.md`、`UI_SPEC.md`、`ARCHITECTURE.md`、`TEST_PLAN.md` 完成并锁定。
|
||||
- **Mock 层完成**:`backend/mock/mock_server.py` + 21 个 fixtures(含 SSE 回放序列)。
|
||||
- **共享前端文件完成**:`frontend/src/i18n/labels.js`、`frontend/src/config/modes.js`。
|
||||
- **环境就绪**:`plantai-bff` conda 环境(3.11);npm 依赖 187 包(Vite 5.4.11/Vue 3.5.13);Playwright + Chromium headless shell 131 下载完成;mock server 在 8099 运行并全接口验证通过。
|
||||
- **D0 探测完成**(`DISCOVERY.md`,345 行)。**契约偏差修正**(基于真实上游):
|
||||
- Neo4j 当选 `neo4j-plant-srt-direct-v1-full`(bolt 18788,QA 7920 实际使用)。schema 为 **canonical**(`SRT2CanonicalEntity`/`SRT2CanonicalAssertion`),非假设的 `:Entity`;**无全文索引**,名字检索走 `name_norm` 前缀索引(0.09s)。
|
||||
- 抽取任务真实状态枚举 `queued/running/completed/failed`,**无 progress 数值**——BFF 按 stage 派生 `progress_pct`。
|
||||
- QA source 真实分数字段为 `final_score`;检索计划在 `query_plan` 键;`native_graph_chains` 为列表、`graph_knowledge_chains` 为 dict 双形态。归一化规则已更新。
|
||||
- 实测 search ≈92s(读超时调至 180s)、stream ≈37s。
|
||||
- `processing-status/progress/summary/run-log` 当前空数据(上游批次目录未配置),前端按空态处理。
|
||||
- **已知问题**:QA 上游 DeepSeek LLM 余额不足(402 Insufficient Balance)——检索链路正常,chat 当前只出证据列表 fallback,无自然语言答案。**需用户充值 DeepSeek 账户**。开发不受阻(mock 覆盖);M2 验收对答案质量相应调整。
|
||||
- **上游重启记录**:无。
|
||||
|
||||
## M0 完成 → M1 启动(2026-08-25 晚)
|
||||
|
||||
- **M0 验收达成**:契约文档、mock(8099 运行中且已按真实上游对齐:canonical schema / queued-runing 派生进度 / run-log JSON)、conda 环境、npm 依赖、Playwright、本地 staging + 远端副本、BFF 骨架 import 通过。
|
||||
- **本地开发环境补齐**:`.venv-backend`(3.12,供后端 agent 本地自测)+ 本地 node_modules;`deploy/sync.sh` 排除规则修正(`.venv-backend`)。
|
||||
- **跨归属占位脚手架**:backend/app 全部模块 + frontend main.js/App.vue 占位,由各 agent 在 M1 以真实实现替换。
|
||||
- **M1 发射**:A1/A2/A3(后端三线)+ B1/B2/B3(前端三线)+ T(测试用例)七个 agent 并行。前端三入口占位组件已由 PM 提供;文件归属含测试目录拆分(`tests/frontend/unit/b1|b2|b3`,后端单测随代码 `backend/app/**/test_*.py`)。
|
||||
- **A1 完成**(15 文件,29 单测全过):config/db/schemas/normalize/upstream/health/sessions/main + pyproject + run.sh + .env.example。7 条偏差全部 PM 裁决通过并回填 API.md(hop 中间态字段、来源聚合取首条、search answer 空串、PATCH 空 body 幂等、title_manual 保护、凭据仅入 BFF 配置、新增 python-multipart)。
|
||||
- **待办**:A1 报告 A3 的 17 个测试失败(`test_kg.py` 用 ASGITransport 直挂裸 router 与 fastapi 0.141 不兼容;`test_extraction.py` 两处 respx 失败)——待 A3 汇报后统一派发。
|
||||
- **B3 完成**(工作台四 tab,54 单测全过,`vite build` 通过):BenchPanel/RetrievalPane/SourcesPane/SourceRow/ChainGroup/ExtractionPane/ProgressItem/KgPane/KgGraphView(手写 SVG 力导向,无第三方库)/KgEntityDetail/graphLayout/bench store/两 API 模块。15 条工作台文案已由 PM 合并进 labels.js,benchLabels.js 改为薄别名。
|
||||
- **集成对齐点(B3 提出,待各线就绪后统一处理)**:① chat store getter 名(`currentEvents/currentMessage/messages`,B2 确认);② bench 折叠由 B1 AppShell 控制(emit/props 接缝);③ `pa-edge-grad` SVG defs id 前缀;④ ExtractionPane 卸载时 `stopPolling`(BenchPanel onBeforeUnmount);⑤ kg 邻域深度 2 的 13s 提示已做。
|
||||
- **A2 完成**(QA 代理 + SSE 中继,36 单测全过):四端点 + STAGE_PHASE/STAGE_LABEL + 帧解析/重发/看门狗/重试。SSE 读超时采用 `read=None` + 应用层看门狗(producer task + Event.wait,规避 anyio.fail_after 毒化 generator 链的工程陷阱)。`degraded` 语义已 PM 裁决:仅 UPSTREAM_DOWN 为 true(API.md 已回填)。
|
||||
- **A2 集成对齐点**:A1 就绪后删除 qa.py/sse_relay.py 内本地等价实现(`_fallback_normalize_qa` 等),归一化以 A1 为准;真实上游冒烟(1.78MB search 响应、SSE 全流程)在远端做(本地 7920 不可达)。
|
||||
- **A3 完成**(图谱直连 + 抽取代理,88 单测全过):Neo4jService(实例回退 18788→19788→17687、TCP 预检、depth=1 绝不执行第二跳)+ 五 kg 端点 + ExtractionService(8000 主/7900 回落、progress_pct 派生、plan 409→null、spool 上传)+ 全套 respx/FakeDriver 单测。**A1 报告的 17 个测试失败已被 A3 自行解决**(测试切换到兼容 pytest-asyncio 的纯 async def)。5 条偏差全部 PM 裁决通过(count 语义、422 暂留原生格式待 M2 全局处理器收敛、plan 失败置 null 等,API.md 已回填)。
|
||||
- **M2 集成前待办(PM 执行)**:① 补 pyproject dev 依赖 `respx`;② main.py 加全局 RequestValidationError 处理器(VALIDATION 信封);③ 删除 A2 本地等价实现切换 A1 normalize;④ 验证 A1 health 的 `probe_neo4j` 接缝;⑤ 远端真实 Neo4j/QA 冒烟。
|
||||
- **后端集成预检(PM,2026-08-25)**:三线代码合并后全量 `pytest .` = **119 passed**(2.34s),仅有 5 个 deprecation warning(A1 main.py 的 `on_event` 弃用,建议转 lifespan——列入 M3 收尾清单,不影响功能)。后端三条线无测试冲突,接缝(config/upstream/envelope)互相兼容。
|
||||
- **B1 完成**(样式层 + 外壳 + 响应式 + 主题/健康 store,25 单测全过,build 通过 285KB/81KB):tokens 逐字复刻、四档断点、抽屉、覆盖层、visualViewport 键盘适配、http.js 统一错误。
|
||||
- **PM 集成修复(B1 偏差裁决 + 接缝接线)**:① laptop 档折叠策略裁决为「首次进入无偏好时自动折叠,把手保留,用户显式展开被尊重」(CSS 改为响应 side-collapsed 状态 + AppShell watch tier);② AppShell 顶栏 title/status 接上 B2 sessions store(原返回 null);③ 移动端全屏覆盖层 tab 条接上 B3 bench store 的 setActiveTab(原点击无效);④ 验证:B1 25 测 + B3 54 测全过、build 通过。
|
||||
- **B2 完成**(会话与对话流式状态机,58 单测全过):chat store 全流程(落库→骨架→SSE→检查点→终态)、stop/retry/刷新恢复/揭示动画、sessions store、SseParser(多行 data/注释/CRLF/UTF-8 跨 chunk)、16 组件、markdown 消毒。手动冒烟:mock SSE 12 帧消费 + 18 模块 transform 全 200。
|
||||
- **B2 偏差裁决 + PM 集成补丁**:① labels 补 4 键(toolParams/toolResult/toolLog/graphSummary);② 消息内 tool 块无 API 产出路径——保留渲染器待数据源;③ checkpoint 时序(揭示完成才落终态)裁决通过;④ interrupted 为前端本地标注(后端枚举不变)裁决通过;⑤ 重试同位置替换、旧 DB 行不更新裁决通过;⑥ **FR-CHAT-015 高亮链补全**(bench store 加 highlightSource + setHighlightSource 2s 自动清除、SourcesPane 传 highlighted、SourceRow 滚动定位 + 2s 渐隐高亮)。验证:B3 54 测 + B2 58 测全绿。
|
||||
|
||||
## M2 集成(2026-08-25 夜)
|
||||
|
||||
- **真实栈部署**:远端 BFF 8090(plantai-bff + python-multipart 补齐)、Vite dev 5180(代理→8090)、集成检查脚本 11/11 通过(健康/qa info/会话/Neo4j canonical/实体搜索/抽取/真实 search ≈90s)。
|
||||
- **修复两个集成接缝 bug**:① A2 的 `get_qa_client` 误用 A1 的 async `get_client()` 返回 coroutine → 裁决为「QA 流式路径独立持有 read=None 的 client,不复用 A1 单例」(语义不同:SSE 长静默会被 300s 读超时误杀);② A3 `_tcp_reachable` 未解包 `open_connection` 的 (reader, writer) 元组。
|
||||
- **PM 集成单点修改**:main.py 转 lifespan + 全局 RequestValidationError→VALIDATION 信封;pyproject 补 respx;远端 @playwright/test 安装 + 根 node_modules symlink(T 方案 b)。
|
||||
- **SSH 隧道三连坑(PM 记录,供后续复用)**:pkill 自匹配杀 shell(用 `[x]` 正则技巧);`ExitOnForwardFailure` 被配置里坏掉的 RemoteForward 15721 触发退出;`ClearAllForwardings=yes` 会清掉命令行自己的 `-L`。
|
||||
- **响应式断链缺陷(M2 核心 bug)**:真实浏览器中流水线相位卡冻结。六轮探针定位:fetch 层 12 个 progress + 3MB complete 全到达 → store 状态正确(探针直读 pinia)→ 唯一 PipelineCard props 已是 done 但 DOM textContent 冻结。**根因**:`send()`/`retry()` 里 `const aMsg = newSkeletonMessage(); this.messages.push(aMsg)` 后闭包持有 **raw 对象**,所有 SSE 相位突变作用在 raw target 上——Pinia reactive 代理读值正确但**突变不触发渲染**,子组件因 props 引用相等被 Vue 跳过 patch。B2 的 vitest 只断言 store 状态未测真实渲染链,故未暴露。**修复**:三处 push 后改持 `this.messages[this.messages.length - 1]`(代理)。修复后 M2 验收 **12/12 全过**(相位推进 4 快照/47s、答案 690 字、来源、图谱、SQLite 刷新恢复、无 JS 错误)。
|
||||
- **诊断探针存档**:`deploy/m2_probe{1..6}.js`(chunk 级/网络层/store 直读/组件 props/卡片枚举),保留备查。
|
||||
|
||||
## M3 功能面补齐(2026-08-25 深夜)
|
||||
|
||||
- **真实 KG 数据缺陷修复(PM)**:① 邻域 0 边——真实库边属性**无** `source_canonical_id/target_canonical_id`(端点在两端节点上),A3 组装代码从边读这两个键恒 None;改为 Cypher 投影时用 `startNode(r)/endNode(r)` 注入;② overview 0/0——1644 万边 COUNT 聚合冷缓存超过 10s 查询超时,新增 `OVERVIEW_QUERY_TIMEOUT=60s` 用于 overview 两查询;③ 自环边(真实库 `gene_structure` 等实体自身关系)在组装层过滤。验证:overview 200/200(gene 35 万)、邻域 21 节点 8 边 0 自环。
|
||||
- **部署工具**:`deploy/bff_restart.sh`(setsid 脱离 SSH + `[u]` pkill 技巧,解决 SSH 后台挂起坑)。
|
||||
- **M3 响应式接线修复(PM)**:① 平板档汉堡/工作台按钮不可见——responsive.css 规则写 `.top-bar.has-tools` 但该类挂在 `.app-shell` 上,选择器永不匹配;② 手机档工作台按钮开的是右抽屉而非全屏覆盖层——`openBench()` 未按 tier 分派;③ 覆盖层内两套 tab 条重复——隐藏 BenchPanel 内部那套保留外壳 tab 条(含关闭按钮)。
|
||||
- **M3 验收 16/16 通过**:真实 KG(实体搜索/邻域图谱/抽取任务列表)、平板抽屉(打开/Esc 关闭)、手机档(底栏隐藏/覆盖层打开/tab 切换)、主题切换(浅色 canvas 精确 #F6F8FC)。三档视口截图存 `artifacts/m3/`。
|
||||
|
||||
## M4 测试轮次(2026-08-25 凌晨)
|
||||
|
||||
- **R1 全量结果**:L1+L2 后端 119/119(行覆盖 87% 达标);L3+L4 前端 137/137(81.54% 达标);L5 Playwright 三档 19 pass/17 fail/12 skip → 收敛 8 缺陷;L2s 上游 smoke 7/7;**FR 追溯主门禁 81/81 双向零失配**。
|
||||
- **R1 缺陷 8 项已批量派发**:BUG-R1-07 Blocker(http.js 30s 读超时 < search 92s)→ B1;BUG-R1-01 Major(kg 搜索丢 q 参数)→ B1;BUG-R1-02/03/04/05/06 Major(会话高亮/删除死代码/QA 降级未禁用/错误信封误显示/markdown 零调用)→ B2;BUG-R1-08 Minor(health 索引误报)→ A3。
|
||||
- **T 侧环境说明**:远端补装 pytest-cov;曾误 `--save-dev` 动 package.json 已还原(md5 一致);证据归档 `~/5090/teamWorkspace/zh/r1_test_evidence/`。
|
||||
- **R1 修复进展**:A3 已交付 BUG-R1-08(probe 列表全等比较误报索引缺失——真实实例另有 canonical_id 约束索引;改为成员判断 + 2 锁定单测,23/90 过,真实冒烟 detail:None)。B1 已交付 BUG-R1-01/07(http.js 默认超时 600s 覆盖 API.md 全表、get 增 query 序列化、FormData 走 multipart;32 测过;隧道实测 KG 搜索 17 条含 q 参数、search 47.7s 不自断)。B2 已交付 BUG-R1-02~06(selectSession 同步 activeId;SessionItem 删除按钮+右键+确认弹层 Teleport;ChatPanel 接 health store 降级禁用;err.code 优先映射 + errText 透传;qaToBlocks/_finalizeReveal 走 renderMarkdown;70 测过,全前端 156 测)。
|
||||
- **R1 修复集成(PM)**:本地全量回归后端 121 测/前端 156 测全绿;mock 本地远端 md5 一致;BFF 重启后 health 无索引误报;R2 全量回归已派发 T。
|
||||
- **R2 全量回归通过(T)**:后端 121/121、前端 156/156、Playwright 48 条(35 pass/12 skip 依赖充值/1 flake 复跑通过)、上游 smoke 7/7、行覆盖 87%/80.59%、FR 追溯 81/81。**R1 八缺陷全确认修复**(含 E2E-01 刷新恢复补验三档全通)。无新缺陷。唯一失败为环境性 flake(tablet 主流程 300s 超时,单独复跑 52.3s 通过,上游 QA 串行排队所致)。T 清理了全部测试污染会话数据。
|
||||
- **M4 判定(PM)**:主/辅门禁全达标、产品用例全 pass → 按 TEST_PLAN 纪律收尾。收尾前采纳两处加固:① E2E 真实流超时 480s/终态等待 360s(T 测试代码);② Vite 忽略 coverage 目录(测试期覆盖率文件触发 198 次页面重载,干扰 E2E 与浏览器访问)。
|
||||
- **M5 最终验收通过(PM,2026-08-25)**:`deploy/m5_acceptance.js` 15/15 全过。生产形态打通(BFF 8090 单进程静态托管,修复 dist 路径解析:backend/frontend/dist → 项目根;sync 排除 dist 需单独同步)。真实 SQLite 历史恢复(相位卡 5/5、引用 chip 高亮定位)、真实问答全链路(答案 698 字 + 4 引用)、三档视口档位全正确。`docs/ACCEPTANCE.md` 归档。**项目收官**——遗留唯一外部依赖:DeepSeek 余额(充值即恢复,零代码改动)。
|
||||
|
||||
## DeepSeek 恢复与 QA 修复(2026-08-25 下午)
|
||||
|
||||
- **诊断**:用户充值后 QA 仍 402。实测三个 DeepSeek key:QA 旧 key(sk-9d70bc)与 PlantAI-rag key(sk-9de0a3)余额均不足;**PlantAI-dev 的 NEW_API_KEY(sk-9c0235)余额充足**。用户指示用该 key。
|
||||
- **手工重启的弯路(PM 记录,教训)**:① 整行 shlex.quote 导致 source 时变量全失效;② 保留旧进程 CONDA_*/PATH 污染新 shell 致 conda activate 崩溃;③ source 缺 `set -a` 导致变量不 export 进 uvicorn;④ runtime env 文件缺 `V15_QDRANT_URL` 等进程内联覆盖项。最终用户提供官方管理脚本解决。
|
||||
- **正确路径**:`~/5090/PlanAI-cooperate/RAG/rag-develop/start_plantAI_RAG.sh` 统一管理 QA(7920)/Neo4j(18788)/Qdrant(26333)/方法学(8000)/Ollama。换 key = 更新 `runtime/qa_v1_1_73.env`(已完成,备份 `.bak_oldkey`)+ 官方脚本 `start`。脚本显式 `QA_V1_LLM_MODEL=deepseek-chat`(flash 模型在新 key 下输出截断)、嵌入服务 `QA_V1_EMBED_URL=Ollama bge-m3`、BGE CUDA 等全套配置。
|
||||
- **验证结果**:PlantAI-rag-new 问答——**自然语言答案 1379 字 + 15 条来源**(degraded_execution=False);PlantAI-dev 知识问答——**success:True + 2671 字答案 + 14 来源**(需把 `QA_V1_1_73_TIMEOUT` 从 300 提到 600,完整链路实测 216~273s;已写入 .env 并重启 9011,备份 `.env.bak_qa_timeout`)。
|
||||
- **PlantAI-dev 9011**:仍用手动方式重启(官方 manage_plantai.sh 默认管 9000 端口),`source .env` + `set -a` 后 uvicorn 拉起,已验证 env 含新超时与 7920 地址。
|
||||
- **运维提示**:QA 服务重启一律走官方脚本 `bash start_plantAI_RAG.sh restart|start|verify`,不要手工起进程。
|
||||
@@ -0,0 +1,345 @@
|
||||
# 发现报告 — PlantAI RAG BFF 上游探测
|
||||
|
||||
> 探测日期:2026-08-24 至 2026-08-25(远程机 `100.109.52.26`,SSH 用户 xzq)
|
||||
> 本文件为团队事实基线。所有结构均来自真实探测响应或源码/OpenAPI,不是猜测。
|
||||
> 未能确认的事项集中在第 6 节,明确标注,勿当作事实使用。
|
||||
|
||||
---
|
||||
|
||||
## 1. 摘要与关键结论
|
||||
|
||||
1. **Neo4j 选型:`neo4j-plant-srt-direct-v1-full`**(bolt `bolt://127.0.0.1:18788`,HTTP `http://127.0.0.1:18575`,db `neo4j`,用户 `neo4j`,密码 `<neo4j-password-in-.env>`)。
|
||||
- 这是 QA 服务 7920 实际使用的实例(进程 `/proc/2586988/environ` 中 `NEO4J_URI=bolt://127.0.0.1:18788`),是权威答案。
|
||||
- 该实例 schema 为 **canonical**(`QA_V1_GRAPH_SCHEMA=canonical`):实体标签 `SRT2CanonicalEntity`(3,820,996 节点),断言标签 `SRT2CanonicalAssertion`(8,246,875),关系 `SRT2_CANONICAL_ASSERTS` / `SRT2_SUPPORTED_BY`(共 16,449,159 条边)。
|
||||
- **任务书假设的 `:Entity` / `[:RELATION]` / `entity_name_ftx` 全文索引在本实例不存在**。名字检索走 `name_norm` RANGE 索引(`srt2_canonical_entity_name_norm`),实测 0.09–0.10s。全文检索需求用 `STARTS WITH`/`CONTAINS` + name_norm 索引即可。
|
||||
2. **抽取接口 8000 可用**:`/health` 返回 `{"status":"healthy"}`。`extract-jobs` 有 49 个历史任务(22 completed / 27 failed)。状态枚举为 `queued → running → completed/failed`(stage 相应为 `queued → mineru → llm → completed/failed`)。**注意:`processing-status`、`progress`、`summary` 均返回 `{"rows":[]}`**(因为当前 `METHODOLOGY_BATCH_DIR` 默认指向 `methodology_batch_llm` 目录且该目录不存在,见第 6 节)。`run-log` 返回 JSON `{"text":""}`(不是纯文本)。
|
||||
3. **QA 7920 检索接口正常**:search 返回 31 个顶层键(见第 4 节),一次检索耗时约 91.9s(其中 88.8s 是 native_graph_chain_retrieval)。**当前 LLM 余额不足(DeepSeek HTTP 402 Insufficient Balance)**,`degraded_execution=true`,所有 LLM 阶段走 deterministic recovery,`answer` 为空或为 fallback 文案。这影响 chat/stream 的最终答案质量,但不影响 retrieval-only search 的证据输出。
|
||||
4. **SSE 阶段序列(有序,五阶段 UI 映射基准)**:
|
||||
`understanding_started → entities_identified → related_search_started → concepts_found → subquestion_retrieved → agent_review_started → evidence_accepted → answer_generation_started → completed`,随后 `event: complete` + 完整 JSON(与 search 同构)。有 `: keepalive` 注释帧(实测 2 次)。
|
||||
5. **端口 8090、5180 空闲**。node v18.19.1 / npm 9.2.0,`npm ping` 通(573ms),Playwright chromium 下载可行(约 150MB,未实际下载)。
|
||||
6. **7900 是加密打包容器**(`plantai-protected-rag-1`,镜像 `plantai-rag-protected:1.0.0`),其 `/api/v1/kg/*` 路由指向空的 `plantai-protected-neo4j-1`(0 节点),kg/stats 全零、kg/query 空结果——**7900 的 kg 路由目前实质不可用**。其 methodology 路由可用但数据为空。
|
||||
|
||||
---
|
||||
|
||||
## 2. Neo4j 实例探测
|
||||
|
||||
### 2.1 对比表
|
||||
|
||||
| 属性 | `neo4j-plant-srt-pc2025-v3-20260817-normalized` | `neo4j-plant-srt-direct-v1-full` | `plantai-protected-neo4j-1` |
|
||||
|---|---|---|---|
|
||||
| bolt | 127.0.0.1:19788 | 127.0.0.1:18788 | 127.0.0.1:17687 |
|
||||
| http | 127.0.0.1:19575 | 127.0.0.1:18575 | 127.0.0.1:17474 |
|
||||
| 镜像 | neo4j:5.15.0-community | neo4j:5.15.0-community | neo4j:5.15.0-community |
|
||||
| 用户名/密码 | neo4j / `<normalized-neo4j-password-in-.env>` | neo4j / `<neo4j-password-in-.env>`(容器 NEO4J_AUTH 原始值为 `password`,运行中已被改) | neo4j / `test1234` |
|
||||
| 数据卷 | `/home/xzq/phase2b-5090-staging-20260823T191150Z/plant_srt_pc2025_merge_20260817_v3_normalized/databases/neo4j/data` | `/home/xzq/home/wutx/gene-family-mas-v2/data/neo4j_plant_srt_direct_v1_docker/data` | docker 匿名卷 `/var/lib/docker/volumes/68af.../` + `/home/xzq/5090/PlanAI-cooperate/hsj-workspace/encryption-test/.../service_data/neo4j` |
|
||||
| heap 设置 | initial 16G / max 32G / pagecache 64G | initial 4G / max 12G / pagecache 24G | 无自定义(默认) |
|
||||
| 标签 | `Entity`(6,316,654), `Paper`(96,648) | `SRT2CanonicalEntity`(3,820,996), `SRT2CanonicalAssertion`(8,246,875), `SRT2Paper`(31,207) | **空库(0 节点)** |
|
||||
| 关系类型 | `ASSERTS`(12,963,472), `SUPPORTED_BY`(13,012,368) | `SRT2_CANONICAL_ASSERTS`, `SRT2_SUPPORTED_BY`(合计 16,449,159) | 无 |
|
||||
| 名字索引 | 无 name_norm 索引(CONTAINS 全扫 0.19s;name_norm 等值 6.81s——**很慢**) | `srt2_canonical_entity_name_norm` RANGE 索引(等值 0.10s / 前缀 0.09s) | 无 |
|
||||
| 全文索引 `entity_name_ftx` | **不存在** | **不存在** | 不存在 |
|
||||
| QA 7920 是否使用 | 否 | **是(bolt://127.0.0.1:18788)** | 否 |
|
||||
| 上游所属 | normalized 版(phase2b staging) | gene-family-mas-v2 直接版(QA 生产) | 加密保护产品线(空) |
|
||||
|
||||
### 2.2 节点与关系真实属性
|
||||
|
||||
**direct-v1-full(当选实例)**
|
||||
|
||||
- `SRT2CanonicalEntity` 节点属性:`entity_type`(gene/gene_family/phenotype/process/protein/experimental_material/method/compound/sequence_feature/genotype/trait/… 共 24+ 类)、`canonical_id`(`cent_*`)、`name`、`name_norm`(小写规范化)、`rule`(如 `exact_name_norm_same_type`、`alias_config_exact`)、`support_count`。
|
||||
- `SRT2CanonicalAssertion` 节点属性(BFF 证据核心,样例见下):
|
||||
`assertion_id`(`cass_*`)、`source`、`source_type`、`source_canonical_id`、`target`、`target_type`、`target_canonical_id`、`relation_norm`、`relation_category`、`best_evidence`、`best_paper_id`、`best_score`、`support_count`、`paper_count`、`title`、`doi`、`pmid`、`publication_year`、`journal`。
|
||||
- `SRT2_CANONICAL_ASSERTS` 关系属性:`assertion_id`、`best_score`、`relation_category`、`relation_norm`、`support_count`、`paper_count`。
|
||||
- `SRT2_SUPPORTED_BY` 关系:**无属性**(`{}`)。
|
||||
- `SRT2Paper` 节点属性:`paper_id`、`title`、`doi`、`pmid`、`journal`、`publication_year`、`metadata_lookup_status`、`metadata_sources`、`metadata_checked_at`。
|
||||
- 数据库列表:仅 `neo4j`(默认库)+ `system`。
|
||||
|
||||
**normalized(备选实例)**
|
||||
|
||||
- `Entity`:`entity_type`、`name`、`name_norm`、`canonical_id`、`layer`。
|
||||
- `ASSERTS` 关系属性:仅 `source_system`、`assertion_id`、`relation_category`、`relation_norm`、`layer`(**没有 evidence/pubmed/paper_id/confidence 等属性**,证据信息挂在 `SRT2CanonicalAssertion` 同构的节点/或需 join Paper 节点,未深挖)。
|
||||
- `SUPPORTED_BY` 关系:`support_id`、`assertion_id`、`layer`。
|
||||
|
||||
> 任务书预期的 `evidence/section/pubmed_id/paper_id/confidence/claim_scope/output_layer` 属性在**两个实例的关系上都未出现**;这些字段实际出现在 QA 服务返回的 `sources[].raw` 里(QA 服务在应用层拼装),图库关系本身不携带。
|
||||
|
||||
### 2.3 选型理由(当选:`neo4j-plant-srt-direct-v1-full`)
|
||||
|
||||
1. **权威性**:QA 7920 服务(产生可用的图谱证据)的进程环境明确指向 `bolt://127.0.0.1:18788`(= direct-v1-full)。BFF 直接复用该实例能保证与 QA 检索结果一致。
|
||||
2. **schema 匹配**:QA 服务的 Cypher(`direct_flow_retriever.py`、`graph_path_chain.py` 等)全部使用 `SRT2Canonical*` 标签,只存在于 direct 实例。
|
||||
3. **检索性能**:名字检索走 `name_norm` RANGE 索引,等值 0.10s / 前缀 0.09s;canonical_id 一跳扩展 0.16s。normalized 实例无 name_norm 索引(等值 6.81s),不适合交互式 BFF。
|
||||
4. **数据规模**:实体 382 万 + 断言 824 万 + 边 1644 万,远大于 protected(空)。
|
||||
5. **normalized 不满足** `entity_name_ftx` 要求(两库都没有该索引);`Entity` 标签虽存在且更大(631 万),但 QA 不用它,且其关系无证据属性。
|
||||
|
||||
**回退顺序**:① direct-v1-full(首选)→ ② normalized(`neo4j/<normalized-neo4j-password-in-.env>`,bolt 19788,schema 不同,需单独的 Cypher 适配层)→ ③ protected(空库,仅在需要读保护线数据时用,密码 `test1234`,bolt 17687)。
|
||||
|
||||
### 2.4 三条可用的 BFF Cypher(已对当选实例实测)
|
||||
|
||||
**(a) 实体名检索(精确 + 前缀,走 name_norm 索引)**
|
||||
|
||||
```cypher
|
||||
MATCH (e:SRT2CanonicalEntity)
|
||||
WHERE e.name_norm = $q_norm OR e.name_norm STARTS WITH $q_norm
|
||||
RETURN e.canonical_id, e.name, e.entity_type, e.support_count
|
||||
LIMIT 20
|
||||
```
|
||||
参数:`q_norm = toLower(用户输入)`。实测 0.09s。(模糊检索可加 `CONTAINS`,但注意 CONTAINS 不走索引;替代方案是先 `STARTS WITH` 取候选再应用层过滤。)
|
||||
|
||||
**(b) 获取单个实体属性**
|
||||
|
||||
```cypher
|
||||
MATCH (e:SRT2CanonicalEntity {canonical_id: $canonical_id})
|
||||
RETURN e {.*}
|
||||
```
|
||||
实测 0.10s(canonical_id 有唯一约束索引 `srt2_canonical_entity_id`)。名字定位:`MATCH (e:SRT2CanonicalEntity {name_norm: $name_norm}) RETURN e LIMIT 5`。
|
||||
|
||||
**(c) 有界邻域扩展(深度 N、节点/边上限,一跳实测 0.16s)**
|
||||
|
||||
```cypher
|
||||
MATCH (s:SRT2CanonicalEntity {canonical_id: $seed_id})
|
||||
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, collect(DISTINCT r1)[..$max_edges] AS edges1
|
||||
OPTIONAL MATCH (n1)-[r2:SRT2_CANONICAL_ASSERTS]-(n2:SRT2CanonicalEntity)
|
||||
WHERE n1 IN level1
|
||||
WITH s, level1, edges1,
|
||||
collect(DISTINCT n2)[..$max_nodes] AS level2,
|
||||
collect(DISTINCT r2)[..$max_edges] AS edges2
|
||||
RETURN s {.*} AS start,
|
||||
[n IN (level1 + level2) | n {.*}] AS nodes,
|
||||
[r IN (edges1 + edges2) | r {.*}] AS edges
|
||||
```
|
||||
要点:每跳分别 cap 并 `ORDER BY paper_count DESC` 优先取高质量边;`OPTIONAL MATCH` 保证孤立节点不丢;深度 >2 时按同模式追加。注意 `[...]` 截取语法在 cap 前的排序至关重要,否则超度节点(hub)会吃满预算。一跳带全属性实测 0.16s,两跳约 13s(可接受,但建议 BFF 先只做一跳 + 按需第二跳)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 抽取接口真实结构(`http://127.0.0.1:8000`,fallback `7900`)
|
||||
|
||||
服务由 `/home/xzq/5090/PlantAI-rag/scripts/start_methodology_api_8000.sh` 启动:conda `gfpy312`,工作目录 `/home/xzq/5090/PlantAI-rag`,`uvicorn api.main:app --host 0.0.0.0 --port 8000`。启动脚本自带 health 检查和 PID 文件(`runtime/methodology_api/methodology_api_8000.pid`)。当前存活,**不要重启**。
|
||||
|
||||
### 3.1 `GET /api/v1/methodology/extract-jobs`(及 `?limit=N`)
|
||||
|
||||
响应形状:`{"jobs": [...], "count": N}`(`limit` 默认 50;`?limit=5` 会截断 jobs 数组,count 仍是截断后的条数)。item 字段(全部实测出现):
|
||||
|
||||
| 字段 | 类型 | 说明/示例 |
|
||||
|---|---|---|
|
||||
| `job_id` | str | 12 位 hex,如 `2943168e9c48` |
|
||||
| `status` | str | 枚举:`queued` / `running` / `completed` / `failed`(磁盘上 49 个任务只有 completed 22 + failed 27) |
|
||||
| `stage` | str | 与 status 同步:`queued` / `mineru` / `llm` / `completed` / `failed` |
|
||||
| `substage` | str | completed 时为 `done`,failed 时为 null |
|
||||
| `message` | str | 中文进度描述 |
|
||||
| `updated_at` | str | `2026-08-07T09:42:17`(无时区) |
|
||||
| `paper_id` / `family` | str | 如 `demo_paper` / `""` |
|
||||
| `source_filename` / `source_path` | str | 上传文件名与绝对路径 |
|
||||
| `output_dir` / `batch_dir` | str | 结果目录 |
|
||||
| `stage_count` / `species_count` / `figure_table_caption_count` / `figure_table_stage_count` | int | 提取统计 |
|
||||
| `config_url` | str | `/api/v1/methodology/extract-jobs/{id}/bbav5-config` |
|
||||
| `methodology_plan_url` | str | `/api/v1/methodology/extract-jobs/{id}/plan` |
|
||||
| `methodology_sandbox_requested` | bool | |
|
||||
| `methodology_sandbox_status` / `_dir` / `_registry` / `_collection` | str | 沙箱相关 |
|
||||
|
||||
### 3.2 `GET /api/v1/methodology/extract-jobs/{job_id}`
|
||||
|
||||
单个 job 的扁平 JSON(无包裹):与列表 item 同字段 + 额外 `content_list`(mineru 结果路径)、`figure_table_files`(object,含 `figure_table_inventory`/`figure_table_inventory_csv`/`figure_table_audit`/`reproduction_plan_preview` 四个路径)、`figure_table_warning`、`figure_table_inventory_url`、`reproduction_plan_preview_url`、`log_path`(failed 时)。样例(`2943168e9c48`,已截断):
|
||||
|
||||
```json
|
||||
{"updated_at":"2026-08-07T09:42:17","job_id":"2943168e9c48","status":"completed","stage":"completed","substage":"done","message":"MinerU+LLM 方法学提取完成","source_path":"/home/xzq/5090/PlantAI-rag/data/evaluation/methodology_upload_jobs/2943168e9c48/upload/wang2019.pdf","content_list":".../mineru/wang2019/txt/wang2019_content_list.json","output_dir":".../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":"","figure_table_files":{...},"config_url":"...","methodology_plan_url":"...","figure_table_inventory_url":"...","reproduction_plan_preview_url":"..."}
|
||||
```
|
||||
未完成时(非 completed)访问 `bbav5-config`/`plan` 会返回 **409** + `{"detail":"Extraction job is not completed: ..."}`;job 不存在返回 404。
|
||||
|
||||
### 3.3 `GET /api/v1/methodology/extract-jobs/{job_id}/plan`
|
||||
|
||||
顶层:`plan_id`、`paper_id`、`gene_family`、`source_batch_dir`、`stages[]`。每个 stage 含:
|
||||
`observation_id`、`literature_stage`(如 `gene_family_identification`)、`literature_tools[]`(`tool_name/version/role/url/parameters`)、`method_components[]`(`component_type/tool/tools/parameters/evidence_sentence`)、`literature_parameters[]`(`component_type/component_tool/parameter_name/parameter_value/parameter_type/evidence_sentence`)、`inputs[]`、更多字段被截断。这是执行计划的完整结构,BFF 的 plan 视图可直接透传。
|
||||
|
||||
### 3.4 `processing-status` / `progress` / `summary` / `run-log`
|
||||
|
||||
| 接口 | 实测响应 | Content-Type |
|
||||
|---|---|---|
|
||||
| `GET .../processing-status` | `{"rows":[]}` | JSON |
|
||||
| `GET .../progress` | `{"rows":[]}` | JSON(正常时 rows 为 progress.jsonl 的逐行 JSON 对象) |
|
||||
| `GET .../summary` | `{"rows":[]}` | JSON(rows 为 summary.csv 的 dict 行) |
|
||||
| `GET .../run-log` | `{"text":""}` | JSON(**不是文本流**;text 内是日志文件原文) |
|
||||
|
||||
**空的原因是** `BATCH_DIR_NAME` 默认 `methodology_batch_llm`(目录不存在)。8000 进程环境里没有 `METHODOLOGY_BATCH_DIR` 覆盖。若 BFF 需要这些接口有数据,须用 `METHODOLOGY_BATCH_DIR=methodology_batch_demo_paper_*` 之类实际存在的目录重启服务——**此动作需团队决策,本探测未执行**。
|
||||
|
||||
### 3.5 `GET /api/v1/methodology/sandbox/example-batches`
|
||||
|
||||
```json
|
||||
{"success":true,"sandbox_root":"/home/xzq/5090/PlantAI-rag/data/evaluation/methodology_qa_sandbox","batches":[{"name":"methodology_batch_Ft_spl","path":"/home/xzq/5090/PlantAI-rag/methodology_batch_Ft_spl","has_plan":false,"has_preview":false},{"name":"methodology_batch_pecan_spl",...},{"name":"methodology_batch_sunflowers_spl",...}]}
|
||||
```
|
||||
|
||||
### 3.6 `POST /api/v1/methodology/extract-upload`(未实际上传,来源:OpenAPI + 源码)
|
||||
|
||||
multipart/form-data,**必填字段仅 `file`**:
|
||||
|
||||
| 字段 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `file` | UploadFile | (必填) | PDF 或 `*_content_list.json` |
|
||||
| `paper_id` | str(form) | `uploaded_paper` | 只保留 `[0-9A-Za-z._-]`,否则替换为 `_` |
|
||||
| `family` | str(form) | `""` | |
|
||||
| `output_name` | str(form) | `""` | 输出目录名基名,最终为 `{output_name}_{job_id}` |
|
||||
| `llm_model` | str(form) | `deepseek-v4-flash` | |
|
||||
| `llm_request_timeout` | int(form) | `75` | 秒 |
|
||||
| `sandbox` | bool(form) | `false` | |
|
||||
| `sandbox_run_id` | str(form) | `""` | |
|
||||
|
||||
成功响应:`{"success": true, "job_id": "<12hex>", "status_url": "/api/v1/methodology/extract-jobs/<job_id>"}`。任务在 FastAPI BackgroundTasks 中异步执行;状态流转:`queued` →(MinerU)`running/stage=mineru` →(LLM 提取)`running/stage=llm` → `completed`(含 `config_url`/`methodology_plan_url`/统计字段/沙箱字段)或 `failed`(`message` 为异常文本,含 `log_path`)。失败样例原因:MinerU 路径不存在(`/home/xia-lab/miniconda3/envs/minerupy311/bin/mineru`)——历史 27 个 failed 任务多为这种环境问题。
|
||||
|
||||
### 3.7 7900 上的同构接口
|
||||
|
||||
7900(容器 `plantai-protected-rag-1`)暴露同样的 `/api/v1/methodology/*` 路由(51 条路径,`extract-upload`/`extract-jobs` 均存在),但 `extract-jobs` 返回 `{"jobs":[],"count":0}`(容器内数据目录独立)。另有 `/api/v1/kg/*`(stats/families/species/query/search)指向空 Neo4j,**全部为空结果**,BFF 不应依赖 7900 的 kg 路由。源码在容器内是编译过的 `.so`(`api/kg_routes.cpython-311-x86_64-linux-gnu.so`),无法读源码,以 OpenAPI + 实测为准。
|
||||
|
||||
---
|
||||
|
||||
## 4. QA 服务响应结构(`http://127.0.0.1:7920`)
|
||||
|
||||
服务:`uvicorn scripts.qa_v1_1_73_server:app`,app-dir `/home/xzq/5090/PlanAI-cooperate/RAG/rag-qa-v1_1_73`,conda `gfpy312`,版本 `1.1.73-assertion-anchored`。启动脚本:`docs/legacy_startup_scripts/start_qa_v1_1_73_*.sh`(注意 legacy 脚本里写的 NEO4J_URI 是 17687,**与运行中进程的 18788 不一致**,以进程环境为准)。LLM:`https://api.deepseek.com` + `deepseek-v4-flash`,**当前 402 Insufficient Balance**。
|
||||
|
||||
### 4.1 `POST /api/v1/qa-v1-1-73/search`(实测 2026-08-25,1.78MB 响应,329 sources)
|
||||
|
||||
**顶层键完整清单(31 个)**:
|
||||
|
||||
| 键 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `success` | bool | true |
|
||||
| `version` | str | `"1.1.73"` |
|
||||
| `query_id` | str | `"20260825_000715_49f801a9"` |
|
||||
| `question` | str | 原问题 |
|
||||
| `answer` | str | retrieval-only 时为空串 |
|
||||
| `citation_validation` | dict | `{valid, cited_pmids[], unknown_pmids[]}` |
|
||||
| `sources` | list[329] | 证据条目(见下) |
|
||||
| `source_count` | int | 329 |
|
||||
| `evidence_chain_top30` | list[30] | 与 sources 同构(top30 精选) |
|
||||
| `provisional_answer` | str | 空 |
|
||||
| `retrieval_hypothesis` | dict | `{question_type, direct_question_entities[], hypothesized_entities[], relation_intents[], concept_queries[], answer_slots[], atomic_claims[], negative_constraints[], parse_status}` |
|
||||
| `query_plan` | dict | `{queries{}, alias_audit{}, agentic_plan{14}, retrieval_options{29}}` |
|
||||
| `retrieval_audit` | dict | `{qdrant[], seed_extraction{}, neo4j[], dedup{}, options{}, neo4j_status:"enabled", bounded_graph_backend{12}, public_citation_filter{}}` |
|
||||
| `graph_evidence_quota_audit` | dict | `{policy, top_k_for_llm, graph_min_requested, graph_candidates_available, non_graph_candidates_available, graph_selected, non_graph_selected, graph_quota_satisfied, note}` |
|
||||
| `graph_knowledge_chains` | dict | `{mode:"neo4j_native_path", chain_policy, chains[305], chain_count, audit{35}, summary{note, used_for_answer_generation}}` |
|
||||
| `native_graph_chains` | list[305] | 图链(见下) |
|
||||
| `native_graph_chain_audit` | dict | 35 个审计键(enabled/chain_source/max_hops/beam_width/top_k_chains/seed_ids/…) |
|
||||
| `assertion_anchored_graph_chains` | list | 本次为 `[]` |
|
||||
| `assertion_anchored_graph_audit` | dict | `{enabled, status, error, fallback_required, hop_policy}` |
|
||||
| `endpoint_path_audit` | dict | `{enabled:false, status:"not_attempted", fallback_to_native_seed_expansion:false}` |
|
||||
| `native_seed_policy_audit` | dict | 20 键(policy/seed_top_k_after_bge/canonical_ids/names/…) |
|
||||
| `agentic_controller_audit` | dict | `{enabled, status, strict_review_accepted, fallback_reason, attempt_count, replan_count, attempts[], final_plan{}}` |
|
||||
| `agentic_review` | dict | `{decision, accepted_evidence_ids[], accepted_chain_ids[], problems[], final_handoff, strict_review_decision, review_fallback_used, fallback_reason}` |
|
||||
| `question_decomposition` | dict | search 时为 `{}` |
|
||||
| `runtime_recovery_audit` | list | 每次 LLM 失败一条:`{stage, mode, degraded:true, error}` |
|
||||
| `degraded_execution` | bool | **true**(LLM 402) |
|
||||
| `subquestion_evidence_groups` | list | `[]`(search 时) |
|
||||
| `subquestion_retrieval_audit` | list | `[]` |
|
||||
| `cluster_audit` | list[106] | `{cluster_id, cluster_key[], representative_assertion_id, representative_srt{source,relation,target}, member_count, member_assertion_ids[], member_paper_ids[], ...}` |
|
||||
| `rerank_audit` | list[80] | `{rank, evidence_id, source, relation, target, rrf_score, bge_score, final_score}` |
|
||||
| `timings` | dict | 秒,float:`{provisional_answer, structured_planner, question_decomposition, agentic_retrieval_plan, retrieval, cluster_bge_rerank_and_select, native_graph_chain_retrieval, final_answer, total}`(本次 total 91.9s) |
|
||||
| `flow` | list[17] | 管道阶段名有序列表 |
|
||||
|
||||
**一条 `sources[]` 的全字段(26 个键,S1 实例)**:
|
||||
|
||||
| 键 | 类型 | 示例值 |
|
||||
|---|---|---|
|
||||
| `evidence_id` | str | `"S1"` |
|
||||
| `kb` | str | `"V16/V15"` |
|
||||
| `source` | str | `"SPL gene family"` |
|
||||
| `relation` | str | `"characterized_in"` |
|
||||
| `target` | str | `"Oryza sativa"` |
|
||||
| `evidence` | str | markdown 段落(`## Phylogenetic analysis of the PeSPL genes ...`,可长) |
|
||||
| `paper_id` | str | `"PMC5488377_12864_2017_Article_3882"` |
|
||||
| `title` | str | 论文标题 |
|
||||
| `pmid` | str | `"28655295"` |
|
||||
| `pmcid` | str | `"PMC5488377"` |
|
||||
| `doi` | str | `"10.1186/s12864-017-3882-4"` |
|
||||
| `species` | str | 常为空串 |
|
||||
| `section` | str | 常为空串 |
|
||||
| `output_layer` | str | `"main_kg"` |
|
||||
| `claim_scope` | str | 常为空串 |
|
||||
| `relation_id` | str | `"cass_d12f62f9abffbd903b97"`(= assertion_id) |
|
||||
| `retrieval_methods` | list[str] | `["qdrant_vector"]` |
|
||||
| `retrieval_queries` | list[str] | 3 条查询串 |
|
||||
| `vector_score` | float | 0.714 |
|
||||
| `rrf_score` | float | 1.114 |
|
||||
| `rerank_score` | float | 0.989 |
|
||||
| `final_score` | float | 0.982 |
|
||||
| `cluster_id` | str | `"clu_0f8da21642adc4f0"` |
|
||||
| `cluster_member_count` | int | 3 |
|
||||
| `cluster_member_assertion_ids` | list[str] | 3 个 cass_* |
|
||||
| `cluster_member_paper_ids` | list[str] | 2 个 |
|
||||
| `cluster_member_pmids` | list[str] | 1 个 |
|
||||
| `raw` | dict | 28 键,完整断言原样:`kb_version, source_system, assertion_id, relation_id, source, source_type, source_canonical_id, relation, relation_norm, relation_category, target, target_type, target_canonical_id, evidence, best_evidence, paper_id, best_paper_id, support_count, paper_count, quality_score, best_score, embedding_text, cluster_id, cluster_key[], cluster_member_assertion_ids[], cluster_member_count, cluster_member_paper_ids[], cluster_member_pmids[]` |
|
||||
|
||||
**`native_graph_chains[]` item**:`{chain_id:"G1", chain_source:"neo4j_native_path", hop_count:3, path_score:0.861, start:"SPL gene", end:"18 SPL genes in rice", bridge_entities[], hops[], score_breakdown{22}}`。
|
||||
**hop**:`{hop_index, from_id, from_name, to_id, to_name, source, relation, target, source_canonical_id, target_canonical_id, source_type, target_type, relation_category, assertion_id, paper_id, title, pmid, pmcid, doi, species, evidence, support_count, paper_count, best_score, edge_score, score_breakdown, passive_relation, structural_direction, semantic_direction}`。
|
||||
|
||||
### 4.2 `POST /api/v1/qa-v1-1-73/chat/stream` SSE 实测
|
||||
|
||||
帧格式:`event: <name>` + `data: <JSON>` 两行一组,空行分隔;期间有 `: keepalive` 注释帧(实测 2 次)。
|
||||
|
||||
**有序 stage 序列(data.stage,五阶段 UI 映射基准)**:
|
||||
|
||||
1. `understanding_started` — details `{}`
|
||||
2. `entities_identified` — details `{entities:["SPL gene family"], question_type:"raw_question"}`
|
||||
3. `related_search_started` — details `{}`
|
||||
4. `concepts_found` — details `{concepts[], concept_count:250, connection_count:335, publication_count:109, candidate_count:328}`
|
||||
5. `subquestion_retrieved` — details `{subquestion_id:"Q0", attempt:1, candidate_count:30, connection_count:305}`
|
||||
6. `agent_review_started` — details `{attempt:1}`
|
||||
7. `evidence_accepted` — details `{concepts[], concept_count, connection_count, publication_count, accepted_count:30}`
|
||||
8. `answer_generation_started` — details `{}`
|
||||
9. `completed` — details `{elapsed_seconds:38.357}`
|
||||
|
||||
**终端帧**:`event: complete` + `data: <完整 JSON>`(与 search 响应同构,约 2.37MB;顶层键同 search 但 `citation_validation` 12 键、`sources` 仅 4 条、`timings` 10 键、`question_decomposition` 13 键、`subquestion_evidence_groups` 1 项、`agentic_controller_audit` 12 键、`agentic_review` 7 键)。**LLM 402 影响**:`answer` 为 fallback 文案 `"The retrieval pipeline completed. The answer model did not return usable text; the most relevant retrieved evidence facts are: - SPL gene family has domain SBP box [PMID: 33234605] ..."`(事实列表而非自然语言答案),`degraded_execution=true`,`runtime_recovery_audit` 4 条 LLM 失败记录。全流程耗时约 37–38s(不带 keepalive 间隙)。
|
||||
|
||||
**health**:`/api/v1/qa-v1-1-73/health` → `{"status":"ok","service":"qa_v1_1_73","version":"1.1.73-assertion-anchored"}`。
|
||||
**info**:返回服务名、管道描述、config(alias_path/taxonomy_path/reranker 模型路径/knowledge_chain_defaults 等)。
|
||||
其他路由:`/chat`(非流式)、`/search`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 环境与启动脚本
|
||||
|
||||
### 5.1 `/home/xzq/5090/PlantAI-rag/scripts/` 清单(15 个)
|
||||
|
||||
| 脚本 | 用途(据头部注释/内容) |
|
||||
|---|---|
|
||||
| `download_marker_models.sh` | 手动下载 marker-pdf 模型(解决自动下载中断) |
|
||||
| `organize_project.sh` | gene-family-mas-v2 项目文件整理(建目录结构) |
|
||||
| `process_all_pdfs.sh` | 批量处理 `gfpaper` 目录 PDF |
|
||||
| `process_by_inode_shell.sh` | 用 inode 处理乱码文件名 PDF → rag_index_v3 |
|
||||
| `process_failed_by_inode.sh` | 用 inode 重处理 failed_to_process 目录 → rag_index_v4 |
|
||||
| `run_v15_3_4_q1_5papers_smoke_20260604.sh` | V15.3.4 Q1 5 篇论文冒烟测试(gfpy312) |
|
||||
| `run_v15_3_4_q1_remaining_500_20260604.sh` | V15.3.4 Q1 剩余 500 篇批量(加载 .env.v15_deepseek) |
|
||||
| `run_v16_rerun6_20260625.sh` | V16 batch10 重跑 6(/data/gene-family-mas-v2) |
|
||||
| `start_dual_kb_qa_api_7902.sh` | 双 KB QA API 7902(/data/gene-family-mas-v2) |
|
||||
| `start_dual_kb_qa_api_8000.sh` | **历史名**;实际启动 `combined_qa_lightrag_keyword_server` 于 **7905**(V15+PC Qdrant 检索) |
|
||||
| `start_methodology_api_8000.sh` | **当前 8000 的启动脚本**(见 5.2) |
|
||||
| `start_mineru_api_50215_20260605.sh` | MinerU API 50215 |
|
||||
| `start_qa_v1_0_0_7908.sh` | QA v1.0.0 7908 |
|
||||
| `start_taxonomy_bge_unified_7907.sh` | taxonomy+BGE 统一服务 7907 |
|
||||
| `start-webui.sh` | WebUI(端口 8787,state 在 ~/.hermes/webui-mas-v2) |
|
||||
|
||||
### 5.2 三个上游服务
|
||||
|
||||
| 服务 | 端口 | 启动方式 | conda / 工作目录 | 重启安全性 |
|
||||
|---|---|---|---|---|
|
||||
| QA v1.1.73(graph QA) | 7920 | `python -m uvicorn scripts.qa_v1_1_73_server:app --app-dir /home/xzq/5090/PlanAI-cooperate/RAG/rag-qa-v1_1_73 --host 127.0.0.1 --port 7920`(PID 2586988,父进程 1 = 守护化;legacy 脚本在 `rag-qa-v1_1_73/docs/legacy_startup_scripts/`,其硬编码 URI 17687 已过时,**不能照抄**) | `gfpy312`;环境来自 `RAG/runtime/qa_v1_1_73.env` + 进程内联 env(`NEO4J_URI=bolt://127.0.0.1:18788`、`NEO4J_PASSWORD=<neo4j-password-in-.env>`、`V15_QDRANT_URL=http://127.0.0.1:26333`、collection `plant_srt_direct_v1_bge_m3_20260702`) | 正在服务流量,**未验证重启安全性**;重启前需确认无长任务依赖(LLM 目前 402 已降级,重启不影响数据) |
|
||||
| Methodology API | 8000 | `/home/xzq/5090/PlantAI-rag/scripts/start_methodology_api_8000.sh`(脚本自带 health 检查 + 幂等启动,health 通过则直接退出) | `gfpy312`;工作目录 `/home/xzq/5090/PlantAI-rag`;日志 `runtime/methodology_api/methodology_api_8000.log` | 脚本幂等、支持 `METHODOLOGY_API_PORT`/`METHODOLOGY_BATCH_DIR` 覆盖;停止后重跑脚本即可。**当前别重启**(本探测依赖其持续在线) |
|
||||
| Protected RAG | 7900 | Docker 容器 `plantai-protected-rag-1`(镜像 `plantai-rag-protected:1.0.0`,compose:`hsj-workspace/encryption-test/server/protected_docker_packager/docker-compose.yml`),同一 compose 栈含 web/workers/redis/qdrant/neo4j 等 8+ 容器 | 容器内 `python -m uvicorn api.app:app`(2 workers),`NEO4J_URI=bolt://neo4j:7687`(compose 内部) | 容器健康检查通过;其 kg 路由数据为空(protected Neo4j 空库),**BFF 不应依赖** |
|
||||
|
||||
### 5.3 其他环境事实
|
||||
|
||||
- 端口 8090、5180:`ss -tln` 无监听 → **空闲可用**。
|
||||
- `node -v` = v18.19.1,`npm -v` = 9.2.0;`npm ping` PONG 573ms → Playwright chromium 下载可行(约 150MB,未实际执行)。
|
||||
- Qdrant 端口映射:QA 用 `qdrant-qa-v1-1-73-full` 26333;normalized 用 `qdrant-plant-srt-pc2025-v3-20260817-normalized` 27333;protected 用 `plantai-protected-qdrant-1` 16333。
|
||||
- 全部 docker 容器(13 个)状态 Up,无异常退出。
|
||||
|
||||
---
|
||||
|
||||
## 6. 遗留问题与风险
|
||||
|
||||
1. **QA LLM 余额不足(最重要风险)**:DeepSeek API 402 Insufficient Balance。所有 LLM 阶段(planner/agentic/review/answer)降级为 deterministic recovery。**chat/stream 目前无法产出自然语言答案**(只有证据列表 fallback),BFF 五阶段 UI 中"答案生成"阶段会看到降级行为。充值时注意:`QA_V1_LLM_BASE_URL=https://api.deepseek.com`,`QA_V1_LLM_MODEL=deepseek-v4-flash`、`QA_V1_FINAL_LLM_MODEL=deepseek-v4-flash`、`QA_V1_LLM_MODEL`(实际 env) = `deepseek-chat` 并存(进程 env 与 runtime env 文件略有差异,未确认哪行最终生效)。
|
||||
2. **`entity_name_ftx` 全文索引不存在于任何实例**。若 BFF 必须全文检索,需要团队决策是否创建(会修改共享实例的 schema——本探测未创建任何东西)。
|
||||
3. **normalized 实例名字检索慢**(name_norm 无索引,等值 6.81s):若团队将来切到 normalized(`Entity` 标签 631 万),需要先建索引。
|
||||
4. **methodology `processing-status`/`progress`/`summary`/`run-log` 当前空数据**:原因是 `METHODOLOGY_BATCH_DIR` 指向不存在的 `methodology_batch_llm` 目录(默认值)。未确认哪个目录名才是"有数据"的正确批次目录(历史任务输出在 `data/evaluation/0505/methodology_batch_demo_paper_*`)。需要读有数据的目录必须重启 8000 并设 env——未执行。
|
||||
5. **QA 7920 的正式启动脚本未找到**:只有 legacy 脚本且其中 NEO4J_URI(17687)与运行中进程(18788)矛盾。进程由谁、以何完整 env 启动,未确认。以 `/proc/2586988/environ` 实测为准。
|
||||
6. **failed 任务(27 个)失败原因**:主要是 MinerU 二进制路径不存在(`/home/xia-lab/miniconda3/envs/minerupy311/bin/mineru`)。上传新 PDF 是否会再次失败取决于 MinerU 环境是否修复——**未测试实际上传**(按任务要求未执行)。
|
||||
7. **7900 的 kg 路由源码不可读**(编译 .so),其 schema 以 OpenAPI + 实测为准;kg 数据为空的原因是 protected Neo4j 空库,后续若保护线灌库,kg 路由可能恢复。
|
||||
8. **一次 search 约 92s**(88.8s 在 native_graph_chain_retrieval),stream 约 37s。BFF 需要长超时(>120s)和进度展示;未做并发压测。
|
||||
9. **`SSH_CONNECTION` 显示会话来自 192.168.0.142 → 192.168.0.175**:远程机在 192.168.0.175(通过 Tailscale IP 100.109.52.26 可达)。BFF 若部署在远程机上,绑定 127.0.0.1 的端口只能本机访问——BFF 与本机服务同机部署即可;跨机需要另开隧道,未做。
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
# PRD.md —— PlantAI-rag-new 产品需求文档
|
||||
|
||||
> 版本 1.0 | 作者 PM | 本文件由 PM 独占写入,所有开发/测试 agent **只读**
|
||||
>
|
||||
> **本文件是全项目需求的唯一来源。** 开发 agent 按此实现,测试 agent 按此写用例。
|
||||
> 每个功能点有唯一编号 `FR-<模块>-<序号>`,测试用例必须在追溯矩阵中与之一一对应。
|
||||
> 需求有歧义时,向 PM 提出,**不要自行解释**。
|
||||
|
||||
---
|
||||
|
||||
## 0. 产品定位
|
||||
|
||||
面向植物生物信息学研究者的**文献知识问答工作台**。用户提出关于植物基因家族的科学问题,系统从已构建的文献知识库(约 100 篇论文、44,595 条关系断言)中检索证据,生成有引用锚定的答案,并展示完整的检索过程、证据来源与知识图谱。同时提供论文抽取任务的管理入口。
|
||||
|
||||
**使用场景**:桌面深度研究为主,**手机端随时查询为重要补充**(实验室、会议、田间)。
|
||||
|
||||
**不做什么**:不做多轮对话记忆(上游 `max_rounds:1`,每次提问独立检索);不做文献全文阅读器;不做基因家族分析流程执行(那是 PlantAI-dev 的职责)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 术语表
|
||||
|
||||
| 术语 | 含义 |
|
||||
|---|---|
|
||||
| 会话 Session | 一组问答的容器,有标题、状态、时间。**注意:会话是组织单位,不是上下文单位**——同一会话内的多次提问互相独立 |
|
||||
| 消息 Message | 会话内的一条记录,`role` 为 `user` 或 `assistant` |
|
||||
| 块 Block | 助手消息的内容单元,判别式联合类型(text/table/code/stream/pipeline/tool/sources/graph) |
|
||||
| 来源 Source | 一条支撑答案的文献证据,含标题、片段、分数、PMID/DOI 等 |
|
||||
| 证据链 Chain | 知识图谱上的一条多跳推理路径,由若干 hop 组成 |
|
||||
| 阶段 Stage | 上游 QA 服务推送的细粒度检索步骤(十余个) |
|
||||
| 相位 Phase | 前端展示用的五阶段归并(规划/检索/证据/生成/完成) |
|
||||
| 上游 Upstream | 现有的三个后端服务:QA(7920)、抽取(8000/7900)、Neo4j |
|
||||
| BFF | 本项目的适配层后端,前端唯一直连对象 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 全局约定
|
||||
|
||||
### 2.1 状态枚举
|
||||
|
||||
**会话状态** `idle | running | done | error`
|
||||
- `idle` 排队中/新建未提问 —— 空心点,ink-subtle
|
||||
- `running` 有进行中的问答 —— 青色 `#00D4FF` 脉冲点
|
||||
- `done` 最近一次问答成功 —— success `#2ED3A0` 实心点
|
||||
- `error` 最近一次问答失败或中断 —— error `#FF5C7A` 实心点
|
||||
|
||||
**消息状态** `streaming | done | error`
|
||||
|
||||
**相位状态** `waiting | running | done`(对应字形 ◌ / ● / ✓)
|
||||
|
||||
### 2.2 错误处理原则
|
||||
|
||||
- BFF 一切错误返回统一信封 `{"ok":false,"error":{"code","message","degraded"}}`,HTTP 状态码语义正确。
|
||||
- 前端**不显示原始异常堆栈**,显示 `error.message` 的中文可读文案。
|
||||
- `degraded:true` 表示「上游不可用」而非「请求有误」,UI 用降级提示(横幅/离线态)而非错误弹窗。
|
||||
- **失败必须可见**:任何静默吞掉错误的实现视为缺陷。
|
||||
|
||||
### 2.3 文案约定
|
||||
|
||||
用户可见文案统一走 `src/i18n/labels.js`。例外:后端返回的 `error.message` 直接展示(后端已中文化)。
|
||||
|
||||
### 2.4 数据不伪造原则
|
||||
|
||||
UI 只展示后端真实返回的数据。**禁止**:占位假数据流入生产路径、把空结果渲染成看起来有内容的样子、用动画暗示实际不存在的流式过程(客户端揭示动画是明示的视觉设计,允许,但不得声称是 token 流)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 模块与编号
|
||||
|
||||
| 模块 | 前缀 | 范围 |
|
||||
|---|---|---|
|
||||
| 应用外壳 | `FR-SHELL` | 三栏布局、顶栏、状态栏、折叠、背景 |
|
||||
| 主题与无障碍 | `FR-THEME` | 深浅主题、动效降级、焦点、对比度 |
|
||||
| 会话管理 | `FR-SESSION` | 列表、分组、增删改查、搜索 |
|
||||
| 对话与问答 | `FR-CHAT` | 消息渲染、作曲器、流式、流水线、持久化 |
|
||||
| 检索过程 | `FR-RETRIEVAL` | 工作台 Tab1 |
|
||||
| 文献来源 | `FR-SOURCE` | 工作台 Tab2 |
|
||||
| 提取任务 | `FR-EXTRACT` | 工作台 Tab3 |
|
||||
| 知识图谱 | `FR-KG` | 工作台 Tab4 |
|
||||
| 响应式 | `FR-RESP` | 四档断点与移动端专项 |
|
||||
| 健康与降级 | `FR-HEALTH` | 上游探活与降级行为 |
|
||||
|
||||
---
|
||||
|
||||
## 4. FR-SHELL 应用外壳
|
||||
|
||||
| 编号 | 功能点 | 业务逻辑与边界 | 验收要点 |
|
||||
|---|---|---|---|
|
||||
| **FR-SHELL-001** | 桌面三栏网格 | `grid-template-columns: 260px minmax(0,1fr) 380px`;`grid-template-rows: 48px minmax(0,1fr) 28px`;区域 `"top top top" / "side chat bench" / "status status status"`。页面本身不滚动(`body{overflow:hidden}`),滚动发生在各栏内部 | 1440×900 下三栏宽度精确;缩放窗口不出现横向滚动条 |
|
||||
| **FR-SHELL-002** | 顶栏 | 高 48px。左:9px 渐变发光圆点 + 「海古纪 PlantAI」渐变文字(`background-clip:text`)。中:当前会话标题(超长省略)+ 状态徽章。右:主题切换按钮。背景 `color-mix(in srgb, var(--pa-canvas) 88%, transparent)` + `backdrop-filter: blur(14px)`,底部 1px hairline | 无会话时中部显示「就绪」徽章;标题超长省略而非换行 |
|
||||
| **FR-SHELL-003** | 底部状态栏 | 高 28px。左:4 个 7px agent 状态点(规划/检索/证据/生成),与流水线相位联动。右:`PlantAI RAG v0.1` + mono 的 QA 版本号 + 会话数 + 上游健康标记 | 问答进行中四点按相位依次点亮;QA 版本号来自 `/api/qa/info` |
|
||||
| **FR-SHELL-004** | 左栏折叠 | 折叠后宽 64px 图标轨,显示 3 个 34×34 图标按钮(会话/图谱/设置),当前项左侧 2px 渐变竖条。折叠状态写入 localStorage `pa-side-collapsed` | 折叠/展开过渡 300ms `ease-out`;刷新后保持 |
|
||||
| **FR-SHELL-005** | 右栏折叠 | 折叠后保留 28px 手柄条(**折叠 ≠ 隐藏**),点击手柄重新展开。状态写入 localStorage `pa-bench-collapsed` | 折叠后手柄可见可点;刷新后保持 |
|
||||
| **FR-SHELL-006** | Aurora 背景 | 固定层,`z-index:0`,`pointer-events:none`。两个 560/620px 渐变光斑,`blur(120px)`,`aurora-drift` 16s 往复;叠加 48px 点阵,顶部渐隐遮罩。浅色主题下 `--pa-aurora-opacity:.08` | 不遮挡任何交互;浅色下明显衰减 |
|
||||
| **FR-SHELL-007** | 首屏初始化 | 顺序:读 localStorage 主题并写 `documentElement.dataset.theme`(**必须在挂载前**,避免闪白)→ 挂载应用 → 并行请求会话列表与 `/api/health`、`/api/qa/info` → 无会话则显示欢迎空态 | 深色主题下刷新无白屏闪烁;接口失败不阻塞渲染 |
|
||||
|
||||
---
|
||||
|
||||
## 5. FR-THEME 主题与无障碍
|
||||
|
||||
| 编号 | 功能点 | 业务逻辑与边界 | 验收要点 |
|
||||
|---|---|---|---|
|
||||
| **FR-THEME-001** | 双主题等价 | 深色为默认。浅色**不是降级稿**,是同一套语义的另一组令牌值。所有组件必须在两套主题下都正确 | 逐组件走查两套主题无失色、无对比度不足 |
|
||||
| **FR-THEME-002** | 切换与持久化 | 顶栏按钮切换,写 `localStorage['pa-theme']`,同步 `documentElement.dataset.theme`。图标日/月互换并 rotate 180°(400ms) | 切换即时生效;刷新保持 |
|
||||
| **FR-THEME-003** | 浅色 glow 衰减 | 浅色下所有辉光乘 `--pa-glow-opacity:.35`——浅底上不衰减会糊 | 浅色下按钮悬停辉光明显弱于深色 |
|
||||
| **FR-THEME-004** | 动效降级 | `@media (prefers-reduced-motion: reduce)` 下关闭全部 animation/transition;状态改由静态符号表达(实心/空心/✓/✕),不依赖运动传达信息 | 开启系统「减少动态效果」后无任何动画,且状态仍可辨认 |
|
||||
| **FR-THEME-005** | 焦点与键盘 | `:focus-visible` 输出 2px `rgba(94,106,210,.5)` 焦点环,offset 1px。所有交互元素键盘可达,Tab 顺序符合视觉顺序 | 纯键盘可完成:新建会话→输入→发送→切换 tab |
|
||||
| **FR-THEME-006** | 对比度 | 深色 ink/canvas ≈15:1,ink-muted/canvas ≥4.5:1;浅色同标准 | 抽检主要文本对比度达标 |
|
||||
|
||||
---
|
||||
|
||||
## 6. FR-SESSION 会话管理
|
||||
|
||||
| 编号 | 功能点 | 业务逻辑与边界 | 验收要点 |
|
||||
|---|---|---|---|
|
||||
| **FR-SESSION-001** | 列表与分组 | 按 `updated_at` 倒序,分「今天 / 昨天 / 更早」三组,组标签 11px/500/字距0.6px。空列表显示引导文案 | 跨零点后分组正确迁移 |
|
||||
| **FR-SESSION-002** | 状态点四态 | 见 §2.1。`running` 脉冲动画,其余静态 | 四种状态视觉可区分,且不仅靠颜色(形状:空心/实心 + 动/静) |
|
||||
| **FR-SESSION-003** | 新建会话 | 顶部渐变按钮(40px 高)。行为:调 `POST /api/sessions` 创建 → 切为活动会话 → 对话区显示欢迎空态。**不预先落一条空消息** | 连点两次产生两个独立会话,不重复 |
|
||||
| **FR-SESSION-004** | 选择与加载 | 点击会话 → `GET /api/sessions/{id}` 拉取消息 → 渲染历史。加载中显示骨架屏。切换会话时若有进行中的流,**中断该流**并把原会话置 `error`(标注「已中断」) | 历史消息含表格/图谱块能正确重渲染 |
|
||||
| **FR-SESSION-005** | 搜索过滤 | 搜索框对 `title` 做不区分大小写的子串匹配,实时过滤(前端过滤,不发请求)。无匹配显示「无匹配会话」 | 输入中文/英文均可过滤;清空恢复全量 |
|
||||
| **FR-SESSION-006** | 重命名 | 双击标题或右键菜单进入编辑,回车提交 `PATCH /api/sessions/{id}`,Esc 取消。空标题不允许提交 | 重命名后列表与顶栏标题同步更新 |
|
||||
| **FR-SESSION-007** | 删除 | 需二次确认。确认后 `DELETE /api/sessions/{id}`(级联删消息)。若删的是当前会话,切到列表中最新一个;列表空则显示欢迎空态 | 删除后刷新不再出现;消息一并清除 |
|
||||
| **FR-SESSION-008** | 标题自动生成 | 新会话首次提问后,用问题前 30 字符(超出加省略号)作为标题,调 `PATCH` 落库。用户手动改过标题的会话(`meta.title_manual=true`)不再自动覆盖 | 首问后标题自动变化;手动改名后再提问不被覆盖 |
|
||||
| **FR-SESSION-009** | 活动态高亮 | 左侧 2px 渐变竖条 + `color-mix(in srgb, var(--pa-g-to) 8%, transparent)` 底 + 22% 内描边 | 活动项一眼可辨,与悬停态区分 |
|
||||
|
||||
---
|
||||
|
||||
## 7. FR-CHAT 对话与问答(核心模块)
|
||||
|
||||
### 7.1 基础渲染
|
||||
|
||||
| 编号 | 功能点 | 业务逻辑与边界 | 验收要点 |
|
||||
|---|---|---|---|
|
||||
| **FR-CHAT-001** | 欢迎空态 | 消息为空时显示:46px 渐变菱形(✧)+ 渐变标题「海古纪 PlantAI 知识工作台」+ 副标题 + **3 张建议卡**(示例问题)。点击建议卡直接以该文本提问 | 卡片悬停上浮 2px + 青色微光;点击即发起真实提问 |
|
||||
| **FR-CHAT-002** | 用户消息 | 右对齐,最大宽 70%,渐变底(深色 `135deg,#2A2A3E,#1C1C2A`;浅色 `#E8ECFF,#F4FBFF`)+ hairline-strong 边,r12,padding 10/15px | 长文本正确换行不溢出 |
|
||||
| **FR-CHAT-003** | 助手消息 | **无底色全文**(Linear 式),左缘 2px 透明竖线悬停变渐变。头部:24px 渐变头像(✦)+ 「PlantAI 智能体」+ 时间。内容按块联合顺序渲染 | 多块混排顺序正确 |
|
||||
| **FR-CHAT-004** | Markdown 渲染 | `markdown-it` 解析 → DOMPurify 消毒 → `v-html`。表格加 `md-table` 类:thead 用 2px 渐变下边框,数据格 mono(表头为「说明」的列除外,保持比例字体);代码块 code-bg + hairline + mono 12.5px | LLM 输出的表格/列表/加粗正确渲染;**注入脚本被消毒** |
|
||||
| **FR-CHAT-014** | 自动滚动 | 新消息或流式更新时滚到底。**例外**:用户手动向上滚动后暂停自动滚动,并显示「回到最新」按钮;点击或用户滚回底部后恢复 | 阅读历史时不被强制拽到底 |
|
||||
| **FR-CHAT-015** | 内联引用 | 答案文本中的引用标记渲染为可点 chip,点击滚动定位到工作台「文献来源」中对应条目并高亮 2s | 引用 chip 与来源列表编号一致 |
|
||||
|
||||
### 7.2 作曲器
|
||||
|
||||
| 编号 | 功能点 | 业务逻辑与边界 | 验收要点 |
|
||||
|---|---|---|---|
|
||||
| **FR-CHAT-005** | 输入框 | r16 surface-1 卡片,`:focus-within` 渐变描边 + 青色辉光。textarea 无边框透明,14px/1.6,min-height 46px,**max-height 160px 后内部滚动**。占位符提示示例问题 | 多行输入自动增高至上限后滚动 |
|
||||
| **FR-CHAT-006** | 三种模式 | pill 切换,参数集定义在 `config/modes.js`:<br>**问答** `{use_llm:true}` → `/api/qa/chat/stream`<br>**方案** `{use_llm:true, native_chain_enabled:true, native_chain_max_hops:3, native_chain_top_k:5}` → 同上,深证据链<br>**检索** `{use_llm:false}` → `/api/qa/search`,只出来源不出答案<br>激活态渐变底 + 白字 + 青光 | 三种模式发出的请求体字段正确;检索模式不产生答案块 |
|
||||
| **FR-CHAT-007** | 发送与快捷键 | `Enter` 换行,`⌘/Ctrl+Enter` 发送。发送按钮渐变 pill,空输入或忙碌时 `disabled`(opacity .45)。底部提示文案说明快捷键 | 快捷键在中英文输入法下均生效;空输入不可发送 |
|
||||
| **FR-CHAT-011** | 停止/中断 | 流式进行中,发送按钮变为「停止」。点击 → `AbortController.abort()` → 关闭 SSE → 当前消息置 `error` 并标注「已停止」→ 已收到的块保留不丢 | 停止后 BFF 侧上游连接也关闭(不留悬挂请求) |
|
||||
|
||||
### 7.3 流式问答(最复杂的部分,详细说明)
|
||||
|
||||
**FR-CHAT-008 流式问答主流程**
|
||||
|
||||
业务逻辑,按时序:
|
||||
|
||||
1. 用户发送 → 前端立即在界面追加用户消息气泡 → `POST /api/sessions/{id}/messages` 落库(`role:user`)
|
||||
2. 会话状态置 `running`,左栏状态点开始脉冲,底栏 agent 点复位
|
||||
3. 前端追加一条助手消息骨架(`status:streaming`),内含一个 `pipeline` 块,五相位全为 `waiting`
|
||||
4. `fetch` + `ReadableStream` 打开 `POST /api/qa/chat/stream`(**不能用 EventSource**——请求是带 JSON body 的 POST)
|
||||
5. 逐帧消费:
|
||||
- `event: progress` → 取 `phase` 字段(**BFF 已计算好,前端不解析上游 stage 名**)→ 更新 `pipeline` 块对应相位为 `running`,此前相位置 `done`;同时把该事件的 `details` 追加为工作台「检索过程」的一张阶段卡
|
||||
- `event: complete` → payload 即完整归一化响应 → 用 `answer` 生成 `text` 块(走揭示动画)、`sources` 生成 `sources` 块、图谱数据生成 `graph` 块;`pipeline` 全部置 `done`
|
||||
- `event: error` → 消息置 `error`,展示 `error.message`,保留已收到的块
|
||||
- `event: timeout` → 同 error,文案为「检索超时」
|
||||
- `event: raw` → 记入 `raw`,不渲染
|
||||
- `:` 注释帧(keepalive)→ 忽略
|
||||
6. 每 ~3s 调 `PATCH /api/sessions/{id}/messages/{mid}` 做检查点持久化
|
||||
7. 终态:消息置 `done`/`error` 落库,会话状态同步,底栏 agent 点定格
|
||||
|
||||
边界与异常:
|
||||
- **上游最长 240s**:期间必须持续有视觉反馈(相位卡 + shimmer + 已耗时计数),不得让用户面对静止界面
|
||||
- **网络中断**(移动端切网常见):`fetch` reject → 置 `error`,文案「连接中断」,提供「重试」按钮。**不自动重连**——每次 LLM 调用昂贵,自动重试会放大成本
|
||||
- **页面刷新中途**:重载后显示最后检查点内容 + 「已中断」标注,会话状态 `error`
|
||||
- **同一会话并发提问**:忙碌时发送按钮 disabled,从交互上杜绝
|
||||
|
||||
**FR-CHAT-009 五阶段流水线卡**
|
||||
|
||||
| 相位 | 颜色令牌 | 上游 stage(由 BFF 归并) |
|
||||
|---|---|---|
|
||||
| 规划 | `--pa-t-planning` `#C0A8DD` | `understanding_started`、`entities_identified`、`related_search_started` |
|
||||
| 检索 | `--pa-t-execution` `#9FBBE0` | `concepts_found`、`subquestion_retrieved`、`agent_review_started`、`subquestion_retry_started` |
|
||||
| 证据 | `--pa-t-knowledge` `#9FC9A2` | `evidence_accepted`、`evidence_review_completed` |
|
||||
| 生成 | `--pa-t-validation` `#DFA88F` | `answer_generation_started` |
|
||||
| 完成 | `--pa-t-done` `#C08532` | `completed` |
|
||||
|
||||
渲染:纵向时间线,每相位 = 16px 状态点(✓ done / ● running / ◌ waiting)+ 2px 连接线 + 相位名 + 描述(取自最近一次该相位事件的摘要)+ 右侧状态文字;running 相位额外显示 11px 旋转 spinner。卡片头部「⚙ 检索流水线」+ 整体状态徽章(语义色)。
|
||||
|
||||
**关键约束**:相位色只回答「哪一步在做什么」,语义色只回答「整体成败」,两者不得混用。
|
||||
|
||||
**FR-CHAT-010 答案揭示动画**
|
||||
|
||||
上游**不做 token 级流式**,完整答案在 `complete` 一次到达。前端以 30ms/字符的速度揭示,尾部显示 8×15px 青色闪烁光标(`blink` 1s),下方显示 3px shimmer 进度线。揭示期间用户可点击「跳过」立即显示全文。揭示结束替换为最终 `text` 块。
|
||||
|
||||
边界:答案超过 2000 字符时,揭示速度自动加快(总时长上限 8s),避免长答案等待过久。
|
||||
|
||||
**FR-CHAT-012 错误与重试**
|
||||
|
||||
- 上游返回 HTTP≥400 且 `detail.error_code == "invalid_llm_citation"`:BFF 已自动重试一次;若仍失败,前端展示「引用校验失败,请重试」并提供重试按钮
|
||||
- 其他错误:展示 `error.message`,提供重试按钮
|
||||
- 重试 = 用相同问题与模式重新发起,**在同一条助手消息位置替换**,不新增用户消息
|
||||
|
||||
**FR-CHAT-013 消息持久化与恢复**
|
||||
|
||||
- 块联合完整落库(`stream` 块为瞬态,**不落库**,落库时已转为 `text`)
|
||||
- `raw` 字段保存完整归一化响应,供排查与重渲染
|
||||
- 刷新后从 `GET /api/sessions/{id}` 恢复全部块,包括表格、来源、图谱
|
||||
|
||||
---
|
||||
|
||||
## 8. FR-RETRIEVAL 检索过程(工作台 Tab1)
|
||||
|
||||
| 编号 | 功能点 | 业务逻辑与边界 | 验收要点 |
|
||||
|---|---|---|---|
|
||||
| **FR-RETRIEVAL-001** | 阶段卡列表 | 每个 SSE `progress` 事件生成一张卡,沿用原型「工具调用卡」视觉:折叠头(相位色图标 + 阶段中文名 + 状态点 + 耗时 mono)+ 展开体(`details` 以 mono 参数块呈现 + 摘要行) | 一次问答后卡片数量与收到的 progress 事件数一致 |
|
||||
| **FR-RETRIEVAL-002** | 相位筛选 | chip:全部/规划/检索/证据/生成。激活态实心 `#5E6AD2` 底 + 白字 | 筛选后仅显示对应相位卡片 |
|
||||
| **FR-RETRIEVAL-003** | 展开折叠 | 点击头部切换,chevron 旋转 90°,展开体 `fade-slide` 200ms。**运行中的卡默认展开**,完成后自动折叠 | 展开状态互相独立 |
|
||||
| **FR-RETRIEVAL-004** | 运行中效果 | 运行中卡片头部 shimmer 扫光 1.8s 循环 + 状态点脉冲 | 完成后动效停止 |
|
||||
| **FR-RETRIEVAL-005** | 检索计划 | 若响应含 `retrieval_plan`,在列表顶部以只读信息块展示;另展示 `/api/qa/info` 的 12 段 pipeline 描述作为静态说明 | 无 plan 时该区域不显示(不留空框) |
|
||||
| **FR-RETRIEVAL-006** | 空态 | 未提问时显示「发起提问后,这里会实时展示检索过程」 | 空态不显示假卡片 |
|
||||
|
||||
---
|
||||
|
||||
## 9. FR-SOURCE 文献来源(工作台 Tab2)
|
||||
|
||||
| 编号 | 功能点 | 业务逻辑与边界 | 验收要点 |
|
||||
|---|---|---|---|
|
||||
| **FR-SOURCE-001** | 来源列表 | 沿用原型「产物文件」的 mono 列表视觉。每条:序号 + 标题(超长省略)+ mono 元信息行(`PMID:xxx` / `DOI:xxx` / 分数)+ 证据片段(默认 2 行截断,点击展开) | 条目数与 `source_count` 一致 |
|
||||
| **FR-SOURCE-002** | 字段展示 | 展示归一化后的 `title / snippet / score / pmid / pmcid / doi / section / support_count`。缺失字段不显示占位符,**不显示 `undefined`/`null`** | 各字段缺失组合下渲染正常 |
|
||||
| **FR-SOURCE-003** | 外链跳转 | 链接优先级:`url` > `doi`(拼 `https://doi.org/{doi}`)> `pmid`(拼 PubMed)> `pmcid`(拼 PMC)。均无则不显示链接图标。新标签页打开,带 `rel="noopener"` | 点击真实跳转到正确文献页 |
|
||||
| **FR-SOURCE-004** | 证据链 | `chains` 按 `chain_id` 分组,每组可展开为逐跳列表:`源实体 —[关系]→ 目标实体`,每跳附 paper_id/PMID。跳数以徽章显示 | 多跳链条展开顺序正确 |
|
||||
| **FR-SOURCE-005** | 会话级聚合 | 顶部切换「本次回答 / 本会话全部」。后者调 `GET /api/sessions/{id}/sources`,按 `paper_id` 去重并累计出现次数 | 去重正确,计数准确 |
|
||||
| **FR-SOURCE-006** | 空态 | 无来源时显示「本次检索未命中文献」,**不显示空列表框** | 空结果不误导为加载中 |
|
||||
|
||||
---
|
||||
|
||||
## 10. FR-EXTRACT 提取任务(工作台 Tab3)
|
||||
|
||||
| 编号 | 功能点 | 业务逻辑与边界 | 验收要点 |
|
||||
|---|---|---|---|
|
||||
| **FR-EXTRACT-001** | 任务列表 | `GET /api/extraction/jobs`。每条沿用原型进度条视觉:任务名 + mono 百分比 + 6px 轨道 + 渐变填充 + sweep 扫光;100% 转为纯 success 色且停止扫光。**上游无 progress 数值**,百分比由 BFF 按 `stage` 派生(queued=5 / mineru=35 / llm=75 / completed=100,failed 显示失败态不显示进度条) | 列表与上游一致;派生数值正确;failed 任务呈现为错误态 |
|
||||
| **FR-EXTRACT-002** | 任务详情 | 点击展开 `GET /api/extraction/jobs/{id}`,展示状态、message(中文进度描述)、统计字段(stage/species/figure_table 计数)、plan(仅 completed 有,未完成置 null 不显示) | 详情字段与上游一致;未完成任务不显示 plan 且不报错 |
|
||||
| **FR-EXTRACT-003** | 运行日志 | `GET /api/extraction/run-log`,mono 等宽展示,最大高度内滚动,自动贴底 | 长日志不撑破布局 |
|
||||
| **FR-EXTRACT-004** | 论文上传 | 作曲器区域附件按钮 → 选 PDF → `POST /api/extraction/upload`(multipart)→ 显示上传进度 → 成功返回 job_id 并刷新列表。**限制**:仅 PDF,单文件 ≤50MB。上游当前环境 MinerU 二进制路径缺失会导致新任务 failed(历史 27 个 failed 任务同因)——若发生,展示上游 message 的明确错误文案 | 非 PDF 被拒;超限被拒;错误文案可读 |
|
||||
| **FR-EXTRACT-005** | 轮询刷新 | 存在 `running` 状态任务时每 5s 轮询列表;全部终态则停止轮询。切到其他 tab 时暂停轮询 | 无进行中任务时不空转 |
|
||||
| **FR-EXTRACT-006** | 离线降级 | 抽取上游不可用时显示离线提示与重试按钮,不影响其他 tab | 8000/7900 全停时该 tab 优雅降级 |
|
||||
|
||||
---
|
||||
|
||||
## 11. FR-KG 知识图谱(工作台 Tab4)
|
||||
|
||||
**双数据源策略**(重要):
|
||||
- **答案图谱**:来自当前消息的 `graph` 块(由 QA 响应的 `knowledge_graph`/`native_graph_chains` 归一化而来)。永远可用,Neo4j 挂了也不受影响。
|
||||
- **自由浏览**:来自 `GET /api/kg/*`,BFF 用 Bolt 直连 Neo4j。上游 `/api/v1/kg/*` 已损坏(指向未运行的 7687),不可依赖。
|
||||
|
||||
| 编号 | 功能点 | 业务逻辑与边界 | 验收要点 |
|
||||
|---|---|---|---|
|
||||
| **FR-KG-001** | 答案图谱渲染 | 手写 SVG 力导向布局。节点圆点 + 标签,边为渐变线(`#5E6AD2→#00D4FF`)带关系名。节点数 >50 时自动降级为列表视图并提示 | 图谱与答案引用的实体一致 |
|
||||
| **FR-KG-002** | 节点着色与图例 | 按实体 `type` 着色,用品牌色 + 辅助紫 + 五相位色构成调色板,底部图例。**同一 type 在会话内颜色稳定** | 图例与节点颜色对应 |
|
||||
| **FR-KG-003** | 节点详情 | 点击节点 → 侧滑详情面板,展示实体属性;若 Neo4j 可用,额外调 `GET /api/kg/entities/{name}` 拉全量属性 | Neo4j 不可用时仍能显示消息内已有属性 |
|
||||
| **FR-KG-004** | 实体搜索 | 搜索框 → `GET /api/kg/entities?q=` → 按 `name_norm` 精确/前缀匹配(走 name_norm 索引,0.1s 内返回)→ 选中渲染其邻域。**注意上游无全文索引**,搜索是精确/前缀匹配,非模糊全文检索 | 中英文实体名(精确或前缀)均可搜到;响应 <1s |
|
||||
| **FR-KG-005** | 邻域展开 | `GET /api/kg/entities/{name}/neighborhood?depth=&limit=`。深度选择 1/2,默认 1(**两跳实测约 13s**,需提示较慢)。节点上限 200,每跳按 paper_count/support_count 降序截断防 hub 吃满预算 | 深度切换生效;超限有提示不卡死 |
|
||||
| **FR-KG-006** | 缩放与平移 | 滚轮/双指缩放(0.3×–3×),拖拽平移,双击复位。**触摸端必须支持双指缩放与单指拖拽** | 手机上可流畅缩放平移 |
|
||||
| **FR-KG-007** | 离线降级 | Neo4j 不可用时:搜索与邻域功能禁用并提示离线;**答案图谱照常渲染**(这是双数据源的意义) | 断开 Neo4j 后 tab 不白屏 |
|
||||
| **FR-KG-008** | 空态 | 本次回答无图谱数据时提示「本次检索未产生图谱关系」,并引导使用实体搜索 | 空态不显示空画布 |
|
||||
|
||||
---
|
||||
|
||||
## 12. FR-RESP 响应式(手机为一等公民)
|
||||
|
||||
### 12.1 断点
|
||||
|
||||
| 编号 | 档位 | 宽度 | 布局规则 |
|
||||
|---|---|---|---|
|
||||
| **FR-RESP-001** | Desktop | ≥1280px | 完整三栏 260/1fr/380 |
|
||||
| **FR-RESP-002** | Laptop | 1024–1279px | 三栏保留;左栏**自动**折叠为 64px 轨;右栏收窄至 340px |
|
||||
| **FR-RESP-003** | Tablet | 768–1023px | 对话单栏铺满;左栏改**左抽屉**(scrim 遮罩 + 滑入),顶栏加汉堡按钮;右栏改**右抽屉**,顶栏加工作台按钮并显示来源数角标 |
|
||||
| **FR-RESP-004** | Mobile | <768px | 单栏;顶栏 = 汉堡 + 标题 + 工作台按钮;对话铺满;作曲器吸底;工作台为**全屏覆盖层 + 顶部 tab 条**;底部状态栏隐藏,四个 agent 点合并为顶栏一枚聚合状态点 |
|
||||
|
||||
抽屉与覆盖层动效沿用 `ease-out` 300ms;打开时 scrim 淡入,点击 scrim 或按 Esc 关闭。
|
||||
|
||||
### 12.2 移动端专项
|
||||
|
||||
| 编号 | 功能点 | 业务逻辑与边界 | 验收要点 |
|
||||
|---|---|---|---|
|
||||
| **FR-RESP-005** | viewport 与安全区 | `<meta viewport ... viewport-fit=cover>`;作曲器 `padding-bottom: env(safe-area-inset-bottom)`;顶栏 `env(safe-area-inset-top)` | iPhone 刘海/Home 条不遮挡内容 |
|
||||
| **FR-RESP-006** | iOS 输入放大规避 | <768px 时 textarea `font-size:16px`(低于 16px 必触发 iOS 自动缩放)。**禁止**用 `maximum-scale=1` 规避——伤害无障碍 | iPhone Safari 聚焦输入框页面不放大 |
|
||||
| **FR-RESP-007** | 悬停态触摸等价 | 所有 hover 样式包在 `@media (hover: hover)` 内。触摸设备下:来源外链图标常驻可见、助手消息左缘线常驻、会话条目去掉悬停底色改用点击反馈 | 手机上无「需要悬停才出现」的功能 |
|
||||
| **FR-RESP-008** | 触控目标 | <768px 下会话条目、bench tab、模式 pill、图标按钮 `min-height:44px`(iOS HIG 标准,高于 DESIGN.md 的 36px) | 手机上不误触 |
|
||||
| **FR-RESP-009** | 视口高度 | 用 `100dvh`,保留 `100vh` 回退,规避移动浏览器地址栏伸缩导致的高度跳变 | 滚动时布局不跳动 |
|
||||
| **FR-RESP-010** | 虚拟键盘 | 监听 `visualViewport` 的 resize:键盘弹出时压缩消息区高度并保持贴底,作曲器始终可见 | 键盘弹出不遮挡输入框 |
|
||||
| **FR-RESP-011** | 表格横向滚动 | <768px 下答案内 `md-table` 包一层横向滚动容器,右侧加渐隐提示。**不压缩字号**(可读性优先) | 宽表格可横滑查看完整内容 |
|
||||
| **FR-RESP-012** | 移动端性能降级 | <768px 下 aurora 模糊半径降低或 `--pa-aurora-opacity:0`;图谱节点数上限降至 30 | 中端手机滚动流畅无卡顿 |
|
||||
| **FR-RESP-013** | 手势(可选) | 左缘右滑开会话抽屉,右滑关工作台覆盖层。**不作为验收门槛** | 实现则可用,不实现不算缺陷 |
|
||||
|
||||
---
|
||||
|
||||
## 13. FR-HEALTH 健康与降级
|
||||
|
||||
| 编号 | 功能点 | 业务逻辑与边界 | 验收要点 |
|
||||
|---|---|---|---|
|
||||
| **FR-HEALTH-001** | 健康轮询 | 挂载后请求 `GET /api/health`,此后每 30s 一次。结果写入 store,驱动状态栏标记与各降级态。轮询失败不弹窗,仅标记 | 上游恢复后 30s 内自动解禁 |
|
||||
| **FR-HEALTH-002** | QA 不可用 | `/api/qa/*` 返 503 degraded:作曲器禁用并显示「知识问答服务当前不可用」,历史会话仍可浏览 | 停掉 7920 后 UI 明确提示而非静默失败 |
|
||||
| **FR-HEALTH-003** | Neo4j 不可用 | 图谱 tab 搜索/邻域禁用并提示离线;答案图谱不受影响 | 见 FR-KG-007 |
|
||||
| **FR-HEALTH-004** | 抽取不可用 | 见 FR-EXTRACT-006 | 仅该 tab 降级 |
|
||||
| **FR-HEALTH-005** | 网络中断 | 流式中断时提示「连接中断」+ 重试按钮;**不自动重连**(避免重复触发昂贵 LLM 调用) | 手机切换网络后有明确提示 |
|
||||
|
||||
---
|
||||
|
||||
## 14. 非功能要求
|
||||
|
||||
| 项 | 要求 |
|
||||
|---|---|
|
||||
| 首屏可交互 | 桌面 <2s(本地网络),手机 <3s |
|
||||
| 长任务反馈 | QA 最长 240s,期间必须持续有视觉反馈,无静止超过 5s 的状态 |
|
||||
| 数据安全 | Neo4j 凭据只存在于 BFF 配置,**绝不下发到浏览器**;LLM 输出必须经 DOMPurify 消毒后才 `v-html` |
|
||||
| 离线容忍 | 前端零外网请求(无 Google Fonts、无 CDN),符合 DESIGN.md 禁令 |
|
||||
| 浏览器支持 | Safari 16+(iOS)、Chrome 110+、Firefox 110+ |
|
||||
|
||||
---
|
||||
|
||||
## 15. FR 索引(供追溯矩阵使用)
|
||||
|
||||
SHELL: 001–007(7)|THEME: 001–006(6)|SESSION: 001–009(9)|CHAT: 001–015(15)|RETRIEVAL: 001–006(6)|SOURCE: 001–006(6)|EXTRACT: 001–006(6)|KG: 001–008(8)|RESP: 001–013(13)|HEALTH: 001–005(5)
|
||||
|
||||
**合计 81 个功能点。** 测试用例追溯矩阵必须覆盖全部 81 项(FR-RESP-013 标注为可选,仍需有用例但允许标记为 N/A)。
|
||||
@@ -0,0 +1,675 @@
|
||||
# TEST_CASES.md —— 需求测试用例与追溯矩阵
|
||||
|
||||
> 版本 1.0 | 作者 T(测试 agent)| 用例唯一来源:`PRD.md`(81 FR);请求/响应依据:`API.md`;分层与轮次纪律:`TEST_PLAN.md`;上游事实:`DISCOVERY.md`
|
||||
> 本波(M0)交付:用例清单 + 双向追溯矩阵 + 执行说明 + E2E 骨架(2 条冒烟)。**全量执行从 M2 集成后开始,按 TEST_PLAN §5 轮次纪律。**
|
||||
> 覆盖判据:每个 FR 至少 1 条 TC,且每条 TC 至少映射 1 个 FR(双向完整 = 主门禁 100%)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 用例清单
|
||||
|
||||
### 1.1 编号与通用约定
|
||||
|
||||
- 编号:`TC-<模块>-<序号>`;E2E 主线场景:`TC-E2E-<主线号>-<视口>`(视口 M=Mobile 390×844 / T=Tablet 768×1024 / D=Desktop 1440×900)。
|
||||
- 测试层:L1 后端单元(pytest -m unit,A 组自测)|L2 后端接口(pytest -m api,respx mock 上游,T 主写)|L2s 上游 smoke(`bash tests/smoke_upstreams.sh`)|L3 前端单元(vitest)|L4 前端组件(vitest + @vue/test-utils)|L5 端到端(Playwright,三档视口)|L6 验收(PM 人工)。
|
||||
- 优先级:P0 = 主流程/数据正确性/安全;P1 = 重要功能与降级;P2 = 视觉细节/辅助路径。
|
||||
- 可断言性要求:预期列均为可观察结果(元素存在/计数/请求体字段/HTTP 状态/存储值),无「检查是否正确」类断言。
|
||||
- 「依赖充值」标注:上游 QA 当前 LLM 402 降级(DISCOVERY.md §6.1),凡断言涉及**自然语言答案内容**的用例均标注「依赖充值」——充值前只能验证结构(answer 字段存在、非空或为 fallback 证据列表、text 块已渲染),充值后验证完整断言。**检索/证据/图谱/来源类断言不受 402 影响。**
|
||||
- 时序说明:流水线用例中的「pipeline 块状态依次 running→done」属 UI 状态推断,若无专门组件埋点,用例从渲染的相位卡/pipeline 卡状态文本断言;帧时序由 mock `sse_frames.json` 保证(与 TEST_CASES 无关的 fixture 细节以 PM 的 mock 为准)。
|
||||
|
||||
### 1.2 SHELL 应用外壳(FR-SHELL-001~007)
|
||||
|
||||
| 编号 | 对应 FR | 标题 | 前置条件 | 步骤 | 预期结果 | 测试层 | 优先级 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-SHELL-001 | FR-SHELL-001 | 桌面三栏网格精确尺寸 | Desktop 1440×900;AppShell 已挂载 | 1. 打开应用;2. 计算 `.app-shell` 的 grid-template-columns 与 rows 实际值(getComputedStyle);3. 计算 `.session-panel`/`.chat-panel`/`.bench-panel` 宽度 | columns = `260px minmax(0,1fr) 380px`;rows = `48px minmax(0,1fr) 28px`;三栏实际宽度 260/800/380(1440 下);`body` computed `overflow:hidden` | L5-D | P0 |
|
||||
| TC-SHELL-002 | FR-SHELL-001 | 缩放下无横向滚动条 | Desktop 初始 1440×900 | 1. 将视口改为 1280×900、再 1440×900;2. 检查 `document.documentElement.scrollWidth` 与 `clientWidth` | 两档下 scrollWidth ≤ clientWidth;`body` 不出现横向滚动条 | L5-D | P1 |
|
||||
| TC-SHELL-003 | FR-SHELL-002 | 顶栏结构:Logo/标题/徽章/主题按钮 | 已有一个活动会话;浅色主题 | 1. 打开应用;2. 断言 `.top-bar` 高 48px;3. 检查左 Logo 圆点与「海古纪 PlantAI」文字(gradient text);4. 检查中部会话标题与状态徽章;5. 检查右侧主题切换按钮 | `.top-bar` offsetHeight=48;logo 文字存在且带 `background-clip:text` 渐变;中部标题=当前会话 title;主题按钮可点 | L5-D | P1 |
|
||||
| TC-SHELL-004 | FR-SHELL-002 | 顶栏无会话时显示「就绪」 | 数据库无任何会话 | 打开应用 | 顶栏中部徽章文本=「就绪」(labels.js 对应键) | L4 | P2 |
|
||||
| TC-SHELL-005 | FR-SHELL-002 | 顶栏长标题省略 | 存在 title 为 60+ 字符的会话 | 1. 打开该会话;2. 检查顶栏标题元素 computed style | 标题 text-overflow=ellipsis;不换行(white-space:nowrap);单行渲染 | L5-D | P2 |
|
||||
| TC-SHELL-006 | FR-SHELL-003 | 状态栏静态结构 | AppShell 已挂载;`/api/health`、`/api/qa/info` 正常 | 1. 检查 `.status-bar` 高 28px;2. 检查左 4 个 `.agent-dot-item`;3. 检查右侧版本/会话数/健康标记 | 高=28px;agent 点数量=4;右侧文本含 `PlantAI RAG v0.1` 与 QA 版本号(来自 `/api/qa/info` 的 `version`,如 `1.1.73-assertion-anchored`) | L5-D | P1 |
|
||||
| TC-SHELL-007 | FR-SHELL-003 | 问答进行中状态栏四点联动 | 会话空闲;mock normal(SSE 回放) | 1. 发起提问;2. 在 SSE 进行中读取四个 agent 点的状态类 | 相位推进到 X 时第 X 个点激活(如 planning 时第 1 点 pulse);progress 全部结束后四点定格(成功态) | L5-D | P1 |
|
||||
| TC-SHELL-008 | FR-SHELL-004 | 左栏折叠为 64px 图标轨 | Desktop 1440×900;左栏展开 | 1. 点击左栏折叠按钮;2. 检查 `.session-panel` 宽度与图标按钮(3 个 34×34);3. 刷新页面;4. 再次检查 | 折叠后宽度=64px;图标按钮=3 个;过渡 300ms;刷新后仍折叠 | L5-D | P1 |
|
||||
| TC-SHELL-009 | FR-SHELL-004 | 左栏折叠状态持久化 | 同上 | 1. 折叠;2. 检查 `localStorage['pa-side-collapsed']` | localStorage 值为折叠标记;展开后标记被清除/反转 | L5-D | P2 |
|
||||
| TC-SHELL-010 | FR-SHELL-005 | 右栏折叠保留手柄条 | Desktop 1440×900;右栏展开 | 1. 点击右栏折叠;2. 检查手柄条尺寸与可见性;3. 点击手柄展开;4. 检查 `localStorage['pa-bench-collapsed']` | 折叠后 `.bench-panel` 隐藏但手柄条 28px 可见可点;点击后恢复展开;刷新后保持折叠态 | L5-D | P1 |
|
||||
| TC-SHELL-011 | FR-SHELL-006 | Aurora 背景不遮挡交互 | 任意视口;AppShell 已挂载 | 1. 检查 aurora 层 computed style;2. 点击顶栏主题按钮、会话列表项、作曲器输入框(逐个执行且均生效) | aurora 层 `position:fixed`、`z-index:0`、`pointer-events:none`;上述交互全部可操作 | L5-D | P1 |
|
||||
| TC-SHELL-012 | FR-SHELL-006 | 浅色主题 aurora 衰减 | 已切浅色 | 检查 aurora 层或根元素的 `--pa-aurora-opacity` | computed 值=`.08`(UI_SPEC §1.2 令牌值) | L4 | P2 |
|
||||
| TC-SHELL-013 | FR-SHELL-007 | 首屏初始化顺序与并行请求 | 深色主题已存 localStorage;无会话 | 1. 打开应用;2. 网络面板记录启动期请求 | `documentElement.dataset.theme` 在应用挂载前已写入(断言:无白色闪屏——深色下首帧背景即深色;实现断言:main.js 在 createApp 前写 dataset);启动期存在 GET `/api/sessions`、`/api/health`、`/api/qa/info` 三个请求且并行(无串行依赖) | L5-D + L3 | P0 |
|
||||
| TC-SHELL-014 | FR-SHELL-007 | 首屏接口失败不阻塞渲染 | mock 对 `/api/health` 返回 500(或 BFF 停止) | 打开应用 | 页面仍渲染 AppShell 与欢迎空态;健康标记显示降级/离线态;无未捕获异常 | L5-D(mock error 场景) | P1 |
|
||||
| TC-SHELL-015 | FR-SHELL-007 | 无会话显示欢迎空态 | 数据库无会话 | 打开应用 | 对话区显示欢迎空态(渐变菱形 + 标题 + 3 张建议卡) | L5-D | P1 |
|
||||
|
||||
### 1.3 THEME 主题与无障碍(FR-THEME-001~006)
|
||||
|
||||
| 编号 | 对应 FR | 标题 | 前置条件 | 步骤 | 预期结果 | 测试层 | 优先级 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-THEME-001 | FR-THEME-001 | 深色默认主题 | 首次打开(无 pa-theme 存储) | 1. 打开应用;2. 检查 `documentElement.dataset.theme` 与 `--pa-canvas` computed 值 | theme='dark';canvas=#0A0A10(rgb(10,10,16)) | L5-D | P0 |
|
||||
| TC-THEME-002 | FR-THEME-001 | 浅色主题逐组件走查 | 已切浅色 | 逐个检查顶栏、会话项、气泡、作曲器、bench 面板的计算样式 | 无失色:各元素背景/文字均来自浅色令牌(canvas=#F6F8FC,surface-1=#FFFFFF);无深色令牌残留(如 #0A0A10 不再出现于背景) | L5-D(或 L6 人工走查) | P0 |
|
||||
| TC-THEME-003 | FR-THEME-002 | 主题切换即时生效 | 默认深色 | 1. 点击顶栏主题按钮;2. 立即检查 `documentElement.dataset.theme` 与 localStorage;3. 刷新后复查 | dataset.theme 即时='light';`localStorage['pa-theme']='light'`;刷新后仍为 light;再切回同理 | L5-D | P0 |
|
||||
| TC-THEME-004 | FR-THEME-002 | 主题图标旋转动画 | 同上 | 切换主题时检查图标 transform | 图标日/月互换且 rotate 180°(transition 400ms) | L5-D | P2 |
|
||||
| TC-THEME-005 | FR-THEME-003 | 浅色 glow 衰减 | 已切浅色 | 检查根元素 `--pa-glow-opacity` computed 值 | 值=.35(深色下=1) | L4 | P2 |
|
||||
| TC-THEME-006 | FR-THEME-004 | 动效降级(reduced motion) | 系统「减少动态效果」开启;Playwright 以 `reducedMotion:'reduce'` 启动 context | 1. 打开应用;2. 检查 aurora/状态点/按钮的 animation 计算样式;3. 发起一次问答 | `@media (prefers-reduced-motion: reduce)` 生效:所有元素 animation-duration=0 或 animation 为 none;running 状态以静态符号(实心点 + 文字徽章)可辨认,无 pulse/shimmer/spin 运动 | L5-D(reducedMotion context) | P1 |
|
||||
| TC-THEME-007 | FR-THEME-005 | 焦点环样式 | 任意视口 | Tab 键聚焦顶栏主题按钮与作曲器 textarea | `:focus-visible` 输出 2px `rgba(94,106,210,.5)` 焦点环(outline-offset 1px) | L5-D | P1 |
|
||||
| TC-THEME-008 | FR-THEME-005 | 纯键盘完成主流程 | 空数据库;mock normal | 只用 Tab/Enter/Esc 完成:新建会话 → 聚焦输入 → 输入问题 → ⌘/Ctrl+Enter 发送 → Tab 切换工作台 tab → Esc 关闭面板 | 每一步可达且生效;Tab 顺序符合视觉顺序(顶栏 → 会话列表 → 对话 → 作曲器 → 工作台) | L5-D(键盘驱动) | P0 |
|
||||
| TC-THEME-009 | FR-THEME-006 | 对比度达标(深色) | 深色主题 | 抽检 `.app-shell` 内 3 处文本(正文 ink、muted、subtle)的计算颜色与 canvas 背景,按 WCAG 公式计算对比度 | ink/canvas ≥15:1;ink-muted/canvas ≥4.5:1;ink-subtle 仅用于非关键信息 | L5-D + L3(工具函数断言,可脚本化) | P1 |
|
||||
| TC-THEME-010 | FR-THEME-006 | 对比度达标(浅色) | 浅色主题 | 同 TC-THEME-009 步骤,浅色令牌 | ink(#16182A)/canvas(#F6F8FC) ≈ 计算值 ≥ 深色同等标准;ink-muted/canvas ≥4.5:1 | L5-D | P1 |
|
||||
|
||||
### 1.4 SESSION 会话管理(FR-SESSION-001~009)
|
||||
|
||||
| 编号 | 对应 FR | 标题 | 前置条件 | 步骤 | 预期结果 | 测试层 | 优先级 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-SESSION-001 | FR-SESSION-001 | 列表按 updated_at 倒序 | 数据库 ≥3 个会话,updated_at 互异(fixtures 保证) | 1. GET `/api/sessions`;2. 前端渲染列表;3. 依次读取条目标题 | 条目顺序 = updated_at 从新到旧 | L2(fixture 会话数据)+ L4 | P0 |
|
||||
| TC-SESSION-002 | FR-SESSION-001 | 分组「今天/昨天/更早」 | ≥3 个会话,时间分别落在今天/昨天/2 天前 | 渲染列表 | 三个组标签(11px/500/字距0.6px)按序出现,组内条目归属正确;组内按时间倒序 | L4(clock mock) | P1 |
|
||||
| TC-SESSION-003 | FR-SESSION-001 | 空列表引导文案 | 数据库无会话 | 渲染列表 | 显示引导文案(「暂无会话」类 labels 文案);不渲染列表条目 | L4 | P2 |
|
||||
| TC-SESSION-004 | FR-SESSION-002 | 状态点四态区分 | 4 个会话分别 idle/running/done/error | 渲染列表 | 形状可区分:idle=空心、running=空心+脉冲动画、done=实心 success 色、error=实心 error 色;running 有 animation,其余无 | L4 | P1 |
|
||||
| TC-SESSION-005 | FR-SESSION-003 | 新建会话 | 任意初始状态 | 1. 点击「新建会话」按钮;2. 观察网络请求与界面 | 发出 POST `/api/sessions`;列表新增一条「新会话」;切换为活动会话;对话区显示欢迎空态;**不产生空消息**(GET 详情 messages 为空数组) | L5-D + L2 | P0 |
|
||||
| TC-SESSION-006 | FR-SESSION-003 | 新建连点不重复 | 同上 | 快速连点新建按钮 2 次 | 产生 2 个独立会话(列表+2,两条 POST);均切到最后创建的一个 | L5-D | P1 |
|
||||
| TC-SESSION-007 | FR-SESSION-004 | 选择会话加载历史 | 会话含 text/table/sources/graph 块消息 | 点击该会话条目 | 发出 GET `/api/sessions/{id}`;加载中显示骨架屏;完成后消息按块类型渲染:markdown 文本、表格(md-table 样式)、来源、图谱块均出现 | L5-D(mock 会话详情含全块) | P0 |
|
||||
| TC-SESSION-008 | FR-SESSION-004 | 切换会话中断进行中流 | 会话 A 流式进行中;会话 B 存在 | 点击会话 B | 会话 A 的 SSE 连接 abort(Network 面板连接关闭);会话 A 状态置 error;切回会话 A 后其消息标注「已中断」 | L5-D(mock slow/正常流) | P0 |
|
||||
| TC-SESSION-009 | FR-SESSION-005 | 搜索过滤(中文/英文/大小写) | ≥3 个会话,标题含中英文混排 | 1. 输入中文关键词;2. 输入英文小写;3. 输入英文大写;4. 输入无匹配词;5. 清空 | 匹配为 title 大小写不敏感子串;无匹配显示「无匹配会话」;清空恢复全量;过滤期间无网络请求 | L5-D + L4 | P1 |
|
||||
| TC-SESSION-010 | FR-SESSION-006 | 重命名提交 | 活动会话 | 1. 双击标题进入编辑;2. 输入新名;3. 回车 | PATCH `/api/sessions/{id}` 请求体 `{title:新名}`;列表与顶栏标题同步更新 | L5-D + L2 | P1 |
|
||||
| TC-SESSION-011 | FR-SESSION-006 | 重命名取消与空标题 | 同上 | 1. 编辑中按 Esc;2. 再次编辑并清空标题后回车 | Esc 放弃改动;空标题提交按钮禁用或提交被拒,不发出 PATCH 空 title | L5-D | P2 |
|
||||
| TC-SESSION-012 | FR-SESSION-007 | 删除需二次确认 | 2 个会话 | 1. 右键/菜单点删除;2. 出现确认弹层时点取消 | 未删除(列表不变、无 DELETE 请求) | L5-D | P1 |
|
||||
| TC-SESSION-013 | FR-SESSION-007 | 删除当前会话切换 | 2 个会话;选中其中一个 | 删除并确认 | DELETE `/api/sessions/{id}` 204;当前会话切换到剩余最新一个;刷新列表后已删会话不再出现 | L5-D + L2 | P0 |
|
||||
| TC-SESSION-014 | FR-SESSION-007 | 删除最后一个会话显示空态 | 仅 1 个会话 | 删除并确认 | 列表空;对话区显示欢迎空态 | L5-D | P1 |
|
||||
| TC-SESSION-015 | FR-SESSION-007 | 级联删除消息 | 会话含 2 条消息;BFF 真实 | 删除会话后 GET `/api/sessions/{id}` | 返回 404(NOT_FOUND 信封);SQLite 中该会话消息行一并删除(L2 断言数据库) | L2 | P0 |
|
||||
| TC-SESSION-016 | FR-SESSION-008 | 首问自动标题 | 新建会话 | 1. 提问(问题 40+ 字符);2. 检查 PATCH 请求与列表标题 | PATCH 请求体 title = 问题前 30 字符 + 省略号;列表标题同步变化 | L5-D + L2 | P0 |
|
||||
| TC-SESSION-017 | FR-SESSION-008 | 手动改名后不覆盖 | 会话 meta.title_manual=true(改过名) | 1. 再次提问;2. 检查是否发出 title PATCH | 不发出改 title 的 PATCH;标题保持手动值 | L2(会话固定数据)+ L4 | P1 |
|
||||
| TC-SESSION-018 | FR-SESSION-009 | 活动态高亮 | ≥2 个会话 | 1. 选中会话 A;2. 检查其 computed style;3. 悬停另一项 B 对比 | A 有 2px 渐变左竖条 + g-to 8% 底色 + 内描边;与悬停态(B)可区分 | L5-D | P2 |
|
||||
|
||||
### 1.5 CHAT 对话与问答(FR-CHAT-001~015)
|
||||
|
||||
#### 1.5.1 基础渲染
|
||||
|
||||
| 编号 | 对应 FR | 标题 | 前置条件 | 步骤 | 预期结果 | 测试层 | 优先级 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-CHAT-001 | FR-CHAT-001 | 欢迎空态元素与建议卡 | 无消息 | 打开应用 | 显示 46px 渐变菱形(✧)+ 渐变标题「海古纪 PlantAI 知识工作台」+ 副标题 + 3 张建议卡 | L5-D | P1 |
|
||||
| TC-CHAT-002 | FR-CHAT-001 | 建议卡点击即提问 | 无消息;mock normal | 点击第 1 张建议卡 | 卡片文本进入作曲器并直接发送;出现用户气泡(内容=卡片文本)与助手骨架;发出 POST `/api/qa/chat/stream` | L5-D | P0 |
|
||||
| TC-CHAT-003 | FR-CHAT-002 | 用户气泡样式与长文本换行 | 任意 | 发送 300 字无空格长文本 | 气泡右对齐,宽度 ≤ 父容器 70%;文本换行且不溢出(scrollWidth ≤ 气泡宽度) | L5-D | P2 |
|
||||
| TC-CHAT-004 | FR-CHAT-003 | 助手消息头部与悬停竖线 | 已有助手消息 | 1. 检查头部(24px 渐变头像 ✦ + 「PlantAI 智能体」+ 时间);2. 悬停消息左缘 | 头部三要素齐全;左缘 2px 竖线 hover 时出现渐变 | L5-D | P2 |
|
||||
| TC-CHAT-005 | FR-CHAT-003 | 多块混排顺序 | 消息 blocks 为 [pipeline,text,table,sources,graph](mock 会话详情) | 渲染该消息 | 各块按 blocks 数组顺序渲染;无块丢失 | L5-D | P1 |
|
||||
| TC-CHAT-006 | FR-CHAT-004 | Markdown 表格样式 | 消息含表格块(mock 含 md-table 内容的 text 块) | 渲染 | 表格带 md-table 类:thead 2px 渐变下边框;数据格 mono 字体(表头为「说明」的列保持比例字体) | L5-D | P1 |
|
||||
| TC-CHAT-007 | FR-CHAT-004 | 脚本注入被消毒 | 向问答返回的 answer 注入 `<script>window.__pwned=1</script>` 与 `<img src=x onerror=...>`(mock fixture 或 BFF 层构造) | 渲染该答案 | `window.__pwned` 为 undefined;DOM 中不存在 script 标签;onerror 未执行;内容经 DOMPurify 后 v-html | L5-D(注入 fixture)+ L3(sanitize 单测) | P0 |
|
||||
| TC-CHAT-008 | FR-CHAT-014 | 流式自动滚动到底 | mock normal;消息区可滚动 | 发起提问 | 流式更新期间滚动条持续贴近底部(scrollTop+clientHeight ≥ scrollHeight−10) | L5-D | P1 |
|
||||
| TC-CHAT-009 | FR-CHAT-014 | 上滚暂停自动滚动 | 流式进行中 | 向上滚动 ≥100px | 不再自动贴底;出现「回到最新」按钮 | L5-D | P1 |
|
||||
| TC-CHAT-010 | FR-CHAT-014 | 「回到最新」恢复滚动 | 上滚暂停状态 | 点击「回到最新」按钮 | 滚动回底部;恢复自动滚动;按钮消失 | L5-D | P1 |
|
||||
| TC-CHAT-011 | FR-CHAT-015 | 内联引用 chip 跳转高亮 | 答案含引用标记(citation 编号)与对应来源 | 点击引用 chip | bench 切换到「文献来源」tab 并滚动定位到对应条目;该条目高亮 2s 后消失;chip 编号与列表条目序号一致 | L5-D | P1 |
|
||||
|
||||
#### 1.5.2 作曲器
|
||||
|
||||
| 编号 | 对应 FR | 标题 | 前置条件 | 步骤 | 预期结果 | 测试层 | 优先级 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-CHAT-012 | FR-CHAT-005 | 输入框高度自适应 | 桌面 | 1. 输入 1 行文本测基线(min-height 46px);2. 输入 10 行文本 | 基线 ≥46px;随内容增高,最大 160px 后内部滚动(scrollHeight > clientHeight) | L5-D | P2 |
|
||||
| TC-CHAT-013 | FR-CHAT-006 | 三模式切换 | 桌面;mock normal | 依次点击 问答/方案/检索 pill | 激活 pill 渐变底+白字+青光;非激活态可区分;切换即生效 | L5-D | P0 |
|
||||
| TC-CHAT-014 | FR-CHAT-006 | 问答模式请求体 | mock 正常;问答模式 | 提问并抓取 POST `/api/qa/chat/stream` 请求体 | body.use_llm=true;question 为输入文本;无 native_chain 字段(或为 modes.js 定义默认) | L5-D + L3(modes.js 参数集断言) | P0 |
|
||||
| TC-CHAT-015 | FR-CHAT-006 | 方案模式请求体 | 方案模式 | 同上 | body.use_llm=true;native_chain_enabled=true;native_chain_max_hops=3;native_chain_top_k=5;端点仍为 `/api/qa/chat/stream` | L5-D + L3 | P0 |
|
||||
| TC-CHAT-016 | FR-CHAT-006 | 检索模式请求与响应 | 检索模式 | 1. 提问;2. 观察端点为 `/api/qa/search`;3. 等待完成;4. 检查消息块 | body.use_llm=false;响应 answer 为空;消息只含 sources(+pipeline)块,**无 text 答案块** | L5-D + L2 | P0 |
|
||||
| TC-CHAT-017 | FR-CHAT-007 | 快捷键发送 | 桌面 | 输入文字后按 ⌘/Ctrl+Enter | 发送;出现用户气泡 | L5-D | P0 |
|
||||
| TC-CHAT-018 | FR-CHAT-007 | Enter 换行 | 桌面 | 输入文字后按 Enter(非组合键) | 不发送;textarea 内换行(行数+1) | L5-D | P1 |
|
||||
| TC-CHAT-019 | FR-CHAT-007 | 中英文输入法快捷键 | IME 输入法激活(模拟 composition 事件) | composition 期间按 Enter | 不触发发送(组合输入确认才触发) | L3(composition 事件模拟) | P1 |
|
||||
| TC-CHAT-020 | FR-CHAT-007 | 空输入与忙碌禁用 | 流式进行中 | 1. 空输入点发送;2. 流式中点发送 | 按钮 disabled(opacity .45);无新请求;底部提示文案显示快捷键说明 | L5-D | P1 |
|
||||
| TC-CHAT-021 | FR-CHAT-011 | 停止流式 | mock slow(长流) | 1. 提问;2. 流式中点击「停止」 | 发送按钮已变为「停止」;点击后 SSE 连接关闭(Network 断言);消息置 error 并标注「已停止」;已收到的块保留 | L5-D | P0 |
|
||||
| TC-CHAT-022 | FR-CHAT-011 | 停止后无悬挂请求 | BFF 真实 + 上游 QA | 流式中停止;随后检查 BFF 日志 | 前端 abort 后 BFF 取消上游请求(BFF 侧无悬挂 upstream 连接;日志有取消记录) | L2(respx 断言 abort)+ L5 | P1 |
|
||||
|
||||
#### 1.5.3 流式主流程(FR-CHAT-008)
|
||||
|
||||
| 编号 | 对应 FR | 标题 | 前置条件 | 步骤 | 预期结果 | 测试层 | 优先级 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-CHAT-023 | FR-CHAT-008 | 主流程-用户消息立即落库 | mock normal;新会话 | 发送问题 | 用户气泡立即出现(不等后端);发出 POST `/api/sessions/{id}/messages`,body={role:user,text};落库成功 | L5-D + L2 | P0 |
|
||||
| TC-CHAT-024 | FR-CHAT-008 | 主流程-会话置 running 与骨架 | 同上 | 发送后观察 | 会话状态置 running(左栏状态点脉冲动画);底栏 4 agent 点复位;出现助手骨架消息(status=streaming)含 pipeline 块,五相位全 waiting | L5-D | P0 |
|
||||
| TC-CHAT-025 | FR-CHAT-008 | 主流程-progress 相位推进与阶段卡 | mock normal(sse_frames 12 帧) | 提问后观察 | 每帧 progress:pipeline 对应相位 running、此前相位 done;工作台「检索过程」新增阶段卡,数量=progress 帧数(11 个 stage 帧);阶段卡 label 为中文(如「已识别实体」) | L5-D | P0 |
|
||||
| TC-CHAT-026 | FR-CHAT-008 | 主流程-complete 生成三块 | mock normal;提问完成 | 等待 complete | 消息含 text 块(揭示动画结束为最终文本)、sources 块、graph 块;pipeline 五相位全部 done;消息状态 done;会话状态 done | L5-D | P0 |
|
||||
| TC-CHAT-027 | FR-CHAT-008 | 主流程-答案内容(依赖充值) | mock normal(mock 答案为固定文本);真实上游需充值 | 断言答案文本 | mock 下:答案文本与 mock fixture 一致(可断言内容);真实上游:answer 为自然语言答案(**依赖充值**,当前 402 下为 fallback 证据列表) | L5-D / L6 | P1 |
|
||||
| TC-CHAT-028 | FR-CHAT-008 | 主流程-检查点持久化节奏 | BFF 真实;流 ≥3s | 提问并抓取 PATCH 请求 | 流式中每约 3s 发出 PATCH `/api/sessions/{id}/messages/{mid}`(块检查点);终态再发一次终态 PATCH(done/error) | L2 + L5 | P0 |
|
||||
| TC-CHAT-029 | FR-CHAT-008 | 主流程-长任务持续反馈(240s) | mock slow(长流,或上游真实 ~92s) | 提问并计时观察 | 240s 内任一时刻页面均有视觉反馈(相位卡 shimmer/已耗时计数);无静止超过 5s 的状态(5s 间隔采样断言有动画元素或文本变化) | L5-D(slow 场景) | P0 |
|
||||
| TC-CHAT-030 | FR-CHAT-008 | 网络中断重试按钮 | 流式中断网(playwright context.setOffline(true)) | 1. 流式中断网;2. 观察消息状态;3. 恢复网络;4. 观察是否自动重连;5. 点「重试」 | 消息置 error,文案「连接中断」;出现「重试」按钮;**不自动重连**(恢复网络后无新请求,需手动点击);点重试后重新发起同问题请求 | L5-D(offline 模拟) | P0 |
|
||||
| TC-CHAT-031 | FR-CHAT-008 | 刷新恢复中断会话 | 流式进行中刷新页面 | 刷新后检查该会话 | 显示最后检查点内容;标注「已中断」;会话状态 error;不自动重试 | L5-D | P0 |
|
||||
| TC-CHAT-032 | FR-CHAT-008 | 同一会话并发提问防护 | 流式进行中 | 观察发送按钮 | 按钮 disabled;无法发送第二个问题 | L5-D | P1 |
|
||||
| TC-CHAT-033 | FR-CHAT-008 | 同会话提问互相独立(无上下文记忆) | 同一会话已完成一次提问 | 第二次提问并抓取请求体 | 第二次请求只含当前 question,无历史消息拼接(max_rounds:1 语义);UI 有「每次提问独立检索」提示文案 | L2 + L5 | P1 |
|
||||
|
||||
#### 1.5.4 流水线卡与揭示动画(FR-CHAT-009 / FR-CHAT-010)
|
||||
|
||||
| 编号 | 对应 FR | 标题 | 前置条件 | 步骤 | 预期结果 | 测试层 | 优先级 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-CHAT-034 | FR-CHAT-009 | 五相位视觉与颜色 | 流式中与完成后分别观察 pipeline 卡 | 检查五个 p-step 的颜色令牌与状态字形 | 相位色:规划#C0A8DD、检索#9FBBE0、证据#9FC9A2、生成#DFA88F、完成#C08532;状态字形 ✓done/●running/◌waiting;running 相位带 11px 旋转 spinner;卡头「⚙ 检索流水线」+ 整体状态徽章(语义色) | L5-D | P0 |
|
||||
| TC-CHAT-035 | FR-CHAT-009 | 相位与语义色不混用 | 流式中出现错误(mock error 场景) | 检查 pipeline 卡 | 相位点颜色只随相位走(五相位色);整体成败徽章只用语义色(success/error)——两者不互换 | L5-D | P1 |
|
||||
| TC-CHAT-036 | FR-CHAT-010 | 揭示动画与跳过 | mock normal;答案长度已知 | 1. 观察 complete 后答案揭示;2. 点击「跳过」 | 揭示速度 30ms/字符,尾部 8×15px 青色闪烁光标 + 3px shimmer 进度线;点「跳过」后立即显示全文,reveal 元素移除 | L5-D | P1 |
|
||||
| TC-CHAT-037 | FR-CHAT-010 | 长答案 8s 上限 | mock 构造 >2000 字符答案 | 测量揭示总时长 | 速度自动加快,总时长 ≤8s | L5-D | P2 |
|
||||
|
||||
#### 1.5.5 错误与重试(FR-CHAT-012)
|
||||
|
||||
| 编号 | 对应 FR | 标题 | 前置条件 | 步骤 | 预期结果 | 测试层 | 优先级 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-CHAT-038 | FR-CHAT-012 | 引用校验失败重试 | BFF 对 invalid_llm_citation 已自动重试一次仍失败(L2 构造) | 观察响应与界面 | 前端展示「引用校验失败,请重试」(INVALID_CITATION 信封 message)并提供重试按钮 | L2(respx 断言 BFF 重试一次)+ L5 | P0 |
|
||||
| TC-CHAT-039 | FR-CHAT-012 | 其他错误展示 message | mock scenario=error | 提问 | 消息置 error;展示 error.message 文案(上游错误中文文案);提供重试按钮;已收到块保留 | L5-D(error 场景) | P0 |
|
||||
| TC-CHAT-040 | FR-CHAT-012 | 重试同位置替换 | 错误后 | 点击重试 | 用相同问题与模式重新发起;**不新增用户消息**(消息列表长度不变,同一条助手消息位置更新) | L5-D | P0 |
|
||||
| TC-CHAT-041 | FR-CHAT-012 | SSE error/timeout 事件 | mock 构造 event:error 与 event:timeout 帧 | 提问 | error 帧→展示 error.message;timeout 帧→文案「检索超时」;两者均置消息 error 并保留已收到块 | L5-D(定制 mock 流) | P1 |
|
||||
|
||||
#### 1.5.6 持久化与恢复(FR-CHAT-013)
|
||||
|
||||
| 编号 | 对应 FR | 标题 | 前置条件 | 步骤 | 预期结果 | 测试层 | 优先级 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-CHAT-042 | FR-CHAT-013 | 块联合完整落库 | BFF 真实;完成一次问答 | 检查 SQLite 该消息行 | blocks 含 text/table/sources/graph/pipeline 等全部完成块;**无 stream 块**(瞬态已转 text);raw 字段保存完整归一化响应 | L2 | P0 |
|
||||
| TC-CHAT-043 | FR-CHAT-013 | 刷新后恢复全部块 | 完成一次含表格/来源/图谱的问答 | 刷新页面并打开该会话 | GET `/api/sessions/{id}` 后全部块正确重渲染(表格、来源、图谱可见);无数据丢失 | L5-D | P0 |
|
||||
|
||||
### 1.6 RETRIEVAL 检索过程(FR-RETRIEVAL-001~006)
|
||||
|
||||
| 编号 | 对应 FR | 标题 | 前置条件 | 步骤 | 预期结果 | 测试层 | 优先级 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-RETRIEVAL-001 | FR-RETRIEVAL-001 | 阶段卡与 progress 帧一一对应 | mock normal;完成一次问答 | 统计工作台 Tab1 卡片数与 Network 中 progress 帧数 | 卡片数 = progress 帧数(sse_frames 11 个 stage 帧 → 11 张卡);每卡含相位色图标 + 阶段中文名 + 状态点 + 耗时 mono | L5-D | P0 |
|
||||
| TC-RETRIEVAL-002 | FR-RETRIEVAL-001 | 阶段卡展开体参数块 | 已有一张阶段卡 | 展开卡头 | 展开体显示 details 参数块(mono)+ 摘要行(如 entities 列表) | L5-D | P2 |
|
||||
| TC-RETRIEVAL-003 | FR-RETRIEVAL-002 | 相位筛选 | 一次问答后存在 4 个相位卡片 | 依次点击 全部/规划/检索/证据/生成 chip | 激活 chip 实心 #5E6AD2 底 + 白字;筛选后仅显示对应相位卡片(计数=对应相位帧数) | L5-D | P1 |
|
||||
| TC-RETRIEVAL-004 | FR-RETRIEVAL-003 | 展开折叠与默认态 | 流式中(running 卡)与完成后各观察一次 | 1. 流式中看卡默认态;2. 完成后看默认态;3. 点击头部 | 运行中的卡默认展开;完成后自动折叠;点击切换展开/折叠;chevron 旋转 90°;展开体 fade-slide 200ms;各卡状态互相独立 | L5-D | P1 |
|
||||
| TC-RETRIEVAL-005 | FR-RETRIEVAL-004 | 运行中效果 | 流式中 | 检查 running 卡 | 头部 shimmer 扫光(1.8s 循环)+ 状态点脉冲;完成后动效停止(animation none/移除) | L5-D | P2 |
|
||||
| TC-RETRIEVAL-006 | FR-RETRIEVAL-005 | 检索计划展示与无 plan 不显示 | mock normal(fixture 含 retrieval_plan)与无 plan 的响应各一次 | 检查列表顶部 | 含 retrieval_plan 时顶部只读信息块展示 plan;无 plan 时该区域**不渲染**(无空框);另展示 `/api/qa/info` 的 12 段 pipeline 静态说明 | L5-D | P1 |
|
||||
| TC-RETRIEVAL-007 | FR-RETRIEVAL-006 | 空态 | 未提问 | 打开工作台 Tab1 | 显示「发起提问后,这里会实时展示检索过程」;无假卡片 | L5-D | P2 |
|
||||
|
||||
### 1.7 SOURCE 文献来源(FR-SOURCE-001~006)
|
||||
|
||||
| 编号 | 对应 FR | 标题 | 前置条件 | 步骤 | 预期结果 | 测试层 | 优先级 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-SOURCE-001 | FR-SOURCE-001 | 来源列表条目数与元素 | mock normal 完成问答(source_count=4) | 打开工作台 Tab2 | 条目数 = 4(与响应 source_count 一致);每条:序号 + 标题(超长省略)+ mono 元信息行(PMID:xxx / DOI:xxx / 分数)+ 证据片段(默认 2 行截断) | L5-D | P0 |
|
||||
| TC-SOURCE-002 | FR-SOURCE-001 | 证据片段展开 | 有一条来源 | 点击片段 | 展开显示完整 snippet;再点收起 | L5-D | P2 |
|
||||
| TC-SOURCE-003 | FR-SOURCE-002 | 字段缺失不渲染占位 | mock 构造 source 缺 pmid/pmcid/doi/section 的组合 | 渲染列表 | 缺失字段位置不显示占位符;页面文本中不含 `undefined`、`null` 字样 | L5-D | P0 |
|
||||
| TC-SOURCE-004 | FR-SOURCE-003 | 外链优先级 | 4 个 source:a 有 url;b 无 url 有 doi;c 无 url/doi 有 pmid;d 无 url/doi/pmid 有 pmcid;e 全无 | 检查各条目链接 | a 链=url;b=`https://doi.org/{doi}`;c=PubMed PMID 链接;d=PMC 链接;e 无链接图标;点击新标签打开且带 `rel="noopener"` | L5-D | P1 |
|
||||
| TC-SOURCE-005 | FR-SOURCE-004 | 证据链分组与展开 | 响应 chains 含 ≥2 个 chain_id | 打开证据链区 | 按 chain_id 分组显示;组徽章显示跳数;展开后逐跳列表 `源实体 —[关系]→ 目标实体`,每跳附 paper_id/PMID;顺序与 hops 数组一致 | L5-D | P1 |
|
||||
| TC-SOURCE-006 | FR-SOURCE-005 | 会话级聚合 | 同一会话完成 2 次问答(相同 paper 出现多次) | 切到「本会话全部」 | 发出 GET `/api/sessions/{id}/sources`;按 paper_id 去重;每条 count = 该 paper 累计出现次数;去重后条数正确 | L5-D + L2 | P1 |
|
||||
| TC-SOURCE-007 | FR-SOURCE-006 | 空态 | mock scenario=empty 完成问答(source_count=0) | 打开工作台 Tab2 | 显示「本次检索未命中文献」;无空列表框(无 .ft-item 元素) | L5-D(empty 场景) | P1 |
|
||||
|
||||
### 1.8 EXTRACT 提取任务(FR-EXTRACT-001~006)
|
||||
|
||||
| 编号 | 对应 FR | 标题 | 前置条件 | 步骤 | 预期结果 | 测试层 | 优先级 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-EXTRACT-001 | FR-EXTRACT-001 | 任务列表与派生百分比 | mock 任务列表(含 queued/running/completed/failed 各一) | 打开工作台 Tab3 | 发出 GET `/api/extraction/jobs`;列表与响应一致;百分比派生正确:queued=5、running(35/75 按 stage)、completed=100、failed 无进度条且显示错误态;100% 为纯 success 色且无 sweep 扫光 | L5-D + L2(BFF 派生断言) | P0 |
|
||||
| TC-EXTRACT-002 | FR-EXTRACT-002 | 任务详情 | 已有一个任务 | 点击展开 | GET `/api/extraction/jobs/{id}`;显示 status、message(中文进度)、stats 统计字段(stage/species/figure_table 计数) | L5-D | P1 |
|
||||
| TC-EXTRACT-003 | FR-EXTRACT-002 | 未完成任务 plan 不显示 | 一个 running 任务 | 展开详情 | plan 为 null 时**不显示 plan 区块**且无报错 | L5-D | P1 |
|
||||
| TC-EXTRACT-004 | FR-EXTRACT-003 | 运行日志 | mock 正常 | 打开运行日志 | GET `/api/extraction/run-log`;mono 等宽展示;长日志在最大高度内滚动(scrollHeight > clientHeight 时出现内部滚动);自动贴底 | L5-D | P2 |
|
||||
| TC-EXTRACT-005 | FR-EXTRACT-004 | 上传 PDF 成功 | 构造 ≤50MB PDF;mock 上传成功 | 附件按钮 → 选 PDF → 上传 | POST `/api/extraction/upload`(multipart,Content-Type 含 multipart/form-data);显示上传进度;成功返回 job_id 并刷新列表(出现新任务) | L5-D | P1 |
|
||||
| TC-EXTRACT-006 | FR-EXTRACT-004 | 非 PDF 被拒 | 一个 .txt 文件 | 尝试上传 | 被拒绝(前端拦截或 422);不发出上传请求(前端拦截时)或返回 VALIDATION 信封 | L5-D + L2 | P1 |
|
||||
| TC-EXTRACT-007 | FR-EXTRACT-004 | 超 50MB 被拒 | 一个 >50MB PDF | 尝试上传 | 被拒绝;提示超出大小限制;不发出上传请求 | L5-D | P2 |
|
||||
| TC-EXTRACT-008 | FR-EXTRACT-004 | 上游失败错误文案 | mock 返回 502(MinerU 不可用) | 上传 | 展示上游 message 的明确错误文案(非堆栈);任务列表/详情为失败态 | L5-D | P1 |
|
||||
| TC-EXTRACT-009 | FR-EXTRACT-005 | 轮询节奏 | 列表含一个 running 任务 | 观察 12s 内网络请求 | 每 5s 轮询一次 GET `/api/extraction/jobs`;全部终态后轮询停止(再观察 12s 无新请求) | L5-D | P1 |
|
||||
| TC-EXTRACT-010 | FR-EXTRACT-005 | 切 tab 暂停轮询 | running 任务存在;切换到其他 tab | 观察网络 | 离开 Tab3 期间无 jobs 轮询请求;切回恢复 | L5-D | P2 |
|
||||
| TC-EXTRACT-011 | FR-EXTRACT-006 | 离线降级 | mock 对 extraction 返回 503(degraded) | 打开 Tab3 | 显示离线提示与重试按钮;其他 tab(检索/来源/图谱)不受影响可正常使用 | L5-D(degraded 场景) | P1 |
|
||||
|
||||
### 1.9 KG 知识图谱(FR-KG-001~008)
|
||||
|
||||
| 编号 | 对应 FR | 标题 | 前置条件 | 步骤 | 预期结果 | 测试层 | 优先级 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-KG-001 | FR-KG-001 | 答案图谱渲染(SVG 力导向) | mock normal 完成问答(graph 块 6 节点 4 边) | 打开工作台 Tab4 | 渲染手写 SVG 力导向图:节点圆点+标签;边为渐变线(#5E6AD2→#00D4FF)带关系名;节点与答案引用实体一致(与 graph 块节点 label 一致) | L5-D | P0 |
|
||||
| TC-KG-002 | FR-KG-001 | 节点 >50 降级列表 | mock 构造 graph 块 >50 节点 | 打开 Tab4 | 自动降级为列表视图并提示;不渲染 SVG 画布 | L5-D | P1 |
|
||||
| TC-KG-003 | FR-KG-002 | 节点着色与图例 | graph 块含 ≥2 种实体 type | 检查节点颜色与图例 | 按 type 着色(品牌+辅助紫+五相位色板);底部图例存在且与节点颜色一一对应;同 type 在会话内颜色稳定(两次问答同一 type 颜色相同) | L5-D | P2 |
|
||||
| TC-KG-004 | FR-KG-003 | 节点详情面板 | graph 块有节点;Neo4j 不可用(mock 无 kg 或 503) | 点击一个节点 | 侧滑详情面板显示实体属性(消息内已有属性);**Neo4j 不可用时仍显示**(不报错、不空白) | L5-D | P1 |
|
||||
| TC-KG-005 | FR-KG-003 | Neo4j 可用时拉全量属性 | Neo4j 可用(真实上游或 mock) | 点击节点 | 面板内额外数据来自 GET `/api/kg/entities/{name}`(发出该请求并渲染返回 properties) | L5-D | P2 |
|
||||
| TC-KG-006 | FR-KG-004 | 实体搜索(精确/前缀) | mock normal | 1. 输入「OsSPL」;2. 回车 | GET `/api/kg/entities?q=OsSPL`;结果显示(精确或前缀匹配项);响应 <1s(断言用时) | L5-D + L2 | P0 |
|
||||
| TC-KG-007 | FR-KG-004 | 实体搜索选中渲染邻域 | 上一步结果 | 点击第一个结果 | 渲染该实体邻域(调 neighborhood 接口或 mock 数据);画布更新为该实体为中心 | L5-D | P1 |
|
||||
| TC-KG-008 | FR-KG-005 | 邻域深度切换 | mock normal | 1. 深度选 1(默认)观察请求;2. 深度选 2 观察 | 请求 depth 参数分别为 1/2;渲染节点数随之变化;深度 2 时显示「较慢」提示文案(两跳实测约 13s) | L5-D + L2 | P1 |
|
||||
| TC-KG-009 | FR-KG-005 | 节点上限 200 截断 | mock 构造 >200 节点邻域 | 深度 2 展开 | 渲染节点数 ≤200;每跳按 paper_count/support_count 降序截断(L2 断言 BFF 截断逻辑);超限有提示不卡死 | L2 + L5 | P1 |
|
||||
| TC-KG-010 | FR-KG-006 | 缩放平移(桌面) | 画布有图 | 滚轮缩放;拖拽平移;双击 | 缩放范围 0.3×–3×;平移生效;双击复位到初始视图 | L5-D | P1 |
|
||||
| TC-KG-011 | FR-KG-006 | 触摸缩放平移(移动端) | Mobile 视口;画布有图 | 双指捏合;单指拖拽 | 双指缩放与单指拖拽均可用且流畅(缩放比例改变;拖拽后画布位移) | L5-M(touch 模拟) | P1 |
|
||||
| TC-KG-012 | FR-KG-007 | Neo4j 不可用降级 | mock kg 接口返回 503(或 BFF 断开 Neo4j) | 打开 Tab4 | 搜索框与邻域功能禁用并提示离线;**答案图谱照常渲染**;tab 不白屏 | L5-D | P1 |
|
||||
| TC-KG-013 | FR-KG-008 | 空态 | 本次回答无 graph 数据(mock empty) | 打开 Tab4 | 提示「本次检索未产生图谱关系」+ 引导使用实体搜索;不显示空画布 | L5-D(empty 场景) | P2 |
|
||||
|
||||
### 1.10 RESP 响应式(FR-RESP-001~013)
|
||||
|
||||
| 编号 | 对应 FR | 标题 | 前置条件 | 步骤 | 预期结果 | 测试层 | 优先级 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-RESP-001 | FR-RESP-001 | Desktop ≥1280 完整三栏 | Desktop 1440×900 | 检查布局 | 三栏 260/1fr/380 可见(同 TC-SHELL-001) | L5-D | P0 |
|
||||
| TC-RESP-002 | FR-RESP-002 | Laptop 1024–1279 左栏自动折叠 | 视口 1200×900 | 检查布局 | 三栏保留;左栏宽=64px(自动折叠,无需点击);右栏=340px | L5-D(1200×900) | P1 |
|
||||
| TC-RESP-003 | FR-RESP-003 | Tablet 左抽屉 | Tablet 768×1024 | 1. 检查布局(对话单栏铺满);2. 点顶栏汉堡 | 左栏默认隐藏;点汉堡后左抽屉 260px 滑入 + scrim 遮罩;顶栏有汉堡按钮;右栏为右抽屉(顶栏工作台按钮带来源数角标);点 scrim 或 Esc 关闭;动效 ease-out 300ms | L5-T | P0 |
|
||||
| TC-RESP-004 | FR-RESP-004 | Mobile 单栏与工作台覆盖层 | Mobile 390×844 | 1. 检查布局;2. 点顶栏工作台按钮 | 顶栏=汉堡+标题+工作台按钮;对话铺满;作曲器吸底;工作台打开为**全屏覆盖层+顶部 tab 条**;底部状态栏隐藏(不可见);四个 agent 点合并为顶栏一枚聚合状态点;点 scrim 或 Esc 关闭 | L5-M | P0 |
|
||||
| TC-RESP-005 | FR-RESP-005 | viewport 与安全区 | Mobile(iPhone 视口) | 检查 meta 与 computed 值 | `<meta viewport>` 含 `viewport-fit=cover`;作曲器 padding-bottom = env(safe-area-inset-bottom);顶栏 padding-top = env(safe-area-inset-top)(computed 值>0) | L5-M | P1 |
|
||||
| TC-RESP-006 | FR-RESP-006 | iOS 输入放大规避 | Mobile 视口(<768px) | 检查 textarea computed font-size | font-size=16px;**未使用** maximum-scale=1(meta 内容断言) | L5-M | P0 |
|
||||
| TC-RESP-007 | FR-RESP-007 | 悬停态触摸等价 | Mobile 视口;有来源条目与助手消息 | 检查来源外链图标、助手消息左缘线、会话条目样式 | 外链图标常驻可见;助手消息左缘线常驻;会话条目无 hover 底色依赖(点击反馈存在);`@media (hover:hover)` 包裹 hover 样式(L3 样式断言) | L5-M | P1 |
|
||||
| TC-RESP-008 | FR-RESP-008 | 触控目标 44px | Mobile 视口 | 检查会话条目、bench tab、模式 pill、图标按钮高度 | min-height ≥44px(computed) | L5-M | P1 |
|
||||
| TC-RESP-009 | FR-RESP-009 | 100dvh 视口高度 | Mobile 视口 | 检查应用根容器高度 | 使用 100dvh(computed 解析为实际视口高度);存在 100vh 回退声明(L3 样式断言) | L5-M | P2 |
|
||||
| TC-RESP-010 | FR-RESP-010 | 虚拟键盘适配 | Mobile 视口;visualViewport 可模拟 | 聚焦 textarea(模拟键盘弹出压缩 visualViewport) | 消息区高度压缩并保持贴底;作曲器始终可见(boundingBox 在视口内) | L5-M | P1 |
|
||||
| TC-RESP-011 | FR-RESP-011 | 表格横向滚动 | Mobile 视口;消息含宽表格 | 检查 md-table 容器 | 表格外层有横向滚动容器;容器 scrollWidth > clientWidth 时可横滑查看完整内容;字体字号未被压缩(computed font-size ≥ 桌面值) | L5-M | P1 |
|
||||
| TC-RESP-012 | FR-RESP-012 | 移动端性能降级 | Mobile 视口 | 检查 aurora 与图谱上限 | aurora 模糊半径降低或 `--pa-aurora-opacity:0`;图谱节点数上限=30(构造 >30 节点 graph 块时渲染 ≤30 或降级列表) | L5-M | P2 |
|
||||
| TC-RESP-013 | FR-RESP-013 | 手势(可选 N/A) | Mobile 视口 | 左缘右滑 | 若实现:右滑开会话抽屉、右滑关工作台覆盖层可用;**未实现不算缺陷,本条 N/A 记录** | L5-M(标记 N/A) | P2 |
|
||||
|
||||
### 1.11 HEALTH 健康与降级(FR-HEALTH-001~005)
|
||||
|
||||
| 编号 | 对应 FR | 标题 | 前置条件 | 步骤 | 预期结果 | 测试层 | 优先级 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-HEALTH-001 | FR-HEALTH-001 | 健康轮询 30s | 打开应用 | 观察 65s 内 GET `/api/health` 请求 | 挂载后立即一次;此后每 30s 一次(65s 内共 3 次) | L5-D | P1 |
|
||||
| TC-HEALTH-002 | FR-HEALTH-001 | 轮询失败不弹窗 | mock `/api/health` 持续 500 | 打开应用 | 无错误弹窗/未捕获异常;状态栏健康标记显示降级态 | L5-D | P1 |
|
||||
| TC-HEALTH-003 | FR-HEALTH-001 | 上游恢复 30s 内解禁 | mock 先 503 后恢复正常(场景切换) | 1. 观察禁用态;2. 切回正常场景;3. ≤30s 内观察 | 上游恢复后 ≤30s(一个轮询周期内)对应功能自动解禁,无需手动刷新 | L5-D | P1 |
|
||||
| TC-HEALTH-004 | FR-HEALTH-002 | QA 不可用降级 | mock scenario=degraded(/api/qa/* 503) | 打开应用 | 作曲器禁用并显示「知识问答服务当前不可用」;历史会话仍可浏览(点击会话可看历史消息);无错误弹窗 | L5-D(degraded 场景) | P0 |
|
||||
| TC-HEALTH-005 | FR-HEALTH-002 | QA 降级信封语义 | mock degraded | 检查响应结构 | HTTP 503 + `{"ok":false,"error":{"code":"UPSTREAM_DOWN","message":"…","degraded":true}}`;前端按 degraded 处理(横幅/离线态)而非错误弹窗 | L2 + L5 | P0 |
|
||||
| TC-HEALTH-006 | FR-HEALTH-003 | Neo4j 不可用降级 | mock kg 503 | 打开 Tab4 | 搜索/邻域禁用并提示离线;答案图谱照常渲染(同 TC-KG-012) | L5-D | P1 |
|
||||
| TC-HEALTH-007 | FR-HEALTH-004 | 抽取不可用降级 | mock extraction 503 | 打开 Tab3 | 仅 Tab3 降级(离线提示+重试按钮);其他 tab 正常(同 TC-EXTRACT-011) | L5-D | P1 |
|
||||
| TC-HEALTH-008 | FR-HEALTH-005 | 网络中断提示与不自动重连 | 流式中断网(同 TC-CHAT-030) | 1. 断网;2. 恢复网络观察 | 提示「连接中断」+ 重试按钮;不自动重连;无重复 LLM 调用(Network 无自动新请求) | L5-D | P0 |
|
||||
|
||||
### 1.12 非功能要求(PRD §14)对照用例
|
||||
|
||||
| 编号 | 对应要求 | 标题 | 前置条件 | 步骤 | 预期结果 | 测试层 | 优先级 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-NFR-001 | 首屏可交互 | 桌面;mock normal | 打开应用并计时(performance.timing / Playwright 计时) | 桌面 <2s 可交互(首屏渲染+会话列表加载完成);手机 <3s | L5-D / L5-M | P1 |
|
||||
| TC-NFR-002 | 长任务反馈 | mock slow(长流) | 提问并采样 | 240s 内无静止 >5s 状态(同 TC-CHAT-029) | L5-D | P0 |
|
||||
| TC-NFR-003 | Neo4j 凭据不下发 | 打开应用并抓取全部网络响应与 JS bundle 文本 | 浏览器侧任何响应/源码中不含 Neo4j 密码(<neo4j-password-in-.env> 等)与 bolt:// URI | L5-D + L3(bundle 扫描) | P0 |
|
||||
| TC-NFR-004 | 零外网请求 | 打开应用并抓取全部网络请求 | 所有请求 host 均为 127.0.0.1(或部署域);无 fonts.googleapis.com / CDN 域名请求 | L5-D | P1 |
|
||||
| TC-NFR-005 | 浏览器兼容基线 | Chrome 110+/Firefox 110+/Safari 16+ | Playwright 三浏览器各跑冒烟 2 条 | 冒烟通过(页面加载 + AppShell 存在) | L5(chromium 常驻;firefox/webkit 每轮按需) | P1 |
|
||||
|
||||
### 1.13 E2E 主线场景(TEST_PLAN §6 九条主线 × 三档视口)
|
||||
|
||||
编号规则 `TC-E2E-<主线号>-<视口>`,M=Mobile(390×844)|T=Tablet(768×1024)|D=Desktop(1440×900)。每条为一条用例,步骤与预期为该视口下的完整流程断言。全量用例在后续轮次落为 spec 文件;本波仅实现 TC-E2E-SMOKE-01/02(§1.14)。
|
||||
|
||||
| 编号 | 主线 | 视口 | 步骤 | 预期结果 | 优先级 |
|
||||
|---|---|---|---|---|---|
|
||||
| TC-E2E-01-M | 1 主流程 | M | 新建会话 → 问答模式提问 → 观察流水线五相位推进 → 等待答案揭示(含表格)→ 来源 tab 有数据 → 图谱 tab 渲染 → 刷新后历史恢复 | 五相位按序推进(waiting→running→done);答案揭示并含 md-table;Tab2 条目数=source_count;Tab4 SVG 图渲染;刷新后该会话消息完整恢复 | P0 |
|
||||
| TC-E2E-01-T | 1 主流程 | T | 同 TC-E2E-01-M(左栏经抽屉进入会话) | 同 TC-E2E-01-M;另断言左/右抽屉开合正常 | P0 |
|
||||
| TC-E2E-01-D | 1 主流程 | D | 同 TC-E2E-01-M | 同 TC-E2E-01-M | P0 |
|
||||
| TC-E2E-02-M | 2 检索模式 | M | 检索模式提问 → 等待完成 → 检查消息块 | 无 text 答案块;仅来源数据;请求体 use_llm=false 且端点 `/api/qa/search` | P0 |
|
||||
| TC-E2E-02-T | 2 检索模式 | T | 同 TC-E2E-02-M | 同 TC-E2E-02-M | P0 |
|
||||
| TC-E2E-02-D | 2 检索模式 | D | 同 TC-E2E-02-M | 同 TC-E2E-02-M | P0 |
|
||||
| TC-E2E-03-M | 3 错误与重试 | M | scenario=error 下提问 → 观察错误;scenario=degraded 下观察作曲器 | error:错误文案展示+重试按钮可用(点击后重新发起);degraded:作曲器禁用+「知识问答服务当前不可用」提示 | P0 |
|
||||
| TC-E2E-03-T | 3 错误与重试 | T | 同 TC-E2E-03-M | 同 TC-E2E-03-M | P0 |
|
||||
| TC-E2E-03-D | 3 错误与重试 | D | 同 TC-E2E-03-M | 同 TC-E2E-03-M | P0 |
|
||||
| TC-E2E-04-M | 4 会话管理 | M | 新建/切换/重命名/删除/搜索过滤/观察分组 | 每步按 SESSION 用例断言:新建+1、切换加载历史、重命名同步顶栏、删除需确认且列表-1、搜索实时过滤、分组标签正确 | P0 |
|
||||
| TC-E2E-04-T | 4 会话管理 | T | 同 TC-E2E-04-M | 同 TC-E2E-04-M | P0 |
|
||||
| TC-E2E-04-D | 4 会话管理 | D | 同 TC-E2E-04-M | 同 TC-E2E-04-M | P0 |
|
||||
| TC-E2E-05-M | 5 中断 | M | 流式中途切会话 → 切回原会话 | 原会话置 error 且标注「已中断」;已收到块保留;无悬挂流 | P0 |
|
||||
| TC-E2E-05-T | 5 中断 | T | 同 TC-E2E-05-M | 同 TC-E2E-05-M | P0 |
|
||||
| TC-E2E-05-D | 5 中断 | D | 同 TC-E2E-05-M | 同 TC-E2E-05-M | P0 |
|
||||
| TC-E2E-06-M | 6 主题 | M | 深浅切换 → 刷新 | 切换即时生效(dataset.theme 与 localStorage);刷新后保持 | P0 |
|
||||
| TC-E2E-06-T | 6 主题 | T | 同 TC-E2E-06-M | 同 TC-E2E-06-M | P0 |
|
||||
| TC-E2E-06-D | 6 主题 | D | 同 TC-E2E-06-M | 同 TC-E2E-06-M | P0 |
|
||||
| TC-E2E-07-M | 7 图谱 | M | 实体搜索 → 选中渲染邻域 → 节点详情 → 双指缩放 + 单指拖拽 | 搜索结果渲染;邻域画布更新;详情面板打开;触摸缩放/拖拽生效 | P1 |
|
||||
| TC-E2E-07-T | 7 图谱 | T | 同 TC-E2E-07-M(触摸为单指拖拽+双指捏合) | 同 TC-E2E-07-M | P1 |
|
||||
| TC-E2E-07-D | 7 图谱 | D | 实体搜索 → 邻域 → 详情 → 滚轮缩放/拖拽平移/双击复位 | 同 TC-E2E-07-M(桌面交互) | P1 |
|
||||
| TC-E2E-08-M | 8 抽取 | M | 任务列表 → 详情展开 → 运行日志;mock 下上传 PDF | 列表渲染+百分比正确;详情字段齐全;日志滚动;上传成功返回 job_id 并刷新列表 | P1 |
|
||||
| TC-E2E-08-T | 8 抽取 | T | 同 TC-E2E-08-M | 同 TC-E2E-08-M | P1 |
|
||||
| TC-E2E-08-D | 8 抽取 | D | 同 TC-E2E-08-M | 同 TC-E2E-08-M | P1 |
|
||||
| TC-E2E-09-M | 9 响应式专项 | M | 布局断言(FR-RESP-004 全项)+ 移动端专项:安全区、16px 输入、44px 触控、表格横滚、键盘适配 | 顶栏=汉堡+标题+工作台按钮;作曲器吸底;工作台全屏覆盖层+tab 条;底栏隐藏;聚合状态点存在;safe-area padding 生效;textarea 16px;触控目标 ≥44px;宽表格横滚;聚焦时作曲器可见 | P0 |
|
||||
| TC-E2E-09-T | 9 响应式专项 | T | 布局断言(FR-RESP-003 全项:抽屉/scrim/角标) | 单栏铺满;左右抽屉开合+scrim+Esc 关闭;汉堡与工作台按钮带来源数角标 | P0 |
|
||||
| TC-E2E-09-D | 9 响应式专项 | D | 布局断言(FR-RESP-001/002:1280 边界与 1200 折叠) | 1440 完整三栏;1200 左栏自动折叠 64px 右栏 340px | P0 |
|
||||
|
||||
### 1.14 E2E 冒烟骨架用例(本波已实现)
|
||||
|
||||
| 编号 | 对应 FR | 标题 | 前置条件 | 步骤 | 预期结果 | 测试层 | 优先级 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| TC-E2E-SMOKE-01 | FR-SHELL-007(页面加载) | 页面加载冒烟 | dev server 5180 可用;三档视口各跑一次 | 打开 `/` | title 含「海古纪 PlantAI」;`#app` 可见且内容非空 | L5-M/T/D | P0 |
|
||||
| TC-E2E-SMOKE-02 | FR-SHELL-001/003/FR-RESP-004(壳层;移动端底栏隐藏断言) | AppShell 存在冒烟 | 同上 | 打开 `/` | 存在 `.app-shell` 时:shell/top-bar 可见且无占位符;status-bar 在 ≥768px 视口可见、移动端隐藏(FR-RESP-004);M0 占位期(无 .app-shell)回退:`.app-shell-placeholder` 可见 | L5-M/T/D | P0 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 追溯矩阵
|
||||
|
||||
### 2.1 统计总览
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| FR 总数 | 81 |
|
||||
| 已覆盖 FR | 81 |
|
||||
| 未覆盖清单 | (空) |
|
||||
| TC 总数 | 179 |
|
||||
| 孤儿 TC(无 FR 映射) | 0 |
|
||||
| 主门禁(FR→TC 且 TC→FR 双向完整) | 81/81 = 100% |
|
||||
|
||||
### 2.2 FR→TC 矩阵(每个 FR 列出其全部 TC)
|
||||
|
||||
| FR | 用例 |
|
||||
|---|---|
|
||||
| FR-SHELL-001 | TC-SHELL-001, TC-SHELL-002, TC-E2E-SMOKE-02, TC-E2E-09-D |
|
||||
| FR-SHELL-002 | TC-SHELL-003, TC-SHELL-004, TC-SHELL-005 |
|
||||
| FR-SHELL-003 | TC-SHELL-006, TC-SHELL-007, TC-E2E-SMOKE-02 |
|
||||
| FR-SHELL-004 | TC-SHELL-008, TC-SHELL-009 |
|
||||
| FR-SHELL-005 | TC-SHELL-010 |
|
||||
| FR-SHELL-006 | TC-SHELL-011, TC-SHELL-012 |
|
||||
| FR-SHELL-007 | TC-SHELL-013, TC-SHELL-014, TC-SHELL-015, TC-E2E-SMOKE-01 |
|
||||
| FR-THEME-001 | TC-THEME-001, TC-THEME-002 |
|
||||
| FR-THEME-002 | TC-THEME-003, TC-THEME-004, TC-E2E-06-M, TC-E2E-06-T, TC-E2E-06-D |
|
||||
| FR-THEME-003 | TC-THEME-005 |
|
||||
| FR-THEME-004 | TC-THEME-006 |
|
||||
| FR-THEME-005 | TC-THEME-007, TC-THEME-008 |
|
||||
| FR-THEME-006 | TC-THEME-009, TC-THEME-010 |
|
||||
| FR-SESSION-001 | TC-SESSION-001, TC-SESSION-002, TC-SESSION-003, TC-E2E-04-M, TC-E2E-04-T, TC-E2E-04-D |
|
||||
| FR-SESSION-002 | TC-SESSION-004 |
|
||||
| FR-SESSION-003 | TC-SESSION-005, TC-SESSION-006, TC-E2E-04-M, TC-E2E-04-T, TC-E2E-04-D |
|
||||
| FR-SESSION-004 | TC-SESSION-007, TC-SESSION-008, TC-E2E-04-M, TC-E2E-04-T, TC-E2E-04-D |
|
||||
| FR-SESSION-005 | TC-SESSION-009, TC-E2E-04-M, TC-E2E-04-T, TC-E2E-04-D |
|
||||
| FR-SESSION-006 | TC-SESSION-010, TC-SESSION-011, TC-E2E-04-M, TC-E2E-04-T, TC-E2E-04-D |
|
||||
| FR-SESSION-007 | TC-SESSION-012, TC-SESSION-013, TC-SESSION-014, TC-SESSION-015, TC-E2E-04-M, TC-E2E-04-T, TC-E2E-04-D |
|
||||
| FR-SESSION-008 | TC-SESSION-016, TC-SESSION-017 |
|
||||
| FR-SESSION-009 | TC-SESSION-018 |
|
||||
| FR-CHAT-001 | TC-CHAT-001, TC-CHAT-002 |
|
||||
| FR-CHAT-002 | TC-CHAT-003 |
|
||||
| FR-CHAT-003 | TC-CHAT-004, TC-CHAT-005 |
|
||||
| FR-CHAT-004 | TC-CHAT-006, TC-CHAT-007 |
|
||||
| FR-CHAT-005 | TC-CHAT-012 |
|
||||
| FR-CHAT-006 | TC-CHAT-013, TC-CHAT-014, TC-CHAT-015, TC-CHAT-016, TC-E2E-02-M, TC-E2E-02-T, TC-E2E-02-D |
|
||||
| FR-CHAT-007 | TC-CHAT-017, TC-CHAT-018, TC-CHAT-019, TC-CHAT-020 |
|
||||
| FR-CHAT-008 | TC-CHAT-023, TC-CHAT-024, TC-CHAT-025, TC-CHAT-026, TC-CHAT-027, TC-CHAT-028, TC-CHAT-029, TC-CHAT-030, TC-CHAT-031, TC-CHAT-032, TC-CHAT-033, TC-E2E-01-M, TC-E2E-01-T, TC-E2E-01-D, TC-E2E-05-M, TC-E2E-05-T, TC-E2E-05-D |
|
||||
| FR-CHAT-009 | TC-CHAT-034, TC-CHAT-035 |
|
||||
| FR-CHAT-010 | TC-CHAT-036, TC-CHAT-037 |
|
||||
| FR-CHAT-011 | TC-CHAT-021, TC-CHAT-022, TC-E2E-05-M, TC-E2E-05-T, TC-E2E-05-D |
|
||||
| FR-CHAT-012 | TC-CHAT-038, TC-CHAT-039, TC-CHAT-040, TC-CHAT-041, TC-E2E-03-M, TC-E2E-03-T, TC-E2E-03-D |
|
||||
| FR-CHAT-013 | TC-CHAT-042, TC-CHAT-043, TC-E2E-01-M, TC-E2E-01-T, TC-E2E-01-D |
|
||||
| FR-CHAT-014 | TC-CHAT-008, TC-CHAT-009, TC-CHAT-010 |
|
||||
| FR-CHAT-015 | TC-CHAT-011 |
|
||||
| FR-RETRIEVAL-001 | TC-RETRIEVAL-001, TC-RETRIEVAL-002 |
|
||||
| FR-RETRIEVAL-002 | TC-RETRIEVAL-003 |
|
||||
| FR-RETRIEVAL-003 | TC-RETRIEVAL-004 |
|
||||
| FR-RETRIEVAL-004 | TC-RETRIEVAL-005 |
|
||||
| FR-RETRIEVAL-005 | TC-RETRIEVAL-006 |
|
||||
| FR-RETRIEVAL-006 | TC-RETRIEVAL-007 |
|
||||
| FR-SOURCE-001 | TC-SOURCE-001, TC-SOURCE-002 |
|
||||
| FR-SOURCE-002 | TC-SOURCE-003 |
|
||||
| FR-SOURCE-003 | TC-SOURCE-004 |
|
||||
| FR-SOURCE-004 | TC-SOURCE-005 |
|
||||
| FR-SOURCE-005 | TC-SOURCE-006 |
|
||||
| FR-SOURCE-006 | TC-SOURCE-007 |
|
||||
| FR-EXTRACT-001 | TC-EXTRACT-001, TC-E2E-08-M, TC-E2E-08-T, TC-E2E-08-D |
|
||||
| FR-EXTRACT-002 | TC-EXTRACT-002, TC-EXTRACT-003, TC-E2E-08-M, TC-E2E-08-T, TC-E2E-08-D |
|
||||
| FR-EXTRACT-003 | TC-EXTRACT-004, TC-E2E-08-M, TC-E2E-08-T, TC-E2E-08-D |
|
||||
| FR-EXTRACT-004 | TC-EXTRACT-005, TC-EXTRACT-006, TC-EXTRACT-007, TC-EXTRACT-008, TC-E2E-08-M, TC-E2E-08-T, TC-E2E-08-D |
|
||||
| FR-EXTRACT-005 | TC-EXTRACT-009, TC-EXTRACT-010 |
|
||||
| FR-EXTRACT-006 | TC-EXTRACT-011 |
|
||||
| FR-KG-001 | TC-KG-001, TC-KG-002 |
|
||||
| FR-KG-002 | TC-KG-003 |
|
||||
| FR-KG-003 | TC-KG-004, TC-KG-005 |
|
||||
| FR-KG-004 | TC-KG-006, TC-KG-007, TC-E2E-07-M, TC-E2E-07-T, TC-E2E-07-D |
|
||||
| FR-KG-005 | TC-KG-008, TC-KG-009, TC-E2E-07-M, TC-E2E-07-T, TC-E2E-07-D |
|
||||
| FR-KG-006 | TC-KG-010, TC-KG-011, TC-E2E-07-M, TC-E2E-07-T, TC-E2E-07-D |
|
||||
| FR-KG-007 | TC-KG-012 |
|
||||
| FR-KG-008 | TC-KG-013 |
|
||||
| FR-RESP-001 | TC-RESP-001, TC-E2E-09-D |
|
||||
| FR-RESP-002 | TC-RESP-002, TC-E2E-09-D |
|
||||
| FR-RESP-003 | TC-RESP-003, TC-E2E-09-T |
|
||||
| FR-RESP-004 | TC-RESP-004, TC-E2E-09-M, TC-E2E-SMOKE-02 |
|
||||
| FR-RESP-005 | TC-RESP-005, TC-E2E-09-M |
|
||||
| FR-RESP-006 | TC-RESP-006, TC-E2E-09-M |
|
||||
| FR-RESP-007 | TC-RESP-007, TC-E2E-09-M |
|
||||
| FR-RESP-008 | TC-RESP-008, TC-E2E-09-M |
|
||||
| FR-RESP-009 | TC-RESP-009, TC-E2E-09-M |
|
||||
| FR-RESP-010 | TC-RESP-010, TC-E2E-09-M |
|
||||
| FR-RESP-011 | TC-RESP-011, TC-E2E-09-M |
|
||||
| FR-RESP-012 | TC-RESP-012, TC-E2E-09-M |
|
||||
| FR-RESP-013 | TC-RESP-013(N/A 可选) |
|
||||
| FR-HEALTH-001 | TC-HEALTH-001, TC-HEALTH-002, TC-HEALTH-003 |
|
||||
| FR-HEALTH-002 | TC-HEALTH-004, TC-HEALTH-005, TC-E2E-03-M, TC-E2E-03-T, TC-E2E-03-D |
|
||||
| FR-HEALTH-003 | TC-HEALTH-006, TC-KG-012 |
|
||||
| FR-HEALTH-004 | TC-HEALTH-007, TC-EXTRACT-011 |
|
||||
| FR-HEALTH-005 | TC-HEALTH-008, TC-CHAT-030 |
|
||||
|
||||
> 非功能要求(PRD §14,非 FR 编号)由 TC-NFR-001~005 覆盖:首屏性能(TC-NFR-001)、长任务反馈(TC-NFR-002)、凭据安全(TC-NFR-003)、零外网(TC-NFR-004)、浏览器兼容(TC-NFR-005)。
|
||||
|
||||
### 2.3 TC→FR 矩阵(每条 TC 列出其全部 FR,证明无孤儿)
|
||||
|
||||
| TC | FR |
|
||||
|---|---|
|
||||
| TC-SHELL-001 | FR-SHELL-001 |
|
||||
| TC-SHELL-002 | FR-SHELL-001 |
|
||||
| TC-SHELL-003 | FR-SHELL-002 |
|
||||
| TC-SHELL-004 | FR-SHELL-002 |
|
||||
| TC-SHELL-005 | FR-SHELL-002 |
|
||||
| TC-SHELL-006 | FR-SHELL-003 |
|
||||
| TC-SHELL-007 | FR-SHELL-003 |
|
||||
| TC-SHELL-008 | FR-SHELL-004 |
|
||||
| TC-SHELL-009 | FR-SHELL-004 |
|
||||
| TC-SHELL-010 | FR-SHELL-005 |
|
||||
| TC-SHELL-011 | FR-SHELL-006 |
|
||||
| TC-SHELL-012 | FR-SHELL-006 |
|
||||
| TC-SHELL-013 | FR-SHELL-007 |
|
||||
| TC-SHELL-014 | FR-SHELL-007 |
|
||||
| TC-SHELL-015 | FR-SHELL-007 |
|
||||
| TC-THEME-001 | FR-THEME-001 |
|
||||
| TC-THEME-002 | FR-THEME-001 |
|
||||
| TC-THEME-003 | FR-THEME-002 |
|
||||
| TC-THEME-004 | FR-THEME-002 |
|
||||
| TC-THEME-005 | FR-THEME-003 |
|
||||
| TC-THEME-006 | FR-THEME-004 |
|
||||
| TC-THEME-007 | FR-THEME-005 |
|
||||
| TC-THEME-008 | FR-THEME-005 |
|
||||
| TC-THEME-009 | FR-THEME-006 |
|
||||
| TC-THEME-010 | FR-THEME-006 |
|
||||
| TC-SESSION-001 | FR-SESSION-001 |
|
||||
| TC-SESSION-002 | FR-SESSION-001 |
|
||||
| TC-SESSION-003 | FR-SESSION-001 |
|
||||
| TC-SESSION-004 | FR-SESSION-002 |
|
||||
| TC-SESSION-005 | FR-SESSION-003 |
|
||||
| TC-SESSION-006 | FR-SESSION-003 |
|
||||
| TC-SESSION-007 | FR-SESSION-004 |
|
||||
| TC-SESSION-008 | FR-SESSION-004 |
|
||||
| TC-SESSION-009 | FR-SESSION-005 |
|
||||
| TC-SESSION-010 | FR-SESSION-006 |
|
||||
| TC-SESSION-011 | FR-SESSION-006 |
|
||||
| TC-SESSION-012 | FR-SESSION-007 |
|
||||
| TC-SESSION-013 | FR-SESSION-007 |
|
||||
| TC-SESSION-014 | FR-SESSION-007 |
|
||||
| TC-SESSION-015 | FR-SESSION-007 |
|
||||
| TC-SESSION-016 | FR-SESSION-008 |
|
||||
| TC-SESSION-017 | FR-SESSION-008 |
|
||||
| TC-SESSION-018 | FR-SESSION-009 |
|
||||
| TC-CHAT-001 | FR-CHAT-001 |
|
||||
| TC-CHAT-002 | FR-CHAT-001 |
|
||||
| TC-CHAT-003 | FR-CHAT-002 |
|
||||
| TC-CHAT-004 | FR-CHAT-003 |
|
||||
| TC-CHAT-005 | FR-CHAT-003 |
|
||||
| TC-CHAT-006 | FR-CHAT-004 |
|
||||
| TC-CHAT-007 | FR-CHAT-004 |
|
||||
| TC-CHAT-008 | FR-CHAT-014 |
|
||||
| TC-CHAT-009 | FR-CHAT-014 |
|
||||
| TC-CHAT-010 | FR-CHAT-014 |
|
||||
| TC-CHAT-011 | FR-CHAT-015 |
|
||||
| TC-CHAT-012 | FR-CHAT-005 |
|
||||
| TC-CHAT-013 | FR-CHAT-006 |
|
||||
| TC-CHAT-014 | FR-CHAT-006 |
|
||||
| TC-CHAT-015 | FR-CHAT-006 |
|
||||
| TC-CHAT-016 | FR-CHAT-006 |
|
||||
| TC-CHAT-017 | FR-CHAT-007 |
|
||||
| TC-CHAT-018 | FR-CHAT-007 |
|
||||
| TC-CHAT-019 | FR-CHAT-007 |
|
||||
| TC-CHAT-020 | FR-CHAT-007 |
|
||||
| TC-CHAT-021 | FR-CHAT-011 |
|
||||
| TC-CHAT-022 | FR-CHAT-011 |
|
||||
| TC-CHAT-023 | FR-CHAT-008 |
|
||||
| TC-CHAT-024 | FR-CHAT-008 |
|
||||
| TC-CHAT-025 | FR-CHAT-008 |
|
||||
| TC-CHAT-026 | FR-CHAT-008 |
|
||||
| TC-CHAT-027 | FR-CHAT-008 |
|
||||
| TC-CHAT-028 | FR-CHAT-008 |
|
||||
| TC-CHAT-029 | FR-CHAT-008 |
|
||||
| TC-CHAT-030 | FR-CHAT-008, FR-HEALTH-005 |
|
||||
| TC-CHAT-031 | FR-CHAT-008 |
|
||||
| TC-CHAT-032 | FR-CHAT-008 |
|
||||
| TC-CHAT-033 | FR-CHAT-008 |
|
||||
| TC-CHAT-034 | FR-CHAT-009 |
|
||||
| TC-CHAT-035 | FR-CHAT-009 |
|
||||
| TC-CHAT-036 | FR-CHAT-010 |
|
||||
| TC-CHAT-037 | FR-CHAT-010 |
|
||||
| TC-CHAT-038 | FR-CHAT-012 |
|
||||
| TC-CHAT-039 | FR-CHAT-012 |
|
||||
| TC-CHAT-040 | FR-CHAT-012 |
|
||||
| TC-CHAT-041 | FR-CHAT-012 |
|
||||
| TC-CHAT-042 | FR-CHAT-013 |
|
||||
| TC-CHAT-043 | FR-CHAT-013 |
|
||||
| TC-RETRIEVAL-001 | FR-RETRIEVAL-001 |
|
||||
| TC-RETRIEVAL-002 | FR-RETRIEVAL-001 |
|
||||
| TC-RETRIEVAL-003 | FR-RETRIEVAL-002 |
|
||||
| TC-RETRIEVAL-004 | FR-RETRIEVAL-003 |
|
||||
| TC-RETRIEVAL-005 | FR-RETRIEVAL-004 |
|
||||
| TC-RETRIEVAL-006 | FR-RETRIEVAL-005 |
|
||||
| TC-RETRIEVAL-007 | FR-RETRIEVAL-006 |
|
||||
| TC-SOURCE-001 | FR-SOURCE-001 |
|
||||
| TC-SOURCE-002 | FR-SOURCE-001 |
|
||||
| TC-SOURCE-003 | FR-SOURCE-002 |
|
||||
| TC-SOURCE-004 | FR-SOURCE-003 |
|
||||
| TC-SOURCE-005 | FR-SOURCE-004 |
|
||||
| TC-SOURCE-006 | FR-SOURCE-005 |
|
||||
| TC-SOURCE-007 | FR-SOURCE-006 |
|
||||
| TC-EXTRACT-001 | FR-EXTRACT-001 |
|
||||
| TC-EXTRACT-002 | FR-EXTRACT-002 |
|
||||
| TC-EXTRACT-003 | FR-EXTRACT-002 |
|
||||
| TC-EXTRACT-004 | FR-EXTRACT-003 |
|
||||
| TC-EXTRACT-005 | FR-EXTRACT-004 |
|
||||
| TC-EXTRACT-006 | FR-EXTRACT-004 |
|
||||
| TC-EXTRACT-007 | FR-EXTRACT-004 |
|
||||
| TC-EXTRACT-008 | FR-EXTRACT-004 |
|
||||
| TC-EXTRACT-009 | FR-EXTRACT-005 |
|
||||
| TC-EXTRACT-010 | FR-EXTRACT-005 |
|
||||
| TC-EXTRACT-011 | FR-EXTRACT-006, FR-HEALTH-004 |
|
||||
| TC-KG-001 | FR-KG-001 |
|
||||
| TC-KG-002 | FR-KG-001 |
|
||||
| TC-KG-003 | FR-KG-002 |
|
||||
| TC-KG-004 | FR-KG-003 |
|
||||
| TC-KG-005 | FR-KG-003 |
|
||||
| TC-KG-006 | FR-KG-004 |
|
||||
| TC-KG-007 | FR-KG-004 |
|
||||
| TC-KG-008 | FR-KG-005 |
|
||||
| TC-KG-009 | FR-KG-005 |
|
||||
| TC-KG-010 | FR-KG-006 |
|
||||
| TC-KG-011 | FR-KG-006 |
|
||||
| TC-KG-012 | FR-KG-007, FR-HEALTH-003 |
|
||||
| TC-KG-013 | FR-KG-008 |
|
||||
| TC-RESP-001 | FR-RESP-001 |
|
||||
| TC-RESP-002 | FR-RESP-002 |
|
||||
| TC-RESP-003 | FR-RESP-003 |
|
||||
| TC-RESP-004 | FR-RESP-004 |
|
||||
| TC-RESP-005 | FR-RESP-005 |
|
||||
| TC-RESP-006 | FR-RESP-006 |
|
||||
| TC-RESP-007 | FR-RESP-007 |
|
||||
| TC-RESP-008 | FR-RESP-008 |
|
||||
| TC-RESP-009 | FR-RESP-009 |
|
||||
| TC-RESP-010 | FR-RESP-010 |
|
||||
| TC-RESP-011 | FR-RESP-011 |
|
||||
| TC-RESP-012 | FR-RESP-012 |
|
||||
| TC-RESP-013 | FR-RESP-013(N/A 可选) |
|
||||
| TC-HEALTH-001 | FR-HEALTH-001 |
|
||||
| TC-HEALTH-002 | FR-HEALTH-001 |
|
||||
| TC-HEALTH-003 | FR-HEALTH-001 |
|
||||
| TC-HEALTH-004 | FR-HEALTH-002 |
|
||||
| TC-HEALTH-005 | FR-HEALTH-002 |
|
||||
| TC-HEALTH-006 | FR-HEALTH-003 |
|
||||
| TC-HEALTH-007 | FR-HEALTH-004 |
|
||||
| TC-HEALTH-008 | FR-HEALTH-005 |
|
||||
| TC-NFR-001 | PRD §14 首屏可交互(非 FR) |
|
||||
| TC-NFR-002 | PRD §14 长任务反馈(非 FR) |
|
||||
| TC-NFR-003 | PRD §14 数据安全(非 FR) |
|
||||
| TC-NFR-004 | PRD §14 离线容忍(非 FR) |
|
||||
| TC-NFR-005 | PRD §14 浏览器支持(非 FR) |
|
||||
| TC-E2E-01-M | FR-CHAT-008, FR-CHAT-013 |
|
||||
| TC-E2E-01-T | FR-CHAT-008, FR-CHAT-013 |
|
||||
| TC-E2E-01-D | FR-CHAT-008, FR-CHAT-013 |
|
||||
| TC-E2E-02-M | FR-CHAT-006 |
|
||||
| TC-E2E-02-T | FR-CHAT-006 |
|
||||
| TC-E2E-02-D | FR-CHAT-006 |
|
||||
| TC-E2E-03-M | FR-CHAT-012, FR-HEALTH-002 |
|
||||
| TC-E2E-03-T | FR-CHAT-012, FR-HEALTH-002 |
|
||||
| TC-E2E-03-D | FR-CHAT-012, FR-HEALTH-002 |
|
||||
| TC-E2E-04-M | FR-SESSION-001, FR-SESSION-003, FR-SESSION-004, FR-SESSION-005, FR-SESSION-006, FR-SESSION-007 |
|
||||
| TC-E2E-04-T | FR-SESSION-001, FR-SESSION-003, FR-SESSION-004, FR-SESSION-005, FR-SESSION-006, FR-SESSION-007 |
|
||||
| TC-E2E-04-D | FR-SESSION-001, FR-SESSION-003, FR-SESSION-004, FR-SESSION-005, FR-SESSION-006, FR-SESSION-007 |
|
||||
| TC-E2E-05-M | FR-CHAT-008, FR-CHAT-011 |
|
||||
| TC-E2E-05-T | FR-CHAT-008, FR-CHAT-011 |
|
||||
| TC-E2E-05-D | FR-CHAT-008, FR-CHAT-011 |
|
||||
| TC-E2E-06-M | FR-THEME-002 |
|
||||
| TC-E2E-06-T | FR-THEME-002 |
|
||||
| TC-E2E-06-D | FR-THEME-002 |
|
||||
| TC-E2E-07-M | FR-KG-004, FR-KG-005, FR-KG-006 |
|
||||
| TC-E2E-07-T | FR-KG-004, FR-KG-005, FR-KG-006 |
|
||||
| TC-E2E-07-D | FR-KG-004, FR-KG-005, FR-KG-006 |
|
||||
| TC-E2E-08-M | FR-EXTRACT-001, FR-EXTRACT-002, FR-EXTRACT-003, FR-EXTRACT-004 |
|
||||
| TC-E2E-08-T | FR-EXTRACT-001, FR-EXTRACT-002, FR-EXTRACT-003, FR-EXTRACT-004 |
|
||||
| TC-E2E-08-D | FR-EXTRACT-001, FR-EXTRACT-002, FR-EXTRACT-003, FR-EXTRACT-004 |
|
||||
| TC-E2E-09-M | FR-RESP-004, FR-RESP-005, FR-RESP-006, FR-RESP-007, FR-RESP-008, FR-RESP-009, FR-RESP-010, FR-RESP-011, FR-RESP-012 |
|
||||
| TC-E2E-09-T | FR-RESP-003 |
|
||||
| TC-E2E-09-D | FR-RESP-001, FR-RESP-002, FR-SHELL-001 |
|
||||
| TC-E2E-SMOKE-01 | FR-SHELL-007 |
|
||||
| TC-E2E-SMOKE-02 | FR-SHELL-001, FR-SHELL-003, FR-RESP-004 |
|
||||
|
||||
### 2.4 模块分布
|
||||
|
||||
| 模块 | TC 条数(含 E2E 拆分) |
|
||||
|---|---|
|
||||
| SHELL | 15 |
|
||||
| THEME | 10 |
|
||||
| SESSION | 18 |
|
||||
| CHAT | 43 |
|
||||
| RETRIEVAL | 7 |
|
||||
| SOURCE | 7 |
|
||||
| EXTRACT | 11 |
|
||||
| KG | 13 |
|
||||
| RESP | 13 |
|
||||
| HEALTH | 8 |
|
||||
| NFR(PRD §14) | 5 |
|
||||
| E2E 主线/冒烟 | 29 |
|
||||
| **合计** | **179** |
|
||||
|
||||
---
|
||||
|
||||
## 3. 执行说明
|
||||
|
||||
### 3.1 分层运行命令(TEST_PLAN §7)
|
||||
|
||||
```bash
|
||||
# L1 后端单元(远端 plantai-bff conda 环境;A 组自测,T 复跑确认)
|
||||
cd backend && pytest tests -m unit -q
|
||||
|
||||
# L2 后端接口(respx mock 上游;用例见 L2 标注项)
|
||||
cd backend && pytest tests -m api -q
|
||||
|
||||
# L2s 上游 smoke(仅集成点 M2 与验收前手动各跑一次;远端本机执行)
|
||||
bash tests/smoke_upstreams.sh
|
||||
|
||||
# L3+L4 前端(vitest;远端 frontend/)
|
||||
cd frontend && npx vitest run
|
||||
|
||||
# L5 E2E(三档视口;dev server 由 playwright webServer 自动拉起或复用已启动的 5180)
|
||||
cd frontend && npx playwright test # playwright 未安装时:npx playwright@1.49.1 test
|
||||
```
|
||||
|
||||
### 3.2 Mock 场景使用(ARCHITECTURE.md §6)
|
||||
|
||||
mock server:`python backend/mock/mock_server.py`(127.0.0.1:8099),场景经查询参数或请求头 `X-Mock-Scenario` 切换。E2E 中由测试代码在请求头注入或 mock 路由拦截实现:
|
||||
|
||||
| scenario | 用途 | 使用用例示例 |
|
||||
|---|---|---|
|
||||
| `normal` | 主流程(SSE 回放 12 帧真实时序) | TC-CHAT-023~033、TC-E2E-01/02 |
|
||||
| `empty` | 空结果(source_count=0、answer 为「未找到…」fallback) | TC-SOURCE-007、TC-KG-013、TC-CHAT-027(空态分支) |
|
||||
| `slow` | 慢响应/长流(持续视觉反馈断言) | TC-CHAT-021、TC-CHAT-029、TC-NFR-002 |
|
||||
| `error` | 上游故障(502 UPSTREAM_ERROR) | TC-CHAT-039、TC-E2E-03 |
|
||||
| `degraded` | QA 不可用(503 UPSTREAM_DOWN) | TC-HEALTH-004/005、TC-EXTRACT-011、TC-KG-012、TC-E2E-03 |
|
||||
|
||||
注意:mock 的场景切换仅覆盖 QA 端点;extraction/kg 的 503 降级在 E2E 中用 Playwright route 拦截模拟。
|
||||
|
||||
### 3.3 真实上游注意事项(DISCOVERY.md)
|
||||
|
||||
1. **QA LLM 402 降级(最重要)**:上游 DeepSeek 余额不足,`degraded_execution=true`,answer 为 fallback 证据列表而非自然语言答案。**涉及自然语言答案内容的断言(TC-CHAT-027 等,标注「依赖充值」)在充值前执行时只验结构不断内容**:answer 字段存在、text 块渲染、fallback 文案可见即可。检索/证据/来源/图谱断言(sources、chains、graph 块)不受 402 影响,照常执行。
|
||||
2. **耗时**:单次 search ≈92s、stream ≈37–38s(DISCOVERY.md §4/§6.8)。真实上游的 L5/L6 用例需把 expect 超时调到 ≥180s(search 模式)/≥120s(chat 模式);BFF 读超时 300s。
|
||||
3. **上游只绑 127.0.0.1**:L2s 必须在远端本机跑;Mac 本地经 SSH 隧道跑(见脚本注释)。
|
||||
4. **Neo4j 当选实例** direct-v1-full(bolt 18788 / HTTP 18575),schema canonical;`/api/kg/*` 依赖 BFF 的 Bolt 直连(上游 kg 路由已死,不探测)。
|
||||
5. **抽取上游**:8000 正常;7900 为容器(jobs 空属正常,BFF 只在 8000 不可达时回落);新上传任务可能因 MinerU 路径缺失 failed——TC-EXTRACT-008 就是断言该错误文案路径。
|
||||
|
||||
### 3.4 L5 运行环境一次性准备(已验证)
|
||||
|
||||
- **Node 18(远端)**:`npx playwright@1.49.1 test` 首次运行自动下载 chromium 1148(≈150MB)。实测 CDN 可达(Mac 直连 2.9MB/s)。
|
||||
- **`@playwright/test` 模块解析**:spec 文件位于 `tests/e2e/`,在 `frontend/node_modules` 之外,Node 默认解析路径找不到 `@playwright/test`,会报 `ERR_MODULE_NOT_FOUND`。**已在 Mac 本地验证**(playwright 1.61.1 实测复现;1.49.1 CJS 变换同样回退到默认 ESM 解析,机制相同)。运行处须一次性执行(不污染任何 agent 归属文件——`node_modules` 不入库不同步):
|
||||
```bash
|
||||
# 远端,一次性
|
||||
cd /home/xzq/5090/teamWorkspace/zh/PlantAI-rag-new
|
||||
ln -sfn frontend/node_modules node_modules # 若目录已存在先确认其内容再覆盖
|
||||
cd frontend && npm install --save-dev @playwright/test@1.49.1 # 写入 package.json 需 PM 裁决,见 §5 未决问题 4
|
||||
```
|
||||
Mac 本地本次验证的等价做法(未改动 `frontend/package.json`):安装到 `/tmp/pw-test-runner` 后 `ln -sfn /tmp/pw-test-runner/node_modules/@playwright/test frontend/node_modules/@playwright/test`,再 `ln -sfn frontend/node_modules <repo>/node_modules`,以 playwright 1.61.1(匹配本机已缓存 chromium-1228)跑通 6/6。
|
||||
- **本地 Node 26 已知问题**:playwright 1.49.1 的 `module.register()` loader 在 Node 26 + 系统高负载下启动极慢/挂起;远端 Node 18 无此问题。本地验证用 playwright 1.61.1 替代。
|
||||
- **本机验证结果(M0 冒烟)**:`6 passed`(mobile/tablet/desktop × 2 条冒烟)。冒烟断言对 B1 已交付的 AppShell 走真实分支(`.app-shell/.top-bar/.status-bar`,移动端 `.status-bar` 按 FR-RESP-004 断言隐藏),M0 占位期走占位回退分支。
|
||||
|
||||
---
|
||||
|
||||
## 4. E2E 骨架交付说明(本波)
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `frontend/playwright.config.js` | 三项目 mobile(iPhone 14 390×844,触摸+UA)/tablet(iPad 768×1024)/desktop(1440×900);chromium;baseURL `http://127.0.0.1:5180`;webServer `npm run dev -- --strictPort`(已有 dev 复用);失败保留 trace/screenshot |
|
||||
| `tests/e2e/smoke.spec.js` | 2 条冒烟(TC-E2E-SMOKE-01/02),三档视口各跑一次共 6 条结果。AppShell 断言带 M0 占位回退(B1 交付后自动走真实分支,无需改测试) |
|
||||
| `tests/smoke_upstreams.sh` | L2s 冒烟:QA 7920 health/info、抽取 8000 health/jobs、7900 health、Neo4j(HTTP 18575 Cypher 只读查询)、mock 8099 health;PASS/FAIL 表 + 非零退出码;支持 `QA_URL/EXTRACT_URL/ALT_URL/MOCK_URL/NEO4J_HTTP/TIMEOUT` 环境变量覆盖 |
|
||||
|
||||
全量 L1–L5 执行从 M2 集成后开始(TEST_PLAN §5 轮次纪律);完整 E2E 用例(TC-E2E-01~09)按本文件在后续轮次落为 spec 文件。
|
||||
|
||||
## 5. 未决问题(提请 PM)
|
||||
|
||||
1. 若干 UI 类断言依赖 UI_SPEC 类名(如 `agent-dot-item`、`p-step`);若 B1 实现时类名有偏差,需按 UI_SPEC §5.3 对齐(T 不自行改用例契约)。
|
||||
2. TC-EXTRACT-005/007(上传进度/超限)依赖 B2/B3 上传 UI 与 50MB 校验落点(前端或 BFF)——已按「前端拦截或 422」双路径写预期,无需改用例。
|
||||
3. TC-KG 节点详情在 Neo4j 不可用时的属性来源为「消息内 graph 块已有属性」;graph 块节点若不含属性字段,该断言在实现时需以实际块结构微调步骤(预期语义不变)。
|
||||
4. **L5 依赖安装需 PM 裁决**:`tests/e2e/` 的 spec 需要解析 `@playwright/test`,而它位于 `frontend/node_modules` 之外。两个方案二选一:(a) 远端一次性 `npm install --save-dev @playwright/test@1.49.1`(写入 B1 归属的 `frontend/package.json`,需 PM 批准);(b) 保持 `npx playwright@1.49.1` 并在远端建 `node_modules` 根符号链接 + `@playwright/test` 符号链接(不改 package.json,M2 前由 T/PM 在远端一次性执行)。本波按 (b) 在 Mac 本地验证通过(6/6 pass),建议远端集成点采用 (b)。
|
||||
5. **task 验证命令差异**:任务书给的验证命令 `npx playwright test`(无版本)在本机无已安装 playwright 时会从 npm 拉最新版;远端 Node 18 + `npx playwright@1.49.1` 仍会拉 1.49.1 的 chromium 1148(≈150MB,CDN 实测可达)。已在本文件 §3.4 记录完整步骤。
|
||||
@@ -0,0 +1,95 @@
|
||||
# TEST_PLAN.md —— 测试策略与轮次纪律
|
||||
|
||||
> 版本 1.0 | 作者 PM | 本文件由 PM 独占写入,所有开发/测试 agent **只读**
|
||||
>
|
||||
> 本文件定义「怎么测」。测什么以 `PRD.md` 为准,用例在 `TEST_CASES.md`(T 编写)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 测试分层
|
||||
|
||||
| 层 | 工具 | 归属 | 覆盖对象 | 运行方式 |
|
||||
|---|---|---|---|---|
|
||||
| L1 后端单元 | pytest | A1/A2/A3 自测 | normalize 别名收敛、SSE 帧解析、阶段→相位映射、SQLite CRUD、错误信封 | `cd backend && pytest tests -m unit` |
|
||||
| L2 后端接口 | pytest + respx(mock 上游) | T 主写,A 组配合 | 每个 BFF 接口的正常/降级/超时/重试路径 | `pytest tests -m api` |
|
||||
| L2s 上游 smoke | 脚本 | T | 对真实 7920/8000/Neo4j 的最小连通验证(**不在 CI 每轮跑**,仅集成点与验收前跑一次) | `bash tests/smoke_upstreams.sh` |
|
||||
| L3 前端单元 | Vitest | B 组自测 + T 补写 | stores、SSE parser、labels 映射、modes 参数集、工具函数 | `cd frontend && npx vitest run` |
|
||||
| L4 前端组件 | Vitest + @vue/test-utils | B 组自测 + T 补写 | 每个组件的全部状态(默认/悬停/激活/禁用/加载/空/错误) | 同上 |
|
||||
| L5 端到端 | Playwright | T | 完整流程 × 三档视口 | `npx playwright test` |
|
||||
| L6 验收 | 人工 + 浏览器 | PM | 真实上游 + 视觉走查 | 见验收脚本 |
|
||||
|
||||
## 2. 覆盖率定义(100% 的可验证判定)
|
||||
|
||||
1. **需求追溯(主门禁)**:`PRD.md` 的 81 个 `FR-*`(FR-RESP-013 除外,为可选项)中,**每一个**在 `TEST_CASES.md` 追溯矩阵中至少映射一条 `TC-*`;同时**无孤儿 TC**(每条用例必须追溯到至少一个 FR)。双向映射完整性 = 100%。
|
||||
2. **行覆盖(辅门禁)**:后端 pytest ≥85%,前端 Vitest ≥80%。未达标不能宣称 100%,但行覆盖达标**不替代**需求覆盖。
|
||||
|
||||
测试 agent 每轮报告首页必须给出:FR 总数 / 已覆盖 / 未覆盖清单(若不为空)。
|
||||
|
||||
## 3. 测试环境
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| L1/L2 运行处 | 远端 `plantai-bff` conda 环境(pytest) |
|
||||
| L3/L4 运行处 | 远端 `frontend/`(Vitest,Node 18) |
|
||||
| L5 运行处 | 远端(Playwright chromium,headed 不强制),**或** Mac 本地通过 SSH 隧道打到远端 |
|
||||
| L5 视口 | iPhone 14 390×844 | iPad 768×1024 | Desktop 1440×900 |
|
||||
| mock 服务 | `python backend/mock/mock_server.py`,端口 8099,scenario 参数切场景 |
|
||||
| 真实上游 | 7920 / 8000 / 7900 / Neo4j(按 DISCOVERY.md 选型) |
|
||||
|
||||
## 4. 缺陷分级
|
||||
|
||||
| 级别 | 定义 | 处置 |
|
||||
|---|---|---|
|
||||
| 阻断 Blocker | 主流程无法走通、数据损坏、崩溃 | 修复前不得进入下一轮 |
|
||||
| 严重 Major | 功能错误、契约不符、数据错误 | 本轮必须修复 |
|
||||
| 一般 Minor | 视觉瑕疵、次要交互问题 | 本轮修复,可批量 |
|
||||
| 轻微 Trivial | 文案、间距等不影响理解 | 累积到 M6 收尾处理 |
|
||||
|
||||
缺陷编号:`BUG-R{n}-{序号}`,报告含复现步骤、预期/实际、截图或日志证据、对应 TC 编号。
|
||||
|
||||
## 5. 轮次纪律(用户明确要求,铁律)
|
||||
|
||||
1. 测试 agent **完整跑完一整轮全部用例**(L1+L2+L3+L4+L5 全量),**中途不修改任何代码**。
|
||||
2. 产出 `docs/TEST_REPORT_R{n}.md`:首页统计(TC 总数 / pass / fail / 未跑 + FR 覆盖统计 + 行覆盖率)、缺陷清单(分级)、证据。
|
||||
3. PM 汇总缺陷,**批量派发**给归属 agent 修复;开发 agent 只修自己文件归属内的缺陷。
|
||||
4. 修复完成后进入下一轮:**再次完整跑完全部用例**(全量回归,不做增量)。
|
||||
5. 循环直至:所有 TC pass + 主门禁 100% + 辅门禁达标。
|
||||
6. 移交 PM 做 L6 验收。验收发现问题按第 4 级(轻微)回流或直接修复,由 PM 判定。
|
||||
|
||||
## 6. L5 端到端场景(必测主线)
|
||||
|
||||
对三档视口分别执行(各视口用例独立编号):
|
||||
|
||||
1. **E2E-主流程**:新建会话 → 问答模式提问 → 流水线五相位推进 → 答案揭示(含表格)→ 来源 tab 有数据 → 图谱 tab 渲染 → 刷新后历史恢复
|
||||
2. **E2E-检索模式**:检索模式提问 → 只出来源不出答案块
|
||||
3. **E2E-错误与重试**:mock `scenario=error` 下提问 → 错误展示 + 重试按钮可用;`scenario=degraded` 下作曲器禁用
|
||||
4. **E2E-会话管理**:新建/切换/重命名/删除/搜索过滤/分组
|
||||
5. **E2E-中断**:流式中途切会话 → 原会话置 error 标注已中断
|
||||
6. **E2E-主题**:深浅切换 + 刷新持久化
|
||||
7. **E2E-图谱**:实体搜索 → 邻域展开 → 节点详情 → 缩放平移(移动端双指)
|
||||
8. **E2E-抽取**:任务列表 → 详情展开 → 运行日志;mock 下上传 PDF
|
||||
9. **E2E-响应式专项**:三档视口各自布局断言(见 PRD FR-RESP-001~004)+ 移动端专项(安全区、16px 输入、44px 触控、表格横滚、键盘适配)
|
||||
|
||||
## 7. 运行命令约定
|
||||
|
||||
```bash
|
||||
# 后端(远端,plantai-bff 环境)
|
||||
cd backend && pytest tests -m unit -q # L1
|
||||
cd backend && pytest tests -m api -q # L2(respx mock)
|
||||
bash tests/smoke_upstreams.sh # L2s 真实连通(手动触发)
|
||||
|
||||
# 前端(远端)
|
||||
cd frontend && npx vitest run # L3+L4
|
||||
cd frontend && npx playwright test # L5(三档视口配置在 playwright.config.js)
|
||||
|
||||
# mock(开发与 L5 的 error/degraded 场景)
|
||||
python backend/mock/mock_server.py # 127.0.0.1:8099
|
||||
```
|
||||
|
||||
## 8. 报告模板(TEST_REPORT_R{n}.md 必须包含)
|
||||
|
||||
1. 轮次信息:日期、被测版本、运行环境
|
||||
2. 首页统计表:TC 总数/pass/fail/skip、FR 覆盖 80/80 或缺口清单、行覆盖率(后端/前端)
|
||||
3. 缺陷清单表:编号 | 级别 | TC | 归属 agent | 现象 | 预期 | 证据路径
|
||||
4. 上轮缺陷修复确认表:BUG 编号 | 是否修复 | 本轮对应 TC 结果
|
||||
5. 遗留问题与风险
|
||||
@@ -0,0 +1,105 @@
|
||||
# TEST_REPORT_R1.md —— 第一轮全量测试报告
|
||||
|
||||
> 轮次 R1 | 日期 2026-08-25 | 测试 agent T | 模板依据 TEST_PLAN §8
|
||||
|
||||
---
|
||||
|
||||
## 1. 轮次信息
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 被测版本 | M1 集成后(PM 已修复集成问题清单,见协调通知) |
|
||||
| 运行环境 | 远端 plantai-bff 机(100.109.52.26);BFF 8090(真实上游 7920/8000/7900/Neo4j 18788 全通);Vite 5180 → 8090;mock 8099 运行中 |
|
||||
| L1/L2 运行 | `backend` 全量 pytest(conda plantai-bff,pytest 9.1.1;本轮补充安装 pytest/pytest-asyncio/pytest-cov) |
|
||||
| L3/L4 运行 | `frontend` vitest 2.1.8(137 用例) |
|
||||
| L5 运行 | Playwright 1.49.1 + chromium-1148,三档视口 42→48 条测试 |
|
||||
| L2s 运行 | `bash tests/smoke_upstreams.sh`(本轮修复 Neo4j 认证缺陷后重跑) |
|
||||
| 轮次纪律 | 只跑测试与写报告,未修改任何产品代码(`frontend/src/**`、`backend/app/**` 零改动;测试脚本自身缺陷修复除外) |
|
||||
|
||||
## 2. 首页统计
|
||||
|
||||
### 2.1 分层结果
|
||||
|
||||
| 层 | 总数 | Pass | Fail | Skip | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| L1+L2 后端(pytest 全量) | 119 | 119 | 0 | 0 | `python -m pytest . -q`;含 A 组自测 + T 复跑确认 |
|
||||
| L3+L4 前端(vitest) | 137 | 137 | 0 | 0 | B1 25 + B2 58 + B3 54 |
|
||||
| L5 端到端(Playwright 三档) | 48 | **19** | **17** | **12** | 详见 §2.3;17 个失败收敛为 **7 个产品缺陷 + 3 个测试缺陷**(测试缺陷已修复,见 §2.4) |
|
||||
| L2s 上游 smoke | 7 | 7 | 0 | 0 | 首跑 Neo4j 401(测试脚本缺陷),修复认证后 7/7 PASS |
|
||||
| **合计(可执行用例)** | **311** | **282** | **17** | **12** | 失败均归因于缺陷清单,无环境性 flake |
|
||||
|
||||
### 2.2 覆盖率(辅门禁)
|
||||
|
||||
| 项 | 实际 | 门禁 | 结论 |
|
||||
|---|---|---|---|
|
||||
| 后端行覆盖(pytest --cov=app) | **87%**(2769 行中 351 未覆盖) | ≥85% | **达标** |
|
||||
| 前端行覆盖(vitest --coverage) | **81.54%**(All files) | ≥80% | **达标** |
|
||||
| FR 需求追溯(主门禁) | **81/81,未覆盖清单为空**(TEST_CASES.md 矩阵,经脚本机械验证双向零失配) | 100% | **达标** |
|
||||
|
||||
后端覆盖率低点(供开发参考,非缺陷):`upstream.py` 32%、`sse_relay.py` 65%。
|
||||
|
||||
### 2.3 L5 明细(48 条 = 16 用例 × 3 视口,按 TEST_CASES 的 TC-E2E 映射)
|
||||
|
||||
| TC 映射 | 用例 | mobile | tablet | desktop | 结论 |
|
||||
|---|---|---|---|---|---|
|
||||
| TC-E2E-SMOKE-01/02 | 冒烟 | ✓ | ✓ | ✓ | PASS |
|
||||
| TC-E2E-01 | 主流程 | ✘ BUG-R1-06 | ✘ BUG-R1-06 | ✘ BUG-R1-06 | 步骤 1–6 全过(新建/提问/相位推进/终态/来源/会话状态);6b markdown 断言失败阻断步骤 7–8 |
|
||||
| TC-E2E-02 | 检索模式 | ✘ BUG-R1-07 | ✘ BUG-R1-07 | ✘ BUG-R1-07 | 真实 search 92s > 前端 30s 超时 → 必现「检索超时」 |
|
||||
| TC-E2E-03 DOWN | QA 不可用降级 | — | — | ✘ BUG-R1-04 | 作曲器未禁用 |
|
||||
| TC-E2E-03 ERROR | 上游错误+重试 | — | — | ✘ BUG-R1-05 | 展示「连接中断」而非上游 message |
|
||||
| TC-E2E-04 核心 | 新建/重命名/搜索/分组 | ✓ | ✓ | ✓ | PASS(新建×2、双击重命名、大小写不敏感过滤、无匹配文案、清空恢复、零网络请求、今天分组) |
|
||||
| TC-E2E-04 活动态 | 活动高亮探针 | ✘ BUG-R1-02 | ✘ BUG-R1-02 | ✘ BUG-R1-02 | 点击会话无 .active |
|
||||
| TC-E2E-04 删除 | 删除入口探针 | ✘ BUG-R1-03 | ✘ BUG-R1-03 | ✘ BUG-R1-03 | 右键无确认弹层(死代码) |
|
||||
| TC-E2E-05 | 中断 | — | — | ✓ | PASS(流式中切会话 → 原会话 error + 「已中断」标注 + 状态点 error) |
|
||||
| TC-E2E-06 | 主题切换持久化 | ✓ | ✓ | ✓ | PASS |
|
||||
| TC-E2E-07 | 图谱搜索/邻域/详情 | ✘ BUG-R1-01 | ✘ BUG-R1-01 | ✘ BUG-R1-01 | 搜索请求无 q 参数,返回随机数据 |
|
||||
| TC-E2E-08 | 抽取任务/日志/上传 | ✓ | ✓ | ✓ | PASS(列表 49 任务、详情展开、日志、mock 上传成功、非 PDF 拦截) |
|
||||
| TC-E2E-09 桌面 | 网格/底栏/折叠 | — | — | ✓ | PASS(260/800/380、48/824/28、body overflow hidden、4 agent 点、折叠持久化) |
|
||||
| TC-E2E-09 平板 | 抽屉/scrim | — | ✓ | — | PASS |
|
||||
| TC-E2E-09 手机 | 移动端专项 | ✓ | — | — | PASS(汉堡/工作台按钮/覆盖层/底栏隐藏/16px/44px/安全区/吸底) |
|
||||
|
||||
**L5 合计:19 Pass / 17 Fail / 12 Skip(skip 均为档位门控设计,非缺陷)。**
|
||||
|
||||
### 2.4 测试自身缺陷(已修复,不计入产品缺陷)
|
||||
|
||||
| 编号 | 现象 | 修复 |
|
||||
|---|---|---|
|
||||
| — | L2s smoke 脚本 Neo4j 探测无 Basic auth → 401 | 补充 `-u neo4j:<neo4j-password-in-.env>`(凭据来自 DISCOVERY.md §2.1),重跑 7/7 PASS |
|
||||
| — | E2E 断言用 `.empty-state, .app-shell` 双元素选择器 → strict mode violation | 改为只断言 `.app-shell` |
|
||||
| — | 抽屉关闭点 scrim 中心(被抽屉遮挡)→ 点击挂起直至超时 | 改为 Esc 优先 + 右侧安全坐标点击 |
|
||||
| — | `grid-template-rows` 断言未考虑 Chromium 对 1fr 的像素解析 | 断言解析值 48/824/28 |
|
||||
|
||||
## 3. 缺陷清单(BUG-R1-xx)
|
||||
|
||||
| 编号 | 级别 | 对应 TC | 归属 agent | 现象 | 预期 | 证据 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **BUG-R1-01** | Major | TC-E2E-07(×3) | B1(`api/http.js`;消费方 B3 `api/kg.js`) | KG 实体搜索 UI 显示与查询无关的随机实体(`!Xunethnic_group · 1` 等);抓包确认请求 URL 为 `/api/kg/entities` **无 `q` 参数**。`http.js` 的 `request()` 不序列化 query 参数,`searchEntities(q)` 的 `{q, limit}` 被丢弃。BFF 直测 `?q=OsSPL14` 返回 17 条正确结果,排除后端问题 | 搜索请求应携带 `?q=<输入词>&limit=20`;结果应含 `OsSPL14` | `/tmp/r1_pw_rest.log` 失败块 1/6/11;BFF 直测对比(q 有值 17 条 vs 空 q 20 条随机);截图 `test-results/bench-E2E-07-*/test-failed-1.png` |
|
||||
| **BUG-R1-02** | Major | TC-E2E-04 活动态(×3) | B2(`stores/chat.js`) | 点击会话后条目无 `.active` 类;`chat.selectSession()` 只设置 `this.sessionId`,**未同步 `sessions.activeId`** → 活动高亮(FR-SESSION-009)、顶栏标题/徽章(FR-SHELL-002 依赖 activeSession)、删除后切换全部失效 | 点击会话后该条目带 `.active`(渐变左条 + 8% 底) | `/tmp/r1_pw_rest.log` 失败块 4/9/14;代码证据:`chat.js selectSession` 无 activeId 赋值 |
|
||||
| **BUG-R1-03** | Major | TC-E2E-04 删除(×3) | B2(`SessionPanel.vue`/`SessionItem.vue`) | 删除确认弹层(`.confirm-box`)在 DOM 存在但**无任何触发入口**:`SessionPanel.askDelete` 定义后零调用;`SessionItem` 无右键/更多按钮;`askDelete` 也未绑定到模板 | 会话条目应有删除入口(右键或更多菜单),点击弹出二次确认(FR-SESSION-007) | `/tmp/r1_pw_rest.log` 失败块 5/10/15;代码证据:`grep -rn askDelete components/session/` 仅 1 处定义 |
|
||||
| **BUG-R1-04** | Major | TC-E2E-03 DOWN | B2(`ChatPanel.vue`) | mock `/api/health` 返回 QA `ok:false` 时,作曲器 textarea 仍 enabled;`Composer` 的 `disabled`/`healthNote` props 已定义但 `ChatPanel` 从未传入(health store 未接线) | QA 不可用时作曲器禁用 + 显示「知识问答服务当前不可用」(FR-HEALTH-002) | `/tmp/r1_pw_target.log` 失败块 1;代码证据:ChatPanel 无 health 引用 |
|
||||
| **BUG-R1-05** | Major | TC-E2E-03 ERROR | B2(`api/qa.js` + `stores/chat.js`) | 上游 502 错误信封(`UPSTREAM_ERROR` message=「上游问答服务返回错误」)被前端展示为**「连接中断,请检查网络后重试」**。原因:`chatStream` 的 `post` 抛 ApiError(信封已正确解析),但 `_runStream` 的 catch 路径把一切非 AbortError 标记为 `disconnected`,未优先检查 `err instanceof ApiError` 的 code/message | 展示上游 message「上游问答服务返回错误」+ 重试按钮(PRD §2.2:后端 error.message 直接展示) | `/tmp/r1_pw_target.log` 失败块 2(Received:「连接中断,请检查网络后重试」);截图 `test-results/mock-scenarios-E2E-03-错误与重-3eae7-M-ERROR*/test-failed-1.png` |
|
||||
| **BUG-R1-06** | Major | TC-E2E-01(×3) | B2(`chat/markdown.js` 或装配处) | 答案含 `- 列表`/`**加粗**` 语法时渲染为**纯文本**(无 `<li>/<strong>/<table>` 标签)。`renderMarkdown` 已实现且被单测覆盖(vitest 通过),但**生产路径零调用**:`qaToBlocks` 直接以原始 answer 生成 text 块,`AssistantMessage` 仅做 `sanitizeHtml` | markdown-it 渲染 + DOMPurify 消毒(FR-CHAT-004);402 fallback 的列表应渲染为 `<ul><li>` | `/tmp/r1_pw_rest.log` 失败块 2/7/12(HTML 头部为纯文本证据);代码证据:`grep -rn renderMarkdown src/` 仅 markdown.js 内定义 |
|
||||
| **BUG-R1-07** | **Blocker** | TC-E2E-02(×3) | B1(`api/http.js`) | 检索模式在真实上游**必现超时**:`http.js` 默认读超时 30s(仅 `raw` 流式豁免),而真实 `/api/qa/search` 单次 **≈92s**(DISCOVERY.md §6.8)→ 34s 时前端自断,UI 显示「检索超时,请重试」。检索模式(FR-CHAT-006 第三种模式)在真实环境完全不可用 | search 请求超时 ≥180s(BFF 契约读超时 180s);92s 检索正常完成并出来源 | `/tmp/r1_pw_rest.log` 失败块 3/8/13(34.1s 失败);截图 `test-results/main-flow-E2E-02-*-mobile/test-failed-1.png`(可见「检索超时,请重试」);代码证据:http.js `TIMEOUT_MS=30000` |
|
||||
| **BUG-R1-08** | Minor | (TC-HEALTH 相关观察) | A3(`routers/health.py`) | `/api/health` 的 `neo4j.detail` 报告「name_norm 索引缺失或异常:['srt2_canonical_entity_id', 'srt2_canonical_entity_nam…'」,但实测实体检索走 name_norm 索引 0.09s 正常、`/api/kg/status` 正常列出索引——健康探测对索引状态的判定与实际矛盾,属误报 | detail 仅在真实异常时出现;正常时勿提示「缺失或异常」 | 远端 `curl http://127.0.0.1:8090/api/health` 响应原文 |
|
||||
|
||||
**缺陷分布**:B1 ×2(BUG-R1-01/07,其中 07 为 Blocker)、B2 ×5(BUG-R1-02/03/04/05/06)、A3 ×1(Minor)。**本轮无 B3 缺陷**(B3 负责的 E2E-07 失败根因在 B1 http.js)。
|
||||
|
||||
## 4. 上轮缺陷修复确认表
|
||||
|
||||
本轮为第一轮,无上轮缺陷。
|
||||
|
||||
## 5. 遗留问题与风险
|
||||
|
||||
1. **QA LLM 402 余额不足(已知,非缺陷)**:chat 答案为 fallback 证据列表而非自然语言。涉及答案内容的断言(TC-CHAT-027 等)按 TEST_CASES §3.3 回退结构断言;**依赖充值的用例未在本轮验证最终答案质量**。BUG-R1-06(markdown)恰由 fallback 列表暴露——充值后自然语言答案同样会命中,修复不受 402 影响。
|
||||
2. **L2 独立接口测试未建立**:`tests/backend/` 仍为空(A 组单测已覆盖 routers 层 119 条)。TEST_CASES 中标注 L2 的用例(检查点节奏、级联删除、INVALID_CITATION 重试等)在后续轮次需补齐 respx mock 测试,当前靠 A 组单测 + L5 间接覆盖。
|
||||
3. **E2E-01 步骤 7–8 被 BUG-R1-06 阻断未执行**:刷新后历史恢复(TC-CHAT-013 的 L5 路径)待修复后补验;L1 侧 `test_sessions_api.py` 已覆盖持久化,风险低。
|
||||
4. **Laptop 档(1024–1279)无独立 Playwright project**:TEST_CASES 的 TC-RESP-002 档位用例(左栏自动折叠 64px/右栏 340px)本轮未跑,建议下轮加 laptop 项目或在该视口手动验证。
|
||||
5. **本轮 T 自身操作事故**(已恢复,记录备查):为测前端覆盖率在远端执行 `npm install --save-dev`,短暂改写了 B1 的 `frontend/package.json` 并打断 Playwright 依赖(一次运行中途崩溃)。已还原 package.json 至 B1 原版并用 `--no-save` 重装。后续 T 在远端装包一律 `--no-save`,写入 package.json 的需求走 PM。
|
||||
6. **测试数据污染**:R1 新建会话已按前缀清理(R1主流程/R1检索/R1会话/R1中断 等),远端 SQLite 中 `水稻SPL基因家族…` 等自动标题会话为测试产生,已尽量清理,PM 若在意可执行一次数据盘点。
|
||||
7. **48 条 L5 中 12 条 skip 为档位门控设计**(如 mock 场景仅桌面代表、E2E-05 仅桌面),已在 TEST_CASES 执行说明中记录;如需三档全跑需 PM 确认(真实上游流成本高:每档主流程 ≈1 分钟、检索 ≈92s)。
|
||||
|
||||
## 6. 证据归档
|
||||
|
||||
- 远端日志:`/tmp/r1_pytest.log`(119 passed)、`/tmp/r1_cov.log`(87%)、`/tmp/r1_vitest_cov.log`(137 passed,81.54%)、`/tmp/r1_pw_rest.log`(L5 主批次)、`/tmp/r1_pw_target.log`(mock 场景桌面批次)、`/tmp/r1_pw_full2.log`(首轮全量,含 6/6 smoke)
|
||||
- 失败截图/trace:远端 `frontend/test-results/`(按用例目录归档;7 个缺陷各有一式三份视口证据,其中 BUG-R1-04/05 为桌面证据)
|
||||
- smoke 输出:本文件 §2.1 引用;修复后 7/7 PASS
|
||||
@@ -0,0 +1,99 @@
|
||||
# TEST_REPORT_R2.md —— 第二轮全量回归测试报告
|
||||
|
||||
> 轮次 R2 | 日期 2026-08-25 | 测试 agent T | 模板依据 TEST_PLAN §8
|
||||
|
||||
---
|
||||
|
||||
## 1. 轮次信息
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 被测版本 | M4 第二轮:R1 的 8 个缺陷修复后(修复清单见协调通知) |
|
||||
| 运行环境 | 远端 plantai-bff 机;BFF 8090(真实上游 7920/8000/7900/Neo4j 全通,health 无索引误报);Vite 5180→8090(HMR 已同步修复);mock 8099(本地/远端 md5 一致) |
|
||||
| L1/L2 | `backend` pytest 全量 + `--cov=app` |
|
||||
| L3/L4 | `frontend` vitest 全量 + `--coverage` |
|
||||
| L5 | Playwright 1.49.1 + chromium-1148,三档视口 48 条 |
|
||||
| L2s | `bash tests/smoke_upstreams.sh` |
|
||||
| 铁律 | 只跑测试与写报告;未修改任何产品代码(`frontend/src/**`、`backend/app/**` 零改动) |
|
||||
|
||||
## 2. 首页统计
|
||||
|
||||
### 2.1 分层结果
|
||||
|
||||
| 层 | 总数 | Pass | Fail | Skip | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| L1+L2 后端 | 121 | 121 | 0 | 0 | 修复后新增 2 条(R1 为 119) |
|
||||
| L3+L4 前端 | 156 | 156 | 0 | 0 | 修复后新增 19 条(R1 为 137) |
|
||||
| L5 端到端(全量 final3) | 48 | 35 | **1(flake)** | 12 | 唯一失败为 tablet E2E-01 上游排队超时,**单独复跑 52.3s 通过**(见 §5.1) |
|
||||
| L5 补跑(受修复影响用例) | 10 | 10 | 0 | 0 | mobile/tablet E2E-01/02 复跑、bench 三档复跑——全部通过 |
|
||||
| L2s 上游 smoke | 7 | 7 | 0 | 0 | 含 Neo4j 认证查询 |
|
||||
|
||||
### 2.2 覆盖率(辅门禁)
|
||||
|
||||
| 项 | 实际 | 门禁 | 结论 |
|
||||
|---|---|---|---|
|
||||
| 后端行覆盖 | **87%**(2786 行中 350 未覆盖) | ≥85% | 达标 |
|
||||
| 前端行覆盖 | **80.59%**(All files) | ≥80% | 达标 |
|
||||
| FR 需求追溯(主门禁) | **81/81,未覆盖清单为空**(TEST_CASES.md 矩阵不变) | 100% | 达标 |
|
||||
|
||||
### 2.3 L5 明细(final3 全量 + 补跑)
|
||||
|
||||
| TC 映射 | 用例 | mobile | tablet | desktop | 结论 |
|
||||
|---|---|---|---|---|---|
|
||||
| TC-E2E-SMOKE-01/02 | 冒烟 | ✓ | ✓ | ✓ | PASS |
|
||||
| TC-E2E-01 | 主流程(含 R1 阻断的刷新恢复补验 + markdown 断言) | ✓ | ✓(补跑) | ✓ | **BUG-R1-06/07 修复确认;刷新恢复步骤已跑通** |
|
||||
| TC-E2E-02 | 检索模式 | ✓ | ✓ | ✓ | **BUG-R1-07 修复确认**(46-49s 正常完成,不再 30s 自断) |
|
||||
| TC-E2E-03 DOWN | QA 不可用降级 | — | — | ✓ | **BUG-R1-04 修复确认**(作曲器禁用) |
|
||||
| TC-E2E-03 ERROR | 上游错误+重试 | — | — | ✓ | **BUG-R1-05 修复确认**(透传上游 message + 重试同位置替换) |
|
||||
| TC-E2E-04 核心 | 新建/重命名/搜索/分组 | ✓ | ✓ | ✓ | PASS |
|
||||
| TC-E2E-04 活动态 | 活动高亮探针 | ✓ | ✓ | ✓ | **BUG-R1-02 修复确认** |
|
||||
| TC-E2E-04 删除 | 删除入口探针 | ✓ | ✓ | ✓ | **BUG-R1-03 修复确认**(右键唤出确认弹层) |
|
||||
| TC-E2E-05 | 中断 | — | — | ✓ | PASS |
|
||||
| TC-E2E-06 | 主题切换持久化 | ✓ | ✓ | ✓ | PASS |
|
||||
| TC-E2E-07 | 图谱搜索/邻域/详情 | ✓ | ✓ | ✓ | **BUG-R1-01 修复确认**(结果含 OsSPL14;51 节点降级列表路径两形态均接受) |
|
||||
| TC-E2E-08 | 抽取任务/日志/上传 | ✓ | ✓ | ✓ | PASS |
|
||||
| TC-E2E-09 桌面 | 网格/底栏/折叠 | — | — | ✓ | PASS |
|
||||
| TC-E2E-09 平板 | 抽屉/scrim | — | ✓ | — | PASS |
|
||||
| TC-E2E-09 手机 | 移动端专项 | ✓ | — | — | PASS |
|
||||
|
||||
**结论:全部 TC-E2E 用例在 R2 至少一次干净运行中通过;final3 唯一失败为环境性 flake(§5.1),复跑通过,非产品缺陷。**
|
||||
|
||||
## 3. 缺陷清单
|
||||
|
||||
**本轮无新缺陷。** R1 的 8 个缺陷全部确认修复(见 §4),未引入回归。
|
||||
|
||||
## 4. 上轮(R1)缺陷修复确认表
|
||||
|
||||
| 编号 | 是否修复 | 本轮对应 TC 结果 |
|
||||
|---|---|---|
|
||||
| BUG-R1-01 kg 搜索 q 参数 | **已修复** | TC-E2E-07 三档 ✓(搜索返回 OsSPL14 正确结果;http.js URLSearchParams 序列化已核实) |
|
||||
| BUG-R1-02 会话活动高亮 | **已修复** | TC-E2E-04 活动态探针 三档 ✓(selectSession 同步 sessions.activeId) |
|
||||
| BUG-R1-03 删除入口死代码 | **已修复** | TC-E2E-04 删除探针 三档 ✓(右键唤出 confirm-box,Teleport z-70) |
|
||||
| BUG-R1-04 QA 降级作曲器未禁用 | **已修复** | TC-E2E-03 DOWN ✓(ChatPanel composerDisabled 接线 health.qaOk) |
|
||||
| BUG-R1-05 502 误显示「连接中断」 | **已修复** | TC-E2E-03 ERROR ✓(err.code 优先映射,透传「上游问答服务返回错误」+ 重试) |
|
||||
| BUG-R1-06 markdown 未渲染 | **已修复** | TC-E2E-01 三档 ✓(qaToBlocks/_finalizeReveal 走 renderMarkdown,HTML 含 <li>/<strong>) |
|
||||
| BUG-R1-07 30s 超时自断 | **已修复** | TC-E2E-02 三档 ✓(默认超时 600s;46-49s 真实检索正常完成) |
|
||||
| BUG-R1-08 health 索引误报 | **已修复** | `/api/health` 实测 `neo4j.detail` 字段已消除(成员判断修复) |
|
||||
|
||||
## 5. 遗留问题与风险
|
||||
|
||||
1. **tablet E2E-01 全量中的 flake(唯一失败)**:final3 中 tablet 档主流程 300s 超时(trace 显示流未在时限内终态,afterEach 清理正常执行),单独复跑 52.3s 通过;mobile/desktop 同轮 53.2s/53.6s 通过。判定为**上游 QA 排队/偶发慢**(同一 spec 三档三次全量中 2/3 通过),非产品缺陷。建议下轮把真实流 E2E 的 test timeout 提到 480s、终态等待上限 360s,消除环境抖动误报。
|
||||
2. **12 条 skip 维持**(设计门控):mock 场景仅桌面代表、E2E-05 仅桌面(真实流成本高,跨档行为一致)。如需三档全跑需 PM 确认。
|
||||
3. **Laptop 档(1024–1279)仍无 Playwright project**(R1 遗留问题 4):TC-RESP-002(左栏自动折叠 64px/右栏 340px)本轮仍未 L5 覆盖,需 PM 决定是否加档。
|
||||
4. **`tests/backend/` L2 respx 独立测试仍空**(R1 遗留问题 2):L2 用例(检查点节奏、级联删除、INVALID_CITATION 重试等)靠 A 组单测 + L5 间接覆盖。
|
||||
5. **QA LLM 402 仍存在**:chat 答案为 fallback 证据列表;「依赖充值」断言维持结构回退。若这是最后一轮,充值后的答案质量断言移交 L6 验收。
|
||||
6. **共享数据**:R2 测试会话已全部清理(按 ID 快照 + 时间窗删除 58 条),远端 SQLite 现仅剩 PM 集成期 7 条会话。
|
||||
7. **T 自身测试缺陷修复(R2 内)**:E2E-07 接受降级列表形态、E2E-01 等 md-text 终态(弃等 stream-wrap 消失的脆断言)、markdown 触发条件收紧、E2E-05 改位置定位、清理改 ID 快照——均已同步远端。
|
||||
|
||||
## 6. 证据归档
|
||||
|
||||
- 远端日志:`/tmp/r2_pytest.log`、`/tmp/r2_cov.log`(121 passed,87%)、`/tmp/r2_vitest_cov.log`(156 passed,80.59%)、`/tmp/r2_smoke.log`(7/7)、`/tmp/r2_pw_final3.log`(L5 全量)、`/tmp/r2_pw_m1.log`(mobile 补跑)、`/tmp/r2_pw_t1.log`(tablet 补跑)
|
||||
- 失败截图/trace:`frontend/test-results/`(final3 tablet E2E-01 一份,为 flake 证据)
|
||||
- 归档:`/home/xzq/5090/teamWorkspace/zh/r1_test_evidence/`(R1 证据保留;R2 日志在 /tmp 保留至 PM 决定归档)
|
||||
|
||||
## 7. 轮次结论
|
||||
|
||||
- 主门禁(FR 追溯 81/81)**达标**;辅门禁(后端 87%、前端 80.59%)**达标**。
|
||||
- R1 的 8 个缺陷**全部确认修复**;R2 **无新缺陷**。
|
||||
- L5 全量唯一失败经复跑确认为环境性 flake,全部 TC-E2E 在干净运行中通过。
|
||||
- **由 PM 判定是否收尾**:若收尾,建议采纳 §5.1 的超时加固建议(T 测试代码变更,无需产品修复)。
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
# UI_SPEC.md —— 设计令牌与组件规格
|
||||
|
||||
> 版本 1.0 | 作者 PM | 本文件由 PM 独占写入,所有开发/测试 agent **只读**
|
||||
>
|
||||
> 唯一权威来源:`/Users/jerry/haiguji/plantai-agent-ui-design/plantai-agent-ui-prototype.html`(1380 行)及其规范性文档 `DESIGN.md`。
|
||||
> **实现以本文件的令牌值为准,以 DESIGN.md 的规则为约束。两者冲突时以 DESIGN.md 的规则优先,但像素值以本文件为准。**
|
||||
> 前端实现时必须逐字复刻本文件列出的 CSS,不得自行"美化"。
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计令牌(`frontend/src/styles/tokens.css`)
|
||||
|
||||
### 1.1 深色主题(默认)`[data-theme="dark"]`
|
||||
|
||||
```css
|
||||
--pa-canvas: #0A0A10; --pa-surface-1: #111119; --pa-surface-2: #161622;
|
||||
--pa-surface-3: #1C1C2A; --pa-raised: #202031; --pa-hairline: #23232F;
|
||||
--pa-hairline-strong: #33334A; --pa-ink: #F2F3F8; --pa-ink-muted: #A9ADC4;
|
||||
--pa-ink-subtle: #6B6F8B; --pa-code-bg: #0D0D16;
|
||||
--pa-user-bubble: linear-gradient(135deg, #2A2A3E, #1C1C2A);
|
||||
--pa-user-bubble-border: #33334A;
|
||||
--pa-glow-opacity: 1; --pa-aurora-opacity: 1;
|
||||
--pa-grid-dot: rgba(255,255,255,.035);
|
||||
--pa-scrim: rgba(10,10,16,.6);
|
||||
```
|
||||
|
||||
### 1.2 浅色主题 `[data-theme="light"]`
|
||||
|
||||
```css
|
||||
--pa-canvas: #F6F8FC; --pa-surface-1: #FFFFFF; --pa-surface-2: #F0F3FA;
|
||||
--pa-surface-3: #E9EDF7; --pa-raised: #FFFFFF; --pa-hairline: #E2E7F2;
|
||||
--pa-hairline-strong: #C9D2E8; --pa-ink: #16182A; --pa-ink-muted: #4C5268;
|
||||
--pa-ink-subtle: #9AA1B8; --pa-code-bg: #F1F4FB;
|
||||
--pa-user-bubble: linear-gradient(135deg, #E8ECFF, #F4FBFF);
|
||||
--pa-user-bubble-border: #C9D2E8;
|
||||
--pa-glow-opacity: .35; --pa-aurora-opacity: .08;
|
||||
--pa-grid-dot: rgba(22,24,42,.05);
|
||||
--pa-scrim: rgba(246,248,252,.6);
|
||||
```
|
||||
|
||||
### 1.3 双主题公共
|
||||
|
||||
```css
|
||||
/* 品牌 */
|
||||
--pa-g-from: #5E6AD2; --pa-g-to: #00D4FF; --pa-aux: #A78BFA;
|
||||
--pa-gradient: linear-gradient(135deg, var(--pa-g-from), var(--pa-g-to));
|
||||
/* 语义色(只表状态,双主题通用) */
|
||||
--pa-success: #2ED3A0; --pa-warning: #F5B85C; --pa-error: #FF5C7A; --pa-info: #5E9BFF;
|
||||
/* 相位五色(只用于流水线卡/相位徽章/底栏状态点) */
|
||||
--pa-t-planning: #C0A8DD; --pa-t-execution: #9FBBE0; --pa-t-knowledge: #9FC9A2;
|
||||
--pa-t-validation: #DFA88F; --pa-t-done: #C08532;
|
||||
/* 字体 */
|
||||
--pa-font-ui: 'SF Pro Display','SF Pro Text','PingFang SC','Helvetica Neue','Microsoft YaHei','Noto Sans SC',system-ui,sans-serif;
|
||||
--pa-font-mono: 'SF Mono',Menlo,'JetBrains Mono',Consolas,'Liberation Mono',monospace;
|
||||
/* 圆角 */
|
||||
--pa-r-xs: 4px; --pa-r-sm: 6px; --pa-r-md: 8px; --pa-r-lg: 12px; --pa-r-xl: 16px; --pa-r-pill: 9999px;
|
||||
/* 缓动 */
|
||||
--pa-ease: cubic-bezier(.4,0,.2,1);
|
||||
--pa-ease-out: cubic-bezier(.16,1,.3,1);
|
||||
--pa-ease-spring: cubic-bezier(.34,1.56,.64,1);
|
||||
/* 壳层尺寸 */
|
||||
--topbar-h: 48px; --statusbar-h: 28px;
|
||||
```
|
||||
|
||||
### 1.4 硬编码值(token 之外的合法常量)
|
||||
|
||||
| 用途 | 值 |
|
||||
|---|---|
|
||||
| 热图 8 级色板 | `#0D0D16, #1E2A5E, #2B4AA8, #3B5FD0, #5E6AD2, #00A3E8, #00D4FF, #8DF0FF`(本项目图谱热力用途) |
|
||||
| 按钮白字 | `#fff` |
|
||||
| 辉光预设 | logo 点 `0 0 10px rgba(0,212,255,.7), 0 0 22px rgba(94,106,210,.5)`;按钮悬停 `0 0 24px rgba(0,212,255,.4)`;焦点输入 `0 0 14px~22px rgba(0,212,255,.16~.18)`;建议卡悬停 `0 0 18px rgba(0,212,255,.15)` |
|
||||
| 徽章 tint 底 | running `rgba(245,184,92,.12)`、done `rgba(46,211,160,.12)`、error `rgba(255,92,122,.12)` |
|
||||
| 相位色 tint | 工具图标 chip 底 `color-mix(in srgb, var(--pa-g-from) 16%, transparent)`,字色 `--pa-g-to` |
|
||||
|
||||
### 1.5 设计规则(DESIGN.md 约束,违反即缺陷)
|
||||
|
||||
1. **渐变克制**:渐变只允许出现在——Logo 文字、主按钮、活动会话高亮边、进度条填充、运行状态点、极光背景、焦点描边、表头下划线、chip/pill 激活态。正文与数据**永远是纯 ink 色**。
|
||||
2. **层级靠表面阶梯 + 发丝线,不靠阴影**:静态卡片 1px `--pa-hairline` 描边;**glow 只属于状态类元素**(运行脉冲、悬停主按钮、焦点输入),静态卡片禁止发光。
|
||||
3. **语义色只表状态**(成功/运行/失败),**相位色只表步骤归属**,两者互不混用。
|
||||
4. **mono 只用于代码、终端、文件路径、数字与参数**;叙述性文字一律 font-ui。
|
||||
5. **动效有目的**:动 = 状态。无装饰性漂浮弹跳。
|
||||
6. **禁外链**:无 Google Fonts、无 CDN(DESIGN.md 硬性禁令,实验室可能离线)。
|
||||
7. 状态表达三重编码:颜色 + 图标 + 文字。
|
||||
|
||||
---
|
||||
|
||||
## 2. 动画规格(`frontend/src/styles/animations.css`)
|
||||
|
||||
| 动画 | 时长/缓动 | 用途 | 关键帧要点 |
|
||||
|---|---|---|---|
|
||||
| `pulse` | 2s infinite | running 状态点/徽章 | box-shadow `0 0 0 0 rgba(0,212,255,.5)` → `0 0 0 8px rgba(0,212,255,0)` → 0 |
|
||||
| `shimmer` | 1.8s infinite | 运行中卡片扫光、流式进度线 | `background-size:200%`,`background-position` 200%→-200% |
|
||||
| `blink` | 1s step-end infinite | 流式光标 | `50% { opacity: 0 }` |
|
||||
| `spin` | 1s linear infinite | running 相位 spinner | `rotate(360deg)` |
|
||||
| `sweep` | 2.4s infinite | 进度条高光带 | 同 shimmer 方向 |
|
||||
| `fade-slide` | .2s `--pa-ease-out` | tab 切换、工具卡展开体 | translateY(6px)→0 + opacity |
|
||||
| `msg-in` | .3s `--pa-ease-out` | 新消息入场 | translateY(8px)→0 + opacity |
|
||||
| `aurora-drift` | 16s ease-in-out infinite alternate | 背景光斑 | translate(-40px,30px) scale(1.08),blob.b 延迟 -8s |
|
||||
| 面板折叠 | .3s `--pa-ease-out` | 左右栏 grid-template-columns | — |
|
||||
| 主题图标 | .4s | 切换 rotate 180° | — |
|
||||
| hover lift | 200ms | 按钮 -1px、建议卡 -2px | — |
|
||||
|
||||
**降级**:`@media (prefers-reduced-motion: reduce)` 下 `*` 全部 `animation:none; transition:none`。状态仍由静态符号(✓/●/◌/实心/空心)表达。
|
||||
|
||||
---
|
||||
|
||||
## 3. 三栏外壳(Desktop ≥1280px)
|
||||
|
||||
```
|
||||
grid: columns 260px minmax(0,1fr) 380px
|
||||
rows var(--topbar-h) minmax(0,1fr) var(--statusbar-h)
|
||||
areas "top top top" "side chat bench" "status status status"
|
||||
```
|
||||
|
||||
- `.side-collapsed` → columns `64px minmax(0,1fr) 380px`
|
||||
- `.bench-collapsed` → columns `260px minmax(0,1fr) 28px`
|
||||
- body `overflow:hidden`,滚动在各栏内部
|
||||
- `.aurora-bg` 固定层 z-index:0,pointer-events:none,两个光斑 560/620px + blur(120px) + 48px 点阵(顶部渐隐 mask)
|
||||
- 顶栏 `.top-bar`:`color-mix(in srgb, var(--pa-canvas) 88%, transparent)` + `backdrop-filter: blur(14px)` + 底部 hairline
|
||||
- 中栏 `.chat-inner` max-width 760px 居中,padding `28px 28px 12px`
|
||||
- 作曲器 `.composer-wrap` padding `4px 28px 14px`,`.composer` max-width 760px
|
||||
|
||||
### 折叠把手
|
||||
|
||||
- 左栏折叠把手:26×26px 圆角按钮,悬浮在左栏底部(bottom 40px),hairline 描边,chevron 左指;展开后图标方向反转
|
||||
- 右栏折叠把手条:28px 宽竖条,内嵌 chevron;折叠时 chevron rotate 180°
|
||||
|
||||
---
|
||||
|
||||
## 4. 组件规格(每个组件精确的 CSS 关键值)
|
||||
|
||||
### 4.1 顶栏与徽章
|
||||
|
||||
| 组件 | 规格 |
|
||||
|---|---|
|
||||
| `.logo-dot` | 9px 圆点,渐变底,双辉光 |
|
||||
| `.logo-text` | 15px/700/ls .2px,`linear-gradient(90deg, from, to)` + `background-clip:text` |
|
||||
| `.icon-btn` | 30×30px r-md,ink-muted → hover ink + surface-2 |
|
||||
| `.top-title` | 居中,13.5px/600,ellipsis |
|
||||
| `.badge` | pill(r-pill,padding 2px 10px),11px/500/ls .5px,6px dot + 文字;四态见 PRD §2.1,`.running` 的 dot 带 pulse |
|
||||
|
||||
### 4.2 会话列表
|
||||
|
||||
| 组件 | 规格 |
|
||||
|---|---|
|
||||
| `.new-task-btn` | 40px 高,r-md,渐变底,白字 14px/600;hover 辉光 + -1px lift |
|
||||
| `.search-box` | 34px 高,surface-2 + hairline;focus-within 渐变描边(double-bg 技巧)+ `0 0 14px rgba(0,212,255,.18)` |
|
||||
| `.group-label` | 11px/500/ls .6px,ink-subtle,padding `10px 6px 5px` |
|
||||
| `.session-item` | padding 8px 10px,r-md,透明边;hover surface-2;active 时 左 2px 渐变条(`left:-10px`)+ `color-mix(g-to 8%)` 底 + `22%` 描边 |
|
||||
| `.status-dot` | 8px;running=`--pa-g-to`+pulse,done=success,error=error,idle=空心 1.5px ink-subtle |
|
||||
| 标题 | 13px/500 ellipsis;meta 11px ink-subtle ellipsis |
|
||||
|
||||
### 4.3 对话区
|
||||
|
||||
| 组件 | 规格 |
|
||||
|---|---|
|
||||
| `.empty-state` | 顶部 9vh;hero-dot 46px r-14 渐变底 ✧,双辉光;h1 28px/600/ls-.4px,ink→ink-muted 渐变字;副标题 14px ink-muted |
|
||||
| `.suggest-card` | 3 列 grid gap 12px;padding 15px,r-lg;hover -2px lift + 渐变描边 + 青色辉光;icon 16px、name 13.5px/600、desc 12px |
|
||||
| `.msg.user .bubble` | 右对齐 max-width 70%,user-bubble 渐变 + border,r-lg,padding 10px 15px,14px |
|
||||
| `.msg.assistant` | 无底色;padding-left 14px;左缘 2px 透明竖线 hover 变渐变 |
|
||||
| `.msg-head` | 24px 渐变头像 r-8 白 ✦ + 名字 12.5px/600 + 时间 11.5px ink-subtle |
|
||||
| `.md-table` | surface-1 + hairline,r-md;thead th 12px/600 surface-3 底 + **2px 渐变下边框**(`border-image: var(--pa-gradient) 1`);数据格 13px,**表头非「说明」的列 mono 12.5px** |
|
||||
| `.code-block` | code-bg + hairline r-md,mono 12.5px lh 1.7,pre-wrap,色 ink-muted |
|
||||
| `.stream-cursor` | 8×15px 青色块,blink 1s;`.shimmer-line` 3px 高 surface-3 轨道 + 青色扫光 |
|
||||
|
||||
### 4.4 流水线卡(`pipeline-card`,RAG 域改为「检索流水线」五相位)
|
||||
|
||||
| 元素 | 规格 |
|
||||
|---|---|
|
||||
| 卡体 | surface-1 + hairline,r-lg,padding 14px 16px,margin 14px 0 |
|
||||
| 头部 | 「⚙ 检索流水线」12.5px/600/ls .3px + 整体语义色徽章 |
|
||||
| 步骤 | 每步 padding 7px 0,gap 10px;步骤间左侧 2px hairline 连接线(left 7px) |
|
||||
| 状态点 | 16px 圆,done=`--pa-t-done` 底白 ✓;running=对应相位色底 ● + pulse;waiting=surface-3 底 + 1.5px hairline-strong 边 ◌ |
|
||||
| 相位名 | 13px,宽 84px 固定 |
|
||||
| 描述 | 12px ink-muted,ellipsis |
|
||||
| 状态文字 | 12px/600;done 金、running 相位色 + 11px spin 环、waiting ink-subtle 400 |
|
||||
|
||||
注意:原型的 running 点颜色是执行蓝;RAG 版按 PRD FR-CHAT-009 的相位色表——**当前相位的点与字用该相位的 `--pa-t-*` 色**。
|
||||
|
||||
### 4.5 阶段卡(原型 `tool-card` 的 RAG 化,见 PRD FR-RETRIEVAL)
|
||||
|
||||
| 元素 | 规格 |
|
||||
|---|---|
|
||||
| 卡体 | surface-1 + hairline,r-lg,overflow hidden;running 时叠加 shimmer 扫光层(`rgba(0,212,255,.05)` 斜扫) |
|
||||
| 头部 | padding 10px 14px;图标 chip 22px r-7(g-from 16% tint 底,g-to 字色);名称 13.5px/600 ellipsis;状态徽章;mono 耗时 11.5px;chevron 展开 rotate 90° |
|
||||
| 展开体 | 顶部 hairline 分隔,padding 11px 14px 13px,fade-slide 入场;`tb-label` 11px/ls .5px;`tb-params` mono 12.5px 青色 code-bg r-sm;`tb-result` 13px 数值 mono success;`tb-log` mono 11.5px lh1.8 code-bg,max-height 130px 滚动,`.log-ok` success / `.log-run` 青色 |
|
||||
|
||||
### 4.6 作曲器
|
||||
|
||||
| 元素 | 规格 |
|
||||
|---|---|
|
||||
| `.composer` | r-xl surface-1 + hairline,padding 12px;focus-within 渐变描边 + `0 0 22px rgba(0,212,255,.16)` |
|
||||
| `.mode-pill` | r-pill,padding 4px 12px,12px/500;默认 hairline + ink-muted;`.on` 渐变底白字 + `0 0 12px rgba(0,212,255,.3)` |
|
||||
| textarea | 透明无边框 14px/1.6,min-height 46px,max-height 160px 内滚;placeholder ink-subtle |
|
||||
| `.send-btn` | r-md,padding 7px 18px,渐变底白字 13px/600;hover 辉光 + -1px;disabled opacity .45 无辉光 |
|
||||
|
||||
### 4.7 右工作台
|
||||
|
||||
| 组件 | 规格 |
|
||||
|---|---|
|
||||
| `.bench-tabs` | padding 10px 14px 0,底部 hairline;tab 13px/500 ink-muted,`.on` 13px/600 ink + **底部 2px 渐变下划线**(fade-slide 入场) |
|
||||
| `.bench-body` | padding 14px,滚动;`.tab-pane` fade-slide |
|
||||
| `.chip` | r-pill padding 3px 11px,11.5px;`.on` 实心 `#5E6AD2` 底白字 |
|
||||
| `.file-tree`(来源列表沿用) | mono 12.5px;条目 padding 6px 8px r-sm;hover surface-2 并显示 `.ft-dl`(opacity 0→1);目录名 g-to 色 600 |
|
||||
| `.prog-item` | 名称 13px/600 + mono 百分比(100% 转 success);轨道 6px r-3 surface-3;填充渐变 + `sweep`;`.full` 纯 success 停扫光 |
|
||||
| `.preview-block` | surface-1 + hairline r-lg padding 12px;标题 12px/600 |
|
||||
|
||||
### 4.8 底部状态栏
|
||||
|
||||
28px 高,surface-1 + 顶部 hairline,padding 0 14px,11.5px ink-subtle。
|
||||
左:4 个 `.agent-dot-item`(7px 点 + 名):默认 surface-3 底 + 1.5px hairline-strong 边;`.running` = 相位色 + pulse;`.done` = success;`.error` = error。**RAG 版四个点名为:规划 / 检索 / 证据 / 生成**(对应 PRD FR-CHAT-009 前四相位;完成相位不进底栏)。
|
||||
右:`PlantAI RAG v0.1` + mono QA 版本 + mono 会话数。
|
||||
|
||||
---
|
||||
|
||||
## 5. 四档响应式(`frontend/src/styles/responsive.css`,覆盖 §3)
|
||||
|
||||
| 档位 | 规则 | 具体行为 |
|
||||
|---|---|---|
|
||||
| Desktop ≥1280px | 无覆盖 | 完整三栏,见 §3 |
|
||||
| Laptop 1024–1279px | `@media (max-width: 1279px) and (min-width: 1024px)` | 左栏强制 `side-collapsed`(64px 轨);右栏列宽 340px |
|
||||
| Tablet 768–1023px | `@media (max-width: 1023px) and (min-width: 768px)` | 单栏:`.app-shell` columns `minmax(0,1fr)`;左栏变**左抽屉**(fixed,260px 宽,translateX 滑入 + scrim);右栏变**右抽屉**;顶栏出现汉堡按钮与工作台按钮(工作台按钮带来源数角标);底栏保留 |
|
||||
| Mobile <768px | `@media (max-width: 767px)` | 顶栏 = 汉堡 + 标题 + 工作台按钮;工作台为**全屏覆盖层**(顶部 tab 条);底栏隐藏;顶栏显示一枚聚合状态点;aurora 光斑 `--pa-aurora-opacity: 0` |
|
||||
|
||||
移动端专项(全部实现,见 PRD §12.2):安全区 `env(safe-area-inset-*)`;textarea 16px 字号;`@media (hover: hover)` 包裹全部悬停态;<768px 交互目标 min-height 44px;`100dvh` + `100vh` 回退;`visualViewport` 键盘适配;`md-table` 横向滚动容器 + 边缘渐隐;图谱节点上限 30。
|
||||
|
||||
抽屉组件(B1 负责):`.drawer-scrim`(fixed inset 0,`--pa-scrim`,fade-in)+ `.drawer`(fixed,宽 300px max,transform 滑入 300ms `--pa-ease-out`)。Esc 或点击 scrim 关闭。焦点进入抽屉时锁定 Tab 焦点在抽屉内,关闭后归还。
|
||||
|
||||
---
|
||||
|
||||
## 6. 主题初始化顺序(防止闪白)
|
||||
|
||||
`index.html` 中 `<html data-theme="dark">` 硬编码;`main.js` 中在 `createApp` **之前**同步执行:
|
||||
|
||||
```js
|
||||
const t = localStorage.getItem('pa-theme');
|
||||
if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t;
|
||||
```
|
||||
|
||||
此后才挂载应用。theme store 仅管理运行时切换。
|
||||
|
||||
---
|
||||
|
||||
## 7. 组件类名复用约定
|
||||
|
||||
B2/B3 组件必须复用 B1 在 `styles/base.css` 与原型中定义的类名,**不得另起炉灶**:`app-shell`、`top-bar`、`logo`、`badge`、`session-panel`、`session-item`、`status-dot`、`chat-panel`、`msg`、`bubble`、`msg-head`、`agent-avatar`、`md-table`、`code-block`、`pipeline-card`、`p-step`、`tool-card`、`stream-wrap`、`stream-text`、`stream-cursor`、`shimmer-line`、`composer`、`mode-pill`、`send-btn`、`bench-panel`、`bench-tabs`、`bench-tab`、`chip`、`ft-item`、`prog-item`、`prog-track`、`prog-fill`、`preview-block`、`status-bar`、`agent-dot-item`、`drawer`、`drawer-scrim`。
|
||||
@@ -0,0 +1,3 @@
|
||||
# frontend/.gitignore(最小版,B1 可扩展)
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>海古纪 PlantAI · 知识工作台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3284
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "plantai-rag-new-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "3.5.13",
|
||||
"pinia": "2.3.0",
|
||||
"markdown-it": "14.1.0",
|
||||
"dompurify": "3.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "5.4.11",
|
||||
"@vitejs/plugin-vue": "5.2.1",
|
||||
"vitest": "2.1.8",
|
||||
"@vue/test-utils": "2.4.6",
|
||||
"jsdom": "25.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// playwright.config.js —— T(测试 agent)归属。
|
||||
// L5 端到端骨架:三档视口(TEST_PLAN §3),与 vitest(vitest.config.js)完全独立。
|
||||
// 完整用例(按 docs/TEST_CASES.md 的 TC-E2E-*)在后续轮次以 spec 文件补齐到 ../tests/e2e/。
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '../tests/e2e',
|
||||
testMatch: '**/*.spec.js',
|
||||
// 2026-08-25 R2 加固(T 建议采纳):真实 QA 流最长 ~92s 且上游串行排队会放大,
|
||||
// 全局 timeout 提到 480s;各 spec 内对不含真实流的用例仍可用 test.setTimeout 收紧。
|
||||
timeout: 480_000,
|
||||
expect: { timeout: 10_000 },
|
||||
fullyParallel: false, // 三项目串行,避免 dev server 端口与 mock 场景互相干扰
|
||||
workers: 1,
|
||||
retries: 0, // 测试轮次纪律:失败即记录,不自动重试掩盖缺陷
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:5180',
|
||||
browserName: 'chromium',
|
||||
headless: true, // 远端可改 headed;本地保持无头
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'mobile',
|
||||
use: {
|
||||
viewport: { width: 390, height: 844 }, // iPhone 14
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
deviceScaleFactor: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'tablet',
|
||||
use: { viewport: { width: 768, height: 1024 } }, // iPad
|
||||
},
|
||||
{
|
||||
name: 'desktop',
|
||||
use: { viewport: { width: 1440, height: 900 } },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev -- --strictPort',
|
||||
url: 'http://127.0.0.1:5180',
|
||||
timeout: 60_000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
// 复用已由 deploy/start-dev.sh 或手动拉起的 dev server(集成/远端场景)
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
<!-- App.vue —— B1 归属。挂载三栏 AppShell。 -->
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
import AppShell from './components/layout/AppShell.vue';
|
||||
import { useHealthStore } from './stores/health.js';
|
||||
|
||||
// FR-SHELL-007 首屏初始化:并行请求健康与 QA 元信息(失败不阻塞渲染)
|
||||
const health = useHealthStore();
|
||||
onMounted(() => {
|
||||
health.start();
|
||||
health.fetchQaInfo();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
health.stop();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell />
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
// api/extraction.js —— 论文抽取任务(BFF 代理 8000/7900;B3 归属)
|
||||
import { get, post } from './http.js';
|
||||
|
||||
/**
|
||||
* 任务列表。上游无 progress 数值,progress_pct 由 BFF 按 stage 派生:
|
||||
* queued=5 / mineru=35 / llm=75 / completed=100 / failed=null。
|
||||
*/
|
||||
export function listJobs({ limit = 20 } = {}) {
|
||||
return get('/api/extraction/jobs', { limit });
|
||||
}
|
||||
|
||||
/** 任务详情(stats 计数 + plan,plan 仅 completed 有) */
|
||||
export function getJob(jobId) {
|
||||
return get(`/api/extraction/jobs/${encodeURIComponent(jobId)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传论文(multipart)。限制:仅 PDF,单文件 ≤50MB(BFF 校验,前端先行)。
|
||||
* @param {File} file
|
||||
* @returns {Promise<{success:boolean, job_id:string, status_url:string, message:string}>}
|
||||
*/
|
||||
export function uploadPaper(file, { paper_id, family, output_name } = {}) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
if (paper_id != null && paper_id !== '') form.append('paper_id', paper_id);
|
||||
if (family != null && family !== '') form.append('family', family);
|
||||
if (output_name != null && output_name !== '') form.append('output_name', output_name);
|
||||
return post('/api/extraction/upload', form);
|
||||
}
|
||||
|
||||
/** 运行日志:{text} 透传,rows 为空属正常现象 */
|
||||
export function getRunLog() {
|
||||
return get('/api/extraction/run-log');
|
||||
}
|
||||
|
||||
/** 上游透传 {rows:[]}——rows 为空属正常(上游批次目录未配置),前端显示空态而非错误 */
|
||||
export function getProcessingStatus() {
|
||||
return get('/api/extraction/processing-status');
|
||||
}
|
||||
|
||||
/** 上游透传 {rows:[]} */
|
||||
export function getSummary() {
|
||||
return get('/api/extraction/summary');
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// api/http.js —— B1 归属,B2/B3 复用。fetch 封装 + 统一错误信封解析。
|
||||
// 契约(API.md §1):非 2xx 与降级响应返回 {"ok":false,"error":{"code","message","degraded"}}。
|
||||
// 网络错误与信封错误一律归一为结构化 ApiError。
|
||||
//
|
||||
// 超时策略(BUG-R1-07 修复,API.md 全表读超时:qa chat 300s / qa search 180s /
|
||||
// extraction upload 600s / kg overview 60s / 邻域 30s):
|
||||
// - 默认读超时 600s = API.md 全表最大值。前端兜底永不先于 BFF 超时自断——
|
||||
// 真实超时时由 BFF 返回 TIMEOUT 信封(504),前端展示契约文案「检索超时」,
|
||||
// 而不是自己 fabricate 一个 NETWORK 错误。
|
||||
// - 调用方可传 opts.timeout(毫秒)做更紧的超时(如已知快路径);
|
||||
// timeout: 0 表示不设超时。
|
||||
// - SSE 流式(raw:true)不走读超时——上游最长 240s,由调用方 AbortSignal 控制生命周期。
|
||||
//
|
||||
// 查询参数(BUG-R1-01 修复):get(path, params, opts) 的 params 经 URLSearchParams
|
||||
// 序列化拼到 query string(undefined/null 跳过),匹配 B3 kg.js / extraction.js 的调用形态:
|
||||
// get('/api/kg/entities', { q, limit }) → /api/kg/entities?q=…&limit=20
|
||||
// get('/api/kg/entities/{id}/neighborhood', { depth, limit }) → …/neighborhood?depth=1&limit=50
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(message, { code = 'NETWORK', status = 0, degraded = false, cause } = {}) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.code = code; // 信封 error.code,网络错误为 'NETWORK'
|
||||
this.status = status; // HTTP 状态码,网络错误为 0
|
||||
this.degraded = degraded; // true = 上游不可用(UI 走降级提示而非错误弹窗)
|
||||
if (cause) this.cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
/** 默认读超时:API.md 全表最大值(extraction upload 600s)——前端兜底不得先于 BFF 超时 */
|
||||
const DEFAULT_TIMEOUT_MS = 600000;
|
||||
|
||||
function buildQuery(params) {
|
||||
if (!params) return '';
|
||||
const sp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v === undefined || v === null) continue;
|
||||
sp.set(k, String(v));
|
||||
}
|
||||
const qs = sp.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
async function request(path, { method = 'GET', body, headers = {}, timeout = DEFAULT_TIMEOUT_MS, signal, raw = false, params } = {}) {
|
||||
// GET 的 params 序列化为 query string(BUG-R1-01)
|
||||
let url = path;
|
||||
if (method === 'GET' && params) url += buildQuery(params);
|
||||
|
||||
const opts = { method, headers: { ...headers }, signal };
|
||||
if (body !== undefined && body !== null) {
|
||||
if (typeof FormData !== 'undefined' && body instanceof FormData) {
|
||||
// multipart(FR-EXTRACT-004 上传):不设 Content-Type(浏览器自动带 boundary)
|
||||
opts.body = body;
|
||||
} else {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
const ctrl = new AbortController();
|
||||
// raw(SSE 流式)不设读超时;timeout:0 显式关闭
|
||||
const t = raw || timeout === 0 ? null : setTimeout(() => ctrl.abort(), timeout);
|
||||
res = await fetch(url, {
|
||||
...opts,
|
||||
signal: signal || ctrl.signal,
|
||||
});
|
||||
if (t) clearTimeout(t);
|
||||
} catch (e) {
|
||||
// AbortError 也可能是调用方主动中断流式请求——继续抛原始错误,由调用方处理
|
||||
if (e.name === 'AbortError' && signal && signal.aborted) throw e;
|
||||
throw new ApiError('网络异常,请检查连接', {
|
||||
code: 'NETWORK',
|
||||
degraded: false,
|
||||
cause: e,
|
||||
});
|
||||
}
|
||||
|
||||
// raw:true —— 返回原始 Response 供 SSE 流式消费(B2 consumeSseStream);
|
||||
// 非 2xx 仍按统一错误信封抛 ApiError(HTTP 错误在流开始前就被拒绝)
|
||||
if (raw) {
|
||||
if (!res.ok) {
|
||||
let payload = null;
|
||||
if ((res.headers.get('content-type') || '').includes('application/json')) {
|
||||
try {
|
||||
payload = await res.json();
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
}
|
||||
if (payload && payload.ok === false && payload.error) {
|
||||
throw new ApiError(payload.error.message || '服务错误', {
|
||||
code: payload.error.code || 'INTERNAL',
|
||||
status: res.status,
|
||||
degraded: !!payload.error.degraded,
|
||||
});
|
||||
}
|
||||
throw new ApiError(`请求失败(HTTP ${res.status})`, { code: 'INTERNAL', status: res.status });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
let payload = null;
|
||||
const isJson = (res.headers.get('content-type') || '').includes('application/json');
|
||||
if (isJson) {
|
||||
try {
|
||||
payload = await res.json();
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
// 统一错误信封(API.md §1)
|
||||
if (payload && payload.ok === false && payload.error) {
|
||||
throw new ApiError(payload.error.message || '服务错误', {
|
||||
code: payload.error.code || 'INTERNAL',
|
||||
status: res.status,
|
||||
degraded: !!payload.error.degraded,
|
||||
});
|
||||
}
|
||||
throw new ApiError(`请求失败(HTTP ${res.status})`, { code: 'INTERNAL', status: res.status });
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
/** params 序列化为 query string(BUG-R1-01);opts 可传 { timeout, signal, raw } */
|
||||
get: (path, params = {}, opts = {}) => request(path, { ...opts, method: 'GET', params }),
|
||||
/** body 为普通对象 → JSON;FormData → multipart。opts 可传 { timeout, signal, raw } */
|
||||
post: (path, body, opts = {}) => request(path, { ...opts, method: 'POST', body }),
|
||||
patch: (path, body, opts = {}) => request(path, { ...opts, method: 'PATCH', body }),
|
||||
del: (path, opts = {}) => request(path, { ...opts, method: 'DELETE' }),
|
||||
/** 原始 fetch(无封装),供 SSE 流式调用(B2 用 ReadableStream 消费 POST /api/qa/chat/stream) */
|
||||
rawFetch: fetch,
|
||||
};
|
||||
|
||||
// 命名导出(B2/B3 api/* 层按此 import;与 api 对象同函数,两种取法等价)
|
||||
export const get = api.get;
|
||||
export const post = api.post;
|
||||
export const patch = api.patch;
|
||||
export const del = api.del;
|
||||
|
||||
// 别名:B2 代码注释中的 HttpError 即 ApiError(统一错误信封,契约 API.md §1)
|
||||
export const HttpError = ApiError;
|
||||
@@ -0,0 +1,37 @@
|
||||
// api/kg.js —— 知识图谱自由浏览(BFF Bolt 直连 Neo4j;B3 归属)
|
||||
// 错误统一走 http.js 信封:所有非 2xx 抛 HttpError{status, code, message, degraded}
|
||||
import { get } from './http.js';
|
||||
|
||||
export function kgStatus() {
|
||||
return get('/api/kg/status');
|
||||
}
|
||||
|
||||
export function kgOverview() {
|
||||
return get('/api/kg/overview');
|
||||
}
|
||||
|
||||
/**
|
||||
* 实体搜索(name_norm 精确/前缀匹配,非模糊全文)。
|
||||
* @param {string} q 查询词
|
||||
* @param {number} [limit=20]
|
||||
* @returns {Promise<{query:string, results:Array<{id,name,type,support_count}>}>}
|
||||
*/
|
||||
export function searchEntities(q, limit = 20) {
|
||||
return get('/api/kg/entities', { q, limit });
|
||||
}
|
||||
|
||||
/**
|
||||
* 实体详情(id 为 canonical_id 或名字)。
|
||||
* @returns {Promise<{id,name,type,properties:object}>}
|
||||
*/
|
||||
export function getEntity(id) {
|
||||
return get(`/api/kg/entities/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 邻域展开(depth 1-2,默认 1;limit ≤200)。
|
||||
* @returns {Promise<{center,center_name,depth,nodes:Array,edges:Array}>}
|
||||
*/
|
||||
export function getNeighborhood(id, { depth = 1, limit = 50 } = {}) {
|
||||
return get(`/api/kg/entities/${encodeURIComponent(id)}/neighborhood`, { depth, limit });
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// api/qa.js —— QA 问答接口(B2 归属)
|
||||
// 契约:docs/API.md §4。错误统一走 http.js 信封:
|
||||
// 所有非 2xx 抛 HttpError{status, code, message, degraded}。
|
||||
import { post, get } from './http.js';
|
||||
import { consumeSseStream } from '../sse/parser.js';
|
||||
|
||||
/** QA 服务元信息(版本 + 12 段 pipeline 描述 + 配置) */
|
||||
export function info() {
|
||||
return get('/api/qa/info');
|
||||
}
|
||||
|
||||
/** 非流式问答(POST /api/qa/chat),响应为归一化 QA 响应 */
|
||||
export function chat(body) {
|
||||
return post('/api/qa/chat', body);
|
||||
}
|
||||
|
||||
/** 检索模式(POST /api/qa/search),BFF 强制 use_llm:false,只出证据不出答案 */
|
||||
export function search(body) {
|
||||
return post('/api/qa/search', body);
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE 流式问答(POST /api/qa/chat/stream)。
|
||||
* 帧事件类型(API.md §4):
|
||||
* progress / complete / error / timeout / raw /(无 event 名 → message)
|
||||
*
|
||||
* @param {object} body 请求体(由 config/modes.js 拼装 + question)
|
||||
* @param {object} [handlers]
|
||||
* @param {(ev: object) => void} [handlers.onProgress]
|
||||
* @param {(ev: object) => void} [handlers.onComplete] payload 为归一化 QA 响应
|
||||
* @param {(ev: object) => void} [handlers.onError] {code,message,retryable}
|
||||
* @param {(ev: object) => void} [handlers.onRaw] raw 仅记录不渲染
|
||||
* @param {(ev: object) => void} [handlers.onUnknown] 未知事件(含无 event 名的帧)
|
||||
* @param {AbortSignal} [signal] 停止/中断(AbortController.signal)
|
||||
* @returns {Promise<void>} 流自然结束 resolve;HTTP 错误抛 HttpError;
|
||||
* abort / 网络中断抛 DOMException(AbortError)/TypeError —— 调用方统一映射文案
|
||||
*/
|
||||
export async function chatStream(body, { onProgress, onComplete, onError, onRaw, onUnknown, signal } = {}) {
|
||||
const noop = () => {};
|
||||
const resp = await post('/api/qa/chat/stream', body, { signal, raw: true });
|
||||
|
||||
const dispatch = {
|
||||
progress: onProgress || noop,
|
||||
complete: onComplete || noop,
|
||||
error: onError || noop,
|
||||
// timeout 帧与 error 帧同等对待;上游 payload 无 code 字段,此处统一补 TIMEOUT(PRD 错误映射)
|
||||
timeout: (ev) => (onError || noop)({ ...ev, code: 'TIMEOUT' }),
|
||||
raw: onRaw || noop,
|
||||
};
|
||||
await consumeSseStream(resp, (ev) => {
|
||||
const fn = dispatch[ev.event] || onUnknown || noop;
|
||||
fn({ ...ev, payload: ev.data });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// api/sessions.js —— 会话与消息 CRUD(B2 归属)
|
||||
// 契约:docs/API.md §5。错误统一走 http.js 信封。
|
||||
import { get, post, patch, del } from './http.js';
|
||||
|
||||
/** GET /api/sessions —— 按 updated_at 倒序 */
|
||||
export function listSessions() {
|
||||
return get('/api/sessions');
|
||||
}
|
||||
|
||||
/** POST /api/sessions —— 创建(可空 title,默认「新会话」),不预置空消息 */
|
||||
export function createSession({ title } = {}) {
|
||||
return post('/api/sessions', title ? { title } : {});
|
||||
}
|
||||
|
||||
/** GET /api/sessions/{id} —— 会话 + messages 详情 */
|
||||
export function getSession(id) {
|
||||
return get(`/api/sessions/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
/** PATCH /api/sessions/{id} —— {title} 或 {status} */
|
||||
export function updateSession(id, patchBody) {
|
||||
return patch(`/api/sessions/${encodeURIComponent(id)}`, patchBody);
|
||||
}
|
||||
|
||||
/** DELETE /api/sessions/{id} —— 204,级联删消息 */
|
||||
export function deleteSession(id) {
|
||||
return del(`/api/sessions/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
/** POST /api/sessions/{id}/messages —— 用户消息 / 助手流式骨架 */
|
||||
export function createMessage(sessionId, body) {
|
||||
return post(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, body);
|
||||
}
|
||||
|
||||
/** PATCH /api/sessions/{id}/messages/{mid} —— 流式检查点(~3s)与终态落库 */
|
||||
export function updateMessage(sessionId, messageId, patchBody) {
|
||||
return patch(
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`,
|
||||
patchBody,
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /api/sessions/{id}/sources —— 会话级来源聚合(按 paper_id 去重 + count) */
|
||||
export function getSessionSources(id) {
|
||||
return get(`/api/sessions/${encodeURIComponent(id)}/sources`);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<!-- BenchPanel.vue —— 右工作台四 tab(B3 归属)
|
||||
FR-RETRIEVAL/SOURCE/EXTRACT/KG。类名复用 UI_SPEC §7:bench-panel / bench-tabs / bench-tab / bench-body / tab-pane。
|
||||
折叠(右栏 28px 手柄条)由 B1 AppShell 的 .bench-collapsed 控制;本组件提供 `collapsed` props
|
||||
与 setCollapsed 事件对接 B1 把手;折叠(含 <1024 收起态)时暂停提取轮询(FR-EXTRACT-005)。 -->
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useBenchStore } from '../../stores/bench.js';
|
||||
import { BENCH_TABS } from '../../i18n/labels.js';
|
||||
import { BENCH_LABELS } from './benchLabels.js';
|
||||
import RetrievalPane from './RetrievalPane.vue';
|
||||
import SourcesPane from './SourcesPane.vue';
|
||||
import ExtractionPane from './ExtractionPane.vue';
|
||||
import KgPane from './KgPane.vue';
|
||||
|
||||
const props = defineProps({
|
||||
collapsed: { type: Boolean, default: false },
|
||||
});
|
||||
const emit = defineEmits(['collapse']);
|
||||
|
||||
const bench = useBenchStore();
|
||||
const collapsedRef = ref(props.collapsed);
|
||||
|
||||
function toggleCollapse() {
|
||||
collapsedRef.value = !collapsedRef.value;
|
||||
bench.setCollapsed(collapsedRef.value);
|
||||
// 收起态(含 <1024 强制收起)时暂停提取轮询(FR-EXTRACT-005)
|
||||
if (collapsedRef.value) bench.pausePolling();
|
||||
emit('collapse', collapsedRef.value);
|
||||
}
|
||||
|
||||
function selectTab(key) {
|
||||
bench.setActiveTab(key);
|
||||
}
|
||||
|
||||
// 工作台按钮(移动端全屏覆盖层入口)需要的来源数角标(FR-RESP-003)
|
||||
const sourceCount = computed(() => {
|
||||
const s = bench.sources;
|
||||
return s ? s.length : 0;
|
||||
});
|
||||
defineExpose({ sourceCount, activeTab: computed(() => bench.activeTab) });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="bench-panel" :class="{ 'collapsed-local': collapsedRef }">
|
||||
<div class="inner">
|
||||
<div class="bench-tabs" role="tablist">
|
||||
<button
|
||||
v-for="tb in BENCH_TABS"
|
||||
:key="tb.key"
|
||||
role="tab"
|
||||
:aria-selected="bench.activeTab === tb.key"
|
||||
:class="['bench-tab', { on: bench.activeTab === tb.key }]"
|
||||
@click="selectTab(tb.key)"
|
||||
>{{ tb.label }}</button>
|
||||
</div>
|
||||
<div class="bench-body">
|
||||
<div v-if="bench.activeTab === 'retrieval'" class="tab-pane"><RetrievalPane /></div>
|
||||
<div v-else-if="bench.activeTab === 'sources'" class="tab-pane"><SourcesPane /></div>
|
||||
<div v-else-if="bench.activeTab === 'extract'" class="tab-pane"><ExtractionPane /></div>
|
||||
<div v-else class="tab-pane"><KgPane /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bench-handle">
|
||||
<button
|
||||
class="bench-handle-btn"
|
||||
type="button"
|
||||
:aria-label="collapsedRef ? BENCH_LABELS.benchExpand : BENCH_LABELS.benchCollapse"
|
||||
@click="toggleCollapse"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 复用 B1 base.css 的 .bench-* 类;此处仅补 B3 专属细节 */
|
||||
.bench-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--pa-surface-1);
|
||||
border-left: 1px solid var(--pa-hairline);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s var(--pa-ease-out);
|
||||
}
|
||||
.inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: 380px;
|
||||
transition: opacity 0.2s var(--pa-ease);
|
||||
flex: 1;
|
||||
}
|
||||
.bench-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 10px 14px 0;
|
||||
border-bottom: 1px solid var(--pa-hairline);
|
||||
}
|
||||
.bench-tab {
|
||||
position: relative;
|
||||
padding: 8px 10px 9px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--pa-ink-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s var(--pa-ease);
|
||||
}
|
||||
.bench-tab:hover { color: var(--pa-ink); }
|
||||
.bench-tab.on { color: var(--pa-ink); font-weight: 600; }
|
||||
.bench-tab.on::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: -1px;
|
||||
height: 2px;
|
||||
background: var(--pa-gradient);
|
||||
border-radius: 2px;
|
||||
animation: fade-slide 0.25s var(--pa-ease-out);
|
||||
}
|
||||
.bench-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px;
|
||||
min-height: 0;
|
||||
}
|
||||
.tab-pane { animation: fade-slide 0.2s var(--pa-ease-out); }
|
||||
.bench-handle {
|
||||
display: none;
|
||||
width: 28px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 10px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.bench-handle-btn {
|
||||
width: 28px;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--pa-ink-subtle);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bench-handle-btn:hover { color: var(--pa-ink); }
|
||||
/* 折叠态由 AppShell 的 .bench-collapsed 控制(B1),此处以 collapsed prop 同步 */
|
||||
.collapsed-local .inner { opacity: 0; pointer-events: none; }
|
||||
.collapsed-local .bench-handle { display: flex; }
|
||||
.collapsed-local .bench-handle svg { transform: rotate(180deg); }
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<!-- ChainGroup.vue —— 证据链分组(B3 归属)
|
||||
FR-SOURCE-004:按 chain_id 分组,可展开为逐跳列表:源实体 —[关系]→ 目标实体,
|
||||
每跳附 paper_id/PMID,跳数以徽章显示(labels.chainBadge)。 -->
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { T } from '../../i18n/labels.js';
|
||||
|
||||
const props = defineProps({
|
||||
chain: { type: Object, required: true },
|
||||
open: { type: Boolean, default: false },
|
||||
});
|
||||
const emit = defineEmits(['toggle']);
|
||||
|
||||
/** 归一化 hop 列表:兼容 {hops:[…]} 与直接数组两种形态(API.md §9) */
|
||||
const hops = computed(() => {
|
||||
const c = props.chain || {};
|
||||
if (Array.isArray(c)) return c;
|
||||
return Array.isArray(c.hops) ? c.hops : [];
|
||||
});
|
||||
|
||||
const hopCount = computed(() => hops.value.length);
|
||||
|
||||
/** 每跳附 paper_id/PMID(真实 hop 字段见 API.md §7 链条容错) */
|
||||
function hopRef(h) {
|
||||
const refs = [];
|
||||
if (h.pmid) refs.push(`PMID:${h.pmid}`);
|
||||
else if (h.paper_id) refs.push(h.paper_id);
|
||||
return refs.join(' · ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chain-group">
|
||||
<button class="chain-head" type="button" @click="emit('toggle')">
|
||||
<svg class="chain-chev" :class="{ open }" width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M9 18l6-6-6-6" /></svg>
|
||||
<span class="chain-id">{{ chain.chain_id || 'chain' }}</span>
|
||||
<span class="badge chain-badge"><span class="dot"></span>{{ hopCount }} {{ T.chainBadge }}</span>
|
||||
</button>
|
||||
<div v-if="open" class="chain-body">
|
||||
<div v-for="(h, i) in hops" :key="i" class="hop-item">
|
||||
<span class="hop-idx">{{ i + 1 }}</span>
|
||||
<span class="hop-text">
|
||||
<span class="hop-ent">{{ h.source || h.from_name }}</span>
|
||||
<span class="hop-rel">—[{{ h.relation || h.target_relation || '?' }}]→</span>
|
||||
<span class="hop-ent">{{ h.target || h.to_name }}</span>
|
||||
</span>
|
||||
<span v-if="hopRef(h)" class="hop-ref">{{ hopRef(h) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chain-group {
|
||||
margin-bottom: 6px;
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.chain-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--pa-ink);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
.chain-head:hover { background: var(--pa-surface-2); }
|
||||
.chain-chev {
|
||||
color: var(--pa-ink-subtle);
|
||||
transition: transform 0.25s var(--pa-ease);
|
||||
flex: none;
|
||||
}
|
||||
.chain-chev.open { transform: rotate(90deg); }
|
||||
.chain-id {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.chain-badge {
|
||||
color: var(--pa-ink-subtle);
|
||||
background: var(--pa-surface-3);
|
||||
flex: none;
|
||||
}
|
||||
.chain-badge .dot { display: none; }
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.badge .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.chain-body {
|
||||
border-top: 1px solid var(--pa-hairline);
|
||||
padding: 6px 10px 8px;
|
||||
animation: fade-slide 0.2s var(--pa-ease-out);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.hop-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.hop-idx {
|
||||
flex: none;
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.hop-text { min-width: 0; word-break: break-word; }
|
||||
.hop-ent { color: var(--pa-ink-muted); font-family: var(--pa-font-mono); }
|
||||
.hop-rel { color: var(--pa-g-to); font-family: var(--pa-font-mono); margin: 0 2px; }
|
||||
.hop-ref { color: var(--pa-ink-subtle); font-size: 10.5px; font-family: var(--pa-font-mono); flex: none; }
|
||||
</style>
|
||||
@@ -0,0 +1,341 @@
|
||||
<!-- ExtractionPane.vue —— 工作台 Tab3 提取任务(B3 归属)
|
||||
FR-EXTRACT-001~006:prog-item 列表、详情展开(stats + plan 仅 completed)、run-log mono 滚动贴底、
|
||||
上传(PDF ≤50MB 校验)、5s 轮询(存在 running 时;tab 切走由 BenchPanel 暂停)、离线降级。 -->
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { useBenchStore } from '../../stores/bench.js';
|
||||
import { getRunLog, uploadPaper } from '../../api/extraction.js';
|
||||
import { STATUS_LABEL, T } from '../../i18n/labels.js';
|
||||
import { BENCH_LABELS } from './benchLabels.js';
|
||||
import ProgressItem from './ProgressItem.vue';
|
||||
|
||||
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
const bench = useBenchStore();
|
||||
const openId = ref(null);
|
||||
const fileInput = ref(null);
|
||||
const uploadMsg = ref('');
|
||||
const uploadOk = ref(false);
|
||||
const uploading = ref(false);
|
||||
const runLog = ref('');
|
||||
const logOpen = ref(false);
|
||||
const logError = ref(false);
|
||||
const logBox = ref(null);
|
||||
|
||||
const jobs = computed(() => bench.jobs);
|
||||
const detail = computed(() => (openId.value ? bench.getJobDetail(openId.value) : null));
|
||||
const detailLoading = computed(() => bench.detailLoading);
|
||||
const detailError = computed(() => bench.detailError);
|
||||
|
||||
function toggleJob(job) {
|
||||
if (openId.value === job.job_id) {
|
||||
openId.value = null;
|
||||
return;
|
||||
}
|
||||
openId.value = job.job_id;
|
||||
bench.loadJobDetail(job.job_id).catch(() => {});
|
||||
}
|
||||
|
||||
function statsRows(job) {
|
||||
const s = job?.stats || job || {};
|
||||
return [
|
||||
['stage', s.stage_count],
|
||||
['species', s.species_count],
|
||||
['figure_table', s.figure_table_caption_count],
|
||||
['figure_table_stage', s.figure_table_stage_count],
|
||||
].filter(([, v]) => v != null);
|
||||
}
|
||||
|
||||
async function fetchRunLog() {
|
||||
logError.value = false;
|
||||
try {
|
||||
const data = await getRunLog();
|
||||
runLog.value = data?.text || '';
|
||||
if (logOpen.value) await scrollLogToBottom();
|
||||
} catch {
|
||||
logError.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function scrollLogToBottom() {
|
||||
await nextTick();
|
||||
if (logBox.value) logBox.value.scrollTop = logBox.value.scrollHeight;
|
||||
}
|
||||
|
||||
function toggleLog() {
|
||||
logOpen.value = !logOpen.value;
|
||||
if (logOpen.value && !runLog.value) fetchRunLog();
|
||||
else if (logOpen.value) scrollLogToBottom();
|
||||
}
|
||||
|
||||
// ---- 上传(FR-EXTRACT-004:仅 PDF,单文件 ≤50MB;失败显示明确文案) ----
|
||||
function pickFile() {
|
||||
uploadMsg.value = '';
|
||||
uploadOk.value = false;
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
async function onFileChange(e) {
|
||||
uploadMsg.value = '';
|
||||
uploadOk.value = false;
|
||||
const file = e.target.files?.[0];
|
||||
if (e.target) e.target.value = '';
|
||||
if (!file) return;
|
||||
const isPdf = file.type === 'application/pdf' || /\.pdf$/i.test(file.name || '');
|
||||
if (!isPdf) {
|
||||
uploadMsg.value = T.extractUploadLimit;
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
uploadMsg.value = T.extractUploadLimit;
|
||||
return;
|
||||
}
|
||||
uploading.value = true;
|
||||
try {
|
||||
await uploadPaper(file);
|
||||
uploadOk.value = true;
|
||||
uploadMsg.value = '';
|
||||
await bench.loadJobs({ force: true }).catch(() => {});
|
||||
if (bench.isPolling()) bench.startPolling();
|
||||
} catch (err) {
|
||||
// 上游 message 已中文化,直接展示(PRD §2.2)
|
||||
uploadMsg.value = (T.extractUploadFail || '上传失败:') + (err?.message || err?.error?.message || String(err));
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 轮询生命周期(FR-EXTRACT-005)----
|
||||
onMounted(() => {
|
||||
bench.loadJobs({ force: true })
|
||||
.then(() => {
|
||||
if (!bench.hasActiveJobs) return;
|
||||
if (!bench.isPolling()) bench.startPolling();
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
watch(
|
||||
() => bench.hasActiveJobs,
|
||||
(has) => {
|
||||
if (has && !bench.isPolling() && !bench.pollPaused && bench.activeTab === 'extract') {
|
||||
bench.startPolling();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function retryLoad() {
|
||||
bench.loadJobs({ force: true }).catch(() => {});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="extraction-pane">
|
||||
<!-- 离线降级(FR-EXTRACT-006,仅此 tab 受影响) -->
|
||||
<div v-if="bench.jobsError" class="offline-block">
|
||||
<span class="offline-dot"></span>
|
||||
<span class="offline-text">{{ T.extractionDown }}</span>
|
||||
<button class="retry-btn" @click="retryLoad">{{ T.retry }}</button>
|
||||
</div>
|
||||
|
||||
<!-- 上传入口(FR-EXTRACT-004) -->
|
||||
<div class="upload-row">
|
||||
<button class="upload-btn" :disabled="uploading" @click="pickFile">{{ T.extractUpload }}</button>
|
||||
<span class="upload-limit">{{ T.extractUploadLimit }}</span>
|
||||
<input ref="fileInput" type="file" accept="application/pdf,.pdf" class="hidden-input" @change="onFileChange" />
|
||||
</div>
|
||||
<div v-if="uploading" class="upload-msg">{{ T.loading }}</div>
|
||||
<div v-else-if="uploadMsg" class="upload-msg err">{{ uploadMsg }}</div>
|
||||
<div v-else-if="uploadOk" class="upload-msg ok">{{ '✓' }}</div>
|
||||
|
||||
<!-- 运行日志(FR-EXTRACT-003,mono 滚动贴底) -->
|
||||
<button class="log-toggle" @click="toggleLog">
|
||||
<svg class="log-chev" :class="{ open: logOpen }" width="11" height="11" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M9 18l6-6-6-6" /></svg>
|
||||
<span>{{ BENCH_LABELS.extractRunLog }}</span>
|
||||
</button>
|
||||
<div v-if="logOpen" class="log-wrap">
|
||||
<div v-if="logError" class="offline-text">{{ T.upstreamError }}</div>
|
||||
<div v-else ref="logBox" class="run-log">{{ runLog || T.loading }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 空态 -->
|
||||
<div v-if="!jobs.length && !bench.jobsLoading" class="empty-note">{{ T.extractEmpty }}</div>
|
||||
|
||||
<!-- 任务列表(FR-EXTRACT-001) -->
|
||||
<div v-for="j in jobs" :key="j.job_id" class="job-block">
|
||||
<ProgressItem :job="j" :open="openId === j.job_id" @toggle="toggleJob(j)" />
|
||||
|
||||
<!-- 详情展开(FR-EXTRACT-002) -->
|
||||
<div v-if="openId === j.job_id" class="job-detail fade-slide">
|
||||
<div v-if="detailLoading" class="detail-loading">{{ T.loading }}</div>
|
||||
<div v-else-if="detailError || !detail" class="detail-error">{{ T.upstreamError }}
|
||||
<button class="retry-btn" @click="bench.loadJobDetail(j.job_id).catch(() => {})">{{ T.retry }}</button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="detail-row status-row">
|
||||
<span class="dl-label">{{ T.labelGroup }}</span>
|
||||
<span class="badge" :class="detail.status"><span class="dot"></span>{{ STATUS_LABEL[detail.status] || detail.status }}</span>
|
||||
</div>
|
||||
<div v-if="detail.message" class="detail-row">
|
||||
<span class="dl-label">message</span>
|
||||
<span class="dl-value">{{ detail.message }}</span>
|
||||
</div>
|
||||
<div class="detail-stats">
|
||||
<div v-for="[k, v] in statsRows(detail)" :key="k" class="stat-item">
|
||||
<span class="stat-v">{{ v }}</span>
|
||||
<span class="stat-k">{{ k }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- plan 仅 completed 有(FR-EXTRACT-002:未完成置 null 不显示且不报错) -->
|
||||
<template v-if="detail.plan">
|
||||
<div class="dl-label">plan</div>
|
||||
<div class="plan-code">
|
||||
<pre>{{ JSON.stringify(detail.plan, null, 2) }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.extraction-pane { display: flex; flex-direction: column; }
|
||||
.offline-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 10px;
|
||||
background: rgba(255, 92, 122, 0.08);
|
||||
border: 1px solid rgba(255, 92, 122, 0.35);
|
||||
border-radius: var(--pa-r-md);
|
||||
font-size: 12px;
|
||||
color: var(--pa-error);
|
||||
}
|
||||
.offline-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--pa-error); flex: none; }
|
||||
.offline-text { font-size: 12px; color: var(--pa-error); }
|
||||
.retry-btn {
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11.5px;
|
||||
color: var(--pa-ink-muted);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
}
|
||||
.retry-btn:hover { color: var(--pa-ink); }
|
||||
.upload-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||
.upload-btn {
|
||||
padding: 5px 14px;
|
||||
border-radius: var(--pa-r-md);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: var(--pa-gradient);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.upload-btn:hover { box-shadow: 0 0 24px rgba(0, 212, 255, 0.4); transform: translateY(-1px); }
|
||||
.upload-btn:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none; transform: none; }
|
||||
.upload-limit { font-size: 11px; color: var(--pa-ink-subtle); }
|
||||
.hidden-input { display: none; }
|
||||
.upload-msg { font-size: 11.5px; margin: 4px 0 8px; color: var(--pa-ink-subtle); }
|
||||
.upload-msg.err { color: var(--pa-error); word-break: break-word; }
|
||||
.upload-msg.ok { color: var(--pa-success); }
|
||||
.log-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
align-self: flex-start;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--pa-ink-subtle);
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.log-toggle:hover { color: var(--pa-ink); }
|
||||
.log-chev { transition: transform 0.25s var(--pa-ease); }
|
||||
.log-chev.open { transform: rotate(90deg); }
|
||||
.log-wrap { margin-bottom: 10px; }
|
||||
.run-log {
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.8;
|
||||
color: var(--pa-ink-muted);
|
||||
background: var(--pa-code-bg);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-sm);
|
||||
padding: 8px 10px;
|
||||
max-height: 130px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.empty-note {
|
||||
text-align: center;
|
||||
color: var(--pa-ink-subtle);
|
||||
font-size: 12px;
|
||||
padding: 24px 8px;
|
||||
}
|
||||
.job-block { margin-bottom: 4px; }
|
||||
.job-detail {
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
padding: 10px 12px;
|
||||
margin: -8px 0 14px;
|
||||
}
|
||||
.fade-slide { animation: fade-slide 0.2s var(--pa-ease-out); }
|
||||
.detail-loading, .detail-error { font-size: 12px; color: var(--pa-ink-subtle); padding: 4px 0; }
|
||||
.detail-error { color: var(--pa-error); display: flex; align-items: center; gap: 8px; }
|
||||
.detail-error .retry-btn { margin-left: 0; }
|
||||
.detail-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px; font-size: 12px; }
|
||||
.dl-label { font-size: 11px; letter-spacing: 0.5px; color: var(--pa-ink-subtle); margin: 8px 0 5px; flex: none; }
|
||||
.status-row .dl-label { margin: 0; }
|
||||
.dl-value { color: var(--pa-ink-muted); word-break: break-word; }
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.badge .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.badge.running, .badge.queued { color: var(--pa-warning); background: rgba(245, 184, 92, 0.12); }
|
||||
.badge.running .dot { animation: pulse 2s infinite; }
|
||||
.badge.completed { color: var(--pa-success); background: rgba(46, 211, 160, 0.12); }
|
||||
.badge.failed { color: var(--pa-error); background: rgba(255, 92, 122, 0.12); }
|
||||
.detail-stats { display: flex; gap: 6px; flex-wrap: wrap; margin: 6px 0; }
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
background: var(--pa-surface-2);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-sm);
|
||||
padding: 3px 8px;
|
||||
}
|
||||
.stat-v { font-family: var(--pa-font-mono); font-size: 12.5px; color: var(--pa-ink); }
|
||||
.stat-k { font-family: var(--pa-font-mono); font-size: 10.5px; color: var(--pa-ink-subtle); }
|
||||
.plan-code {
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
color: var(--pa-ink-muted);
|
||||
background: var(--pa-code-bg);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-sm);
|
||||
padding: 8px 10px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.plan-code pre { margin: 0; white-space: pre-wrap; word-break: break-all; }
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<!-- KgEntityDetail.vue —— 实体详情侧滑面板(B3 归属)
|
||||
FR-KG-003:点击节点 → 侧滑详情面板展示实体属性;Neo4j 可用时调 GET /api/kg/entities/{name}
|
||||
拉全量属性;不可用时仅显示消息内已有属性。 -->
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { getEntity } from '../../api/kg.js';
|
||||
import { T } from '../../i18n/labels.js';
|
||||
import { BENCH_LABELS } from './benchLabels.js';
|
||||
|
||||
const props = defineProps({
|
||||
node: { type: Object, default: null }, // {id,label,type} 或消息 graph 节点
|
||||
});
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const loading = ref(false);
|
||||
const failed = ref(false);
|
||||
const extra = ref(null); // Neo4j 全量属性
|
||||
|
||||
const localProps = computed(() => {
|
||||
const n = props.node || {};
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(n)) {
|
||||
if (k === 'id' || k === 'label' || k === 'type') continue;
|
||||
if (v == null || v === '') continue;
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const propEntries = computed(() => {
|
||||
const merged = { ...localProps.value, ...(extra.value?.properties || {}) };
|
||||
return Object.entries(merged).filter(([, v]) => v != null && v !== '');
|
||||
});
|
||||
|
||||
const nodeKey = computed(() => (props.node?.id != null ? String(props.node.id) : props.node?.label));
|
||||
|
||||
onMounted(() => {
|
||||
fetchExtra();
|
||||
});
|
||||
watch(nodeKey, () => {
|
||||
extra.value = null;
|
||||
failed.value = false;
|
||||
fetchExtra();
|
||||
});
|
||||
|
||||
async function fetchExtra() {
|
||||
const n = props.node;
|
||||
if (!n) return;
|
||||
const key = n.id != null ? String(n.id) : n.label;
|
||||
if (!key) return;
|
||||
loading.value = true;
|
||||
failed.value = false;
|
||||
try {
|
||||
const data = await getEntity(key);
|
||||
extra.value = data;
|
||||
} catch {
|
||||
failed.value = true; // Neo4j 不可用:仍显示消息内已有属性(FR-KG-003)
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(v) {
|
||||
if (v && typeof v === 'object') {
|
||||
try { return JSON.stringify(v); } catch { return String(v); }
|
||||
}
|
||||
return String(v);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kg-entity-detail">
|
||||
<div class="detail-head">
|
||||
<span class="detail-title">{{ T.kgEntityDetail }}</span>
|
||||
<button class="close-btn" type="button" :aria-label="BENCH_LABELS.close" @click="emit('close')">✕</button>
|
||||
</div>
|
||||
<div v-if="node" class="detail-body">
|
||||
<div class="ent-name">{{ node.label || node.id }}</div>
|
||||
<div class="ent-meta">
|
||||
<span class="ent-type">{{ node.type }}</span>
|
||||
<span v-if="node.id && node.id !== node.label" class="ent-id">{{ node.id }}</span>
|
||||
</div>
|
||||
<div v-if="loading" class="detail-note">{{ T.loading }}</div>
|
||||
<div class="prop-list">
|
||||
<div v-for="[k, v] in propEntries" :key="k" class="prop-row">
|
||||
<span class="prop-k">{{ k }}</span>
|
||||
<span class="prop-v">{{ fmt(v) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!propEntries.length && !loading" class="detail-note">—</div>
|
||||
</div>
|
||||
<div v-else class="detail-body">
|
||||
<div class="detail-note">{{ T.upstreamError }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kg-entity-detail {
|
||||
position: fixed;
|
||||
top: var(--topbar-h);
|
||||
right: 0;
|
||||
bottom: var(--statusbar-h);
|
||||
width: 300px;
|
||||
max-width: 90vw;
|
||||
background: var(--pa-surface-1);
|
||||
border-left: 1px solid var(--pa-hairline-strong);
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slide-in 0.3s var(--pa-ease-out);
|
||||
box-shadow: -8px 0 24px rgba(10, 10, 16, 0.35);
|
||||
}
|
||||
@keyframes slide-in {
|
||||
from { transform: translateX(24px); opacity: 0; }
|
||||
to { transform: none; opacity: 1; }
|
||||
}
|
||||
.detail-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--pa-hairline);
|
||||
}
|
||||
.detail-title { font-size: 12px; font-weight: 600; }
|
||||
.close-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--pa-ink-subtle);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
border-radius: var(--pa-r-sm);
|
||||
}
|
||||
.close-btn:hover { color: var(--pa-ink); background: var(--pa-surface-2); }
|
||||
.detail-body { flex: 1; overflow-y: auto; padding: 14px; }
|
||||
.ent-name {
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.ent-meta { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; }
|
||||
.ent-type {
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11px;
|
||||
background: var(--pa-surface-3);
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.ent-id { font-family: var(--pa-font-mono); font-size: 10.5px; color: var(--pa-ink-subtle); }
|
||||
.detail-note { font-size: 12px; color: var(--pa-ink-subtle); padding: 8px 0; }
|
||||
.prop-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.prop-row { display: flex; gap: 8px; align-items: baseline; font-size: 12px; line-height: 1.5; }
|
||||
.prop-k {
|
||||
flex: none;
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--pa-g-to);
|
||||
background: var(--pa-code-bg);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-sm);
|
||||
padding: 1px 6px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.prop-v { color: var(--pa-ink-muted); word-break: break-all; }
|
||||
</style>
|
||||
@@ -0,0 +1,274 @@
|
||||
<!-- KgGraphView.vue —— 手写 SVG 力导向图谱渲染器(B3 归属)
|
||||
FR-KG-001/002/006:节点圆点+标签、渐变边+关系名、type→稳定调色板、图例;
|
||||
缩放 0.3–3×、拖拽平移、双击复位、触摸双指缩放/单指拖拽。
|
||||
节点数超过上限时由 KgPane 决定降级列表视图(本组件仍可渲染,由父层把控)。 -->
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { forceLayout, buildPalette } from './graphLayout.js';
|
||||
import { BENCH_LABELS } from './benchLabels.js';
|
||||
|
||||
const props = defineProps({
|
||||
nodes: { type: Array, default: () => [] },
|
||||
edges: { type: Array, default: () => [] },
|
||||
height: { type: Number, default: 320 },
|
||||
});
|
||||
const emit = defineEmits(['node-click']);
|
||||
|
||||
const viewW = ref(800);
|
||||
const viewH = ref(600);
|
||||
const scale = ref(1);
|
||||
const tx = ref(0);
|
||||
const ty = ref(0);
|
||||
const container = ref(null);
|
||||
|
||||
const layout = computed(() =>
|
||||
forceLayout({ nodes: props.nodes, edges: props.edges }, { width: viewW.value, height: viewH.value }),
|
||||
);
|
||||
const colorOf = computed(() => {
|
||||
const types = (props.nodes || []).map((n) => n?.type || 'unknown');
|
||||
return buildPalette(types);
|
||||
});
|
||||
const renderEdges = computed(() => layout.value.edges);
|
||||
const renderNodes = computed(() => layout.value.nodes);
|
||||
const nodeById = computed(() => {
|
||||
const m = new Map();
|
||||
for (const n of props.nodes || []) m.set(String(n.id), n);
|
||||
return m;
|
||||
});
|
||||
const viewBox = computed(() => `0 0 ${viewW.value} ${viewH.value}`);
|
||||
|
||||
// 图例:type → 颜色(按名称稳定排序)
|
||||
const legendItems = computed(() => {
|
||||
const counts = new Map();
|
||||
for (const n of props.nodes || []) {
|
||||
const t = n.type || 'unknown';
|
||||
counts.set(t, (counts.get(t) || 0) + 1);
|
||||
}
|
||||
return [...counts.keys()].sort().map((t) => ({ type: t, color: colorOf.value.get(t), count: counts.get(t) }));
|
||||
});
|
||||
|
||||
const transform = computed(() => `translate(${tx.value}px, ${ty.value}px) scale(${scale.value})`);
|
||||
|
||||
const clampScale = (v) => Math.min(3, Math.max(0.3, v));
|
||||
|
||||
// ---- 手势(指针事件统一处理鼠标与触摸)----
|
||||
const pointers = new Map();
|
||||
const pan = ref(null); // {x, y, tx, ty}
|
||||
|
||||
function setTransform(ns, nx, ny) {
|
||||
scale.value = clampScale(ns);
|
||||
tx.value = nx;
|
||||
ty.value = ny;
|
||||
}
|
||||
|
||||
function onPointerDown(e) {
|
||||
container.value?.setPointerCapture?.(e.pointerId);
|
||||
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (pointers.size === 2) {
|
||||
// 双指开始:记录初始距离与变换
|
||||
const [p1, p2] = [...pointers.values()];
|
||||
pan.value = {
|
||||
x: (p1.x + p2.x) / 2,
|
||||
y: (p1.y + p2.y) / 2,
|
||||
tx: tx.value,
|
||||
ty: ty.value,
|
||||
dist: Math.hypot(p1.x - p2.x, p1.y - p2.y),
|
||||
scale: scale.value,
|
||||
};
|
||||
} else if (pointers.size === 1) {
|
||||
pan.value = { x: e.clientX, y: e.clientY, tx: tx.value, ty: ty.value, dist: null, scale: scale.value };
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerMove(e) {
|
||||
if (!pointers.has(e.pointerId)) return;
|
||||
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (pointers.size >= 2) {
|
||||
// 双指:缩放 + 平移(FR-KG-006)
|
||||
const [p1, p2] = [...pointers.values()];
|
||||
const dist = Math.hypot(p1.x - p2.x, p1.y - p2.y);
|
||||
if (pan.value && pan.value.dist != null && pan.value.dist > 0) {
|
||||
const ns = clampScale(pan.value.scale * (dist / pan.value.dist));
|
||||
const cx = (p1.x + p2.x) / 2;
|
||||
const cy = (p1.y + p2.y) / 2;
|
||||
const nx = pan.value.tx + (cx - pan.value.x);
|
||||
const ny = pan.value.ty + (cy - pan.value.y);
|
||||
setTransform(ns, nx, ny);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pointers.size === 1 && pan.value) {
|
||||
tx.value = pan.value.tx + (e.clientX - pan.value.x);
|
||||
ty.value = pan.value.ty + (e.clientY - pan.value.y);
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e) {
|
||||
pointers.delete(e.pointerId);
|
||||
if (pointers.size === 1) {
|
||||
const [p] = [...pointers.values()];
|
||||
pan.value = { x: p.x, y: p.y, tx: tx.value, ty: ty.value, dist: null, scale: scale.value };
|
||||
} else if (pointers.size === 0) {
|
||||
pan.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 滚轮缩放(以鼠标位置为锚点)
|
||||
function onWheel(e) {
|
||||
e.preventDefault();
|
||||
const rect = container.value?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
||||
const ns = clampScale(scale.value * factor);
|
||||
const px = e.clientX - rect.left;
|
||||
const py = e.clientY - rect.top;
|
||||
const nx = px - ((px - tx.value) * ns) / scale.value;
|
||||
const ny = py - ((py - ty.value) * ns) / scale.value;
|
||||
setTransform(ns, nx, ny);
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
setTransform(1, 0, 0);
|
||||
}
|
||||
|
||||
function onClickNode(n) {
|
||||
emit('node-click', nodeById.value.get(n.id) || { id: n.id, label: n.label, type: n.type });
|
||||
}
|
||||
|
||||
// 挂载时测量容器宽度以设定视口宽
|
||||
let ro = null;
|
||||
onMounted(() => {
|
||||
const el = container.value;
|
||||
if (!el) return;
|
||||
const apply = () => {
|
||||
const w = el.clientWidth || 348;
|
||||
viewW.value = Math.max(320, Math.round(w * 2));
|
||||
viewH.value = Math.max(300, Math.round(props.height * 2));
|
||||
};
|
||||
apply();
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
ro = new ResizeObserver(apply);
|
||||
ro.observe(el);
|
||||
}
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
ro?.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kg-graph">
|
||||
<div
|
||||
ref="container"
|
||||
class="graph-canvas"
|
||||
:style="{ height: height + 'px' }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerUp"
|
||||
@wheel.prevent="onWheel"
|
||||
@dblclick="resetView"
|
||||
>
|
||||
<svg class="graph-svg" :viewBox="viewBox" preserveAspectRatio="xMidYMid meet">
|
||||
<defs>
|
||||
<linearGradient id="pa-edge-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#5E6AD2" />
|
||||
<stop offset="100%" stop-color="#00D4FF" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g :style="{ transform: transform, transformOrigin: '0 0' }">
|
||||
<g v-for="(e, i) in renderEdges" :key="'e' + i" class="edge-g">
|
||||
<line
|
||||
:x1="renderNodes.find(n => n.id === e.source)?.x ?? 0"
|
||||
:y1="renderNodes.find(n => n.id === e.source)?.y ?? 0"
|
||||
:x2="renderNodes.find(n => n.id === e.target)?.x ?? 0"
|
||||
:y2="renderNodes.find(n => n.id === e.target)?.y ?? 0"
|
||||
stroke="url(#pa-edge-grad)"
|
||||
stroke-width="1.2"
|
||||
stroke-opacity="0.65"
|
||||
/>
|
||||
<text
|
||||
v-if="e.relation"
|
||||
class="edge-label"
|
||||
:x="((renderNodes.find(n => n.id === e.source)?.x ?? 0) + (renderNodes.find(n => n.id === e.target)?.x ?? 0)) / 2"
|
||||
:y="((renderNodes.find(n => n.id === e.source)?.y ?? 0) + (renderNodes.find(n => n.id === e.target)?.y ?? 0)) / 2"
|
||||
>{{ e.relation }}</text>
|
||||
</g>
|
||||
<g v-for="n in renderNodes" :key="'n' + n.id" class="node-g" @click="onClickNode(n)">
|
||||
<circle :cx="n.x" :cy="n.y" :r="7" :fill="colorOf.get(n.type)" stroke="var(--pa-surface-1)" stroke-width="1.5" />
|
||||
<text :x="n.x" :y="n.y + 3.2" text-anchor="middle" class="node-label">{{ n.label }}</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="graph-tools">
|
||||
<button class="reset-btn" type="button" :title="BENCH_LABELS.resetView" @click="resetView">⟲</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="legendItems.length" class="kg-legend">
|
||||
<span v-for="l in legendItems" :key="l.type" class="lg-item">
|
||||
<span class="lg-dot" :style="{ background: l.color }"></span>
|
||||
<span class="lg-type">{{ l.type }}</span>
|
||||
<span class="lg-count">{{ l.count }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kg-graph { display: flex; flex-direction: column; }
|
||||
.graph-canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-lg);
|
||||
background: var(--pa-code-bg);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
.graph-canvas:active { cursor: grabbing; }
|
||||
.graph-svg { width: 100%; height: 100%; display: block; }
|
||||
.graph-svg :deep(g) { transition: transform 0.08s linear; }
|
||||
.node-g { cursor: pointer; }
|
||||
.node-g circle { transition: fill 0.15s var(--pa-ease); }
|
||||
.node-g:hover circle { stroke: var(--pa-ink); stroke-width: 2; }
|
||||
.node-label {
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 11px;
|
||||
fill: var(--pa-ink);
|
||||
paint-order: stroke;
|
||||
stroke: var(--pa-code-bg);
|
||||
stroke-width: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.edge-label {
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 9px;
|
||||
fill: var(--pa-ink-subtle);
|
||||
paint-order: stroke;
|
||||
stroke: var(--pa-code-bg);
|
||||
stroke-width: 2.5px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.graph-tools { position: absolute; top: 8px; right: 8px; display: flex; gap: 6px; }
|
||||
.reset-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: var(--pa-r-sm);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
background: var(--pa-surface-1);
|
||||
color: var(--pa-ink-muted);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.reset-btn:hover { color: var(--pa-ink); background: var(--pa-surface-2); }
|
||||
.kg-legend { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 8px; }
|
||||
.lg-item { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; color: var(--pa-ink-muted); }
|
||||
.lg-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
|
||||
.lg-type { font-family: var(--pa-font-ui); }
|
||||
.lg-count { font-family: var(--pa-font-mono); color: var(--pa-ink-subtle); font-size: 10px; }
|
||||
</style>
|
||||
@@ -0,0 +1,343 @@
|
||||
<!-- KgPane.vue —— 工作台 Tab4 知识图谱(B3 归属)
|
||||
FR-KG-001~008:答案图谱渲染(graph 块)、节点着色图例、节点详情侧滑、实体搜索、
|
||||
邻域深度 1/2、离线降级(答案图谱仍可用)、空态、>50 节点(移动端 >30)降级列表视图。 -->
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useBenchStore } from '../../stores/bench.js';
|
||||
import { searchEntities, getNeighborhood } from '../../api/kg.js';
|
||||
import { T } from '../../i18n/labels.js';
|
||||
import { BENCH_LABELS } from './benchLabels.js';
|
||||
import KgGraphView from './KgGraphView.vue';
|
||||
import KgEntityDetail from './KgEntityDetail.vue';
|
||||
|
||||
const bench = useBenchStore();
|
||||
|
||||
const query = ref('');
|
||||
const results = ref([]);
|
||||
const searching = ref(false);
|
||||
const searchError = ref(false);
|
||||
const detailNode = ref(null);
|
||||
const graphData = ref(null); // 当前渲染的 {nodes, edges}(答案图谱或邻域结果)
|
||||
const graphSource = ref(null); // 'answer' | 'neighborhood'
|
||||
const listModeForced = ref(false);
|
||||
|
||||
const isMobile = () => (typeof window !== 'undefined' ? window.innerWidth < 768 : false);
|
||||
|
||||
// 答案图谱(永远可用,FR-KG-007)
|
||||
const answerGraph = computed(() => {
|
||||
const g = bench.currentGraph;
|
||||
if (!g) return null;
|
||||
const nodes = Array.isArray(g.nodes) ? g.nodes : [];
|
||||
const edges = Array.isArray(g.edges) ? g.edges : [];
|
||||
return nodes.length ? { nodes, edges } : null;
|
||||
});
|
||||
|
||||
// 节点上限:桌面 50 / 移动端 30(FR-KG-001、FR-RESP-012)
|
||||
const nodeLimit = computed(() => bench.kgNodeLimit || 50);
|
||||
const isDegradedList = computed(() => {
|
||||
if (listModeForced.value) return true;
|
||||
const g = graphData.value;
|
||||
return !!g && g.nodes.length > nodeLimit.value;
|
||||
});
|
||||
|
||||
const depth = ref(1);
|
||||
const neighborhoodMeta = ref(null);
|
||||
|
||||
const itemsWithEdges = computed(() => {
|
||||
const g = graphData.value;
|
||||
if (!g) return [];
|
||||
const edges = g.edges || [];
|
||||
return g.nodes.map((n) => {
|
||||
const related = edges.filter((e) => e.source === n.id || e.target === n.id);
|
||||
return { ...n, edgeCount: related.length, relations: [...new Set(related.map((e) => e.relation || e.label))] };
|
||||
});
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (isMobile()) bench.setKgNodeLimit(30);
|
||||
// 答案图谱为空时,若 Neo4j 可用可自由浏览;状态探测放 kg tab 激活时
|
||||
if (bench.kgStatus == null) {
|
||||
bench.checkKgStatus().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// 切会话/切消息后重置浏览态(答案图谱跟随变化)
|
||||
watch(answerGraph, (g) => {
|
||||
if (g) {
|
||||
graphData.value = g;
|
||||
graphSource.value = 'answer';
|
||||
listModeForced.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 实体搜索(FR-KG-004,精确/前缀匹配,无全文索引)----
|
||||
async function onSearch() {
|
||||
const q = query.value.trim();
|
||||
if (!q) return;
|
||||
if (!bench.kgReady) {
|
||||
searchError.value = true;
|
||||
return;
|
||||
}
|
||||
searching.value = true;
|
||||
searchError.value = false;
|
||||
try {
|
||||
const data = await searchEntities(q, 20);
|
||||
results.value = data?.results || [];
|
||||
} catch {
|
||||
searchError.value = true;
|
||||
} finally {
|
||||
searching.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 邻域展开(FR-KG-005,depth 1/2 默认 1;两跳约 13s 需提示较慢)----
|
||||
const loadingNeighborhood = ref(false);
|
||||
const neighborhoodError = ref(false);
|
||||
const slowNotice = ref(false);
|
||||
|
||||
async function loadNeighborhood(id, name) {
|
||||
if (!bench.kgReady) {
|
||||
neighborhoodError.value = true;
|
||||
return;
|
||||
}
|
||||
loadingNeighborhood.value = true;
|
||||
neighborhoodError.value = false;
|
||||
slowNotice.value = false;
|
||||
try {
|
||||
const data = await getNeighborhood(id, { depth: depth.value, limit: 50 });
|
||||
graphData.value = { nodes: data.nodes || [], edges: data.edges || [] };
|
||||
graphSource.value = 'neighborhood';
|
||||
neighborhoodMeta.value = data;
|
||||
results.value = [];
|
||||
slowNotice.value = depth.value === 2;
|
||||
} catch {
|
||||
neighborhoodError.value = true;
|
||||
} finally {
|
||||
loadingNeighborhood.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectResult(r) {
|
||||
detailNode.value = null;
|
||||
loadNeighborhood(r.id, r.name);
|
||||
}
|
||||
|
||||
function onNodeClick(n) {
|
||||
detailNode.value = n;
|
||||
}
|
||||
|
||||
function onDepthChange(v) {
|
||||
depth.value = v;
|
||||
const meta = neighborhoodMeta.value;
|
||||
if (meta?.center) loadNeighborhood(meta.center, meta.center_name);
|
||||
}
|
||||
|
||||
// 离线态:Neo4j 不可用 → 搜索与邻域禁用(FR-KG-007)
|
||||
const kgDown = computed(() => !bench.kgReady && bench.kgStatus != null);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kg-pane">
|
||||
<!-- 离线降级(FR-KG-007:答案图谱仍可用) -->
|
||||
<div v-if="kgDown" class="offline-block">
|
||||
<span class="offline-dot"></span>
|
||||
<span class="offline-text">{{ T.kgDown }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 答案图谱 / 邻域结果 -->
|
||||
<template v-if="graphData && graphData.nodes.length">
|
||||
<div v-if="isDegradedList" class="degrade-note">
|
||||
{{ BENCH_LABELS.kgDegradedListPrefix }} {{ nodeLimit }}{{ BENCH_LABELS.kgDegradedListSuffix }}
|
||||
</div>
|
||||
<template v-if="isDegradedList">
|
||||
<div class="node-list">
|
||||
<div v-for="n in itemsWithEdges" :key="n.id" class="node-list-item" @click="onNodeClick(n)">
|
||||
<span class="nl-name">{{ n.label }}</span>
|
||||
<span class="nl-meta">
|
||||
<span class="nl-type">{{ n.type }}</span>
|
||||
<span v-if="n.edgeCount" class="nl-edges">{{ n.edgeCount }} {{ BENCH_LABELS.kgRelationUnit }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<KgGraphView :nodes="graphData.nodes" :edges="graphData.edges" :height="320" @node-click="onNodeClick" />
|
||||
</template>
|
||||
<div v-if="graphSource === 'neighborhood'" class="nb-meta">
|
||||
<span class="nb-center">{{ neighborhoodMeta?.center_name }}</span>
|
||||
<span class="nb-count">{{ graphData.nodes.length }} {{ BENCH_LABELS.kgNodeUnit }} · {{ (graphData.edges || []).length }} {{ BENCH_LABELS.kgEdgeUnit }}</span>
|
||||
<span v-if="slowNotice" class="nb-slow">{{ BENCH_LABELS.kgDepth2NeighborhoodSlow }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 空态(FR-KG-008,不显示空画布) -->
|
||||
<div v-else-if="!loadingNeighborhood" class="empty-note">{{ T.kgEmpty }}</div>
|
||||
|
||||
<!-- 自由浏览区(FR-KG-004/005) -->
|
||||
<div class="kg-browse">
|
||||
<div class="search-row">
|
||||
<input
|
||||
v-model="query"
|
||||
class="search-box"
|
||||
type="text"
|
||||
:placeholder="T.kgSearchPlaceholder"
|
||||
:disabled="kgDown"
|
||||
@keydown.enter="onSearch"
|
||||
/>
|
||||
<button class="search-btn" :disabled="kgDown || searching" @click="onSearch">
|
||||
{{ searching ? T.loading : BENCH_LABELS.kgSearch }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="searchError" class="offline-text">{{ T.kgDown }}</div>
|
||||
<div v-if="results.length" class="search-results">
|
||||
<div v-for="r in results" :key="r.id" class="result-item" @click="onSelectResult(r)">
|
||||
<span class="rs-name">{{ r.name }}</span>
|
||||
<span class="rs-meta">{{ r.type }} · {{ r.support_count ?? 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="neighborhoodError" class="offline-text">{{ T.kgDown }}</div>
|
||||
|
||||
<!-- 邻域深度选择(默认 1,FR-KG-005) -->
|
||||
<div v-if="neighborhoodMeta" class="depth-row">
|
||||
<span class="depth-label">{{ T.kgDepth }}</span>
|
||||
<button :class="['chip', { on: depth === 1 }]" @click="onDepthChange(1)">1</button>
|
||||
<button :class="['chip', { on: depth === 2 }]" @click="onDepthChange(2)">2</button>
|
||||
<span class="depth-hint">{{ BENCH_LABELS.kgDepth2SlowHint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 实体详情侧滑(FR-KG-003) -->
|
||||
<KgEntityDetail v-if="detailNode" :node="detailNode" @close="detailNode = null" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kg-pane { display: flex; flex-direction: column; gap: 10px; }
|
||||
.offline-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 92, 122, 0.08);
|
||||
border: 1px solid rgba(255, 92, 122, 0.35);
|
||||
border-radius: var(--pa-r-md);
|
||||
font-size: 12px;
|
||||
color: var(--pa-error);
|
||||
}
|
||||
.offline-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--pa-error); flex: none; }
|
||||
.offline-text { font-size: 12px; color: var(--pa-error); }
|
||||
.degrade-note { font-size: 11.5px; color: var(--pa-ink-subtle); }
|
||||
.node-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
}
|
||||
.node-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s var(--pa-ease);
|
||||
}
|
||||
.node-list-item:hover { background: var(--pa-surface-2); }
|
||||
.nl-name {
|
||||
font-family: var(--pa-font-mono);
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.nl-meta { display: flex; gap: 6px; align-items: center; flex: none; }
|
||||
.nl-type { font-size: 10.5px; color: var(--pa-ink-subtle); }
|
||||
.nl-edges { font-family: var(--pa-font-mono); font-size: 10.5px; color: var(--pa-ink-muted); }
|
||||
.empty-note {
|
||||
text-align: center;
|
||||
color: var(--pa-ink-subtle);
|
||||
font-size: 12px;
|
||||
padding: 24px 8px;
|
||||
}
|
||||
.kg-browse { display: flex; flex-direction: column; gap: 8px; }
|
||||
.search-row { display: flex; gap: 6px; }
|
||||
.search-box {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--pa-r-md);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
background: var(--pa-surface-2);
|
||||
color: var(--pa-ink);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.search-box:focus { outline: none; border-color: var(--pa-g-to); box-shadow: 0 0 14px rgba(0, 212, 255, 0.18); }
|
||||
.search-box:disabled { opacity: 0.5; }
|
||||
.search-btn {
|
||||
flex: none;
|
||||
padding: 0 14px;
|
||||
height: 34px;
|
||||
border-radius: var(--pa-r-md);
|
||||
border: none;
|
||||
background: var(--pa-gradient);
|
||||
color: #fff;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.search-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.search-results {
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s var(--pa-ease);
|
||||
}
|
||||
.result-item:hover { background: var(--pa-surface-2); }
|
||||
.rs-name {
|
||||
font-family: var(--pa-font-mono);
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.rs-meta { flex: none; font-family: var(--pa-font-mono); font-size: 10.5px; color: var(--pa-ink-subtle); }
|
||||
.depth-row { display: flex; align-items: center; gap: 6px; }
|
||||
.depth-label { font-size: 11.5px; color: var(--pa-ink-subtle); margin-right: 2px; }
|
||||
.chip {
|
||||
width: 26px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11.5px;
|
||||
color: var(--pa-ink-muted);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s var(--pa-ease);
|
||||
}
|
||||
.chip:hover { color: var(--pa-ink); }
|
||||
.chip.on { color: #fff; background: var(--pa-g-from); border-color: transparent; }
|
||||
.depth-hint { font-size: 10.5px; color: var(--pa-ink-subtle); }
|
||||
.nb-meta { display: flex; gap: 8px; align-items: baseline; font-size: 11px; color: var(--pa-ink-subtle); }
|
||||
.nb-center { font-family: var(--pa-font-mono); color: var(--pa-ink-muted); }
|
||||
.nb-count { font-family: var(--pa-font-mono); }
|
||||
.nb-slow { color: var(--pa-warning); }
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<!-- ProgressItem.vue —— 提取任务进度条(B3 归属)
|
||||
FR-EXTRACT-001:prog-item 视觉——任务名 + mono 百分比 + 6px 轨道 + 渐变填充 + sweep 扫光;
|
||||
100% 转 success 停扫光;failed 显示错误态不显示进度条。 -->
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { STATUS_LABEL } from '../../i18n/labels.js';
|
||||
|
||||
const props = defineProps({
|
||||
job: { type: Object, required: true },
|
||||
open: { type: Boolean, default: false },
|
||||
});
|
||||
const emit = defineEmits(['toggle']);
|
||||
|
||||
const pct = computed(() => props.job?.progress_pct);
|
||||
const isFailed = computed(() => props.job?.status === 'failed');
|
||||
const isFull = computed(() => pct.value != null && pct.value >= 100);
|
||||
|
||||
function toggle() {
|
||||
emit('toggle');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prog-item" :class="{ failed: isFailed, clickable: true }">
|
||||
<div class="pi-head" role="button" tabindex="0" @click="toggle" @keydown.enter="toggle" @keydown.space.prevent="toggle">
|
||||
<span class="pi-name" :title="job.title">{{ job.title }}</span>
|
||||
<span v-if="isFailed" class="pi-pct err">{{ STATUS_LABEL.error }}</span>
|
||||
<span v-else-if="pct != null" class="pi-pct" :class="{ ok: isFull }">{{ pct }}%</span>
|
||||
</div>
|
||||
<div v-if="!isFailed && pct != null" class="prog-track">
|
||||
<div class="prog-fill" :class="{ full: isFull }" :style="{ width: Math.min(100, Math.max(0, pct)) + '%' }"></div>
|
||||
</div>
|
||||
<div v-if="isFailed && job.message" class="pi-errmsg">{{ job.message }}</div>
|
||||
<div v-if="job.message && !isFailed" class="pi-msg">{{ job.message }}</div>
|
||||
<div v-if="open" class="pi-chev-row">
|
||||
<svg class="pi-chev open" width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M9 18l6-6-6-6" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.prog-item { margin-bottom: 16px; }
|
||||
.prog-item.failed { padding: 8px 10px; border: 1px solid var(--pa-hairline); border-radius: var(--pa-r-md); background: var(--pa-surface-1); }
|
||||
.pi-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pi-head:focus-visible { outline: 2px solid rgba(94, 106, 210, 0.5); outline-offset: 1px; }
|
||||
.pi-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.pi-pct { font-family: var(--pa-font-mono); font-size: 12px; color: var(--pa-ink-muted); flex: none; }
|
||||
.pi-pct.ok { color: var(--pa-success); }
|
||||
.pi-pct.err { color: var(--pa-error); font-weight: 600; }
|
||||
.prog-track { height: 6px; border-radius: 3px; background: var(--pa-surface-3); overflow: hidden; position: relative; }
|
||||
.prog-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--pa-gradient);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: width 0.5s var(--pa-ease);
|
||||
}
|
||||
.prog-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 200%;
|
||||
background: linear-gradient(100deg, transparent 20%, rgba(255, 255, 255, 0.45) 50%, transparent 80%);
|
||||
will-change: transform;
|
||||
animation: sweep 2.4s infinite;
|
||||
}
|
||||
.prog-fill.full { background: var(--pa-success); }
|
||||
.prog-fill.full::after { animation: none; }
|
||||
.pi-msg { font-size: 11.5px; color: var(--pa-ink-subtle); margin-top: 5px; }
|
||||
.pi-errmsg {
|
||||
font-size: 11.5px;
|
||||
color: var(--pa-error);
|
||||
margin-top: 6px;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,278 @@
|
||||
<!-- RetrievalPane.vue —— 工作台 Tab1 检索过程(B3 归属)
|
||||
FR-RETRIEVAL-001~006:阶段卡列表(每 SSE progress 事件一张)、相位筛选 chips、
|
||||
retrieval_plan 信息块、运行中 shimmer、空态。 -->
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { getActivePinia } from 'pinia';
|
||||
import { useBenchStore } from '../../stores/bench.js';
|
||||
import { TOOL_FILTERS, PHASE_LABEL, PHASE_COLOR_VAR, PHASE_DESC, T } from '../../i18n/labels.js';
|
||||
import { BENCH_LABELS } from './benchLabels.js';
|
||||
|
||||
const bench = useBenchStore();
|
||||
|
||||
const phaseIcons = { planning: '◇', retrieval: '⌕', evidence: '⚖', generation: '✎', done: '✓' };
|
||||
|
||||
/** 流是否已终态(done/error/停止)——终态后所有阶段卡必须收口为 done,
|
||||
* 否则非 done 相位的卡片会永远停留在「运行中」+ shimmer(2026-08-25 修复)。
|
||||
* 只认 completed 事件收口是不够的:上游 completed 只影响它自己那张卡。 */
|
||||
const streamFinished = computed(() => {
|
||||
const msg = bench.currentAssistantMessage;
|
||||
if (!msg) return false;
|
||||
try {
|
||||
const chat = getActivePinia()?._s.get('chat');
|
||||
return !!chat && !chat.busy && msg.status !== 'streaming';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// 每张卡的状态:流已终态 → 全部 done;否则仅完成相位是 done,其余 running(FR-RETRIEVAL-004)
|
||||
const cardStates = computed(() => {
|
||||
const byStage = new Map();
|
||||
const order = [];
|
||||
for (const e of bench.stageEvents) {
|
||||
if (!byStage.has(e.stage)) {
|
||||
byStage.set(e.stage, { count: 0, last: e, first: e });
|
||||
order.push(e.stage);
|
||||
}
|
||||
const g = byStage.get(e.stage);
|
||||
g.count += 1;
|
||||
g.last = e;
|
||||
}
|
||||
const isDone = (g) => streamFinished.value || g.last.phase === 'done';
|
||||
return order.map((stage) => {
|
||||
const g = byStage.get(stage);
|
||||
return { stage, group: g, status: isDone(g) ? 'done' : 'running' };
|
||||
});
|
||||
});
|
||||
|
||||
const filtered = computed(() => {
|
||||
const f = bench.phaseFilter;
|
||||
return f === 'all'
|
||||
? cardStates.value
|
||||
: cardStates.value.filter((c) => c.group.last.phase === f);
|
||||
});
|
||||
|
||||
const plan = computed(() => {
|
||||
const msg = bench.currentAssistantMessage;
|
||||
if (!msg) return null;
|
||||
return msg.retrieval_plan || msg.raw?.retrieval_plan || null;
|
||||
});
|
||||
|
||||
// 展开状态本地管理:运行中的卡默认展开,完成后自动折叠(FR-RETRIEVAL-003)
|
||||
const open = ref({});
|
||||
function isOpen(stage) {
|
||||
if (stage in open.value) return open.value[stage];
|
||||
const c = cardStates.value.find((x) => x.stage === stage);
|
||||
return c ? c.status === 'running' : false;
|
||||
}
|
||||
function toggle(stage) {
|
||||
open.value = { ...open.value, [stage]: !isOpen(stage) };
|
||||
}
|
||||
|
||||
// 耗时 mono(mm:ss 或 s,未完成带 +)
|
||||
function fmtDur(ms) {
|
||||
if (!ms || !isFinite(ms) || ms <= 0) return '';
|
||||
const s = Math.round(ms / 1000);
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
const base = m > 0 ? `${m}m${String(r).padStart(2, '0')}s` : `${r}s`;
|
||||
return base;
|
||||
}
|
||||
|
||||
function detailLines(details) {
|
||||
if (!details) return [];
|
||||
return Object.entries(details).map(([k, v]) => {
|
||||
let val = v;
|
||||
if (Array.isArray(v)) val = v.join(', ');
|
||||
else if (v && typeof v === 'object') {
|
||||
try { val = JSON.stringify(v); } catch { val = String(v); }
|
||||
}
|
||||
return `${k}: ${val}`;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="retrieval-pane">
|
||||
<!-- 检索计划信息块(FR-RETRIEVAL-005,无则整块不显示) -->
|
||||
<div v-if="plan" class="preview-block plan-block">
|
||||
<div class="pb-title">{{ T.retrievalPlanTitle }}</div>
|
||||
<div class="plan-rows">
|
||||
<div v-for="(v, k) in plan" :key="k" class="plan-row">
|
||||
<span class="plan-k">{{ k }}</span>
|
||||
<span class="plan-v">{{ Array.isArray(v) ? v.join('、') : String(v) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 相位筛选 chips(FR-RETRIEVAL-002) -->
|
||||
<div class="filter-chips">
|
||||
<button
|
||||
v-for="f in TOOL_FILTERS"
|
||||
:key="f.key"
|
||||
:class="['chip', { on: bench.phaseFilter === f.key }]"
|
||||
@click="bench.setPhaseFilter(f.key)"
|
||||
>{{ f.label }}</button>
|
||||
</div>
|
||||
|
||||
<!-- 空态(FR-RETRIEVAL-006,不显示假卡片) -->
|
||||
<div v-if="!filtered.length" class="empty-note">{{ T.retrievalEmpty }}</div>
|
||||
|
||||
<!-- 阶段卡(FR-RETRIEVAL-001) -->
|
||||
<div
|
||||
v-for="c in filtered"
|
||||
:key="c.stage"
|
||||
:class="['tool-card', c.status, { open: isOpen(c.stage) }]"
|
||||
>
|
||||
<div class="tool-head" @click="toggle(c.stage)">
|
||||
<span class="t-ico" :style="{ color: `var(${PHASE_COLOR_VAR[c.group.last.phase]})` }">
|
||||
{{ phaseIcons[c.group.last.phase] || '•' }}
|
||||
</span>
|
||||
<span class="t-name" :title="c.group.last.label">{{ c.group.last.label }}</span>
|
||||
<span class="badge" :class="c.status"><span class="dot"></span>{{ c.status === 'running' ? T.stepRunning : T.stepDone }}</span>
|
||||
<span v-if="c.group.last.ts" class="t-dur">{{ fmtDur(c.group.last.ts - c.group.first.ts) }}</span>
|
||||
<svg class="t-chev" width="13" height="13" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M9 18l6-6-6-6" /></svg>
|
||||
</div>
|
||||
<div v-if="isOpen(c.stage)" class="tool-body">
|
||||
<div class="tb-label">{{ BENCH_LABELS.stageLabel }}</div>
|
||||
<div class="tb-phase">
|
||||
<span class="phase-dot" :style="{ background: `var(${PHASE_COLOR_VAR[c.group.last.phase]})` }"></span>
|
||||
{{ PHASE_LABEL[c.group.last.phase] || c.group.last.phase }} · {{ PHASE_DESC[c.group.last.phase] || '' }}
|
||||
</div>
|
||||
<template v-if="detailLines(c.group.last.details).length">
|
||||
<div class="tb-label">{{ BENCH_LABELS.paramsLabel }}</div>
|
||||
<div class="tb-params"><div v-for="l in detailLines(c.group.last.details)" :key="l">{{ l }}</div></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.retrieval-pane { display: flex; flex-direction: column; }
|
||||
.plan-block { margin-bottom: 12px; }
|
||||
.plan-rows { display: flex; flex-direction: column; gap: 5px; }
|
||||
.plan-row { display: flex; gap: 8px; font-size: 12px; line-height: 1.5; }
|
||||
.plan-k {
|
||||
flex: none;
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--pa-g-to);
|
||||
background: var(--pa-code-bg);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-sm);
|
||||
padding: 1px 6px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.plan-v { color: var(--pa-ink-muted); min-width: 0; word-break: break-word; }
|
||||
.filter-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.chip {
|
||||
padding: 3px 11px;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11.5px;
|
||||
color: var(--pa-ink-muted);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s var(--pa-ease);
|
||||
}
|
||||
.chip:hover { color: var(--pa-ink); }
|
||||
.chip.on { color: #fff; background: var(--pa-g-from); border-color: transparent; }
|
||||
.empty-note {
|
||||
text-align: center;
|
||||
color: var(--pa-ink-subtle);
|
||||
font-size: 12px;
|
||||
padding: 24px 8px;
|
||||
}
|
||||
.tool-card {
|
||||
margin: 0 0 10px;
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.tool-card.running { position: relative; }
|
||||
.tool-card.running::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 200%;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(100deg, transparent 30%, rgba(0, 212, 255, 0.05) 50%, transparent 70%);
|
||||
will-change: transform;
|
||||
animation: shimmer 1.8s infinite;
|
||||
}
|
||||
.tool-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 11px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.t-ico {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 7px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11.5px;
|
||||
background: color-mix(in srgb, var(--pa-g-from) 16%, transparent);
|
||||
color: var(--pa-g-to);
|
||||
}
|
||||
.t-name {
|
||||
flex: 1;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.t-dur { font-family: var(--pa-font-mono); font-size: 11.5px; color: var(--pa-ink-subtle); }
|
||||
.t-chev { color: var(--pa-ink-subtle); transition: transform 0.25s var(--pa-ease); flex: none; }
|
||||
.tool-card.open .t-chev { transform: rotate(90deg); }
|
||||
.tool-body {
|
||||
padding: 0 11px 10px;
|
||||
border-top: 1px solid var(--pa-hairline);
|
||||
padding-top: 11px;
|
||||
animation: fade-slide 0.2s var(--pa-ease-out);
|
||||
}
|
||||
.tb-label { font-size: 11px; letter-spacing: 0.5px; color: var(--pa-ink-subtle); margin: 8px 0 5px; }
|
||||
.tb-phase { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--pa-ink-muted); }
|
||||
.phase-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
|
||||
.tb-params {
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--pa-g-to);
|
||||
background: var(--pa-code-bg);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-sm);
|
||||
padding: 7px 10px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
/* 徽章(复用 B1 .badge;此处补 bench 内联兜底) */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
flex: none;
|
||||
}
|
||||
.badge .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.badge.running { color: var(--pa-warning); background: rgba(245, 184, 92, 0.12); }
|
||||
.badge.running .dot { animation: pulse 2s infinite; }
|
||||
.badge.done { color: var(--pa-success); background: rgba(46, 211, 160, 0.12); }
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<!-- SourceRow.vue —— 文献来源单条(B3 归属)
|
||||
FR-SOURCE-001/002/003:mono 列表视觉、字段缺失不渲染占位、外链优先级 url>doi>pmid>pmcid。 -->
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { T } from '../../i18n/labels.js';
|
||||
|
||||
const props = defineProps({
|
||||
source: { type: Object, required: true },
|
||||
index: { type: Number, default: 0 },
|
||||
count: { type: Number, default: null }, // 会话级聚合的出现次数
|
||||
highlighted: { type: Boolean, default: false }, // FR-CHAT-015:引用 chip 定位高亮
|
||||
});
|
||||
|
||||
const expanded = ref(false);
|
||||
const rootEl = ref(null);
|
||||
|
||||
// 高亮激活时滚动到可视区(FR-CHAT-015 滚动定位)
|
||||
watch(
|
||||
() => props.highlighted,
|
||||
(on) => {
|
||||
if (on && rootEl.value) {
|
||||
rootEl.value.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** 外链优先级:url > doi > pmid > pmcid;均无 → null(不显示链接图标,FR-SOURCE-003) */
|
||||
const linkHref = computed(() => {
|
||||
const s = props.source || {};
|
||||
if (s.url) return s.url;
|
||||
if (s.doi) return `https://doi.org/${s.doi}`;
|
||||
if (s.pmid) return `https://pubmed.ncbi.nlm.nih.gov/${s.pmid}/`;
|
||||
if (s.pmcid) return `https://www.ncbi.nlm.nih.gov/pmc/articles/${s.pmcid}/`;
|
||||
return null;
|
||||
});
|
||||
|
||||
const metaParts = computed(() => {
|
||||
const s = props.source || {};
|
||||
const parts = [];
|
||||
if (s.pmid) parts.push(`PMID:${s.pmid}`);
|
||||
if (s.doi) parts.push(`DOI:${s.doi}`);
|
||||
if (s.score != null) parts.push(`${T.score} ${Number(s.score).toFixed(2)}`);
|
||||
if (props.count != null && props.count > 1) parts.push(`×${props.count}`);
|
||||
return parts;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootEl" class="ft-item source-row" :class="{ 'hl-active': highlighted }">
|
||||
<span class="ft-ico idx">{{ index + 1 }}</span>
|
||||
<div class="src-main">
|
||||
<div class="src-line1">
|
||||
<span class="ft-name" :title="source.title || ''">{{ source.title }}</span>
|
||||
</div>
|
||||
<div class="ft-size src-meta">{{ metaParts.join(' · ') }}</div>
|
||||
<div v-if="source.snippet" class="src-snippet" :class="{ clamp: !expanded }" @click="expanded = !expanded">
|
||||
{{ source.snippet }}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
v-if="linkHref"
|
||||
class="ft-dl"
|
||||
:href="linkHref"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
:title="linkHref"
|
||||
@click.stop
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<path d="M15 3h6v6" />
|
||||
<path d="M10 14L21 3" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.source-row {
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--pa-r-sm);
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 12.5px;
|
||||
transition: background 0.15s var(--pa-ease);
|
||||
}
|
||||
.source-row:hover { background: var(--pa-surface-2); }
|
||||
.source-row.hl-active {
|
||||
background: color-mix(in srgb, var(--pa-g-to) 14%, transparent);
|
||||
border-radius: var(--pa-r-sm);
|
||||
animation: hl-fade 2s var(--pa-ease-out) forwards;
|
||||
}
|
||||
@keyframes hl-fade {
|
||||
0% { background: color-mix(in srgb, var(--pa-g-to) 26%, transparent); }
|
||||
100% { background: color-mix(in srgb, var(--pa-g-to) 6%, transparent); }
|
||||
}
|
||||
.ft-item { display: flex; align-items: flex-start; gap: 8px; }
|
||||
.ft-ico { flex: none; width: 16px; text-align: center; }
|
||||
.idx { color: var(--pa-ink-subtle); font-size: 11px; line-height: 1.7; }
|
||||
.src-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||
.src-line1 { display: flex; align-items: baseline; min-width: 0; }
|
||||
.ft-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--pa-ink);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.src-meta { font-size: 11px; color: var(--pa-ink-subtle); }
|
||||
.ft-size { font-size: 11px; color: var(--pa-ink-subtle); }
|
||||
.src-snippet {
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--pa-ink-muted);
|
||||
font-family: var(--pa-font-ui);
|
||||
cursor: pointer;
|
||||
word-break: break-word;
|
||||
}
|
||||
.src-snippet.clamp {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ft-dl {
|
||||
flex: none;
|
||||
color: var(--pa-ink-subtle);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding-top: 2px;
|
||||
transition: opacity 0.15s var(--pa-ease), color 0.15s var(--pa-ease);
|
||||
}
|
||||
.ft-dl svg { width: 13px; height: 13px; }
|
||||
.ft-dl:hover { color: var(--pa-g-to); }
|
||||
/* 移动端触摸:链接图标常驻(FR-RESP-007 由 media (hover:none) 兜底,此处对齐) */
|
||||
@media (hover: none) {
|
||||
.ft-dl { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user