Compare commits

...
Author SHA1 Message Date
potaser 1319328b85 feat(review): auto-seed journal_directory on startup when empty 2026-10-11 20:55:36 +08:00
admin 2d0e3a4858 fix(build): compile_sdk 白名单加入 review_proxy.py(科研审稿反代断流高危点) 2026-10-11 16:40:24 +08:00
admin ea0f2b3fad Merge pull request 'feat(review): 教育科研工作台 + 论文自动重排版 + journals v4 数据集' (#1) from wjq/PlantAI-rag-new:feature/academic-review-assistant into main
Reviewed-on: http://8.163.92.8:3000/admin/PlantAI-rag-new/pulls/1
Reviewed-by: admin <jerryhaozhang@gmail.com>
2026-10-11 13:49:12 +08:00
admin 19fa12eb39 merge main: 解决 .gitignore 冲突(合并部署文档忽略规则) 2026-10-11 13:47:47 +08:00
admin 11337a788f 更新 .gitignore
解决冲突

Signed-off-by: admin <jerryhaozhang@gmail.com>
2026-10-11 13:29:35 +08:00
potaser 55410b663a feat(review): journal format-rule fetch scripts (offline CLI)
- fetch_format_rules.py CLI: robots.txt-aware fetcher (httpx + bs4,
  >=2s per-host throttle) + DeepSeek quote-backed rule extractor
- Status: OUP/Wiley/Cell return 403 from this environment; the five
  featured journals' format_rules in journals.json were manually
  verified against official sites instead. Tooling kept for future
  re-runs; data/format_cache/ is runtime output and gitignored
- Service code never imports this toolchain (standalone CLI only)
2026-10-11 12:19:18 +08:00
potaser dddb70ff6f fix(review): directory partition source shown as constant note
Stop sending partition_source_url; respond with a fixed explanatory
note instead. DB values untouched (dropped after SELECT).
2026-10-11 12:19:18 +08:00
potaser 649e66f41d feat(review): tectonic PDF compile API (P3a) + rename journals panel
- POST/GET /api/review/compile[/{id}[/pdf|/log]]: isolated workdir,
  --untrusted, 60s timeout with process-group kill, single-slot queue
  (429 QUEUE_FULL), 7-day retention sweep
- tools/install_tectonic.sh: pinned tectonic 0.17.0 (sha256) + README
- Rename menu/panel title to 学术期刊情报
- Compile UI stays a placeholder per product decision (P3 deferred)
2026-10-11 12:19:18 +08:00
potaser 42aeb8fa4c feat(review): paper auto-reformat P1 (.tex rule engine + diff panel)
- Rule engine: font/margin/line-spacing/bib-style transformers,
  tighten-only + structural guard + idempotency
- POST/GET /api/review/reformat with 200-envelope errors and job store
- ReformatPanel: journal dropdown (featured + directory search),
  source/diff tabs; compile tab is a dev placeholder for now
- journals.json format_rules_default kept as generic fallback
2026-10-11 12:19:17 +08:00
potaser c747ebd77a feat(review): official format rules for 5 featured journals
- Replace featured list with accessible journals: Genome Biology,
  BMC Plant Biology, Molecular Plant, Frontiers in Plant Science, Rice
- Per-journal format_rules verified against official author guidelines
  (rules_source=official, source_url, last_verified=2026-10-10)
- Update recommend tests to the new seed set
2026-10-11 12:19:17 +08:00
potaser 5a71e29cf7 feat(edu): complete 7-panel academic assistant + journals v4 dataset
Backend:
- review_service: presubmit / recommend / journals / journals/directory /
  format-check / conferences endpoints
- journals.json 5 精选种子 + journal_directory 1056 条全量(v4 数据)
- v4 数据集:2025 JIF(2026 JCR 发布)+ 2025 中科院分区 + 1053 条官网 URL
- format_check 行距 bug 修复(基线 y + 物理过滤 + docx EMU)

BFF:
- review_proxy 反代(修复 httpx build_request await bug)
- REVIEW_ENABLED 开关(默认关闭,零痕部署)

Frontend:
- 教育科研工作台(7 面板 + 菜单 + 状态管理)
- TopBar 教育科研入口(v-show 覆盖层语义,RAG 会话不丢)
- 期刊情报:Tab 切换 / 搜索 / 筛选 / 分页 / 官网列 / 展开凭证
- 智能推荐 / AI预审稿 / 专业报告 / 投稿台账 / 排版规范 / 会议日历
2026-10-10 16:05:08 +08:00
potaser d43d30ee3f feat: add review_service with plant genomics presubmit, journals endpoint, and BFF proxy 2026-10-09 16:54:03 +08:00
78 changed files with 23034 additions and 9 deletions
+13 -1
View File
@@ -1,4 +1,4 @@
# 运行时产物与环境
# 运行时产物与环境
node_modules
dist/
.venv/
@@ -43,5 +43,17 @@ paper_writer_service/paper.db-shm
paper_writer_service/paper.db-wal
paper_writer_service/paper.log
# review_service 运行时产物(不入库)
review_service/review.db
review_service/review.db-wal
review_service/review.db-shm
review_service/review.log
review_service/data/reformat_jobs/
# 期刊种子 journals.json 是源码:顶层 **/data/ 会误伤 review_service/data/,显式放行
!review_service/data/
review_service/tools/tectonic
review_service/data/tectonic-cache/
review_service/data/format_cache/
# 部署方案文档(含主机凭据,不入库)
docs/DEPLOY_PAPER_176.md
+54
View File
@@ -0,0 +1,54 @@
# AGENTS.md —— 协作约束(科研审稿模块)
## 项目背景
本项目(PlantAI-rag-new)是植物基因组学文献知识工作台,拥有自研 RAG 与知识库
(Qdrant + Neo4j + vstore)。2026-10 起新增「科研投稿与智能审稿助手」功能
(审稿周期/期刊分析/期刊推荐/投稿前自审/审稿意见辅助/格式检查)。
## 审稿模块(review_service)约束 —— 必须遵守
1. **所有审稿相关新代码只允许写在 `review_service/` 目录下新增**。
- 服务自身:FastAPI 独立进程,端口 8100(模式同 `paper_writer_service` 8097 / `bioflow_service` 8098)。
- 环境变量前缀统一 `REVIEW_`;LLM 凭据复用 `DOCGEN_LLM_*` 裸键(回退链见 `review_service/app/services/llm.py`)。
2. **BFF(`sdk/`)层只允许条件注册代理路由**:
- 允许的全部改动 = `sdk/app/config.py` 增加 `review_enabled` / `review_service_url` 等开关键
+ `sdk/app/main.py` 按开关 `include_router(review_proxy.router)`
+ 新文件 `sdk/app/routers/review_proxy.py`(反代实现)。
- **严禁**修改 sdk 下其他 router/service/db 的任何既有逻辑。
3. **严禁修改现有 RAG 核心与既有服务代码**,包括但不限于:
- `agent/`(qa 8091 / methodology 8092 / legacy-rag 8093)
- `vstore/`(8094,审稿服务只经 HTTP 只读消费其 `/api/vstore/*` 接口)
- `paper_writer_service/`、`bioflow_service/`、`bioflow_kernel/`
- `ui/`(前端入口另行按 UI_SPEC 流程评审)
- `knowledge/`、`kb-libraries/`、`tooling/`(依赖锁另议)
4. **依赖纪律**:审稿服务只使用统一 venv(`tooling/requirements.lock`)中已有的库
(fastapi / httpx / pydantic-settings 等)。如确需新增全局依赖,
须先更新 `tooling/requirements.lock` 并经人工确认。
5. **上游对接只读**:PDF 解析走 vstore 8094(`POST /api/vstore/ingest` +
`GET/DELETE /api/vstore/documents/{id}`);LLM 走 DeepSeek OpenAI 兼容接口;
后续期刊数据/期刊推荐等子功能同样以新 service + 新路由在 `review_service/` 内扩展。
6. **format_check 例外**:format_check 允许用 pymupdf 本地解档获取版式元数据
(字体/边距/行距/页数),因为 vstore 只回文本不返回版式信息。正文提取仍走 vstore。
## 目录速览(审稿模块)
```
review_service/
├── app/main.py # FastAPI 入口(/health + 挂载业务路由)
├── app/config.py # pydantic-settings(REVIEW_ 前缀)
├── app/routers/presubmit.py # POST /api/review/presubmit(自审稿报告)
├── app/routers/format_check.py # POST /api/review/format-check(版式与规范检查)
├── app/services/llm.py # DeepSeek httpx 封装(complete_text/complete_json)
├── app/services/paper_parse.py # PDF → 纯文本(经 vstore 解析接口)
├── app/services/format_check.py# 版式规则引擎(pymupdf/docx 本地解档 + 规则检查)
├── restart.sh # 8100 重启脚本
└── pytest.ini
```
## 启动
```bash
cd review_service && bash restart.sh # 默认 8100,venv 从 ../plantai-rag-new-venv 推导
# BFF 开关:sdk/.env 加 REVIEW_ENABLED=1 后 bff_restart(反代 /api/review/*)
```
@@ -0,0 +1,42 @@
# feat(review): journal_directory 启动自动 seed(部署即运行)
## 背景
source_v4.xlsx(1056 条目录)此前需手动跑 import_journals.py 入库,部署后忘跑即出现"目录数据未导入"。本次实现启动时自动检测 + 自动导入。线上当前环境已由人工完成导入(review.db 内 1056 行),本功能消除的是新环境部署/重建库时的手动步骤风险。
## 修改内容
| 文件 | 变更 |
|---|---|
| `app/services/journal_seed.py`(新增) | 共享核心:schema/清洗/解析/upsert 从 CLI 原样迁入;`extract_rows_from_xlsx()`(口径 779/277 与 id 冲突闸门,不符不写库)、`upsert_to_db()`、`ensure_journal_directory_seeded()`(空表才导,四键 dict 契约,绝不抛异常) |
| `scripts/import_journals.py` | 瘦身为薄 CLI(-431/+92 行):对账报告/退出码 0/1/2/3 全保留,核心复用 journal_seed |
| `app/config.py` | +`seed_on_startup=True`(REVIEW_SEED_ON_STARTUP 可关)、+`journal_directory_seed_path` |
| `app/main.py` | lifespan:开关门控 → `asyncio.to_thread` 导入(不堵 event loop)→ try/except 不阻塞启动;路径走 `db.path` 注入链 |
| `tests/conftest.py`(新增) | 全局关 seed,防止单测触碰真库/拖慢套件 |
| `tests/test_seed.py`(新增) | 6 用例(见下) |
不动:source_v4.xlsx、journal_directory 路由/表 schema、db.py、前端。
## 测试用例与结果(pytest 78 passed,新增 6 条)
1. 空库 → seed 成功,rows=1056(en=779/zh=277)✓
2. 非空库(1 行)→ skip,count 不变 ✓
3. xlsx 不存在 → 返回 error,不抛异常,不写库 ✓
4. 幂等:连调两次,第二次 skip ✓
5. 开关 off(lifespan)→ 表不创建 ✓
6. 开关 on(lifespan)→ tmp 库 1056 行 ✓
## 部署验证
```bash
bash review_service/restart.sh # 非空库:日志 "already has 1056 rows, skip"
curl 'http://127.0.0.1:8100/api/review/journals/directory?lang=en' # total=779
# 空库冷启动(本机已验证等价路径):日志 "seeded 1056 rows (en=779, zh=277, N.Ns)"
# 特殊部署:REVIEW_SEED_ON_STARTUP=false → 日志 "seed skipped"
```
本机已实测:真机重启走 skip 路径(`INFO:app.main:already has 1056 rows, skip`),CLI 薄壳回归 exit=0 / `en=779 zh=277 → ✓ 相符`。
## 是否需要立即部署
待管理员确认。线上已手动导入 1056 行,本改动属部署便利性补强(消除新环境忘跑导入脚本的风险),可随下次正常发版评估带上。
+27
View File
@@ -0,0 +1,27 @@
# review_service/.env.example —— 运行时配置模板
# 用法: cp .env.example .env 后填写(.env 已被仓库 .gitignore 忽略,勿提交)
# restart.sh 会 `set -a; source .env` 导出给 uvicorn 进程。
# ---- LLM(DeepSeek;REVIEW_LLM_* 优先 → DOCGEN_LLM_* 回退 → 内置默认,见 services/llm.py)----
# 最简做法:从部署树抄一行即可复用同一把 key:
# grep '^DOCGEN_LLM_API_KEY=' /home/xzq/5090/teamWorkspace/zh/PlantAI-rag-new/sdk/.env >> .env
REVIEW_LLM_BASE_URL=https://api.deepseek.com/v1
REVIEW_LLM_API_KEY=
REVIEW_LLM_MODEL=deepseek-chat
#REVIEW_LLM_TIMEOUT=300
# ---- 上游(只读消费)----
#REVIEW_VSTORE_BASE_URL=http://127.0.0.1:8094
#REVIEW_METHODOLOGY_BASE_URL=http://127.0.0.1:8092 # 预留(MinerU 重链路,当前未用)
# ---- PDF 解析(经 vstore)----
#REVIEW_PARSE_TIMEOUT=180 # ingest→取文→清理 总读超时(秒)
#REVIEW_PARSE_DELETE_AFTER=1 # 1=解析后删除 vstore 临时文档(0=保留便于排查)
# ---- 自审稿 ----
#REVIEW_PRESUBMIT_READ_TIMEOUT=360 # LLM 长调用读超时(秒)
#REVIEW_MAX_TEXT_CHARS=60000 # 送 LLM 的正文截断上限
#REVIEW_MAX_PDF_BYTES=52428800 # 上传 PDF 上限 50MB
# ---- 服务(restart.sh 读取)----
#REVIEW_BIND_HOST=127.0.0.1
+7
View File
@@ -0,0 +1,7 @@
"""review_service —— 科研投稿与智能审稿助手(8100)。
独立部署树(模式同 paper_writer_service 8097 / bioflow_service 8098):
- BFF(sdk 8090) 经 routers/review_proxy.py 条件反代 /api/review/*(REVIEW_ENABLED)。
- 上游只读消费:vstore 8094(PDF 解析)、DeepSeek(LLM,REVIEW_LLM_*/DOCGEN_LLM_*)。
- 约束见根目录 AGENTS.md:审稿相关新代码只允许落在本目录。
"""
+1
View File
@@ -0,0 +1 @@
"""review 服务(8100)应用包。"""
+111
View File
@@ -0,0 +1,111 @@
"""config.py —— review 服务(8100)环境变量驱动配置(pydantic-settings)。
全部键可用环境变量覆盖(前缀 REVIEW_)。LLM 沿用 docgen 的裸键模式:
优先 REVIEW_LLM_*,回退 DOCGEN_LLM_*(与 paper_writer_service 共享同一套
DeepSeek 凭据,由 services/llm.py 直接读 os.environ)。零硬编码宿主路径。
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
env_prefix="REVIEW_",
)
app_name: str = "plantai-review-service"
version: str = "0.1.0"
# ---- 上游(只读消费;不修改 vstore/methodology 任何代码)----
# vstore 8094:POST /api/vstore/ingest + GET/DELETE /api/vstore/documents/{id}
# (pymupdf 段落解析,同步秒级;methodology 8092 的 MinerU 链路是
# 分钟级异步 job,不适配同步自审稿,仅预留地址供后续扩展)
vstore_base_url: str = "http://127.0.0.1:8094"
methodology_base_url: str = "http://127.0.0.1:8092"
# ---- PDF 解析 ----
parse_timeout: float = 180.0 # vstore ingest→fetch→delete 读超时(秒)
parse_delete_after: bool = True # 解析完成后删除 vstore 临时文档(不污染私有库)
# ---- 自审稿(presubmit)----
presubmit_read_timeout: float = 360.0 # LLM 长调用读超时(秒)
max_text_chars: int = 60000 # 送 LLM 的正文截断上限(约 3 万汉字/6 万字符)
max_pdf_bytes: int = 50 * 1024 * 1024 # 上传 PDF 上限 50MB(对齐 extraction FR-EXTRACT-004)
# ---- 期刊情报与投稿推荐(recommend)----
db_path: str = "review.db" # 独立 SQLite(相对 review_service/;REVIEW_DB_PATH 覆盖)
journals_seed_path: str = "data/journals/journals.json" # 种子(相对 review_service/)
journal_directory_seed_path: str = "data/journals/source_v4.xlsx" # 目录源 xlsx(相对 review_service/)
seed_on_startup: bool = True # 启动自动 seed journal_directory(空表才导;false 关闭)
vstore_match_timeout: float = 30.0 # scope 入库/向量检索读超时(秒)
recommend_read_timeout: float = 60.0 # LLM 重排读超时(秒)
recommend_query_chars: int = 3000 # 送 vstore 的检索 query 截断(防嵌入模型超长)
recommend_scope_top_k: int = 10 # search-chunks top_k(vstore 上限 20)
# ---- 论文重排版(reformat,P1:.tex 源码级变换,不编译)----
max_tex_bytes: int = 2 * 1024 * 1024 # 上传 .tex 上限 2MB(PDF 的 50MB 不适用)
reformat_dir: str = "data/reformat_jobs" # 产物落盘(相对 review_service/)
# ---- LaTeX 编译 PDF(compile,P3:tectonic 单文件二进制)----
tectonic_path: str = "tools/tectonic" # 相对 review_service/;也可绝对路径
tectonic_cache_dir: str = "data/tectonic-cache" # 宏包缓存(部署时预热,运行期只读命中)
compile_timeout_s: float = 60.0 # 单次编译墙钟上限,超时整进程组 kill
max_compile_pdf_bytes: int = 50 * 1024 * 1024 # 产物 PDF 上限 50MB
compile_queue_limit: int = 5 # 等待槽位上限(>5 → QUEUE_FULL 429)
compile_retention_days: int = 7 # 编译产物保留天数(后台清扫)
@property
def _service_root(self) -> "Path":
# review_service/app/config.py → review_service/(相对本文件定位,不依赖 cwd)
return Path(__file__).resolve().parent.parent
@property
def db_path_abs(self) -> "Path":
# 绝对 db_path 会被 Path 除法原样返回;相对路径落在 review_service/ 下
return self._service_root / self.db_path
@property
def journals_seed_abs(self) -> "Path":
return self._service_root / self.journals_seed_path
@property
def journal_directory_seed_abs(self) -> "Path":
return self._service_root / self.journal_directory_seed_path
@property
def reformat_dir_abs(self) -> "Path":
p = Path(self.reformat_dir)
return p if p.is_absolute() else self._service_root / p
@property
def tectonic_abs(self) -> "Path":
p = Path(self.tectonic_path)
return p if p.is_absolute() else self._service_root / p
@property
def tectonic_cache_abs(self) -> "Path":
p = Path(self.tectonic_cache_dir)
return p if p.is_absolute() else self._service_root / p
@property
def vstore_base(self) -> str:
return self.vstore_base_url.rstrip("/")
@property
def methodology_base(self) -> str:
return self.methodology_base_url.rstrip("/")
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()
settings = get_settings()
+191
View File
@@ -0,0 +1,191 @@
"""app/db.py —— review 服务独立 SQLite(aiosqlite + WAL,无 ORM):journals 表。
- 单连接 + asyncio 写锁(模式对齐 paper_writer_service/bioflow,但表结构与种子
逻辑为本模块自研);路径默认 review_service/review.db(REVIEW_DB_PATH 覆盖)。
- 种子:data/journals/journals.json → journals 表(幂等 upsert,vstore_doc_id 列
永不被种子覆盖——该列记录 scope_desc 在 vstore 8094 的 document_id)。
- 只在 review_service/ 内使用,不触碰 BFF/生产服务数据库。
"""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from typing import Any, Iterable
import aiosqlite
from .config import get_settings
_SCHEMA = """
CREATE TABLE IF NOT EXISTS journals (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
scope_desc TEXT NOT NULL,
impact_factor REAL,
review_time_weeks REAL,
acceptance_rate REAL,
submission_deadline TEXT,
official_url TEXT,
last_updated TEXT NOT NULL,
vstore_doc_id TEXT
);
"""
# 种子 upsert 列(刻意不含 vstore_doc_id:种子刷新不得清掉向量库映射)
_SEED_COLS = (
"id",
"name",
"scope_desc",
"impact_factor",
"review_time_weeks",
"acceptance_rate",
"submission_deadline",
"official_url",
"last_updated",
)
_UPSERT_SQL = (
"INSERT INTO journals (" + ", ".join(_SEED_COLS) + ") VALUES ("
+ ", ".join("?" for _ in _SEED_COLS) + ") "
+ "ON CONFLICT(id) DO UPDATE SET "
+ ", ".join(f"{c}=excluded.{c}" for c in _SEED_COLS if c != "id")
)
class SeedError(Exception):
"""种子文件缺失/损坏(journals.json 不可读)。"""
class Database:
"""aiosqlite 单连接封装(WAL;写串行化)。"""
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 = get_settings().db_path_abs
return self._path
def set_path(self, path: str | Path) -> None:
"""测试注入:在进入 TestClient lifespan 之前预置库路径(须尚未 connect)。"""
if self._conn is not None:
raise RuntimeError("Database 已连接,无法再改路径(先 close_db())")
self._path = Path(path)
@property
def connected(self) -> bool:
return self._conn is not None
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 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[dict[str, Any]]:
async with self.conn.execute(sql, tuple(params)) as cur:
rows = await cur.fetchall()
return [dict(r) for r in rows]
async def fetchone(self, sql: str, params: Iterable[Any] = ()) -> dict[str, Any] | None:
async with self.conn.execute(sql, tuple(params)) as cur:
row = await cur.fetchone()
return dict(row) if row is not None else None
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() # type: ignore[union-attr]
return cur.lastrowid or 0
async def executemany(self, sql: str, seq: list[tuple[Any, ...]]) -> None:
async with self._lock:
await self.conn.executemany(sql, seq)
await self._conn.commit() # type: ignore[union-attr]
db = Database()
async def init_db(path: str | Path | None = None) -> None:
"""幂等初始化:已连接则跳过(测试可在 lifespan 前 set_path 注入路径)。"""
if path is not None:
db.set_path(path)
if db.connected:
return
await db.connect()
async def close_db() -> None:
await db.close()
def set_path(path: str | Path) -> None:
"""同步注入库路径(测试 fixture 用;须在 init_db 之前调用)。"""
db.set_path(path)
async def ensure_journals_seeded() -> int:
"""装载种子期刊(幂等)。返回 journals 表行数。"""
row = await db.fetchone("SELECT COUNT(*) AS c FROM journals")
if row and int(row["c"]) > 0:
return int(row["c"])
seed_file = get_settings().journals_seed_abs
try:
payload = json.loads(Path(seed_file).read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise SeedError(f"期刊种子文件不存在:{seed_file}") from exc
except (OSError, ValueError) as exc:
raise SeedError(f"期刊种子文件不可读:{exc}") from exc
journals = payload.get("journals") if isinstance(payload, dict) else None
if not isinstance(journals, list) or not journals:
raise SeedError(f"期刊种子文件缺少 journals 数组:{seed_file}")
rows: list[tuple[Any, ...]] = []
for item in journals:
if not isinstance(item, dict) or not item.get("id") or not item.get("name"):
continue
rows.append(tuple(item.get(c) for c in _SEED_COLS))
if not rows:
raise SeedError(f"期刊种子文件无有效条目:{seed_file}")
await db.executemany(_UPSERT_SQL, rows)
return len(rows)
async def list_journals() -> list[dict[str, Any]]:
"""全部期刊(impact_factor 降序,NULL 靠后)。"""
return await db.fetchall(
"SELECT * FROM journals ORDER BY impact_factor DESC, name ASC"
)
async def get_journal(journal_id: str) -> dict[str, Any] | None:
return await db.fetchone("SELECT * FROM journals WHERE id = ?", (journal_id,))
async def set_vstore_doc_id(journal_id: str, doc_id: str) -> None:
"""记录 scope_desc 在 vstore 的 document_id(向量白名单检索用)。"""
await db.execute(
"UPDATE journals SET vstore_doc_id = ? WHERE id = ?", (doc_id, journal_id)
)
+116
View File
@@ -0,0 +1,116 @@
"""main.py —— review 服务应用入口(uvicorn 入口)。
- 独立部署树 review_service/(科研投稿与智能审稿助手,端口 8100)。
- 端点:/health(GET,BFF 探活反代)+ /api/review/*(自审稿等业务路由)。
- 不依赖 BFF/生产服务(8090 等绝不探测/连接);上游仅消费 vstore 8094。
"""
from __future__ import annotations
import asyncio
import logging
import time
from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .config import get_settings
from .db import close_db, db, init_db
from .routers import (
compile,
conferences,
format_check,
journal_directory,
journals,
presubmit,
reformat,
)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(title=settings.app_name, version=settings.version)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
@asynccontextmanager
async def _lifespan(_app: FastAPI):
# journals 推荐依赖独立 SQLite:启动即建表(幂等;测试可先 set_path 注入
# tmp 路径,本函数沿用注入路径)
await init_db()
# 编译产物过期清扫:启动跑一次(失败不阻塞启动)
try:
from .services.latex_compile import sweep_expired
sweep_expired()
except Exception: # noqa: BLE001
logger.warning("compile sweep 启动清扫失败(忽略)", exc_info=True)
# journal_directory 自动 seed:空表才导(xlsx 解析约 1-3s,不堵 event loop;
# 路径沿用 db.path 注入链——生产=settings.db_path_abs,测试=tmp)
if settings.seed_on_startup:
try:
from .services.journal_seed import ensure_journal_directory_seeded
_t0 = time.time()
_result = await asyncio.to_thread(
ensure_journal_directory_seeded, db.path)
if _result.get("seeded"):
_c = _result.get("counts") or {}
logger.info(
"seeded %s rows (en=%s, zh=%s, %.1fs)",
_result["rows"], _c.get("en"), _c.get("zh"),
time.time() - _t0)
elif _result.get("error"):
logger.warning(
"journal_directory 启动 seed 失败(忽略,不阻塞启动): %s",
_result["error"])
else:
logger.info("%s", _result.get("reason") or "journal_directory seed skipped")
except Exception: # noqa: BLE001
logger.warning("journal_directory 启动 seed 失败(忽略,不阻塞启动)", exc_info=True)
else:
logger.info("journal_directory seed skipped (REVIEW_SEED_ON_STARTUP=false)")
try:
yield
finally:
await close_db()
app.router.lifespan_context = _lifespan
@app.get("/health")
async def health() -> dict[str, object]:
vstore_ok = False
vstore_error: str | None = None
try:
async with httpx.AsyncClient(timeout=2.0) as client:
resp = await client.get(f"{settings.vstore_base}/api/vstore/health")
resp.raise_for_status()
vstore_ok = bool(resp.json().get("ok"))
except Exception as exc: # noqa: BLE001
vstore_error = str(exc)
return {
"ok": True,
"status": "ok" if vstore_ok else "degraded",
"upstreams": {"vstore": {"connected": vstore_ok, "error": vstore_error}},
"version": settings.version,
}
app.include_router(presubmit.router)
app.include_router(journals.router)
app.include_router(format_check.router)
app.include_router(conferences.router)
app.include_router(journal_directory.router)
app.include_router(reformat.router)
app.include_router(compile.router)
return app
app = create_app()
+1
View File
@@ -0,0 +1 @@
"""review 服务路由包。"""
+184
View File
@@ -0,0 +1,184 @@
"""routers/compile.py —— LaTeX 编译 PDF(P3a:tectonic)。
端点:
POST /api/review/compile multipart:file(.tex 可选) | job_id(可选,二选一)
→ {ok, compile_id, pdf_url, log_url, meta}
GET /api/review/compile/{cid} 编译元信息(meta.json)
GET /api/review/compile/{cid}/pdf output.pdf(FileResponse)
GET /api/review/compile/{cid}/log output.log(text/plain)
信封:业务错误 = HTTP 200 + {ok:false,error:{code,message,detail?,degraded:false}}
(QUEUE_FULL 例外:HTTP 429 + 同款信封体,前端仍按 ok/error 单一口径处理)。
detail = log 尾 200 行(COMPILE_FAILED / TIMEOUT)。
"""
from __future__ import annotations
import json
import logging
import re
import uuid
from pathlib import Path
from typing import Any
from fastapi import APIRouter, File, Form, UploadFile
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
from pydantic import BaseModel
from ..config import get_settings
from ..services import latex_compile as lc
from ..services.latex_compile import (
CompileError,
QueueFullError,
ToolUnavailableError,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/review", tags=["review-compile"])
_CID_RE = re.compile(r"^[0-9a-f]{12}$")
class CompileResponse(BaseModel):
ok: bool
compile_id: str | None = None
pdf_url: str | None = None
log_url: str | None = None
meta: dict[str, Any] | None = None
error: dict[str, Any] | None = None
def _error(code: str, message: str, detail: str = "") -> CompileResponse:
err: dict[str, Any] = {"code": code, "message": message, "degraded": False}
if detail:
err["detail"] = detail
return CompileResponse(ok=False, error=err)
def _jobs_root() -> Path:
d = get_settings().reformat_dir_abs
d.mkdir(parents=True, exist_ok=True)
return d
@router.post("/compile", response_model=CompileResponse)
async def compile_tex(
file: UploadFile | None = File(None, description="LaTeX 源码(.tex,与 job_id 二选一)"),
job_id: str = Form("", description="P1 重排版任务 id(优先于 file)"),
) -> CompileResponse:
settings = get_settings()
# ---- 输入解析:job_id 优先,否则直接上传的 .tex ----
jid = (job_id or "").strip()
if jid:
if not _CID_RE.fullmatch(jid):
return _error("VALIDATION", "job_id 格式非法(应为 12 位 hex)")
src = _jobs_root() / f"{jid}.tex"
if not src.is_file():
return _error("VALIDATION", "job_id 对应的重排版结果不存在,请重新排版")
try:
source = src.read_text("utf-8")
except UnicodeDecodeError:
return _error("VALIDATION", "任务源码不是合法 UTF-8")
anchor_id = jid
else:
if file is None:
return _error("VALIDATION", "必须提供 file(.tex)或 job_id")
filename = file.filename or "paper.tex"
if Path(filename).name != filename or ".." in filename:
return _error("VALIDATION", "非法文件名")
if not filename.lower().endswith(".tex"):
return _error("VALIDATION", "仅支持 .tex 文件")
content = await file.read()
if not content:
return _error("VALIDATION", "上传文件为空")
if len(content) > settings.max_tex_bytes:
return _error("VALIDATION", "源码超过 2MB 上限")
try:
source = content.decode("utf-8")
except UnicodeDecodeError:
return _error("VALIDATION", "源码必须为 UTF-8 编码")
anchor_id = uuid.uuid4().hex[:12]
# ---- 编译(隔离目录;失败信封带 log 尾)----
lc.prepare_input(anchor_id, source)
try:
result = await lc.compile_tex(anchor_id)
except ToolUnavailableError as exc:
return _error("TOOL_UNAVAILABLE", str(exc))
except QueueFullError as exc:
return JSONResponse(
status_code=429,
content={
"ok": False,
"compile_id": None, "pdf_url": None, "log_url": None, "meta": None,
"error": {"code": "QUEUE_FULL", "message": str(exc), "degraded": False},
},
)
except CompileError as exc:
return _error(exc.code, str(exc), detail=exc.log_tail)
meta_path = lc.compile_dir(anchor_id) / "meta.json"
try:
meta = json.loads(meta_path.read_text("utf-8"))
except (OSError, ValueError):
meta = {"compile_id": anchor_id, "seconds": result["seconds"]}
return CompileResponse(
ok=True,
compile_id=anchor_id,
pdf_url=f"/api/review/compile/{anchor_id}/pdf",
log_url=f"/api/review/compile/{anchor_id}/log",
meta=meta,
)
def _cid_dir(cid: str) -> Path | None:
if not _CID_RE.fullmatch(cid or ""):
return None
d = lc.compile_dir(cid)
return d if d.is_dir() else None
@router.get("/compile/{compile_id}", response_model=CompileResponse)
async def get_compile(compile_id: str) -> CompileResponse:
cdir = _cid_dir(compile_id)
meta_path = cdir / "meta.json" if cdir else None
if meta_path is None or not meta_path.is_file():
return _error("NOT_FOUND", "编译任务不存在或已过期,请重新编译")
try:
meta = json.loads(meta_path.read_text("utf-8"))
except (OSError, ValueError):
return _error("NOT_FOUND", "编译产物损坏,请重新编译")
return CompileResponse(
ok=True,
compile_id=compile_id,
pdf_url=f"/api/review/compile/{compile_id}/pdf",
log_url=f"/api/review/compile/{compile_id}/log",
meta=meta,
)
@router.get("/compile/{compile_id}/pdf", response_model=None)
async def download_compile_pdf(compile_id: str) -> FileResponse | JSONResponse:
cdir = _cid_dir(compile_id)
pdf = cdir / "output.pdf" if cdir else None
if pdf is None or not pdf.is_file():
return JSONResponse(
status_code=200,
content={"ok": False, "error": {
"code": "NOT_FOUND", "message": "PDF 不存在或已过期", "degraded": False}},
)
return FileResponse(pdf, media_type="application/pdf", filename=f"{compile_id}.pdf")
@router.get("/compile/{compile_id}/log", response_model=None)
async def download_compile_log(compile_id: str) -> PlainTextResponse | JSONResponse:
cdir = _cid_dir(compile_id)
log = cdir / "output.log" if cdir else None
if log is None or not log.is_file():
return JSONResponse(
status_code=200,
content={"ok": False, "error": {
"code": "NOT_FOUND", "message": "日志不存在或已过期", "degraded": False}},
)
return PlainTextResponse(log.read_text("utf-8", errors="replace"))
+209
View File
@@ -0,0 +1,209 @@
"""routers/conferences.py —— 会议投稿时间线(只读静态种子)。
- GET /api/review/conferences?domain=&ccf=&upcoming_days=180
data/journals/conferences.json → 过滤/排序 → 列表 + meta。
- 数据诚实性铁律:每条会议必须带 deadline_source ∈ {placeholder, official}、
last_verified 与 deadline_confidence ∈ {high, medium, low},缺失/非法条目加载时
跳过并记日志(宁缺毋造);本端点无任何生成日期的逻辑,日期只来自种子常量。
v2 共 27/27 全 placeholder,meta.data_source_note 显式声明占位与置信度,
由前端渲染「⚠ 日期待核验」及置信度提示。
- 过滤语义:domain 对 domain_tags 精确匹配(大小写不敏感);ccf ∈ {A,B,C};
upcoming_days=0 不过滤,>0 仅保留 deadline_full_paper ∈ [UTC now, now+N 天],
缺失/不可解析截稿日期在过滤开启时排除。排序:deadline_full_paper 升序,
缺失/不可解析恒排最后。
- 只读无写接口;文件不可读 → ok=false + DATA_UNAVAILABLE 信封;参数非法 →
VALIDATION 信封(均 HTTP 200,对齐本服务"错误信封永远 200"约定)。
- 静态文件首次请求加载进内存(lru_cache),进程内 O(1);不触碰 SQLite/BFF。
"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timedelta, timezone
from functools import lru_cache
from pathlib import Path
from typing import Any
from fastapi import APIRouter
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/review", tags=["review-conferences"])
# review_service/app/routers/ → parents[2] = review_service(对齐 format_check 写法)
_CONFERENCES_PATH = (
Path(__file__).resolve().parents[2] / "data" / "journals" / "conferences.json"
)
_CCF_OK = frozenset({"A", "B", "C"})
_REQUIRED = ("id", "name", "deadline_source", "last_verified", "deadline_confidence")
_CONFIDENCE_OK = frozenset({"high", "medium", "low"})
_SOURCE_NOTE = (
"会议截稿/通知日期首版均为占位数据(deadline_source=placeholder),"
"接入官方渠道前须人工核验;deadline_confidence 标注日期置信度"
"(high=官方已公布/历年极稳定,medium=按历年规律推算,low=待核验);"
"所有时间按 AoE (UTC-12) 时区。"
)
# ---- 请求/响应模型 ----
class ConferenceItem(BaseModel):
"""会议公开字段(种子全字段白名单;未知键丢弃)。"""
id: str
name: str
full_name: str = ""
domain_tags: list[str] = []
application_tags: list[str] = []
ccf_rank: str | None = None
deadline_abstract: str | None = None
deadline_full_paper: str | None = None
notification_date: str | None = None
conference_date: str | None = None
location: str | None = None
official_url: str | None = None
timezone_note: str | None = None
deadline_source: str
last_verified: str
deadline_confidence: str = ""
class ConferencesResponse(BaseModel):
ok: bool
items: list[ConferenceItem] = []
meta: dict[str, Any] | None = None
error: dict[str, Any] | None = None
def _error(code: str, message: str) -> ConferencesResponse:
return ConferencesResponse(
ok=False, items=[], meta=None,
error={"code": code, "message": message, "degraded": False},
)
# ---- 种子加载(首次调用读盘,进程内缓存)----
@lru_cache(maxsize=1)
def _load_conferences() -> list[dict[str, Any]] | None:
"""读 conferences.json → 合法会议列表;文件不可读 → None(DATA_UNAVAILABLE)。"""
try:
payload = json.loads(_CONFERENCES_PATH.read_text("utf-8"))
except (OSError, ValueError) as exc:
logger.warning("conferences: 种子文件不可读 %s: %s", _CONFERENCES_PATH, exc)
return None
raw = payload.get("conferences") if isinstance(payload, dict) else None
if not isinstance(raw, list):
logger.warning("conferences: 种子缺少 conferences 数组:%s", _CONFERENCES_PATH)
return None
items: list[dict[str, Any]] = []
for conf in raw:
if not isinstance(conf, dict):
continue
missing = [k for k in _REQUIRED if not conf.get(k)]
if missing:
logger.warning("conferences: 条目缺字段 %s(id=%s),跳过", missing, conf.get("id"))
continue
if conf.get("deadline_source") not in ("placeholder", "official"):
logger.warning(
"conferences: deadline_source 非法(id=%s),跳过", conf.get("id")
)
continue
if str(conf.get("deadline_confidence") or "").strip().lower() not in _CONFIDENCE_OK:
logger.warning(
"conferences: deadline_confidence 非法(id=%s),跳过", conf.get("id")
)
continue
items.append(conf)
return items
def _parse_deadline(value: Any) -> datetime | None:
"""截稿时间 → aware datetime(naive 视为 UTC);非法 → None。"""
if not isinstance(value, str) or not value.strip():
return None
try:
dt = datetime.fromisoformat(value.strip())
except ValueError:
return None
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt
# ---- GET /api/review/conferences ----
@router.get("/conferences", response_model=ConferencesResponse)
async def conferences_list(
domain: str = "",
ccf: str = "",
upcoming_days: int = 180,
) -> ConferencesResponse:
"""只读:会议投稿时间线(无写/删/改接口)。
- ?domain= 对 domain_tags 精确匹配(大小写不敏感),空=不过滤;
- ?ccf= ccf_rank ∈ A/B/C,空=不过滤,非法值 → VALIDATION 信封;
- ?upcoming_days= int≥0,默认 180;0=不过滤,>0 仅保留截稿在
[UTC now, now+N 天] 内的会议,负数 → VALIDATION 信封;
- 排序按 deadline_full_paper 升序,缺失/不可解析恒排最后;
- 空结果 → items=[] + meta.count=0(HTTP 200,不报错)。
"""
ccf_norm = (ccf or "").strip().upper()
if ccf_norm and ccf_norm not in _CCF_OK:
return _error("VALIDATION", f"不支持的 ccf={ccf!r},可选:A / B / C")
if upcoming_days < 0:
return _error(
"VALIDATION", f"upcoming_days 必须 ≥ 0,收到 {upcoming_days}"
)
all_items = _load_conferences()
if all_items is None:
return _error("DATA_UNAVAILABLE", "会议种子数据不可读,请联系管理员")
items = list(all_items)
dom = (domain or "").strip().casefold()
if dom:
items = [
c for c in items
if any(
str(t).strip().casefold() == dom
for t in (c.get("domain_tags") or [])
)
]
if ccf_norm:
items = [
c for c in items
if str(c.get("ccf_rank") or "").strip().upper() == ccf_norm
]
if upcoming_days > 0:
now = datetime.now(timezone.utc)
limit = now + timedelta(days=upcoming_days)
items = [
c for c in items
if (dl := _parse_deadline(c.get("deadline_full_paper"))) is not None
and now <= dl <= limit
]
def _sort_key(c: dict[str, Any]) -> tuple[bool, datetime]:
dl = _parse_deadline(c.get("deadline_full_paper"))
if dl is None:
return (True, datetime.max.replace(tzinfo=timezone.utc))
return (False, dl)
items.sort(key=_sort_key)
out = [ConferenceItem(**c) for c in items]
verified = max(
(str(c.get("last_verified") or "") for c in all_items), default=""
)
meta: dict[str, Any] = {
"count": len(out),
"last_verified": verified or None,
"data_source_note": _SOURCE_NOTE,
"query": {
"domain": (domain or "").strip(),
"ccf": ccf_norm,
"upcoming_days": upcoming_days,
},
}
return ConferencesResponse(ok=True, items=out, meta=meta, error=None)
@@ -0,0 +1,81 @@
"""routers/format_check.py —— 版式与规范检查(POST /api/review/format-check)。
纯规则引擎(services/format_check.py):PDF/Word 本地解档取版式元数据 +
journals.json 规则集(format_rules_default + 每条 format_rules 带 rules_source)。
无 LLM / 无 vstore,同步执行通常 <1s;恒 200 + {ok, checks, summary, meta|error} 信封。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, File, Form, UploadFile
from pydantic import BaseModel
from ..config import get_settings
from ..services import format_check
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/review", tags=["review-format"])
class CheckItem(BaseModel):
key: str
name: str
status: str # pass | warn | fail
detail: str
suggestion: str = ""
class FormatCheckResponse(BaseModel):
ok: bool
checks: list[CheckItem] | None = None
summary: dict[str, int] | None = None
meta: dict[str, object] | None = None
error: dict[str, object] | None = None
def _error(code: str, message: str, *, degraded: bool = False) -> FormatCheckResponse:
return FormatCheckResponse(
ok=False, error={"code": code, "message": message, "degraded": degraded},
)
@router.post("/format-check", response_model=FormatCheckResponse)
async def format_check_endpoint(
file: UploadFile = File(..., description="待检查的 PDF / Word(.docx) / LaTeX(.tex) 文档"),
target_journal: str = Form("", description="目标期刊(可选,传 journals 的 id)"),
) -> FormatCheckResponse:
settings = get_settings()
filename = file.filename or ""
low = filename.lower()
if low.endswith(".pdf"):
kind = "pdf"
elif low.endswith(".docx"):
kind = "docx"
elif low.endswith(".tex"):
kind = "tex"
else:
return _error("VALIDATION", "仅支持 PDF / Word(.docx)/ LaTeX(.tex)文件")
content = await file.read()
if not content:
return _error("VALIDATION", "上传文件为空")
if len(content) > settings.max_pdf_bytes:
return _error("VALIDATION", "上传文件超过 50MB 上限")
try:
result = format_check.check_document(content, kind, (target_journal or "").strip())
except format_check.FormatCheckError as exc:
logger.warning("format-check: 解档失败:%s", exc)
return _error("PARSE_FAILED", str(exc))
meta = dict(result.get("meta") or {})
meta["filename"] = filename
return FormatCheckResponse(
ok=True,
checks=[CheckItem(**c) for c in result["checks"]],
summary=result["summary"],
meta=meta,
)
@@ -0,0 +1,280 @@
"""routers/journal_directory.py —— 期刊目录浏览(1056 本,只读分页)。
- GET /api/review/journals/directory:journal_directory 表(scripts/import_journals.py
导入的 v4 目录:2025 中科院分区 + 2025 JIF(2026 年 JCR 发布)+ 年报 2025 版
(中文统计年 2024)+ 官网网址/核对状态)→ 过滤/排序/分页 → 列表 + meta。
- 与 GET /journals(journals.json 精选 5 本,供 FormatPanel 下拉/recommend 语义)物理
分离(方案 A);本端点绝不读写 journals 表,recommend/journal_kb/journal_rank 零触碰。
- 读路径只 SELECT;表由导入脚本拥有(db.py 零改动);表不存在 → data_imported=false
优雅降级(HTTP 200 空列表)。
读时归一化(拍板细节 1-3,均在 API 层做,不动脚本/DB 原值):
细节1 lang=zh → cas_partition 恒 null + cas_partition_note="不按中科院分区口径列示";
细节2 jif_note 源文本映射(v4 值域已是用户可读文本,旧值映射保留兜底);
细节3 缺值即矛盾清零:en 且 jif=null → jif_source_url=null。
(zh 的 IF 载体是 composite/comprehensive,其 jif_source_url 本恒 null。)
决策2:响应不下发"来源"文本字段(partition_source/jif_source/data_source_label
已移除);jif_source_url/data_source_url 保留供展开行链接。
决策4(来源展示优化):partition_source_url 不再下发——DB 原值分毫未动
(仍 SELECT * 读出后被模型丢弃),响应改为常量字段 partition_source_note
文案,前端以灰色说明文字替代原"分区来源"超链接。
新增 official_check_status / jcr_release_year。
"""
from __future__ import annotations
import json
import logging
import sqlite3
from typing import Any
from fastapi import APIRouter
from pydantic import BaseModel
from ..db import db
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/review", tags=["review-directory"])
_LANGS = frozenset({"en", "zh"})
# 排序列白名单(列名不可参数化 → 白名单防注入)
_FIELDS = frozenset({
"name",
"jif",
"composite_impact_factor",
"comprehensive_impact_factor",
"research_direction",
"cas_partition",
})
_ORDERS = frozenset({"asc", "desc"})
_PARTS = {"1": "1区", "2": "2区", "3": "3区", "4": "4区"}
_MAX_SIZE = 200
# 细节2:源核对状态 → 用户可读 note
_JIF_NOTE_MAP = {
"源表标N/A": "未被2024 JCR收录",
"所用年度来源未检得": "本次汇编未查到,建议查阅最新 JCR",
}
_ZH_PART_NOTE = "不按中科院分区口径列示"
# 决策4:分区来源改为常量说明文案(不下发 partition_source_url;DB 原值不动)
_PARTITION_SOURCE_NOTE = (
"2025 中科院官方表(2026 年起停更;官方入口 fenqubiao.com 需机构登录)"
)
_NOTE_EN = (
"分区:2025年中科院公开表(多表比对,2026年起停止更新);"
"JIF:2025年度指标(2026年JCR发布,两份公开汇编一致或已核对出版社官方指标);"
"整理日期2026-10-10。空白=来源未给值≠0;研究方向/期刊标记/文章类型为导航标签,非官方评级。"
)
_NOTE_ZH = (
"指标:国内自然科学期刊年报2025版(统计年2024,official,含复合/综合IF两口径);"
"中文期刊不按中科院分区口径列示;国内IF与JIF统计源不同,不可混排。"
)
_UNIMPORTED_NOTE = "目录数据未导入(请先运行 scripts/import_journals.py)"
# ---- 响应模型 ----
class DirectoryItem(BaseModel):
"""目录公开字段(DB 全列白名单;imported_at 等内部列丢弃)。"""
id: str
lang: str
name: str
research_direction: str | None = None
cas_partition: str | None = None
cas_partition_note: str | None = None
cas_partition_year: int | None = None
jif: float | None = None
jif_year: int | None = None
jif_note: str | None = None
jif_check_status: str | None = None
composite_impact_factor: float | None = None
comprehensive_impact_factor: float | None = None
metric_year: int | None = None
metric_report_year: int | None = None
metric_caliber: str | None = None
annual_report_page: int | None = None
top_flag: str | None = None
publisher_series: str | None = None
journal_mark: str | None = None
article_type: str | None = None
issn: str | None = None
eissn: str | None = None
jcr_disciplines: list[str] = []
supplement_note: str | None = None
evidence_note: str | None = None
official_url: str | None = None
official_check_status: str | None = None
jcr_release_year: int | None = None
jif_source_url: str | None = None
partition_source_note: str = _PARTITION_SOURCE_NOTE # 决策4:常量,替代分区来源链接
data_source_url: str | None = None
class DirectoryResponse(BaseModel):
ok: bool
items: list[DirectoryItem] = []
meta: dict[str, Any] | None = None
error: dict[str, Any] | None = None
def _error(code: str, message: str) -> DirectoryResponse:
return DirectoryResponse(
ok=False, items=[], meta=None,
error={"code": code, "message": message, "degraded": False},
)
# ---- 读时归一化(拍板细节 1-3)----
def _present(raw: dict[str, Any], lang: str) -> dict[str, Any]:
d = dict(raw)
# jcr_disciplines:JSON 数组字符串 → list
jcr = d.get("jcr_disciplines")
if jcr:
try:
parsed = json.loads(jcr)
d["jcr_disciplines"] = parsed if isinstance(parsed, list) else []
except (TypeError, ValueError):
d["jcr_disciplines"] = []
else:
d["jcr_disciplines"] = []
# 细节2:jif_note 源文本 → 用户可读
note = d.get("jif_note")
if note in _JIF_NOTE_MAP:
d["jif_note"] = _JIF_NOTE_MAP[note]
# 细节1:zh 分区归一(DB 原值"不按此口径列示"只留在库里)
if lang == "zh":
d["cas_partition"] = None
d["cas_partition_note"] = _ZH_PART_NOTE
# 细节3:缺值 → 来源链接清零(消除 null 值却挂来源链接的矛盾;
# 分区来源链接已按决策4 移出响应,改为常量 partition_source_note)
if lang == "en":
if d.get("jif") is None:
d["jif_source_url"] = None
return d
def _escape_like(s: str) -> str:
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
# ---- GET /api/review/journals/directory ----
@router.get("/journals/directory", response_model=DirectoryResponse)
async def journals_directory(
lang: str = "en",
domain: str = "",
cas_partition: str = "",
q: str = "",
page: int = 1,
size: int = 50,
field: str = "",
order: str = "",
) -> DirectoryResponse:
"""只读:期刊目录分页浏览(无写接口)。
- ?lang=en|zh(默认 en;**强制单语,保证国际/中文永不混排**);
- ?domain= 研究方向精确匹配;?cas_partition=1|2|3|4(仅 lang=en,zh 忽略);
- ?q= 期刊名模糊(ASCII 大小写不敏感);?page≥1 ?size 1-200(默认 50);
- ?field= 排序白名单(name/jif/composite_impact_factor/
comprehensive_impact_factor/research_direction/cas_partition),?order=asc|desc;
未传 field → 默认排序 en=jif 降序 / zh=复合IF 降序(NULL 恒排最后);
- 参数非法 → VALIDATION 信封;表不存在 → data_imported=false 空列表(HTTP 200)。
"""
lang_n = (lang or "").strip().lower()
if lang_n not in _LANGS:
return _error("VALIDATION", f"不支持的 lang={lang!r},可选:en / zh")
if page < 1:
return _error("VALIDATION", f"page 必须 ≥ 1,收到 {page}")
if size < 1 or size > _MAX_SIZE:
return _error("VALIDATION", f"size 必须在 1-{_MAX_SIZE},收到 {size}")
field_n = (field or "").strip()
if field_n and field_n not in _FIELDS:
return _error(
"VALIDATION",
f"不支持的 field={field!r},可选:{', '.join(sorted(_FIELDS))}",
)
order_n = (order or "").strip().lower()
if order_n and order_n not in _ORDERS:
return _error("VALIDATION", f"不支持的 order={order!r},可选:asc / desc")
part_q = (cas_partition or "").strip()
if part_q:
part_q = _PARTS.get(part_q, _PARTS.get(part_q.rstrip("区").strip(), ""))
if not part_q:
return _error(
"VALIDATION",
f"不支持的 cas_partition={cas_partition!r},可选:1 / 2 / 3 / 4",
)
# 过滤条件(白名单拼接;参数全部占位符)
where = ["lang = ?"]
params: list[Any] = [lang_n]
domain_n = (domain or "").strip()
if domain_n:
where.append("research_direction = ?")
params.append(domain_n)
if part_q and lang_n == "en": # zh 不分区:忽略(meta 回显)
where.append("cas_partition = ?")
params.append(part_q)
q_n = (q or "").strip()
if q_n:
where.append("name LIKE ? ESCAPE '\\'")
params.append(f"%{_escape_like(q_n)}%")
where_sql = " AND ".join(where)
# 排序(列名出自白名单;NULL 恒排最后)
if field_n:
dir_sql = "DESC" if order_n == "desc" else "ASC"
order_sql = f"{field_n} IS NULL, {field_n} {dir_sql}"
else:
col = "jif" if lang_n == "en" else "composite_impact_factor"
order_sql = f"{col} IS NULL, {col} DESC"
try:
total_row = await db.fetchone(
f"SELECT COUNT(*) AS c FROM journal_directory WHERE {where_sql}", params # noqa: S608
)
total = int(total_row["c"]) if total_row else 0
rows = await db.fetchall(
f"SELECT * FROM journal_directory WHERE {where_sql} " # noqa: S608
f"ORDER BY {order_sql} LIMIT ? OFFSET ?",
[*params, size, (page - 1) * size],
)
dom_rows = await db.fetchall(
"SELECT research_direction AS d, COUNT(*) AS c FROM journal_directory "
"WHERE lang = ? AND research_direction IS NOT NULL "
"GROUP BY 1 ORDER BY c DESC, d ASC",
[lang_n],
)
except sqlite3.OperationalError as exc:
# 表不存在(脚本未跑)→ 优雅降级,HTTP 200
logger.warning("journal_directory: 表不可用(目录未导入?): %s", exc)
return DirectoryResponse(
ok=True, items=[],
meta={
"total": 0, "page": page, "size": size, "lang": lang_n,
"domain": domain_n, "cas_partition": part_q, "q": q_n,
"domains": [], "data_source_note": _UNIMPORTED_NOTE,
"data_imported": False,
},
error=None,
)
items = [DirectoryItem(**_present(dict(r), lang_n)) for r in rows]
meta: dict[str, Any] = {
"total": total,
"page": page,
"size": size,
"lang": lang_n,
"domain": domain_n,
"cas_partition": part_q if lang_n == "en" else "",
"q": q_n,
"domains": [r["d"] for r in dom_rows if r["d"]],
"data_source_note": _NOTE_EN if lang_n == "en" else _NOTE_ZH,
"data_imported": True,
}
return DirectoryResponse(ok=True, items=items, meta=meta, error=None)
+354
View File
@@ -0,0 +1,354 @@
"""routers/journals.py —— 期刊情报与智能投稿推荐。
- GET /api/review/journals 只读:journals 表全量列表(?field=/&order= 排序),
meta 含 count / last_updated / data_source_note。无任何写接口;空表返回
空数组 + meta.count=0(HTTP 200,绝不 500)。
- POST /api/review/recommend 智能投稿推荐:vstore 向量匹配(摘要 vs
scope_desc,白名单检索)→ LLM 按 id 白名单重排(幻觉期刊丢弃、缺漏规则
补位)→ 三档输出:挑战型 / 适配型 / 稳妥型,附匹配理由与风险提示。
降级哲学(同 presubmit):向量失败 → 全量候选 + vector_ok=false;
LLM 失败 → 规则分档 + llm_ok=false;推荐永不 5xx。仅入参非法返回
VALIDATION 错误信封(HTTP 200,与本服务其余端点一致)。
"""
from __future__ import annotations
import json
import logging
from functools import lru_cache
from pathlib import Path
from typing import Any, Literal
from fastapi import APIRouter
from pydantic import BaseModel, Field
from ..config import get_settings
from ..db import SeedError, ensure_journals_seeded, list_journals
from ..services import journal_kb, journal_rank, llm
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/review", tags=["review-journals"])
# ---- 请求/响应模型 ----
class Preferences(BaseModel):
max_review_weeks: float | None = Field(default=None, ge=1, le=104)
min_impact_factor: float | None = Field(default=None, ge=0, le=200)
tier_focus: Literal["all", "challenge", "fit", "safe"] = "all"
class RecommendRequest(BaseModel):
abstract: str
preferences: Preferences = Preferences()
class JournalItem(BaseModel):
id: str
name: str
impact_factor: float | None = None
review_time_weeks: float | None = None
acceptance_rate: float | None = None
official_url: str | None = None
similarity: float | None = None
tier: str
match_reason: str
risk_note: str
class TierBox(BaseModel):
label: str
items: list[JournalItem]
class RecommendResponse(BaseModel):
ok: bool
tiers: dict[str, TierBox] | None = None
meta: dict[str, Any] | None = None
error: dict[str, Any] | None = None
# ---- GET /api/review/journals(只读列表)----
class JournalBrief(BaseModel):
"""期刊公开字段(journals 表全列;不含 vstore_doc_id 内部映射)。
标签/分区 6 字段不进 SQLite:GET 时按 id 从 journals.json 内存缓存合并;
JSON 缺该 id → 空数组/null 默认值。
"""
id: str
name: str
scope_desc: str = ""
impact_factor: float | None = None
review_time_weeks: float | None = None
acceptance_rate: float | None = None
submission_deadline: str | None = None
official_url: str | None = None
last_updated: str = ""
scope_tags: list[str] = []
application_tags: list[str] = []
cas_partition: str | None = None
jcr_quartile: str | None = None
partition_source: str | None = None
cross_domain: list[str] = []
class JournalsListResponse(BaseModel):
ok: bool
items: list[JournalBrief] = []
meta: dict[str, Any] | None = None
error: dict[str, Any] | None = None
# ?field= 排序白名单(列名无法参数化,白名单防注入;非法值 → VALIDATION 信封)
_SORT_FIELDS = frozenset({
"id",
"name",
"impact_factor",
"review_time_weeks",
"acceptance_rate",
"last_updated",
})
_DATA_SOURCE_NOTE = (
"IF/审稿周期为公开近似值,acceptance_rate 为 null 表示暂无公开数据,需人工核验。"
"CAS/JCR 分区为首版占位数据,需核验。"
)
# journals.json 新增字段(标签/分区;刻意不进 SQLite,见 C-2 方案)
_TAG_FIELDS = (
"scope_tags",
"application_tags",
"cas_partition",
"jcr_quartile",
"partition_source",
"cross_domain",
)
@lru_cache(maxsize=1)
def _load_tag_extras() -> dict[str, dict[str, Any]]:
"""journals.json 标签/分区字段 → {id: {6 新字段}}。
首次请求读盘后进程内缓存,后续 GET O(1) 按 id 合并(不做每请求磁盘 IO)。
JSON 缺该 id → 合并时用空数组/null 默认;文件不可读 → 空 dict 降级(不 500)。
"""
seed = get_settings().journals_seed_abs
try:
payload = json.loads(Path(seed).read_text("utf-8"))
except (OSError, ValueError) as exc:
logger.warning("journals: 标签扩展文件不可读,合并降级为默认值:%s", exc)
return {}
raw = payload.get("journals") if isinstance(payload, dict) else None
if not isinstance(raw, list):
return {}
out: dict[str, dict[str, Any]] = {}
for j in raw:
if isinstance(j, dict) and j.get("id"):
out[str(j["id"])] = {k: j.get(k) for k in _TAG_FIELDS}
return out
def _tag_fields(extra: dict[str, Any] | None) -> dict[str, Any]:
"""缓存条目 → JournalBrief 关键字参数(JSON 缺该 id → 空数组/null 默认)。"""
ex = extra or {}
return {
"scope_tags": list(ex.get("scope_tags") or []),
"application_tags": list(ex.get("application_tags") or []),
"cas_partition": ex.get("cas_partition"),
"jcr_quartile": ex.get("jcr_quartile"),
"partition_source": ex.get("partition_source"),
"cross_domain": list(ex.get("cross_domain") or []),
}
def _sort_rows(rows: list[dict[str, Any]], field: str, order: str) -> list[dict[str, Any]]:
"""按白名单字段排序;NULL 恒排最后;次序不稳定时按 db 默认序(稳定排序保留)。"""
present = [r for r in rows if r.get(field) is not None]
missing = [r for r in rows if r.get(field) is None]
present.sort(key=lambda r: r.get(field), reverse=(order == "desc"))
return present + missing
def _list_error(message: str) -> JournalsListResponse:
return JournalsListResponse(
ok=False, items=[], meta=None,
error={"code": "VALIDATION", "message": message, "degraded": False},
)
@router.get("/journals", response_model=JournalsListResponse)
async def journals_list(
field: str = "name",
order: str = "asc",
) -> JournalsListResponse:
"""只读:journals 表全量列表(无写/删/改接口)。
- ?field= 排序字段白名单:id/name/impact_factor/review_time_weeks/
acceptance_rate/last_updated,默认 name;
- ?order=asc|desc,默认 asc;
- 空表 → items=[] + meta.count=0(HTTP 200,不报错)。种子装载由
POST /recommend 首次调用幂等完成,本端点严格只读。
"""
col = (field or "").strip()
if col not in _SORT_FIELDS:
return _list_error(
f"不支持的排序字段 field={field!r},可选:{', '.join(sorted(_SORT_FIELDS))}"
)
ord_norm = (order or "asc").strip().lower()
if ord_norm not in ("asc", "desc"):
return _list_error(f"不支持的排序方向 order={order!r},可选:asc, desc")
rows = await list_journals()
rows = _sort_rows(rows, col, ord_norm)
tag_extras = _load_tag_extras()
items = [
JournalBrief(
id=str(r.get("id") or ""),
name=str(r.get("name") or ""),
scope_desc=str(r.get("scope_desc") or ""),
impact_factor=r.get("impact_factor"),
review_time_weeks=r.get("review_time_weeks"),
acceptance_rate=r.get("acceptance_rate"),
submission_deadline=r.get("submission_deadline"),
official_url=r.get("official_url"),
last_updated=str(r.get("last_updated") or ""),
**_tag_fields(tag_extras.get(str(r.get("id") or ""))),
)
for r in rows
]
latest = max((str(r.get("last_updated") or "") for r in rows), default="")
meta: dict[str, Any] = {
"count": len(items),
"last_updated": latest or None,
"data_source_note": _DATA_SOURCE_NOTE,
}
return JournalsListResponse(ok=True, items=items, meta=meta, error=None)
def _error(code: str, message: str, *, degraded: bool = False) -> RecommendResponse:
return RecommendResponse(
ok=False, tiers=None, meta=None,
error={"code": code, "message": message, "degraded": degraded},
)
def _item_sort_key(it: JournalItem) -> tuple[int, float, float]:
return (
1 if it.similarity is None else 0,
-(it.similarity or 0.0),
-(it.impact_factor or 0.0),
)
def _apply_soft_preferences(item: JournalItem, prefs: Preferences) -> None:
"""软偏好:不满足只加风险提示,不删条目。"""
weeks = item.review_time_weeks
if prefs.max_review_weeks is not None and weeks is not None and weeks > prefs.max_review_weeks:
prefix = f"⚠ 审稿约 {weeks:g} 周,超出你 {prefs.max_review_weeks:g} 周的周期偏好;"
item.risk_note = prefix + ("无" if item.risk_note == "无" else item.risk_note)
inf = item.impact_factor
if prefs.min_impact_factor is not None:
if inf is None:
item.risk_note = "⚠ 影响因子未收录,无法确认满足你的要求;" + item.risk_note
elif inf < prefs.min_impact_factor:
prefix = f"⚠ 影响因子 {inf:g} 低于你 {prefs.min_impact_factor:g} 的要求;"
item.risk_note = prefix + ("无" if item.risk_note == "无" else item.risk_note)
@router.post("/recommend", response_model=RecommendResponse)
async def recommend(req: RecommendRequest) -> RecommendResponse:
settings = get_settings()
# ---- 入参校验(手动,错误信封与本服务其余端点一致)----
abstract = (req.abstract or "").strip()
if len(abstract) < 50:
return _error("VALIDATION", "abstract 至少需要 50 个字符(请提供摘要或正文片段)")
if len(abstract) > 20000:
return _error("VALIDATION", "abstract 超过 20000 字符上限")
prefs = req.preferences
# ---- 种子 + 期刊元数据 ----
try:
await ensure_journals_seeded()
journals = await list_journals()
except SeedError as exc:
logger.error("recommend: 种子装载失败:%s", exc)
return _error("SEED_UNAVAILABLE", str(exc), degraded=True)
if not journals:
return _error("SEED_UNAVAILABLE", "期刊库为空", degraded=True)
# ---- ① vstore 向量匹配(摘要 vs scope_desc)----
try:
candidates, vector_ok = await journal_kb.match(abstract, journals)
except journal_kb.MatchError as exc:
logger.warning("recommend: 向量匹配降级:%s", exc)
candidates, vector_ok = [], False
if not candidates:
# 无有效语义分 → 全量候选(similarity=None),LLM 仍可按 scope 文本重排
candidates = [{"journal": j, "similarity": None} for j in journals]
vector_ok = False
# ---- ② LLM 重排(id 白名单硬校验)----
prefs_dict = prefs.model_dump(exclude_none=True)
llm_ok = True
dropped = 0
repaired = 0
try:
rankings, dropped, repaired = await journal_rank.rerank(abstract, candidates, prefs_dict)
except (llm.LLMError, journal_rank.RankError, ValueError) as exc:
# ValueError:complete_json 最终 json.loads 修复仍失败(同 presubmit 宽容口径)
logger.warning("recommend: LLM 重排降级为规则分档:%s", exc)
rankings = journal_rank.fallback_rankings(candidates)
llm_ok = False
# ---- ③ 三档组装(名称/指标全部来自 DB,不采信 LLM 文本)----
by_id = {str(c["journal"]["id"]): c for c in candidates}
tiers: dict[str, list[JournalItem]] = {t: [] for t in journal_rank.TIERS}
for r in rankings:
cand = by_id.get(r.journal_id)
if cand is None:
continue
j = cand["journal"]
sim = cand.get("similarity")
item = JournalItem(
id=str(j["id"]),
name=str(j.get("name") or j["id"]),
impact_factor=j.get("impact_factor"),
review_time_weeks=j.get("review_time_weeks"),
acceptance_rate=j.get("acceptance_rate"),
official_url=j.get("official_url"),
similarity=round(float(sim), 4) if sim is not None else None,
tier=r.tier,
match_reason=r.match_reason,
risk_note=r.risk_note,
)
_apply_soft_preferences(item, prefs)
tiers.setdefault(r.tier, []).append(item)
# 档内排序:相似度(None 靠后)→ 影响因子
for box in tiers.values():
box.sort(key=_item_sort_key)
# tier_focus:软展示聚焦(默认 all;聚焦时其余档 items 置空,label 保留)
out_tiers: dict[str, TierBox] = {}
for t in journal_rank.TIERS:
items = tiers.get(t, [])
if prefs.tier_focus != "all" and t != prefs.tier_focus:
items = []
out_tiers[t] = TierBox(label=journal_rank.TIER_LABELS[t], items=items)
meta: dict[str, Any] = {
"model": llm._llm_config()["model"],
"vector_ok": vector_ok,
"llm_ok": llm_ok,
"seed_count": len(journals),
"candidate_count": len(candidates),
"dropped_hallucinated": dropped,
"repaired": repaired,
"preferences": prefs_dict,
"abstract_chars": len(abstract),
}
return RecommendResponse(ok=True, tiers=out_tiers, meta=meta, error=None)
+87
View File
@@ -0,0 +1,87 @@
"""routers/presubmit.py —— 投稿前自审稿(POST /api/review/presubmit)。
流程:上传 PDF → paper_parse(vstore 8094 解析)→ LLM 结构化审稿
(DeepSeek json_object,System Prompt 为植物基因组学六维专属清单,
见 services/self_review.py)→ 归一化为 SelfReviewReport。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, File, Form, UploadFile
from pydantic import BaseModel
from ..config import get_settings
from ..services import llm, paper_parse, self_review
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/review", tags=["review-presubmit"])
class PresubmitResponse(BaseModel):
ok: bool
report: self_review.SelfReviewReport | None = None
error: dict[str, object] | None = None
meta: dict[str, object] | None = None
def _error(code: str, message: str, *, degraded: bool = False) -> PresubmitResponse:
return PresubmitResponse(
ok=False, report=None,
error={"code": code, "message": message, "degraded": degraded},
)
@router.post("/presubmit", response_model=PresubmitResponse)
async def presubmit_review(
file: UploadFile = File(..., description="待审稿 PDF"),
target_journal: str = Form("", description="目标期刊(可选,影响格式类意见)"),
) -> PresubmitResponse:
settings = get_settings()
filename = file.filename or "paper.pdf"
if not filename.lower().endswith(".pdf"):
return _error("VALIDATION", "仅支持 PDF 文件")
content = await file.read()
if not content:
return _error("VALIDATION", "上传文件为空")
if len(content) > settings.max_pdf_bytes:
return _error("VALIDATION", "PDF 超过 50MB 上限")
# ---- 解析(vstore 8094)----
try:
text = await paper_parse.parse_pdf_bytes(content, filename=filename)
except paper_parse.ParseError as exc:
logger.warning("presubmit: 解析失败:%s", exc)
return _error("PARSE_FAILED", str(exc), degraded=True)
truncated = len(text) > settings.max_text_chars
feed_text = text[: settings.max_text_chars]
# ---- LLM 结构化自审(植物基因组学六维清单)----
try:
payload = await llm.complete_json(
self_review.SYSTEM_PROMPT,
self_review.build_user_prompt(feed_text, (target_journal or "").strip()),
temperature=0.2,
max_tokens=8000,
timeout=settings.presubmit_read_timeout,
)
except llm.LLMError as exc:
logger.error("presubmit: LLM 审稿失败:%s", exc)
return _error("UPSTREAM_DOWN", f"审稿模型调用失败:{exc}", degraded=True)
report = self_review.coerce_report(payload)
return PresubmitResponse(
ok=True,
report=report,
meta={
"filename": filename,
"target_journal": (target_journal or "").strip(),
"text_chars": len(feed_text),
"truncated": truncated,
"model": llm._llm_config()["model"],
},
)
+165
View File
@@ -0,0 +1,165 @@
"""routers/reformat.py —— 论文自动排版 P1(.tex 源码级重排版,不编译)。
端点:
POST /api/review/reformat multipart 上传 .tex + target_journal →
修改后源码 + 逐行 diff 报告(job 落盘)
GET /api/review/reformat/{id} 取完整报告(重启后仍在)
GET /api/review/reformat/{id}/file 下载修改后的 .tex
信封与其余 review 端点一致:业务错误 = HTTP 200 + {ok:false,error:{code,message,degraded:false}},
前端经 review.js unwrap 归一为 ApiError。本次改动无上游依赖(零 LLM/零 vstore),
degraded 恒 false。
"""
from __future__ import annotations
import hashlib
import json
import logging
import re
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import APIRouter, File, Form, UploadFile
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel
from ..config import get_settings
from ..services.reformat import ReformatError, reformat_tex, resolve_rules
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/review", tags=["review-reformat"])
_JOB_ID_RE = re.compile(r"^[0-9a-f]{12}$")
_ENGINE = "reformat-p1/1.0"
class ReformatResponse(BaseModel):
ok: bool
job_id: str | None = None
modified_tex: str | None = None
changes: list[dict[str, Any]] | None = None
warnings: list[dict[str, Any]] | None = None
meta: dict[str, Any] | None = None
error: dict[str, Any] | None = None
def _error(code: str, message: str) -> ReformatResponse:
return ReformatResponse(
ok=False,
error={"code": code, "message": message, "degraded": False},
)
def _jobs_dir() -> Path:
d = get_settings().reformat_dir_abs
d.mkdir(parents=True, exist_ok=True)
return d
@router.post("/reformat", response_model=ReformatResponse)
async def reformat(
file: UploadFile = File(..., description="LaTeX 源码(.tex 单文件)"),
target_journal: str = Form("", description="目标期刊 id/名称,或 default=通用规则"),
) -> ReformatResponse:
settings = get_settings()
# ---- 入参校验(拍板 5:只收 .tex,docx/pdf 走 format-check 审核不重排)----
filename = file.filename or "paper.tex"
if not filename.lower().endswith(".tex"):
return _error("VALIDATION", "仅支持 .tex 文件(docx/pdf 请走格式审核)")
target = (target_journal or "").strip()
if not target:
return _error("VALIDATION", "必须指定目标期刊(或选择「通用规则」)")
content = await file.read()
if not content:
return _error("VALIDATION", "上传文件为空")
if len(content) > settings.max_tex_bytes:
return _error("VALIDATION", "源码超过 2MB 上限")
try:
source = content.decode("utf-8")
except UnicodeDecodeError:
return _error("VALIDATION", "源码必须为 UTF-8 编码")
# ---- 规则解析(journals.json / format_cache / journal_directory 只读回链)----
resolved = await resolve_rules(target)
# ---- 变换(纯规则引擎;结构畸形 → PARSER_FAILED)----
try:
result = reformat_tex(source, resolved["rules"])
except ReformatError as exc:
logger.warning("reformat: 结构守卫拒收 %s: %s", filename, exc)
return _error(exc.code, str(exc))
# ---- 落盘(.tex + .json 报告;重启后 GET 仍可用)----
job_id = uuid.uuid4().hex[:12]
base = _jobs_dir()
(base / f"{job_id}.tex").write_text(result["modified_tex"], encoding="utf-8")
meta: dict[str, Any] = {
"journal_id": resolved["journal_id"],
"journal_name": resolved["journal_name"],
"rules": resolved["rules"],
"rules_source": resolved["rules_source"],
"rules_note": resolved["rules_note"],
"filename": filename,
"original_sha256": hashlib.sha256(content).hexdigest(),
"engine": _ENGINE,
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
payload = {
"ok": True,
"job_id": job_id,
"modified_tex": result["modified_tex"],
"changes": result["changes"],
"warnings": result["warnings"],
"meta": meta,
}
(base / f"{job_id}.json").write_text(
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
)
logger.info(
"reformat: %s → job %s(%d 处修改, %d 条提示, 规则 %s)",
filename, job_id,
sum(1 for c in result["changes"] if c["action"] == "modified"),
sum(1 for c in result["changes"] if c["action"] == "warning"),
resolved["rules_source"],
)
return ReformatResponse(**payload)
def _load_job(job_id: str) -> dict[str, Any] | None:
if not _JOB_ID_RE.fullmatch(job_id or ""):
return None
path = _jobs_dir() / f"{job_id}.json"
try:
payload = json.loads(path.read_text("utf-8"))
except (OSError, ValueError):
return None
return payload if isinstance(payload, dict) and payload.get("ok") else None
_NOT_FOUND = {"ok": False, "error": {"code": "NOT_FOUND",
"message": "任务不存在或已过期,请重新上传源码",
"degraded": False}}
@router.get("/reformat/{job_id}", response_model=ReformatResponse)
async def get_reformat_job(job_id: str) -> ReformatResponse:
payload = _load_job(job_id)
if payload is None:
return _error("NOT_FOUND", "任务不存在或已过期,请重新上传源码")
return ReformatResponse(**payload)
@router.get("/reformat/{job_id}/file", response_model=None)
async def download_reformat_file(job_id: str) -> FileResponse | JSONResponse:
"""下载修改后的 .tex;缺件/非法 id → 200 信封(前端单一错误口径)。"""
valid = bool(_JOB_ID_RE.fullmatch(job_id or ""))
tex = _jobs_dir() / f"{job_id}.tex" if valid else None
if tex is None or not tex.is_file():
return JSONResponse(status_code=200, content=_NOT_FOUND)
return FileResponse(tex, media_type="application/x-tex", filename=f"{job_id}.tex")
+1
View File
@@ -0,0 +1 @@
"""review 服务服务层包。"""
+788
View File
@@ -0,0 +1,788 @@
"""services/format_check.py —— 版式与规范规则引擎(⑤ 论文排版与规范)。
纯规则、无 LLM、无 vstore、同步执行(通常 <1s):
- PDF → pymupdf 本地解档(AGENTS.md 约束 6 例外:vstore 只回文本,字体/边距/
行距/页数等版式元数据拿不到;正文提取仍走 vstore,本服务不碰正文语义);
- Word → python-docx(页边距/行距精确,页数取 docProps/app.xml 的 <Pages>);
- 规则集 → data/journals/journals.json:
顶层 format_rules_default(通用默认值,rules_source="default")
每条目可选 format_rules(按期刊覆盖,必须带 rules_source: placeholder|official|default)
⚠ 诚实原则:不知道期刊真实投稿指南 → 该条只写 {"rules_source": "placeholder"},
所有具体数字回退 default 通用值,绝不编造期刊专属数字。
输出:{checks[{name,key,status,detail,suggestion}], summary{pass,warn,fail}, meta{...}}
status ∈ pass|warn|fail。参考文献样式宽容化:仅"明确不符"(如 [J] 标引但规则要求 APA)
才 fail,"疑似但不确定/检测不到"一律 warn,避免误伤。
"""
from __future__ import annotations
import io
import json
import re
import statistics
import zipfile
from pathlib import Path
from typing import Any
import fitz # pymupdf(venv 已装 1.28.x)
from docx import Document
from docx.shared import Length
# review_service/app/services/ → parents[2] = review_service
_JOURNALS_PATH = Path(__file__).resolve().parents[2] / "data" / "journals" / "journals.json"
_MAX_SCAN_PAGES = 80 # 版式扫描页数上限(正文/字体/边距;稿件通常远小于此)
_MAX_REFS_SCAN = 40 # 从文末向前找参考文献标题的页数窗口
_MAX_PARAS = 8000 # docx 段落扫描上限
_PT_TO_MM = 25.4 / 72.0
_EMU_TO_MM = 1.0 / 36000.0
_HEADING_RE = re.compile(
r"^\s*(references|bibliography|literature cited|works cited|参考文献|文献)\s*[::]?\s*$",
re.IGNORECASE,
)
_FIG_RE = re.compile(r"(?im)^\s*(figure|fig\.?|图)\s*\d+")
_TAB_RE = re.compile(r"(?im)^\s*(table|表)\s*\d+")
_GB_TAG_RE = re.compile(r"\[[A-Z]{1,3}\d*\]") # [J] [M] [D] [C] …(GB/T 7714 标引)
_PAREN_YEAR_RE = re.compile(r"\((?:19|20)\d{2}\)") # (2024) 作者-年份制
_BRACKET_NUM_RE = re.compile(r"^\[\d+\]") # [1] 编号制
_DOI_RE = re.compile(r"doi:?\.?org|doi:\s*1\.", re.IGNORECASE)
_STYLE_LABEL = {
"gb7714": "GB/T 7714",
"author_year": "作者-年份制(类 APA)",
"numbered": "编号制(顺序编码)",
"unknown": "无法确定",
}
class FormatCheckError(Exception):
"""解档/规则加载失败(路由器转 PARSE_FAILED)。"""
# ---------------------------------------------------------------- 规则集加载
def load_ruleset(target_journal: str) -> tuple[dict[str, Any], dict[str, Any]]:
"""返回 (rules, ruleset_meta)。target_journal 传 id 或 name 均可;未知 → default。"""
try:
data = json.loads(_JOURNALS_PATH.read_text("utf-8"))
except Exception as exc: # noqa: BLE001
raise FormatCheckError(f"规则集加载失败:{exc}") from exc
default = data.get("format_rules_default") or {}
jid = (target_journal or "").strip()
entry = None
if jid:
for j in data.get("journals") or []:
if j.get("id") == jid or j.get("name") == jid:
entry = j
break
if entry is None:
rules = dict(default)
meta = {
"journal_id": None,
"journal_name": None,
"ruleset_source": "default",
"rules_source": str(default.get("rules_source", "default")),
}
else:
journal_rules = entry.get("format_rules") or {}
rules = {**default, **journal_rules}
meta = {
"journal_id": entry.get("id"),
"journal_name": entry.get("name"),
"ruleset_source": "journal",
# 占位规则(无官方核验)→ 前端黄字提示
"rules_source": str(journal_rules.get("rules_source", "placeholder")),
}
rules.setdefault("rules_source", "default")
return rules, meta
# ---------------------------------------------------------------- PDF 解档(pymupdf)
def _norm_font(name: str) -> str:
n = (name or "").split("+")[-1].strip()
return n or "unknown"
def extract_profile_pdf(data: bytes) -> dict[str, Any]:
try:
doc = fitz.open(stream=data, filetype="pdf")
except Exception as exc: # noqa: BLE001
raise FormatCheckError(f"PDF 解档失败:{exc}") from exc
if getattr(doc, "needs_pass", False):
doc.close()
raise FormatCheckError("PDF 已加密,无法解析")
pages_total = doc.page_count
scan = min(pages_total, _MAX_SCAN_PAGES)
margin_rows: list[tuple[float, float, float, float]] = [] # t r b l (mm)
font_w: dict[str, int] = {}
size_w: dict[float, int] = {}
ratios: list[float] = []
img_ids: set[int] = set()
fig_caps = tab_caps = 0
for pno in range(scan):
page = doc[pno]
rect = page.rect
blocks = page.get_text("blocks")
content = [b for b in blocks if b[4].strip()]
if content:
left = min(b[0] for b in content)
top = min(b[1] for b in content)
right = rect.width - max(b[2] for b in content)
bottom = rect.height - max(b[3] for b in content)
margin_rows.append(
(
max(0.0, top) * _PT_TO_MM,
max(0.0, right) * _PT_TO_MM,
max(0.0, bottom) * _PT_TO_MM,
max(0.0, left) * _PT_TO_MM,
)
)
for img in page.get_images(full=True):
img_ids.add(img[0])
text = page.get_text()
fig_caps += len(_FIG_RE.findall(text))
tab_caps += len(_TAB_RE.findall(text))
d = page.get_text("dict")
for block in d.get("blocks", []):
if block.get("type") != 0:
continue
line_meta: list[tuple[float, float]] = [] # (基线 y, 字号)
for line in block.get("lines", []):
spans = [s for s in line.get("spans", []) if s.get("text", "").strip()]
if not spans:
continue
row_size = max(s.get("size", 0.0) for s in spans)
if row_size <= 0:
continue
for s in spans:
size = round(float(s.get("size", 0.0)), 1)
chars = len(s.get("text", ""))
if size > 0:
norm = _norm_font(s.get("font", ""))
font_w[norm] = font_w.get(norm, 0) + chars
size_w[size] = size_w.get(size, 0) + chars
# 行距基准用 span.origin 基线 y,不用 bbox 顶缘:bbox 顶随字形高低
# 漂移,表格/图注等同基线并列文本会产出假性小 delta,混入中位数会把
# 行距拉成 0.1x 的不可能值。
origin = spans[0].get("origin")
line_meta.append((origin[1] if origin else line["bbox"][1], row_size))
for i in range(1, len(line_meta)):
delta = line_meta[i][0] - line_meta[i - 1][0]
size = line_meta[i][1]
# 行距倍数 = 相邻行基线 y 差 / 当前行字号;
# 过滤物理不可能值:<0.5(行重叠/同基线并列表格)、>4.0(异常结构)
ratio = delta / size
if 0.5 <= ratio <= 4.0:
ratios.append(ratio)
refs_text, refs_found = _find_refs_pdf(doc, pages_total)
doc.close()
if margin_rows:
med = [statistics.median(col) for col in zip(*margin_rows)]
margins = {"top": med[0], "right": med[1], "bottom": med[2], "left": med[3]}
else:
margins = None
return {
"source_kind": "pdf",
"pages": pages_total,
"margins_mm": margins,
"font_weights": font_w,
"size_weights": size_w,
"line_spacing_median": statistics.median(ratios) if ratios else None,
"figures": max(len(img_ids), fig_caps),
"tables": tab_caps,
"refs_text": refs_text,
"refs_found": refs_found,
}
def _find_refs_pdf(doc: "fitz.Document", pages_total: int) -> tuple[str | None, bool]:
"""从文末向前找参考文献标题行;找到 → 标题行至文末文本;找不到 → (None, False)。"""
buf: list[str] = []
start = max(0, pages_total - _MAX_REFS_SCAN)
for pno in range(pages_total - 1, start - 1, -1):
page_text = doc[pno].get_text()
lines = page_text.splitlines()
for idx in range(len(lines) - 1, -1, -1):
if _HEADING_RE.match(lines[idx].strip()):
tail = "\n".join(lines[idx + 1:])
joined = "\n".join([tail] + buf).strip()
return (joined if len(joined) >= 50 else None), bool(joined)
buf.insert(0, page_text)
return None, False
# ---------------------------------------------------------------- Word 解档(python-docx)
def _docx_app_pages(data: bytes) -> int | None:
try:
with zipfile.ZipFile(io.BytesIO(data)) as zf:
xml = zf.read("docProps/app.xml").decode("utf-8", "ignore")
m = re.search(r"<Pages>(\d+)</Pages>", xml)
return int(m.group(1)) if m else None
except Exception: # noqa: BLE001
return None
def extract_profile_docx(data: bytes) -> dict[str, Any]:
try:
doc = Document(io.BytesIO(data))
except Exception as exc: # noqa: BLE001
raise FormatCheckError(f"Word 文件解析失败:{exc}") from exc
side_lists: dict[str, list[float]] = {"top": [], "right": [], "bottom": [], "left": []}
for sec in doc.sections:
for k, v in (
("top", sec.top_margin),
("right", sec.right_margin),
("bottom", sec.bottom_margin),
("left", sec.left_margin),
):
if v is not None:
side_lists[k].append(float(v) * _EMU_TO_MM)
margins = None
if all(side_lists.values()):
margins = {k: statistics.median(v) for k, v in side_lists.items()}
try:
normal = doc.styles["Normal"].font
default_name = normal.name
default_size = float(normal.size.pt) if normal.size is not None else None
except Exception: # noqa: BLE001
default_name, default_size = None, None
font_w: dict[str, int] = {}
size_w: dict[float, int] = {}
ratios: list[float] = []
fig_caps = tab_caps = 0
paras = doc.paragraphs
scan_count = min(len(paras), _MAX_PARAS)
for i in range(scan_count):
para = paras[i]
text = para.text
if not text.strip():
continue
ls = para.paragraph_format.line_spacing
if isinstance(ls, Length):
# 精确行距(exactly)以 EMU 存储,Length 是 int 子类——直接当倍数会得出
# 十万级的假值;用正文默认字号换算成倍数
if default_size:
r = float(ls.pt) / default_size
if 0.5 <= r <= 4.0:
ratios.append(r)
elif isinstance(ls, (int, float)) and not isinstance(ls, bool) and ls > 0:
r = float(ls) # multiple 行距本身就是倍数
if 0.5 <= r <= 4.0:
ratios.append(r)
if _FIG_RE.match(text):
fig_caps += 1
elif _TAB_RE.match(text):
tab_caps += 1
for run in para.runs:
if not run.text:
continue
name = run.font.name or default_name or "unknown"
pt = run.font.size.pt if run.font.size is not None else default_size
chars = len(run.text)
font_w[str(name)] = font_w.get(str(name), 0) + chars
if pt:
size_w[round(float(pt), 1)] = size_w.get(round(float(pt), 1), 0) + chars
# 参考文献节:标题段落之后全部文本
refs_text, refs_found = None, False
for i, para in enumerate(paras):
if _HEADING_RE.match((para.text or "").strip()):
tail = "\n".join(p.text for p in paras[i + 1:] if (p.text or "").strip())
if len(tail) >= 50:
refs_text, refs_found = tail, True
break
return {
"source_kind": "docx",
"pages": _docx_app_pages(data), # Word 未记录 → None(检查项降级为 warn)
"margins_mm": margins,
"font_weights": font_w,
"size_weights": size_w,
"line_spacing_median": statistics.median(ratios) if ratios else None,
"figures": max(len(doc.inline_shapes), fig_caps),
"tables": max(len(doc.tables), tab_caps),
"refs_text": refs_text,
"refs_found": refs_found,
}
# ---------------------------------------------------------------- LaTeX 解档(正则+状态机,零新依赖)
#
# 决策前提:只做"解析 + 检查 + 建议代码片段",不自动改写 LaTeX(用户可能有自定义宏,
# 自动改写会引错;LaTeX 应由用户自己编辑)。不引入 pylatexenc/texsoup。
# 会相互覆盖的字体类宏包:≥2 个同时引入 → warn(times + mathptmx 类冲突)
_TEX_FONT_PKGS = {"times", "mathptmx", "newtx", "newpx", "txfonts", "pxfonts", "mathpazo"}
def _strip_tex_comments(src: str) -> str:
r"""去行内 % 注释(r-string,避免 \% 被解释为转义序列)。"""
return re.sub(r"(?<!\\)%.*", "", src)
def _parse_geometry(src: str) -> dict[str, float] | None:
"""\\usepackage[...]{geometry} 或 \\geometry{...} → {边: mm}(可缺边,检查端容缺)。"""
m = re.search(r"\\usepackage\s*\[([^\]]*)\]\s*\{\s*geometry\s*\}", src)
if not m:
m = re.search(r"\\geometry\s*\{([^}]*)\}", src)
if not m:
return None
opts = m.group(1)
def to_mm(val: str) -> float | None:
mm = re.match(r"\s*([\d.]+)\s*(mm|cm|in|pt)?\s*$", val)
if not mm:
return None
x = float(mm.group(1))
unit = mm.group(2) or "pt" # geometry 裸数字按 TeX 惯例为 pt
return {"mm": x, "cm": x * 10, "in": x * 25.4, "pt": x * 25.4 / 72.0}[unit]
kv = {}
for pair in opts.split(","):
if "=" in pair:
k, v = pair.split("=", 1)
kv[k.strip().lower()] = v.strip()
out: dict[str, float] = {}
if "margin" in kv:
v = to_mm(kv["margin"])
if v is not None:
return {"top": v, "right": v, "bottom": v, "left": v}
for k in ("top", "right", "bottom", "left"):
if k in kv:
v = to_mm(kv[k])
if v is not None:
out[k] = v
if "inner" in kv:
v = to_mm(kv["inner"])
if v is not None:
out.setdefault("left", v)
if "outer" in kv:
v = to_mm(kv["outer"])
if v is not None:
out.setdefault("right", v)
return out or None
def extract_tex_profile(data: bytes) -> dict[str, Any]:
"""正则 + 轻量状态机解析 .tex 源文件 → 与 DocProfile 同构(+tex 扩展字段)。"""
try:
text = data.decode("utf-8")
except UnicodeDecodeError:
text = data.decode("latin-1", errors="replace")
src = _strip_tex_comments(text)
# \documentclass[10pt,a4paper]{article}
m_cls = re.search(r"\\documentclass\s*(?:\[[^\]]*\])?\s*\{\s*([\w*]+)\s*\}", src)
cls = m_cls.group(1) if m_cls else None
m_opt = re.search(r"\\documentclass\s*\[([^\]]*)\]", src)
cls_opts = m_opt.group(1) if m_opt else ""
m_pt = re.search(r"(\d+)\s*pt", cls_opts)
# 标准类未写选项时 LaTeX 默认 10pt;无 documentclass → None(检查项降级 warn)
fontsize = int(m_pt.group(1)) if m_pt else (10 if cls else None)
paper = "a4paper" if "a4paper" in cls_opts else ("letterpaper" if "letterpaper" in cls_opts else None)
# \usepackage 列表(支持 {a,b} 逗号多包)
packages: list[str] = []
for m in re.finditer(r"\\usepackage\s*(?:\[[^\]]*\])?\s*\{\s*([^}]+?)\s*\}", src):
packages += [p.strip().lower() for p in m.group(1).split(",") if p.strip()]
# 行距:\linespread{...} 或 \setstretch{...}(值直接按"行距倍数"口径评估——
# 按步骤4 规格:低于 min_line_spacing 即 fail;LaTeX 实际倍数还含 baselinestretch
# 的字号比,本引擎取显式设置值做保守判定)
line_spread: float | None = None
m = re.search(r"\\linespread\s*\{\s*([\d.]+)\s*\}", src)
if not m:
m = re.search(r"\\setstretch\s*\{\s*([\d.]+)\s*\}", src)
if m:
line_spread = float(m.group(1))
# 页边距(geometry)
margins = _parse_geometry(src)
# 计数
figures = len(re.findall(r"\\begin\s*\{\s*figure\*?\s*\}", src))
tables = len(re.findall(r"\\begin\s*\{\s*table\*?\s*\}", src))
cites = len(re.findall(r"\\cite\w*\s*\{", src))
# 参考文献:\bibliography{...}(BibTeX)/ \printbibliography(biblatex)/ thebibliography
has_bibtex = bool(re.search(r"\\bibliography\s*\{", src))
has_printbib = bool(re.search(r"\\printbibliography", src))
has_thebib = bool(re.search(r"\\begin\s*\{\s*thebibliography\s*\}", src))
has_bibstyle = bool(re.search(r"\\bibliographystyle\s*\{", src)) # BibTeX 必需;biblatex 不需要
has_bib = has_bibtex or has_printbib or has_thebib
refs_text: str | None = None
m_bib = re.search(r"\\begin\s*\{\s*thebibliography\s*\}[\s\S]*?\\end\s*\{\s*thebibliography\s*\}", src)
if m_bib:
refs_text = m_bib.group(0)
# 宏表(\newcommand{\xxx} / \renewcommand)——用于解析准确性(识别自定义宏)
macros = re.findall(r"\\(?:new|re)command\s*\{\s*\\(\w+)\s*\}", src)
# 匿名评审命令探测(规则集 anonymous:true 时检查)
m_anon = re.search(r"\\(?:anonymi[sz]e|anon\b|hideauthor|ifdoubleblind)", src, re.IGNORECASE)
return {
"source_kind": "tex",
"pages": None, # LaTeX 源文件页数需编译后才有 → 检查项降级 warn(宽容化)
"margins_mm": margins,
"font_weights": {},
"size_weights": {float(fontsize): 100} if fontsize else {},
"line_spacing_median": line_spread,
"figures": figures,
"tables": tables,
"refs_text": refs_text,
"refs_found": has_bib,
"tex": {
"class": cls,
"fontsize_pt": fontsize,
"paper": paper,
"packages": packages,
"cites": cites,
"macros": macros,
"anon_cmd": m_anon.group(0) if m_anon else None,
"bib_bibtex": has_bibtex,
"bib_style": has_bibstyle,
},
}
# ---------------------------------------------------------------- 参考文献样式检测
def detect_refs_style(text: str | None) -> tuple[str, str]:
"""宽容化检测:返回 (detected, sample_line)。含糊 → unknown(后续只 warn)。"""
if not text:
return "unknown", ""
lines = [l.strip() for l in text.splitlines() if l.strip()][:40]
if not lines:
return "unknown", ""
n = len(lines)
gb = sum(1 for l in lines if _GB_TAG_RE.search(l))
year = sum(1 for l in lines if _PAREN_YEAR_RE.search(l))
num = sum(1 for l in lines if _BRACKET_NUM_RE.match(l))
doi = sum(1 for l in lines if _DOI_RE.search(l))
sample = lines[0][:80]
if gb / n >= 0.4:
return "gb7714", sample
if num / n >= 0.4 and year / n < 0.3:
return "numbered", sample
if year / n >= 0.3 or doi / n >= 0.3:
return "author_year", sample
return "unknown", sample
# ---------------------------------------------------------------- 检查项构建
def _chk(key: str, name: str, status: str, detail: str, suggestion: str = "") -> dict[str, str]:
return {"key": key, "name": name, "status": status, "detail": detail, "suggestion": suggestion}
def _check_pages(profile: dict[str, Any], rules: dict[str, Any]) -> dict[str, str]:
limit = int(rules.get("max_pages", 10))
pages = profile.get("pages")
kind = profile.get("source_kind") or "doc"
if pages is None:
# 宽容化:Word/LaTeX 页数缺失绝不 fail
if kind == "tex":
return _chk(
"pages", "页数", "warn",
f"LaTeX 源无法直接统计页数(规则上限 {limit} 页),需编译后核对",
"编译后确认(如 xelatex main.tex)总页数 ≤ 上限",
)
return _chk(
"pages", "页数", "warn",
"Word 未记录页数,请自行核对",
f"在 Word 中按 Ctrl+End 查看总页数,或导出 PDF 后重查(规则上限 {limit} 页)",
)
if pages <= limit:
return _chk("pages", "页数", "pass", f"共 {pages} 页,规则上限 {limit} 页")
return _chk(
"pages", "页数", "fail", f"共 {pages} 页,超出规则上限 {limit} 页",
f"压缩至 ≤{limit} 页,或将方法/图表移入补充材料",
)
def _check_fonts(profile: dict[str, Any], rules: dict[str, Any]) -> dict[str, str]:
min_font = float(rules.get("min_font_pt", 9))
# LaTeX:字号来自 \documentclass 选项(10/11/12pt,粒度粗)
if profile.get("source_kind") == "tex":
tex = profile.get("tex") or {}
cls = tex.get("class") or "documentclass"
fs = tex.get("fontsize_pt")
pkgs = tex.get("packages") or []
font_pkgs = sorted(p for p in pkgs if p in _TEX_FONT_PKGS or p in ("helvet", "courier", "charter", "fourier"))
detail = (
f"\\documentclass 选项字号 {fs}pt(类 {cls});"
f"字体宏包:{'、'.join(font_pkgs) if font_pkgs else '未引入'}"
+ (f";纸张 {tex['paper']}" if tex.get("paper") else "")
)
if fs is None:
return _chk("fonts", "字体字号", "warn", detail + "\n建议:\n \\documentclass[11pt]{article}", "补充 \\documentclass 声明")
if fs < min_font:
# LaTeX 粒度粗 → 按步骤4 规格降为 warn(非 fail)
return _chk(
"fonts", "字体字号", "warn", detail + f"\n建议:\n \\documentclass[11pt]{{{cls}}}",
f"正文字号低于下限 {min_font:.0f}pt,请改用 11pt/12pt 选项",
)
return _chk("fonts", "字体字号", "pass", detail)
size_w: dict[float, int] = profile.get("size_weights") or {}
font_w: dict[str, int] = profile.get("font_weights") or {}
if not size_w:
return _chk("fonts", "字体字号", "warn", "未能提取字体信息", "确认文档含可选中文本(扫描件无法检查字体)")
min_font = float(rules.get("min_font_pt", 9))
max_fonts = int(rules.get("max_fonts", 6))
total = sum(size_w.values())
body_pt = max(size_w, key=size_w.get)
body_ratio = int(size_w[body_pt] / total * 100)
distinct = len(font_w)
top_fonts = sorted(font_w.items(), key=lambda kv: -kv[1])[:3]
top_text = "、".join(f"{k}({v}字)" for k, v in top_fonts)
detail = f"主要字号 {body_pt}pt(约 {body_ratio}%),共 {distinct} 种字体;高频字体:{top_text}"
if body_pt < min_font:
return _chk(
"fonts", "字体字号", "fail", detail,
f"将正文字号提高至 ≥{min_font}pt(当前主字号 {body_pt}pt)",
)
if distinct > max_fonts:
return _chk(
"fonts", "字体字号", "warn", detail,
f"字体种类偏多({distinct} > {max_fonts}),建议全文统一为 1–2 种正文字体",
)
return _chk("fonts", "字体字号", "pass", detail)
def _check_margins(profile: dict[str, Any], rules: dict[str, Any]) -> dict[str, str]:
limit = float(rules.get("min_margin_mm", 15))
margins = profile.get("margins_mm")
# LaTeX:页边距来自 geometry;低于下限按步骤4 规格给 warn(非 fail),附建议片段
if profile.get("source_kind") == "tex":
if not margins:
return _chk(
"margins", "页边距", "warn",
"未通过 geometry 设置页边距(按类默认评估)"
+ f"\n建议:\n \\usepackage[margin=25mm]{{geometry}}",
"在导言区显式声明页边距",
)
lo = min(margins.values())
detail = f"geometry 页边距最小 {lo:.0f}mm(" + "、".join(f"{k}{v:.0f}" for k, v in margins.items()) + f"),规则下限 {limit:.0f}mm"
if lo < limit:
return _chk(
"margins", "页边距", "warn",
detail + f"\n建议:\n \\usepackage[margin={limit:.0f}mm]{{geometry}}",
f"调大页边距至 ≥{limit:.0f}mm",
)
return _chk("margins", "页边距", "pass", detail)
if not margins:
return _chk("margins", "页边距", "warn", "未能提取页边距信息", "检查文档是否含可定位文本")
vals = [margins.get(k) for k in ("top", "right", "bottom", "left")]
vals = [v for v in vals if v is not None]
if not vals:
return _chk("margins", "页边距", "warn", "未能提取页边距信息", "检查文档是否含可定位文本")
t, r = margins.get("top"), margins.get("right")
b, l = margins.get("bottom"), margins.get("left")
lo = min(vals)
sides = "、".join(
f"{name}{v:.0f}" for name, v in (("上", t), ("右", r), ("下", b), ("左", l)) if v is not None
)
detail = f"四边最小 {lo:.0f}mm({sides}),规则下限 {limit:.0f}mm"
if lo >= limit:
return _chk("margins", "页边距", "pass", detail)
if lo >= limit * 0.8:
return _chk("margins", "页边距", "warn", detail, f"将页边距调大至 ≥{limit:.0f}mm")
return _chk("margins", "页边距", "fail", detail, f"页边距过小,请调至 ≥{limit:.0f}mm(版式设置)")
def _check_spacing(profile: dict[str, Any], rules: dict[str, Any]) -> dict[str, str]:
lo = float(rules.get("min_line_spacing", 1.15))
ratio = profile.get("line_spacing_median")
# LaTeX:\linespread/\setstretch 显式设置值;低于下限按步骤4 规格直接 fail
if profile.get("source_kind") == "tex":
if ratio is None:
return _chk(
"spacing", "行距", "pass",
"未显式设置 \\linespread/\\setstretch,LaTeX 类默认约 1.2 倍(如 10pt→12pt baselineskip)",
)
detail = f"\\linespread/\\setstretch = {ratio:.2f},规则下限 {lo:.2f}"
if ratio < lo:
return _chk(
"spacing", "行距", "fail",
detail + f"\n建议:\n \\linespread{{{lo:.2f}}} % 或 \\setstretch{{{lo:.2f}}}(导言区)",
"在导言区调大行距设置",
)
return _chk("spacing", "行距", "pass", detail)
if ratio is None:
return _chk("spacing", "行距", "warn", "未能提取行距信息", "确认段落使用固定/多倍行距设置")
lo = float(rules.get("min_line_spacing", 1.15))
detail = f"正文中位行距约 {ratio:.2f} 倍,规则下限 {lo:.2f} 倍"
if ratio >= lo:
return _chk("spacing", "行距", "pass", detail)
if ratio >= lo * 0.9:
return _chk("spacing", "行距", "warn", detail, f"段落行距设为 ≥{lo:.2f} 倍(如 1.5 倍行距)")
return _chk("spacing", "行距", "fail", detail, f"行距过密,请设为 ≥{lo:.2f} 倍行距")
def _check_figures(profile: dict[str, Any], rules: dict[str, Any]) -> dict[str, str]:
figures = int(profile.get("figures") or 0)
tables = int(profile.get("tables") or 0)
min_f = int(rules.get("min_figures", 1))
detail = f"检测到图 {figures} 张、表 {tables} 个(图注/表注正则 + 内嵌对象启发式)"
if figures >= min_f:
return _chk("figures", "图表数量", "pass", detail)
# 启发式检测不可靠 → 低于阈值只 warn 不 fail
return _chk(
"figures", "图表数量", "warn", detail,
f"常规研究论文建议 ≥{min_f} 图;若为综述/短文可忽略(本项为启发式检测)",
)
# 仅"明确不符"才 fail 的 (expected, detected) 组合(宽容化:其余含糊组合一律 warn)
_REF_FAIL_PAIRS = {
("apa", "gb7714"), # 找到 [J] 标引但规则要求 APA
("gb7714", "author_year"), # 无 [J] 标引的作者-年份制,但规则要求 GB/T 7714
("apa", "numbered"), # 编号制引文但规则要求 APA
}
def _check_refs(profile: dict[str, Any], rules: dict[str, Any]) -> dict[str, str]:
expected = str(rules.get("refs_style", "apa")).lower()
exp_label = _STYLE_LABEL.get(expected, expected)
if not profile.get("refs_found"):
return _chk(
"refs", "参考文献格式", "warn",
"未找到参考文献节(References/参考文献 / \\bibliography / thebibliography)",
"确认存在参考文献声明后重查",
)
if profile.get("source_kind") == "tex":
tex = profile.get("tex") or {}
# BibTeX:\bibliography 存在但缺 \bibliographystyle → warn(样式引擎未知)
if tex.get("bib_bibtex") and not tex.get("bib_style"):
return _chk(
"refs", "参考文献格式", "warn",
f"规则要求 {exp_label};检测到 \\bibliography 但缺少 \\bibliographystyle(BibTeX 无法确定引用样式)"
+ "\n建议:\n \\bibliographystyle{plain} % 按期刊/模板指定实际样式",
"在 \\bibliography 前补 \\bibliographystyle",
)
if not profile.get("refs_text"):
# 条目在 .bbl/编译产物,源文件无文本 → 宽容化只 warn
return _chk(
"refs", "参考文献格式", "warn",
f"规则要求 {exp_label};检测到参考文献声明(条目由编译生成,源文件无条目文本)",
"编译后核对参考文献条目样式",
)
detected, sample = detect_refs_style(profile.get("refs_text"))
det_label = _STYLE_LABEL[detected]
detail = f"规则要求 {exp_label};检测到 {det_label};样例:{sample}"
# 宽容化:仅"明确不符"(_REF_FAIL_PAIRS)才 fail;含糊/不确定一律 warn
if (expected, detected) in _REF_FAIL_PAIRS:
return _chk("refs", "参考文献格式", "fail", detail, f"按 {exp_label} 调整全部文献条目(可用文献管理工具一键转换)")
if detected == "unknown":
return _chk("refs", "参考文献格式", "warn", detail, f"请人工核对是否符合 {exp_label}")
if expected == "gb7714" and detected == "numbered":
return _chk("refs", "参考文献格式", "warn", detail, "GB/T 7714 顺序编码制也用编号,需人工确认条目含 [J] 等类型标引")
if expected == "author-year-journal" and detected in ("gb7714", "numbered"):
return _chk("refs", "参考文献格式", "warn", detail, f"请对照期刊投稿指南核对是否符合 {exp_label}")
return _chk("refs", "参考文献格式", "pass", detail)
def _check_font_packages(profile: dict[str, Any], rules: dict[str, Any]) -> dict[str, str]:
"""LaTeX 特有:多个相互覆盖的字体宏包(times + mathptmx 类)→ warn。"""
pkgs = set((profile.get("tex") or {}).get("packages") or [])
clash = sorted(pkgs & _TEX_FONT_PKGS)
if len(clash) >= 2:
detail = (
f"检测到多个相互覆盖的字体宏包:{'、'.join(clash)}(后加载者覆盖前者,易产生冲突)"
+ "\n建议:\n %只保留一套字体方案,例如:\n \\usepackage{newtx} % newtxtext + newtxmath 一体"
)
return _chk("fontpkgs", "字体包兼容", "warn", detail, "移除重复/冲突的字体宏包,只保留一套")
return _chk(
"fontpkgs", "字体包兼容", "pass",
"字体宏包:" + ("、".join(clash) if clash else "未引入冲突类字体包"),
)
def _check_anonymous(profile: dict[str, Any], rules: dict[str, Any]) -> dict[str, str]:
"""LaTeX 特有且规则集 anonymous:true 时才出:源文件需含匿名处理命令。"""
tex = profile.get("tex") or {}
cmd = tex.get("anon_cmd")
if cmd:
return _chk("anonymous", "匿名评审", "pass", f"检测到匿名处理命令 {cmd}")
return _chk(
"anonymous", "匿名评审", "warn",
"规则要求匿名评审,源文件未检测到匿名处理命令"
+ "\n建议:\n %隐去作者/致谢/自引,或定义匿名宏:\n \\newcommand{\\anon}[1]{[ANON]}",
"按期刊要求对作者信息做匿名处理",
)
def build_checks(profile: dict[str, Any], rules: dict[str, Any]) -> list[dict[str, str]]:
checks = [
_check_pages(profile, rules),
_check_fonts(profile, rules),
_check_margins(profile, rules),
_check_spacing(profile, rules),
]
if profile.get("source_kind") == "tex":
checks.append(_check_font_packages(profile, rules))
checks.append(_check_figures(profile, rules))
checks.append(_check_refs(profile, rules))
if profile.get("source_kind") == "tex" and rules.get("anonymous"):
checks.append(_check_anonymous(profile, rules))
return checks
# ---------------------------------------------------------------- 入口
def check_document(data: bytes, kind: str, target_journal: str) -> dict[str, Any]:
"""kind ∈ pdf|docx|tex。返回 {checks, summary, meta}(filename 由路由器补入 meta)。"""
if kind == "pdf":
profile = extract_profile_pdf(data)
elif kind == "docx":
profile = extract_profile_docx(data)
elif kind == "tex":
profile = extract_tex_profile(data)
else:
raise FormatCheckError(f"不支持的文件类型:{kind}")
rules, ruleset_meta = load_ruleset(target_journal)
checks = build_checks(profile, rules)
summary = {"pass": 0, "warn": 0, "fail": 0}
for c in checks:
summary[c["status"]] = summary.get(c["status"], 0) + 1
detected, _ = detect_refs_style(profile.get("refs_text")) if profile.get("refs_found") else ("unknown", "")
meta = {
**ruleset_meta,
"file_type": kind,
"pages": profile.get("pages"),
"refs_style_expected": str(rules.get("refs_style", "apa")).lower(),
"refs_style_detected": detected,
}
if kind == "tex":
tex = profile.get("tex") or {}
meta["tex"] = {
"class": tex.get("class"),
"fontsize_pt": tex.get("fontsize_pt"),
"paper": tex.get("paper"),
"packages": tex.get("packages"),
"cites": tex.get("cites"),
"macros": (tex.get("macros") or [])[:10],
}
return {"checks": checks, "summary": summary, "meta": meta}
@@ -0,0 +1,232 @@
"""services/format_extractor.py —— 投稿指南 → 格式规则 JSON(LLM 抽取,第一批)。
- 调 services/llm.py 的 complete_json(DeepSeek json_object + 修复重试,零新依赖)
- Prompt = 「格式规则抽取器」模板:目标字段与 journals.json format_rules_default
同构(max_pages / min_font_pt / min_margin_mm / min_line_spacing / min_figures /
max_fonts / refs_style / anonymous),强制每字段带 source_quotes + confidence
- 诚实原则:正文没写 → null,绝不编造(与 format_check 诚实原则一致)
- 解析/校验失败 → {"ok": False, "error": ...}
返回:
成功 {"ok": True, "rules": {...}, "source_quotes": {...},
"confidence": {...}, "notes": str, "raw": LLM原始JSON, "error": None}
失败 {"ok": False, "error": "...", 其余键为 None}
source_url 由调用方(scripts/fetch_format_rules.py)在落盘时补入结果文件。
"""
from __future__ import annotations
import logging
import re
from typing import Any
from .llm import LLMError, complete_json
logger = logging.getLogger(__name__)
# 「格式规则抽取器」模板(system prompt)
_SYSTEM = """你是「格式规则抽取器」:从期刊官方投稿指南的 Markdown 正文中抽取格式规则,供投稿排版检查引擎使用。
# 目标字段(rules 对象,严格用以下字段名)
- max_pages:正文页数上限(整数;原文无明确上限 → null)
- min_font_pt:最小字号(数字,单位 pt;原文若用 cm/mm/inch 给出则换算为 pt)
- min_margin_mm:最小页边距(数字,单位 mm;原文若用 inch 给出则 ×25.4 换算)
- min_line_spacing:最小行距倍数(数字;如 "double-spaced" → 2.0,"1.5 spacing" → 1.5)
- min_figures:研究论文最少图数(整数;无明确要求 → null)
- max_fonts:全文最多字体种类(整数;无明确要求 → null)
- refs_style:参考文献样式,枚举 apa | gb7714 | author_year | numbered | vancouver | mla | chicago | other(无法判断 → null)
- anonymous:是否要求匿名/双盲评审(true / false / null)
# 凭证(强制)
- 每个非 null 字段必须给 source_quotes[字段名]:从正文【逐字】摘录的 1-3 句原文,
不改写、不翻译、不丢掉含关键数字的句子。
- source_quotes["_general"]:整体最有凭证价值的 1-3 条原文摘录。
- confidence:{"overall": 0-1, "per_field": {"字段名": 0-1}}。
直接原文命中 ≥0.9;单位换算 0.6-0.9;靠上下文推断 <0.6。
# 诚实原则
- 正文没写到的字段一律 null,绝不用常识或训练记忆补值。
- 单位换算允许做,但该字段 confidence 不得超过 0.9。
- 换算过程、拿不准的点、页面疑似不完整,写进 notes。
# 输出
只输出一个 JSON 对象,不要代码块、不要解释文字:
{"rules": {"max_pages": null, "min_font_pt": null, "min_margin_mm": null,
"min_line_spacing": null, "min_figures": null, "max_fonts": null,
"refs_style": null, "anonymous": null},
"source_quotes": {"字段名": ["原文1", "..."], "_general": ["原文1", "..."]},
"confidence": {"overall": 0.0, "per_field": {"字段名": 0.0}},
"notes": "一句话说明抽取依据与不确定点"}"""
_USER_TMPL = """期刊:{journal_name}
=== 官方投稿指南正文(Markdown,已截断) ===
{markdown}"""
_RULE_INT_KEYS = ("max_pages", "min_figures", "max_fonts")
_REFS_ALLOWED = {"apa", "gb7714", "author_year", "numbered", "vancouver",
"mla", "chicago", "other"}
_REFS_ALIAS = {
"apa-7": "apa", "apa7": "apa", "apa 7th": "apa", "apa_7th": "apa",
"harvard": "author_year", "author-year": "author_year", "authoryear": "author_year",
"gb/t 7714": "gb7714", "gb/t-7714": "gb7714", "gb7714-2015": "gb7714",
"gb/t7714": "gb7714", "chicago author-date": "chicago",
}
# 字符串值带显式单位时的目标单位换算表(LLM 若回传原文单位的兜底;无单位=数值即目标单位)
_UNIT_TO_MM = {"mm": 1.0, "cm": 10.0, "in": 25.4, "inch": 25.4, "inches": 25.4, "pt": 25.4 / 72}
_UNIT_TO_PT = {"pt": 1.0, "mm": 72 / 25.4, "cm": 72 / 2.54, "in": 72.0, "inch": 72.0, "inches": 72.0}
def _to_int(v: Any) -> int | None:
if isinstance(v, bool) or v is None:
return None
if isinstance(v, int):
return v
if isinstance(v, float):
return int(round(v))
if isinstance(v, str):
m = re.search(r"-?\d+", v)
if m:
return int(m.group(0))
return None
def _to_float(v: Any, units: dict[str, float] | None = None) -> float | None:
if isinstance(v, bool) or v is None:
return None
if isinstance(v, (int, float)):
return float(v)
if isinstance(v, str):
m = re.search(r"(-?\d+(?:\.\d+)?)\s*([a-zA-Z]+)?", v)
if not m:
return None
x = float(m.group(1))
unit = (m.group(2) or "").lower()
if units and unit:
factor = units.get(unit)
if factor is None:
return None # 未知单位不猜
x *= factor
return x
return None
def _to_bool(v: Any) -> bool | None:
if isinstance(v, bool):
return v
if isinstance(v, (int, float)) and not isinstance(v, bool):
if v in (0, 1):
return bool(v)
if isinstance(v, str):
low = v.strip().lower()
if low in ("true", "yes", "required"):
return True
if low in ("false", "no", "not required"):
return False
return None
def _norm_refs(v: Any) -> str | None:
if not isinstance(v, str) or not v.strip():
return None
low = v.strip().lower()
low = _REFS_ALIAS.get(low, low)
return low if low in _REFS_ALLOWED else "other"
def _norm_conf(v: Any) -> float | None:
if isinstance(v, bool) or not isinstance(v, (int, float)):
return None
return max(0.0, min(1.0, float(v)))
def _normalize(raw: dict[str, Any]) -> dict[str, Any]:
"""白名单归一:类型错的值置 null(宁缺勿错,raw 全量保留在结果里)。"""
src = raw.get("rules")
src = src if isinstance(src, dict) else {}
rules: dict[str, Any] = {}
for k in _RULE_INT_KEYS:
rules[k] = _to_int(src.get(k))
rules["min_font_pt"] = _to_float(src.get("min_font_pt"), _UNIT_TO_PT)
rules["min_margin_mm"] = _to_float(src.get("min_margin_mm"), _UNIT_TO_MM)
rules["min_line_spacing"] = _to_float(src.get("min_line_spacing"))
rules["refs_style"] = _norm_refs(src.get("refs_style"))
rules["anonymous"] = _to_bool(src.get("anonymous"))
sq_src = raw.get("source_quotes")
sq_src = sq_src if isinstance(sq_src, dict) else {}
quotes: dict[str, list[str]] = {}
for key, val in sq_src.items():
if key != "_general" and key not in rules:
continue
items = val if isinstance(val, list) else [val]
cleaned = [str(x).strip() for x in items if isinstance(x, (str, int, float))
and str(x).strip()]
if cleaned:
quotes[key] = cleaned
conf_src = raw.get("confidence")
conf_src = conf_src if isinstance(conf_src, dict) else {}
pf_src = conf_src.get("per_field")
pf_src = pf_src if isinstance(pf_src, dict) else {}
per_field: dict[str, float] = {}
for k, v in pf_src.items():
c = _norm_conf(v)
if c is not None and (k in rules or k == "_general"):
per_field[k] = c
overall = _norm_conf(conf_src.get("overall"))
if overall is None and per_field:
overall = sum(per_field.values()) / len(per_field)
confidence = {"overall": overall, "per_field": per_field}
notes = raw.get("notes")
return {
"rules": rules,
"source_quotes": quotes,
"confidence": confidence,
"notes": str(notes) if notes else "",
}
def _validation_error(norm: dict[str, Any]) -> str | None:
"""强制凭证:任一非 null 字段缺 source_quotes → 拒收(ok=False)。"""
missing = [k for k, v in norm["rules"].items()
if v is not None and k not in norm["source_quotes"]]
if missing:
return f"LLM 输出缺少非 null 字段的 source_quotes:{', '.join(missing)}"
if not norm["source_quotes"].get("_general"):
return "LLM 输出缺少 source_quotes._general 整体引证"
if any(v is not None for v in norm["rules"].values()):
return None
return "LLM 未抽出任何非 null 字段(rules 全空)"
async def extract_format_rules(markdown: str, journal_name: str) -> dict:
"""markdown(≤30000 字符)+ 期刊名 → 抽取结果(schema 见模块 docstring)。"""
if not markdown or not markdown.strip():
return {"ok": False, "error": "markdown 为空", "rules": None,
"source_quotes": None, "confidence": None, "notes": None, "raw": None}
user = _USER_TMPL.format(journal_name=journal_name, markdown=markdown)
try:
raw = await complete_json(_SYSTEM, user, temperature=0.1, max_tokens=8000)
except LLMError as exc:
return {"ok": False, "error": f"LLM 调用失败:{exc}", "rules": None,
"source_quotes": None, "confidence": None, "notes": None, "raw": None}
if not isinstance(raw, dict):
return {"ok": False, "error": f"LLM 输出非 JSON 对象:{type(raw).__name__}",
"rules": None, "source_quotes": None, "confidence": None,
"notes": None, "raw": None}
norm = _normalize(raw)
err = _validation_error(norm)
if err:
logger.warning("[extract] %s 校验失败:%s", journal_name, err)
return {"ok": False, "error": err, "rules": None,
"source_quotes": norm["source_quotes"], "confidence": norm["confidence"],
"notes": norm["notes"], "raw": raw}
filled = sum(1 for v in norm["rules"].values() if v is not None)
logger.info("[extract] %s → 8 字段中 %d 个非 null", journal_name, filled)
return {"ok": True, "error": None, "rules": norm["rules"],
"source_quotes": norm["source_quotes"],
"confidence": norm["confidence"], "notes": norm["notes"], "raw": raw}
@@ -0,0 +1,356 @@
"""services/format_fetcher.py —— 投稿指南页面抓取(格式规则自动获取·第一批)。
约束(用户拍板 2026-10-10):
- 零新依赖:httpx + beautifulsoup4(venv 已装 4.15.0,用户明确指定使用;
lxml 6.1.2 在 requirements.lock 内,作 bs4 解析器)
- 同域限速 ≥ 2s(robots.txt 与正文请求共用计时器)
- 抓正文前先 GET /robots.txt,命中 Disallow → ok=False 跳过
- UA: "PlantAI-RAG-Bot/1.0 (+contact: admin@example.com)"
- 只提取正文(去导航/页脚/脚本),截断至 30000 字符
返回固定四键:{"ok": bool, "markdown": str, "error": str | None, "fetched_at": str}
"""
from __future__ import annotations
import asyncio
import logging
import re
import time
from datetime import datetime, timezone
from urllib.parse import urlparse
import httpx
from bs4 import BeautifulSoup, NavigableString
logger = logging.getLogger(__name__)
USER_AGENT = "PlantAI-RAG-Bot/1.0 (+contact: admin@example.com)"
TIMEOUT = 30.0
MAX_CHARS = 30_000
MIN_INTERVAL_PER_HOST = 2.0 # 同域请求间隔 ≥ 2s(robots 与正文共用)
_MIN_TEXT_CHARS = 200 # 正文短于此 → 疑似 JS 渲染/反爬页
_HEADERS = {
"User-Agent": USER_AGENT,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
}
# 同域限速:host → 上次请求完成时刻(monotonic)
_last_hit: dict[str, float] = {}
_throttle_lock = asyncio.Lock()
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def _fail(error: str) -> dict:
return {"ok": False, "markdown": "", "error": error, "fetched_at": _now_iso()}
async def _throttle(host: str) -> None:
"""同域串行 + ≥2s 间隔(全局锁:本批抓取量小,简单可靠优先)。"""
async with _throttle_lock:
now = time.monotonic()
wait = _last_hit.get(host, now) + MIN_INTERVAL_PER_HOST - now
if wait > 0:
await asyncio.sleep(wait)
_last_hit[host] = time.monotonic()
# ---------------------------------------------------------------- robots.txt
def _parse_robots(text: str) -> list[tuple[list[str], list[tuple[str, str]]]]:
"""→ [(agents, [(kind, pattern)...]), ...](连续 UA 行同组;规则行后出现 UA=新组)。"""
groups: list[tuple[list[str], list[tuple[str, str]]]] = []
agents: list[str] = []
rules: list[tuple[str, str]] = []
for raw in text.splitlines():
line = raw.split("#", 1)[0].strip()
if not line or ":" not in line:
continue
key, _, value = line.partition(":")
key, value = key.strip().lower(), value.strip()
if key == "user-agent":
if agents and rules: # 上一组已出规则 → 当前 UA 行开新组
groups.append((agents, rules))
agents, rules = [], []
agents.append(value)
elif key in ("allow", "disallow"):
if not agents: # 无前置 UA 的规则按 * 组处理
agents = ["*"]
rules.append((key, value))
if agents:
groups.append((agents, rules))
return groups
def _robots_path_allowed(groups, path: str) -> tuple[bool, str]:
"""最长匹配决胜(同长 Allow 优先;`$` 结尾=精确匹配;空 Disallow 跳过)。"""
def matched_by(pat: str) -> bool:
if pat.endswith("$"):
return path == pat[:-1]
return path.startswith(pat)
token = "plantai-rag-bot"
matched = [rules for agents, rules in groups
if any(token in a.lower() for a in agents)]
if not matched:
matched = [rules for agents, rules in groups
if any(a.lower() == "*" for a in agents)]
if not matched:
return True, "robots.txt 无匹配组(本 bot 不受限)"
best_len, best_allow, best_pat = -1, False, ""
for rules in matched:
for kind, pat in rules:
if not pat: # "Disallow:" 空值 = 放行一切(该组无需处理)
continue
if not matched_by(pat):
continue
length = len(pat)
allow = kind == "allow"
if length > best_len or (length == best_len and allow and not best_allow):
best_len, best_allow, best_pat = length, allow, pat
if best_len < 0:
return True, "robots.txt 允许(无路径匹配)"
if best_allow:
return True, f"robots.txt Allow 命中({best_pat})"
return False, f"robots.txt Disallow 命中({best_pat})"
async def _robots_allows(client: httpx.AsyncClient, url: str) -> tuple[bool, str]:
p = urlparse(url)
robots_url = f"{p.scheme}://{p.netloc}/robots.txt"
await _throttle(p.netloc.lower())
try:
resp = await client.get(robots_url, headers=_HEADERS)
except httpx.HTTPError as exc:
return True, f"robots.txt 获取失败({type(exc).__name__}),按允许处理"
if resp.status_code != 200:
return True, f"robots.txt HTTP {resp.status_code},按允许处理"
groups = _parse_robots(resp.text)
return _robots_path_allowed(groups, p.path or "/")
# ---------------------------------------------------------------- 正文提取
_STRIP_TAGS = ("script", "style", "noscript", "iframe", "form", "nav",
"footer", "aside", "button", "select", "textarea", "input", "svg")
_STRIP_ROLES = ("navigation", "contentinfo", "complementary", "search")
# 正文容器候选(按优先级;首个文本 ≥500 字符者胜出)
_CONTENT_SELECTORS = (
"main", "article", '[role="main"]', "#content", "#main", "#main-content",
".article-body", "#article-body", ".article__body", ".hub-article-body",
".page-content", ".main-content", ".content-area",
)
_CHALLENGE_MARKERS = ("just a moment", "attention required", "captcha",
"access denied", "ddos protection", "verify you are human")
def _decode_html(resp: httpx.Response) -> str:
"""header charset → <meta charset> → utf-8(errors=replace),防 OUP/Wiley 无 header 乱码。"""
enc = None
m = re.search(r"charset=([\w.-]+)", resp.headers.get("content-type", ""), re.I)
if m:
enc = m.group(1)
if not enc:
m = re.search(rb"""charset\s*=\s*["']?([\w.-]+)""", resp.content[:4096], re.I)
if m:
enc = m.group(1).decode("ascii", "ignore")
try:
return resp.content.decode(enc or "utf-8", errors="replace")
except LookupError:
return resp.content.decode("utf-8", errors="replace")
def _squeeze(s: str) -> str:
"""行内空白压缩 + 去空行(保留单换行)。"""
lines = [re.sub(r"[ \t\r\f\v]+", " ", line).strip() for line in s.split("\n")]
return "\n".join(line for line in lines if line)
def _inline(el) -> str:
"""行内渲染:a→md链接 / strong→** / em→* / code→` / br→换行;跳过列表与图片。"""
parts: list[str] = []
for child in el.children:
name = getattr(child, "name", None)
if name is None: # NavigableString
parts.append(str(child))
continue
if name in ("ul", "ol", "img", "picture", "figure"):
continue
if name == "a":
href = (child.get("href") or "").strip()
txt = _inline(child).strip()
if not txt:
continue
parts.append(f"[{txt}]({href})" if href.startswith(("http://", "https://")) else txt)
elif name in ("strong", "b"):
parts.append(f"**{_inline(child)}**")
elif name in ("em", "i"):
parts.append(f"*{_inline(child)}*")
elif name == "code":
parts.append(f"`{_inline(child).strip()}`")
elif name == "br":
parts.append("\n")
else:
parts.append(_inline(child))
return "".join(parts)
def _list_md(el, ordered: bool) -> str:
lines: list[str] = []
idx = 1
for li in el.find_all("li", recursive=False):
# li 文本不含嵌套列表(嵌套列表随后缩进输出)
buf = []
for child in li.children:
if getattr(child, "name", None) in ("ul", "ol"):
continue
buf.append(_inline(child) if getattr(child, "name", None) else str(child))
txt = " ".join("".join(buf).split())
if txt:
lines.append(f"{idx}. {txt}" if ordered else f"- {txt}")
idx += 1
for nested in li.find_all(("ul", "ol"), recursive=False):
sub = _list_md(nested, nested.name == "ol")
if sub:
lines.extend(" " + line for line in sub.split("\n"))
return "\n".join(lines)
def _table_md(table) -> str | None:
rows: list[list[str]] = []
for tr in table.find_all("tr"):
cells = [" ".join(_squeeze(_inline(c)).split())
for c in tr.find_all(("td", "th"))]
if cells:
rows.append(cells)
if not rows:
return None
out = ["| " + " | ".join(rows[0]) + " |"]
if table.find("th"): # 首行含表头 → 补分隔行(合法 GFM 表)
out.append("| " + " | ".join(["---"] * len(rows[0])) + " |")
out += ["| " + " | ".join(r) + " |" for r in rows[1:]]
return "\n".join(out)
def _blocks(el, out: list[str]) -> None:
for child in el.children:
name = getattr(child, "name", None)
if name is None: # 裸文本
txt = _squeeze(str(child))
if txt:
out.append(txt)
continue
if name in _STRIP_TAGS:
continue
if name in ("h1", "h2", "h3", "h4", "h5", "h6"):
txt = " ".join(_squeeze(_inline(child)).split())
if txt:
out.append("#" * int(name[1]) + " " + txt)
elif name == "p":
txt = _squeeze(_inline(child))
if txt:
out.append(txt)
elif name in ("ul", "ol"):
md = _list_md(child, name == "ol")
if md:
out.append(md)
elif name == "table":
md = _table_md(child)
if md:
out.append(md)
elif name == "blockquote":
txt = _squeeze(_inline(child))
if txt:
out.append("\n".join("> " + line for line in txt.split("\n")))
elif name == "pre":
code = child.get_text().strip("\n")
if code.strip():
out.append("```\n" + code + "\n```")
elif name == "hr":
out.append("---")
else: # div/section/article/main/figure/dl/details/… 容器递归
_blocks(child, out)
def _html_to_markdown(html: str) -> tuple[str, str | None]:
"""→ (markdown, error)。选正文容器 → 去壳 → DOM 递归转 Markdown。"""
soup = BeautifulSoup(html, "lxml") # lxml 在 lock 内;缺时 bs4 自动回退 html.parser
title = (soup.title.get_text() if soup.title else "") or ""
for tag in soup.find_all(_STRIP_TAGS):
tag.decompose()
for tag in soup.find_all(attrs={"role": _STRIP_ROLES}):
tag.decompose()
container = None
fallback_hits = []
for sel in _CONTENT_SELECTORS:
for node in soup.select(sel):
text_len = len(node.get_text(" ", strip=True))
fallback_hits.append((text_len, node))
if text_len >= 500:
container = node
break
if container is not None:
break
if container is None:
if fallback_hits:
container = max(fallback_hits, key=lambda x: x[0])[1]
else:
container = soup.body or soup
blocks: list[str] = []
_blocks(container, blocks)
md = "\n\n".join(blocks)
probe = (title + " " + md[:500]).lower()
marker = next((m for m in _CHALLENGE_MARKERS if m in probe), None)
if marker:
return "", f"疑似反爬/挑战页(命中 {marker!r},title={title[:80]!r})"
if len(md) < _MIN_TEXT_CHARS:
return "", f"正文提取过短({len(md)} < {_MIN_TEXT_CHARS} 字符),疑似 JS 渲染或空壳页"
if len(md) > MAX_CHARS:
md = md[:MAX_CHARS - 1] + "…"
return md, None
# ---------------------------------------------------------------- 入口
async def fetch_submission_guidelines(url: str) -> dict:
"""抓投稿指南页面 → 正文 Markdown。
流程:robots.txt 检查(≥2s 限速)→ 禁止即跳过 → GET 正文(≥2s 限速)
→ 状态/类型校验 → 正文提取 → 截断 30000 字符。
"""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return _fail(f"非法 URL:{url!r}")
host = parsed.netloc.lower()
async with httpx.AsyncClient(follow_redirects=True, timeout=TIMEOUT) as client:
allowed, robots_reason = await _robots_allows(client, url)
if not allowed:
logger.info("[fetch] robots 禁止,跳过 %s(%s)", url, robots_reason)
return _fail(robots_reason)
await _throttle(host)
try:
resp = await client.get(url, headers=_HEADERS)
except httpx.HTTPError as exc:
return _fail(f"请求失败({type(exc).__name__}):{exc}")
if resp.status_code != 200:
return _fail(f"HTTP {resp.status_code}")
ctype = resp.headers.get("content-type", "")
if "html" not in ctype and "xhtml" not in ctype:
return _fail(f"非 HTML 响应:{ctype or '无 Content-Type'}")
markdown, err = _html_to_markdown(_decode_html(resp))
if err:
return _fail(f"{err}(robots: {robots_reason})")
logger.info("[fetch] %s → %d 字符(robots: %s)", url, len(markdown), robots_reason)
return {"ok": True, "markdown": markdown, "error": None, "fetched_at": _now_iso()}
+223
View File
@@ -0,0 +1,223 @@
"""services/journal_kb.py —— 期刊 scope_desc → vstore 入库 + 向量匹配(只读消费 8094)。
链路(全部复用 vstore 既有 API,零改动):
1. ensure_scope_docs:各期刊 scope_desc 以 .txt 入库(category=journal_scope,
name=期刊名),document_id 回写 journals.vstore_doc_id(幂等:已有 id 不重灌)。
2. match:GET /api/vstore/search-chunks?q=<摘要截断>&doc_ids=<id 白名单>&top_k=N
→ 按 doc_id 映射回期刊,取该期刊最高 chunk 分数为 similarity。
3. 自愈:白名单命中 0 条 → GET /documents/{id} 探活 → 失效的重灌(vstore 去重
语义下同内容重灌会换 doc_id,需回写)→ 重试一次 → 仍 0 视为语义低相似。
错误语义:连通性/上游异常抛 MatchError(路由降级为全量候选);
返回 ([], False) 表示检索成功但无有效匹配(路由同样降级)。
"""
from __future__ import annotations
import logging
from typing import Any
import httpx
from ..config import get_settings
from ..db import set_vstore_doc_id
logger = logging.getLogger(__name__)
SCOPE_CATEGORY = "journal_scope"
class MatchError(Exception):
"""vstore 向量匹配失败(不可达/响应异常)。"""
def _timeout() -> httpx.Timeout:
t = get_settings().vstore_match_timeout
return httpx.Timeout(connect=5.0, read=t, write=t, pool=5.0)
def _err_body(resp: httpx.Response) -> str:
try:
payload = resp.json()
except ValueError:
return (resp.text or "").strip()[:500] or "(空响应体)"
if isinstance(payload, dict):
err = payload.get("error")
if isinstance(err, dict) and err.get("message"):
return str(err["message"])
if isinstance(err, str):
return err
return str(payload)[:500]
async def _ingest_scope(client: httpx.AsyncClient, base: str, journal: dict[str, Any]) -> str:
"""单本期刊 scope_desc 入库,返回 document_id。"""
content = str(journal.get("scope_desc") or "").strip()
if not content:
raise MatchError(f"期刊 scope_desc 为空:{journal.get('id')}")
jid = str(journal.get("id") or "")
try:
resp = await client.post(
f"{base}/api/vstore/ingest",
files={"file": (f"{jid}.txt", content.encode("utf-8"), "text/plain")},
data={
"name": str(journal.get("name") or jid),
"category": SCOPE_CATEGORY,
"description": "review_service 期刊范围种子(scope_desc 向量匹配)",
},
)
except httpx.HTTPError as exc:
raise MatchError(f"vstore 不可达({base}):{exc}") from exc
if resp.status_code >= 400:
raise MatchError(f"vstore ingest 失败(HTTP {resp.status_code}):{_err_body(resp)}")
try:
payload = resp.json()
except ValueError as exc:
raise MatchError(f"vstore ingest 响应非 JSON:{exc}") from exc
if not isinstance(payload, dict) or not payload.get("ok"):
raise MatchError(
str((payload.get("error") or {}).get("message") if isinstance(payload, dict) else "") or "vstore ingest 失败"
)
doc_id = str((payload.get("item") or {}).get("document_id") or "")
if not doc_id:
raise MatchError("vstore ingest 响应缺少 document_id")
return doc_id
async def ensure_scope_docs(journals: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""补齐缺失的 scope 入库映射(就地回写 vstore_doc_id 并持久到 DB)。"""
settings = get_settings()
missing = [j for j in journals if not j.get("vstore_doc_id")]
if not missing:
return journals
async with httpx.AsyncClient(timeout=_timeout()) as client:
for j in missing:
try:
doc_id = await _ingest_scope(client, settings.vstore_base, j)
except MatchError as exc:
# 单本失败不阻断其余(该期刊将不进白名单,由路由降级兜底)
logger.warning("journal_kb: scope 入库失败(%s):%s", j.get("id"), exc)
continue
j["vstore_doc_id"] = doc_id
await set_vstore_doc_id(str(j["id"]), doc_id)
return journals
async def _search(
client: httpx.AsyncClient, base: str, query: str, doc_ids: list[str]
) -> list[dict[str, Any]]:
"""chunk 级白名单检索。连通/上游错误抛 MatchError。"""
if not doc_ids:
# doc_ids 为空时 vstore 侧等于不过滤 → 会命中用户私有库,必须拦死
raise MatchError("白名单为空,拒绝全库检索")
try:
resp = await client.get(
f"{base}/api/vstore/search-chunks",
params={
"q": query,
"top_k": get_settings().recommend_scope_top_k,
"doc_ids": ",".join(doc_ids),
},
)
except httpx.HTTPError as exc:
raise MatchError(f"vstore search-chunks 不可达:{exc}") from exc
if resp.status_code >= 400:
raise MatchError(f"vstore search-chunks 失败(HTTP {resp.status_code}):{_err_body(resp)}")
try:
payload = resp.json()
except ValueError as exc:
raise MatchError(f"vstore search-chunks 响应非 JSON:{exc}") from exc
if not isinstance(payload, dict) or not payload.get("ok"):
raise MatchError(
str((payload.get("error") or {}).get("message") if isinstance(payload, dict) else "") or "vstore search-chunks 失败"
)
items = payload.get("items")
return items if isinstance(items, list) else []
async def _doc_alive(client: httpx.AsyncClient, base: str, doc_id: str) -> bool:
try:
resp = await client.get(f"{base}/api/vstore/documents/{doc_id}")
except httpx.HTTPError:
return False
if resp.status_code != 200:
return False
try:
payload = resp.json()
except ValueError:
return False
return bool(isinstance(payload, dict) and payload.get("ok"))
async def _refresh_dead_docs(
client: httpx.AsyncClient, base: str, journals: list[dict[str, Any]]
) -> None:
"""白名单 0 命中时探活:失效文档重灌并回写新 id。"""
for j in journals:
doc_id = str(j.get("vstore_doc_id") or "")
if not doc_id or await _doc_alive(client, base, doc_id):
continue
try:
new_id = await _ingest_scope(client, base, j)
except MatchError as exc:
logger.warning("journal_kb: scope 重灌失败(%s):%s", j.get("id"), exc)
continue
j["vstore_doc_id"] = new_id
await set_vstore_doc_id(str(j["id"]), new_id)
async def match(
abstract: str, journals: list[dict[str, Any]]
) -> tuple[list[dict[str, Any]], bool]:
"""摘要 vs scope_desc 向量匹配。
返回 (candidates, vector_ok);candidates 元素:
{"journal": <journals 行>, "similarity": float}
按 similarity 降序。MatchError → 路由降级;([], False) → 语义低相似。
"""
settings = get_settings()
query = abstract.strip()[: settings.recommend_query_chars]
if not query:
return [], False
await ensure_scope_docs(journals)
whitelisted = [j for j in journals if j.get("vstore_doc_id")]
if not whitelisted:
raise MatchError("无任何期刊 scope 入库成功(vstore_doc_id 为空)")
doc_map = {str(j["vstore_doc_id"]): j for j in whitelisted}
timeout = _timeout()
async with httpx.AsyncClient(timeout=timeout) as client:
items = await _search(client, settings.vstore_base, query, list(doc_map))
if not items:
await _refresh_dead_docs(client, settings.vstore_base, whitelisted)
doc_map = {str(j["vstore_doc_id"]): j for j in whitelisted if j.get("vstore_doc_id")}
if not doc_map:
return [], False
items = await _search(client, settings.vstore_base, query, list(doc_map))
# 聚合:每本期刊取最高 chunk 分
best: dict[str, float] = {}
for item in items:
if not isinstance(item, dict):
continue
j = doc_map.get(str(item.get("doc_id") or ""))
if j is None:
continue
try:
score = float(item.get("score"))
except (TypeError, ValueError):
continue
jid = str(j["id"])
if jid not in best or score > best[jid]:
best[jid] = score
if not best:
return [], False
by_id = {str(j["id"]): j for j in journals}
candidates = [
{"journal": by_id[jid], "similarity": score}
for jid, score in best.items()
if jid in by_id
]
candidates.sort(key=lambda c: c["similarity"], reverse=True)
return candidates, bool(candidates)
+210
View File
@@ -0,0 +1,210 @@
"""services/journal_rank.py —— LLM 重排 + 幻觉防护 + 三档归类。
职责:
1. rerank:把带语义分的候选期刊(来自 journal_kb)+ 用户偏好交给 LLM,
要求按 id 白名单返回 {rankings:[{id, tier, match_reason, risk_note}]}。
2. validate:硬校验——白名单外 id 丢弃(幻觉拦截);tier 非法/LLM 缺漏的
期刊按规则补位(绝不因 LLM 输出丢失候选期刊);期刊名称/指标一律不采信
LLM,最终展示全部用 DB 字段(防止改名型幻觉)。
3. fallback_rankings:LLM 不可用时的确定性规则分档(按 impact_factor)。
三档语义:challenge 挑战型(高影响/慢审稿)、fit 适配型(均衡)、
safe 稳妥型(快速/门槛低)。
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
from ..config import get_settings
from . import llm
TIERS = ("challenge", "fit", "safe")
TIER_LABELS = {
"challenge": "挑战型(高影响因子/慢审稿)",
"fit": "适配型(中等影响/周期适中)",
"safe": "稳妥型(快速发表/门槛较低)",
}
# 规则分档阈值(近似值,可后续配置化)
_CHALLENGE_MIN_IF = 9.0
_SAFE_MAX_IF = 4.0
# tier 同义词归一(LLM 偶发中文标签)
_TIER_ALIASES = {
"挑战": "challenge",
"挑战型": "challenge",
"高风险": "challenge",
"适配": "fit",
"适配型": "fit",
"稳妥": "safe",
"稳妥型": "safe",
"低风险": "safe",
}
SYSTEM_PROMPT = """你是学术投稿推荐专家(植物基因组学方向)。根据给定候选期刊、论文摘要与用户偏好,为**每一个**候选期刊输出推荐分档与理由。
硬规则(违反即视为无效输出):
1. id 白名单:只能使用输入 candidates 中出现的 id;绝对禁止添加、替换或臆造列表之外的任何期刊。
2. 每个输入 id 恰好输出一次,不得遗漏、不得重复;期刊名称等元数据由系统填充,你只输出 id。
3. tier 只能取 challenge | fit | safe:
- challenge(挑战型):高影响因子、审稿较慢或竞争激烈的期刊
- fit(适配型):影响力与发表周期均衡、与论文契合的期刊
- safe(稳妥型):审稿较快、门槛较低、命中概率较高的期刊
4. match_reason:中文,1-2 句,结合摘要的研究主题与该刊 scope_desc 及指标(如有 similarity 一并参考),说明为何匹配/不匹配。
5. risk_note:中文,1-2 句,指出投稿风险(超出用户偏好、IF 门槛偏高、范围匹配偏弱、审稿周期长等);无明显风险写「无」。
6. 用户 preferences 为软约束:不满足偏好的期刊仍必须输出,并在 risk_note 中明确说明。
7. 不确定时宁可保守陈述,禁止编造统计数据(录用率、审稿天数等)。
输出:仅一个 JSON 对象,格式:
{"rankings":[{"id":"...","tier":"challenge","match_reason":"...","risk_note":"..."}]}
不要输出任何 JSON 之外的内容。"""
class RankError(Exception):
"""LLM 输出形态异常(无法按 JSON 解析/结构非法)。"""
@dataclass
class Ranking:
journal_id: str
tier: str
match_reason: str
risk_note: str
def rule_tier(journal: dict[str, Any]) -> str:
"""确定性分档规则(LLM 缺漏/不可用时兜底)。"""
inf = journal.get("impact_factor")
weeks = journal.get("review_time_weeks")
if isinstance(inf, (int, float)):
if inf >= _CHALLENGE_MIN_IF:
return "challenge"
if inf <= _SAFE_MAX_IF:
return "safe"
elif isinstance(weeks, (int, float)) and weeks <= 4:
return "safe"
return "fit"
def build_user_prompt(
abstract: str,
candidates: list[dict[str, Any]],
preferences: dict[str, Any],
) -> str:
"""候选 + 偏好 + 摘要 → 用户消息(JSON)。"""
payload = {
"abstract": abstract.strip()[:4000],
"preferences": preferences or {},
"candidates": [
{
"id": str(c["journal"]["id"]),
"name": str(c["journal"].get("name") or ""),
"impact_factor": c["journal"].get("impact_factor"),
"review_time_weeks": c["journal"].get("review_time_weeks"),
"acceptance_rate": c["journal"].get("acceptance_rate"),
"similarity": c.get("similarity"),
"scope_desc": str(c["journal"].get("scope_desc") or "")[:400],
}
for c in candidates
],
}
return json.dumps(payload, ensure_ascii=False)
def _normalize_tier(raw: Any) -> str | None:
tier = str(raw or "").strip().lower()
tier = _TIER_ALIASES.get(str(raw or "").strip(), tier)
return tier if tier in TIERS else None
def validate_rankings(
payload: Any, candidates: list[dict[str, Any]]
) -> tuple[list[Ranking], int, int]:
"""校验 LLM 输出。
返回 (rankings, dropped_hallucinated, repaired):
- 白名单外 id / 重复 id → 丢弃并计数(repaired 中重复不计);
- tier 非法、LLM 缺漏的 id → 按规则补位(repaired 计数)。
"""
if not isinstance(payload, dict) or not isinstance(payload.get("rankings"), list):
raise RankError("LLM 输出缺少 rankings 数组")
by_id = {str(c["journal"]["id"]): c["journal"] for c in candidates}
input_ids = list(by_id)
seen: set[str] = set()
rankings: list[Ranking] = []
dropped = 0
repaired = 0
for entry in payload["rankings"]:
if not isinstance(entry, dict):
dropped += 1
continue
jid = str(entry.get("id") or "").strip()
if jid in seen:
continue
if jid not in by_id:
dropped += 1 # 幻觉期刊:绝不进输出
continue
seen.add(jid)
tier = _normalize_tier(entry.get("tier"))
if tier is None:
tier = rule_tier(by_id[jid])
repaired += 1
reason = str(entry.get("match_reason") or "").strip() or "基于期刊范围与指标的匹配"
risk = str(entry.get("risk_note") or "").strip() or "无"
rankings.append(Ranking(journal_id=jid, tier=tier, match_reason=reason, risk_note=risk))
for jid in input_ids:
if jid in seen:
continue
rankings.append(
Ranking(
journal_id=jid,
tier=rule_tier(by_id[jid]),
match_reason="LLM 未给出该期刊条目,已按影响因子/周期规则补位",
risk_note="未提供(LLM 输出缺漏,规则兜底)",
)
)
repaired += 1
return rankings, dropped, repaired
async def rerank(
abstract: str,
candidates: list[dict[str, Any]],
preferences: dict[str, Any],
) -> tuple[list[Ranking], int, int]:
"""LLM 重排 + 校验。LLMError / RankError 上抛(路由降级为规则分档)。"""
settings = get_settings()
payload = await llm.complete_json(
SYSTEM_PROMPT,
build_user_prompt(abstract, candidates, preferences),
temperature=0.2,
max_tokens=3000,
timeout=settings.recommend_read_timeout,
)
return validate_rankings(payload, candidates)
def fallback_rankings(candidates: list[dict[str, Any]]) -> list[Ranking]:
"""LLM 不可用时的确定性分档(不丢任何候选期刊)。"""
rankings: list[Ranking] = []
for c in candidates:
j = c["journal"]
inf = j.get("impact_factor")
weeks = j.get("review_time_weeks")
rankings.append(
Ranking(
journal_id=str(j["id"]),
tier=rule_tier(j),
match_reason=(
f"规则分档:影响因子 {inf if inf is not None else '未收录'}、"
f"审稿约 {weeks if weeks is not None else '未收录'} 周"
),
risk_note="LLM 不可用,本条按影响因子/周期规则估算",
)
)
return rankings
+520
View File
@@ -0,0 +1,520 @@
"""app/services/journal_seed.py —— journal_directory 核心导入逻辑(CLI 与启动 seed 共享)。
数据流(schema 仍由本模块独家拥有,app 路由层只读不建表):
openpyxl 非 read_only 加载(读 cell.hyperlink.target 来源链接)
→ 「国际期刊」「中文期刊」两 sheet 按【列名】映射
→ 行级清洗(空→None 绝不填 0;分区数字→"N区";JCR学科→JSON 数组)
→ 按 id 幂等 upsert(ON CONFLICT DO UPDATE,重跑 count 不变)
两个入口:
- scripts/import_journals.py(CLI):extract_rows_from_xlsx + upsert_to_db
+ 对账报告(exit code 语义见脚本 docstring)
- ensure_journal_directory_seeded():服务启动自动 seed(main.py lifespan),
仅当表不存在或为空才导入;任何失败收敛为 dict 返回值,绝不抛异常。
v4 源(2026-10-10):JIF=2025 统计年/2026 JCR 发布;中文 IF=2024 统计年/
年报 2025 版。源 xlsx 路径走 settings.journal_directory_seed_abs
(REVIEW_JOURNAL_DIRECTORY_SEED_PATH 覆盖)。
id 策略:有 ISSN → "{lang}:{ISSN}"; 无 ISSN → "{lang}:{slug(name)}";
同 lang 内冲突 → 抛 JournalSeedError(CLI 中止导入 / startup 返回 error)。
"""
from __future__ import annotations
import json
import re
import sqlite3
import unicodedata
from collections import Counter
from datetime import datetime
from pathlib import Path
from typing import Any
import openpyxl
from ..config import get_settings
SHEET_INTL = "国际期刊"
SHEET_ZH = "中文期刊"
EXPECT_INTL = 779 # 口径 sheet 声明(v4 不变)
EXPECT_ZH = 277
# 官网列(v4 实际列名「官网网址」在首位;其余为历史/备用别名)
OFFICIAL_URL_HEADERS = ("官网网址", "官网", "官方网址", "期刊官网")
# JCR 学科列别名:v4 改名「JCR学科(2024源表)」,v2 为「JCR学科(源表)」
JCR_DISCIPLINE_HEADERS = ("JCR学科(2024源表)", "JCR学科(源表)")
# 既有表缺列时的幂等迁移(缺哪加哪,重复跑不报错)
MIGRATE_COLS = (
("official_url", "TEXT"),
("official_check_status", "TEXT"),
("jcr_release_year", "INTEGER"),
)
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS journal_directory (
id TEXT PRIMARY KEY,
lang TEXT NOT NULL,
name TEXT NOT NULL,
research_direction TEXT,
cas_partition TEXT,
cas_partition_note TEXT,
cas_partition_year INTEGER,
jif REAL,
jif_year INTEGER,
jif_note TEXT,
jif_check_status TEXT,
composite_impact_factor REAL,
comprehensive_impact_factor REAL,
metric_year INTEGER,
metric_report_year INTEGER,
metric_caliber TEXT,
annual_report_page INTEGER,
top_flag TEXT,
publisher_series TEXT,
journal_mark TEXT,
article_type TEXT,
issn TEXT,
eissn TEXT,
jcr_disciplines TEXT,
supplement_note TEXT,
evidence_note TEXT,
official_url TEXT,
official_check_status TEXT,
jcr_release_year INTEGER,
partition_source TEXT,
jif_source TEXT,
jif_source_url TEXT,
partition_source_url TEXT,
data_source_label TEXT,
data_source_url TEXT,
imported_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_dir_lang_name ON journal_directory(lang, name);
CREATE INDEX IF NOT EXISTS idx_dir_lang_jif ON journal_directory(lang, jif);
CREATE INDEX IF NOT EXISTS idx_dir_lang_cif ON journal_directory(lang, composite_impact_factor);
CREATE INDEX IF NOT EXISTS idx_dir_lang_part ON journal_directory(lang, cas_partition);
CREATE INDEX IF NOT EXISTS idx_dir_lang_domain ON journal_directory(lang, research_direction);
"""
# upsert 列序(除 id 外全部 ON CONFLICT DO UPDATE)
COLS = (
"lang", "name", "research_direction", "cas_partition", "cas_partition_note",
"cas_partition_year", "jif", "jif_year", "jif_note", "jif_check_status",
"composite_impact_factor", "comprehensive_impact_factor", "metric_year",
"metric_report_year", "metric_caliber", "annual_report_page", "top_flag",
"publisher_series", "journal_mark", "article_type", "issn", "eissn",
"jcr_disciplines", "supplement_note", "evidence_note", "official_url",
"official_check_status", "jcr_release_year",
"partition_source", "jif_source", "jif_source_url", "partition_source_url",
"data_source_label", "data_source_url", "imported_at",
)
UPSERT_SQL = (
"INSERT INTO journal_directory (id, " + ", ".join(COLS) + ") VALUES ("
+ ", ".join("?" for _ in range(len(COLS) + 1)) + ") "
+ "ON CONFLICT(id) DO UPDATE SET "
+ ", ".join(f"{c}=excluded.{c}" for c in COLS)
)
class JournalSeedError(Exception):
"""xlsx 缺失/结构错误/口径不符/id 冲突(不写库)。
exit_code 对齐 CLI 语义:1=id 冲突中止;2=行数与口径不符;3=文件/结构错误。
"""
def __init__(
self,
message: str,
*,
exit_code: int = 3,
stage: str = "读取失败",
conflicts: list[str] | None = None,
) -> None:
super().__init__(message)
self.exit_code = exit_code
self.stage = stage
self.conflicts = conflicts or []
def new_stats() -> dict[str, Any]:
"""对账统计骨架(CLI 报告与解析共享)。"""
return {
"read_intl": 0, "read_zh": 0, "success": 0, "skipped": 0, "failed": 0,
"jif_missing": [], "cif_missing": [], "part_blank": [], "part_notfound": [],
"part_dist": Counter(), "hl_jif": 0, "hl_part": 0, "hl_zh": 0,
"id_issn": 0, "id_slug": 0, "official_url_detected": False,
"migrated_cols": [], "migrated_skipped": [],
"official_total": 0, "official_filled": 0, "official_check_filled": 0,
"jcr_filled": 0, "official_missing": [],
}
def migrate(conn: sqlite3.Connection) -> tuple[list[str], list[str]]:
"""幂等加列:PRAGMA 探测现有列,缺哪个 ALTER ADD 哪个。"""
existing = {r[1] for r in conn.execute("PRAGMA table_info(journal_directory)")}
added: list[str] = []
skipped: list[str] = []
for col, decl in MIGRATE_COLS:
if col in existing:
skipped.append(col)
else:
conn.execute(f"ALTER TABLE journal_directory ADD COLUMN {col} {decl}")
added.append(col)
conn.commit()
return added, skipped
# ---- 清洗原语 ----
def text(v: Any) -> str | None:
"""单元格 → strip 后的文本;空/纯空白 → None(绝不填 0/空串)。"""
if v is None:
return None
s = str(v).strip()
return s or None
def to_float(v: Any) -> float | None:
if v is None or (isinstance(v, str) and not v.strip()):
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def to_int(v: Any) -> int | None:
f = to_float(v)
if f is None:
return None
return int(f)
def slugify(name: str) -> str:
"""小写 + 非词字符折叠为连字符;\\w 含 CJK(中文期刊无 ISSN 时的 id 兜底)。"""
s = unicodedata.normalize("NFC", name.strip().casefold())
s = re.sub(r"[^\w]+", "-", s, flags=re.UNICODE)
s = re.sub(r"-{2,}", "-", s)
return s.strip("-")
def make_id(lang: str, name: str, issn: str | None) -> str:
if issn:
return f"{lang}:{issn}"
return f"{lang}:{slugify(name)}"
def parse_partition(raw: Any) -> tuple[str | None, str | None]:
"""分区 → (cas_partition, note)。数字 1-4 → 'N区';'未检得' → note;空白 → 双 None。"""
if raw is None:
return None, None
if isinstance(raw, (int, float)) and not isinstance(raw, bool):
n = int(raw)
if 1 <= n <= 4:
return f"{n}区", None
return None, None
s = str(raw).strip()
if not s:
return None, None
if "未检得" in s:
return None, "未检得"
m = re.fullmatch(r"([1-4])\s*区?", s)
if m:
return f"{m.group(1)}区", None
return s, None # 未知文本:原样保留(不丢信息)
def split_disciplines(raw: Any) -> str | None:
"""'A(SCIE);B(SCIE)' → JSON 数组字符串。"""
s = text(raw)
if not s:
return None
parts = [p.strip() for p in s.split(";") if p.strip()]
return json.dumps(parts, ensure_ascii=False) if parts else None
def hyperlink_target(cell) -> str | None:
"""超链接单元格 → target URL;无链接 → None(需要非 read_only 模式)。"""
try:
target = cell.hyperlink.target if cell.hyperlink is not None else None
except Exception: # noqa: BLE001
return None
return text(target)
# ---- sheet 解析 ----
def header_index(ws) -> dict[str, int]:
headers = [text(c.value) for c in ws[1]]
return {h: i for i, h in enumerate(headers) if h is not None}
def read_intl(ws, imported_at: str, stats: dict) -> list[tuple]:
"""国际期刊 sheet → 行元组(顺序同 COLS 前置 id)。"""
ix = header_index(ws)
for req in ("期刊名称", "研究方向", "中科院分区", "JIF影响因子"):
if req not in ix:
raise JournalSeedError(
f"{SHEET_INTL} 缺列:{req}(实际列:{list(ix)})",
exit_code=3, stage="结构错误",
)
url_col = next((ix[h] for h in OFFICIAL_URL_HEADERS if h in ix), None)
jcr_hdr = next((h for h in JCR_DISCIPLINE_HEADERS if h in ix), None)
stats["official_url_detected"] = url_col is not None
if url_col is not None:
stats["official_url_headers"] = next(h for h in OFFICIAL_URL_HEADERS if h in ix)
rows: list[tuple] = []
for row in ws.iter_rows(min_row=2):
if not any(c.value is not None for c in row):
continue # 尾部幻影空行:不计入 skipped
name = text(row[ix["期刊名称"]].value) if ix["期刊名称"] < len(row) else None
if not name:
stats["skipped"] += 1
continue
issn = text(row[ix["ISSN"]].value) if "ISSN" in ix else None
jif = to_float(row[ix["JIF影响因子"]].value)
check_status = text(row[ix["JIF核对状态"]].value) if "JIF核对状态" in ix else None
official_check = text(row[ix["官网核对状态"]].value) if "官网核对状态" in ix else None
jcr_year = to_int(row[ix["JCR发布年"]].value) if "JCR发布年" in ix else None
part, part_note = parse_partition(row[ix["中科院分区"]].value)
# JIF 空 → note = 核对状态原文("未检得"/"源表标N/A" 等说明)
jif_note = check_status if jif is None else None
jif_url = hyperlink_target(row[ix["JIF数据来源"]]) if "JIF数据来源" in ix else None
part_url = hyperlink_target(row[ix["分区数据来源"]]) if "分区数据来源" in ix else None
official = None
if url_col is not None and url_col < len(row):
official = text(row[url_col].value) or hyperlink_target(row[url_col])
rows.append((
make_id("en", name, issn), "en", name,
text(row[ix["研究方向"]].value),
part, part_note,
to_int(row[ix["分区版本年"]].value) if "分区版本年" in ix else None,
jif,
to_int(row[ix["JIF统计年"]].value) if "JIF统计年" in ix else None,
jif_note, check_status,
None, None, None, None, None, None,
text(row[ix["Top(源表)"]].value) if "Top(源表)" in ix else None,
text(row[ix["出版系列"]].value) if "出版系列" in ix else None,
text(row[ix["期刊标记"]].value) if "期刊标记" in ix else None,
text(row[ix["文章类型"]].value) if "文章类型" in ix else None,
issn,
text(row[ix["eISSN"]].value) if "eISSN" in ix else None,
split_disciplines(row[ix[jcr_hdr]].value) if jcr_hdr else None,
text(row[ix["补充说明"]].value) if "补充说明" in ix else None,
text(row[ix["补充证据"]].value) if "补充证据" in ix else None,
official,
official_check,
jcr_year,
"official", "official",
jif_url, part_url,
text(row[ix["JIF数据来源"]].value) if "JIF数据来源" in ix else None,
None, # data_source_url(国际表来源即 jif/partition 两链接)
imported_at,
))
# 统计
stats["official_total"] += 1
stats["official_filled"] += 1 if official else 0
stats["official_check_filled"] += 1 if official_check else 0
stats["jcr_filled"] += 1 if jcr_year else 0
if not official:
stats["official_missing"].append(("en", name))
if jif is None:
stats["jif_missing"].append((name, check_status or ""))
if part is None and part_note == "未检得":
stats["part_notfound"].append(name)
elif part is None:
stats["part_blank"].append(name)
stats["hl_jif"] += 1 if jif_url else 0
stats["hl_part"] += 1 if part_url else 0
stats["id_issn" if issn else "id_slug"] += 1
return rows
def read_zh(ws, imported_at: str, stats: dict) -> list[tuple]:
"""中文期刊 sheet → 行元组。"""
ix = header_index(ws)
for req in ("中文期刊名称", "复合影响因子", "综合影响因子"):
if req not in ix:
raise JournalSeedError(
f"{SHEET_ZH} 缺列:{req}(实际列:{list(ix)})",
exit_code=3, stage="结构错误",
)
url_col = next((ix[h] for h in OFFICIAL_URL_HEADERS if h in ix), None)
stats["official_url_detected_zh"] = url_col is not None
rows: list[tuple] = []
for row in ws.iter_rows(min_row=2):
if not any(c.value is not None for c in row):
continue # 尾部幻影空行:不计入 skipped
name = text(row[ix["中文期刊名称"]].value)
if not name:
stats["skipped"] += 1
continue
composite = to_float(row[ix["复合影响因子"]].value)
comprehensive = to_float(row[ix["综合影响因子"]].value)
check_status = text(row[ix["核对状态"]].value) if "核对状态" in ix else None
src_label = text(row[ix["数据来源"]].value) if "数据来源" in ix else None
src_url = hyperlink_target(row[ix["数据来源"]]) if "数据来源" in ix else None
official = None
if url_col is not None and url_col < len(row):
official = text(row[url_col].value) or hyperlink_target(row[url_col])
official_check = text(row[ix["官网核对状态"]].value) if "官网核对状态" in ix else None
rows.append((
make_id("zh", name, None), "zh", name,
text(row[ix["研究方向"]].value),
# 中文分区原样(常量"不按此口径列示"),不分区 → partition_source=None
text(row[ix["中科院分区"]].value) if "中科院分区" in ix else None,
None, # cas_partition_note:中文表无"未检得"语义(口径=不按此口径列示)
None, # cas_partition_year
None, None, None, check_status,
composite, comprehensive,
to_int(row[ix["指标统计年"]].value) if "指标统计年" in ix else None,
to_int(row[ix["年报版本年"]].value) if "年报版本年" in ix else None,
text(row[ix["指标口径"]].value) if "指标口径" in ix else None,
to_int(row[ix["年报PDF页码"]].value) if "年报PDF页码" in ix else None,
None, None, None, None, None, None, None,
text(row[ix["备注"]].value) if "备注" in ix else None,
None, # evidence_note(中文表无此列)
official,
official_check,
None, # jcr_release_year:中文表无 JCR 发布年列(前端中文 Tab 不显示)
None, # partition_source:中文不按中科院口径
"official", # jif_source:国内年报(official)
None, None,
src_label, src_url,
imported_at,
))
stats["official_total"] += 1
stats["official_filled"] += 1 if official else 0
stats["official_check_filled"] += 1 if official_check else 0
if not official:
stats["official_missing"].append(("zh", name))
if composite is None:
stats["cif_missing"].append((name, check_status or ""))
stats["hl_zh"] += 1 if src_url else 0
stats["id_slug"] += 1
return rows
# ---- 共享主流程 ----
def extract_rows_from_xlsx(
source: Path,
*,
imported_at: str | None = None,
stats: dict | None = None,
) -> tuple[list[tuple], list[tuple], dict]:
"""xlsx → (rows_en, rows_zh, stats)。口径不符/id 冲突抛 JournalSeedError(不写库)。"""
stats = stats if stats is not None else new_stats()
imported_at = imported_at or datetime.now().isoformat(timespec="seconds")
try:
wb = openpyxl.load_workbook(source, data_only=True)
except FileNotFoundError as exc:
raise JournalSeedError(
f"源文件不存在: {source}", exit_code=3, stage="读取失败"
) from exc
except Exception as exc: # noqa: BLE001
raise JournalSeedError(
f"无法打开 Excel: {exc}", exit_code=3, stage="读取失败"
) from exc
if SHEET_INTL not in wb.sheetnames or SHEET_ZH not in wb.sheetnames:
raise JournalSeedError(
f"缺少 sheet:需要 {SHEET_INTL}/{SHEET_ZH},实际 {wb.sheetnames}",
exit_code=3, stage="结构错误",
)
rows_en = read_intl(wb[SHEET_INTL], imported_at, stats)
rows_zh = read_zh(wb[SHEET_ZH], imported_at, stats)
stats["read_intl"], stats["read_zh"] = len(rows_en), len(rows_zh)
for r in rows_en:
stats["part_dist"][r[4] or ("未检得" if r[5] else "空白")] += 1
# 口径行数校验(不符 → 抛错,不写库)
if len(rows_en) != EXPECT_INTL or len(rows_zh) != EXPECT_ZH:
raise JournalSeedError(
f"行数与口径不符:国际 {len(rows_en)}/{EXPECT_INTL}、"
f"中文 {len(rows_zh)}/{EXPECT_ZH},中止导入",
exit_code=2, stage="行数校验失败",
)
# id 冲突检测(同 lang 内重复 → 抛错,不写库)
ids = [r[0] for r in rows_en + rows_zh]
dup = [i for i, c in Counter(ids).items() if c > 1]
if dup:
conflicts = [f"{i} × {Counter(ids)[i]}" for i in sorted(dup)]
raise JournalSeedError(
f"发现 {len(conflicts)} 个重复 id,中止导入(未写库)",
exit_code=1, stage="id 冲突中止", conflicts=conflicts,
)
return rows_en, rows_zh, stats
def upsert_to_db(db_path: Path, rows_en: list[tuple], rows_zh: list[tuple]) -> dict[str, int]:
"""建表(IF NOT EXISTS)+幂等加列+upsert → {"en","zh","total"}。"""
db_path = Path(db_path)
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
try:
conn.executescript(SCHEMA_SQL)
migrate(conn)
conn.executemany(UPSERT_SQL, list(rows_en) + list(rows_zh))
conn.commit()
d = dict(conn.execute(
"SELECT lang, COUNT(*) FROM journal_directory GROUP BY lang").fetchall())
return {"en": d.get("en", 0), "zh": d.get("zh", 0), "total": sum(d.values())}
finally:
conn.close()
def ensure_journal_directory_seeded(
db_path: Path | None = None,
source_xlsx: Path | None = None,
) -> dict:
"""启动自动 seed:空表才导入;任何失败收敛为 dict,绝不抛异常。
返回统一四键:seeded/reason/error/rows(另有 counts 仅成功时给出 en/zh 明细)。
- 已有数据(只认空/非空,不做补差)→ seeded=False, reason="already has N rows, skip"
- 空表导入成功 → seeded=True, rows=1056
- xlsx 缺失/结构错误/口径不符/id 冲突 → seeded=False, error="..."
"""
settings = get_settings()
db = Path(db_path) if db_path is not None else settings.db_path_abs
source = Path(source_xlsx) if source_xlsx is not None else settings.journal_directory_seed_abs
try:
if db.exists():
conn = sqlite3.connect(str(db))
try:
row = conn.execute("SELECT COUNT(*) FROM journal_directory").fetchone()
except sqlite3.OperationalError:
row = None # 表不存在 → 视为空
finally:
conn.close()
else:
row = None
if row is not None and int(row[0]) > 0:
n = int(row[0])
return {
"seeded": False, "reason": f"already has {n} rows, skip",
"error": None, "rows": n,
}
rows_en, rows_zh, _ = extract_rows_from_xlsx(source)
counts = upsert_to_db(db, rows_en, rows_zh)
return {
"seeded": True, "reason": None, "error": None,
"rows": counts["total"], "counts": counts,
}
except JournalSeedError as exc:
return {"seeded": False, "reason": None, "error": str(exc), "rows": None}
except Exception as exc: # noqa: BLE001
return {"seeded": False, "reason": None, "error": str(exc), "rows": None}
@@ -0,0 +1,236 @@
"""services/latex_compile.py —— tectonic 编译 .tex → PDF(P3a)。
隔离与安全(对齐设计清单):
1. 一编译一目录:{reformat_dir}/{anchor_id}/compile/(input.tex/output.pdf/output.log/meta.json)
2. 文件名永不落盘:用户文件名只做后缀校验,写入一律 input.tex
3. tectonic --untrusted(官方旗标:禁用已知不安全特性,含 shell escape 路径)
4. 子进程 start_new_session + 超时 killpg(SIGKILL)(整进程组)
5. 单槽队列:asyncio.Lock + pending 计数,超出 compile_queue_limit → QueueFullError
6. 产物 PDF > max_compile_pdf_bytes → 删文件 + OutputTooLargeError
7. 启动/每次编译前机会性清扫 retention_days 天前的 job 目录(只删 12 位 hex 锚点目录)
tectonic 二进制缺失 → ToolUnavailableError(路由层回安装指引)。
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import shutil
import signal
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from ..config import get_settings
logger = logging.getLogger(__name__)
_ANCHOR_ID_RE = re.compile(r"^[0-9a-f]{12}$")
_ENGINE = "tectonic-p3/0.17.0"
_LOG_TAIL_LINES = 200
# 单槽并发控制(进程内;重启归零可接受——本服务无持久队列需求)
_lock = asyncio.Lock()
_pending = 0
class CompileError(Exception):
"""编译失败(含 log 尾)。code ∈ {COMPILE_FAILED, TIMEOUT, OUTPUT_TOO_LARGE}。"""
def __init__(self, code: str, message: str, log_tail: str = "") -> None:
super().__init__(message)
self.code = code
self.log_tail = log_tail
class QueueFullError(Exception):
"""等待槽位超限。"""
class ToolUnavailableError(Exception):
"""tectonic 二进制缺失/不可执行。"""
def _tectonic_bin() -> Path:
return get_settings().tectonic_abs
def _ensure_tool() -> Path:
bin_path = _tectonic_bin()
if not (bin_path.is_file() and os.access(bin_path, os.X_OK)):
raise ToolUnavailableError(
f"tectonic 未安装({bin_path})。"
"请执行:cd review_service && bash tools/install_tectonic.sh"
)
return bin_path
def _log_tail(path: Path, lines: int = _LOG_TAIL_LINES) -> str:
try:
text = path.read_text("utf-8", errors="replace")
except OSError:
return ""
return "\n".join(text.splitlines()[-lines:])
def compile_dir(anchor_id: str) -> Path:
"""{reformat_dir}/{anchor_id}/compile/(存在性由调用方按需 mkdir)。"""
return get_settings().reformat_dir_abs / anchor_id / "compile"
def prepare_input(anchor_id: str, source: str) -> Path:
"""写入隔离目录 input.tex;返回其路径。"""
cdir = compile_dir(anchor_id)
cdir.mkdir(parents=True, exist_ok=True)
input_tex = cdir / "input.tex"
input_tex.write_text(source, encoding="utf-8")
return input_tex
async def compile_tex(anchor_id: str) -> dict[str, Any]:
"""同步编译 anchor 的 input.tex(须先 prepare_input)。
→ {compile_id: anchor_id, pdf_path, log_path, seconds, pdf_bytes}
失败抛 CompileError / QueueFullError / ToolUnavailableError。
"""
global _pending
settings = get_settings()
_ensure_tool() # 快速失败:不占队列
sweep_expired()
if _pending >= settings.compile_queue_limit:
raise QueueFullError(
f"编译队列已满({_pending} 个任务排队),请稍后重试"
)
_pending += 1
try:
async with _lock:
return await _run_once(anchor_id)
finally:
_pending -= 1
async def _run_once(anchor_id: str) -> dict[str, Any]:
settings = get_settings()
cdir = compile_dir(anchor_id)
input_tex = cdir / "input.tex"
pdf_path = cdir / "output.pdf"
log_path = cdir / "output.log"
if not input_tex.is_file():
raise CompileError("COMPILE_FAILED", "编译输入不存在,请重新上传源码")
env = dict(os.environ)
env["TECTONIC_CACHE_DIR"] = str(settings.tectonic_cache_abs)
cmd = [
str(_tectonic_bin()), "-X", "compile", input_tex.name,
"--untrusted", "--keep-logs", "--outdir", ".",
]
started = time.monotonic()
try:
proc = await asyncio.create_subprocess_exec(
*cmd, cwd=cdir, env=env,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
start_new_session=True,
)
try:
await asyncio.wait_for(
proc.communicate(), timeout=settings.compile_timeout_s
)
except asyncio.TimeoutError:
_kill_group(proc)
try:
await asyncio.wait_for(proc.wait(), timeout=5.0)
except asyncio.TimeoutError:
pass
log_path.write_text(_log_tail(log_path) + "\n[timeout] 编译超时,进程已终止",
encoding="utf-8")
raise CompileError(
"TIMEOUT",
f"编译超过 {settings.compile_timeout_s:g} 秒未完成,已终止",
_log_tail(log_path),
) from None
except FileNotFoundError as exc:
raise ToolUnavailableError(f"tectonic 不可执行:{exc}") from exc
finally:
seconds = round(time.monotonic() - started, 2)
# tectonic --keep-logs 把日志写在 cwd 的 input.log
produced_log = cdir / "input.log"
if produced_log.is_file():
shutil.move(str(produced_log), str(log_path))
if not log_path.is_file():
log_path.write_text("", encoding="utf-8")
produced_pdf = cdir / "input.pdf"
if not produced_pdf.is_file():
raise CompileError(
"COMPILE_FAILED",
"编译失败(未产出 PDF),请查看日志定位 LaTeX 错误",
_log_tail(log_path),
)
pdf_bytes = produced_pdf.stat().st_size
if pdf_bytes > settings.max_compile_pdf_bytes:
produced_pdf.unlink(missing_ok=True)
raise CompileError(
"OUTPUT_TOO_LARGE",
f"产出 PDF 超过 {settings.max_compile_pdf_bytes // (1024 * 1024)}MB 上限,已删除",
_log_tail(log_path),
)
produced_pdf.rename(pdf_path)
meta = {
"compile_id": anchor_id,
"engine": _ENGINE,
"seconds": seconds,
"pdf_bytes": pdf_bytes,
"returncode": 0,
"compiled_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
(cdir / "meta.json").write_text(
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8"
)
logger.info("compile: %s → PDF %d bytes, %.2fs", anchor_id, pdf_bytes, seconds)
return {
"compile_id": anchor_id,
"pdf_path": pdf_path,
"log_path": log_path,
"seconds": seconds,
"pdf_bytes": pdf_bytes,
}
def _kill_group(proc: asyncio.subprocess.Process) -> None:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
try:
proc.kill()
except ProcessLookupError:
pass
def sweep_expired() -> int:
"""删除 mtime 超 retention_days 的 12-hex 锚点目录(编译产物+P1 落盘一并清)。"""
settings = get_settings()
base = settings.reformat_dir_abs
if not base.is_dir():
return 0
cutoff = time.time() - settings.compile_retention_days * 86400
removed = 0
for child in base.iterdir():
if not child.is_dir() or not _ANCHOR_ID_RE.fullmatch(child.name):
continue
try:
if child.stat().st_mtime < cutoff:
shutil.rmtree(child, ignore_errors=True)
removed += 1
except OSError:
continue
if removed:
logger.info("compile sweep: 清理 %d 个过期任务目录(>%d 天)",
removed, settings.compile_retention_days)
return removed
+203
View File
@@ -0,0 +1,203 @@
"""services/llm.py —— LLM 调用(httpx 裸调 DeepSeek,模式同 paper_writer_service)。
- complete_text(system, user, *, temperature=0.3, max_tokens=8000, timeout=None) -> str
- complete_json(...)(response_format json_object + 一次 JSON 修复重试)
- 4 次尝试(1.5s / 3s / 6s 指数退避,429 / 5xx / 超时)
- env 键(优先 REVIEW_LLM_*,回退 DOCGEN_LLM_*,再回退内置默认):
*_LLM_BASE_URL / *_LLM_API_KEY / *_LLM_MODEL / *_LLM_TIMEOUT
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
from typing import Any
import httpx
logger = logging.getLogger(__name__)
DEFAULT_LLM_BASE_URL = "https://api.deepseek.com/v1"
DEFAULT_LLM_MODEL = "deepseek-chat"
DEFAULT_LLM_TIMEOUT = 300.0
BACKOFF_SECONDS = (1.5, 3.0, 6.0)
MAX_ATTEMPTS = 4
class LLMError(Exception):
"""LLM 调用失败(重试尽/响应异常)。"""
def _env(keys: tuple[str, ...], default: str = "") -> str:
for key in keys:
val = os.environ.get(key)
if val:
return val
return default
def _llm_config() -> dict[str, Any]:
return {
"base_url": _env(
("REVIEW_LLM_BASE_URL", "DOCGEN_LLM_BASE_URL"), DEFAULT_LLM_BASE_URL
).rstrip("/"),
"api_key": _env(("REVIEW_LLM_API_KEY", "DOCGEN_LLM_API_KEY"), ""),
"model": _env(("REVIEW_LLM_MODEL", "DOCGEN_LLM_MODEL"), DEFAULT_LLM_MODEL),
"timeout": float(
_env(("REVIEW_LLM_TIMEOUT", "DOCGEN_LLM_TIMEOUT"), str(DEFAULT_LLM_TIMEOUT))
),
}
def _extract_text(payload: dict[str, Any]) -> str:
try:
choices = payload.get("choices") or []
if choices:
content = choices[0].get("message", {}).get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
return "".join(
str(c.get("text", "")) if isinstance(c, dict) else str(c) for c in content
)
except Exception:
pass
return ""
async def _chat_once(
client: httpx.AsyncClient,
cfg: dict[str, Any],
system: str,
user: str,
*,
temperature: float,
max_tokens: int,
json_mode: bool,
timeout: float | None,
) -> str:
headers = {"Content-Type": "application/json"}
if cfg["api_key"]:
headers["Authorization"] = f"Bearer {cfg['api_key']}"
body: dict[str, Any] = {
"model": cfg["model"],
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": temperature,
"max_tokens": max_tokens,
}
if json_mode:
body["response_format"] = {"type": "json_object"}
resp = await client.post(
f"{cfg['base_url']}/chat/completions",
headers=headers,
json=body,
timeout=timeout if timeout is not None else cfg["timeout"],
)
resp.raise_for_status()
payload = resp.json()
if not isinstance(payload, dict):
raise LLMError("LLM 响应形态异常(非 JSON 对象)")
text = _extract_text(payload)
if not text:
raise LLMError("LLM 响应缺少 choices[0].message.content")
return text
async def _complete(
system: str,
user: str,
*,
temperature: float,
max_tokens: int,
json_mode: bool,
timeout: float | None,
) -> str:
cfg = _llm_config()
last_exc: BaseException | None = None
async with httpx.AsyncClient() as client:
for attempt in range(MAX_ATTEMPTS):
try:
return await _chat_once(
client, cfg, system, user,
temperature=temperature, max_tokens=max_tokens, json_mode=json_mode,
timeout=timeout,
)
except LLMError:
raise
except (httpx.HTTPStatusError, httpx.HTTPError, httpx.TimeoutException) as exc:
last_exc = exc
status = getattr(getattr(exc, "response", None), "status_code", None)
retryable = status is None or status == 429 or status >= 500
if not retryable or attempt == MAX_ATTEMPTS - 1:
raise LLMError(f"LLM 调用失败(HTTP {status or '网络'}):{exc}") from exc
await asyncio.sleep(BACKOFF_SECONDS[min(attempt, len(BACKOFF_SECONDS) - 1)])
raise LLMError(f"LLM 调用失败:{last_exc}")
async def complete_text(
system: str,
user: str,
*,
temperature: float = 0.3,
max_tokens: int = 8000,
timeout: float | None = None,
) -> str:
"""文本补全。失败抛 LLMError。"""
return await _complete(
system, user, temperature=temperature, max_tokens=max_tokens, json_mode=False,
timeout=timeout,
)
def _repair_json(text: str) -> str:
text = text.strip()
if text.startswith("```"):
text = text.strip("`")
if text.lower().startswith("json"):
text = text[4:]
text = text.strip()
if not text:
raise LLMError("LLM 返回空内容")
if text.startswith("{"):
return text
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
return text[start:end + 1]
raise LLMError("LLM 输出中未找到 JSON 对象")
async def complete_json(
system: str,
user: str,
*,
temperature: float = 0.3,
max_tokens: int = 8000,
timeout: float | None = None,
) -> dict[str, Any]:
"""JSON 补全(json_object + 一次修复重试)。"""
text = await _complete(
system, user, temperature=temperature, max_tokens=max_tokens, json_mode=True,
timeout=timeout,
)
try:
return json.loads(text)
except ValueError:
pass
text2 = await _complete(
system + "\n(重要:你的回答必须是单个合法 JSON 对象,不要输出其他任何内容)",
user,
temperature=temperature,
max_tokens=max_tokens,
json_mode=True,
timeout=timeout,
)
try:
return json.loads(text2)
except ValueError:
repaired = _repair_json(text2)
return json.loads(repaired)
+139
View File
@@ -0,0 +1,139 @@
"""paper_parse.py —— PDF → 纯文本(经 vstore 8094 现有解析接口,零改动复用)。
链路(全部只读消费 vstore 既有 API,不修改 vstore 代码):
1. POST {vstore}/api/vstore/ingest multipart file(vstore 内部 pymupdf 解析)
2. GET {vstore}/api/vstore/documents/{doc_id} → document.paragraphs 拼接纯文本
3. DELETE {vstore}/api/vstore/documents/{doc_id} (临时解析件 best-effort 清理,
REVIEW_PARSE_DELETE_AFTER=0 可关闭——保留文档便于人工排查)
为何不用 methodology 8092:其 /extract-upload 是 MinerU 分钟级异步 job
(MinerU 子进程 + LLM 抽取两阶段),不适配同步自审稿;vstore 段落解析秒级
返回,满足 presubmit 场景。methodology 地址在 config 中预留供后续扩展。
"""
from __future__ import annotations
import logging
from typing import Any
import httpx
from ..config import get_settings
logger = logging.getLogger(__name__)
class ParseError(Exception):
"""PDF 解析失败(上游不可达/响应异常/空文本)。"""
async def parse_pdf(path: str) -> str:
"""接收 PDF 文件路径,返回拼接后的纯文本。失败抛 ParseError。"""
try:
with open(path, "rb") as fh:
file_bytes = fh.read()
except OSError as exc:
raise ParseError(f"读取 PDF 失败:{exc}") from exc
return await parse_pdf_bytes(file_bytes, filename=str(path).rsplit("/", 1)[-1] or "paper.pdf")
async def parse_pdf_bytes(file_bytes: bytes, filename: str = "paper.pdf") -> str:
"""接收 PDF 字节流,返回拼接后的纯文本。失败抛 ParseError。"""
if not file_bytes:
raise ParseError("PDF 内容为空")
settings = get_settings()
base = settings.vstore_base
timeout = httpx.Timeout(
connect=5.0, read=settings.parse_timeout, write=settings.parse_timeout, pool=5.0
)
async with httpx.AsyncClient(timeout=timeout) as client:
# ---- 1) ingest(vstore 内部 pymupdf 解析 + 嵌入 + 加密落盘)----
try:
resp = await client.post(
f"{base}/api/vstore/ingest",
files={"file": (filename, file_bytes, "application/pdf")},
data={
"name": filename,
"category": "review_presubmit",
"description": "review_service 临时解析件",
},
)
except httpx.HTTPError as exc:
raise ParseError(f"vstore 不可达({base}):{exc}") from exc
if resp.status_code >= 400:
# 读上游错误体(信封 JSON 或裸文本),区分“不可达”与“上游报错”
raise ParseError(f"vstore ingest 失败(HTTP {resp.status_code}):{_err_body(resp)}")
try:
payload = resp.json()
except ValueError as exc:
raise ParseError(f"vstore ingest 响应非 JSON:{exc}") from exc
if not isinstance(payload, dict) or not payload.get("ok"):
message = _err_message(payload) or "vstore ingest 失败"
raise ParseError(message)
doc_id = str((payload.get("item") or {}).get("document_id") or "")
if not doc_id:
raise ParseError("vstore ingest 响应缺少 document_id")
# ---- 2) 取明文全文(paragraphs 拼接)+ 3) 清理临时件 ----
try:
text = await _fetch_text(client, base, doc_id)
finally:
if settings.parse_delete_after:
await _best_effort_delete(client, base, doc_id)
text = text.strip()
if not text:
raise ParseError("PDF 解析结果为空(可能是扫描件/图片型 PDF,vstore 无 OCR)")
return text
async def _fetch_text(client: httpx.AsyncClient, base: str, doc_id: str) -> str:
"""取 vstore 文档明文并按段落拼接纯文本。"""
try:
resp = await client.get(f"{base}/api/vstore/documents/{doc_id}")
except httpx.HTTPError as exc:
raise ParseError(f"vstore 取文档失败({doc_id}):{exc}") from exc
if resp.status_code >= 400:
raise ParseError(
f"vstore 取文档失败({doc_id},HTTP {resp.status_code}):{_err_body(resp)}"
)
try:
payload = resp.json()
except ValueError as exc:
raise ParseError(f"vstore 取文档响应非 JSON({doc_id}):{exc}") from exc
if not isinstance(payload, dict) or not payload.get("ok"):
raise ParseError(_err_message(payload) or f"vstore 取文档失败({doc_id})")
document: dict[str, Any] = payload.get("document") or {}
paragraphs = document.get("paragraphs") or []
return "\n".join(str(p) for p in paragraphs if str(p).strip())
async def _best_effort_delete(client: httpx.AsyncClient, base: str, doc_id: str) -> None:
try:
await client.delete(f"{base}/api/vstore/documents/{doc_id}")
except Exception: # noqa: BLE001 - 清理失败不阻断(文档级 secure_delete 已尽力)
logger.warning("paper_parse: vstore 临时文档清理失败(%s)", doc_id, exc_info=True)
def _err_message(payload: Any) -> str:
if isinstance(payload, dict):
err = payload.get("error")
if isinstance(err, dict):
return str(err.get("message") or "")
if isinstance(err, str):
return err
return ""
def _err_body(resp: httpx.Response) -> str:
"""上游错误体:优先 JSON 信封 message,回退裸文本(截断 500 字符)。"""
try:
payload = resp.json()
except ValueError:
return (resp.text or "").strip()[:500] or "(空响应体)"
return _err_message(payload) or str(payload)[:500]
+553
View File
@@ -0,0 +1,553 @@
r"""services/reformat.py —— P1 论文重排版(.tex 源码级规则变换,不编译)。
链路:resolve_rules(三级规则解析)→ reformat_tex(4 变换器 + 结构守卫)
→ 路由层落盘 data/reformat_jobs/{job_id}.tex/.json。
纯规则、零 LLM、零执行用户内容、不碰 docx/pdf(那两个只在 FormatPanel 审核)。
拍板(用户 4 点确认,2026-10-10):
1. changes[] 每条带 rule_key ∈ {font_pt, margin, line_spacing, bib_style}、
action ∈ {modified, inserted, warning}(P1 只产生 modified/warning,
inserted 留给后续阶段);前端按 rule_key 着色、按 action 计数。
2. 保守插入策略:命令/选项缺失一律【不改源码】→ changes 里 action=warning,
reason 附"建议手动添加"的代码片段;warning 不计入"已修改"计数。
(warnings[] 顶层数组只放结构级提示,如多处 \documentclass。)
3. target_journal 必填;"default"= 通用规则(min_font_pt=11, min_margin_mm=20,
min_line_spacing=1.5, refs_style=apa),前端下拉首项即通用。
4. P1 仅 .tex;结构畸形宁拒不猜(PARSER_FAILED)。
铁律:
① 只在违反规则时改(合规零触碰 → 重跑幂等,changes=[])
② 只收紧不放松:字号/行距/边距只调大、绝不调小
③ 每条修改必须能回答"为什么":reason 带原值/目标值/规则
规则解析链(resolve_rules):
"default" → 内置通用规则
journals.json id/name 命中 → data/format_cache/{id}.json ok → fetched
→ 否则 format_rules 非 placeholder → official
→ 否则通用规则 + 占位说明
形如 "en:xxx"/"zh:xxx"(journal_directory)→ 只读查目录拿 name → 回链上一步
其他字符串(期刊名) → 回链 journals.json → 未中 → 通用 + 未匹配说明
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
# review_service/app/services/ → parents[2] = review_service/
_ROOT = Path(__file__).resolve().parents[2]
_SEED_PATH = _ROOT / "data" / "journals" / "journals.json"
_CACHE_DIR = _ROOT / "data" / "format_cache"
_EPS = 1e-9
# 通用规则(拍板 3:与 journals.json format_rules_default 的 9/15/1.15 不同,
# 这是重排版自己的"通用规则"口径,勿混用)
RULES_DEFAULT: dict[str, Any] = {
"rules_source": "default",
"max_pages": 10,
"min_font_pt": 11,
"min_margin_mm": 20.0,
"min_line_spacing": 1.5,
"min_figures": 1,
"max_fonts": 6,
"refs_style": "apa",
"anonymous": None,
}
# 参考文献样式 → 经典 .bst(均为发行版基础件)。
# 修正(2026-10-10):author_year 旧映射 plain 是错的——plain.bst 是编号制 [1];
# author-year 制应映射 apalike.bst(Author, 2020)。apa 同理。
REFS_BST_MAP: dict[str, str] = {
"apa": "apalike",
"author_year": "apalike",
"numbered": "unsrt",
"vancouver": "unsrt",
"gb7714": "gb7714",
}
# biblatex 工作流的 style= 建议名(与 .bst 是两套体系)
BIBLATEX_STYLE_MAP: dict[str, str] = {
"apa": "apa",
"author_year": "authoryear",
"numbered": "numeric",
"vancouver": "numeric",
"gb7714": "gb7714",
}
_DOCCLASS_RE = re.compile(r"(\\documentclass)\s*(\[[^\]]*\])?\s*\{([^}]*)\}")
_PKG_RE = re.compile(r"\\(?:usepackage|RequirePackage)\s*(\[[^\]]*\])?\s*\{([^}]*)\}")
_SPACING_RE = re.compile(r"\\(?:linespread|setstretch)\s*\{\s*(\d+(?:\.\d+)?)\s*\}")
_BIBSTYLE_RE = re.compile(r"\\bibliographystyle\s*\{\s*([^}]+?)\s*\}")
_PT_RE = re.compile(r"(\d+(?:\.\d+)?)\s*pt\b")
_BEGIN_DOC_RE = re.compile(r"\\begin\s*\{\s*document\s*\}")
_BIBLATEX_RE = re.compile(r"\\(?:printbibliography|addbibresource)")
_MM_FACTORS = {"mm": 1.0, "cm": 10.0, "in": 25.4, "inch": 25.4, "pt": 25.4 / 72.0}
_SIDE_KEYS = ("left", "right", "top", "bottom")
_MARGIN_ATTRS = ("margin", "hmargin", "vmargin", "left", "right", "top", "bottom",
"inner", "outer")
class ReformatError(Exception):
"""结构畸形无法安全处理(code: PARSER_FAILED)。"""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
# ================================================================ 规则解析链
def _seed_entries() -> list[dict[str, Any]]:
try:
data = json.loads(_SEED_PATH.read_text("utf-8"))
entries = data.get("journals")
return [e for e in entries if isinstance(e, dict)] if isinstance(entries, list) else []
except (OSError, ValueError):
return []
def _find_seed(text: str) -> dict[str, Any] | None:
low = text.strip().casefold()
if not low:
return None
for e in _seed_entries():
if str(e.get("id", "")).casefold() == low or str(e.get("name", "")).casefold() == low:
return e
return None
def _cache_rules(journal_id: str) -> dict[str, Any] | None:
"""data/format_cache/{id}.json(抓取+LLM 抽取产物);ok=false/缺失 → None。"""
try:
payload = json.loads((_CACHE_DIR / f"{journal_id}.json").read_text("utf-8"))
except (OSError, ValueError):
return None
if not isinstance(payload, dict) or not payload.get("ok"):
return None
rules = payload.get("format_rules")
return rules if isinstance(rules, dict) and rules.get("rules_source") else None
def _merged(rules: dict[str, Any]) -> dict[str, Any]:
"""专属规则叠加在通用规则上(缺失键/显式 null 保留——fetched 的 null 是诚实未知)。"""
return {**RULES_DEFAULT, **rules}
async def _directory_name(journal_id: str) -> str | None:
"""journal_directory id → name(只读;表未连/不存在 → None,绝不抛错)。"""
try:
from ..db import db # 惰性导入:resolver 可在无 lifespan 的测试中工作
row = await db.fetchone(
"SELECT name FROM journal_directory WHERE id = ?", (journal_id,)
)
return str(row["name"]) if row and row.get("name") else None
except Exception: # noqa: BLE001 —— 查不到/未连接都按未匹配处理
return None
async def resolve_rules(target: str) -> dict[str, Any]:
"""→ {journal_id, journal_name, rules, rules_source, rules_note}(永不失败)。"""
target = (target or "").strip()
if target == "default":
return {
"journal_id": "default",
"journal_name": "通用规则",
"rules": dict(RULES_DEFAULT),
"rules_source": "default",
"rules_note": "通用规则(APA + 通用论文规范)",
}
display_name = target
entry = _find_seed(target)
if entry is None and ":" in target:
# journal_directory id(en:0028-0836 / zh:slug)→ 目录查 name → 再回链种子
name = await _directory_name(target)
if name:
display_name = name
entry = _find_seed(name)
elif entry is None:
display_name = target # 直接当期刊名回链
if entry is not None:
jid = str(entry.get("id") or "")
jname = str(entry.get("name") or display_name)
cached = _cache_rules(jid)
if cached:
return {
"journal_id": jid, "journal_name": jname, "rules": _merged(cached),
"rules_source": "fetched",
"rules_note": "该刊规则来自官网抓取 + LLM 抽取(data/format_cache,待人工核验)",
}
fr = entry.get("format_rules") if isinstance(entry.get("format_rules"), dict) else {}
if fr and fr.get("rules_source") not in (None, "placeholder", "default"):
return {
"journal_id": jid, "journal_name": jname, "rules": _merged(fr),
"rules_source": str(fr.get("rules_source")),
"rules_note": "journals.json 期刊专属规则",
}
return {
"journal_id": jid, "journal_name": jname, "rules": dict(RULES_DEFAULT),
"rules_source": "default",
"rules_note": f"「{jname}」在 journals.json 中仅有占位规则(placeholder),已用通用规则",
}
return {
"journal_id": None,
"journal_name": display_name or target,
"rules": dict(RULES_DEFAULT),
"rules_source": "default",
"rules_note": f"未匹配到「{display_name or target}」的专属格式规则,已用通用规则",
}
# ================================================================ 变换器工具
def _change(line_no: int, original: str, modified: str, reason: str,
rule_key: str, action: str) -> dict[str, Any]:
return {
"line": line_no,
"original": original.rstrip("\n"),
"modified": modified.rstrip("\n"),
"reason": reason,
"rule_key": rule_key,
"action": action,
}
def _warning_change(line_no: int, original: str, reason: str, rule_key: str) -> dict[str, Any]:
return _change(line_no, original, "", reason, rule_key, "warning")
def _parse_mm(raw: str) -> float | None:
"""geometry 长度值 → mm;裸数字按 TeX 惯例 pt(同 format_check 先例);未知 → None。"""
m = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([A-Za-z]*)\s*", raw or "")
if not m:
return None
unit = (m.group(2) or "pt").lower()
factor = _MM_FACTORS.get(unit)
if factor is None:
return None
return float(m.group(1)) * factor
def _fmt(v: float) -> str:
return f"{v:g}"
# ================================================================ ① 字号 font_pt
def _transform_font(lines: list[str], orig: list[str], rules: dict[str, Any],
doc_idx: int) -> list[dict[str, Any]]:
min_pt = rules.get("min_font_pt")
if min_pt is None:
return []
min_pt = float(min_pt)
line = lines[doc_idx]
m = _DOCCLASS_RE.search(line)
if not m: # 守卫已保证,理论不可达
return []
opts = m.group(2) # "[...]" 或 None
cls = m.group(3).strip()
opts_inner = opts[1:-1] if opts else ""
pt_m = _PT_RE.search(opts_inner)
if pt_m is None:
# 拍板 2:选项缺失不自动加 → warning + 建议片段
if opts_inner:
suggestion = f"\\documentclass[{opts_inner}, {_fmt(min_pt)}pt]{{{cls}}}"
else:
suggestion = f"\\documentclass[{_fmt(min_pt)}pt]{{{cls}}}"
return [_warning_change(
doc_idx + 1, orig[doc_idx],
f"未检测到字号选项,无法核验正文字号(目标下限 {_fmt(min_pt)}pt),"
f"建议手动声明:{suggestion}",
"font_pt",
)]
cur = float(pt_m.group(1))
if cur >= min_pt - _EPS:
return []
target = next((s for s in (10, 11, 12) if s >= min_pt - _EPS), min_pt)
new_inner = opts_inner[:pt_m.start(1)] + _fmt(target) + opts_inner[pt_m.end(1):]
new_line = line[:m.start(2) + 1] + new_inner + line[m.start(2) + 1 + len(opts_inner):]
lines[doc_idx] = new_line
return [_change(
doc_idx + 1, orig[doc_idx], new_line,
f"正文字号 {_fmt(cur)}pt 低于目标下限 {_fmt(min_pt)}pt,改为 {_fmt(target)}pt",
"font_pt", "modified",
)]
# ================================================================ ② 页边距 margin
def _find_geometry(lines: list[str]) -> tuple[int, re.Match] | None:
for i, line in enumerate(lines):
for m in _PKG_RE.finditer(line):
pkgs = [p.strip().lower() for p in m.group(2).split(",")]
if "geometry" in pkgs:
return i, m
return None
def _patch_attr(line: str, key: str, new_val: str) -> str:
"""geometry 选项里 key=old → key=new(首个匹配)。
值止于 `,` 或 `]`——绝不能吞掉 `]{geometry}` 尾巴(否则下一轮就找不到
geometry 宏包,破坏幂等)。key 带 \\b 防 hmargin/margin 互撞。
"""
return re.sub(rf"(\b{key}\s*=\s*)([^,\]]+)", lambda m: m.group(1) + new_val,
line, count=1)
def _transform_margin(lines: list[str], orig: list[str], rules: dict[str, Any],
doc_idx: int) -> list[dict[str, Any]]:
min_mm = rules.get("min_margin_mm")
if min_mm is None:
return []
min_mm = float(min_mm)
out: list[dict[str, Any]] = []
found = _find_geometry(lines)
if found is None:
return [_warning_change(
doc_idx + 1, orig[doc_idx],
f"未检测到 geometry 宏包,无法核验页边距(目标下限 {_fmt(min_mm)}mm),"
f"建议手动添加:\\usepackage[margin={_fmt(min_mm)}mm]{{geometry}}"
f"(置于 \\documentclass 之后)",
"margin",
)]
gi, gm = found
opts = gm.group(1)
if opts is None:
# \usepackage{geometry} 裸用 → 建议加选项(拍板 2 不自动改)
return [_warning_change(
gi + 1, orig[gi],
f"geometry 未设置边距选项,无法核验页边距(目标下限 {_fmt(min_mm)}mm),"
f"建议手动改为:\\usepackage[margin={_fmt(min_mm)}mm]{{geometry}}",
"margin",
)]
opts_inner = opts[1:-1]
attrs: dict[str, str] = {}
for part in opts_inner.split(","):
if "=" in part:
k, _, v = part.partition("=")
attrs[k.strip().lower()] = v.strip()
present = [k for k in _MARGIN_ATTRS if k in attrs]
if not present:
return [_warning_change(
gi + 1, orig[gi],
f"geometry 未设置边距相关选项(目标下限 {_fmt(min_mm)}mm),"
f"建议在现有选项中添加:margin={_fmt(min_mm)}mm",
"margin",
)]
# 逐边解析提供者(side > h/vmargin > margin;inner/outer 单独按自身长度评)
def provider_for(side: str) -> str | None:
if side in attrs:
return side
if side in ("left", "right") and "hmargin" in attrs:
return "hmargin"
if side in ("top", "bottom") and "vmargin" in attrs:
return "vmargin"
if "margin" in attrs:
return "margin"
if side in ("left", "right") and "inner" in attrs:
return "inner" # 见下:inner/outer 单独评估,此处不按边处理
if side in ("left", "right") and "outer" in attrs:
return "outer"
return None
patch_keys: dict[str, str] = {} # attr → 新值(同键只抬一次)
reasons: list[str] = []
unresolved: list[str] = []
for side in _SIDE_KEYS:
pkey = provider_for(side)
if pkey in ("inner", "outer"):
continue # 由下方 inner/outer 评估负责
if pkey is None:
unresolved.append(side)
continue
mm = _parse_mm(attrs.get(pkey, ""))
if mm is None:
unresolved.append(side)
continue
if mm < min_mm - _EPS and pkey not in patch_keys:
patch_keys[pkey] = f"{_fmt(min_mm)}mm"
reasons.append(f"页边距 {side}({_fmt(mm)}mm) 低于下限 {_fmt(min_mm)}mm → {_fmt(min_mm)}mm")
for key in ("inner", "outer"):
if key in attrs:
mm = _parse_mm(attrs[key])
if mm is None:
unresolved.append(key)
elif mm < min_mm - _EPS and key not in patch_keys:
patch_keys[key] = f"{_fmt(min_mm)}mm"
reasons.append(f"页边距 {key}({_fmt(mm)}mm) 低于下限 {_fmt(min_mm)}mm → {_fmt(min_mm)}mm")
out: list[dict[str, Any]] = []
if patch_keys:
new_line = lines[gi]
for key, val in patch_keys.items():
new_line = _patch_attr(new_line, key, val)
lines[gi] = new_line
out.append(_change(
gi + 1, orig[gi], new_line, ";".join(reasons), "margin", "modified",
))
if unresolved:
uniq = "、".join(dict.fromkeys(unresolved))
out.append(_warning_change(
gi + 1, orig[gi],
f"geometry 未设置/无法解析 {uniq} 边(沿用类默认,无法核验),"
f"建议显式声明:{uniq}={_fmt(min_mm)}mm",
"margin",
))
return out
# ================================================================ ③ 行距 line_spacing
def _transform_spacing(lines: list[str], orig: list[str], rules: dict[str, Any],
doc_idx: int) -> list[dict[str, Any]]:
min_ls = rules.get("min_line_spacing")
if min_ls is None:
return []
min_ls = float(min_ls)
# 只看导言区(\begin{document} 之前);正文中的 \setstretch 是局部覆盖,不碰
end = len(lines)
for i, line in enumerate(lines):
if _BEGIN_DOC_RE.search(line):
end = i
break
records: list[tuple[int, re.Match]] = []
for i in range(end):
for m in _SPACING_RE.finditer(lines[i]):
records.append((i, m))
if not records:
return [_warning_change(
doc_idx + 1, orig[doc_idx],
f"未在导言区检测到 \\linespread 或 \\setstretch,无法核验行距"
f"(目标下限 {_fmt(min_ls)}),建议手动添加到导言区:"
f"\\linespread{{{_fmt(min_ls)}}}",
"line_spacing",
)]
i, m = records[-1] # 最后一次出现者生效(LaTeX 后设覆盖前设)
cur = float(m.group(1))
if cur >= min_ls - _EPS:
return []
new_line = lines[i][:m.start(1)] + _fmt(min_ls) + lines[i][m.end(1):]
lines[i] = new_line
return [_change(
i + 1, orig[i], new_line,
f"行距 {_fmt(cur)} 低于目标下限 {_fmt(min_ls)},改为 {_fmt(min_ls)}",
"line_spacing", "modified",
)]
# ================================================================ ④ 参考文献 bib_style
def _transform_bib(lines: list[str], orig: list[str], rules: dict[str, Any],
doc_idx: int) -> list[dict[str, Any]]:
style = str(rules.get("refs_style") or "").strip().lower()
if not style:
# 规则未提供已确认的 refs_style(如 Frontiers 两种制式并存待核实):
# 绝不猜测改写用户样式——保持原样 + warning 提示(拍板 2026-10-10)
return [_warning_change(
doc_idx + 1, orig[doc_idx],
"该刊引用风格未确认,未改动 \\bibliographystyle;"
"建议人工核实期刊投稿指南后再调整",
"bib_style",
)]
m = next((mm for line in lines if (mm := _BIBSTYLE_RE.search(line))), None)
if m is not None:
i = next(idx for idx, line in enumerate(lines) if _BIBSTYLE_RE.search(line))
cur = m.group(1).strip()
if style not in REFS_BST_MAP:
return [_warning_change(
i + 1, orig[i],
f"目标参考文献样式 {style} 无对应标准 .bst,未替换"
f"(当前 \\bibliographystyle{{{cur}}}),请按目标期刊模板手动选择样式",
"bib_style",
)]
target = REFS_BST_MAP[style]
if cur.casefold() == target.casefold():
return []
new_line = lines[i][:m.start(1)] + target + lines[i][m.end(1):]
lines[i] = new_line
hint = "(需 gbt7714 宏包)" if target == "gb7714" else ""
return [_change(
i + 1, orig[i], new_line,
f"参考文献样式要求 {style}:\\bibliographystyle{{{cur}}} → {{{target}}}{hint}",
"bib_style", "modified",
)]
has_biblatex = any(_BIBLATEX_RE.search(line) for line in lines)
if has_biblatex:
suggest = BIBLATEX_STYLE_MAP.get(style, style)
return [_warning_change(
doc_idx + 1, orig[doc_idx],
f"检测到 biblatex 工作流(未找到 \\bibliographystyle),"
f"请在 \\printbibliography 选项中手动配置:style={suggest}(对应 {style})",
"bib_style",
)]
if style in REFS_BST_MAP:
target = REFS_BST_MAP[style]
return [_warning_change(
doc_idx + 1, orig[doc_idx],
f"未检测到 \\bibliographystyle,建议手动添加:\\bibliographystyle{{{target}}}"
f"(对应 {style})",
"bib_style",
)]
return [_warning_change(
doc_idx + 1, orig[doc_idx],
f"未检测到 \\bibliographystyle,目标样式 {style} 无标准 .bst 映射,"
f"请按期刊模板手动配置",
"bib_style",
)]
# ================================================================ 入口
def reformat_tex(source: str, rules: dict[str, Any]) -> dict[str, Any]:
"""源码 + 规则 → {modified_tex, changes[], warnings[]}。
结构守卫:0 处 \\documentclass → ReformatError(PARSER_FAILED);
>1 处 → 只处理第一处 + warnings 补 MULTI_DOCUMENTCLASS。
"""
lines = source.splitlines(keepends=True)
doc_idxs = [i for i, line in enumerate(lines) if _DOCCLASS_RE.search(line)]
if not doc_idxs:
raise ReformatError(
"PARSER_FAILED",
"未找到 \\documentclass{...}:无法确定主文档"
"(P1 不支持由 \\input 拆分的多文件工程,请上传完整单文件源码)",
)
warnings: list[dict[str, str]] = []
if len(doc_idxs) > 1:
warnings.append({
"code": "MULTI_DOCUMENTCLASS",
"message": f"检测到 {len(doc_idxs)} 处 \\documentclass,"
f"仅处理第一处(第 {doc_idxs[0] + 1} 行)",
})
doc_idx = doc_idxs[0]
orig = list(lines) # 纯净快照:所有 change.original 取自这里
changes: list[dict[str, Any]] = []
changes += _transform_font(lines, orig, rules, doc_idx)
changes += _transform_margin(lines, orig, rules, doc_idx)
changes += _transform_spacing(lines, orig, rules, doc_idx)
changes += _transform_bib(lines, orig, rules, doc_idx)
changes.sort(key=lambda c: c["line"])
return {"modified_tex": "".join(lines), "changes": changes, "warnings": warnings}
+155
View File
@@ -0,0 +1,155 @@
"""services/self_review.py —— 植物基因组学专属自审稿(System Prompt + 报告模型)。
六大领域维度检查清单硬编码在 SYSTEM_PROMPT(发给 DeepSeek):
1. 基因组组装质量(N50/L50/BUSCO 完整格式/Hi-C 挂载率,缺项即 Major)
2. 基因注释可靠性(MAKER/AUGUSTUS 流程、RNA-seq 证据、基因数与已知范围一致性)
3. 比较基因组学与进化(Bootstrap/后验概率、OrthoFinder 等直系同源策略)
4. 数据可用性合规(SRA/BioProject + 组装/GFF3 Accession,缺失即 Major)
5. 统计与实验设计(GWAS/QTL 样本量·多重校正·群体结构;转录组生物学重复)
6. 伦理与合规·植物特异性(凭证标本 voucher、ABS 惠益分享声明)
Major/Minor 每条必须给 Evidence(论文章节位置或具体指标数值),由 coerce
宽容归一化(缺失补空串,不丢条目)。
"""
from __future__ import annotations
from pydantic import BaseModel, Field
RECOMMENDATIONS = ("accept", "minor_revision", "major_revision", "reject")
SYSTEM_PROMPT = """你是一位植物基因组学领域资深审稿人,长期为 Plant Cell、Nature Plants、
Genome Biology、Molecular Plant、Plant Journal 等期刊审稿。请对用户提供的论文正文做
投稿前自审(pre-submission self review),严格按以下六个植物基因组学专属维度逐项检查,
输出结构化审稿意见。所有条目使用中文撰写(专业术语、指标名、工具名保留英文)。
【维度一:基因组组装质量】必查指标:
- contig N50 / scaffold N50 / L50(报了哪个要写明哪个)
- BUSCO 完整度:必须按完整格式报告——例如
"Complete 95.3% [S:93.1%, D:2.2%], Missing 4.7%, n=1614 (embryophyta_odb10)"
(含 Complete/Single-copy/Duplicated/Missing 四项与数据集版本、n 值)
- Hi-C 挂载率(anchoring rate,chromosome-level 比例)及挂载到染色体的 scaffold 比例
判定规则:若论文只报 N50 而未报 BUSCO 完整度或 Hi-C 挂载率(组装类论文),直接列为
Major Weakness。同时检查组装是否为 pseudo-chromosome 级别、污染评估(如 k-mer/coverage
检查)与 gap 比例。
【维度二:基因注释可靠性】检查:
- 是否使用标准注释流程(MAKER、AUGUSTUS、BRAKER、GeMoMa、EVidenceModeler 等),参数与版本是否交代
- 是否有转录组证据支持:RNA-seq(组织/时期/物种内或近缘种)比对支撑比例,或蛋白同源证据
- 基因数量是否与该物种已知基因数范围一致(如水稻 ~37-40k、拟南芥 ~27k、玉米 ~39-45k、
小麦 ~107k、大豆 ~46k;近缘种偏差过大须说明);有无转座元件基因误注释的排查
- 注释质量评估:BRAKER/AED 分值、BUSCO 在蛋白集上的复核
【维度三:比较基因组学与进化】检查:
- 系统发育树:每个节点必须标注 Bootstrap 支持率(建议 ≥1000 次重复)或贝叶斯后验概率;
未标注或仅"高/低支持"的描述 → Major 或 Minor(视严重程度)
- 直系同源基因鉴定策略是否说明清楚:OrthoFinder/OrthoMCL/RBX 等工具、版本、参数、
聚类阈值(inflation/coverage)、single-copy 标记基因的筛选方式
- 分子进化分析是否规范:dN/dS 检验方法与多重校正、共线性(synteny)分析参数、
分化时间估算是否校准(化石/二次校准点)
【维度四:数据可用性合规——极其重要】必查:
- 原始测序数据是否上传 NCBI SRA / BioProject(或 ENA/DDBJ),是否给出 Accession 号
(PRJNA/PRJEB/ SRP 开头)
- 基因组组装是否有 GenBank/ENA accession(GCA_/GCF_),GFF3 注释文件、蛋白/CDS 文件
是否有可公开获取的位置(仓库 accession 或补充数据链接)
- 转录组数据是否同样有 SRA accession
判定规则:组装/注释类论文若缺失上述任何一环,直接列为 Major Weakness。
【维度五:统计与实验设计】检查:
- GWAS/QTL 定位:样本量/群体大小是否充分;多重检验校正是否明确(Bonferroni/FDR/
permutation 阈值/PCA 显著性水平);群体结构控制(PCA、亲缘关系矩阵 K、分层分析)
是否交代;表型测量描述是否充分
- 转录组分析:生物学重复数量(≥3 为底线)、差异分析工具(DESeq2/edgeR 等)与
校正方法、批次效应处理、|log2FC| 与 padj 阈值
- 其他统计:t 检验/ANOVA 是否满足前提、重复类型(生物学 vs 技术重复)写明与否
【维度六:伦理与合规(植物特异性)】检查:
- 野生植物材料:是否提供凭证标本(voucher specimen)信息——标本馆编号、存放机构、
采集地点(GPS)与采集许可;无凭证标本的野生资源取样 → Major 或 Minor(视用途)
- 遗传资源获取与惠益分享(ABS)合规性:是否声明符合《名古屋议定书》/本国遗传资源
获取条例(如中国《生物安全法》/《种子法》相关条款)、是否说明材料来源与授权
- 农业转基因/基因编辑材料的合规声明(若涉及)
输出要求(必须输出单个 JSON 对象,不要输出其他任何内容):
{
"summary": "总体评价(3-5 句,须点明论文类型与所适用的维度)",
"major_weaknesses": [{"title": "...", "detail": "...", "evidence": "...", "suggestion": "..."}],
"minor_weaknesses": [{"title": "...", "detail": "...", "evidence": "...", "suggestion": "..."}],
"presentation_format": [{"title": "...", "detail": "...", "evidence": "...", "suggestion": "..."}],
"questions_for_authors": ["..."],
"recommendation": "major_revision"
}
硬性规则:
1. major_weaknesses 3-8 条、minor_weaknesses 3-8 条;上述维度中命中的硬性判定规则
(组装缺 BUSCO/挂载率、缺数据 Accession)必须出现。
2. **每条 major_weaknesses 的 evidence 字段必填**:必须引用论文的具体章节位置
(如 "Methods 2.1"、"Table 2"、"Results 第 3 段"、"Data Availability 一句话未见")
或具体指标数值(如 "仅报 scaffold N50=42.6 Mb,未提供 BUSCO 与 Hi-C 挂载率")。
evidence 严禁编造:引用不到具体位置时,写明"正文未见相关报告"。
3. minor_weaknesses/presentation_format 的 evidence 尽量给出,无法定位时可留空字符串。
4. 论文不涉及的维度(如纯 RNA-seq 论文不谈组装)在 summary 中说明并跳过,不硬凑条目。
5. recommendation 只能是 accept / minor_revision / major_revision / reject 之一,
缺失关键合规项(维度四)时通常不高于 major_revision。
6. presentation_format 覆盖:图表规范(图注含单位/误差棒说明)、参考文献格式、
字数与结构、英文语法(若投英文刊)。
"""
class WeaknessItem(BaseModel):
title: str
detail: str = ""
evidence: str = ""
suggestion: str = ""
class SelfReviewReport(BaseModel):
summary: str = ""
major_weaknesses: list[WeaknessItem] = Field(default_factory=list)
minor_weaknesses: list[WeaknessItem] = Field(default_factory=list)
presentation_format: list[WeaknessItem] = Field(default_factory=list)
questions_for_authors: list[str] = Field(default_factory=list)
recommendation: str = "major_revision"
def build_user_prompt(text: str, target_journal: str) -> str:
header = f"目标期刊:{target_journal}\n\n" if target_journal else ""
return f"{header}论文正文:\n\n{text}"
def _coerce_items(raw: object) -> list[WeaknessItem]:
items: list[WeaknessItem] = []
if not isinstance(raw, list):
return items
for entry in raw:
if isinstance(entry, str) and entry.strip():
items.append(WeaknessItem(title=entry.strip()))
elif isinstance(entry, dict):
title = str(entry.get("title") or "").strip()
detail = str(entry.get("detail") or "").strip()
if not title and not detail:
continue
items.append(WeaknessItem(
title=title or "(未命名)",
detail=detail,
evidence=str(entry.get("evidence") or "").strip(),
suggestion=str(entry.get("suggestion") or "").strip(),
))
return items
def coerce_report(payload: dict) -> SelfReviewReport:
questions = payload.get("questions_for_authors") or []
if not isinstance(questions, list):
questions = []
recommendation = str(payload.get("recommendation") or "").strip().lower()
if recommendation not in RECOMMENDATIONS:
recommendation = "major_revision"
return SelfReviewReport(
summary=str(payload.get("summary") or "").strip(),
major_weaknesses=_coerce_items(payload.get("major_weaknesses")),
minor_weaknesses=_coerce_items(payload.get("minor_weaknesses")),
presentation_format=_coerce_items(payload.get("presentation_format")),
questions_for_authors=[str(q).strip() for q in questions if str(q).strip()],
recommendation=recommendation,
)
@@ -0,0 +1,493 @@
{
"version": 2,
"updated": "2026-10-10",
"notes": "会议种子数据 v2。deadline_source=placeholder 表示日期为首版占位数据(按各会议公开周期窗口/历年规律填写,未经官方渠道逐条核验),接入官方数据源前须人工核验;严禁以 AI 推测日期替代核验。deadline_confidence 标注日期置信度:high=官方已公布或历年极稳定;medium=按历年规律区间推算;low=新一届尚未公告或历届波动大,待核验。ccf_rank 按 CCF 推荐目录(第七版,2026-03 发布)标注,非目录内会议为 null(含全部生物信息/植物类会议)。timezone_note 默认 AoE (UTC-12)。首版不做自动同步。",
"conferences": [
{
"id": "naacl-2027",
"name": "NAACL 2027",
"full_name": "North American Chapter of the Association for Computational Linguistics",
"domain_tags": ["AI/ML", "自然语言处理"],
"application_tags": ["交叉学科"],
"ccf_rank": "B",
"deadline_abstract": null,
"deadline_full_paper": "2026-10-12T23:59:59+00:00",
"notification_date": null,
"conference_date": "2027-06-01 ~ 2027-06-05",
"location": "San Francisco, California, USA",
"official_url": "https://www.naacl.org/",
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "high"
},
{
"id": "coling-2027",
"name": "COLING 2027",
"full_name": "International Conference on Computational Linguistics",
"domain_tags": ["AI/ML", "自然语言处理"],
"application_tags": ["交叉学科"],
"ccf_rank": "B",
"deadline_abstract": null,
"deadline_full_paper": "2026-10-12T23:59:59+00:00",
"notification_date": null,
"conference_date": "2027-05-09 ~ 2027-05-14",
"location": "Macau SAR, China",
"official_url": null,
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "medium"
},
{
"id": "www-2027",
"name": "WWW 2027",
"full_name": "The Web Conference (ACM WWW)",
"domain_tags": ["AI/ML", "数据挖掘"],
"application_tags": ["交叉学科"],
"ccf_rank": "A",
"deadline_abstract": "2026-10-18T23:59:59+00:00",
"deadline_full_paper": "2026-10-25T23:59:59+00:00",
"notification_date": "2027-01-04",
"conference_date": "2027-05-10 ~ 2027-05-14",
"location": "Dublin, Ireland",
"official_url": "https://www2027.thewebconf.org/",
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "high"
},
{
"id": "recomb-2027",
"name": "RECOMB 2027",
"full_name": "International Conference on Research in Computational Molecular Biology",
"domain_tags": ["生物信息学"],
"application_tags": ["交叉学科"],
"ccf_rank": null,
"deadline_abstract": "2026-11-06T23:59:59+00:00",
"deadline_full_paper": "2026-11-13T23:59:59+00:00",
"notification_date": "2027-02-01",
"conference_date": "2027-04-25 ~ 2027-04-28",
"location": "TBD(待官方公布)",
"official_url": "https://recomb.org/",
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "medium"
},
{
"id": "cvpr-2027",
"name": "CVPR 2027",
"full_name": "IEEE/CVF Conference on Computer Vision and Pattern Recognition",
"domain_tags": ["AI/ML", "计算机视觉"],
"application_tags": ["交叉学科"],
"ccf_rank": "A",
"deadline_abstract": "2026-11-10T23:59:59+00:00",
"deadline_full_paper": "2026-11-16T23:59:59+00:00",
"notification_date": "2027-02-25",
"conference_date": "2027-06-20 ~ 2027-06-25",
"location": "Seattle, USA",
"official_url": "https://cvpr.thecvf.com/",
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "high"
},
{
"id": "wsdm-2027",
"name": "WSDM 2027",
"full_name": "International Conference on Web Search and Data Mining",
"domain_tags": ["AI/ML", "数据挖掘"],
"application_tags": ["交叉学科"],
"ccf_rank": "B",
"deadline_abstract": null,
"deadline_full_paper": "2026-11-17T23:59:59+00:00",
"notification_date": null,
"conference_date": "2027-02-15 ~ 2027-02-19",
"location": "Hong Kong SAR, China",
"official_url": "https://www.wsdm-conference.org/",
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "high"
},
{
"id": "pag-2027",
"name": "PAG 2027",
"full_name": "Plant & Animal Genome Conference",
"domain_tags": ["植物/农学", "基因组学"],
"application_tags": ["种业", "分子育种"],
"ccf_rank": null,
"deadline_abstract": "2026-11-30T23:59:59+00:00",
"deadline_full_paper": "2026-12-15T23:59:59+00:00",
"notification_date": "2027-01-05",
"conference_date": "2027-01-08 ~ 2027-01-12",
"location": "San Diego, USA",
"official_url": "https://www.intlpag.org/",
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "medium"
},
{
"id": "acl-2027",
"name": "ACL 2027",
"full_name": "Annual Meeting of the Association for Computational Linguistics",
"domain_tags": ["AI/ML", "自然语言处理"],
"application_tags": ["交叉学科"],
"ccf_rank": "A",
"deadline_abstract": null,
"deadline_full_paper": "2027-01-04T23:59:59+00:00",
"notification_date": null,
"conference_date": "2027-08-20 ~ 2027-08-22",
"location": "Kyoto, Japan",
"official_url": "https://2027.aclweb.org/",
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "high"
},
{
"id": "plantbio-2027",
"name": "Plant Biology 2027",
"full_name": "ASPB Annual Meeting — Plant Biology",
"domain_tags": ["植物/农学", "分子生物学"],
"application_tags": ["分子育种"],
"ccf_rank": null,
"deadline_abstract": "2027-01-08T23:59:59+00:00",
"deadline_full_paper": "2027-01-08T23:59:59+00:00",
"notification_date": null,
"conference_date": "2027-07(待官方公布)",
"location": "Albuquerque, New Mexico, USA",
"official_url": "https://plantbiology.aspb.org/",
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "high"
},
{
"id": "ijcai-2027",
"name": "IJCAI 2027",
"full_name": "International Joint Conference on Artificial Intelligence",
"domain_tags": ["AI/ML"],
"application_tags": ["交叉学科"],
"ccf_rank": "B",
"deadline_abstract": "2027-01-11T23:59:59+00:00",
"deadline_full_paper": "2027-01-18T23:59:59+00:00",
"notification_date": null,
"conference_date": "2027-08-07 ~ 2027-08-17",
"location": "Kyoto, Japan / Hengqin, China",
"official_url": "https://www.ijcai.org/",
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "medium"
},
{
"id": "sigir-2027",
"name": "SIGIR 2027",
"full_name": "ACM SIGIR Conference on Research and Development in Information Retrieval",
"domain_tags": ["AI/ML", "信息检索"],
"application_tags": ["交叉学科"],
"ccf_rank": "A",
"deadline_abstract": "2027-01-14T23:59:59+00:00",
"deadline_full_paper": "2027-01-21T23:59:59+00:00",
"notification_date": "2027-04-05",
"conference_date": "2027-07-20 ~ 2027-07-24",
"location": "San Jose, California, USA",
"official_url": "https://sigir2027.org/",
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "high"
},
{
"id": "icml-2027",
"name": "ICML 2027",
"full_name": "International Conference on Machine Learning",
"domain_tags": ["AI/ML", "深度学习"],
"application_tags": ["交叉学科"],
"ccf_rank": "A",
"deadline_abstract": "2027-01-15T23:59:59+00:00",
"deadline_full_paper": "2027-01-22T23:59:59+00:00",
"notification_date": "2027-05-01",
"conference_date": "2027-07-19 ~ 2027-07-25",
"location": "TBD(待官方公布)",
"official_url": "https://icml.cc/",
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "medium"
},
{
"id": "chil-2027",
"name": "CHIL 2027",
"full_name": "ACM Conference on Health, Inference, and Learning",
"domain_tags": ["AI/ML", "健康医疗"],
"application_tags": ["交叉学科"],
"ccf_rank": null,
"deadline_abstract": "2027-01-15T23:59:59+00:00",
"deadline_full_paper": "2027-01-29T23:59:59+00:00",
"notification_date": null,
"conference_date": "2027-06(待官方公布)",
"location": "TBD(待官方公布)",
"official_url": "https://dl.acm.org/conference/chil",
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "low"
},
{
"id": "kdd-2027",
"name": "KDD 2027 (Cycle 2)",
"full_name": "ACM SIGKDD Conference on Knowledge Discovery and Data Mining",
"domain_tags": ["AI/ML", "数据挖掘"],
"application_tags": ["交叉学科"],
"ccf_rank": "A",
"deadline_abstract": "2027-01-31T23:59:59+00:00",
"deadline_full_paper": "2027-02-07T23:59:59+00:00",
"notification_date": null,
"conference_date": "2027-08-01 ~ 2027-08-05",
"location": "San Jose, California, USA",
"official_url": "https://kdd2027.kdd.org/",
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "medium"
},
{
"id": "ismb-eccb-2027",
"name": "ISMB/ECCB 2027",
"full_name": "Intelligent Systems for Molecular Biology / European Conference on Computational Biology",
"domain_tags": ["生物信息学"],
"application_tags": ["交叉学科"],
"ccf_rank": null,
"deadline_abstract": "2027-02-12T23:59:59+00:00",
"deadline_full_paper": "2027-02-19T23:59:59+00:00",
"notification_date": null,
"conference_date": "2027-07-18 ~ 2027-07-22",
"location": "Copenhagen, Denmark",
"official_url": "https://www.iscb.org/ismbeccb2027/home",
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "medium"
},
{
"id": "ipmbc-2027",
"name": "IPMB 2027",
"full_name": "International Congress on Plant Molecular Biology (14th)",
"domain_tags": ["植物/农学", "分子生物学"],
"application_tags": ["分子育种"],
"ccf_rank": null,
"deadline_abstract": "2027-02-15T23:59:59+00:00",
"deadline_full_paper": "2027-02-22T23:59:59+00:00",
"notification_date": null,
"conference_date": "2027-07(待官方公布)",
"location": "Wuhan, China",
"official_url": "https://www.ipmb2027.org/",
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "low"
},
{
"id": "uai-2027",
"name": "UAI 2027",
"full_name": "Conference on Uncertainty in Artificial Intelligence",
"domain_tags": ["AI/ML"],
"application_tags": ["交叉学科"],
"ccf_rank": "B",
"deadline_abstract": null,
"deadline_full_paper": "2027-02-24T23:59:59+00:00",
"notification_date": null,
"conference_date": "2027-08-16 ~ 2027-08-20",
"location": "TBD(待官方公布)",
"official_url": "https://www.auai.org/",
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "medium"
},
{
"id": "miccai-2027",
"name": "MICCAI 2027",
"full_name": "International Conference on Medical Image Computing and Computer-Assisted Intervention",
"domain_tags": ["AI/ML", "医学影像"],
"application_tags": ["交叉学科"],
"ccf_rank": null,
"deadline_abstract": "2027-02-11T23:59:59+00:00",
"deadline_full_paper": "2027-02-25T23:59:59+00:00",
"notification_date": null,
"conference_date": "2027-09-26 ~ 2027-10-01",
"location": "Auckland, New Zealand",
"official_url": "https://miccai.org/",
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "medium"
},
{
"id": "iccv-2027",
"name": "ICCV 2027",
"full_name": "IEEE/CVF International Conference on Computer Vision",
"domain_tags": ["AI/ML", "计算机视觉"],
"application_tags": ["交叉学科"],
"ccf_rank": "A",
"deadline_abstract": "2027-02-26T23:59:59+00:00",
"deadline_full_paper": "2027-03-05T23:59:59+00:00",
"notification_date": null,
"conference_date": "2027-10-02 ~ 2027-10-08",
"location": "Hong Kong SAR, China",
"official_url": null,
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "medium"
},
{
"id": "bosc-2027",
"name": "BOSC 2027",
"full_name": "Bioinformatics Open Source Conference",
"domain_tags": ["生物信息学"],
"application_tags": ["交叉学科"],
"ccf_rank": null,
"deadline_abstract": null,
"deadline_full_paper": "2027-03-26T23:59:59+00:00",
"notification_date": null,
"conference_date": "2027-07-18 ~ 2027-07-22",
"location": "Copenhagen, Denmark",
"official_url": "https://openbioinformatics.org/",
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "low"
},
{
"id": "acm-bcb-2027",
"name": "ACM-BCB 2027",
"full_name": "ACM Conference on Bioinformatics, Computational Biology and Health Informatics",
"domain_tags": ["生物信息学", "AI/ML"],
"application_tags": ["交叉学科"],
"ccf_rank": null,
"deadline_abstract": "2027-04-16T23:59:59+00:00",
"deadline_full_paper": "2027-04-23T23:59:59+00:00",
"notification_date": null,
"conference_date": "2027-08(待官方公布)",
"location": "TBD(待官方公布)",
"official_url": "https://acm-bcb.org/",
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "medium"
},
{
"id": "ieee-bibm-2027",
"name": "IEEE BIBM 2027",
"full_name": "IEEE International Conference on Bioinformatics and Bioengineering",
"domain_tags": ["生物信息学"],
"application_tags": ["交叉学科"],
"ccf_rank": null,
"deadline_abstract": "2027-05-07T23:59:59+00:00",
"deadline_full_paper": "2027-05-14T23:59:59+00:00",
"notification_date": null,
"conference_date": "2027-11(待官方公布)",
"location": "TBD(待官方公布)",
"official_url": null,
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "low"
},
{
"id": "emnlp-2027",
"name": "EMNLP 2027",
"full_name": "Conference on Empirical Methods in Natural Language Processing",
"domain_tags": ["AI/ML", "自然语言处理"],
"application_tags": ["交叉学科"],
"ccf_rank": "B",
"deadline_abstract": "2027-05-14T23:59:59+00:00",
"deadline_full_paper": "2027-05-21T23:59:59+00:00",
"notification_date": null,
"conference_date": "2027-11(待官方公布)",
"location": "TBD(待官方公布)",
"official_url": null,
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "low"
},
{
"id": "neurips-2027",
"name": "NeurIPS 2027",
"full_name": "Conference on Neural Information Processing Systems",
"domain_tags": ["AI/ML", "深度学习"],
"application_tags": ["交叉学科"],
"ccf_rank": "A",
"deadline_abstract": "2027-05-14T23:59:59+00:00",
"deadline_full_paper": "2027-05-21T23:59:59+00:00",
"notification_date": null,
"conference_date": "2027-12(待官方公布)",
"location": "TBD(待官方公布)",
"official_url": "https://neurips.cc/",
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "medium"
},
{
"id": "psb-2028",
"name": "PSB 2028",
"full_name": "Pacific Symposium on Biocomputing",
"domain_tags": ["生物信息学"],
"application_tags": ["交叉学科"],
"ccf_rank": null,
"deadline_abstract": null,
"deadline_full_paper": "2027-07-01T23:59:59+00:00",
"notification_date": null,
"conference_date": "2028-01(待官方公布)",
"location": "TBD(待官方公布)",
"official_url": "https://psb.stanford.edu/",
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "low"
},
{
"id": "pag-europe-2027",
"name": "PAG Europe 2027",
"full_name": "Plant & Animal Genome Conference Europe",
"domain_tags": ["植物/农学", "基因组学"],
"application_tags": ["种业", "分子育种"],
"ccf_rank": null,
"deadline_abstract": null,
"deadline_full_paper": "2027-07-09T23:59:59+00:00",
"notification_date": null,
"conference_date": "2027-10(待官方公布)",
"location": "TBD(待官方公布)",
"official_url": null,
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "low"
},
{
"id": "aaai-2028",
"name": "AAAI 2028",
"full_name": "AAAI Conference on Artificial Intelligence",
"domain_tags": ["AI/ML"],
"application_tags": ["交叉学科"],
"ccf_rank": "A",
"deadline_abstract": "2027-07-20T23:59:59+00:00",
"deadline_full_paper": "2027-07-27T23:59:59+00:00",
"notification_date": null,
"conference_date": "2028-02(待官方公布)",
"location": "TBD(待官方公布)",
"official_url": "https://aaai.org/",
"timezone_note": "AoE (UTC-12)",
"deadline_source": "placeholder",
"last_verified": "2026-10-10",
"deadline_confidence": "medium"
}
]
}
@@ -0,0 +1,61 @@
journals directory 导入对账报告
生成时间: 2026-10-10T15:37:11
源文件: /home/xzq/5090/teamWorkspace/wjq/PlantAI-RAG-new-latest/PlantAI-rag-new/review_service/data/journals/source_v4.xlsx(193386 bytes)
阶段: 完成
=== 计数 ===
读取有效行: 国际 779 / 中文 277 / 合计 1056
成功: 1056 跳过(名称空): 0 失败: 0
写入后 count(*): en=779 zh=277 total=1056
期望: en=779 zh=277 total=1056 → ✓ 相符
=== 分区分布(国际)===
1区 233 / 2区 214 / 3区 181 / 4区 134 / 空白 17
分区空白(→null 无note): 17
分区未检得(→null + note): 0
=== JIF 缺失 13 条(jif=null + note=核对状态)===
- eLife [2025 JIF未检得/未给值]
- Heliyon [2025 JIF未检得/未给值]
- Australian Journal of Crop Science [2025 JIF未检得/未给值]
- International Journal of Agriculture and Biology [2025 JIF未检得/未给值]
- Journal of Agriculture and Rural Development in the Tropics and Subtropics [2025 JIF未检得/未给值]
- Fruits [2025 JIF未检得/未给值]
- RANGELAND JOURNAL [2025 JIF未检得/未给值]
- Science of The Total Environment [2025 JIF未检得/未给值]
- Chemosphere [2025 JIF未检得/未给值]
- Environmental Science and Pollution Research [2025 JIF未检得/未给值]
- Bioengineered [2025 JIF未检得/未给值]
- Cell Biomaterials [2025 JIF未检得/未给值]
- National Science Open [2025 JIF未检得/未给值]
=== 中文复合IF 缺失 0 条 ===
=== 分区未检得 期刊名 ===
(无——源表以空白表示未检得,见上方'分区空白')
=== 重复 id 冲突(应为空)===
(无)
=== id 策略 ===
ISSN 型: 692 条 slug 型: 364 条
=== 来源超链接 ===
国际 JIF来源: 779/779 分区来源: 762/779 中文数据来源: 277/277
=== 结构迁移(ALTER TABLE,幂等)===
本次加列: official_check_status, jcr_release_year
已存在跳过: official_url
=== 官网映射(决策:official_source 不入库)===
识别列: 官网网址
official_url 填充: 1053/1056 official_check_status 填充: 1056/1056
jcr_release_year 填充: 779(仅国际=2026;中文表无此列→null)
官网缺失 3 条(official_url=null,前端显示「—」不生成链接):
- [zh] 中国食用菌
- [zh] 水土保持应用技术
- [zh] 粮食与饲料工业
=== 幂等性验证 ===
第二次 upsert 后 count 不变: ✓ 通过
+172
View File
@@ -0,0 +1,172 @@
{
"version": 1,
"updated": "2026-10-10",
"notes": "植物基因组学领域种子期刊。acceptance_rate 无公开权威数据一律为 null(不编造);impact_factor / review_time_weeks 为公开 JCR/期刊官网常识近似值,以 last_updated 标注刷新时间,接入真实数据源前需人工核验。scope_desc 为英文范围描述,供 vstore 向量匹配使用。 format_rules_default 为通用默认规则(rules_source=default);精选 5 本 format_rules 已于 2026-10-10 按各刊官网投稿指南人工核验填入(rules_source=official,附 source_url / last_verified / notes),官网未规定的字段一律 null 不猜测。 新增标签字段(不进 SQLite,接口按 id 从本文件合并):scope_tags / application_tags / cross_domain 按 scope_desc 归类的标签;cas_partition / jcr_quartile 为占位(partition_source=placeholder),核实不确定的一律 null、不猜测,接入真实分区数据前需人工核验。 精选列表已更换为本环境可访问官网的 5 本(Genome Biology / BMC Plant Biology / Molecular Plant / Frontiers in Plant Science / Rice);原 OUP/Wiley/Springer 4 本(The Plant Cell / The Plant Journal / Theoretical and Applied Genetics / The Plant Genome)移出精选,仍保留在 journal_directory 1056 条全量目录中。",
"format_rules_default": {
"rules_source": "default",
"max_pages": 10,
"min_font_pt": 9,
"min_margin_mm": 15,
"min_line_spacing": 1.15,
"min_figures": 1,
"max_fonts": 6,
"refs_style": "apa"
},
"journals": [
{
"id": "genome-biology",
"name": "Genome Biology",
"scope_desc": "High-impact open-access journal for genome-scale research across all organisms, including genome assembly and annotation, comparative and evolutionary genomics, computational methods, single-cell and multi-omics studies, and reproducible bioinformatics.",
"impact_factor": 10.1,
"review_time_weeks": 4.0,
"acceptance_rate": null,
"submission_deadline": null,
"official_url": "https://genomebiology.biomedcentral.com",
"last_updated": "2026-10-09",
"scope_tags": ["基因组学", "生物信息学", "多组学", "计算生物学"],
"application_tags": ["基础研究", "算法与方法"],
"cas_partition": "1区",
"jcr_quartile": "Q1",
"partition_source": "placeholder",
"cross_domain": ["进化生物学", "单细胞组学"],
"format_rules": {
"max_pages": null,
"min_font_pt": null,
"min_margin_mm": null,
"min_line_spacing": 2.0,
"max_fonts": null,
"min_figures": null,
"refs_style": "vancouver",
"anonymous": false,
"rules_source": "official",
"source_url": "https://genomebiology.biomedcentral.com/submission-guidelines",
"last_verified": "2026-10-10",
"notes": "官网要求双倍行距('Use double-line spacing')、行页码编号;参考文献 'Journal abbreviations follow Index Medicus/MEDLINE'(顺序编号制→vancouver);BMC 出版社 policy 页明确 single-anonymous(审稿人知道作者身份,作者无需匿名);字号/边距/页数上限该页未规定。BMC editorial policy 辅助来源:https://www.biomedcentral.com/getpublished/editorial-policies"
}
},
{
"id": "bmc-plant-biology",
"name": "BMC Plant Biology",
"scope_desc": "Open-access journal covering all aspects of plant biology, from molecular and cellular studies to the physiology and ecology of whole plants and communities, including plant-microbe interactions, biotechnology and applied plant science.",
"impact_factor": null,
"review_time_weeks": null,
"acceptance_rate": null,
"submission_deadline": null,
"official_url": "https://bmcplantbiol.biomedcentral.com",
"last_updated": "2026-10-10",
"scope_tags": ["植物生物学", "分子生物学", "逆境生物学", "植物-微生物互作"],
"application_tags": ["基础研究"],
"cas_partition": null,
"jcr_quartile": null,
"partition_source": "placeholder",
"cross_domain": ["植物生物技术"],
"format_rules": {
"max_pages": null,
"min_font_pt": null,
"min_margin_mm": null,
"min_line_spacing": 2.0,
"max_fonts": null,
"min_figures": null,
"refs_style": "vancouver",
"anonymous": false,
"rules_source": "official",
"source_url": "https://bmcplantbiol.biomedcentral.com/submission-guidelines",
"last_verified": "2026-10-10",
"notes": "同 BMC 系列模板页:双倍行距+行页码编号;参考文献 MEDLINE 缩写(→vancouver);BMC 系列 editorial policy 为 single-anonymous(作者无需匿名);字号/边距/页数上限该页未规定。"
}
},
{
"id": "molecular-plant",
"name": "Molecular Plant",
"scope_desc": "High-impact journal for fundamental and translational plant science at the molecular level, covering genomics, cell and developmental biology, signal transduction, stress and metabolic responses, and plant biotechnology.",
"impact_factor": null,
"review_time_weeks": null,
"acceptance_rate": null,
"submission_deadline": null,
"official_url": "https://www.cell.com/molecular-plant",
"last_updated": "2026-10-10",
"scope_tags": ["分子生物学", "基因组学", "多组学"],
"application_tags": ["基础研究", "分子育种"],
"cas_partition": null,
"jcr_quartile": null,
"partition_source": "placeholder",
"cross_domain": ["植物生物技术"],
"format_rules": {
"max_pages": null,
"min_font_pt": 12,
"min_margin_mm": null,
"min_line_spacing": 1.5,
"max_fonts": null,
"min_figures": null,
"refs_style": "author_year",
"anonymous": null,
"rules_source": "official",
"source_url": "https://www.cell.com/molecular-plant/authors",
"last_verified": "2026-10-10",
"notes": "官网作者指南(本环境 cell.com 403,依据作者浏览器核对摘录):正文 12 point 字号、1.5 行距,参考文献 'cited in the text by name and year in parentheses'(作者-年份制→author_year);页数上限/边距/图表下限/匿名审稿政策该摘录未覆盖→null"
}
},
{
"id": "frontiers-in-plant-science",
"name": "Frontiers in Plant Science",
"scope_desc": "Open-access journal spanning fundamental and applied plant sciences: molecular and cellular mechanisms, physiology, ecology, evolution, and applications in agriculture and biotechnology.",
"impact_factor": null,
"review_time_weeks": null,
"acceptance_rate": null,
"submission_deadline": null,
"official_url": "https://www.frontiersin.org/journals/plant-science",
"last_updated": "2026-10-10",
"scope_tags": ["植物生物学", "分子生物学", "生理学", "生态学"],
"application_tags": ["基础研究"],
"cas_partition": null,
"jcr_quartile": null,
"partition_source": "placeholder",
"cross_domain": ["农业科学"],
"format_rules": {
"max_pages": null,
"min_font_pt": null,
"min_margin_mm": null,
"min_line_spacing": 1.0,
"max_fonts": null,
"min_figures": null,
"refs_style": null,
"anonymous": null,
"rules_source": "official",
"source_url": "https://www.frontiersin.org/journals/plant-science/for-authors/author-guidelines",
"last_verified": "2026-10-10",
"notes": "官网 author-guidelines:'The entire document should be single-spaced and must contain page and line numbers'(单倍行距→1.0);页数按 Article types 页 word count 而非数字页限→max_pages=null;字号/边距/图表下限该页未规定。Frontiers 为 open peer review;官网未明文规定作者是否匿名,建议人工核实 review-guidelines 页。Frontiers 官网明确两种制式并存:Harvard (author-date) 或 Vancouver (numbered),具体到 Plant Science 需查 help center(当前抓取失败),建议人工核实 https://www.frontiersin.org/help/"
}
},
{
"id": "rice",
"name": "Rice",
"scope_desc": "Open-access journal publishing research on all aspects of rice science, from molecular biology and genetics to breeding, physiology, ecology and production systems, with relevance to cereal crop improvement.",
"impact_factor": null,
"review_time_weeks": null,
"acceptance_rate": null,
"submission_deadline": null,
"official_url": "https://thericejournal.springeropen.com",
"last_updated": "2026-10-10",
"scope_tags": ["水稻", "作物基因组学", "遗传育种"],
"application_tags": ["基础研究", "分子育种", "种业"],
"cas_partition": null,
"jcr_quartile": null,
"partition_source": "placeholder",
"cross_domain": ["作物科学"],
"format_rules": {
"max_pages": null,
"min_font_pt": null,
"min_margin_mm": null,
"min_line_spacing": 2.0,
"max_fonts": null,
"min_figures": null,
"refs_style": "vancouver",
"anonymous": false,
"rules_source": "official",
"source_url": "https://thericejournal.springeropen.com/submission-guidelines",
"last_verified": "2026-10-10",
"notes": "SpringerOpen 系列模板页:'Use double-line spacing'(双倍行距→2.0)、行页码编号;参考文献 'Journal abbreviations follow Index Medicus/MEDLINE'(→vancouver);审稿明文 single-anonymous(审稿人知道作者身份,作者无需匿名);字号/边距/页数上限/图表下限该页未规定。"
}
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,815 @@
{
"ok": true,
"items": [
{
"id": "en:0028-0836",
"lang": "en",
"name": "NATURE",
"research_direction": "综合与交叉",
"cas_partition": "1区",
"cas_partition_note": null,
"cas_partition_year": 2025,
"jif": 48.5,
"jif_year": 2024,
"jif_note": null,
"jif_check_status": "已匹配公开汇编",
"composite_impact_factor": null,
"comprehensive_impact_factor": null,
"metric_year": null,
"metric_report_year": null,
"metric_caliber": null,
"annual_report_page": null,
"top_flag": "是",
"publisher_series": "Nature Portfolio(含合作刊)",
"journal_mark": "经典专业刊",
"article_type": "研究/方法/综合",
"issn": "0028-0836",
"eissn": "1476-4687",
"jcr_disciplines": [
"MULTIDISCIPLINARY SCIENCES(SCIE)"
],
"supplement_note": null,
"evidence_note": null,
"official_url": null,
"partition_source": "official",
"jif_source": "official",
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
"data_source_label": "JIF来源",
"data_source_url": null
},
{
"id": "en:0036-8075",
"lang": "en",
"name": "SCIENCE",
"research_direction": "综合与交叉",
"cas_partition": "1区",
"cas_partition_note": null,
"cas_partition_year": 2025,
"jif": 45.8,
"jif_year": 2024,
"jif_note": null,
"jif_check_status": "已匹配公开汇编",
"composite_impact_factor": null,
"comprehensive_impact_factor": null,
"metric_year": null,
"metric_report_year": null,
"metric_caliber": null,
"annual_report_page": null,
"top_flag": "是",
"publisher_series": "Science / AAAS",
"journal_mark": "经典专业刊",
"article_type": "研究/方法/综合",
"issn": "0036-8075",
"eissn": "1095-9203",
"jcr_disciplines": [
"MULTIDISCIPLINARY SCIENCES(SCIE)"
],
"supplement_note": null,
"evidence_note": null,
"official_url": null,
"partition_source": "official",
"jif_source": "official",
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
"data_source_label": "JIF来源",
"data_source_url": null
},
{
"id": "en:0092-8674",
"lang": "en",
"name": "CELL",
"research_direction": "综合与交叉",
"cas_partition": "1区",
"cas_partition_note": null,
"cas_partition_year": 2025,
"jif": 42.5,
"jif_year": 2024,
"jif_note": null,
"jif_check_status": "已匹配公开汇编",
"composite_impact_factor": null,
"comprehensive_impact_factor": null,
"metric_year": null,
"metric_report_year": null,
"metric_caliber": null,
"annual_report_page": null,
"top_flag": "是",
"publisher_series": "Cell Press(含合作刊)",
"journal_mark": "经典专业刊",
"article_type": "研究/方法/综合",
"issn": "0092-8674",
"eissn": "1097-4172",
"jcr_disciplines": [
"BIOCHEMISTRY & MOLECULAR BIOLOGY(SCIE)",
"CELL BIOLOGY(SCIE)"
],
"supplement_note": null,
"evidence_note": null,
"official_url": null,
"partition_source": "official",
"jif_source": "official",
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
"data_source_label": "JIF来源",
"data_source_url": null
},
{
"id": "en:2666-6758",
"lang": "en",
"name": "Innovation",
"research_direction": "综合与交叉",
"cas_partition": "1区",
"cas_partition_note": null,
"cas_partition_year": 2025,
"jif": 25.7,
"jif_year": 2024,
"jif_note": null,
"jif_check_status": "已匹配公开汇编",
"composite_impact_factor": null,
"comprehensive_impact_factor": null,
"metric_year": null,
"metric_report_year": null,
"metric_caliber": null,
"annual_report_page": null,
"top_flag": "是",
"publisher_series": "其他",
"journal_mark": "领域期刊/新兴刊",
"article_type": "研究/方法/综合",
"issn": "2666-6758",
"eissn": "2666-6758",
"jcr_disciplines": [
"MULTIDISCIPLINARY SCIENCES(ESCI)"
],
"supplement_note": null,
"evidence_note": null,
"official_url": null,
"partition_source": "official",
"jif_source": "official",
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
"data_source_label": "JIF来源",
"data_source_url": null
},
{
"id": "en:2095-9273",
"lang": "en",
"name": "Science Bulletin",
"research_direction": "综合与交叉",
"cas_partition": "1区",
"cas_partition_note": null,
"cas_partition_year": 2025,
"jif": 21.1,
"jif_year": 2024,
"jif_note": null,
"jif_check_status": "已匹配公开汇编",
"composite_impact_factor": null,
"comprehensive_impact_factor": null,
"metric_year": null,
"metric_report_year": null,
"metric_caliber": null,
"annual_report_page": null,
"top_flag": "是",
"publisher_series": "其他",
"journal_mark": "领域期刊/新兴刊",
"article_type": "研究/方法/综合",
"issn": "2095-9273",
"eissn": "2095-9281",
"jcr_disciplines": [
"MULTIDISCIPLINARY SCIENCES(SCIE)"
],
"supplement_note": null,
"evidence_note": null,
"official_url": null,
"partition_source": "official",
"jif_source": "official",
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
"data_source_label": "JIF来源",
"data_source_url": null
},
{
"id": "en:2095-5138",
"lang": "en",
"name": "National Science Review",
"research_direction": "综合与交叉",
"cas_partition": "1区",
"cas_partition_note": null,
"cas_partition_year": 2025,
"jif": 17.1,
"jif_year": 2024,
"jif_note": null,
"jif_check_status": "已匹配公开汇编",
"composite_impact_factor": null,
"comprehensive_impact_factor": null,
"metric_year": null,
"metric_report_year": null,
"metric_caliber": null,
"annual_report_page": null,
"top_flag": "是",
"publisher_series": "其他",
"journal_mark": "领域期刊/新兴刊",
"article_type": "研究/方法/综合",
"issn": "2095-5138",
"eissn": "2053-714X",
"jcr_disciplines": [
"MULTIDISCIPLINARY SCIENCES(SCIE)"
],
"supplement_note": null,
"evidence_note": null,
"official_url": null,
"partition_source": "official",
"jif_source": "official",
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
"data_source_label": "JIF来源",
"data_source_url": null
},
{
"id": "en:nature-communications",
"lang": "en",
"name": "Nature Communications",
"research_direction": "综合与交叉",
"cas_partition": "1区",
"cas_partition_note": null,
"cas_partition_year": 2025,
"jif": 15.7,
"jif_year": 2024,
"jif_note": null,
"jif_check_status": "已匹配公开汇编",
"composite_impact_factor": null,
"comprehensive_impact_factor": null,
"metric_year": null,
"metric_report_year": null,
"metric_caliber": null,
"annual_report_page": null,
"top_flag": "是",
"publisher_series": "Nature Portfolio(含合作刊)",
"journal_mark": "领域期刊/新兴刊",
"article_type": "研究/方法/综合",
"issn": null,
"eissn": "2041-1723",
"jcr_disciplines": [
"MULTIDISCIPLINARY SCIENCES(SCIE)"
],
"supplement_note": null,
"evidence_note": null,
"official_url": null,
"partition_source": "official",
"jif_source": "official",
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
"data_source_label": "JIF来源",
"data_source_url": null
},
{
"id": "en:advanced-science",
"lang": "en",
"name": "Advanced Science",
"research_direction": "综合与交叉",
"cas_partition": "1区",
"cas_partition_note": null,
"cas_partition_year": 2025,
"jif": 14.1,
"jif_year": 2024,
"jif_note": null,
"jif_check_status": "已匹配公开汇编",
"composite_impact_factor": null,
"comprehensive_impact_factor": null,
"metric_year": null,
"metric_report_year": null,
"metric_caliber": null,
"annual_report_page": null,
"top_flag": "是",
"publisher_series": "其他",
"journal_mark": "领域期刊/新兴刊",
"article_type": "研究/方法/综合",
"issn": null,
"eissn": "2198-3844",
"jcr_disciplines": [
"CHEMISTRY, MULTIDISCIPLINARY(SCIE)",
"MATERIALS SCIENCE, MULTIDISCIPLINARY(SCIE)",
"NANOSCIENCE & NANOTECHNOLOGY(SCIE)"
],
"supplement_note": null,
"evidence_note": null,
"official_url": null,
"partition_source": "official",
"jif_source": "official",
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
"data_source_label": "JIF来源",
"data_source_url": null
},
{
"id": "en:2375-2548",
"lang": "en",
"name": "Science Advances",
"research_direction": "综合与交叉",
"cas_partition": "1区",
"cas_partition_note": null,
"cas_partition_year": 2025,
"jif": 12.5,
"jif_year": 2024,
"jif_note": null,
"jif_check_status": "已匹配公开汇编",
"composite_impact_factor": null,
"comprehensive_impact_factor": null,
"metric_year": null,
"metric_report_year": null,
"metric_caliber": null,
"annual_report_page": null,
"top_flag": "是",
"publisher_series": "Science / AAAS",
"journal_mark": "领域期刊/新兴刊",
"article_type": "研究/方法/综合",
"issn": "2375-2548",
"eissn": "2375-2548",
"jcr_disciplines": [
"MULTIDISCIPLINARY SCIENCES(SCIE)"
],
"supplement_note": null,
"evidence_note": null,
"official_url": null,
"partition_source": "official",
"jif_source": "official",
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
"data_source_label": "JIF来源",
"data_source_url": null
},
{
"id": "en:2096-5168",
"lang": "en",
"name": "Research",
"research_direction": "综合与交叉",
"cas_partition": "1区",
"cas_partition_note": null,
"cas_partition_year": 2025,
"jif": 10.9,
"jif_year": 2024,
"jif_note": null,
"jif_check_status": "已按Clarivate勘误更新",
"composite_impact_factor": null,
"comprehensive_impact_factor": null,
"metric_year": null,
"metric_report_year": null,
"metric_caliber": null,
"annual_report_page": null,
"top_flag": "是",
"publisher_series": "Science Partner Journals(合作刊)",
"journal_mark": "领域期刊/新兴刊",
"article_type": "研究/方法/综合",
"issn": "2096-5168",
"eissn": "2639-5274",
"jcr_disciplines": [
"MULTIDISCIPLINARY SCIENCES(SCIE)"
],
"supplement_note": "2024 JIF采用2025-10-15官方重载修订值。",
"evidence_note": null,
"official_url": null,
"partition_source": "official",
"jif_source": "official",
"jif_source_url": "https://journalcitationreports.zendesk.com/hc/en-gb/articles/36310787349521-2025",
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
"data_source_label": "JIF来源",
"data_source_url": null
},
{
"id": "en:0027-8424",
"lang": "en",
"name": "PROCEEDINGS OF THE NATIONAL ACADEMY OF SCIENCES OF THE UNITED STATES OF AMERICA",
"research_direction": "综合与交叉",
"cas_partition": "1区",
"cas_partition_note": null,
"cas_partition_year": 2025,
"jif": 9.1,
"jif_year": 2024,
"jif_note": null,
"jif_check_status": "已匹配公开汇编",
"composite_impact_factor": null,
"comprehensive_impact_factor": null,
"metric_year": null,
"metric_report_year": null,
"metric_caliber": null,
"annual_report_page": null,
"top_flag": "是",
"publisher_series": "其他",
"journal_mark": "经典专业刊",
"article_type": "研究/方法/综合",
"issn": "0027-8424",
"eissn": "1091-6490",
"jcr_disciplines": [
"MULTIDISCIPLINARY SCIENCES(SCIE)"
],
"supplement_note": null,
"evidence_note": null,
"official_url": null,
"partition_source": "official",
"jif_source": "official",
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
"data_source_label": "JIF来源",
"data_source_url": null
},
{
"id": "en:scientific-data",
"lang": "en",
"name": "Scientific Data",
"research_direction": "综合与交叉",
"cas_partition": "2区",
"cas_partition_note": null,
"cas_partition_year": 2025,
"jif": 6.9,
"jif_year": 2024,
"jif_note": null,
"jif_check_status": "已匹配公开汇编",
"composite_impact_factor": null,
"comprehensive_impact_factor": null,
"metric_year": null,
"metric_report_year": null,
"metric_caliber": null,
"annual_report_page": null,
"top_flag": "否",
"publisher_series": "Nature Portfolio(含合作刊)",
"journal_mark": "领域期刊/新兴刊",
"article_type": "研究/方法/综合",
"issn": null,
"eissn": "2052-4463",
"jcr_disciplines": [
"MULTIDISCIPLINARY SCIENCES(SCIE)"
],
"supplement_note": null,
"evidence_note": null,
"official_url": null,
"partition_source": "official",
"jif_source": "official",
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
"data_source_label": "JIF来源",
"data_source_url": null
},
{
"id": "en:iscience",
"lang": "en",
"name": "iScience",
"research_direction": "综合与交叉",
"cas_partition": "2区",
"cas_partition_note": null,
"cas_partition_year": 2025,
"jif": 4.1,
"jif_year": 2024,
"jif_note": null,
"jif_check_status": "已匹配公开汇编",
"composite_impact_factor": null,
"comprehensive_impact_factor": null,
"metric_year": null,
"metric_report_year": null,
"metric_caliber": null,
"annual_report_page": null,
"top_flag": "否",
"publisher_series": "Cell Press(含合作刊)",
"journal_mark": "领域期刊/新兴刊",
"article_type": "研究/方法/综合",
"issn": null,
"eissn": "2589-0042",
"jcr_disciplines": [
"MULTIDISCIPLINARY SCIENCES(SCIE)"
],
"supplement_note": null,
"evidence_note": null,
"official_url": null,
"partition_source": "official",
"jif_source": "official",
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
"data_source_label": "JIF来源",
"data_source_url": null
},
{
"id": "en:2047-217X",
"lang": "en",
"name": "GigaScience",
"research_direction": "综合与交叉",
"cas_partition": "2区",
"cas_partition_note": null,
"cas_partition_year": 2025,
"jif": 3.9,
"jif_year": 2024,
"jif_note": null,
"jif_check_status": "已匹配公开汇编",
"composite_impact_factor": null,
"comprehensive_impact_factor": null,
"metric_year": null,
"metric_report_year": null,
"metric_caliber": null,
"annual_report_page": null,
"top_flag": "否",
"publisher_series": "其他",
"journal_mark": "领域期刊/新兴刊",
"article_type": "研究/方法/综合",
"issn": "2047-217X",
"eissn": "2047-217X",
"jcr_disciplines": [
"MULTIDISCIPLINARY SCIENCES(SCIE)"
],
"supplement_note": null,
"evidence_note": null,
"official_url": null,
"partition_source": "official",
"jif_source": "official",
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
"data_source_label": "JIF来源",
"data_source_url": null
},
{
"id": "en:2045-2322",
"lang": "en",
"name": "Scientific Reports",
"research_direction": "综合与交叉",
"cas_partition": "3区",
"cas_partition_note": null,
"cas_partition_year": 2025,
"jif": 3.9,
"jif_year": 2024,
"jif_note": null,
"jif_check_status": "已匹配公开汇编",
"composite_impact_factor": null,
"comprehensive_impact_factor": null,
"metric_year": null,
"metric_report_year": null,
"metric_caliber": null,
"annual_report_page": null,
"top_flag": "否",
"publisher_series": "Nature Portfolio(含合作刊)",
"journal_mark": "领域期刊/新兴刊",
"article_type": "研究/方法/综合",
"issn": "2045-2322",
"eissn": "2045-2322",
"jcr_disciplines": [
"MULTIDISCIPLINARY SCIENCES(SCIE)"
],
"supplement_note": null,
"evidence_note": null,
"official_url": null,
"partition_source": "official",
"jif_source": "official",
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
"data_source_label": "JIF来源",
"data_source_url": null
},
{
"id": "en:pnas-nexus",
"lang": "en",
"name": "PNAS Nexus",
"research_direction": "综合与交叉",
"cas_partition": "3区",
"cas_partition_note": null,
"cas_partition_year": 2025,
"jif": 3.8,
"jif_year": 2024,
"jif_note": null,
"jif_check_status": "已匹配公开汇编",
"composite_impact_factor": null,
"comprehensive_impact_factor": null,
"metric_year": null,
"metric_report_year": null,
"metric_caliber": null,
"annual_report_page": null,
"top_flag": "否",
"publisher_series": "其他",
"journal_mark": "领域期刊/新兴刊",
"article_type": "研究/方法/综合",
"issn": null,
"eissn": "2752-6542",
"jcr_disciplines": [
"MULTIDISCIPLINARY SCIENCES(ESCI)",
"SOCIAL SCIENCES, INTERDISCIPLINARY(ESCI)"
],
"supplement_note": null,
"evidence_note": null,
"official_url": null,
"partition_source": "official",
"jif_source": "official",
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
"data_source_label": "JIF来源",
"data_source_url": null
},
{
"id": "en:heliyon",
"lang": "en",
"name": "Heliyon",
"research_direction": "综合与交叉",
"cas_partition": "4区",
"cas_partition_note": null,
"cas_partition_year": 2025,
"jif": 3.6,
"jif_year": 2024,
"jif_note": null,
"jif_check_status": "已匹配公开汇编",
"composite_impact_factor": null,
"comprehensive_impact_factor": null,
"metric_year": null,
"metric_report_year": null,
"metric_caliber": null,
"annual_report_page": null,
"top_flag": "否",
"publisher_series": "其他",
"journal_mark": "领域期刊/新兴刊",
"article_type": "研究/方法/综合",
"issn": null,
"eissn": "2405-8440",
"jcr_disciplines": [
"MULTIDISCIPLINARY SCIENCES(SCIE)"
],
"supplement_note": null,
"evidence_note": null,
"official_url": null,
"partition_source": "official",
"jif_source": "official",
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
"data_source_label": "JIF来源",
"data_source_url": null
},
{
"id": "en:2054-5703",
"lang": "en",
"name": "Royal Society Open Science",
"research_direction": "综合与交叉",
"cas_partition": "3区",
"cas_partition_note": null,
"cas_partition_year": 2025,
"jif": 2.9,
"jif_year": 2024,
"jif_note": null,
"jif_check_status": "已匹配公开汇编",
"composite_impact_factor": null,
"comprehensive_impact_factor": null,
"metric_year": null,
"metric_report_year": null,
"metric_caliber": null,
"annual_report_page": null,
"top_flag": "否",
"publisher_series": "其他",
"journal_mark": "领域期刊/新兴刊",
"article_type": "研究/方法/综合",
"issn": "2054-5703",
"eissn": "2054-5703",
"jcr_disciplines": [
"MULTIDISCIPLINARY SCIENCES(SCIE)"
],
"supplement_note": null,
"evidence_note": null,
"official_url": null,
"partition_source": "official",
"jif_source": "official",
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
"data_source_label": "JIF来源",
"data_source_url": null
},
{
"id": "en:1932-6203",
"lang": "en",
"name": "PLoS One",
"research_direction": "综合与交叉",
"cas_partition": "3区",
"cas_partition_note": null,
"cas_partition_year": 2025,
"jif": 2.6,
"jif_year": 2024,
"jif_note": null,
"jif_check_status": "已匹配公开汇编",
"composite_impact_factor": null,
"comprehensive_impact_factor": null,
"metric_year": null,
"metric_report_year": null,
"metric_caliber": null,
"annual_report_page": null,
"top_flag": "否",
"publisher_series": "其他",
"journal_mark": "领域期刊/新兴刊",
"article_type": "研究/方法/综合",
"issn": "1932-6203",
"eissn": "1932-6203",
"jcr_disciplines": [
"MULTIDISCIPLINARY SCIENCES(SCIE)"
],
"supplement_note": null,
"evidence_note": null,
"official_url": null,
"partition_source": "official",
"jif_source": "official",
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
"data_source_label": "JIF来源",
"data_source_url": null
},
{
"id": "en:2167-8359",
"lang": "en",
"name": "PeerJ",
"research_direction": "综合与交叉",
"cas_partition": "3区",
"cas_partition_note": null,
"cas_partition_year": 2025,
"jif": 2.4,
"jif_year": 2024,
"jif_note": null,
"jif_check_status": "已匹配公开汇编",
"composite_impact_factor": null,
"comprehensive_impact_factor": null,
"metric_year": null,
"metric_report_year": null,
"metric_caliber": null,
"annual_report_page": null,
"top_flag": "否",
"publisher_series": "其他",
"journal_mark": "领域期刊/新兴刊",
"article_type": "研究/方法/综合",
"issn": "2167-8359",
"eissn": "2167-8359",
"jcr_disciplines": [
"MULTIDISCIPLINARY SCIENCES(SCIE)"
],
"supplement_note": null,
"evidence_note": null,
"official_url": null,
"partition_source": "official",
"jif_source": "official",
"jif_source_url": "https://github.com/yongqianxiao/share_repo/blob/master/JCR/2024JCR%E5%AE%8C%E6%95%B4%E7%89%88%EF%BC%882025.6.18%E5%8F%91%E5%B8%83%EF%BC%89.xlsx",
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
"data_source_label": "JIF来源",
"data_source_url": null
},
{
"id": "en:2050-084X",
"lang": "en",
"name": "eLife",
"research_direction": "综合与交叉",
"cas_partition": "1区",
"cas_partition_note": null,
"cas_partition_year": 2025,
"jif": null,
"jif_year": 2024,
"jif_note": "未被2024 JCR收录",
"jif_check_status": "源表标N/A",
"composite_impact_factor": null,
"comprehensive_impact_factor": null,
"metric_year": null,
"metric_report_year": null,
"metric_caliber": null,
"annual_report_page": null,
"top_flag": "是",
"publisher_series": "其他",
"journal_mark": "领域期刊/新兴刊",
"article_type": "研究/方法/综合",
"issn": "2050-084X",
"eissn": "2050-084X",
"jcr_disciplines": [
"BIOLOGY(ESCI)"
],
"supplement_note": "源表2024 JIF为N/A;不可用历史IF代填。",
"evidence_note": null,
"official_url": null,
"partition_source": "official",
"jif_source": null,
"jif_source_url": null,
"partition_source_url": "https://huaxue.hebtu.edu.cn/a/2025/09/23/A5A5BBD96F6B4410B87B6D4C43BDFD63.html",
"data_source_label": "JIF来源",
"data_source_url": null
}
],
"meta": {
"total": 21,
"page": 1,
"size": 50,
"lang": "en",
"domain": "综合与交叉",
"cas_partition": "",
"q": "",
"domains": [
"细胞分子发育与生理",
"生态环境与保护",
"微生物真菌与生物技术",
"农学作物与种质",
"遗传组学与计算生物",
"植物科学与分子育种",
"水产渔业与海洋生物",
"食品营养与安全",
"植物保护与昆虫",
"畜牧动物营养与繁殖",
"土壤肥料与地球生物化学",
"林业木材与草业",
"兽医与寄生虫",
"综合与交叉",
"园艺果树与采后"
],
"data_source_note": "分区:2025年中科院公开表(official);JIF:2024 JCR,由2025版JCR发布(official);整理日期2026-10-10。空白=来源未给值≠0;研究方向/期刊标记/文章类型为导航标签,非官方评级。",
"data_imported": true
},
"error": null
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,10 @@
{
"ok": false,
"items": [],
"meta": null,
"error": {
"code": "VALIDATION",
"message": "size 必须在 1-200,收到 500",
"degraded": false
}
}
@@ -0,0 +1,10 @@
{
"ok": false,
"items": [],
"meta": null,
"error": {
"code": "VALIDATION",
"message": "不支持的 lang='xx',可选:en / zh",
"degraded": false
}
}
Binary file not shown.
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
[pytest]
python_files = test_*.py
pythonpath = .
markers =
review_unit: 审稿服务单元测试
asyncio_mode = auto
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# restart.sh —— REVIEW 服务重启脚本(8100;不触碰生产服务 8090/8091/8092/8093/8094/8097/8098)。
# 用法: restart.sh [python] [port]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# 默认 venv:本机统一 venv 固定路径(原先 $REPO_ROOT/../ 推导在本工作区不存在)
PYTHON="${1:-/home/xzq/5090/teamWorkspace/zh/plantai-rag-new-venv/bin/python}"
PORT="${2:-8100}"
HOST="${REVIEW_BIND_HOST:-127.0.0.1}"
[[ -x "$PYTHON" ]] || { echo "Python not found: $PYTHON" >&2; exit 2; }
APP_MODULE="app.main:app"
if command -v lsof >/dev/null 2>&1; then
PID="$(lsof -ti tcp:"$PORT" -sTCP:LISTEN 2>/dev/null || true)"
if [[ -n "$PID" ]]; then
echo "[restart] 停止 $PORT 旧进程 pid=$PID"
kill $PID 2>/dev/null || true
sleep 2
fi
fi
cd "$SCRIPT_DIR"
# 加载运行时配置(.env 不入 git):services/llm.py 直接读 REVIEW_LLM_*/DOCGEN_LLM_*
# 裸键,paper_parse.py 读 REVIEW_*——需 set -a 显式导出(同 paper_writer_service 先例)。
set -a
[ -f .env ] && source .env
set +a
echo "[restart] 启动审稿服务 :$PORT($PYTHON)"
nohup "$PYTHON" -m uvicorn "$APP_MODULE" --host "$HOST" --port "$PORT" \
> "$SCRIPT_DIR/review.log" 2>&1 &
echo "[restart] 完成;日志 review_service/review.log"
+17
View File
@@ -0,0 +1,17 @@
# scripts/ —— 离线工具(不被 app/ 服务代码引用,独立 CLI)
## fetch_format_rules.py —— 投稿指南自动抓取(探索性,第一批)
- 依赖:venv 已装的 httpx + beautifulsoup4 4.15.0(用户拍板 2026-10-10);
解析器 lxml 6.1.2 在 `tooling/requirements.lock` 内。
- 用法与流程见 `fetch_format_rules.py` 模块 docstring(CLI 一次一本期刊)。
- 产物:`data/format_cache/{journal_id}.json`(运行产物,已 gitignore,不入库)。
### 现状(2026-10-10)
OUP / Wiley / Cell 官网对本运行环境返回 403,自动抓取路线失败;
Genome Biology / BMC Plant Biology / Frontiers / Rice 可抓但 LLM 抽取结果
仍需人工对照官网。当前 `journals.json` 中 5 本精选期刊的 `format_rules`
为**人工核验官网原文后写入**(`rules_source=official` + `source_url` +
`last_verified`),**不是**本脚本产物。本工具保留作为后续换网络环境 /
换可达站点后的重跑手段。
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""scripts/fetch_format_rules.py —— 格式规则自动获取(第一批,人工核验用 CLI)。
用法(在 review_service/ 下):
/home/xzq/5090/teamWorkspace/zh/plantai-rag-new-venv/bin/python \
scripts/fetch_format_rules.py <journal_id> <guidelines_url>
流程:
1. fetch_submission_guidelines(url) # robots.txt + 同域 ≥2s 限速 + 正文转 Markdown
2. extract_format_rules(markdown, journal_name) # DeepSeek 抽取(llm.py)
3. 存 data/format_cache/{journal_id}.json(失败也存,便于批量对账)
4. 打印完整结果 JSON 供人工核验
凭证:结果文件顶层 source_url(步骤 1 的实际抓取地址)+ source_quotes(LLM
逐字引证,按字段分组);非 null 字段缺引证 → 抽取判失败(ok=false)。
约束(第一批):只跑指定 3 本;不触 API / 前端 / journals.json(format_rules 只读
查名,不写回)。journal_name 优先取 data/journals/journals.json 的 name(只读)。
"""
from __future__ import annotations
import asyncio
import json
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
_ROOT = Path(__file__).resolve().parents[1] # review_service/
sys.path.insert(0, str(_ROOT))
_JOURNALS_JSON = _ROOT / "data" / "journals" / "journals.json"
_CACHE_DIR = _ROOT / "data" / "format_cache"
def _load_env() -> None:
"""镜像 restart.sh 的 `set -a; source .env`:REVIEW_LLM_*/DOCGEN_LLM_* 裸键
由 services/llm.py 在每次调用时读 os.environ(不缓存),故调用前灌入即可。"""
env_path = _ROOT / ".env"
if not env_path.is_file():
return
for raw in env_path.read_text("utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
if line.startswith("export "):
line = line[7:].strip()
key, _, val = line.partition("=")
os.environ.setdefault(key.strip(), val.strip().strip("\"'"))
def _journal_name(journal_id: str) -> str:
"""journals.json 只读查名;查不到 → id 连字符转空格并首字母大写(LLM 上下文可读)。"""
try:
data = json.loads(_JOURNALS_JSON.read_text("utf-8"))
for j in data.get("journals") or []:
if j.get("id") == journal_id:
return str(j.get("name") or journal_id)
except Exception: # noqa: BLE001
pass
return journal_id.replace("-", " ").title()
def main() -> int:
if len(sys.argv) != 3:
print("用法: fetch_format_rules.py <journal_id> <guidelines_url>", file=sys.stderr)
return 2
journal_id = re.sub(r"[^\w.-]", "_", sys.argv[1].strip())
url = sys.argv[2].strip()
_load_env()
from app.services.format_extractor import extract_format_rules
from app.services.format_fetcher import fetch_submission_guidelines
async def run() -> tuple[dict, dict]:
fetched = await fetch_submission_guidelines(url)
print(f"[1/2] 抓取 {url} → ok={fetched['ok']}"
+ (f",markdown={len(fetched['markdown'])} 字符"
if fetched["ok"] else f",error={fetched['error']}"))
if not fetched["ok"]:
return fetched, {"ok": False, "error": f"抓取失败:{fetched['error']}",
"rules": None, "source_quotes": None,
"confidence": None, "notes": None, "raw": None}
extracted = await extract_format_rules(fetched["markdown"], _journal_name(journal_id))
if extracted["ok"]:
filled = sum(1 for v in extracted["rules"].values() if v is not None)
print(f"[2/2] LLM 抽取 → ok=True,8 字段中 {filled} 个非 null")
else:
print(f"[2/2] LLM 抽取 → ok=False,error={extracted['error']}")
return fetched, extracted
fetched, extracted = asyncio.run(run())
result = {
"journal_id": journal_id,
"journal_name": _journal_name(journal_id),
"source_url": url,
"fetched_at": fetched.get("fetched_at"),
"extracted_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"markdown_chars": len(fetched.get("markdown") or ""),
"ok": bool(extracted.get("ok")),
"error": extracted.get("error"),
"format_rules": ({**extracted["rules"], "rules_source": "fetched"}
if extracted.get("ok") else None),
"source_quotes": extracted.get("source_quotes"),
"confidence": extracted.get("confidence"),
"notes": extracted.get("notes"),
"raw_llm": extracted.get("raw"),
}
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
out_path = _CACHE_DIR / f"{journal_id}.json"
out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", "utf-8")
print(f"[save] {out_path.relative_to(_ROOT)}")
print("===== 完整结果(人工核验)=====")
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result["ok"] else 1
if __name__ == "__main__":
sys.exit(main())
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""scripts/import_journals.py —— 期刊目录导入 CLI(source_v4.xlsx → SQLite journal_directory)。
核心解析/upsert/schema 在 app/services/journal_seed.py(与启动自动 seed 共享);
本 CLI 仅 import 该模块(sys.path bootstrap),零网络零 BFF,仍不碰 db.py。
职责:口径校验 → 幂等 upsert → 对账报告 data/journals/import_report.txt。
数据流:
openpyxl 非 read_only 加载(需要读 cell.hyperlink.target 来源链接)
→ 「国际期刊」「中文期刊」两 sheet 按【列名】映射(不按索引,防列序漂移)
→ 行级清洗(空→None 绝不填 0;分区数字→"N区";JCR学科→JSON 数组)
→ 按 id 幂等 upsert(ON CONFLICT DO UPDATE,重跑 count 不变)
→ 对账报告 data/journals/import_report.txt
v4 源(2026-10-10):JIF=2025 统计年/2026 JCR 发布;中文 IF=2024 统计年/
年报 2025 版;新增官网三列 + JCR发布年;JCR学科列改名「JCR学科(2024源表)」。
旧文件 source.xlsx(v2)原样保留为回退点,勿覆盖。
schema 迁移(幂等 + 备份):既有表缺列 → PRAGMA table_info 探测后 ALTER TABLE
ADD COLUMN,缺哪加哪、重复跑不报错;跑前先 cp review.db review.db.bak-<日期>。
id 策略:有 ISSN → "{lang}:{ISSN原样(strip)}";无 ISSN → "{lang}:{slug(name)}";
同 lang 内 id 冲突 → 报告冲突列表并【中止导入】(非零退出,不写库)。
用法:python scripts/import_journals.py (cwd 任意;路径按本文件位置推导)
退出码:0 成功;1 id 冲突中止;2 源数据行数与口径不符;3 结构/文件错误。
"""
from __future__ import annotations
import os
import sqlite3
import sys
import time
from collections import Counter
from datetime import datetime
from pathlib import Path
from typing import Any
# ---- 路径(review_service/scripts/ → parents[1] = review_service)----
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT)) # 供 app.services.journal_seed import
from app.services.journal_seed import ( # noqa: E402
UPSERT_SQL,
EXPECT_INTL,
EXPECT_ZH,
SHEET_INTL,
SHEET_ZH,
JournalSeedError,
extract_rows_from_xlsx,
new_stats,
upsert_to_db,
)
SOURCE_XLSX = ROOT / "data" / "journals" / "source_v4.xlsx" # v4;v2=source.xlsx 保留回退
REPORT_TXT = ROOT / "data" / "journals" / "import_report.txt"
def build_report(
stage: str,
stats: dict,
counts: dict[str, int] | None,
conflicts: list[str],
idempotent_ok: bool | None,
exit_note: str = "",
) -> str:
L: list[str] = []
L.append("journals directory 导入对账报告")
L.append(f"生成时间: {datetime.now().isoformat(timespec='seconds')}")
L.append(f"源文件: {SOURCE_XLSX}({SOURCE_XLSX.stat().st_size if SOURCE_XLSX.exists() else 0} bytes)")
L.append(f"阶段: {stage}")
L.append("")
L.append("=== 计数 ===")
L.append(f"读取有效行: 国际 {stats['read_intl']} / 中文 {stats['read_zh']} / 合计 {stats['read_intl'] + stats['read_zh']}")
L.append(f"成功: {stats['success']} 跳过(名称空): {stats['skipped']} 失败: {stats['failed']}")
if counts is not None:
L.append(f"写入后 count(*): en={counts.get('en', 0)} zh={counts.get('zh', 0)} total={counts.get('total', 0)}")
L.append(f"期望: en={EXPECT_INTL} zh={EXPECT_ZH} total={EXPECT_INTL + EXPECT_ZH} → {'✓ 相符' if counts.get('total') == EXPECT_INTL + EXPECT_ZH else '✗ 不符'}")
L.append("")
L.append("=== 分区分布(国际)===")
L.append(" / ".join(f"{k} {v}" for k, v in sorted(stats["part_dist"].items())))
L.append(f"分区空白(→null 无note): {len(stats['part_blank'])}")
L.append(f"分区未检得(→null + note): {len(stats['part_notfound'])}")
L.append("")
L.append(f"=== JIF 缺失 {len(stats['jif_missing'])} 条(jif=null + note=核对状态)===")
for n, s in stats["jif_missing"]:
L.append(f" - {n} [{s}]")
L.append("")
L.append(f"=== 中文复合IF 缺失 {len(stats['cif_missing'])} 条 ===")
for n, s in stats["cif_missing"]:
L.append(f" - {n} [{s}]")
L.append("")
L.append("=== 分区未检得 期刊名 ===")
if stats["part_notfound"]:
for n in stats["part_notfound"]:
L.append(f" - {n}")
else:
L.append(" (无——源表以空白表示未检得,见上方'分区空白')")
L.append("")
L.append("=== 重复 id 冲突(应为空)===")
if conflicts:
for c in conflicts:
L.append(f" !! {c}")
else:
L.append(" (无)")
L.append("")
L.append("=== id 策略 ===")
L.append(f"ISSN 型: {stats['id_issn']} 条 slug 型: {stats['id_slug']} 条")
L.append("")
L.append("=== 来源超链接 ===")
L.append(f"国际 JIF来源: {stats['hl_jif']}/{stats['read_intl']} 分区来源: {stats['hl_part']}/{stats['read_intl']} 中文数据来源: {stats['hl_zh']}/{stats['read_zh']}")
L.append("")
L.append("=== 结构迁移(ALTER TABLE,幂等)===")
added = stats.get("migrated_cols") or []
skipped = stats.get("migrated_skipped") or []
L.append(f" 本次加列: {', '.join(added) if added else '无(列已齐)'}")
L.append(f" 已存在跳过: {', '.join(skipped) if skipped else '无'}")
L.append("")
L.append("=== 官网映射(决策:official_source 不入库)===")
L.append(f" 识别列: {stats.get('official_url_headers', '未识别')}")
tot = stats.get("official_total", 0)
L.append(f" official_url 填充: {stats.get('official_filled', 0)}/{tot}"
f" official_check_status 填充: {stats.get('official_check_filled', 0)}/{tot}")
L.append(f" jcr_release_year 填充: {stats.get('jcr_filled', 0)}(仅国际=2026;中文表无此列→null)")
missing = stats.get("official_missing") or []
if missing:
L.append(f" 官网缺失 {len(missing)} 条(official_url=null,前端显示「—」不生成链接):")
for lg, n in missing:
L.append(f" - [{lg}] {n}")
else:
L.append(" 官网缺失: (无)")
L.append("")
L.append("=== 幂等性验证 ===")
if idempotent_ok is None:
L.append(" 未执行")
else:
L.append(f" 第二次 upsert 后 count 不变: {'✓ 通过' if idempotent_ok else '✗ 失败'}")
L.append("")
if exit_note:
L.append(f"!!! {exit_note}")
return "\n".join(L) + "\n"
def main() -> int:
if not SOURCE_XLSX.exists():
print(f"[错误] 源文件不存在: {SOURCE_XLSX}", file=sys.stderr)
return 3
stats: dict[str, Any] = new_stats()
imported_at = datetime.now().isoformat(timespec="seconds")
t0 = time.time()
# 1) 读 Excel + 解析 + 口径/id 校验(journal_seed 共享;失败不写库)
try:
rows_en, rows_zh, stats = extract_rows_from_xlsx(
SOURCE_XLSX, imported_at=imported_at, stats=stats)
except JournalSeedError as exc:
REPORT_TXT.write_text(
build_report(exc.stage, stats, None, exc.conflicts, None, str(exc)),
encoding="utf-8")
if exc.exit_code == 1:
print(f"[中止] {exc}", file=sys.stderr)
for c in exc.conflicts:
print(f" !! {c}", file=sys.stderr)
elif exc.exit_code == 2:
print(f"[中止] {exc}", file=sys.stderr)
else:
print(f"[错误] {exc}", file=sys.stderr)
return exc.exit_code
all_rows = rows_en + rows_zh
# 2) 写 SQLite(独立 stdlib 连接;表由 journal_seed 创建,db.py 零触碰)
db_path = Path(os.environ.get("REVIEW_DB_PATH") or (ROOT / "review.db"))
if not db_path.is_absolute():
db_path = ROOT / db_path
counts = upsert_to_db(db_path, rows_en, rows_zh)
stats["success"] = len(all_rows)
# 3) 幂等验证:同数据第二次 upsert → count 不变
conn = sqlite3.connect(str(db_path))
try:
conn.executemany(UPSERT_SQL, all_rows)
conn.commit()
cur = conn.execute(
"SELECT lang, COUNT(*) FROM journal_directory GROUP BY lang")
d = {lang: n for lang, n in cur.fetchall()}
idempotent_ok = {"en": d.get("en", 0), "zh": d.get("zh", 0),
"total": sum(d.values())} == counts
# 分区分布来自实际库(防统计与写入脱节;未检得 note 单列)
stats["part_dist"] = Counter(dict(conn.execute(
"SELECT COALESCE(cas_partition, "
"CASE WHEN cas_partition_note='未检得' THEN '未检得' ELSE '空白' END), COUNT(*) "
"FROM journal_directory WHERE lang='en' GROUP BY 1").fetchall()))
finally:
conn.close()
report = build_report("完成", stats, counts, [], idempotent_ok)
REPORT_TXT.write_text(report, encoding="utf-8")
print(report)
print(f"[完成] 耗时 {time.time() - t0:.2f}s db={db_path} 报告={REPORT_TXT}")
return 0
if __name__ == "__main__":
sys.exit(main())
+11
View File
@@ -0,0 +1,11 @@
"""tests/conftest.py —— 全局测试环境(不 import app,须先于测试模块执行)。
- REVIEW_SEED_ON_STARTUP=false:默认关闭启动自动 seed,防止单测触碰真实
review.db、并避免每个 TestClient 都导入 1056 行 xlsx(开关自身由
test_seed.py 显式覆盖验证)。
"""
from __future__ import annotations
import os
os.environ.setdefault("REVIEW_SEED_ON_STARTUP", "false")
+228
View File
@@ -0,0 +1,228 @@
"""tests/test_compile.py —— P3a LaTeX 编译单元/集成测试。
真实调用 tools/tectonic(宏包已预热缓存);二进制缺失时集成用例 skipif 跳过,
校验/队列/清扫等纯逻辑用例照常运行。
运行: cd review_service && <venv>/bin/python -m pytest -q
"""
from __future__ import annotations
import os
import time
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.config import get_settings
from app.main import app
from app.services import latex_compile as lc
MINIMAL = "\\documentclass[11pt]{article}\n\\begin{document}\nhello p3\n\\end{document}\n"
_TECTONIC_OK = get_settings().tectonic_abs.is_file()
needs_tectonic = pytest.mark.skipif(
not _TECTONIC_OK, reason="tools/tectonic 未安装(bash tools/install_tectonic.sh)"
)
@pytest.fixture()
def client(tmp_path, monkeypatch):
settings = get_settings()
monkeypatch.setattr(settings, "reformat_dir", str(tmp_path / "jobs"))
# 缓存目录不注入 tmp:用部署预热好的 data/tectonic-cache(否则每次测试重拉 bundle)
monkeypatch.setattr(settings, "compile_timeout_s", 60.0)
monkeypatch.setattr(lc, "_pending", 0)
with TestClient(app) as c:
yield c
# ================================================================ 校验(无需 tectonic)
def test_validation_no_input(client: TestClient):
r = client.post("/api/review/compile", data={})
body = r.json()
assert r.status_code == 200 and body["ok"] is False
assert body["error"]["code"] == "VALIDATION"
def test_validation_bad_job_id(client: TestClient):
r = client.post("/api/review/compile", data={"job_id": "../evil"})
assert r.json()["error"]["code"] == "VALIDATION"
def test_validation_job_not_found(client: TestClient):
r = client.post("/api/review/compile", data={"job_id": "0123456789ab"})
assert r.json()["error"]["code"] == "VALIDATION"
def test_validation_path_traversal_filename(client: TestClient):
r = client.post(
"/api/review/compile",
files={"file": ("../evil.tex", b"x", "application/x-tex")},
)
assert r.json()["error"]["code"] == "VALIDATION"
def test_validation_non_tex(client: TestClient):
r = client.post(
"/api/review/compile",
files={"file": ("paper.pdf", b"x", "application/pdf")},
)
assert r.json()["error"]["code"] == "VALIDATION"
def test_validation_oversize(client: TestClient, monkeypatch):
monkeypatch.setattr(get_settings(), "max_tex_bytes", 10)
r = client.post(
"/api/review/compile",
files={"file": ("paper.tex", b"01234567890", "application/x-tex")},
)
assert r.json()["error"]["code"] == "VALIDATION"
# ================================================================ 编译(真实 tectonic)
@needs_tectonic
def test_compile_happy_path(client: TestClient):
r = client.post(
"/api/review/compile",
files={"file": ("paper.tex", MINIMAL.encode(), "application/x-tex")},
)
body = r.json()
assert r.status_code == 200 and body["ok"] is True, body
cid = body["compile_id"]
assert len(cid) == 12
pdf = client.get(body["pdf_url"])
assert pdf.status_code == 200
assert pdf.headers["content-type"].startswith("application/pdf")
assert pdf.content[:5] == b"%PDF-"
log = client.get(body["log_url"])
assert log.status_code == 200
assert "LaTeX2e" in log.text and "Output written" in log.text
meta = client.get(f"/api/review/compile/{cid}")
assert meta.json()["ok"] is True
assert meta.json()["meta"]["pdf_bytes"] > 0
@needs_tectonic
def test_compile_by_job_id(client: TestClient):
tex = (
"\\documentclass[11pt]{article}\n\\begin{document}\nvia job\n\\end{document}\n"
)
rf = client.post(
"/api/review/reformat",
files={"file": ("paper.tex", tex.encode(), "application/x-tex")},
data={"target_journal": "default"},
).json()
assert rf["ok"] is True
r = client.post("/api/review/compile", data={"job_id": rf["job_id"]})
body = r.json()
assert body["ok"] is True and body["compile_id"] == rf["job_id"]
pdf = client.get(body["pdf_url"])
assert pdf.content[:5] == b"%PDF-"
@needs_tectonic
def test_compile_malicious_write18(client: TestClient, tmp_path):
canary = tmp_path / "canary_p3"
evil = (
"\\documentclass{article}\n"
f"\\write18{{touch {canary}}}\n"
"\\begin{document}\nno shell please\n\\end{document}\n"
)
r = client.post(
"/api/review/compile",
files={"file": ("evil.tex", evil.encode(), "application/x-tex")},
)
body = r.json()
# 系统无损即通过(信封 ok 与否均可):canary 绝不能出现
assert not canary.exists(), r"\write18 竟然执行了!"
if body["ok"]:
pdf = client.get(body["pdf_url"])
assert pdf.content[:5] == b"%PDF-"
else:
assert body["error"]["code"] in ("COMPILE_FAILED", "TIMEOUT")
@needs_tectonic
def test_compile_failed_returns_log_tail(client: TestClient):
bad = "\\documentclass{article}\n\\begin{document}\n\\undefinedcommandxyz\n"
r = client.post(
"/api/review/compile",
files={"file": ("bad.tex", bad.encode(), "application/x-tex")},
)
body = r.json()
assert body["ok"] is False
assert body["error"]["code"] == "COMPILE_FAILED"
assert "log" in body["error"]["message"].lower() or body["error"].get("detail")
@needs_tectonic
def test_compile_timeout(client: TestClient, monkeypatch):
monkeypatch.setattr(get_settings(), "compile_timeout_s", 5.0)
loop_tex = (
"\\documentclass{article}\n\\begin{document}\n"
"\\count255=0\n\\loop\\iftrue\\advance\\count255 by 1\\repeat\n"
"\\end{document}\n"
)
started = time.monotonic()
r = client.post(
"/api/review/compile",
files={"file": ("loop.tex", loop_tex.encode(), "application/x-tex")},
)
elapsed = time.monotonic() - started
body = r.json()
assert body["ok"] is False
assert body["error"]["code"] == "TIMEOUT"
assert elapsed < 15.0, f"超时回收过慢:{elapsed:.1f}s"
# ================================================================ 队列 / 清扫
def test_queue_full(client: TestClient, monkeypatch):
monkeypatch.setattr(lc, "_pending", 5)
monkeypatch.setattr(get_settings(), "compile_queue_limit", 5)
r = client.post(
"/api/review/compile",
files={"file": ("paper.tex", MINIMAL.encode(), "application/x-tex")},
)
assert r.status_code == 429
body = r.json()
assert body["ok"] is False and body["error"]["code"] == "QUEUE_FULL"
def test_tool_unavailable(client: TestClient, monkeypatch):
monkeypatch.setattr(get_settings(), "tectonic_path", "tools/definitely-missing")
r = client.post(
"/api/review/compile",
files={"file": ("paper.tex", MINIMAL.encode(), "application/x-tex")},
)
assert r.status_code == 200
assert r.json()["error"]["code"] == "TOOL_UNAVAILABLE"
assert "install_tectonic" in r.json()["error"]["message"]
def test_sweep_expired(tmp_path, monkeypatch):
settings = get_settings()
monkeypatch.setattr(settings, "reformat_dir", str(tmp_path / "jobs"))
base = tmp_path / "jobs"
stale = base / "aabbccddeeff"
stale.mkdir(parents=True)
(stale / "output.pdf").write_bytes(b"%PDF-fake")
old = time.time() - 8 * 86400
os.utime(stale, (old, old))
fresh = base / "112233445566"
fresh.mkdir()
noise = base / "not-a-job"
noise.mkdir()
removed = lc.sweep_expired()
assert removed == 1
assert not stale.exists()
assert fresh.exists()
assert noise.exists()
+163
View File
@@ -0,0 +1,163 @@
"""tests/test_presubmit.py —— presubmit 离线单元测试(mock 解析与 LLM,不依赖上游)。
覆盖:非 PDF 校验 / 报告归一化 / LLM 失败降级 / 解析失败降级 / 非法 recommendation 回退。
运行: cd review_service && <venv>/bin/python -m pytest -q
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.services import llm, paper_parse
FAKE_PDF = b"%PDF-1.4 fake-bytes-for-test"
LLM_PAYLOAD = {
"summary": "研究问题清晰,但统计与证据链存在重大缺口。",
"major_weaknesses": [
{
"title": "统计方法不当",
"detail": "未做多重检验校正",
"evidence": "Methods 2.1 仅报 p<0.05,未见 FDR/Bonferroni",
"suggestion": "补充 FDR 校正",
},
"纯字符串条目", # 宽容归一化:字符串 → WeaknessItem(title=...)
],
# minor 故意不带 evidence 字段 → 归一化应补空串
"minor_weaknesses": [{"detail": "缺少缩写表", "title": "缩写"}],
"presentation_format": [{"title": "图注不完整", "detail": "Fig.2 无单位", "suggestion": "补单位"}],
"questions_for_authors": ["群体结构如何校正?", ""],
"recommendation": "MAJOR_REVISION", # 应归一化为小写
}
@pytest.fixture()
def client() -> TestClient:
return TestClient(app)
@pytest.fixture(autouse=True)
def _mock_upstreams(monkeypatch):
async def fake_parse(file_bytes: bytes, filename: str = "paper.pdf") -> str:
assert file_bytes == FAKE_PDF
return "论文正文……" * 10
async def fake_llm_json(system: str, user: str, **kwargs):
assert "论文正文" in user
return dict(LLM_PAYLOAD)
async def boom_llm(*args, **kwargs):
raise llm.LLMError("余额不足")
async def boom_parse(*args, **kwargs):
raise paper_parse.ParseError("PDF 解析结果为空")
monkeypatch.setattr(paper_parse, "parse_pdf_bytes", fake_parse)
monkeypatch.setattr(llm, "complete_json", fake_llm_json)
yield
# monkeypatch 自动还原
def _post(client: TestClient, filename: str, content: bytes, journal: str = ""):
data = {"target_journal": journal} if journal else {}
return client.post(
"/api/review/presubmit",
files={"file": (filename, content, "application/pdf")},
data=data,
)
def test_non_pdf_rejected(client: TestClient):
r = client.post(
"/api/review/presubmit",
files={"file": ("a.txt", b"x", "text/plain")},
)
body = r.json()
assert body["ok"] is False
assert body["error"]["code"] == "VALIDATION"
def test_empty_file_rejected(client: TestClient):
body = _post(client, "p.pdf", b"").json()
assert body["ok"] is False
assert body["error"]["code"] == "VALIDATION"
def test_normal_report_normalized(client: TestClient):
body = _post(client, "p.pdf", FAKE_PDF, journal="Plant Cell").json()
assert body["ok"] is True
report = body["report"]
# recommendation 大写 → 小写
assert report["recommendation"] == "major_revision"
# 字符串条目宽容归一化
assert len(report["major_weaknesses"]) == 2
assert report["major_weaknesses"][1]["title"] == "纯字符串条目"
# Evidence 字段:有则透传,字符串条目/缺失则为空串
assert report["major_weaknesses"][0]["evidence"] == "Methods 2.1 仅报 p<0.05,未见 FDR/Bonferroni"
assert report["major_weaknesses"][1]["evidence"] == ""
assert report["minor_weaknesses"][0]["evidence"] == ""
# 空问题剔除
assert report["questions_for_authors"] == ["群体结构如何校正?"]
assert report["minor_weaknesses"] and report["presentation_format"] and report["summary"]
# meta 透传
assert body["meta"]["target_journal"] == "Plant Cell"
assert body["meta"]["text_chars"] > 0
assert body["meta"]["truncated"] is False
def test_llm_failure_degrades(client: TestClient, monkeypatch):
async def boom(*args, **kwargs):
raise llm.LLMError("余额不足")
monkeypatch.setattr(llm, "complete_json", boom)
body = _post(client, "p.pdf", FAKE_PDF).json()
assert body["ok"] is False
assert body["error"]["code"] == "UPSTREAM_DOWN"
assert body["error"]["degraded"] is True
def test_parse_failure_degrades(client: TestClient, monkeypatch):
async def boom(*args, **kwargs):
raise paper_parse.ParseError("PDF 解析结果为空")
monkeypatch.setattr(paper_parse, "parse_pdf_bytes", boom)
body = _post(client, "p.pdf", FAKE_PDF).json()
assert body["ok"] is False
assert body["error"]["code"] == "PARSE_FAILED"
assert body["error"]["degraded"] is True
def test_invalid_recommendation_falls_back(client: TestClient, monkeypatch):
async def weird(*args, **kwargs):
return {"summary": "x", "recommendation": "strong_accept", "major_weaknesses": "not-a-list"}
monkeypatch.setattr(llm, "complete_json", weird)
body = _post(client, "p.pdf", FAKE_PDF).json()
assert body["ok"] is True
assert body["report"]["recommendation"] == "major_revision"
assert body["report"]["major_weaknesses"] == []
def test_domain_system_prompt_wired(client: TestClient, monkeypatch):
"""植物基因组学六维清单 + Evidence 规则必须注入 System Prompt。"""
captured: dict[str, str] = {}
async def spy(system: str, user: str, **kwargs):
captured["system"] = system
return dict(LLM_PAYLOAD)
monkeypatch.setattr(llm, "complete_json", spy)
body = _post(client, "p.pdf", FAKE_PDF, journal="Plant Cell").json()
assert body["ok"] is True
system = captured["system"]
for marker in (
"BUSCO", "N50", "L50", "Hi-C", "挂载率", # 维度1 组装质量
"MAKER", "AUGUSTUS", "RNA-seq", # 维度2 注释可靠性
"Bootstrap", "OrthoFinder", # 维度3 比较基因组
"SRA", "BioProject", "GFF3", "accession", # 维度4 数据合规
"GWAS", "多重检验校正", "生物学重复", # 维度5 统计设计
"凭证标本", "ABS", "惠益分享", # 维度6 伦理合规
"evidence", "章节", # Evidence 硬性要求
):
assert marker in system, f"System Prompt 缺少领域标记: {marker}"
+271
View File
@@ -0,0 +1,271 @@
"""tests/test_recommend.py —— /api/review/recommend 离线单元测试。
mock vstore 向量匹配(journal_kb.match)与 LLM(llm.complete_json),不依赖上游。
核心回归:LLM 幻觉期刊名绝不进输出(白名单 id 硬校验 + 名称一律取 DB)。
运行: cd review_service && <venv>/bin/python -m pytest -q
"""
from __future__ import annotations
import asyncio
import pytest
from fastapi.testclient import TestClient
from app import db as db_mod
from app.main import app
from app.services import journal_kb, journal_rank, llm
ABSTRACT = (
"We report a chromosome-level genome assembly of wild rice Oryza rufipogon "
"using PacBio HiFi and Hi-C sequencing, annotate 38,412 protein-coding genes "
"with RNA-seq evidence, and perform comparative genomics and GWAS analyses "
"to reveal the evolutionary basis of agronomic traits in rice."
)
SEED_NAMES = {
"Genome Biology",
"BMC Plant Biology",
"Molecular Plant",
"Frontiers in Plant Science",
"Rice",
}
# 覆盖全部 5 本种子期刊的合法重排结果
ALL_RANKINGS = [
{"id": "genome-biology", "tier": "challenge", "match_reason": "组装与比较基因组学范围契合", "risk_note": "IF 门槛高"},
{"id": "molecular-plant", "tier": "challenge", "match_reason": "分子机制研究契合", "risk_note": "竞争激烈"},
{"id": "frontiers-in-plant-science", "tier": "fit", "match_reason": "植物生物学范围契合", "risk_note": "无"},
{"id": "rice", "tier": "fit", "match_reason": "水稻基因组学契合", "risk_note": "无"},
{"id": "bmc-plant-biology", "tier": "safe", "match_reason": "植物生物学全领域契合", "risk_note": "无"},
]
def make_fake_match(scores: dict[str, float] | None = None, fail: bool = False):
async def fake(abstract: str, journals: list[dict]):
if fail:
raise journal_kb.MatchError("vstore 不可达(mock)")
out = [
{"journal": j, "similarity": (scores or {}).get(str(j["id"]), 0.5)}
for j in journals
]
return out, True
return fake
def make_fake_llm(rankings: list[dict] | None = None, fail: bool = False, payload=None):
async def fake(system: str, user: str, **kwargs):
if fail:
raise llm.LLMError("余额不足(mock)")
if payload is not None:
return payload
return {"rankings": rankings if rankings is not None else []}
return fake
@pytest.fixture()
def client(tmp_path, monkeypatch):
db_mod.set_path(tmp_path / "review_test.db") # 进 lifespan 前注入 tmp 路径
monkeypatch.setattr(journal_kb, "match", make_fake_match())
monkeypatch.setattr(llm, "complete_json", make_fake_llm(ALL_RANKINGS))
with TestClient(app) as c: # CM 触发 lifespan:init_db(注入路径) + 收尾 close
yield c
def _all_items(body: dict) -> list[dict]:
items: list[dict] = []
for tier in ("challenge", "fit", "safe"):
items.extend(body["tiers"][tier]["items"])
return items
def test_recommend_happy_path(client: TestClient):
r = client.post("/api/review/recommend", json={"abstract": ABSTRACT})
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert set(body["tiers"]) == {"challenge", "fit", "safe"}
assert "挑战型" in body["tiers"]["challenge"]["label"]
assert "适配型" in body["tiers"]["fit"]["label"]
assert "稳妥型" in body["tiers"]["safe"]["label"]
items = _all_items(body)
assert len(items) == 5
assert {it["name"] for it in items} == SEED_NAMES
for it in items:
assert it["match_reason"], f"缺少匹配理由:{it['name']}"
assert it["risk_note"], f"缺少风险提示:{it['name']}"
assert it["similarity"] == 0.5
# 三档内容与 LLM 输出一致
assert [it["name"] for it in body["tiers"]["challenge"]["items"]][0] == "Genome Biology"
assert body["tiers"]["safe"]["items"][0]["name"] == "BMC Plant Biology"
meta = body["meta"]
assert meta["llm_ok"] is True and meta["vector_ok"] is True
assert meta["dropped_hallucinated"] == 0
assert meta["seed_count"] == 5
def test_seed_has_no_fabricated_acceptance_rate(client: TestClient):
"""种子诚实性:无公开数据的 acceptance_rate 必须为 null。"""
body = client.post("/api/review/recommend", json={"abstract": ABSTRACT}).json()
for it in _all_items(body):
assert it["acceptance_rate"] is None, f"编造了录用率:{it['name']}"
def test_hallucinated_journal_dropped(client: TestClient, monkeypatch):
"""LLM 幻觉期刊(白名单外 id)绝不进输出;缺漏期刊规则补位。"""
hallucinated = {
"id": "nature-genetics",
"tier": "challenge",
"match_reason": "幻觉期刊,输入列表中并不存在",
"risk_note": "x",
}
monkeypatch.setattr(
llm,
"complete_json",
make_fake_llm([hallucinated, ALL_RANKINGS[1]]), # 1 幻觉 + 1 真实,其余缺漏
)
body = client.post("/api/review/recommend", json={"abstract": ABSTRACT}).json()
assert body["ok"] is True
items = _all_items(body)
raw = str(body)
assert "nature-genetics" not in raw and "Nature Genetics" not in raw
assert "幻觉期刊" not in raw # 幻觉条目的理由同样被丢弃
assert {it["name"] for it in items} <= SEED_NAMES
assert len(items) == 5 # 4 本缺漏由规则补位,不丢候选
assert body["meta"]["dropped_hallucinated"] == 1
assert body["meta"]["repaired"] == 4
def test_llm_name_tamper_ignored(client: TestClient, monkeypatch):
"""LLM 篡改期刊名不采信:输出名称一律取 DB。"""
tampered = [
{"id": "genome-biology", "tier": "fit", "name": "Fake Journal X",
"match_reason": "被篡改的名称", "risk_note": "无"}
]
monkeypatch.setattr(llm, "complete_json", make_fake_llm(tampered))
body = client.post("/api/review/recommend", json={"abstract": ABSTRACT}).json()
assert body["ok"] is True
raw = str(body)
assert "Fake Journal X" not in raw
gb = [it for it in _all_items(body) if it["id"] == "genome-biology"]
assert gb and gb[0]["name"] == "Genome Biology"
def test_llm_failure_fallback(client: TestClient, monkeypatch):
"""LLM 失败 → 规则分档,推荐仍可用(永不 5xx)。"""
monkeypatch.setattr(llm, "complete_json", make_fake_llm(fail=True))
body = client.post("/api/review/recommend", json={"abstract": ABSTRACT}).json()
assert body["ok"] is True
assert body["meta"]["llm_ok"] is False
items = _all_items(body)
assert len(items) == 5
assert {it["name"] for it in items} == SEED_NAMES
# 规则分档:IF≥9 → challenge(仅 Genome Biology 10.1 有公开 IF)
challenge = {it["name"] for it in body["tiers"]["challenge"]["items"]}
assert challenge == {"Genome Biology"}
for it in items:
assert it["match_reason"] and it["risk_note"]
def test_vector_failure_degrades(client: TestClient, monkeypatch):
"""向量匹配失败 → 全量候选 + similarity 为 null,不报错。"""
monkeypatch.setattr(journal_kb, "match", make_fake_match(fail=True))
body = client.post("/api/review/recommend", json={"abstract": ABSTRACT}).json()
assert body["ok"] is True
assert body["meta"]["vector_ok"] is False
assert body["meta"]["candidate_count"] == 5
items = _all_items(body)
assert len(items) == 5
assert all(it["similarity"] is None for it in items)
def test_invalid_tier_repaired(client: TestClient, monkeypatch):
"""非法 tier 按规则补位,不丢期刊。"""
bad = [
{"id": "rice", "tier": "超越顶刊", "match_reason": "tier 非法", "risk_note": "无"},
{"id": "genome-biology", "tier": "challenge", "match_reason": "合法", "risk_note": "无"},
]
monkeypatch.setattr(llm, "complete_json", make_fake_llm(bad))
body = client.post("/api/review/recommend", json={"abstract": ABSTRACT}).json()
assert body["ok"] is True
assert len(_all_items(body)) == 5
# rice 无公开 IF/周期 → 规则分档 fit
fit_ids = [it["id"] for it in body["tiers"]["fit"]["items"]]
assert "rice" in fit_ids
assert body["meta"]["repaired"] >= 1
def test_short_abstract_validation(client: TestClient):
body = client.post("/api/review/recommend", json={"abstract": "too short"}).json()
assert body["ok"] is False
assert body["error"]["code"] == "VALIDATION"
assert body["tiers"] is None
def test_tier_focus_filters_other_tiers(client: TestClient):
body = client.post(
"/api/review/recommend",
json={"abstract": ABSTRACT, "preferences": {"tier_focus": "safe"}},
).json()
assert body["ok"] is True
assert body["tiers"]["challenge"]["items"] == []
assert body["tiers"]["fit"]["items"] == []
assert body["tiers"]["safe"]["items"] # 聚焦档保留
assert body["meta"]["preferences"]["tier_focus"] == "safe"
def test_soft_preference_risk_note(client: TestClient):
"""软偏好:超周期偏好只加风险提示,不删条目。"""
body = client.post(
"/api/review/recommend",
json={"abstract": ABSTRACT, "preferences": {"max_review_weeks": 3}},
).json()
assert body["ok"] is True
items = _all_items(body)
assert len(items) == 5 # 不删
# Genome Biology 审稿约 4 周 > 3 周 → 带 ⚠ 提示
gb = next(it for it in items if it["id"] == "genome-biology")
assert "超出你 3 周" in gb["risk_note"]
def test_seed_idempotent(tmp_path):
"""种子装载幂等:连跑两次仍恰好 5 行,且不编造 acceptance_rate。"""
async def work():
await db_mod.init_db(tmp_path / "seed.db")
try:
await db_mod.ensure_journals_seeded()
await db_mod.ensure_journals_seeded()
return await db_mod.list_journals()
finally:
await db_mod.close_db()
rows = asyncio.run(work())
assert len(rows) == 5
assert {r["id"] for r in rows} == {
"genome-biology", "bmc-plant-biology", "molecular-plant",
"frontiers-in-plant-science", "rice",
}
assert all(r["acceptance_rate"] is None for r in rows)
assert all(r["scope_desc"] for r in rows)
assert all(r["official_url"] for r in rows)
def test_rule_fallback_covers_all_tiers():
"""纯规则分档覆盖三档(不依赖 LLM 与网络)。"""
candidates = [
{"journal": {"id": "plant-cell", "impact_factor": 11.6, "review_time_weeks": 6.0}, "similarity": None},
{"journal": {"id": "plant-journal", "impact_factor": 7.2, "review_time_weeks": 5.0}, "similarity": None},
{"journal": {"id": "plant-genome", "impact_factor": 3.3, "review_time_weeks": 4.0}, "similarity": None},
]
rankings = journal_rank.fallback_rankings(candidates)
assert {r.journal_id for r in rankings} == {"plant-cell", "plant-journal", "plant-genome"}
by_id = {r.journal_id: r.tier for r in rankings}
assert by_id["plant-cell"] == "challenge"
assert by_id["plant-journal"] == "fit"
assert by_id["plant-genome"] == "safe"
+407
View File
@@ -0,0 +1,407 @@
"""tests/test_reformat.py —— P1 论文重排版单元测试(离线,零 LLM/零网络/零磁盘污染)。
覆盖:规则解析链(default/种子占位/目录回链/抓取缓存/未匹配)+ 4 变换器
(改/不改/保守 warning)+ 结构守卫 + 幂等 + API 信封(校验/落盘/GET/404)。
运行: cd review_service && <venv>/bin/python -m pytest -q
"""
from __future__ import annotations
import asyncio
import re
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.services import reformat as rf
from app.services.reformat import RULES_DEFAULT, ReformatError, reformat_tex, resolve_rules
# 金样例:4 处违规(字号/边距/行距/样式),全部命令齐备 → 0 warning
GOLDEN = r"""\documentclass[10pt]{article}
\usepackage[margin=1in,top=10mm]{geometry}
\linespread{1.0}
\bibliographystyle{plain}
\begin{document}
Hello.
\end{document}
"""
MINIMAL = "\\documentclass[11pt]{article}\n\\begin{document}\nx\n\\end{document}\n"
def _run(source: str, **rule_overrides):
rules = {**RULES_DEFAULT, **rule_overrides}
return reformat_tex(source, rules)
def _modified(changes):
return [c for c in changes if c["action"] == "modified"]
def _warnings(changes):
return [c for c in changes if c["action"] == "warning"]
def _by_key(changes, rule_key, action=None):
out = [c for c in changes if c["rule_key"] == rule_key
and (action is None or c["action"] == action)]
return out
# ================================================================ 规则解析链
def test_resolve_default():
r = asyncio.run(resolve_rules("default"))
assert r["rules_source"] == "default"
assert r["rules"] == RULES_DEFAULT
assert r["rules"]["min_font_pt"] == 11
assert r["rules"]["min_margin_mm"] == 20
assert r["rules"]["min_line_spacing"] == 1.5
assert r["rules"]["refs_style"] == "apa"
assert r["journal_id"] == "default"
def test_resolve_seed_name_official():
r = asyncio.run(resolve_rules("Genome Biology"))
assert r["journal_id"] == "genome-biology"
assert r["rules_source"] == "official"
assert r["rules"]["min_line_spacing"] == 2.0
assert r["rules"]["refs_style"] == "vancouver"
def test_resolve_seed_id():
r = asyncio.run(resolve_rules("genome-biology"))
assert r["journal_id"] == "genome-biology"
assert r["rules_source"] == "official"
def test_resolve_unknown_name():
r = asyncio.run(resolve_rules("Nonexistent Journal of Nowhere"))
assert r["rules_source"] == "default"
assert r["journal_id"] is None
assert "未匹配" in r["rules_note"]
def test_resolve_directory_id_backlink(monkeypatch):
async def fake_dir_name(journal_id: str):
return "Genome Biology" if journal_id.startswith("zh:") else None
monkeypatch.setattr(rf, "_directory_name", fake_dir_name)
r = asyncio.run(resolve_rules("zh:some-slug"))
assert r["journal_id"] == "genome-biology"
def test_resolve_fetched_cache(monkeypatch):
monkeypatch.setattr(rf, "_cache_rules", lambda jid: {
"rules_source": "fetched", "min_font_pt": 10, "refs_style": "gb7714"})
r = asyncio.run(resolve_rules("genome-biology"))
assert r["rules_source"] == "fetched" # 抓取缓存优先于 official
assert r["rules"]["min_font_pt"] == 10 # 专属覆盖通用
assert r["rules"]["min_margin_mm"] == 20 # 缺失键保留通用
assert r["rules"]["refs_style"] == "gb7714"
def test_resolve_official_format_rules(monkeypatch):
monkeypatch.setattr(rf, "_find_seed", lambda text: {
"id": "x-journal", "name": "X Journal",
"format_rules": {"rules_source": "official", "min_line_spacing": 1.3}})
r = asyncio.run(resolve_rules("X Journal"))
assert r["rules_source"] == "official"
assert r["rules"]["min_line_spacing"] == 1.3
# ================================================================ ① 字号 font_pt
def test_font_modify_below_min():
src = "\\documentclass[10pt]{article}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src)
font = _by_key(res["changes"], "font_pt")
assert len(font) == 1
c = font[0]
assert c["action"] == "modified"
assert "[11pt]" in res["modified_tex"] and "[10pt]" not in res["modified_tex"]
assert "10pt" in c["original"] and "11pt" in c["modified"]
def test_font_compliant_untouched():
res = _run(MINIMAL)
assert res["modified_tex"] == MINIMAL # warning 不改源码(拍板 2)
assert _by_key(res["changes"], "font_pt") == []
def test_font_missing_option_is_warning():
src = "\\documentclass[a4paper]{article}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src)
assert res["modified_tex"] == src # 拍板 2:绝不自动插入选项
(c,) = _by_key(res["changes"], "font_pt")
assert c["action"] == "warning"
assert "\\documentclass[a4paper, 11pt]{article}" in c["reason"]
def test_font_min_above_standard_uses_min():
src = "\\documentclass[12pt]{article}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src, min_font_pt=14)
assert "[14pt]" in res["modified_tex"]
# ================================================================ ② 页边距 margin
def test_margin_bumps_hmargin_vmargin():
src = "\\documentclass[11pt]{article}\n\\usepackage[hmargin=10mm,vmargin=15mm]{geometry}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src)
assert "hmargin=20mm" in res["modified_tex"]
assert "vmargin=20mm" in res["modified_tex"]
assert "]{geometry}" in res["modified_tex"] # 值替换不得吞掉包名尾巴
margin = _by_key(res["changes"], "margin")
assert len(margin) == 1 # 同行多键合并一条
assert "left" in margin[0]["reason"] and "top" in margin[0]["reason"]
def test_margin_per_side_only_bumps_violations():
res = _run(GOLDEN)
(c,) = [x for x in res["changes"] if x["rule_key"] == "margin"]
assert c["action"] == "modified"
assert "top=20mm" in c["modified"] # top 10mm 违规
assert "margin=1in" in c["modified"] # 1in=25.4mm 合规,不动
def test_margin_compliant_untouched():
src = "\\documentclass[11pt]{article}\n\\usepackage[margin=25mm]{geometry}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src)
assert res["modified_tex"] == src
def test_margin_no_geometry_is_warning():
src = "\\documentclass[11pt]{article}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src)
assert res["modified_tex"] == src
(c,) = [x for x in res["changes"] if x["rule_key"] == "margin"]
assert c["action"] == "warning"
assert "\\usepackage[margin=20mm]{geometry}" in c["reason"]
def test_margin_geometry_without_margin_attrs_is_warning():
src = "\\documentclass[11pt]{article}\n\\usepackage[scale=0.7]{geometry}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src)
assert res["modified_tex"] == src
(c,) = [x for x in res["changes"] if x["rule_key"] == "margin"]
assert c["action"] == "warning"
assert "margin=20mm" in c["reason"]
def test_margin_partial_sides_bump_and_warn():
src = "\\documentclass[11pt]{article}\n\\usepackage[left=10mm]{geometry}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src)
assert "left=20mm" in res["modified_tex"]
assert "]{geometry}" in res["modified_tex"]
mod = _by_key(res["changes"], "margin", "modified")
warn = _by_key(res["changes"], "margin", "warning")
assert len(mod) == 1 and len(warn) == 1
assert "right" in warn[0]["reason"]
# ================================================================ ③ 行距 line_spacing
def test_spacing_modify():
res = _run(GOLDEN)
(c,) = [x for x in res["changes"] if x["rule_key"] == "line_spacing"]
assert c["action"] == "modified"
assert "\\linespread{1.5}" in res["modified_tex"]
def test_spacing_ignores_body_override():
src = ("\\documentclass[11pt]{article}\n\\linespread{1.5}\n\\begin{document}\n"
"\\setstretch{1.0}\nx\n\\end{document}\n")
res = _run(src)
assert res["modified_tex"] == src # 正文局部覆盖不碰(导言区已合规)
def test_spacing_missing_is_warning():
src = "\\documentclass[11pt]{article}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src)
(c,) = [x for x in res["changes"] if x["rule_key"] == "line_spacing"]
assert c["action"] == "warning"
assert "\\linespread{1.5}" in c["reason"]
# ================================================================ ④ 参考文献 bib_style
def test_bib_plain_to_apalike():
res = _run(GOLDEN)
(c,) = [x for x in res["changes"] if x["rule_key"] == "bib_style"]
assert c["action"] == "modified"
assert "\\bibliographystyle{apalike}" in res["modified_tex"]
def test_bib_author_year_maps_apalike_not_plain():
"""回归(2026-10-10 修正):author_year 是姓名-年份制 → apalike,绝不映射编号制 plain。"""
src = "\\documentclass[11pt]{article}\n\\bibliographystyle{unsrt}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src, refs_style="author_year")
assert "\\bibliographystyle{apalike}" in res["modified_tex"]
assert "unsrt" not in res["modified_tex"].split("bibliographystyle")[1]
def test_bib_null_refs_style_warns_without_touching_source():
"""拍板(2026-10-10):refs_style=null(如 Frontiers 两种制式并存)→ 保持原样 + warning。"""
src = "\\documentclass[11pt]{article}\n\\bibliographystyle{unsrt}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src, refs_style=None)
assert res["modified_tex"] == src # 绝不猜测改写用户样式
(c,) = _by_key(res["changes"], "bib_style")
assert c["action"] == "warning"
assert "未确认" in c["reason"] and "bibliographystyle" in c["reason"]
def test_bib_gb7714_hint():
src = "\\documentclass[11pt]{article}\n\\bibliographystyle{plain}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src, refs_style="gb7714")
assert "\\bibliographystyle{gb7714}" in res["modified_tex"]
(c,) = _by_key(res["changes"], "bib_style")
assert "gbt7714" in c["reason"]
def test_bib_unmapped_style_is_warning():
src = "\\documentclass[11pt]{article}\n\\bibliographystyle{plain}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src, refs_style="mla")
assert res["modified_tex"] == src
(c,) = _by_key(res["changes"], "bib_style")
assert c["action"] == "warning" and "mla" in c["reason"]
def test_bib_missing_is_warning():
src = "\\documentclass[11pt]{article}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src)
(c,) = [x for x in res["changes"] if x["rule_key"] == "bib_style"]
assert c["action"] == "warning"
assert "\\bibliographystyle{apalike}" in c["reason"]
def test_bib_biblatex_workflow_is_warning():
src = ("\\documentclass[11pt]{article}\n\\usepackage{biblatex}\n"
"\\begin{document}\n\\printbibliography\n\\end{document}\n")
res = _run(src)
(c,) = [x for x in res["changes"] if x["rule_key"] == "bib_style"]
assert c["action"] == "warning"
assert "style=apa" in c["reason"]
# ================================================================ 结构守卫 + 幂等
def test_parser_failed_without_documentclass():
with pytest.raises(ReformatError) as ei:
_run("% just a fragment\n")
assert ei.value.code == "PARSER_FAILED"
def test_multi_documentclass_first_wins():
src = ("\\documentclass[10pt]{article}\n\\begin{document}\n"
"\\documentclass[9pt]{book}\n\\end{document}\n")
res = _run(src)
assert [w["code"] for w in res["warnings"]] == ["MULTI_DOCUMENTCLASS"]
assert "[11pt]" in res["modified_tex"]
assert res["modified_tex"].count("\\documentclass") == 2 # 第二处不碰
def test_idempotent_second_run_clean():
first = _run(GOLDEN)
assert len(_modified(first["changes"])) == 4
assert first["warnings"] == []
second = _run(first["modified_tex"])
assert second["changes"] == []
assert second["modified_tex"] == first["modified_tex"]
def test_changes_sorted_and_fields_complete():
res = _run(GOLDEN)
lines = [c["line"] for c in res["changes"]]
assert lines == sorted(lines)
for c in res["changes"]:
assert set(c) == {"line", "original", "modified", "reason", "rule_key", "action"}
# ================================================================ API(信封/落盘)
def _post(client: TestClient, content: bytes, journal: str = "default",
filename: str = "paper.tex", **data):
if journal is not None:
data.setdefault("target_journal", journal)
return client.post("/api/review/reformat",
files={"file": (filename, content, "text/plain")}, data=data)
@pytest.fixture()
def client(tmp_path, monkeypatch):
jobs = tmp_path / "jobs"
def fake_dir():
jobs.mkdir(parents=True, exist_ok=True)
return jobs
monkeypatch.setattr("app.routers.reformat._jobs_dir", fake_dir)
return TestClient(app)
def test_api_non_tex_rejected(client: TestClient):
body = _post(client, b"x", filename="a.pdf").json()
assert body["ok"] is False
assert body["error"]["code"] == "VALIDATION"
assert body["error"]["degraded"] is False
def test_api_missing_target_rejected(client: TestClient):
body = _post(client, GOLDEN.encode(), journal=None).json()
assert body["ok"] is False and body["error"]["code"] == "VALIDATION"
def test_api_empty_file_rejected(client: TestClient):
body = _post(client, b"").json()
assert body["ok"] is False and body["error"]["code"] == "VALIDATION"
def test_api_non_utf8_rejected(client: TestClient):
body = _post(client, b"\xff\xfe\x00bad").json()
assert body["ok"] is False and body["error"]["code"] == "VALIDATION"
def test_api_parser_failed_envelope(client: TestClient):
body = _post(client, b"% fragment, no documentclass").json()
assert body["ok"] is False and body["error"]["code"] == "PARSER_FAILED"
assert "documentclass" in body["error"]["message"]
def test_api_success_and_persist(client: TestClient):
body = _post(client, GOLDEN.encode()).json()
assert body["ok"] is True
assert re.fullmatch(r"[0-9a-f]{12}", body["job_id"])
mod = _modified(body["changes"])
assert len(mod) == 4 and body["warnings"] == []
assert "\\documentclass[11pt]" in body["modified_tex"]
meta = body["meta"]
assert meta["rules_source"] == "default"
assert meta["rules"]["min_font_pt"] == 11
assert meta["engine"] == "reformat-p1/1.0"
assert len(meta["original_sha256"]) == 64
# 持久化:重启语义的 GET 回读同一 job
got = client.get(f"/api/review/reformat/{body['job_id']}").json()
assert got["ok"] and got["job_id"] == body["job_id"]
assert len(_modified(got["changes"])) == 4
f = client.get(f"/api/review/reformat/{body['job_id']}/file")
assert f.status_code == 200
assert "x-tex" in f.headers["content-type"]
assert "\\documentclass[11pt]" in f.text
def test_api_job_not_found(client: TestClient):
for path in ("/api/review/reformat/000000000000",
"/api/review/reformat/zzzz"):
body = client.get(path).json()
assert body["ok"] is False and body["error"]["code"] == "NOT_FOUND"
f = client.get("/api/review/reformat/000000000000/file")
assert f.status_code == 200 and f.json()["error"]["code"] == "NOT_FOUND"
def test_api_idempotent_reupload(client: TestClient):
first = _post(client, GOLDEN.encode()).json()
assert first["ok"] and len(_modified(first["changes"])) == 4
second = _post(client, first["modified_tex"].encode()).json()
assert second["ok"] and second["changes"] == []
assert second["modified_tex"] == first["modified_tex"]
+96
View File
@@ -0,0 +1,96 @@
"""tests/test_seed.py —— journal_directory 启动自动 seed 单元测试。
不依赖上游;直接调 ensure_journal_directory_seeded(参数注入 tmp 路径,
真实 xlsx 只读)。经 TestClient lifespan 的用例验证 main.py 接线与开关。
运行: cd review_service && <venv>/bin/python -m pytest -q
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app import db as db_mod
from app.config import get_settings
from app.main import app
from app.services.journal_seed import ensure_journal_directory_seeded
SOURCE = Path(__file__).resolve().parents[1] / "data" / "journals" / "source_v4.xlsx"
def _count(db_path: Path) -> int:
"""journal_directory 行数;库/表不存在 → 0。"""
if not db_path.exists():
return 0
conn = sqlite3.connect(str(db_path))
try:
try:
return int(conn.execute("SELECT COUNT(*) FROM journal_directory").fetchone()[0])
except sqlite3.OperationalError:
return 0
finally:
conn.close()
def test_seed_empty_db(tmp_path: Path):
db = tmp_path / "review_test.db"
r = ensure_journal_directory_seeded(db_path=db, source_xlsx=SOURCE)
assert r["seeded"] is True
assert r["rows"] == 1056
assert r["error"] is None and r["reason"] is None
assert r["counts"]["en"] == 779 and r["counts"]["zh"] == 277
assert _count(db) == 1056
def test_seed_skips_nonempty(tmp_path: Path):
db = tmp_path / "review_test.db"
conn = sqlite3.connect(str(db))
try:
conn.executescript("CREATE TABLE journal_directory (id TEXT PRIMARY KEY)")
conn.execute("INSERT INTO journal_directory VALUES ('x')")
conn.commit()
finally:
conn.close()
r = ensure_journal_directory_seeded(db_path=db, source_xlsx=SOURCE)
assert r["seeded"] is False
assert "already has 1 rows" in r["reason"]
assert r["error"] is None
assert _count(db) == 1 # 计数不变,未被覆盖/补差
def test_seed_missing_xlsx_no_raise(tmp_path: Path):
r = ensure_journal_directory_seeded(
db_path=tmp_path / "x.db", source_xlsx=tmp_path / "nope.xlsx")
assert r["seeded"] is False
assert r["error"] # 有 error 文案
assert r["rows"] is None
assert not (tmp_path / "x.db").exists() # 不写库
def test_seed_idempotent(tmp_path: Path):
db = tmp_path / "review_test.db"
r1 = ensure_journal_directory_seeded(db_path=db, source_xlsx=SOURCE)
r2 = ensure_journal_directory_seeded(db_path=db, source_xlsx=SOURCE)
assert r1["seeded"] is True and r1["rows"] == 1056
assert r2["seeded"] is False
assert "already has 1056 rows" in r2["reason"]
assert _count(db) == 1056
def test_startup_toggle_off_keeps_table_absent(tmp_path: Path):
"""conftest 全局 REVIEW_SEED_ON_STARTUP=false:lifespan 不 seed。"""
db_mod.set_path(tmp_path / "review_test.db")
with TestClient(app):
pass
assert _count(tmp_path / "review_test.db") == 0
def test_startup_toggle_on_seeds_via_lifespan(tmp_path: Path, monkeypatch):
"""显式打开开关:lifespan 经 db.path 注入路径完成 seed。"""
monkeypatch.setattr(get_settings(), "seed_on_startup", True)
db_mod.set_path(tmp_path / "review_test.db")
with TestClient(app):
pass
assert _count(tmp_path / "review_test.db") == 1056
+13
View File
@@ -0,0 +1,13 @@
# tools/ —— 编译器工具链(P3)
## tectonic(LaTeX → PDF)
- 单文件 musl 静态二进制,无 root、无系统 TeX Live 依赖;版本与 sha256 钉死在
`install_tectonic.sh`(本目录二进制不入 git)。
- 安装:`cd review_service && bash tools/install_tectonic.sh`
部署树(zh)同一条命令;或直接 scp `tools/tectonic` + `data/tectonic-cache/`。
- 宏包缓存:`data/tectonic-cache/`(环境变量 `REVIEW_TECTONIC_CACHE_DIR` 覆盖)。
首次编译会联网拉 bundle(~200MB),建议部署时用 hello.tex 预热:
`TECTONIC_CACHE_DIR=$PWD/data/tectonic-cache tools/tectonic -X compile hello.tex --untrusted`
- 安全:编译一律 `--untrusted`(tectonic 官方旗标,禁用已知不安全特性)+
运行于隔离工作目录 + 60s 超时整组 kill + 禁 shell escape。
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# install_tectonic.sh —— 一键安装/校验 tectonic 编译器(P3a)。
# 幂等:已装且 sha256 匹配则直接退出 0。部署树(zh)同一条命令。
# 用法:cd review_service && bash tools/install_tectonic.sh
set -euo pipefail
TECTONIC_VERSION="0.17.0"
TECTONIC_TARBALL_SHA256="8533d07f9ccbd7a65824b9e0459041bca34af1eb33daba48f59215593753a3b7"
TECTONIC_BIN_SHA256="a98aa59ad5c1df39a6c9e56cbfc5088f2b11d6c179c0130b97998e4bd46a46da"
URL="https://github.com/tectonic-typesetting/tectonic/releases/download/tectonic%40${TECTONIC_VERSION}/tectonic-${TECTONIC_VERSION}-x86_64-unknown-linux-musl.tar.gz"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BIN="${HERE}/tectonic"
if [[ -x "$BIN" ]] && echo "$(sha256sum "$BIN" | cut -d' ' -f1)" = "$TECTONIC_BIN_SHA256"; then
echo "[install_tectonic] 已安装且校验通过:$BIN ($("$BIN" --version))"
exit 0
fi
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
echo "[install_tectonic] 下载 tectonic ${TECTONIC_VERSION}(musl 静态版)…"
curl -fL --retry 3 -o "$tmp/tectonic.tar.gz" "$URL"
echo "[install_tectonic] 校验 tarball sha256…"
echo "${TECTONIC_TARBALL_SHA256} $tmp/tectonic.tar.gz" | sha256sum -c -
tar -xzf "$tmp/tectonic.tar.gz" -C "$tmp" tectonic
echo "[install_tectonic] 校验二进制 sha256…"
echo "${TECTONIC_BIN_SHA256} $tmp/tectonic" | sha256sum -c -
install -m 0755 "$tmp/tectonic" "$BIN"
echo "[install_tectonic] 完成:$BIN ($("$BIN" --version))"
echo "[install_tectonic] 提示:首次真实编译会联网拉宏包 bundle(约 200MB),"
echo " 服务端 TECTONIC_CACHE_DIR 默认 data/tectonic-cache,建议部署时预热。"
+5
View File
@@ -81,6 +81,11 @@ class Settings(BaseSettings):
paper_writer_url: str = "http://127.0.0.1:8097"
paper_writer_read_timeout: float = 360.0 # write_paper_section 同步长调用读超时(秒)
# ---- 科研审稿服务(review_service 8100;REVIEW_ENABLED 为假时路由不注册)----
review_enabled: bool = False
review_service_url: str = "http://127.0.0.1:8100"
review_read_timeout: float = 360.0 # presubmit 上传+LLM 长调用读超时(秒)
# ---- 生产形态:BFF 静态托管 ui/dist(ARCHITECTURE.md §3,M6 挂载)----
frontend_dist_dir: str = "ui/dist" # 生产形态:静态托管 ui/dist
+3
View File
@@ -26,6 +26,7 @@ from .routers import capabilities, docgen, extraction, figures, health, kg, lega
from .routers import bioflow_proxy
from .routers import paper
from .routers import private_docs
from .routers import review_proxy
from .upstream import close_client
logging.basicConfig(level=logging.INFO)
@@ -62,6 +63,8 @@ def create_app() -> FastAPI:
app.include_router(bioflow_proxy.router) # 生信反代(BIOFLOW_ENABLED 开关,FR-BIO-028)
if get_settings().paper_writer_enabled:
app.include_router(paper.router) # 论文撰写(PAPER_WRITER_ENABLED 开关)
if get_settings().review_enabled:
app.include_router(review_proxy.router) # 科研审稿反代(REVIEW_ENABLED 开关)
@app.exception_handler(RequestValidationError)
async def _validation_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
+85
View File
@@ -0,0 +1,85 @@
"""routers/review_proxy.py —— BFF 科研审稿反代(REVIEW_ENABLED 条件注册)。
- GET /api/review/health → 探活 review 服务 /health(离线返回 offline 标记,
前端据此隐藏审稿入口;模式同 routers/paper.py)。
- /api/review/{path:path} → 透明反代 review_service(8100);presubmit 为
上传+LLM 长调用,读超时取 settings.review_read_timeout。
- REVIEW_ENABLED 为假时路由不注册(main.py 按开关 include,三形态部署)。
约束:本文件是 BFF 层对审稿模块的唯一新增面(新文件,不改既有路由逻辑)。
review_service 失败统一 503 UPSTREAM_DOWN 信封(经 app.upstream.envelope)。
"""
from __future__ import annotations
import logging
from typing import Any
import httpx
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, Response
from ..config import get_settings
from ..upstream import envelope
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/review", tags=["review-proxy"])
PROXY_CONNECT_TIMEOUT = 5.0
def _service_base() -> str:
return get_settings().review_service_url.rstrip("/")
@router.get("/health")
async def review_health() -> dict[str, Any]:
"""探活反代 8100 /health;离线返回 offline 标记。"""
s = get_settings()
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{s.review_service_url.rstrip('/')}/health")
resp.raise_for_status()
return resp.json()
except Exception: # noqa: BLE001
return {"ok": False, "status": "offline"}
async def _proxy(request: Request, path: str) -> Any:
"""httpx 透传反代(非 SSE;presubmit 长读超时);失败→503 信封。"""
s = get_settings()
base = _service_base()
url = f"{base}/api/review/{path.lstrip('/')}"
if request.url.query:
url = f"{url}?{request.url.query}"
body = await request.body()
headers = {k: v for k, v in request.headers.items()
if k.lower() not in ("host", "content-length", "connection")}
timeout = httpx.Timeout(
connect=PROXY_CONNECT_TIMEOUT,
read=s.review_read_timeout,
write=s.review_read_timeout,
pool=5.0,
)
async with httpx.AsyncClient(timeout=timeout) as client:
try:
upstream = client.build_request( # build_request 是同步方法,返回 httpx.Request,不可 await
request.method, url, content=body, headers=headers
)
resp = await client.send(upstream)
except httpx.HTTPError as exc:
logger.warning("review_proxy: 审稿服务不可达 %s: %s", url, exc)
payload, status = envelope(
"UPSTREAM_DOWN", message="审稿服务当前不可用,请稍后重试",
degraded=True, status=503,
)
return JSONResponse(status_code=status, content=payload)
content = await resp.aread()
media_type = resp.headers.get("content-type", "") or "application/json"
return Response(status_code=resp.status_code, content=content, media_type=media_type)
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
async def proxy_all(path: str, request: Request) -> Any:
return await _proxy(request, path)
+2 -1
View File
@@ -31,12 +31,13 @@ rsync -a --exclude '__pycache__' --exclude 'test_*.py' --exclude 'data' --exclud
"$SOURCE_ROOT/" "$OUTPUT_DIR/"
# 白名单明文:main.py + config.py + __init__.py(编译前从发现集排除)
WHITELIST_PY=("app/main.py" "app/config.py" "app/schemas.py" "app/db.py" "app/routers/qa.py" "app/services/sse_relay.py" "app/routers/bioflow_proxy.py" "app/routers/paper.py" "app/services/paper/agent.py" "app/services/paper/mcp_client.py")
WHITELIST_PY=("app/main.py" "app/config.py" "app/schemas.py" "app/db.py" "app/routers/qa.py" "app/services/sse_relay.py" "app/routers/bioflow_proxy.py" "app/routers/paper.py" "app/routers/review_proxy.py" "app/services/paper/agent.py" "app/services/paper/mcp_client.py")
# 白名单理由:main.py=uvicorn 入口;config.py=运维可读面;
# schemas.py=pydantic v2 模型(validator 方法被 Cython 转 cyfunction 触发
# PydanticUserError,实测编译后无法导入——纯数据契约面,无敏感逻辑,明文保留);
# db.py=2026-09-03 断流崩溃根治:客户端中途断开流时 Cython 协程
# bioflow_proxy.py=2026-09-06 生信流式反代(SSE 逐块 yield,同型断流高危点,TL 补)
# review_proxy.py=2026-10-11 科研审稿反代(上传+LLM 长调用反代,同类断流高危点)
# db.py=2026-09-03 断流崩溃根治:客户端中途断开流时 Cython 协程
# __Pyx_Coroutine_SendEx 断言 abort 整个 BFF(Cython 3.2.8/3.3.0 均复现,
# A/B 证据见 docs/team/deploy_plan.md 附档)。db 是崩溃协程热点,
+1 -3
View File
@@ -1,3 +1 @@
# frontend/.gitignore(最小版,B1 可扩展)
node_modules/
dist/
.env.local
+1
View File
@@ -0,0 +1 @@
export default {}
+7 -2
View File
@@ -1,11 +1,14 @@
<!-- App.vue —— B1 归属。挂载三栏 AppShell。 -->
<!-- App.vue —— B1 归属。挂载三栏 AppShell + 教育科研覆盖层(步骤5;v-show 切换不卸载)。 -->
<script setup>
import { onMounted, onUnmounted } from 'vue';
import AppShell from './components/layout/AppShell.vue';
import EduWorkspace from './components/edu/EduWorkspace.vue';
import { useHealthStore } from './stores/health.js';
import { useAppStore } from './stores/app.js';
// FR-SHELL-007 首屏初始化:并行请求健康与 QA 元信息(失败不阻塞渲染)
const health = useHealthStore();
const appStore = useAppStore();
onMounted(() => {
health.start();
health.fetchQaInfo();
@@ -16,5 +19,7 @@ onUnmounted(() => {
</script>
<template>
<AppShell />
<!-- 覆盖层语义(D1):两者始终挂载,仅 display 切换——RAG 会话/上传/滚动零丢失 -->
<AppShell v-show="appStore.isChat" />
<EduWorkspace v-show="appStore.isEdu" />
</template>
+135
View File
@@ -0,0 +1,135 @@
// api/review.js —— 科研审稿端点封装(BFF 8090 → review_service 8100)
// 相对路径 /api/review/* → vite proxy(vite.config.js bffTarget)→ BFF 反代(须 REVIEW_ENABLED=1)。
//
// 错误契约适配:review_service 业务错误是 **HTTP 200 + {ok:false,error:{...}}**
// (VALIDATION/PARSE_FAILED/UPSTREAM_DOWN/SEED_UNAVAILABLE 均实测 200),
// 而 http.js 只对非 2xx 抛 ApiError —— 200 信封会正常 resolve。
// unwrap() 把两条错误路径(200 信封 / 非 2xx http.js 抛出)归一成 ApiError,
// 调用方只需 catch + reviewErrorMessage(err) 一个口径。
import { get, post, ApiError } from './http.js';
const BASE = '/api/review';
function unwrap(promise) {
return promise.then((payload) => {
if (payload && payload.ok === false && payload.error) {
const err = new ApiError(payload.error.message || '服务错误', {
code: payload.error.code || 'INTERNAL',
status: 200,
degraded: !!payload.error.degraded,
});
if (payload.error.detail) err.detail = payload.error.detail; // 编译失败的 log 尾等
throw err;
}
return payload;
});
}
/** ① GET /api/review/journals?field=&order=
* → {ok, items:[{id,name,scope_desc,impact_factor,review_time_weeks,acceptance_rate,
* submission_deadline,official_url,last_updated}],
* meta:{count,last_updated,data_source_note}} */
export function getJournals(params = {}) {
return unwrap(get(`${BASE}/journals`, params));
}
/** ①' GET /api/review/journals/directory?lang=&domain=&cas_partition=&q=&page=&size=&field=&order=
* → {ok, items[{id,lang,name,research_direction,cas_partition,cas_partition_note,
* cas_partition_year,jif,jif_year,jif_note,jif_check_status,composite_impact_factor,
* comprehensive_impact_factor,metric_year,metric_report_year,metric_caliber,
* annual_report_page,top_flag,publisher_series,journal_mark,article_type,issn,eissn,
* jcr_disciplines[],supplement_note,evidence_note,official_url,partition_source,
* jif_source,jif_source_url,partition_source_url,data_source_label,data_source_url}],
* meta:{total,page,size,lang,domain,cas_partition,q,domains[],
* data_source_note,data_imported}}
* 1056 本目录(779 国际 + 277 中文);只读分页,lang 强制单语(en/zh 永不混排);
* cas_partition 仅 lang=en 过滤;field 排序白名单 name/jif/composite_impact_factor/
* comprehensive_impact_factor/research_direction/cas_partition;缺值 source 已归零。 */
export function getDirectory(params = {}) {
return unwrap(get(`${BASE}/journals/directory`, params));
}
/** ⑦' GET /api/review/conferences?domain=&ccf=&upcoming_days=
* → {ok, items:[{id,name,full_name,domain_tags,application_tags,ccf_rank,
* deadline_abstract,deadline_full_paper,notification_date,conference_date,
* location,official_url,timezone_note,deadline_source,last_verified}],
* meta:{count,last_verified,data_source_note,query}}
* 只读静态种子(全 placeholder);排序/过滤由后端做,upcoming_days=0 表示不过滤。 */
export function getConferences(params = {}) {
return unwrap(get(`${BASE}/conferences`, params));
}
/** ③ POST /api/review/presubmit(multipart PDF)→ {ok, report, meta}
* FormData 自动 multipart(http.js 不设 Content-Type,浏览器带 boundary);
* opts 可传 {onUploadProgress, signal, timeout}(FormData+进度走 XHR 分支)。
* 读超时沿用默认 600s(> BFF 360s),不先于上游自断。 */
export function presubmit(formData, opts = {}) {
return unwrap(post(`${BASE}/presubmit`, formData, opts));
}
/** ② POST /api/review/recommend → {ok, tiers:[{tier,journals:[...]}], meta} */
export function recommend(payload, opts = {}) {
return unwrap(post(`${BASE}/recommend`, payload, opts));
}
/** ⑤ POST /api/review/format-check(multipart PDF/Word)
* → {ok, checks:[{key,name,status,detail,suggestion}], summary:{pass,warn,fail},
* meta:{filename,file_type,pages,ruleset_source,rules_source,journal_*,
* refs_style_expected,refs_style_detected}}
* 纯规则引擎,无 LLM,同步秒回(无需 onUploadProgress)。 */
export function formatCheck(formData, opts = {}) {
return unwrap(post(`${BASE}/format-check`, formData, opts));
}
/** ⑥ POST /api/review/reformat(multipart .tex + target_journal)
* → {ok, job_id, modified_tex,
* changes:[{line,original,modified,reason,rule_key,action}],
* warnings:[{code,message}], // 仅结构级(如多处 \documentclass)
* meta:{journal_id,journal_name,rules,rules_source,rules_note,filename,
* original_sha256,engine,generated_at}}
* rule_key ∈ {font_pt,margin,line_spacing,bib_style};
* action ∈ {modified,warning}(warning=缺插入点的保守建议,不计入已修改)。
* 纯规则引擎,同步秒回;job 落盘 → GET `${BASE}/reformat/{id}` 可回读。 */
export function reformatTex(formData, opts = {}) {
return unwrap(post(`${BASE}/reformat`, formData, opts));
}
/** P3a POST /api/review/compile(multipart:job_id 或 file 二选一,前端走 job_id)
* → {ok, compile_id, pdf_url, log_url, meta:{seconds,pdf_bytes,engine,compiled_at}}
* 错误信封:VALIDATION / TOOL_UNAVAILABLE / COMPILE_FAILED / TIMEOUT / OUTPUT_TOO_LARGE
* 为 HTTP 200 信封;QUEUE_FULL 为 HTTP 429 + 同款信封(http.js 抛非 2xx ApiError,
* 两者都归一成 ApiError)。COMPILE_FAILED/TIMEOUT 的 err.detail = log 尾 200 行。
* 编译为单槽队列,耗时约 10-30 秒(默认 60s 上限)。 */
export function compileTex(formData, opts = {}) {
return unwrap(post(`${BASE}/compile`, formData, opts));
}
/** ⑤ format-check 专用错误文案:解档/校验类 PARSE_FAILED/VALIDATION 用后端原话
* (与 presubmit 的"扫描件"提示是不同语境,不能复用 CODE_HINTS),其余走统一口径。 */
export function formatErrorMessage(err) {
if (err instanceof ApiError && (err.code === 'PARSE_FAILED' || err.code === 'VALIDATION') && err.message) {
return err.message;
}
return reviewErrorMessage(err);
}
/** 后端 error.code → 用户可读提示。
* null = 不覆盖、直接用后端 message(VALIDATION 入参问题后端原话更具体)。 */
const CODE_HINTS = {
VALIDATION: null,
PARSE_FAILED: '论文解析失败:可能是扫描件/图片型 PDF,请换文本型 PDF。',
UPSTREAM_DOWN: '审稿模型暂时不可用,请稍后重试。',
SEED_UNAVAILABLE: '期刊库未就绪(种子数据缺失),请联系管理员。',
TIMEOUT: '请求超时,请稍后重试。',
NETWORK: '网络异常,请检查连接后重试。',
};
/** ApiError(或任意错误)→ 用户可读文案。degraded 由调用方读 err.degraded 走降级横幅样式。 */
export function reviewErrorMessage(err) {
if (err instanceof ApiError) {
const hint = CODE_HINTS[err.code];
if (hint) return hint;
if (err.message) return err.message; // VALIDATION 等:后端原话
}
return '服务异常,请稍后重试';
}
+114
View File
@@ -0,0 +1,114 @@
<!-- EduSideNav.vue —— 教育科研左侧菜单(步骤4)。
7 项与 edu store SECTIONS 一致;顶部「← 返回问答」回 chat 视图
(AppShell 含 TopBar 在 edu 视图整体 display:none,此为唯一显式返回入口)。
高亮仅用现成 --pa-* 变量;无 icon 组件 → emoji。 -->
<script setup>
import { useAppStore } from '../../stores/app.js';
import { useEduStore } from '../../stores/edu.js';
const app = useAppStore();
const edu = useEduStore();
const ITEMS = [
{ key: 'journals', label: '学术期刊情报', icon: '📰' },
{ key: 'recommend', label: '智能投稿推荐', icon: '🎯' },
{ key: 'presubmit', label: 'AI 预审稿系统', icon: '🔍' },
{ key: 'report', label: '专业审稿意见报告', icon: '📝' },
{ key: 'format', label: '论文排版与规范', icon: '📐' },
{ key: 'reformat', label: '论文自动重排版', icon: '🧩' },
{ key: 'tracker', label: '投稿进度与返修', icon: '📮' },
{ key: 'calendar', label: '会议与投稿日历', icon: '📅' },
];
</script>
<template>
<nav class="edu-sidenav">
<button class="back-btn" type="button" title="返回问答" @click="app.gotoChat()">
← 返回问答
</button>
<ul class="edu-menu">
<li v-for="it in ITEMS" :key="it.key">
<button
class="edu-item"
:class="{ active: edu.section === it.key }"
type="button"
:title="it.label"
@click="edu.setSection(it.key)"
>
<span class="edu-icon" aria-hidden="true">{{ it.icon }}</span>
<span class="edu-label">{{ it.label }}</span>
</button>
</li>
</ul>
</nav>
</template>
<style scoped>
.edu-sidenav {
display: flex;
flex-direction: column;
gap: 10px;
height: 100%;
padding: 14px 10px;
background: var(--pa-surface-1);
border-right: 1px solid var(--pa-hairline);
}
.back-btn {
display: flex;
align-items: center;
padding: 6px 10px;
border: none;
border-radius: var(--pa-r-md);
background: transparent;
color: var(--pa-ink-muted);
font-size: 12px;
cursor: pointer;
transition: color .15s var(--pa-ease), background .15s var(--pa-ease);
}
.back-btn:hover {
color: var(--pa-ink);
background: var(--pa-surface-2);
}
.edu-menu {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.edu-item {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: none;
border-radius: var(--pa-r-md);
background: transparent;
color: var(--pa-ink-muted);
font-size: 12.5px;
text-align: left;
cursor: pointer;
transition: color .15s var(--pa-ease), background .15s var(--pa-ease);
}
.edu-item:hover {
color: var(--pa-ink);
background: var(--pa-surface-2);
}
.edu-item.active {
color: var(--pa-ink);
font-weight: 600;
background: var(--pa-surface-2);
box-shadow: inset 2px 0 0 var(--pa-g-from);
}
.edu-icon {
flex: none;
font-size: 14px;
line-height: 1;
}
.edu-label {
min-width: 0;
line-height: 1.35;
}
</style>
+62
View File
@@ -0,0 +1,62 @@
<!-- EduWorkspace.vue —— 教育科研全屏工作台(步骤4;覆盖层语义 D1)。
App.vue 中用 v-show 挂载(始终存活,仅 display 切换):
左 200px EduSideNav + 右侧 <component :is> 按 edu.section 动态切面板。
面板进行中请求/草稿存于 edu store(面板卸载不中断、切菜单不丢)。 -->
<script setup>
import { computed } from 'vue';
import { useEduStore } from '../../stores/edu.js';
import EduSideNav from './EduSideNav.vue';
import JournalsPanel from './panels/JournalsPanel.vue';
import RecommendPanel from './panels/RecommendPanel.vue';
import PresubmitPanel from './panels/PresubmitPanel.vue';
import ReportPanel from './panels/ReportPanel.vue';
import FormatPanel from './panels/FormatPanel.vue';
import ReformatPanel from './panels/ReformatPanel.vue';
import TrackerPanel from './panels/TrackerPanel.vue';
import CalendarPanel from './panels/CalendarPanel.vue';
const edu = useEduStore();
/** section → 面板组件映射(与 edu store SECTIONS 一致;
* training → calendar 换版,TrainingPanel.vue 保留在 panels/ 下未删除,仅解除映射) */
const panelMap = {
journals: JournalsPanel,
recommend: RecommendPanel,
presubmit: PresubmitPanel,
report: ReportPanel,
format: FormatPanel,
reformat: ReformatPanel,
tracker: TrackerPanel,
calendar: CalendarPanel,
};
const currentPanel = computed(() => panelMap[edu.section] || JournalsPanel);
</script>
<template>
<div class="edu-workspace">
<EduSideNav class="edu-nav" />
<main class="edu-panel">
<component :is="currentPanel" />
</main>
</div>
</template>
<style scoped>
.edu-workspace {
display: flex;
height: 100vh;
background: var(--pa-canvas);
color: var(--pa-ink);
}
.edu-nav {
width: 200px;
flex: none;
}
.edu-panel {
flex: 1;
min-width: 0;
overflow: auto;
padding: 24px 28px;
}
</style>
@@ -0,0 +1,418 @@
<!-- CalendarPanel.vue —— ⑦' 会议与投稿日历(会议时间线;步骤 3.1)。
数据链:onMounted → edu.fetchConferences({domain, ccf, upcoming_days}) →
review.js getConferences() → GET /api/review/conferences
(过滤/排序在后端:domain 对 domain_tags 精确匹配、ccf ∈ A/B/C、
upcoming_days=180 窗口 / 0=不过滤;按 deadline_full_paper 升序)。
数据诚实性铁律:deadline_source=placeholder 的会议挂「⚠ 日期待核验」黄徽章,
并附 deadline_confidence 置信度(high=官方已公布/历年极稳定,medium=按历年
规律推算,low=待核验);领域标签按族着色:AI/ML 蓝 / 生物信息学 绿 /
植物·农学 黄 / 交叉·健康医疗 紫。面板不生成任何日期,倒计时仅由种子 ISO
时间与本机时钟相减得出。
倒计时色阶:≤0 灰「已结束」/ ≤7 红 / 8–30 橙 / >30 绿。
筛选为 panel-local refs(不进 store/drafts);每次进面板按当前筛选重查
(27 条静态种子,成本可忽略,保证筛选 UI 与列表一致)。
空态文案:「暂无可投会议,或数据待核验」。样式 cl- 前缀。 -->
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useEduStore } from '../../../stores/edu.js';
const edu = useEduStore();
/* —— 筛选(panel-local)—— */
const domain = ref('');
const ccf = ref('');
const upcomingOnly = ref(true); // 默认开 → upcoming_days=180;关 → 0(含已过期)
const CCF_OPTIONS = ['A', 'B', 'C'];
/* 领域下拉从已加载条目累积(切筛选不清空,避免选项随过滤缩水) */
const domainOptions = ref([]);
function noteDomains(items) {
for (const c of items || []) {
for (const t of (c.domain_tags || [])) {
if (!domainOptions.value.includes(t)) domainOptions.value.push(t);
}
}
}
function reload() {
edu
.fetchConferences({
domain: domain.value,
ccf: ccf.value,
upcoming_days: upcomingOnly.value ? 180 : 0,
})
.then(noteDomains);
}
onMounted(() => {
// loading = 切菜单期间请求在飞(store 持有,不重发);其余状态按当前筛选重查
if (edu.conferencesStatus !== 'loading') reload();
else noteDomains(edu.conferences);
});
/* —— 倒计时(本机时钟计算;后端只负责窗口过滤)—— */
function countdown(iso) {
if (!iso) return { cls: 'ended', text: '日期缺失' };
const t = Date.parse(iso);
if (Number.isNaN(t)) return { cls: 'ended', text: '日期缺失' };
const d = Math.ceil((t - Date.now()) / 86400000);
if (d <= 0) return { cls: 'ended', text: '已结束' };
if (d <= 7) return { cls: 'red', text: `还有 ${d} 天` };
if (d <= 30) return { cls: 'orange', text: `还有 ${d} 天` };
return { cls: 'green', text: `还有 ${d} 天` };
}
const day = (iso) => (iso ? String(iso).slice(0, 10) : '—');
const isPlaceholder = (c) => c.deadline_source === 'placeholder';
/* —— 置信度:deadline_confidence → 中文(徽章文案/悬浮提示)—— */
const CONF_LABEL = { high: '高', medium: '中', low: '低' };
const confLabel = (v) => CONF_LABEL[String(v || '').toLowerCase()] || '—';
const confTitle = (v) =>
`deadline_source=placeholder,日期未经官方核验;置信度:${v || '—'}` +
'(high=官方已公布/历年极稳定,medium=按历年规律推算,low=待核验)';
/* —— 领域标签族着色:ai=蓝 / bio=绿 / plant=黄 / cross=紫 —— */
const DOMAIN_FAMILY = {
'AI/ML': 'ai', '深度学习': 'ai', '计算机视觉': 'ai', '自然语言处理': 'ai',
'数据挖掘': 'ai', '信息检索': 'ai',
'生物信息学': 'bio', '医学影像': 'bio',
'植物/农学': 'plant', '基因组学': 'plant', '分子生物学': 'plant',
'健康医疗': 'cross',
};
const famClass = (t) => `cl-chip--${DOMAIN_FAMILY[t] || 'ai'}`;
const appClass = (t) => (t === '交叉学科' ? 'cl-chip--cross' : 'cl-chip--app');
const count = computed(() => edu.conferences.length);
</script>
<template>
<section class="cl">
<!-- 头部 -->
<header class="cl-head">
<h1 class="cl-title">会议与投稿日历</h1>
<span v-if="edu.conferencesStatus === 'ready'" class="cl-count">共 {{ count }} 个会议</span>
</header>
<!-- 时区说明(固定一行)+ 诚实性提示 -->
<p class="cl-tz">所有时间按 AoE (UTC-12) 时区 · 未经核验的日期以 ⚠ 标注并附置信度</p>
<!-- 筛选栏 -->
<div class="cl-filters">
<label class="cl-field">
<span class="cl-field-label">领域</span>
<select v-model="domain" class="cl-select" @change="reload">
<option value="">全部领域</option>
<option v-for="d in domainOptions" :key="d" :value="d">{{ d }}</option>
</select>
</label>
<label class="cl-field">
<span class="cl-field-label">CCF</span>
<select v-model="ccf" class="cl-select" @change="reload">
<option value="">全部等级</option>
<option v-for="r in CCF_OPTIONS" :key="r" :value="r">CCF-{{ r }}</option>
</select>
</label>
<label class="cl-check">
<input v-model="upcomingOnly" type="checkbox" @change="reload" />
仅显示最近可投
</label>
</div>
<!-- ERROR -->
<div v-if="edu.conferencesStatus === 'error'" class="cl-state" role="alert">
<p class="cl-state-msg">{{ edu.conferencesError }}</p>
<button class="cl-retry" type="button" @click="reload">重试</button>
</div>
<!-- LOADING -->
<div v-else-if="edu.conferencesStatus === 'loading'" class="cl-state" aria-busy="true">
<div v-for="n in 3" :key="n" class="cl-skel"></div>
<p class="cl-state-msg">会议数据加载中…</p>
</div>
<!-- READY + 空态 -->
<div v-else-if="!edu.conferences.length" class="cl-state">
<p class="cl-state-msg">暂无可投会议,或数据待核验</p>
</div>
<!-- READY + 列表(后端已按 deadline_full_paper 升序) -->
<ul v-else class="cl-list">
<li v-for="c in edu.conferences" :key="c.id" class="cl-card">
<div class="cl-row1">
<span class="cl-name">{{ c.name }}</span>
<span v-if="c.ccf_rank" class="cl-chip cl-chip--ccf">CCF-{{ c.ccf_rank }}</span>
<span class="cl-cd" :class="`cl-cd--${countdown(c.deadline_full_paper).cls}`">
{{ countdown(c.deadline_full_paper).text }}
</span>
<span v-if="isPlaceholder(c)" class="cl-chip cl-chip--warn" :title="confTitle(c.deadline_confidence)">
⚠ 日期待核验(置信度:{{ confLabel(c.deadline_confidence) }})
</span>
</div>
<p class="cl-full">{{ c.full_name }}</p>
<div class="cl-tags">
<span v-for="t in c.domain_tags" :key="`d-${t}`" class="cl-chip cl-chip--tag" :class="famClass(t)">{{ t }}</span>
<span v-for="t in c.application_tags" :key="`a-${t}`" class="cl-chip cl-chip--tag" :class="appClass(t)">{{ t }}</span>
</div>
<dl class="cl-meta">
<div class="cl-meta-item">
<dt>截稿(摘要)</dt>
<dd>{{ day(c.deadline_abstract) }}</dd>
</div>
<div class="cl-meta-item">
<dt>截稿(全文)</dt>
<dd class="cl-deadline">{{ day(c.deadline_full_paper) }}</dd>
</div>
<div class="cl-meta-item">
<dt>通知日</dt>
<dd>{{ day(c.notification_date) }}</dd>
</div>
<div class="cl-meta-item">
<dt>会期</dt>
<dd>{{ c.conference_date || '—' }}</dd>
</div>
<div class="cl-meta-item">
<dt>地点</dt>
<dd>{{ c.location || '—' }}</dd>
</div>
<div class="cl-meta-item">
<dt>官网</dt>
<dd>
<a v-if="c.official_url" class="cl-link" :href="c.official_url" target="_blank" rel="noopener">
{{ String(c.official_url).replace(/^https?:\/\//, '').replace(/\/$/, '') }}
</a>
<span v-else>—</span>
</dd>
</div>
</dl>
</li>
</ul>
</section>
</template>
<style scoped>
.cl {
display: flex;
flex-direction: column;
gap: 10px;
}
.cl-head {
display: flex;
align-items: baseline;
gap: 10px;
}
.cl-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--pa-ink);
}
.cl-count {
font-size: 12px;
color: var(--pa-ink-muted);
}
.cl-tz {
margin: 0;
font-size: 12px;
color: var(--pa-ink-subtle);
}
/* 筛选栏 */
.cl-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 14px;
padding: 10px 12px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
}
.cl-field {
display: flex;
align-items: center;
gap: 6px;
}
.cl-field-label {
font-size: 12px;
color: var(--pa-ink-muted);
}
.cl-select {
padding: 4px 8px;
border: 1px solid var(--pa-hairline-strong);
border-radius: var(--pa-r-sm);
background: var(--pa-surface-2);
color: var(--pa-ink);
font-size: 12px;
}
.cl-check {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--pa-ink);
cursor: pointer;
user-select: none;
}
/* 状态区 */
.cl-state {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 0;
}
.cl-state-msg {
margin: 0;
font-size: 13px;
color: var(--pa-ink-muted);
}
.cl-retry {
align-self: flex-start;
padding: 5px 14px;
border: none;
border-radius: var(--pa-r-md);
background: var(--pa-surface-2);
color: var(--pa-ink);
font-size: 12px;
cursor: pointer;
transition: background .15s var(--pa-ease);
}
.cl-retry:hover {
background: var(--pa-surface-3);
}
.cl-skel {
height: 72px;
border-radius: var(--pa-r-sm);
background: var(--pa-surface-2);
animation: cl-pulse 1.2s var(--pa-ease) infinite alternate;
}
@keyframes cl-pulse {
from { opacity: .55; }
to { opacity: 1; }
}
/* 卡片列表 */
.cl-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.cl-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
}
.cl-row1 {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.cl-name {
font-size: 14px;
font-weight: 600;
color: var(--pa-ink);
}
.cl-full {
margin: 0;
font-size: 12px;
color: var(--pa-ink-muted);
}
/* 徽章 */
.cl-chip {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: var(--pa-r-pill);
font-size: 11px;
line-height: 1.5;
}
.cl-chip--ccf {
background: var(--pa-surface-3);
color: var(--pa-ink);
border: 1px solid var(--pa-hairline-strong);
}
.cl-chip--warn {
background: color-mix(in srgb, var(--pa-warning) 14%, transparent);
color: var(--pa-warning);
border: 1px solid color-mix(in srgb, var(--pa-warning) 45%, transparent);
font-weight: 500;
}
.cl-chip--tag {
background: var(--pa-surface-2);
color: var(--pa-ink-muted);
border: 1px solid var(--pa-hairline);
}
.cl-chip--app {
background: color-mix(in srgb, var(--pa-info) 10%, transparent);
color: var(--pa-info);
border-color: color-mix(in srgb, var(--pa-info) 35%, transparent);
}
/* 领域标签族色:ai 蓝 / bio 绿 / plant 黄 / cross 紫(置于 app 之后,交叉优先) */
.cl-chip--ai {
background: color-mix(in srgb, var(--pa-info) 12%, transparent);
color: var(--pa-info);
border-color: color-mix(in srgb, var(--pa-info) 35%, transparent);
}
.cl-chip--bio {
background: color-mix(in srgb, var(--pa-success) 14%, transparent);
color: var(--pa-success);
border-color: color-mix(in srgb, var(--pa-success) 40%, transparent);
}
.cl-chip--plant {
background: color-mix(in srgb, var(--pa-warning) 14%, transparent);
color: var(--pa-warning);
border-color: color-mix(in srgb, var(--pa-warning) 40%, transparent);
}
.cl-chip--cross {
background: color-mix(in srgb, #7c5cbf 14%, transparent);
color: #7c5cbf;
border-color: color-mix(in srgb, #7c5cbf 40%, transparent);
}
/* 倒计时色阶:≤0 灰 / ≤7 红 / 8–30 橙 / >30 绿 */
.cl-cd {
font-family: var(--pa-font-mono);
font-size: 12.5px;
font-weight: 600;
}
.cl-cd--red { color: var(--pa-error); }
.cl-cd--orange { color: var(--pa-warning); }
.cl-cd--green { color: var(--pa-success); }
.cl-cd--ended { color: var(--pa-ink-muted); }
/* 元信息网格 */
.cl-meta {
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 6px 14px;
}
.cl-meta-item dt {
font-size: 11px;
color: var(--pa-ink-subtle);
}
.cl-meta-item dd {
margin: 0;
font-size: 12.5px;
color: var(--pa-ink);
}
.cl-deadline {
font-family: var(--pa-font-mono);
font-weight: 600;
}
.cl-link {
color: var(--pa-g-from);
text-decoration: none;
}
.cl-link:hover {
text-decoration: underline;
}
</style>
@@ -0,0 +1,495 @@
<!-- FormatPanel.vue —— ⑤ 论文排版与规范(④ 后端规则引擎前端)。
链路:本地 PDF/Word/LaTeX(form-data)→ edu.submitFormatCheck →
POST /api/review/format-check(file + target_journal=期刊id,纯规则 <1s)
→ {ok, checks[{key,name,status,detail,suggestion}], summary{pass,warn,fail},
meta{filename,file_type,pages,ruleset_source,rules_source,journal_*,
refs_style_*,tex{class,fontsize_pt,paper,packages,cites,macros}}}
(真实响应结构写入前已 curl 8100 实测:PDF/Word/LaTeX 三类型 + 错误信封)。
LaTeX:detail 中含 "\n建议:\n" 标记时拆分为 正文 + <pre class="fc-code"> 代码块
(只展示建议片段,不自动改写)。
优先级:formatReport 存在 → 结果区(不退回上传区);否则 loading > error > 上传区。
rules_source="placeholder" → 结果区顶部黄字提示(诚实展示占位规则)。
结果跨菜单保留(store formatReport);期刊下拉草稿 drafts.formatJournalId;
文件为面板本地 ref。样式 fc- 前缀。 -->
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useEduStore } from '../../../stores/edu.js';
const edu = useEduStore();
const MAX_SIZE = 50 * 1024 * 1024;
const STATUS_LABEL = { pass: '通过', warn: '警告', fail: '不通过' };
const FILE_LABEL = { pdf: 'PDF', docx: 'Word', tex: 'LaTeX' };
const DETAIL_CODE_MARKER = '\n建议:\n'; // 后端 LaTeX 建议片段统一标记
const fileInput = ref(null);
const file = ref(null);
const fileError = ref('');
const dragging = ref(false);
const hasReport = computed(() => !!(edu.formatReport && edu.formatReport.ok));
const loading = computed(() => edu.formatStatus === 'loading');
const errored = computed(() => edu.formatStatus === 'error');
const report = computed(() => edu.formatReport || null);
const meta = computed(() => edu.formatReport?.meta || null);
const summary = computed(() => edu.formatReport?.summary || { pass: 0, warn: 0, fail: 0 });
const checks = computed(() => edu.formatReport?.checks || []);
const placeholderRules = computed(() => meta.value?.rules_source === 'placeholder');
const fmtSize = (b) => (b / 1048576).toFixed(1) + ' MB';
/** detail 按 "\n建议:\n" 拆成 正文 + LaTeX 代码片段(无标记则整段为正文) */
function splitDetail(detail) {
const text = String(detail || '');
const i = text.indexOf(DETAIL_CODE_MARKER);
if (i < 0) return { text, code: '' };
return { text: text.slice(0, i), code: text.slice(i + DETAIL_CODE_MARKER.length) };
}
onMounted(() => {
if (edu.journalsStatus === 'idle') edu.fetchJournals(); // 目标期刊下拉数据源
});
function acceptFile(f) {
fileError.value = '';
if (!f) return;
const name = String(f.name || '').toLowerCase();
if (!name.endsWith('.pdf') && !name.endsWith('.docx') && !name.endsWith('.tex')) {
fileError.value = '仅支持 PDF / Word(.docx)/ LaTeX(.tex)文件';
return;
}
if (!f.size) {
fileError.value = '上传文件为空';
return;
}
if (f.size > MAX_SIZE) {
fileError.value = '上传文件超过 50MB 上限';
return;
}
file.value = f;
}
function onPick(e) {
acceptFile(e.target.files?.[0]);
e.target.value = '';
}
function onDrop(e) {
dragging.value = false;
acceptFile(e.dataTransfer?.files?.[0]);
}
function removeFile() {
file.value = null;
}
async function submit() {
if (!file.value || loading.value) return;
const fd = new FormData();
fd.append('file', file.value);
fd.append('target_journal', edu.drafts.formatJournalId || ''); // 期刊 id;空=通用规则
await edu.submitFormatCheck(fd);
}
function reset() {
edu.clearFormatReport();
file.value = null;
fileError.value = '';
}
</script>
<template>
<section class="fc">
<header class="fc-head">
<h1 class="fc-title">论文排版与规范</h1>
<span class="fc-sub">PDF / Word / LaTeX · 页数·字体·边距·行距·图表·参考文献</span>
</header>
<!-- ========== ① 有结果 → 结果区(最高优先级) ========== -->
<template v-if="hasReport">
<p v-if="placeholderRules" class="fc-placeholder" role="note">
⚠ 格式规则基于占位数据,建议核对期刊官网投稿指南
</p>
<div class="fc-meta">
📄 {{ meta?.filename || '—' }} · {{ FILE_LABEL[meta?.file_type] || meta?.file_type }} ·
{{ meta?.ruleset_source === 'journal' ? meta?.journal_name : '通用规则' }} ·
{{ meta?.pages != null ? `共 ${meta?.pages} 页` : '页数未记录' }}
<span class="fc-meta-sub">
· 参考文献要求 {{ meta?.refs_style_expected }} / 检出 {{ meta?.refs_style_detected }}
</span>
</div>
<!-- 汇总条 -->
<div class="fc-summary">
<span class="fc-count pass">{{ summary.pass }} 项通过</span>
<span class="fc-count warn">{{ summary.warn }} 项警告</span>
<span class="fc-count fail">{{ summary.fail }} 项不通过</span>
<button class="fc-again" type="button" @click="reset">重新检查</button>
</div>
<!-- 逐项检查卡 -->
<article v-for="c in checks" :key="c.key" class="fc-card" :class="c.status">
<span class="fc-badge" :class="c.status">{{ STATUS_LABEL[c.status] || c.status }}</span>
<div class="fc-card-body">
<div class="fc-card-name">{{ c.name }}</div>
<p class="fc-card-detail">{{ splitDetail(c.detail).text }}</p>
<pre v-if="splitDetail(c.detail).code" class="fc-code">{{ splitDetail(c.detail).code }}</pre>
<p v-if="c.suggestion" class="fc-card-sug">建议:{{ c.suggestion }}</p>
</div>
</article>
</template>
<!-- ========== ② loading ========== -->
<div v-else-if="loading" class="fc-state" aria-busy="true">
<div class="fc-skel"></div>
<div class="fc-skel fc-skel-mid"></div>
<p class="fc-state-msg">版式检查中…(纯规则,通常 &lt;1 秒)</p>
</div>
<!-- ========== ③ error ========== -->
<div v-else-if="errored" class="fc-state" role="alert">
<p class="fc-state-msg fc-state-error">{{ edu.formatError }}</p>
<div class="fc-err-btns">
<button v-if="file" class="fc-retry" type="button" @click="submit">重试</button>
<button class="fc-retry" type="button" @click="reset">重新选择文件</button>
</div>
</div>
<!-- ========== ④ 上传区 ========== -->
<div v-else class="fc-upload">
<div
class="fc-drop"
:class="{ dragging, filled: !!file }"
role="button"
tabindex="0"
@click="fileInput.click()"
@keydown.enter="fileInput.click()"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
>
<input
ref="fileInput"
class="fc-file-input"
type="file"
accept=".pdf,.docx,.tex,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
@click.stop
@change="onPick"
/>
<template v-if="file">
<span class="fc-file-name">📄 {{ file.name }} · {{ fmtSize(file.size) }}</span>
<button class="fc-remove" type="button" title="移除文件" @click.stop="removeFile">×</button>
</template>
<template v-else>
<span class="fc-drop-main">拖拽 PDF / Word(.docx) / LaTeX(.tex) 到此处,或点击选择</span>
<span class="fc-hint">仅支持 PDF / Word(.docx) / LaTeX(.tex) · ≤ 50MB · 纯本地规则检查</span>
</template>
</div>
<p v-if="fileError" class="fc-file-error">{{ fileError }}</p>
<label class="fc-label" for="fc-journal">目标期刊(选填,决定规则集)</label>
<select
id="fc-journal"
v-model="edu.drafts.formatJournalId"
class="fc-input fc-select"
:disabled="loading"
>
<option value="">不指定(通用规则)</option>
<option v-for="j in edu.journals" :key="j.id" :value="j.id">{{ j.name }}</option>
</select>
<button class="fc-submit" type="button" :disabled="!file || loading" @click="submit">
开始版式检查
</button>
</div>
</section>
</template>
<style scoped>
.fc {
display: flex;
flex-direction: column;
gap: 12px;
}
.fc-head {
display: flex;
align-items: baseline;
gap: 10px;
}
.fc-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--pa-ink);
}
.fc-sub {
font-size: 12px;
color: var(--pa-ink-subtle);
}
/* 占位规则黄字提示(仅 rules_source=placeholder 时渲染) */
.fc-placeholder {
margin: 0;
padding: 8px 12px;
border: 1px solid var(--pa-warning);
border-radius: var(--pa-r-md);
background: transparent;
color: var(--pa-warning);
font-size: 12.5px;
}
.fc-meta {
font-size: 12.5px;
color: var(--pa-ink-muted);
}
.fc-meta-sub {
color: var(--pa-ink-subtle);
}
/* 汇总条 */
.fc-summary {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.fc-count {
padding: 3px 12px;
border: 1px solid currentColor;
border-radius: var(--pa-r-pill);
font-size: 12.5px;
font-weight: 600;
}
.fc-count.pass { color: var(--pa-success); }
.fc-count.warn { color: var(--pa-warning); }
.fc-count.fail { color: var(--pa-error); }
.fc-again {
margin-left: auto;
padding: 5px 14px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
background: transparent;
color: var(--pa-ink-muted);
font-size: 12px;
cursor: pointer;
}
.fc-again:hover {
color: var(--pa-ink);
background: var(--pa-surface-2);
}
/* 检查卡 */
.fc-card {
display: flex;
gap: 12px;
padding: 12px 14px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-left-width: 3px;
border-radius: var(--pa-r-md);
}
.fc-card.pass { border-left-color: var(--pa-success); }
.fc-card.warn { border-left-color: var(--pa-warning); }
.fc-card.fail { border-left-color: var(--pa-error); }
.fc-badge {
flex: none;
align-self: flex-start;
min-width: 52px;
text-align: center;
padding: 2px 9px;
border: 1px solid currentColor;
border-radius: var(--pa-r-pill);
font-size: 11.5px;
font-weight: 700;
}
.fc-badge.pass { color: var(--pa-success); }
.fc-badge.warn { color: var(--pa-warning); }
.fc-badge.fail { color: var(--pa-error); }
.fc-card-body {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
}
.fc-card-name {
font-size: 13.5px;
font-weight: 600;
color: var(--pa-ink);
}
.fc-card-detail {
margin: 0;
font-family: var(--pa-font-mono);
font-size: 12.5px;
line-height: 1.65;
color: var(--pa-ink-muted);
word-break: break-word;
}
.fc-card-sug {
margin: 0;
font-size: 12.5px;
line-height: 1.6;
color: var(--pa-ink);
}
/* LaTeX 建议代码片段(detail 中 "建议:" 标记后的部分):等宽 + 灰底块 */
.fc-code {
margin: 0;
padding: 8px 12px;
background: var(--pa-code-bg);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-sm);
font-family: var(--pa-font-mono);
font-size: 12.5px;
line-height: 1.65;
color: var(--pa-ink);
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
}
/* 状态区 */
.fc-state {
display: flex;
flex-direction: column;
gap: 10px;
}
.fc-skel {
height: 64px;
border-radius: var(--pa-r-md);
background: var(--pa-surface-2);
animation: fc-pulse 1.2s var(--pa-ease) infinite alternate;
}
.fc-skel-mid {
height: 96px;
}
@keyframes fc-pulse {
from { opacity: .55; }
to { opacity: 1; }
}
.fc-state-msg {
margin: 0;
font-size: 13px;
color: var(--pa-ink-muted);
}
.fc-state-error {
color: var(--pa-error);
}
.fc-err-btns {
display: flex;
gap: 10px;
}
.fc-retry {
padding: 5px 14px;
border: none;
border-radius: var(--pa-r-md);
background: var(--pa-surface-2);
color: var(--pa-ink);
font-size: 12px;
cursor: pointer;
}
.fc-retry:hover {
background: var(--pa-surface-3);
}
/* 上传区 */
.fc-upload {
display: flex;
flex-direction: column;
gap: 10px;
}
.fc-drop {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 130px;
padding: 18px;
border: 1.5px dashed var(--pa-hairline-strong);
border-radius: var(--pa-r-lg);
background: var(--pa-surface-1);
cursor: pointer;
transition: border-color .15s var(--pa-ease), background .15s var(--pa-ease);
}
.fc-drop:hover,
.fc-drop.dragging {
border-color: var(--pa-g-from);
background: var(--pa-surface-2);
}
.fc-drop.filled {
border-style: solid;
}
.fc-file-input {
display: none;
}
.fc-drop-main {
font-size: 13.5px;
color: var(--pa-ink);
}
.fc-hint {
font-size: 12px;
color: var(--pa-ink-subtle);
}
.fc-file-name {
font-size: 13.5px;
color: var(--pa-ink);
word-break: break-all;
}
.fc-remove {
position: absolute;
top: 8px;
right: 10px;
width: 22px;
height: 22px;
border: none;
border-radius: 50%;
background: var(--pa-surface-3);
color: var(--pa-ink-muted);
font-size: 15px;
line-height: 1;
cursor: pointer;
}
.fc-remove:hover {
color: var(--pa-ink);
}
.fc-file-error {
margin: 0;
font-size: 12.5px;
color: var(--pa-error);
}
.fc-label {
font-size: 12px;
font-weight: 500;
color: var(--pa-ink-muted);
}
.fc-input {
width: 320px;
max-width: 100%;
box-sizing: border-box;
padding: 7px 11px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
background: var(--pa-surface-2);
color: var(--pa-ink);
font-size: 13px;
font-family: inherit;
}
.fc-input:focus {
outline: none;
border-color: var(--pa-g-from);
}
.fc-select:disabled {
opacity: .6;
}
.fc-submit {
align-self: flex-start;
padding: 8px 22px;
border: none;
border-radius: var(--pa-r-md);
background: var(--pa-gradient);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.fc-submit:disabled {
opacity: .45;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,779 @@
<!-- JournalsPanel.vue —— ① 学术期刊情报(步骤5 目录化改造,D2 严格只读)。
数据链:onMounted → edu.fetchDirectory() → review.js getDirectory()
→ GET /api/review/journals/directory(相对路径 → vite proxy → BFF → 8100)。
双 Tab 强制单语(🌍 en 779 本 / 🇨🇳 zh 277 本,后端 lang 过滤保证永不混排);
搜索/研究方向/分区筛选 + 后端分页(每页 50)+ 行展开详情 + 四态
(loading 骨架 / error 重试 / 未导入 / 无匹配)。
v4 改造(决策2):主视图「来源」列移除,末列为「官网」列——official_url 有值
→ <a>短域名(去协议+去尾斜杠)</a>,null → 「—」纯文本不生成链接;
展开行承载凭证 4 项:JIF核对状态 / 官网核对状态(可靠=绿、未逐站复核=浅绿、
未检得·待核实=橙)/ JIF数据来源链接 / 分区数据来源链接;zh 展开行无 JCR 字段
(jcr_release_year 恒 null,年报版本年 metric_report_year 单独展示)。
底部固定表C长版数据源声明;顶部黄字按 Tab 切换(en/zh 两套口径)。
三态与 journals(FormatPanel 下拉的 5 本精选)完全隔离,edu.journals 链路零改动。
读时归一化由后端承担:zh cas_partition 恒 null + note、jif_note 两类文案、
缺值 source 归零(前端只管展示 tooltip/链接)。 -->
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useEduStore } from '../../../stores/edu.js';
const edu = useEduStore();
/* —— panel-local 纯视图态(丢弃无妨;查询条件在 store)—— */
const searchInput = ref(''); // 输入框即时值(300ms 防抖后才进 store)
const expandedId = ref(''); // 展开详情的期刊 id('' = 无)
const jumpInput = ref(''); // 跳页输入
let searchTimer = null;
const tab = computed(() => edu.journalsTab);
const q = computed(() => edu.directoryQuery);
/* 数据源声明:meta 到达且 lang 匹配 → 服务端文案;否则本地兜底(切 Tab 即时切换)。
顶部黄字按 Tab 切换;底部另有表C长版统一声明(FOOT_NOTE,双 Tab 相同)。 */
const LOCAL_NOTE = {
en: '分区:2025年中科院公开表(多表比对,2026年起停更);JIF:2025年度指标(2026年JCR发布,两份公开汇编一致或已核对出版社官方指标);整理日期2026-10-10;空白=来源未给值≠0',
zh: '指标:国内自然科学期刊年报2025版(统计年2024,与JIF统计源不同、不混排);中文期刊不按中科院分区口径列示',
};
/* 底部数据源声明(表C 长版,拍板决策3;末尾补来源口径说明) */
const FOOT_NOTE =
'JIF 取自 2025 年度指标(2026 年 JCR 发布)并经两份公开汇编交叉一致或出版社官方指标核对,' +
'中科院分区采用 2025 版公开表(多表比对、2026 年起停更),' +
'中文期刊复合/综合影响因子取自国内年报 2025 版(统计年 2024,与 JIF 统计源不同、不混排),' +
'期刊官网均附核对状态(3 本中文刊未检得可靠官网留空待核实)。' +
'分区为高校转载版,官方入口需机构登录;JIF/中文数据为公开汇编镜像,仅供参考核对。';
/* —— 凭证状态配色:可靠=绿 / 未逐站复核=浅绿 / 未检得·待核实=橙 —— */
const JIF_OK = new Set(['两份公开汇编一致', '已核对出版社2025指标']);
const URL_OK = new Set([
'出版社/期刊主页匹配',
'期刊/主办方官方入口匹配',
'期刊/出版平台页面检索核对',
]);
const URL_SOFT = 'ISSN匹配公开元数据;未逐站复核';
function checkCls(v, okSet) {
if (!v) return '';
if (okSet.has(v)) return 'jp-ok';
if (v === URL_SOFT) return 'jp-ok-soft';
if (v.includes('未检得') || v.includes('待核实')) return 'jp-warn';
return '';
}
const sourceNote = computed(() =>
edu.directoryMeta && edu.directoryMeta.lang === tab.value
? edu.directoryMeta.data_source_note
: LOCAL_NOTE[tab.value]
);
const countText = computed(() =>
edu.directoryMeta && edu.directoryMeta.lang === tab.value
? edu.directoryMeta.total
: '—'
);
/* 研究方向 options(只认当前 lang 的 meta,防切 Tab 后旧词表残留) */
const domains = computed(() =>
edu.directoryMeta && edu.directoryMeta.lang === tab.value ? edu.directoryMeta.domains : []
);
const total = computed(() => edu.directoryMeta?.total ?? 0);
const totalPages = computed(() => Math.ceil(total.value / q.value.size) || 1);
const hasFilter = computed(
() => !!(q.value.q || q.value.domain || q.value.cas_partition)
);
/* 排序游标:后端默认(en=jif desc / zh=复合IF desc)在 field='' 时生效,箭头如实回显 */
const activeSort = computed(() => ({
field: q.value.field || (tab.value === 'en' ? 'jif' : 'composite_impact_factor'),
order: q.value.order || 'desc',
}));
const CAS_OPTIONS = ['1', '2', '3', '4'];
/* —— 交互 —— */
function onTab(next) {
if (next === tab.value) return;
clearTimeout(searchTimer);
searchInput.value = '';
jumpInput.value = '';
expandedId.value = '';
edu.switchDirectoryTab(next);
}
function onSearchInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
edu.fetchDirectory({ q: searchInput.value.trim(), page: 1 });
expandedId.value = '';
}, 300);
}
function onDomainChange(e) {
edu.fetchDirectory({ domain: e.target.value, page: 1 });
expandedId.value = '';
}
function onCasChange(e) {
edu.fetchDirectory({ cas_partition: e.target.value, page: 1 });
expandedId.value = '';
}
function clearFilters() {
clearTimeout(searchTimer);
searchInput.value = '';
edu.fetchDirectory({ q: '', domain: '', cas_partition: '', page: 1 });
expandedId.value = '';
}
function gotoPage(p) {
const n = Math.min(Math.max(1, p), totalPages.value);
if (n === q.value.page) return;
expandedId.value = '';
edu.fetchDirectory({ page: n });
}
function jumpPage() {
const n = parseInt(jumpInput.value, 10);
jumpInput.value = '';
if (Number.isNaN(n)) return;
gotoPage(n);
}
function retry() {
edu.fetchDirectory();
}
function toggleExpand(id) {
expandedId.value = expandedId.value === id ? '' : id;
}
function toggleSort(field) {
const cur = activeSort.value;
if (cur.field === field) {
// 同列切换升/降(不重置页码:total 不变,当前页仍有效)
edu.fetchDirectory({ field, order: cur.order === 'asc' ? 'desc' : 'asc' });
} else {
edu.fetchDirectory({ field, order: field === 'name' ? 'asc' : 'desc' });
}
}
const arrow = (f) => (activeSort.value.field === f ? (activeSort.value.order === 'asc' ? '▲' : '▼') : '');
const fmt = (v) => (v === null || v === undefined ? '—' : v);
const fmtIf = (v, year) => (v === null || v === undefined ? '—' : year ? `${v} (${year})` : `${v}`);
const shortUrl = (u) => String(u || '').replace(/^https?:\/\//, '').replace(/\/$/, '') || '—';
onMounted(() => {
// idle 才查:ready 复用 store 缓存(切菜单回来筛选仍在);error 不自动重试防循环
if (edu.directoryStatus === 'idle') edu.fetchDirectory();
});
onUnmounted(() => clearTimeout(searchTimer));
</script>
<template>
<section class="jp">
<!-- 头部:标题 + 共 N 本(meta 与当前 Tab 匹配才回显,防切 Tab 瞬间旧计数) -->
<header class="jp-head">
<h1 class="jp-title">学术期刊情报</h1>
<span class="jp-count">共 {{ countText }} 本</span>
</header>
<!-- 诚实性声明(黄字;两套口径按 Tab 切换) -->
<p class="jp-note jp-note--warn">{{ sourceNote }}</p>
<!-- Tab:🌍 国际 / 🇨🇳 中文(切换全清筛选) -->
<nav class="jp-tabs" role="tablist">
<button
class="jp-tab"
:class="{ on: tab === 'en' }"
type="button"
role="tab"
:aria-selected="tab === 'en'"
@click="onTab('en')"
>🌍 国际期刊</button>
<button
class="jp-tab"
:class="{ on: tab === 'zh' }"
type="button"
role="tab"
:aria-selected="tab === 'zh'"
@click="onTab('zh')"
>🇨🇳 中文期刊</button>
</nav>
<!-- 搜索(防抖 300ms → q + page=1) -->
<div class="jp-tools">
<input
v-model="searchInput"
class="jp-search"
type="search"
placeholder="搜索期刊名(如 Nature、Plant Cell)"
@input="onSearchInput"
>
</div>
<!-- 筛选栏(domains 来自当前 lang 的 meta;分区下拉仅 en) -->
<div v-if="domains.length" class="jp-filters">
<div class="jp-fgroup">
<span class="jp-flabel">研究方向</span>
<select class="jp-select" :value="q.domain" @change="onDomainChange">
<option value="">全部</option>
<option v-for="d in domains" :key="d" :value="d">{{ d }}</option>
</select>
</div>
<div v-if="tab === 'en'" class="jp-fgroup">
<span class="jp-flabel">分区</span>
<select class="jp-select" :value="q.cas_partition" @change="onCasChange">
<option value="">全部</option>
<option v-for="p in CAS_OPTIONS" :key="p" :value="p">{{ p }}区</option>
</select>
</div>
<button v-if="hasFilter" class="jp-clear" type="button" @click="clearFilters">清除筛选</button>
</div>
<!-- ERROR:directory 三态独立于 journals;reviewErrorMessage 已映射 -->
<div v-if="edu.directoryStatus === 'error'" class="jp-state" role="alert">
<p class="jp-state-msg">{{ edu.directoryError }}</p>
<button class="jp-retry" type="button" @click="retry">重试</button>
</div>
<!-- LOADING:骨架行 -->
<div v-else-if="edu.directoryStatus === 'loading'" class="jp-state" aria-busy="true">
<div class="jp-skel-head"></div>
<div v-for="n in 6" :key="n" class="jp-skel-row"></div>
<p class="jp-state-msg">期刊目录加载中…</p>
</div>
<!-- 空态 A:未导入(data_imported=false,HTTP 200 优雅降级路径) -->
<div v-else-if="edu.directoryMeta && edu.directoryMeta.data_imported === false" class="jp-state">
<p class="jp-state-msg">目录数据未导入(需先运行 review_service/scripts/import_journals.py)</p>
</div>
<!-- 空态 B:有筛选 → 无匹配(≠ 无数据) -->
<div v-else-if="!edu.directory.length && hasFilter" class="jp-state">
<p class="jp-state-msg">无匹配结果,可清除筛选后重试</p>
<button class="jp-retry" type="button" @click="clearFilters">清除筛选</button>
</div>
<!-- 空态 C:兜底(已导入但整表为空) -->
<div v-else-if="!edu.directory.length" class="jp-state">
<p class="jp-state-msg">暂无期刊数据</p>
</div>
<!-- READY + 表格(写交互 = 筛选/排序重查 + 分页 + 行展开;en/zh 各 5 列,末列官网) -->
<table v-else class="jp-table">
<thead>
<tr v-if="tab === 'en'">
<th class="jp-th-sort jp-c-name" :class="{ on: activeSort.field === 'name' }" @click="toggleSort('name')">
期刊名 <span class="jp-arrow">{{ arrow('name') }}</span>
</th>
<th class="jp-th-sort jp-c-dir" :class="{ on: activeSort.field === 'research_direction' }" @click="toggleSort('research_direction')">
研究方向 <span class="jp-arrow">{{ arrow('research_direction') }}</span>
</th>
<th class="jp-th-sort jp-c-part" :class="{ on: activeSort.field === 'cas_partition' }" @click="toggleSort('cas_partition')">
分区 <span class="jp-arrow">{{ arrow('cas_partition') }}</span>
</th>
<th class="jp-th-sort jp-c-jif" :class="{ on: activeSort.field === 'jif' }" @click="toggleSort('jif')">
JIF <span class="jp-arrow">{{ arrow('jif') }}</span>
</th>
<th class="jp-c-url">官网</th>
</tr>
<tr v-else>
<th class="jp-th-sort jp-c-name" :class="{ on: activeSort.field === 'name' }" @click="toggleSort('name')">
期刊名 <span class="jp-arrow">{{ arrow('name') }}</span>
</th>
<th class="jp-th-sort jp-c-dir" :class="{ on: activeSort.field === 'research_direction' }" @click="toggleSort('research_direction')">
研究方向 <span class="jp-arrow">{{ arrow('research_direction') }}</span>
</th>
<th class="jp-th-sort jp-c-comp" :class="{ on: activeSort.field === 'composite_impact_factor' }" @click="toggleSort('composite_impact_factor')">
复合IF <span class="jp-arrow">{{ arrow('composite_impact_factor') }}</span>
</th>
<th class="jp-th-sort jp-c-compr" :class="{ on: activeSort.field === 'comprehensive_impact_factor' }" @click="toggleSort('comprehensive_impact_factor')">
综合IF <span class="jp-arrow">{{ arrow('comprehensive_impact_factor') }}</span>
</th>
<th class="jp-c-url">官网</th>
</tr>
</thead>
<tbody>
<template v-for="j in edu.directory" :key="j.id">
<tr class="jp-row" :class="{ open: expandedId === j.id }" @click="toggleExpand(j.id)">
<!-- en:期刊名 / 研究方向 / 分区 / JIF(年) / 官网 -->
<template v-if="tab === 'en'">
<td class="jp-name jp-c-name">{{ j.name }}</td>
<td class="jp-c-dir">{{ j.research_direction || '—' }}</td>
<td class="jp-part jp-c-part">{{ j.cas_partition || '—' }}</td>
<td class="jp-num jp-c-jif" :title="j.jif_note || ''">
{{ fmtIf(j.jif, j.jif_year) }}
</td>
<td class="jp-c-url">
<a v-if="j.official_url" class="jp-link" :href="j.official_url" target="_blank" rel="noopener" @click.stop>{{ shortUrl(j.official_url) }}</a>
<span v-else class="jp-dash">—</span>
</td>
</template>
<!-- zh:期刊名 / 研究方向 / 复合IF(统计年) / 综合IF / 官网 -->
<template v-else>
<td class="jp-name jp-c-name">{{ j.name }}</td>
<td class="jp-c-dir">{{ j.research_direction || '—' }}</td>
<td class="jp-num jp-c-comp" :title="j.jif_note || ''">{{ fmtIf(j.composite_impact_factor, j.metric_year) }}</td>
<td class="jp-num jp-c-compr">{{ fmt(j.comprehensive_impact_factor) }}</td>
<td class="jp-c-url">
<a v-if="j.official_url" class="jp-link" :href="j.official_url" target="_blank" rel="noopener" @click.stop>{{ shortUrl(j.official_url) }}</a>
<span v-else class="jp-dash">—</span>
</td>
</template>
</tr>
<!-- 展开行(两 Tab 均 5 列) -->
<tr v-if="expandedId === j.id" class="jp-xp-row">
<td colspan="5">
<!-- en 详情:年份/状态/凭证链接 -->
<div v-if="tab === 'en'" class="jp-xp">
<div class="jp-xp-group">
<span class="jp-xp-label">分区年</span><span class="jp-xp-val">{{ fmt(j.cas_partition_year) }}</span>
<span class="jp-xp-label">JIF年</span><span class="jp-xp-val">{{ fmt(j.jif_year) }}</span>
<span class="jp-xp-label">JIF核对状态</span><span class="jp-xp-val" :class="checkCls(j.jif_check_status, JIF_OK)">{{ j.jif_check_status || '—' }}</span>
<span class="jp-xp-label">Top</span><span class="jp-xp-val">{{ j.top_flag || '—' }}</span>
</div>
<div class="jp-xp-group">
<span class="jp-xp-label">官网核对</span><span class="jp-xp-val" :class="checkCls(j.official_check_status, URL_OK)">{{ j.official_check_status || '—' }}</span>
<span class="jp-xp-label">JCR发布年</span><span class="jp-xp-val">{{ fmt(j.jcr_release_year) }}</span>
</div>
<div class="jp-xp-group">
<span class="jp-xp-label">出版系列</span><span class="jp-xp-val">{{ j.publisher_series || '—' }}</span>
<span class="jp-xp-label">期刊标记</span><span class="jp-xp-val">{{ j.journal_mark || '—' }}</span>
<span class="jp-xp-label">文章类型</span><span class="jp-xp-val">{{ j.article_type || '—' }}</span>
</div>
<div class="jp-xp-group">
<span class="jp-xp-label">ISSN</span>
<span class="jp-xp-val jp-mono">{{ j.issn || '—' }}<template v-if="j.eissn"> · eISSN {{ j.eissn }}</template></span>
</div>
<div class="jp-xp-group">
<span class="jp-xp-label">JCR学科</span>
<span v-for="d in j.jcr_disciplines || []" :key="d" class="jp-chip on">{{ d }}</span>
<span v-if="!(j.jcr_disciplines || []).length" class="jp-dash">—</span>
</div>
<div class="jp-xp-group">
<span class="jp-xp-label">来源</span>
<template v-if="j.jif_source_url">
<a class="jp-link" :href="j.jif_source_url" target="_blank" rel="noopener" @click.stop>
JIF来源:{{ shortUrl(j.jif_source_url) }}
</a>
<span class="jp-src-note">(汇编镜像,官方入口需机构订阅)</span>
</template>
<span v-else class="jp-dash">JIF来源:无</span>
<span class="jp-src-note">分区来源:{{ j.partition_source_note }}</span>
</div>
</div>
<!-- zh 详情:年报口径指标 + 不可混排黄字 -->
<div v-else class="jp-xp">
<div class="jp-xp-group">
<span class="jp-xp-label">复合IF</span><span class="jp-xp-val jp-mono">{{ fmt(j.composite_impact_factor) }}</span>
<span class="jp-xp-label">综合IF</span><span class="jp-xp-val jp-mono">{{ fmt(j.comprehensive_impact_factor) }}</span>
<span class="jp-xp-label">统计年</span><span class="jp-xp-val">{{ fmt(j.metric_year) }}</span>
</div>
<div class="jp-xp-group">
<span class="jp-xp-label">年报版本</span><span class="jp-xp-val">{{ j.metric_report_year ? `${j.metric_report_year}版` : '—' }}</span>
<span class="jp-xp-label">年报页码</span><span class="jp-xp-val">{{ j.annual_report_page ? `第${j.annual_report_page}页` : '—' }}</span>
<span class="jp-xp-label">核对状态</span><span class="jp-xp-val" :class="checkCls(j.jif_check_status, JIF_OK)">{{ j.jif_check_status || '—' }}</span>
</div>
<div class="jp-xp-group">
<span class="jp-xp-label">官网核对</span><span class="jp-xp-val" :class="checkCls(j.official_check_status, URL_OK)">{{ j.official_check_status || '—' }}</span>
</div>
<div class="jp-xp-group">
<span class="jp-xp-label">指标口径</span><span class="jp-xp-val">{{ j.metric_caliber || '—' }}</span>
</div>
<div class="jp-xp-group">
<span class="jp-xp-label">来源</span>
<template v-if="j.data_source_url">
<a class="jp-link" :href="j.data_source_url" target="_blank" rel="noopener" @click.stop>
年报原页:{{ shortUrl(j.data_source_url) }}
</a>
<span class="jp-src-note">(国内自然科学期刊年报 2025 版)</span>
</template>
<span v-else class="jp-dash">年报原页:无</span>
<template v-if="j.jif_source_url">
<a class="jp-link" :href="j.jif_source_url" target="_blank" rel="noopener" @click.stop>
JIF来源:{{ shortUrl(j.jif_source_url) }}
</a>
<span class="jp-src-note">(汇编镜像,官方入口需机构订阅)</span>
</template>
<span v-else class="jp-dash">JIF来源:无</span>
<span class="jp-src-note">分区来源:{{ j.partition_source_note }}</span>
</div>
<p class="jp-xp-note">⚠ 国内IF与JIF统计源不同,不可混排</p>
</div>
</td>
</tr>
</template>
</tbody>
</table>
<!-- 分页(后端分页;total 超过 size 才显示) -->
<footer v-if="edu.directoryStatus === 'ready' && totalPages > 1" class="jp-pager">
<button class="jp-page-btn" type="button" :disabled="q.page <= 1" @click="gotoPage(q.page - 1)">‹ 上一页</button>
<span class="jp-page-info">第 {{ q.page }} / {{ totalPages }} 页</span>
<button class="jp-page-btn" type="button" :disabled="q.page >= totalPages" @click="gotoPage(q.page + 1)">下一页 ›</button>
<label class="jp-page-jump">
跳至
<input v-model="jumpInput" class="jp-page-input" type="number" min="1" :max="totalPages" @change="jumpPage">
页
</label>
<span class="jp-page-size">每页 {{ q.size }} 条</span>
</footer>
<!-- 底部数据源声明(表C 长版,拍板决策3;双 Tab 统一) -->
<p class="jp-foot">{{ FOOT_NOTE }}</p>
</section>
</template>
<style scoped>
.jp {
display: flex;
flex-direction: column;
gap: 10px;
}
.jp-head {
display: flex;
align-items: baseline;
gap: 10px;
}
.jp-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--pa-ink);
}
.jp-count {
font-size: 12px;
color: var(--pa-ink-muted);
}
.jp-note {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--pa-ink-subtle);
}
.jp-note--warn {
color: var(--pa-warning);
}
/* —— Tab —— */
.jp-tabs {
display: flex;
gap: 6px;
}
.jp-tab {
padding: 6px 14px;
border: 1px solid var(--pa-hairline-strong);
border-radius: var(--pa-r-pill);
background: var(--pa-surface-1);
color: var(--pa-ink-muted);
font-size: 12.5px;
cursor: pointer;
transition: background .15s var(--pa-ease), color .15s var(--pa-ease), border-color .15s var(--pa-ease);
}
.jp-tab:hover {
background: var(--pa-surface-2);
color: var(--pa-ink);
}
.jp-tab.on {
background: color-mix(in srgb, var(--pa-g-from) 14%, transparent);
border-color: color-mix(in srgb, var(--pa-g-from) 50%, transparent);
color: var(--pa-ink);
font-weight: 600;
}
/* —— 搜索 —— */
.jp-tools {
display: flex;
}
.jp-search {
width: min(360px, 100%);
padding: 6px 10px;
border: 1px solid var(--pa-hairline-strong);
border-radius: var(--pa-r-md);
background: var(--pa-surface-1);
color: var(--pa-ink);
font-size: 13px;
}
.jp-search:focus {
outline: none;
border-color: color-mix(in srgb, var(--pa-g-from) 55%, transparent);
}
.jp-state {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 0;
}
.jp-state-msg {
margin: 0;
font-size: 13px;
color: var(--pa-ink-muted);
}
.jp-retry {
align-self: flex-start;
padding: 5px 14px;
border: none;
border-radius: var(--pa-r-md);
background: var(--pa-surface-2);
color: var(--pa-ink);
font-size: 12px;
cursor: pointer;
transition: background .15s var(--pa-ease);
}
.jp-retry:hover {
background: var(--pa-surface-3);
}
.jp-skel-head,
.jp-skel-row {
border-radius: var(--pa-r-sm);
background: var(--pa-surface-2);
animation: jp-pulse 1.2s var(--pa-ease) infinite alternate;
}
.jp-skel-head {
height: 34px;
margin-bottom: 2px;
}
.jp-skel-row {
height: 30px;
margin-bottom: 6px;
}
@keyframes jp-pulse {
from { opacity: .55; }
to { opacity: 1; }
}
.jp-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
overflow: hidden;
}
.jp-table thead th {
padding: 9px 12px;
text-align: left;
font-size: 12px;
font-weight: 500;
color: var(--pa-ink-muted);
background: var(--pa-surface-1);
border-bottom: 1px solid var(--pa-hairline);
white-space: nowrap;
}
.jp-th-sort {
cursor: pointer;
user-select: none;
transition: color .15s var(--pa-ease);
}
.jp-th-sort:hover {
color: var(--pa-ink);
}
.jp-th-sort.on {
color: var(--pa-ink);
font-weight: 600;
}
.jp-arrow {
font-size: 10px;
color: var(--pa-g-from);
}
.jp-table tbody td {
padding: 9px 12px;
border-bottom: 1px solid var(--pa-hairline);
color: var(--pa-ink);
}
.jp-table tbody tr:last-child td {
border-bottom: none;
}
.jp-table tbody tr:hover {
background: var(--pa-surface-2);
}
.jp-name {
font-weight: 500;
}
.jp-num {
text-align: center;
font-family: var(--pa-font-mono);
color: var(--pa-ink-muted);
}
.jp-link {
color: var(--pa-g-from);
text-decoration: none;
}
.jp-link:hover {
text-decoration: underline;
}
.jp-dash {
color: var(--pa-ink-subtle);
}
/* 来源口径说明(灰色小字):分区常量文案 + 链接后括号补充 */
.jp-src-note {
font-size: 12px;
color: var(--pa-ink-subtle);
}
/* —— 列宽白名单(table 自适应 + min-width,不硬截断)—— */
.jp-c-name { min-width: 200px; }
.jp-c-dir { min-width: 140px; }
.jp-c-part { min-width: 64px; text-align: center; }
.jp-c-jif { min-width: 110px; }
.jp-c-url { min-width: 180px; }
.jp-c-comp { min-width: 110px; }
.jp-c-compr { min-width: 110px; }
/* —— 凭证状态配色:可靠=绿 / 未逐站复核=浅绿 / 未检得·待核实=橙 —— */
.jp-ok {
color: var(--pa-success);
font-weight: 500;
}
.jp-ok-soft {
color: color-mix(in srgb, var(--pa-success) 60%, var(--pa-ink));
}
.jp-warn {
color: var(--pa-warning);
font-weight: 500;
}
/* —— 筛选栏 —— */
.jp-filters {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
}
.jp-fgroup {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.jp-flabel {
flex: none;
width: 56px;
font-size: 12px;
color: var(--pa-ink-muted);
}
.jp-select {
padding: 4px 8px;
border: 1px solid var(--pa-hairline-strong);
border-radius: var(--pa-r-sm);
background: var(--pa-surface-2);
color: var(--pa-ink);
font-size: 12px;
}
.jp-clear {
align-self: flex-start;
padding: 4px 12px;
border: none;
border-radius: var(--pa-r-md);
background: var(--pa-surface-3);
color: var(--pa-ink);
font-size: 12px;
cursor: pointer;
transition: background .15s var(--pa-ease);
}
.jp-clear:hover {
background: var(--pa-surface-2);
}
/* —— 行展开 —— */
.jp-part {
text-align: center;
font-weight: 500;
}
.jp-row {
cursor: pointer;
}
.jp-row.open {
background: var(--pa-surface-2);
}
.jp-xp-row td {
background: var(--pa-surface-2);
border-bottom: 1px solid var(--pa-hairline);
}
.jp-xp {
display: flex;
flex-direction: column;
gap: 8px;
padding: 4px 2px;
}
.jp-xp-group {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.jp-xp-label {
flex: none;
width: 56px;
font-size: 11px;
color: var(--pa-ink-subtle);
}
.jp-xp-val {
font-size: 12.5px;
color: var(--pa-ink);
}
.jp-mono {
font-family: var(--pa-font-mono);
}
.jp-xp-note {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--pa-warning);
}
.jp-chip {
padding: 3px 10px;
border: 1px solid var(--pa-hairline-strong);
border-radius: var(--pa-r-pill);
background: var(--pa-surface-2);
color: var(--pa-ink-muted);
font-size: 12px;
line-height: 1.5;
}
.jp-chip.on {
background: color-mix(in srgb, var(--pa-g-from) 16%, transparent);
border-color: color-mix(in srgb, var(--pa-g-from) 50%, transparent);
color: var(--pa-ink);
font-weight: 500;
}
/* —— 分页 —— */
.jp-pager {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.jp-page-btn {
padding: 5px 12px;
border: 1px solid var(--pa-hairline-strong);
border-radius: var(--pa-r-md);
background: var(--pa-surface-1);
color: var(--pa-ink);
font-size: 12px;
cursor: pointer;
transition: background .15s var(--pa-ease);
}
.jp-page-btn:hover:not(:disabled) {
background: var(--pa-surface-2);
}
.jp-page-btn:disabled {
opacity: .4;
cursor: default;
}
.jp-page-info {
font-family: var(--pa-font-mono);
font-size: 12px;
color: var(--pa-ink-muted);
}
.jp-page-jump {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--pa-ink-muted);
}
.jp-page-input {
width: 56px;
padding: 3px 6px;
border: 1px solid var(--pa-hairline-strong);
border-radius: var(--pa-r-sm);
background: var(--pa-surface-1);
font-size: 12px;
}
.jp-page-size {
font-size: 12px;
color: var(--pa-ink-subtle);
}
/* —— 底部数据源声明(表C 长版) —— */
.jp-foot {
margin: 0;
padding-top: 4px;
border-top: 1px solid var(--pa-hairline);
font-size: 12px;
line-height: 1.6;
color: var(--pa-ink-subtle);
}
</style>
@@ -0,0 +1,614 @@
<!-- PresubmitPanel.vue —— ③ AI 预审稿系统(步骤8,产品核心功能)。
链路:本地 PDF(form-data)→ edu.submitPresubmit(fd, {onUploadProgress})
→ POST /api/review/presubmit(file + target_journal;≤50MB,vstore 解析 + 六维 LLM)
→ {ok, report{summary, major/minor_weaknesses[], presentation_format[],
questions_for_authors[], recommendation}, meta{filename,target_journal,text_chars,truncated,model}}
(真实响应结构写入前已 curl 8100 实测;self_review.py 六维 prompt 不动,本组件纯展示)。
优先级:edu.report 存在 → 结果区(不退回上传区);否则 loading > error > 上传区。
结果/targetJournal 草稿存 store 跨菜单保留;PDF 文件为面板本地 ref(切菜单需重选)。
http.js FormData+onUploadProgress → XHR 分支,上传阶段显示真实百分比。 -->
<script setup>
import { computed, ref } from 'vue';
import { useEduStore } from '../../../stores/edu.js';
const edu = useEduStore();
const MAX_PDF = 50 * 1024 * 1024;
const REC = {
accept: { label: '接收', cls: 'ok' },
minor_revision: { label: '小修', cls: 'minor' },
major_revision: { label: '大修', cls: 'major' },
reject: { label: '拒稿', cls: 'reject' },
};
const fileInput = ref(null);
const file = ref(null); // File 面板本地(不进 store)
const fileError = ref('');
const dragging = ref(false);
const uploadPct = ref(0);
const hasReport = computed(() => !!(edu.report && edu.report.report));
const loading = computed(() => edu.reportStatus === 'loading');
const errored = computed(() => edu.reportStatus === 'error');
const report = computed(() => edu.report?.report || null);
const meta = computed(() => edu.report?.meta || null);
const rec = computed(() => REC[report.value?.recommendation] || REC.major_revision);
/** 上传阶段(pct<1)显示真实百分比;上传完成后是解析 + LLM 长等待 */
const phaseText = computed(() =>
uploadPct.value < 1
? `上传中 ${Math.round(uploadPct.value * 100)}%`
: '审稿生成中…(约 15–60 秒,PDF 解析 + 六维 LLM)'
);
const fmtSize = (b) => (b / 1048576).toFixed(1) + ' MB';
function acceptFile(f) {
fileError.value = '';
if (!f) return;
if (!String(f.name || '').toLowerCase().endsWith('.pdf')) {
fileError.value = '仅支持 PDF 文件';
return;
}
if (!f.size) {
fileError.value = '上传文件为空';
return;
}
if (f.size > MAX_PDF) {
fileError.value = 'PDF 超过 50MB 上限';
return;
}
file.value = f;
}
function onPick(e) {
acceptFile(e.target.files?.[0]);
e.target.value = ''; // 允许重复选择同名文件
}
function onDrop(e) {
dragging.value = false;
acceptFile(e.dataTransfer?.files?.[0]);
}
function removeFile() {
file.value = null;
}
async function submit() {
if (!file.value || loading.value) return;
const fd = new FormData();
fd.append('file', file.value); // 浏览器自动 multipart boundary(http.js 不设 Content-Type)
fd.append('target_journal', (edu.drafts.targetJournal || '').trim());
uploadPct.value = 0;
await edu.submitPresubmit(fd, {
onUploadProgress: (e) => {
uploadPct.value = e.pct;
},
});
}
/** 重新上传(结果区)/ 重新选择(错误区):清结果回上传区,保留 targetJournal 草稿 */
function reset() {
edu.clearReport();
file.value = null;
uploadPct.value = 0;
fileError.value = '';
}
</script>
<template>
<section class="pp">
<header class="pp-head">
<h1 class="pp-title">AI 预审稿系统</h1>
<span class="pp-sub">植物基因组学六维清单 · PDF ≤ 50MB</span>
</header>
<!-- ========== ① 有结果 → 结果区(最高优先级,不退回上传区) ========== -->
<template v-if="hasReport">
<div v-if="meta" class="pp-meta">
📄 {{ meta.filename }}
<template v-if="meta.target_journal"> · 目标期刊 {{ meta.target_journal }}</template>
· {{ meta.text_chars }} 字符
<span v-if="meta.truncated" class="pp-truncated">· 正文超限已截断至 6 万字符</span>
</div>
<div class="pp-topline">
<span class="pp-rec" :class="rec.cls">{{ rec.label }}</span>
<button class="pp-again" type="button" @click="reset">重新上传</button>
</div>
<p class="pp-summary">{{ report.summary }}</p>
<!-- 主要问题(重视觉) -->
<h2 class="pp-h2">主要问题({{ report.major_weaknesses.length }})</h2>
<article
v-for="(it, i) in report.major_weaknesses"
:key="'maj-' + i"
class="pp-card major"
>
<div class="pp-card-title">
<span class="pp-idx">{{ i + 1 }}</span>{{ it.title }}
</div>
<p class="pp-detail">{{ it.detail }}</p>
<div v-if="it.evidence" class="pp-evidence">
<span class="pp-tag">证据</span><code class="pp-evi-text">{{ it.evidence }}</code>
</div>
<div v-if="it.suggestion" class="pp-suggestion">
<span class="pp-tag">建议</span>{{ it.suggestion }}
</div>
</article>
<p v-if="!report.major_weaknesses.length" class="pp-empty">无主要问题</p>
<!-- 次要问题(轻视觉) -->
<h2 class="pp-h2">次要问题({{ report.minor_weaknesses.length }})</h2>
<article
v-for="(it, i) in report.minor_weaknesses"
:key="'min-' + i"
class="pp-card minor"
>
<div class="pp-card-title">
<span class="pp-idx light">{{ i + 1 }}</span>{{ it.title }}
</div>
<p class="pp-detail light">{{ it.detail }}</p>
<div v-if="it.evidence" class="pp-evidence light">
<span class="pp-tag">证据</span><code class="pp-evi-text">{{ it.evidence }}</code>
</div>
<div v-if="it.suggestion" class="pp-suggestion light">
<span class="pp-tag">建议</span>{{ it.suggestion }}
</div>
</article>
<p v-if="!report.minor_weaknesses.length" class="pp-empty">无次要问题</p>
<!-- 排版与规范(默认折叠) -->
<details class="pp-details">
<summary>排版与规范({{ report.presentation_format.length }})</summary>
<article
v-for="(it, i) in report.presentation_format"
:key="'pre-' + i"
class="pp-card minor"
>
<div class="pp-card-title">
<span class="pp-idx light">{{ i + 1 }}</span>{{ it.title }}
</div>
<p class="pp-detail light">{{ it.detail }}</p>
<div v-if="it.evidence" class="pp-evidence light">
<span class="pp-tag">证据</span><code class="pp-evi-text">{{ it.evidence }}</code>
</div>
<div v-if="it.suggestion" class="pp-suggestion light">
<span class="pp-tag">建议</span>{{ it.suggestion }}
</div>
</article>
<p v-if="!report.presentation_format.length" class="pp-empty">无排版类意见</p>
</details>
<!-- 给作者的问题 -->
<h2 class="pp-h2">给作者的问题({{ report.questions_for_authors.length }})</h2>
<ol class="pp-questions">
<li v-for="(q, i) in report.questions_for_authors" :key="'q-' + i">{{ q }}</li>
</ol>
<p v-if="!report.questions_for_authors.length" class="pp-empty">无</p>
</template>
<!-- ========== ② loading:上传进度 / 审稿长等待 ========== -->
<div v-else-if="loading" class="pp-state" aria-busy="true">
<div class="pp-bar">
<div class="pp-bar-fill" :style="{ width: Math.round(uploadPct * 100) + '%' }"></div>
</div>
<div class="pp-skel"></div>
<div class="pp-skel pp-skel-mid"></div>
<p class="pp-state-msg">{{ phaseText }}</p>
</div>
<!-- ========== ③ error ========== -->
<div v-else-if="errored" class="pp-state" role="alert">
<p class="pp-state-msg pp-state-error">{{ edu.reportError }}</p>
<div class="pp-err-btns">
<button v-if="file" class="pp-retry" type="button" @click="submit">重试</button>
<button class="pp-retry" type="button" @click="reset">重新选择文件</button>
</div>
</div>
<!-- ========== ④ 上传区(report 为 null 时才显示) ========== -->
<div v-else class="pp-upload">
<div
class="pp-drop"
:class="{ dragging, filled: !!file }"
role="button"
tabindex="0"
@click="fileInput.click()"
@keydown.enter="fileInput.click()"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
>
<input
ref="fileInput"
class="pp-file-input"
type="file"
accept=".pdf,application/pdf"
@click.stop
@change="onPick"
/>
<template v-if="file">
<span class="pp-file-name">📄 {{ file.name }} · {{ fmtSize(file.size) }}</span>
<button class="pp-remove" type="button" title="移除文件" @click.stop="removeFile">×</button>
</template>
<template v-else>
<span class="pp-drop-main">拖拽 PDF 到此处,或点击选择</span>
<span class="pp-hint">仅支持 PDF · ≤ 50MB</span>
</template>
</div>
<p v-if="fileError" class="pp-file-error">{{ fileError }}</p>
<label class="pp-label" for="pp-tj">目标期刊(选填,影响格式类意见)</label>
<input
id="pp-tj"
v-model="edu.drafts.targetJournal"
class="pp-input"
type="text"
placeholder="如 The Plant Cell(留空则不限定)"
/>
<button class="pp-submit" type="button" :disabled="!file || loading" @click="submit">
{{ loading ? phaseText : '开始预审稿' }}
</button>
</div>
</section>
</template>
<style scoped>
.pp {
display: flex;
flex-direction: column;
gap: 12px;
}
.pp-head {
display: flex;
align-items: baseline;
gap: 10px;
}
.pp-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--pa-ink);
}
.pp-sub {
font-size: 12px;
color: var(--pa-ink-subtle);
}
.pp-meta {
font-size: 12px;
color: var(--pa-ink-muted);
}
.pp-truncated {
color: var(--pa-warning);
}
.pp-topline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.pp-rec {
display: inline-flex;
align-items: center;
padding: 3px 14px;
border: 1px solid currentColor;
border-radius: var(--pa-r-pill);
font-size: 13px;
font-weight: 700;
}
.pp-rec.ok { color: var(--pa-success); }
.pp-rec.minor { color: var(--pa-info); }
.pp-rec.major { color: var(--pa-warning); }
.pp-rec.reject { color: var(--pa-error); }
.pp-again {
padding: 5px 14px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
background: transparent;
color: var(--pa-ink-muted);
font-size: 12px;
cursor: pointer;
}
.pp-again:hover {
color: var(--pa-ink);
background: var(--pa-surface-2);
}
.pp-summary {
margin: 0;
padding: 12px 14px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
font-size: 13.5px;
line-height: 1.7;
color: var(--pa-ink);
}
.pp-h2 {
margin: 8px 0 0;
font-size: 14px;
font-weight: 600;
color: var(--pa-ink);
}
.pp-card {
display: flex;
flex-direction: column;
gap: 7px;
padding: 12px 14px;
border-radius: var(--pa-r-md);
}
.pp-card.major {
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline-strong);
}
.pp-card.minor {
background: transparent;
border: 1px solid var(--pa-hairline);
}
.pp-card-title {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 13.5px;
font-weight: 600;
color: var(--pa-ink);
line-height: 1.5;
}
.pp-idx {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--pa-surface-3);
color: var(--pa-ink);
font-family: var(--pa-font-mono);
font-size: 11px;
margin-top: 1px;
}
.pp-idx.light {
background: transparent;
border: 1px solid var(--pa-hairline);
color: var(--pa-ink-muted);
}
.pp-detail {
margin: 0;
font-size: 13px;
line-height: 1.65;
color: var(--pa-ink);
}
.pp-detail.light {
color: var(--pa-ink-muted);
}
.pp-evidence {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 10px;
background: var(--pa-code-bg);
border-radius: var(--pa-r-sm);
}
.pp-evidence.light {
background: transparent;
border: 1px dashed var(--pa-hairline);
}
.pp-evi-text {
font-family: var(--pa-font-mono);
font-size: 12.5px;
line-height: 1.6;
color: var(--pa-ink);
white-space: normal;
}
.pp-evidence.light .pp-evi-text {
color: var(--pa-ink-muted);
}
.pp-suggestion {
font-size: 13px;
line-height: 1.6;
color: var(--pa-ink);
}
.pp-suggestion.light {
color: var(--pa-ink-muted);
}
.pp-tag {
flex: none;
display: inline-block;
margin-right: 8px;
padding: 1px 7px;
border-radius: var(--pa-r-sm);
background: var(--pa-surface-3);
font-size: 11px;
color: var(--pa-ink-muted);
vertical-align: 1px;
}
.pp-empty {
margin: 0;
font-size: 12.5px;
color: var(--pa-ink-subtle);
}
.pp-details {
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
padding: 8px 14px;
}
.pp-details summary {
cursor: pointer;
font-size: 13.5px;
font-weight: 600;
color: var(--pa-ink-muted);
}
.pp-details summary:hover {
color: var(--pa-ink);
}
.pp-details[open] summary {
margin-bottom: 10px;
color: var(--pa-ink);
}
.pp-questions {
margin: 0;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
line-height: 1.65;
color: var(--pa-ink);
}
.pp-state {
display: flex;
flex-direction: column;
gap: 10px;
padding: 6px 0;
}
.pp-bar {
height: 6px;
border-radius: var(--pa-r-pill);
background: var(--pa-surface-2);
overflow: hidden;
}
.pp-bar-fill {
height: 100%;
background: var(--pa-g-from);
transition: width .2s var(--pa-ease);
}
.pp-skel {
height: 56px;
border-radius: var(--pa-r-md);
background: var(--pa-surface-2);
animation: pp-pulse 1.2s var(--pa-ease) infinite alternate;
}
.pp-skel-mid {
height: 88px;
}
@keyframes pp-pulse {
from { opacity: .55; }
to { opacity: 1; }
}
.pp-state-msg {
margin: 0;
font-size: 13px;
color: var(--pa-ink-muted);
}
.pp-state-error {
color: var(--pa-error);
}
.pp-err-btns {
display: flex;
gap: 10px;
}
.pp-retry {
padding: 5px 14px;
border: none;
border-radius: var(--pa-r-md);
background: var(--pa-surface-2);
color: var(--pa-ink);
font-size: 12px;
cursor: pointer;
}
.pp-retry:hover {
background: var(--pa-surface-3);
}
.pp-upload {
display: flex;
flex-direction: column;
gap: 10px;
}
.pp-drop {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 130px;
padding: 18px;
border: 1.5px dashed var(--pa-hairline-strong);
border-radius: var(--pa-r-lg);
background: var(--pa-surface-1);
cursor: pointer;
transition: border-color .15s var(--pa-ease), background .15s var(--pa-ease);
}
.pp-drop:hover,
.pp-drop.dragging {
border-color: var(--pa-g-from);
background: var(--pa-surface-2);
}
.pp-drop.filled {
border-style: solid;
}
.pp-file-input {
display: none;
}
.pp-drop-main {
font-size: 13.5px;
color: var(--pa-ink);
}
.pp-hint {
font-size: 12px;
color: var(--pa-ink-subtle);
}
.pp-file-name {
font-size: 13.5px;
color: var(--pa-ink);
word-break: break-all;
}
.pp-remove {
position: absolute;
top: 8px;
right: 10px;
width: 22px;
height: 22px;
border: none;
border-radius: 50%;
background: var(--pa-surface-3);
color: var(--pa-ink-muted);
font-size: 15px;
line-height: 1;
cursor: pointer;
}
.pp-remove:hover {
color: var(--pa-ink);
}
.pp-file-error {
margin: 0;
font-size: 12.5px;
color: var(--pa-error);
}
.pp-label {
font-size: 12px;
font-weight: 500;
color: var(--pa-ink-muted);
}
.pp-input {
width: 320px;
max-width: 100%;
box-sizing: border-box;
padding: 7px 11px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
background: var(--pa-surface-2);
color: var(--pa-ink);
font-size: 13px;
}
.pp-input:focus {
outline: none;
border-color: var(--pa-g-from);
}
.pp-submit {
align-self: flex-start;
padding: 8px 22px;
border: none;
border-radius: var(--pa-r-md);
background: var(--pa-gradient);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.pp-submit:disabled {
opacity: .45;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,507 @@
<!-- RecommendPanel.vue —— ② 智能投稿推荐(步骤7,产品核心功能)。
链路:表单草稿 → edu.submitRecommend(payload) → POST /api/review/recommend(JSON)
→ {ok, tiers{challenge,fit,safe}{label,items}, meta{vector_ok,llm_ok,...}}
(真实响应结构已于写入前 curl 8100 核对,按真实字段渲染)。
纯文本粘贴版(PDF 上传后续迭代);目标期刊字段首版砍掉(后端无此入参)。
tier chips 为纯前端结果筛选(不改 payload 语义);结果与草稿存 edu store,
切菜单往返不丢、mount 无请求。D2 同样只读。 -->
<script setup>
import { computed } from 'vue';
import { useEduStore } from '../../../stores/edu.js';
const edu = useEduStore();
const TIERS = ['challenge', 'fit', 'safe'];
const FILTERS = [
{ key: 'all', label: '全部' },
{ key: 'challenge', label: '挑战' },
{ key: 'fit', label: '适配' },
{ key: 'safe', label: '稳妥' },
];
const abstractLen = computed(() => edu.drafts.abstract.trim().length);
const abstractOk = computed(() => abstractLen.value >= 50);
const loading = computed(() => edu.recommendStatus === 'loading');
const errored = computed(() => edu.recommendStatus === 'error');
const result = computed(() =>
edu.recommend && edu.recommend.ok && edu.recommend.tiers ? edu.recommend : null
);
const meta = computed(() => result.value?.meta || null);
const visibleTiers = computed(() => {
if (!result.value) return [];
const f = edu.drafts.tierFilter;
return TIERS.filter((k) => f === 'all' || f === k);
});
const itemsOf = (key) => result.value?.tiers?.[key]?.items || [];
const labelOf = (key) => result.value?.tiers?.[key]?.label || key;
function setFilter(k) {
edu.drafts.tierFilter = k;
}
/** prefs(同构草稿)→ preferences(接口字段):null 数值剔除,tier_focus 恒 'all'(chips 纯前端筛选) */
function buildPayload() {
const { max_review_weeks: w, min_impact_factor: inf, tier_focus: focus } = edu.drafts.prefs;
const preferences = { tier_focus: focus || 'all' };
const num = (v) => (v === null || v === undefined || v === '' || Number.isNaN(Number(v)) ? null : Number(v));
const nw = num(w);
const ninf = num(inf);
// 本地钳制到接口 pydantic 范围(越界会触发 FastAPI 422 非信封响应)
if (nw !== null) preferences.max_review_weeks = Math.min(104, Math.max(1, nw));
if (ninf !== null) preferences.min_impact_factor = Math.min(200, Math.max(0, ninf));
return { abstract: edu.drafts.abstract.trim(), preferences };
}
async function submit() {
if (!abstractOk.value || loading.value) return;
await edu.submitRecommend(buildPayload());
}
const fmt = (v) => (v === null || v === undefined ? '—' : v);
const riskClass = (note) => {
if (!note || note === '无') return 'none';
return String(note).includes('⚠') ? 'warn' : '';
};
</script>
<template>
<section class="rp">
<header class="rp-head">
<h1 class="rp-title">智能投稿推荐</h1>
<span class="rp-sub">向量匹配期刊范围 + LLM 三档重排</span>
</header>
<!-- ========== 表单区(草稿存 edu.drafts,切菜单不丢) ========== -->
<div class="rp-form">
<label class="rp-label" for="rp-abstract">
论文摘要 <span class="rp-req">必填</span>
</label>
<textarea
id="rp-abstract"
v-model="edu.drafts.abstract"
class="rp-textarea"
rows="7"
maxlength="20000"
placeholder="粘贴论文摘要(≥50 字符),或粘贴全文前 500 字"
></textarea>
<div class="rp-count" :class="{ ok: abstractOk }">
{{ edu.drafts.abstract.trim().length }} / 20000 字符
<span v-if="!abstractOk">· 至少需要 50 字符</span>
</div>
<div class="rp-prefs">
<div class="rp-field">
<label class="rp-label" for="rp-weeks">最大审稿周期(周,选填)</label>
<input
id="rp-weeks"
v-model.number="edu.drafts.prefs.max_review_weeks"
class="rp-input"
type="number"
min="1"
max="104"
placeholder="如 8"
/>
</div>
<div class="rp-field">
<label class="rp-label" for="rp-if">最低影响因子(选填)</label>
<input
id="rp-if"
v-model.number="edu.drafts.prefs.min_impact_factor"
class="rp-input"
type="number"
min="0"
max="200"
step="0.1"
placeholder="如 3"
/>
</div>
</div>
<button
class="rp-submit"
type="button"
:disabled="!abstractOk || loading"
@click="submit"
>
{{ loading ? '推荐生成中…(约 10–30 秒)' : '开始推荐' }}
</button>
</div>
<!-- ========== 状态区 ========== -->
<div v-if="loading" class="rp-state" aria-busy="true">
<div class="rp-skel"></div>
<div class="rp-skel rp-skel-mid"></div>
<div class="rp-skel"></div>
<p class="rp-state-msg">正在进行向量匹配与 LLM 重排,请稍候…</p>
</div>
<div v-else-if="errored" class="rp-state" role="alert">
<p class="rp-state-msg rp-state-error">{{ edu.recommendError }}</p>
<button class="rp-retry" type="button" @click="submit">重试</button>
</div>
<div v-else-if="!result" class="rp-state">
<p class="rp-state-msg">粘贴摘要后点击「开始推荐」:从期刊库语义匹配候选,按挑战/适配/稳妥三档给出匹配理由与风险提示。</p>
</div>
<!-- ========== 结果区 ========== -->
<template v-else>
<!-- 降级横幅(meta 标志,非报错红) -->
<div v-if="meta && (!meta.vector_ok || !meta.llm_ok)" class="rp-degraded">
{{ !meta.llm_ok ? 'LLM 不可用:以下为影响因子/周期规则分档' : '向量匹配降级:按全量期刊范围重排' }}
</div>
<!-- 结果元信息 -->
<div v-if="meta" class="rp-meta">
候选 {{ meta.candidate_count }} / 种子 {{ meta.seed_count }} 本
· 幻觉丢弃 {{ meta.dropped_hallucinated }} · 规则补位 {{ meta.repaired }}
</div>
<!-- 档位 chips(纯前端筛选,不重查) -->
<div class="rp-chips" role="tablist" aria-label="档位筛选">
<button
v-for="f in FILTERS"
:key="f.key"
class="rp-chip"
:class="{ on: edu.drafts.tierFilter === f.key }"
type="button"
role="tab"
:aria-selected="edu.drafts.tierFilter === f.key"
@click="setFilter(f.key)"
>
{{ f.label }}
</button>
</div>
<section v-for="key in visibleTiers" :key="key" class="rp-tier">
<h2 class="rp-tier-head">
<span class="rp-dot" :class="key" aria-hidden="true"></span>
{{ labelOf(key) }}
<span class="rp-tier-count">{{ itemsOf(key).length }} 本</span>
</h2>
<p v-if="!itemsOf(key).length" class="rp-empty">本档暂无期刊</p>
<article v-for="it in itemsOf(key)" :key="it.id" class="rp-card">
<header class="rp-card-head">
<span class="rp-card-name">{{ it.name }}</span>
<span class="rp-card-metrics">
<span class="rp-metric">IF {{ fmt(it.impact_factor) }}</span>
<span class="rp-metric">约 {{ fmt(it.review_time_weeks) }} 周</span>
<a
v-if="it.official_url"
class="rp-link"
:href="it.official_url"
target="_blank"
rel="noopener"
>官网 ↗</a>
</span>
</header>
<p v-if="it.similarity !== null && it.similarity !== undefined" class="rp-sim">
语义匹配 {{ Number(it.similarity).toFixed(2) }}
</p>
<p class="rp-reason">
<span class="rp-k">匹配理由</span>{{ it.match_reason }}
</p>
<p class="rp-risk" :class="riskClass(it.risk_note)">
<span class="rp-k">风险</span>{{ it.risk_note }}
</p>
</article>
</section>
</template>
</section>
</template>
<style scoped>
.rp {
display: flex;
flex-direction: column;
gap: 14px;
}
.rp-head {
display: flex;
align-items: baseline;
gap: 10px;
}
.rp-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--pa-ink);
}
.rp-sub {
font-size: 12px;
color: var(--pa-ink-subtle);
}
.rp-form {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 16px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
}
.rp-label {
font-size: 12px;
font-weight: 500;
color: var(--pa-ink-muted);
}
.rp-req {
margin-left: 4px;
font-size: 11px;
color: var(--pa-warning);
}
.rp-textarea {
width: 100%;
box-sizing: border-box;
resize: vertical;
min-height: 120px;
padding: 10px 12px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
background: var(--pa-surface-2);
color: var(--pa-ink);
font-family: var(--pa-font-ui);
font-size: 13px;
line-height: 1.55;
}
.rp-textarea:focus {
outline: none;
border-color: var(--pa-g-from);
}
.rp-count {
font-size: 11.5px;
color: var(--pa-ink-subtle);
text-align: right;
}
.rp-count.ok {
color: var(--pa-success);
}
.rp-prefs {
display: flex;
gap: 14px;
flex-wrap: wrap;
}
.rp-field {
display: flex;
flex-direction: column;
gap: 5px;
}
.rp-input {
width: 150px;
box-sizing: border-box;
padding: 6px 10px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
background: var(--pa-surface-2);
color: var(--pa-ink);
font-family: var(--pa-font-mono);
font-size: 13px;
}
.rp-input:focus {
outline: none;
border-color: var(--pa-g-from);
}
.rp-submit {
align-self: flex-start;
padding: 8px 22px;
border: none;
border-radius: var(--pa-r-md);
background: var(--pa-gradient);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity .15s var(--pa-ease);
}
.rp-submit:disabled {
opacity: .45;
cursor: not-allowed;
}
.rp-state {
display: flex;
flex-direction: column;
gap: 8px;
padding: 6px 0;
}
.rp-skel {
height: 56px;
border-radius: var(--pa-r-md);
background: var(--pa-surface-2);
animation: rp-pulse 1.2s var(--pa-ease) infinite alternate;
}
.rp-skel-mid {
height: 88px;
}
@keyframes rp-pulse {
from { opacity: .55; }
to { opacity: 1; }
}
.rp-state-msg {
margin: 0;
font-size: 13px;
line-height: 1.6;
color: var(--pa-ink-muted);
}
.rp-state-error {
color: var(--pa-error);
}
.rp-retry {
align-self: flex-start;
padding: 5px 14px;
border: none;
border-radius: var(--pa-r-md);
background: var(--pa-surface-2);
color: var(--pa-ink);
font-size: 12px;
cursor: pointer;
}
.rp-retry:hover {
background: var(--pa-surface-3);
}
.rp-degraded {
padding: 8px 12px;
border-radius: var(--pa-r-md);
background: var(--pa-surface-2);
border-left: 3px solid var(--pa-warning);
font-size: 12.5px;
color: var(--pa-warning);
}
.rp-meta {
font-size: 11.5px;
color: var(--pa-ink-subtle);
}
.rp-chips {
display: flex;
gap: 8px;
}
.rp-chip {
padding: 4px 14px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-pill);
background: transparent;
color: var(--pa-ink-muted);
font-size: 12px;
cursor: pointer;
transition: color .15s var(--pa-ease), background .15s var(--pa-ease);
}
.rp-chip:hover {
color: var(--pa-ink);
}
.rp-chip.on {
color: #fff;
background: var(--pa-g-from);
border-color: transparent;
font-weight: 600;
}
.rp-tier {
display: flex;
flex-direction: column;
gap: 10px;
}
.rp-tier-head {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--pa-ink);
}
.rp-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex: none;
}
.rp-dot.challenge { background: var(--pa-warning); }
.rp-dot.fit { background: var(--pa-info); }
.rp-dot.safe { background: var(--pa-success); }
.rp-tier-count {
font-size: 11.5px;
font-weight: 400;
color: var(--pa-ink-subtle);
}
.rp-empty {
margin: 0;
font-size: 12.5px;
color: var(--pa-ink-subtle);
}
.rp-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 14px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
transition: background .15s var(--pa-ease);
}
.rp-card:hover {
background: var(--pa-surface-2);
}
.rp-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.rp-card-name {
font-size: 14px;
font-weight: 600;
color: var(--pa-ink);
}
.rp-card-metrics {
display: flex;
align-items: center;
gap: 10px;
}
.rp-metric {
font-family: var(--pa-font-mono);
font-size: 12px;
color: var(--pa-ink-muted);
}
.rp-link {
font-size: 12px;
color: var(--pa-g-from);
text-decoration: none;
}
.rp-link:hover {
text-decoration: underline;
}
.rp-sim {
margin: 0;
font-size: 11.5px;
color: var(--pa-ink-subtle);
}
.rp-reason,
.rp-risk {
margin: 0;
font-size: 13px;
line-height: 1.6;
}
.rp-reason {
color: var(--pa-ink);
}
.rp-risk {
color: var(--pa-ink-muted);
}
.rp-risk.warn {
color: var(--pa-warning);
}
.rp-risk.none {
color: var(--pa-ink-subtle);
}
.rp-k {
display: inline-block;
margin-right: 8px;
padding: 1px 7px;
border-radius: var(--pa-r-sm);
background: var(--pa-surface-3);
font-size: 11px;
color: var(--pa-ink-muted);
vertical-align: 1px;
}
</style>
@@ -0,0 +1,812 @@
<!-- ReformatPanel.vue —— ⑤' 论文自动重排版 P1(.tex 源码级规则变换,不编译)。
链路:本地 .tex(≤2MB,仅 .tex——docx/pdf 只走 ⑤ 审核不重排)+ 目标期刊 →
edu.submitReformat → POST /api/review/reformat(纯规则,同步秒回)
→ {ok, job_id, modified_tex, changes[{line,original,modified,reason,
rule_key,action}], warnings[结构级], meta{journal_*,rules*,
filename,original_sha256,engine,generated_at}}
目标下拉分组:通用规则(首项默认 default,APA+通用规范)/ 精选期刊 /
期刊目录(选 __dir_* 进搜索模式,getDirectory 300ms 防抖本地态,
选定后 target = 目录 id,后端经 journal_directory 回链)。
结果三 Tab:修改后源码(Blob 下载)/ Diff 报告(按 rule_key 着色、
按 action 计数:modified=已修改、warning=建议·橙色·不计入)/
编译 PDF(P3 决策暂缓:仅「开发中」占位,不触发编译;后端 tectonic
API(P3a)保留备用,前端不暴露编译入口,引导下载 .tex 去 Overleaf/
本地编译)。
结果跨菜单保留(store reformatResult);样式 rf- 前缀,零新增依赖。 -->
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
import { getDirectory, reviewErrorMessage } from '../../../api/review.js';
import { useEduStore } from '../../../stores/edu.js';
const edu = useEduStore();
const MAX_SIZE = 2 * 1024 * 1024; // 与后端 REVIEW_MAX_TEX_BYTES 一致
const RULE_LABEL = { font_pt: '字号', margin: '页边距', line_spacing: '行距', bib_style: '参考文献' };
const RULE_MOD = { font_pt: 'k1', margin: 'k2', line_spacing: 'k3', bib_style: 'k4' };
const SOURCE_LABEL = {
default: '通用规则', official: '期刊专属规则', fetched: '官网抓取规则', cache: '缓存规则',
};
const fileInput = ref(null);
const file = ref(null);
const fileError = ref('');
const dragging = ref(false);
const tab = ref('source'); // 'source' | 'diff' | 'pdf'
/* —— 目标期刊(drafts.reformatTarget 持久:'default' | 精选 id | '__dir_en__'
过渡态 | 'dir:<id>' 目录已选)—— */
const targetChoice = ref(edu.drafts.reformatTarget || 'default');
const dirMode = computed(() => {
const t = targetChoice.value;
if (t === '__dir_en__') return 'en';
if (t === '__dir_zh__') return 'zh';
return '';
});
const actualTarget = computed(() => {
const t = String(targetChoice.value || '');
if (t.startsWith('dir:')) return t.slice(4);
if (t.startsWith('__dir')) return ''; // 搜索中未选定 → 不可提交
return t;
});
const targetReady = computed(() => !!actualTarget.value);
const dirPick = computed(() => {
const t = targetChoice.value;
const pick = edu.drafts.reformatDirPick;
if (t.startsWith('dir:') && pick && `dir:${pick.id}` === t) return pick;
return null;
});
/* —— 目录搜索(本地态,不占用 store.directory ——那是 ① 目录页的数据)—— */
const dirQuery = ref('');
const dirResults = ref([]);
const dirLoading = ref(false);
const dirError = ref('');
let dirTimer = null;
let dirSeq = 0;
watch(targetChoice, (v) => {
edu.drafts.reformatTarget = v;
if (!v.startsWith('__dir')) {
dirQuery.value = '';
dirResults.value = [];
dirError.value = '';
}
});
async function runDirSearch() {
const q = dirQuery.value.trim();
if (!dirMode.value || !q) {
dirResults.value = [];
return;
}
const seq = ++dirSeq;
dirLoading.value = true;
dirError.value = '';
try {
const payload = await getDirectory({ q, lang: dirMode.value, size: 20 });
if (seq !== dirSeq) return;
dirResults.value = (payload && payload.items) || [];
} catch (err) {
if (seq !== dirSeq) return;
dirError.value = reviewErrorMessage(err);
} finally {
if (seq === dirSeq) dirLoading.value = false;
}
}
watch(dirQuery, () => {
clearTimeout(dirTimer);
dirTimer = setTimeout(runDirSearch, 300);
});
function pickDir(item) {
targetChoice.value = `dir:${item.id}`;
edu.drafts.reformatDirPick = { id: item.id, name: item.name };
dirQuery.value = '';
dirResults.value = [];
}
/* —— 结果视图 —— */
const hasResult = computed(() => !!(edu.reformatResult && edu.reformatResult.ok));
const loading = computed(() => edu.reformatStatus === 'loading');
const errored = computed(() => edu.reformatStatus === 'error');
const result = computed(() => edu.reformatResult || null);
const meta = computed(() => result.value?.meta || null);
const changes = computed(() => result.value?.changes || []);
const structWarnings = computed(() => result.value?.warnings || []);
const modifiedCount = computed(() => changes.value.filter((c) => c.action === 'modified').length);
const suggestCount = computed(() => changes.value.filter((c) => c.action === 'warning').length);
const sourceLabel = computed(() => SOURCE_LABEL[meta.value?.rules_source] || meta.value?.rules_source || '—');
onMounted(() => {
if (edu.journalsStatus === 'idle') edu.fetchJournals(); // 精选期刊下拉数据源
// 恢复的 target 是目录刊但无选中记录(历史草稿)→ 回落通用规则
if (targetChoice.value.startsWith('dir:') && !dirPick.value) {
targetChoice.value = 'default';
}
});
/* —— 文件(仅 .tex,拍板 5)—— */
function acceptFile(f) {
fileError.value = '';
if (!f) return;
if (!String(f.name || '').toLowerCase().endsWith('.tex')) {
fileError.value = '仅支持 LaTeX 源码(.tex);docx/pdf 请使用「论文排版与规范」审核';
return;
}
if (!f.size) {
fileError.value = '上传文件为空';
return;
}
if (f.size > MAX_SIZE) {
fileError.value = '源码超过 2MB 上限';
return;
}
file.value = f;
}
function onPick(e) {
acceptFile(e.target.files?.[0]);
e.target.value = '';
}
function onDrop(e) {
dragging.value = false;
acceptFile(e.dataTransfer?.files?.[0]);
}
function removeFile() {
file.value = null;
}
async function submit() {
if (!file.value || loading.value || !targetReady.value) return;
const fd = new FormData();
fd.append('file', file.value);
fd.append('target_journal', actualTarget.value);
tab.value = 'source';
await edu.submitReformat(fd);
}
function reset() {
edu.clearReformat();
file.value = null;
fileError.value = '';
tab.value = 'source';
}
function download() {
const tex = result.value?.modified_tex;
if (tex == null) return;
const base = String(meta.value?.filename || 'paper').replace(/\.tex$/i, '');
const url = URL.createObjectURL(new Blob([tex], { type: 'application/x-tex;charset=utf-8' }));
const a = document.createElement('a');
a.href = url;
a.download = `${base}.reformat.tex`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
</script>
<template>
<section class="rf">
<header class="rf-head">
<h1 class="rf-title">论文自动重排版</h1>
<span class="rf-sub">LaTeX (.tex) · 字号 · 页边距 · 行距 · 参考文献 · 纯规则不编译</span>
</header>
<!-- ========== ① 有结果 → 结果区 ========== -->
<template v-if="hasResult">
<div class="rf-meta">
📄 {{ meta?.filename || '—' }} · 目标:{{ meta?.journal_name || '—' }}({{ sourceLabel }})·
{{ meta?.engine }} · {{ meta?.generated_at || '' }}
<span class="rf-meta-sub"> · job {{ result.job_id }}</span>
</div>
<p v-if="meta?.rules_note" class="rf-note" role="note">{{ meta.rules_note }}</p>
<!-- 汇总:已修改 / 建议(橙,不计入修改)/ 结构提示 -->
<div class="rf-summary">
<span class="rf-count mod">{{ modifiedCount }} 处已修改</span>
<span class="rf-count sug">{{ suggestCount }} 条建议</span>
<span class="rf-count warn" v-if="structWarnings.length">{{ structWarnings.length }} 条结构提示</span>
<button class="rf-again" type="button" @click="reset">重新排版</button>
<button class="rf-dl" type="button" @click="download">下载修改后源码</button>
</div>
<article v-for="(w, i) in structWarnings" :key="'w' + i" class="rf-struct">
⚠ {{ w.message }}
</article>
<!-- Tab:修改后源码 | Diff 报告 | 编译 PDF(P3a) -->
<div class="rf-tabs" role="tablist">
<button class="rf-tab" :class="{ active: tab === 'source' }" type="button"
role="tab" @click="tab = 'source'">修改后源码</button>
<button class="rf-tab" :class="{ active: tab === 'diff' }" type="button"
role="tab" @click="tab = 'diff'">Diff 报告</button>
<button class="rf-tab" :class="{ active: tab === 'pdf' }" type="button"
role="tab" @click="tab = 'pdf'">编译 PDF</button>
</div>
<pre v-if="tab === 'source'" class="rf-src">{{ result.modified_tex }}</pre>
<div v-else-if="tab === 'diff'" class="rf-diff">
<p v-if="!changes.length" class="rf-empty">
✅ 未发现违反目标规则的可修改项——源码未被触碰(规则:{{ sourceLabel }})。
</p>
<article
v-for="(c, i) in changes"
:key="i"
class="rf-row"
:class="c.action"
>
<header class="rf-row-head">
<span class="rf-line">L{{ c.line }}</span>
<span class="rf-rule" :class="RULE_MOD[c.rule_key]">{{ RULE_LABEL[c.rule_key] || c.rule_key }}</span>
<span class="rf-act" :class="c.action">
{{ c.action === 'modified' ? '已修改' : '建议' }}
</span>
</header>
<div class="rf-pair">
<pre class="rf-old">{{ c.original }}</pre>
<pre v-if="c.action === 'modified'" class="rf-new">{{ c.modified }}</pre>
<pre v-else class="rf-skip">(未修改 — 缺少可安全修改的插入点)</pre>
</div>
<p class="rf-reason" :class="{ sug: c.action === 'warning' }">{{ c.reason }}</p>
</article>
</div>
<!-- 编译 PDF(P3 暂缓):「开发中」占位,不触发编译;后端 P3a API 保留备用 -->
<div v-else class="rf-compile">
<div class="rf-wip" role="note">
<p class="rf-wip-main">
PDF 编译功能开发中,暂时请下载修改后的 .tex 文件,在 Overleaf 或本地编译。
后续将支持多文件工程和图片上传。
</p>
<p class="rf-wip-sub">已提供逐行修改对照(Diff 报告)与修改后源码下载。</p>
<a
class="rf-wip-feedback"
href="http://192.168.0.175:3000/admin/PlantAI-rag-new/issues"
target="_blank"
rel="noopener noreferrer"
>反馈需求 →</a>
</div>
</div>
</template>
<!-- ========== ② loading ========== -->
<div v-else-if="loading" class="rf-state" aria-busy="true">
<div class="rf-skel"></div>
<p class="rf-state-msg">重排版中…(纯规则引擎,通常 &lt;1 秒)</p>
</div>
<!-- ========== ③ error ========== -->
<div v-else-if="errored" class="rf-state" role="alert">
<p class="rf-state-msg rf-state-error">{{ edu.reformatError }}</p>
<div class="rf-err-btns">
<button v-if="file" class="rf-retry" type="button" @click="submit">重试</button>
<button class="rf-retry" type="button" @click="reset">重新开始</button>
</div>
</div>
<!-- ========== ④ 上传区 ========== -->
<div v-else class="rf-upload">
<div
class="rf-drop"
:class="{ dragging, filled: !!file }"
role="button"
tabindex="0"
@click="fileInput.click()"
@keydown.enter="fileInput.click()"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
>
<input
ref="fileInput"
class="rf-file-input"
type="file"
accept=".tex"
@click.stop
@change="onPick"
/>
<template v-if="file">
<span class="rf-file-name">📄 {{ file.name }} · {{ (file.size / 1024).toFixed(1) }} KB</span>
<button class="rf-remove" type="button" title="移除文件" @click.stop="removeFile">×</button>
</template>
<template v-else>
<span class="rf-drop-main">拖拽 LaTeX 源码(.tex)到此处,或点击选择</span>
<span class="rf-hint">仅 .tex 单文件(多文件 \input 工程请先合并)· ≤ 2MB · UTF-8</span>
</template>
</div>
<p v-if="fileError" class="rf-file-error">{{ fileError }}</p>
<label class="rf-label" for="rf-target">目标期刊(必选,决定规则集)</label>
<select id="rf-target" v-model="targetChoice" class="rf-input rf-select" :disabled="loading">
<optgroup label="通用规则">
<option value="default">通用规则(APA + 通用论文规范)</option>
</optgroup>
<optgroup v-if="edu.journals.length" label="精选期刊">
<option v-for="j in edu.journals" :key="j.id" :value="j.id">{{ j.name }}</option>
</optgroup>
<optgroup label="期刊目录(1056 本)">
<option value="__dir_en__">国际目录 · 搜索选择…</option>
<option value="__dir_zh__">中文目录 · 搜索选择…</option>
<option v-if="dirPick" :value="`dir:${dirPick.id}`">{{ dirPick.name }}(已选定)</option>
</optgroup>
</select>
<!-- 目录搜索模式:输入 → 300ms 防抖 → 列表点选 -->
<div v-if="dirMode" class="rf-dirsearch">
<input
v-model="dirQuery"
class="rf-input"
type="search"
:placeholder="dirMode === 'en' ? '搜索国际目录期刊名 / ISSN…' : '搜索中文目录期刊名 / ISSN…'"
:disabled="loading"
/>
<p v-if="dirError" class="rf-file-error">{{ dirError }}</p>
<p v-if="dirLoading" class="rf-dirhint">搜索中…</p>
<p v-else-if="dirQuery.trim() && !dirResults.length && !dirError" class="rf-dirhint">无匹配期刊</p>
<ul v-if="dirResults.length" class="rf-dirlist">
<li v-for="it in dirResults" :key="it.id">
<button class="rf-diritem" type="button" @click="pickDir(it)">
<span class="rf-dirname">{{ it.name }}</span>
<span class="rf-dirmeta">
{{ it.issn ? 'ISSN ' + it.issn : '' }}
<template v-if="it.cas_partition"> · {{ it.cas_partition }}</template>
<template v-if="it.jif"> · JIF {{ it.jif }}</template>
</span>
</button>
</li>
</ul>
</div>
<p v-else-if="targetChoice.startsWith('dir:') && !dirPick" class="rf-dirhint">
⚠ 目录选中记录缺失,请重新搜索选择期刊
</p>
<button
class="rf-submit"
type="button"
:disabled="!file || !targetReady || loading"
@click="submit"
>
开始自动重排版
</button>
<p v-if="dirMode && !actualTarget" class="rf-dirhint">请从搜索结果中选择期刊后提交</p>
</div>
</section>
</template>
<style scoped>
.rf {
display: flex;
flex-direction: column;
gap: 12px;
}
.rf-head {
display: flex;
align-items: baseline;
gap: 10px;
}
.rf-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--pa-ink);
}
.rf-sub {
font-size: 12px;
color: var(--pa-ink-subtle);
}
.rf-meta {
font-size: 12.5px;
color: var(--pa-ink-muted);
}
.rf-meta-sub {
color: var(--pa-ink-subtle);
}
.rf-note {
margin: 0;
padding: 8px 12px;
border: 1px solid var(--pa-warning);
border-radius: var(--pa-r-md);
background: transparent;
color: var(--pa-warning);
font-size: 12.5px;
}
.rf-struct {
padding: 8px 12px;
border: 1px solid var(--pa-warning);
border-radius: var(--pa-r-md);
color: var(--pa-warning);
font-size: 12.5px;
}
/* 汇总条 */
.rf-summary {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.rf-count {
padding: 3px 12px;
border: 1px solid currentColor;
border-radius: var(--pa-r-pill);
font-size: 12.5px;
font-weight: 600;
}
.rf-count.mod { color: var(--pa-success); }
.rf-count.sug { color: var(--pa-warning); }
.rf-count.warn { color: var(--pa-warning); }
.rf-again,
.rf-dl {
padding: 5px 14px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
background: transparent;
color: var(--pa-ink-muted);
font-size: 12px;
cursor: pointer;
}
.rf-dl { margin-left: auto; }
.rf-again:hover,
.rf-dl:hover {
color: var(--pa-ink);
background: var(--pa-surface-2);
}
/* Tab */
.rf-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--pa-hairline);
}
.rf-tab {
padding: 7px 14px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--pa-ink-muted);
font-size: 13px;
cursor: pointer;
}
.rf-tab.active {
color: var(--pa-ink);
font-weight: 600;
border-bottom-color: var(--pa-g-from);
}
.rf-tab:disabled {
opacity: .45;
cursor: not-allowed;
}
.rf-src {
margin: 0;
padding: 14px 16px;
max-height: 60vh;
overflow: auto;
background: var(--pa-code-bg);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
font-family: var(--pa-font-mono);
font-size: 12.5px;
line-height: 1.65;
color: var(--pa-ink);
white-space: pre;
}
.rf-empty {
margin: 0;
font-size: 13px;
color: var(--pa-ink-muted);
}
/* Diff 行 */
.rf-diff {
display: flex;
flex-direction: column;
gap: 10px;
}
.rf-row {
padding: 10px 14px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-left-width: 3px;
border-radius: var(--pa-r-md);
}
.rf-row.modified { border-left-color: var(--pa-success); }
.rf-row.warning { border-left-color: var(--pa-warning); }
.rf-row-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.rf-line {
font-family: var(--pa-font-mono);
font-size: 12px;
color: var(--pa-ink-subtle);
}
.rf-rule {
padding: 1px 8px;
border: 1px solid currentColor;
border-radius: var(--pa-r-pill);
font-size: 11.5px;
font-weight: 600;
}
.rf-rule.k1 { color: var(--pa-g-from); } /* 字号 */
.rf-rule.k2 { color: var(--pa-success); } /* 页边距 */
.rf-rule.k3 { color: var(--pa-info, #5b8def); } /* 行距 */
.rf-rule.k4 { color: var(--pa-error); } /* 参考文献 */
.rf-act {
margin-left: auto;
padding: 1px 8px;
border-radius: var(--pa-r-pill);
font-size: 11.5px;
font-weight: 700;
}
.rf-act.modified {
color: var(--pa-success);
border: 1px solid var(--pa-success);
}
.rf-act.warning {
color: var(--pa-warning);
border: 1px solid var(--pa-warning);
}
.rf-pair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.rf-pair pre {
margin: 0;
padding: 8px 10px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-sm);
font-family: var(--pa-font-mono);
font-size: 12.5px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
overflow-x: auto;
}
.rf-old {
background: var(--pa-surface-2);
color: var(--pa-ink-muted);
}
.rf-old::before {
content: '− ';
color: var(--pa-error);
}
.rf-new {
background: var(--pa-surface-2);
color: var(--pa-ink);
}
.rf-new::before {
content: '+ ';
color: var(--pa-success);
}
.rf-skip {
background: transparent;
color: var(--pa-warning);
font-style: italic;
}
.rf-reason {
margin: 6px 0 0;
font-family: var(--pa-font-mono);
font-size: 12.5px;
line-height: 1.6;
color: var(--pa-ink-muted);
white-space: pre-wrap;
word-break: break-word;
}
.rf-reason.sug {
color: var(--pa-warning);
}
/* 编译 PDF(P3 暂缓:开发中占位) */
.rf-compile {
display: flex;
flex-direction: column;
gap: 10px;
}
.rf-wip {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
padding: 16px 18px;
border: 1px dashed var(--pa-hairline-strong);
border-radius: var(--pa-r-lg);
background: var(--pa-surface-1);
}
.rf-wip-main {
margin: 0;
font-size: 13.5px;
line-height: 1.7;
color: var(--pa-ink);
}
.rf-wip-sub {
margin: 0;
font-size: 12.5px;
color: var(--pa-ink-subtle);
}
.rf-wip-feedback {
font-size: 12.5px;
font-weight: 600;
color: var(--pa-g-from);
text-decoration: none;
}
.rf-wip-feedback:hover { text-decoration: underline; }
/* 状态区 */
.rf-state {
display: flex;
flex-direction: column;
gap: 10px;
}
.rf-skel {
height: 96px;
border-radius: var(--pa-r-md);
background: var(--pa-surface-2);
animation: rf-pulse 1.2s var(--pa-ease) infinite alternate;
}
@keyframes rf-pulse {
from { opacity: .55; }
to { opacity: 1; }
}
.rf-state-msg {
margin: 0;
font-size: 13px;
color: var(--pa-ink-muted);
}
.rf-state-error { color: var(--pa-error); }
.rf-err-btns { display: flex; gap: 10px; }
.rf-retry {
padding: 5px 14px;
border: none;
border-radius: var(--pa-r-md);
background: var(--pa-surface-2);
color: var(--pa-ink);
font-size: 12px;
cursor: pointer;
}
.rf-retry:hover { background: var(--pa-surface-3); }
/* 上传区 */
.rf-upload {
display: flex;
flex-direction: column;
gap: 10px;
}
.rf-drop {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 130px;
padding: 18px;
border: 1.5px dashed var(--pa-hairline-strong);
border-radius: var(--pa-r-lg);
background: var(--pa-surface-1);
cursor: pointer;
transition: border-color .15s var(--pa-ease), background .15s var(--pa-ease);
}
.rf-drop:hover,
.rf-drop.dragging {
border-color: var(--pa-g-from);
background: var(--pa-surface-2);
}
.rf-drop.filled { border-style: solid; }
.rf-file-input { display: none; }
.rf-drop-main {
font-size: 13.5px;
color: var(--pa-ink);
}
.rf-hint {
font-size: 12px;
color: var(--pa-ink-subtle);
}
.rf-file-name {
font-size: 13.5px;
color: var(--pa-ink);
word-break: break-all;
}
.rf-remove {
position: absolute;
top: 8px;
right: 10px;
width: 22px;
height: 22px;
border: none;
border-radius: 50%;
background: var(--pa-surface-3);
color: var(--pa-ink-muted);
font-size: 15px;
line-height: 1;
cursor: pointer;
}
.rf-remove:hover { color: var(--pa-ink); }
.rf-file-error {
margin: 0;
font-size: 12.5px;
color: var(--pa-error);
}
.rf-label {
font-size: 12px;
font-weight: 500;
color: var(--pa-ink-muted);
}
.rf-input {
width: 360px;
max-width: 100%;
box-sizing: border-box;
padding: 7px 11px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
background: var(--pa-surface-2);
color: var(--pa-ink);
font-size: 13px;
font-family: inherit;
}
.rf-input:focus {
outline: none;
border-color: var(--pa-g-from);
}
.rf-select:disabled { opacity: .6; }
.rf-submit {
align-self: flex-start;
padding: 8px 22px;
border: none;
border-radius: var(--pa-r-md);
background: var(--pa-gradient);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.rf-submit:disabled {
opacity: .45;
cursor: not-allowed;
}
/* 目录搜索 */
.rf-dirsearch {
display: flex;
flex-direction: column;
gap: 8px;
}
.rf-dirhint {
margin: 0;
font-size: 12.5px;
color: var(--pa-ink-subtle);
}
.rf-dirlist {
list-style: none;
margin: 0;
padding: 0;
max-height: 240px;
overflow: auto;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
background: var(--pa-surface-1);
}
.rf-diritem {
width: 100%;
display: flex;
align-items: baseline;
gap: 10px;
padding: 8px 12px;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
}
.rf-diritem:hover { background: var(--pa-surface-2); }
.rf-dirname {
font-size: 13px;
color: var(--pa-ink);
}
.rf-dirmeta {
font-size: 12px;
color: var(--pa-ink-subtle);
}
</style>
@@ -0,0 +1,420 @@
<!-- ReportPanel.vue —— ④ 专业审稿意见报告(读者视角,正式报告版式)。
与 PresubmitPanel(作者视角·修改工作台)共用同一份 edu.report
({ok, report{summary, major/minor_weaknesses[], presentation_format[],
questions_for_authors[], recommendation}, meta{filename,target_journal,text_chars,truncated,model}}),
生成时间取 edu.reportCreatedAt(store 盖章,payload 无时间字段)。
无报告 → 引导去 section='presubmit'。
工具栏:导出 PDF = window.print()(打印时 :global 隐藏 .edu-sidenav/.top-bar/.rp-toolbar);
复制 Markdown = clipboard API(安全上下文)→ execCommand 兜底(LAN http:// 非安全上下文)。
不动 presubmit 后端 / self_review.py / 六维 Prompt,纯前端只读展示。 -->
<script setup>
import { computed, onUnmounted, ref } from 'vue';
import { useEduStore } from '../../../stores/edu.js';
const edu = useEduStore();
const REC = {
accept: { label: '接收', cls: 'ok' },
minor_revision: { label: '小修', cls: 'minor' },
major_revision: { label: '大修', cls: 'major' },
reject: { label: '拒稿', cls: 'reject' },
};
const toast = ref(false);
let toastTimer = null;
const report = computed(() => edu.report?.report || null);
const meta = computed(() => edu.report?.meta || null);
const rec = computed(() => REC[report.value?.recommendation] || REC.major_revision);
const createdAtText = computed(() => {
const s = edu.reportCreatedAt;
return s ? String(s).slice(0, 16).replace('T', ' ') : '—';
});
/* 五节结构:1 总体评价(summary 单列)+ 2..5 列表节(questions 映射为 {title} 条目) */
const sections = computed(() => {
const r = report.value;
if (!r) return [];
return [
{ title: '主要问题', items: r.major_weaknesses || [] },
{ title: '次要问题', items: r.minor_weaknesses || [] },
{ title: '排版与规范', items: r.presentation_format || [] },
{
title: '给作者的问题',
items: (r.questions_for_authors || []).map((q) => ({ title: q, detail: '', evidence: '', suggestion: '' })),
},
];
});
function exportPdf() {
window.print();
}
function buildMarkdown() {
const r = report.value;
if (!r) return '';
const m = meta.value || {};
const lines = [
'# AI 预审稿报告',
'',
`- 文件:${m.filename || '—'}`,
`- 目标期刊:${m.target_journal || '未指定'}`,
`- 生成时间:${createdAtText.value}`,
`- 推荐决定:${rec.value.label}(${r.recommendation})`,
'',
'## 总体评价',
'',
r.summary || '无',
'',
];
const weakSec = (title, items) => {
lines.push(`## ${title}`, '');
if (!items || !items.length) {
lines.push('无', '');
return;
}
items.forEach((it, i) => {
lines.push(`${i + 1}. ${it.title}`, '');
if (it.detail) lines.push(it.detail, '');
if (it.evidence) lines.push(` - 证据:${it.evidence}`);
if (it.suggestion) lines.push(` - 建议:${it.suggestion}`);
lines.push('');
});
};
weakSec('主要问题', r.major_weaknesses);
weakSec('次要问题', r.minor_weaknesses);
weakSec('排版与规范', r.presentation_format);
lines.push('## 给作者的问题', '');
const qs = r.questions_for_authors || [];
if (!qs.length) lines.push('无', '');
else qs.forEach((q, i) => lines.push(`${i + 1}. ${q}`));
lines.push('', '---', `*正文 ${m.text_chars ?? '—'} 字符${m.truncated ? ' · 已截断' : ''}*`);
return lines.join('\n');
}
function showToast() {
toast.value = true;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => (toast.value = false), 1600);
}
async function copyMarkdown() {
const text = buildMarkdown();
if (!text) return;
let ok = false;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
ok = true;
}
} catch {
/* fallthrough → execCommand 兜底 */
}
if (!ok) {
// LAN http:// 非安全上下文 navigator.clipboard 不可用 → 隐藏 textarea + execCommand
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;top:-1000px;opacity:0';
document.body.appendChild(ta);
ta.select();
ok = document.execCommand('copy');
ta.remove();
} catch {
ok = false;
}
}
if (ok) showToast();
}
onUnmounted(() => clearTimeout(toastTimer));
</script>
<template>
<section class="rp">
<!-- ========== ① 无报告 → 引导 ========== -->
<div v-if="!edu.hasReport" class="rp-guide">
<div class="rp-guide-icon" aria-hidden="true">📝</div>
<p class="rp-guide-text">尚无审稿报告。先上传 PDF 运行 AI 预审稿,生成后在此查看正式报告。</p>
<button class="rp-guide-btn" type="button" @click="edu.setSection('presubmit')">
去预审稿 →
</button>
</div>
<!-- ========== ② 有报告 → 正式报告(读者视角) ========== -->
<template v-else>
<div class="rp-toolbar">
<button class="rp-tool" type="button" @click="exportPdf">📥 导出 PDF</button>
<button class="rp-tool" type="button" @click="copyMarkdown">📋 复制为 Markdown</button>
<transition name="rp-fade">
<span v-if="toast" class="rp-toast" role="status">已复制</span>
</transition>
</div>
<article class="rp-sheet">
<header class="rp-head">
<h1 class="rp-title">AI 预审稿报告</h1>
<div class="rp-headmeta">
<span class="rp-meta-line">
📄 {{ meta?.filename || '—' }}
<template v-if="meta?.target_journal"> · 目标期刊 {{ meta.target_journal }}</template>
· 生成于 {{ createdAtText }}
</span>
<span class="rp-rec" :class="rec.cls">{{ rec.label }} · {{ report.recommendation }}</span>
</div>
</header>
<!-- 1. 总体评价 -->
<section class="rp-sec">
<h2 class="rp-h2">1. 总体评价</h2>
<p class="rp-summary">{{ report.summary || '无' }}</p>
</section>
<!-- 2..5 列表节 -->
<section v-for="(s, si) in sections" :key="s.title" class="rp-sec">
<h2 class="rp-h2">{{ si + 2 }}. {{ s.title }}({{ s.items.length }})</h2>
<div v-for="(it, i) in s.items" :key="i" class="rp-item">
<p class="rp-item-title">{{ i + 1 }}. {{ it.title }}</p>
<p v-if="it.detail" class="rp-item-detail">{{ it.detail }}</p>
<p v-if="it.evidence" class="rp-item-evi"><b>证据:</b>{{ it.evidence }}</p>
<p v-if="it.suggestion" class="rp-item-sug"><b>建议:</b>{{ it.suggestion }}</p>
</div>
<p v-if="!s.items.length" class="rp-none">无</p>
</section>
<footer class="rp-foot">
正文 {{ meta?.text_chars ?? '—' }} 字符<template
v-if="meta?.truncated"
> · 已截断至 6 万字符</template
> · 报告生成于 {{ createdAtText }}
</footer>
</article>
</template>
</section>
</template>
<style scoped>
.rp {
display: flex;
flex-direction: column;
gap: 14px;
}
/* ---------- 引导空态 ---------- */
.rp-guide {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 56px 24px;
text-align: center;
border: 1px dashed var(--pa-hairline);
border-radius: var(--pa-r-lg);
}
.rp-guide-icon {
font-size: 30px;
}
.rp-guide-text {
margin: 0;
font-size: 13.5px;
line-height: 1.7;
color: var(--pa-ink-muted);
max-width: 420px;
}
.rp-guide-btn {
padding: 8px 22px;
border: none;
border-radius: var(--pa-r-md);
background: var(--pa-gradient);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.rp-guide-btn:hover {
filter: brightness(1.08);
}
/* ---------- 工具栏 ---------- */
.rp-toolbar {
position: relative;
display: flex;
gap: 10px;
}
.rp-tool {
padding: 7px 16px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
background: var(--pa-surface-1);
color: var(--pa-ink);
font-size: 12.5px;
cursor: pointer;
}
.rp-tool:hover {
background: var(--pa-surface-2);
border-color: var(--pa-hairline-strong);
}
.rp-toast {
position: absolute;
left: 50%;
top: -8px;
transform: translate(-50%, -100%);
padding: 5px 14px;
border-radius: var(--pa-r-pill);
background: var(--pa-raised);
border: 1px solid var(--pa-success);
color: var(--pa-success);
font-size: 12px;
pointer-events: none;
}
.rp-fade-enter-active,
.rp-fade-leave-active {
transition: opacity .2s var(--pa-ease);
}
.rp-fade-enter-from,
.rp-fade-leave-to {
opacity: 0;
}
/* ---------- 纸面版式:等宽正式报告,左右留白宽、行距松 ---------- */
.rp-sheet {
max-width: 760px;
width: 100%;
margin: 0 auto;
padding: 40px 56px;
box-sizing: border-box;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
font-family: var(--pa-font-mono);
color: var(--pa-ink);
line-height: 1.85;
}
.rp-head {
padding-bottom: 16px;
border-bottom: 2px solid var(--pa-hairline-strong);
}
.rp-title {
margin: 0 0 10px;
font-family: var(--pa-font-mono);
font-size: 19px;
font-weight: 700;
letter-spacing: .04em;
}
.rp-headmeta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.rp-meta-line {
font-size: 12px;
color: var(--pa-ink-muted);
}
.rp-rec {
display: inline-flex;
padding: 3px 14px;
border: 1px solid currentColor;
border-radius: var(--pa-r-pill);
font-size: 12.5px;
font-weight: 700;
white-space: nowrap;
}
.rp-rec.ok { color: var(--pa-success); }
.rp-rec.minor { color: var(--pa-info); }
.rp-rec.major { color: var(--pa-warning); }
.rp-rec.reject { color: var(--pa-error); }
.rp-sec {
margin-top: 26px;
}
.rp-h2 {
margin: 0 0 12px;
font-family: var(--pa-font-mono);
font-size: 14.5px;
font-weight: 700;
color: var(--pa-ink);
letter-spacing: .03em;
}
.rp-summary {
margin: 0;
font-size: 13.5px;
line-height: 1.95;
color: var(--pa-ink);
}
.rp-item {
padding: 13px 0;
border-bottom: 1px solid var(--pa-hairline);
}
.rp-item:last-child {
border-bottom: none;
}
.rp-item-title {
margin: 0 0 6px;
font-size: 13.5px;
font-weight: 700;
line-height: 1.7;
color: var(--pa-ink);
}
.rp-item-detail {
margin: 0;
font-size: 13px;
line-height: 1.9;
color: var(--pa-ink);
}
.rp-item-evi {
margin: 9px 0 0;
padding-left: 13px;
border-left: 3px solid var(--pa-hairline-strong);
font-size: 12.5px;
line-height: 1.8;
color: var(--pa-ink-muted);
}
.rp-item-sug {
margin: 6px 0 0;
font-size: 12.5px;
line-height: 1.8;
color: var(--pa-ink-muted);
}
.rp-item-evi b,
.rp-item-sug b {
color: var(--pa-ink);
font-weight: 700;
}
.rp-none {
margin: 0;
font-size: 13px;
color: var(--pa-ink-subtle);
}
.rp-foot {
margin-top: 30px;
padding-top: 14px;
border-top: 1px dashed var(--pa-hairline);
font-family: var(--pa-font-mono);
font-size: 11.5px;
color: var(--pa-ink-subtle);
}
/* ---------- 打印(导出 PDF = window.print):只留报告纸面 ----------
注:打印态下 #fff/#000/#bbb 为硬编码(仅 @media print 生效)——
屏幕用暗色主题变量直接打印会输出大面积黑底,故打印强制白底黑字。 */
@media print {
:global(.edu-sidenav),
:global(.top-bar),
.rp-toolbar {
display: none !important;
}
.rp-sheet {
max-width: none;
padding: 0;
border: none;
background: #fff;
}
.rp-sheet * {
color: #000 !important;
background: transparent !important;
border-color: #bbb !important;
}
}
</style>
@@ -0,0 +1,394 @@
<!-- TrackerPanel.vue —— ⑥ 投稿进度与返修(纯前端 localStorage 台账,无后端)。
严格复用 edu store 现有 API:submissionsSorted(updatedAt 倒序)/ addSubmission /
updateSubmission / removeSubmission / SUBMISSION_STATUS;不新增字段、不改 store。
交互:新增(内联抽屉)/ 编辑(同一表单预填)/ 删除(行内二次确认)/ 空态引导。
日期约定:表单为 'YYYY-MM-DD';存量 submittedAt 可能是 ISO → 读取统一 slice(0,10) 兼容;
addSubmission 固定 submittedAt=now,故仅当用户改了日期时再 updateSubmission 补丁。 -->
<script setup>
import { reactive, ref } from 'vue';
import { useEduStore, SUBMISSION_STATUS } from '../../../stores/edu.js';
const edu = useEduStore();
const STATUS_LABEL = {
draft: '草稿',
submitted: '已投稿',
under_review: '审稿中',
revision: '返修中',
accepted: '录用',
rejected: '拒稿',
};
const formOpen = ref(false);
const mode = ref('create'); // 'create' | 'edit'
const editingId = ref(null);
const formError = ref('');
const pendingDeleteId = ref(null); // 删除行内二次确认
const form = reactive({ journal: '', status: 'draft', date: '', notes: '' });
const todayStr = () => new Date().toISOString().slice(0, 10);
const dateOf = (s) => String(s || '').slice(0, 10);
const dtOf = (s) => String(s || '').slice(0, 16).replace('T', ' ');
function openCreate() {
mode.value = 'create';
editingId.value = null;
form.journal = '';
form.status = 'draft';
form.date = todayStr();
form.notes = '';
formError.value = '';
pendingDeleteId.value = null;
formOpen.value = true;
}
function openEdit(row) {
mode.value = 'edit';
editingId.value = row.id;
form.journal = row.journal;
form.status = row.status;
form.date = dateOf(row.submittedAt);
form.notes = row.notes || '';
formError.value = '';
pendingDeleteId.value = null;
formOpen.value = true;
}
function closeForm() {
formOpen.value = false;
formError.value = '';
}
function save() {
const journal = form.journal.trim();
if (!journal) {
formError.value = '请输入期刊名';
return;
}
if (mode.value === 'create') {
const rec = edu.addSubmission({ journal, status: form.status, notes: form.notes });
// addSubmission 固定 submittedAt=now(ISO);仅当用户改了投稿日期才补丁(复用 updateSubmission,不改 store)
if (form.date && form.date !== todayStr()) {
edu.updateSubmission(rec.id, { submittedAt: form.date });
}
} else {
edu.updateSubmission(editingId.value, {
journal,
status: form.status,
notes: form.notes,
submittedAt: form.date,
});
}
closeForm();
}
function confirmDelete() {
if (pendingDeleteId.value) edu.removeSubmission(pendingDeleteId.value);
pendingDeleteId.value = null;
}
</script>
<template>
<section class="tk">
<header class="tk-head">
<h1 class="tk-title">投稿进度与返修</h1>
<span class="tk-sub">本地台账 · 仅存于当前浏览器</span>
<button class="tk-add" type="button" @click="openCreate">+ 新增投稿</button>
</header>
<!-- ========== 新增/编辑共用抽屉 ========== -->
<div v-if="formOpen" class="tk-form">
<div class="tk-form-grid">
<label class="tk-field">
<span class="tk-label">期刊名<i class="tk-req">*</i></span>
<input
v-model="form.journal"
class="tk-input"
type="text"
placeholder="如 The Plant Cell"
@keydown.enter="save"
/>
</label>
<label class="tk-field">
<span class="tk-label">状态</span>
<select v-model="form.status" class="tk-input tk-select">
<option v-for="s in SUBMISSION_STATUS" :key="s" :value="s">
{{ STATUS_LABEL[s] }}
</option>
</select>
</label>
<label class="tk-field">
<span class="tk-label">投稿日期</span>
<input v-model="form.date" class="tk-input" type="date" />
</label>
<label class="tk-field tk-field-wide">
<span class="tk-label">备注(可空)</span>
<textarea
v-model="form.notes"
class="tk-input tk-textarea"
rows="3"
placeholder="如:审稿 2 人已返回,等待编辑决定…"
></textarea>
</label>
</div>
<p v-if="formError" class="tk-form-error">{{ formError }}</p>
<div class="tk-form-btns">
<button class="tk-save" type="button" @click="save">
{{ mode === 'create' ? '创建' : '保存' }}
</button>
<button class="tk-cancel" type="button" @click="closeForm">取消</button>
</div>
</div>
<!-- ========== 空态 ========== -->
<p v-if="!edu.submissionsSorted.length" class="tk-empty">
暂无投稿记录,点击「新增投稿」开始追踪
</p>
<!-- ========== 列表(store 已按 updatedAt 倒序) ========== -->
<div
v-for="row in edu.submissionsSorted"
:key="row.id"
class="tk-row"
:class="{ confirming: pendingDeleteId === row.id }"
>
<div class="tk-main">
<span class="tk-journal" :title="row.journal">{{ row.journal }}</span>
<span class="tk-badge" :class="row.status">{{ STATUS_LABEL[row.status] || row.status }}</span>
<span class="tk-date">投稿 {{ dateOf(row.submittedAt) || '—' }}</span>
<span class="tk-updated">更新 {{ dtOf(row.updatedAt) || '—' }}</span>
</div>
<p class="tk-notes" :class="{ empty: !row.notes }">{{ row.notes || '—' }}</p>
<div class="tk-actions">
<template v-if="pendingDeleteId === row.id">
<span class="tk-confirm-q">确认删除该记录?</span>
<button class="tk-danger" type="button" @click="confirmDelete">确认删除</button>
<button class="tk-cancel" type="button" @click="pendingDeleteId = null">取消</button>
</template>
<template v-else>
<button class="tk-act" type="button" @click="openEdit(row)">编辑</button>
<button class="tk-act tk-danger" type="button" @click="pendingDeleteId = row.id">
删除
</button>
</template>
</div>
</div>
</section>
</template>
<style scoped>
.tk {
display: flex;
flex-direction: column;
gap: 12px;
}
.tk-head {
display: flex;
align-items: baseline;
gap: 10px;
}
.tk-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--pa-ink);
}
.tk-sub {
font-size: 12px;
color: var(--pa-ink-subtle);
margin-right: auto;
}
.tk-add {
padding: 7px 16px;
border: none;
border-radius: var(--pa-r-md);
background: var(--pa-gradient);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.tk-add:hover {
filter: brightness(1.08);
}
/* ---------- 抽屉表单 ---------- */
.tk-form {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 16px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline-strong);
border-radius: var(--pa-r-lg);
}
.tk-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.tk-field {
display: flex;
flex-direction: column;
gap: 5px;
}
.tk-field-wide {
grid-column: 1 / -1;
}
.tk-label {
font-size: 12px;
font-weight: 500;
color: var(--pa-ink-muted);
}
.tk-req {
color: var(--pa-error);
font-style: normal;
margin-left: 3px;
}
.tk-input {
padding: 7px 11px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
background: var(--pa-surface-2);
color: var(--pa-ink);
font-size: 13px;
font-family: inherit;
}
.tk-input:focus {
outline: none;
border-color: var(--pa-g-from);
}
.tk-textarea {
resize: vertical;
min-height: 60px;
}
.tk-form-error {
margin: 0;
font-size: 12.5px;
color: var(--pa-error);
}
.tk-form-btns {
display: flex;
gap: 10px;
}
.tk-save {
padding: 6px 20px;
border: none;
border-radius: var(--pa-r-md);
background: var(--pa-gradient);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.tk-cancel,
.tk-act {
padding: 6px 14px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
background: transparent;
color: var(--pa-ink-muted);
font-size: 12.5px;
cursor: pointer;
}
.tk-cancel:hover,
.tk-act:hover {
color: var(--pa-ink);
background: var(--pa-surface-2);
}
.tk-danger {
color: var(--pa-error);
border-color: currentColor;
}
.tk-danger:hover {
color: var(--pa-error);
background: transparent;
filter: brightness(1.15);
}
/* ---------- 空态 ---------- */
.tk-empty {
margin: 0;
padding: 40px 16px;
text-align: center;
font-size: 13px;
color: var(--pa-ink-subtle);
border: 1px dashed var(--pa-hairline);
border-radius: var(--pa-r-lg);
}
/* ---------- 列表行 ---------- */
.tk-row {
display: flex;
flex-direction: column;
gap: 7px;
padding: 12px 16px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
transition: border-color .15s var(--pa-ease);
}
.tk-row:hover {
border-color: var(--pa-hairline-strong);
}
.tk-row.confirming {
border-color: var(--pa-error);
}
.tk-main {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.tk-journal {
font-size: 14px;
font-weight: 600;
color: var(--pa-ink);
max-width: 44%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 状态徽章 6 态配色:灰/蓝/青/橙/绿/红。
青(under_review)无专用 --pa-cyan 变量 → 用现成渐变终点色 --pa-g-to(#00D4FF),不硬编码 */
.tk-badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border: 1px solid currentColor;
border-radius: var(--pa-r-pill);
font-size: 11.5px;
font-weight: 600;
line-height: 1.6;
}
.tk-badge.draft { color: var(--pa-ink-subtle); }
.tk-badge.submitted { color: var(--pa-info); }
.tk-badge.under_review { color: var(--pa-g-to); }
.tk-badge.revision { color: var(--pa-warning); }
.tk-badge.accepted { color: var(--pa-success); }
.tk-badge.rejected { color: var(--pa-error); }
.tk-date,
.tk-updated {
font-family: var(--pa-font-mono);
font-size: 11.5px;
color: var(--pa-ink-subtle);
}
.tk-notes {
margin: 0;
font-size: 12.5px;
line-height: 1.55;
color: var(--pa-ink-muted);
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.tk-notes.empty {
color: var(--pa-ink-subtle);
}
.tk-actions {
display: flex;
align-items: center;
gap: 8px;
}
.tk-confirm-q {
font-size: 12.5px;
color: var(--pa-error);
}
</style>
@@ -0,0 +1,16 @@
<!-- TrainingPanel.vue —— ⑦ 审稿训练与教育(步骤4 占位)。 -->
<script setup>
</script>
<template>
<section class="edu-panel-inner">
<h1 class="edu-title">审稿训练与教育</h1>
<p class="edu-placeholder">功能开发中,敬请期待</p>
</section>
</template>
<style scoped>
.edu-panel-inner { display: flex; flex-direction: column; gap: 8px; }
.edu-title { margin: 0; font-size: 16px; font-weight: 600; color: var(--pa-ink); }
.edu-placeholder { margin: 0; font-size: 13px; color: var(--pa-ink-muted); }
</style>
+20
View File
@@ -14,6 +14,7 @@ import { APP_NAME, STATUS_LABEL, T } from '../../i18n/labels.js';
import { BF } from '../../i18n/bioflow.js';
import { useThemeStore } from '../../stores/theme.js';
import { useBioflowStore } from '../../stores/bioflow.js';
import { useAppStore } from '../../stores/app.js';
const props = defineProps({
/** 当前会话标题(无会话为 null → 显示「就绪」徽章) */
@@ -40,6 +41,9 @@ const bioflowStore = useBioflowStore();
const bioflowOnline = computed(() => bioflowStore.availability === 'online');
const bioflowStatus = computed(() => bioflowStore.availability);
/* ---------- 教育科研入口(步骤5;覆盖层视图切换 D1,同 themeStore 静态导入) ---------- */
const appStore = useAppStore();
/* ---------- B2/B3 store 数据(动态导入 + 空值守卫) ---------- */
const chatStore = ref(null);
const benchStore = ref(null);
@@ -160,6 +164,8 @@ const iconClose = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
const iconRailExt = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l2.4 4.9 5.4.8-3.9 3.8.9 5.4-4.8-2.5-4.8 2.5.9-5.4L4.2 7.7l5.4-.8z"/><path d="M20 14v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-5"/></svg>';
const iconRailSettings = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.09a1.7 1.7 0 0 0-1-1.55 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.87 1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.09a1.7 1.7 0 0 0 1.55-1 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34h0a1.7 1.7 0 0 0 1-1.55V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1 1.55h0a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87v0a1.7 1.7 0 0 0 1.55 1H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.55 1z"/></svg>';
const iconRailBio = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 3c0 4-1.2 8.6-5 11"/><path d="M7 3c0 4 1.2 8.6 5 11"/><path d="M17 3c0 4 1.2 8.6 5 11"/><path d="M17 3c0 4-1.2 8.6-5 11"/><path d="M8.5 8h7"/><path d="M8 13.5h8"/><path d="M10.5 18h3"/></svg>';
// 教育科研(步骤5:毕业帽,与 rail 同款内联 SVG)
const iconRailEdu = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 10L12 5 2 10l10 5 10-5z"/><path d="M6 12.5V17c0 1.7 2.7 3 6 3s6-1.3 6-3v-4.5"/><path d="M22 10v6"/></svg>';
</script>
<template>
@@ -223,6 +229,12 @@ const iconRailBio = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
<span v-html="iconRailBio"></span>
<span class="rail-label">生信</span>
</button>
<!-- 教育科研入口(步骤5:覆盖层切 edu 视图;on 高亮在 edu 态由 EduSideNav 返回后可见) -->
<button class="icon-btn top-rail-btn" :class="{ on: appStore.isEdu }" type="button" title="教育科研" aria-label="教育科研"
@click="appStore.setView('edu')">
<span v-html="iconRailEdu"></span>
<span class="rail-label">教育科研</span>
</button>
<button class="icon-btn top-rail-btn" type="button" :title="T.railSettings" :aria-label="T.railSettings"
@click="$emit('goto-settings')">
<span v-html="iconRailSettings"></span>
@@ -245,3 +257,11 @@ const iconRailBio = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
</button>
</header>
</template>
<style scoped>
/* 教育科研入口高亮(步骤5;仅现成 --pa-* 变量,同 bench-tab.on 思路) */
.top-rail-btn.on {
color: var(--pa-ink);
background: var(--pa-surface-2);
}
</style>
+31
View File
@@ -0,0 +1,31 @@
// stores/app.js —— 全局视图状态(chat ⇄ edu,无 vue-router;覆盖层语义 D1)
// 切到 'edu' 时 App.vue 用 v-show 隐藏 AppShell 而不卸载:RAG 会话、
// 上传文件、滚动位置零丢失(严禁改用 v-if)。
// 仅内存态:刷新页面必回 'chat',避免用户被卡在 edu 视图。
import { defineStore } from 'pinia';
const VIEWS = ['chat', 'edu'];
export const useAppStore = defineStore('app', {
state: () => ({
currentView: 'chat', // 'chat' | 'edu',默认 chat
}),
getters: {
isChat: (s) => s.currentView === 'chat',
isEdu: (s) => s.currentView === 'edu',
},
actions: {
setView(v) {
if (VIEWS.includes(v)) this.currentView = v; // 非法值静默忽略
},
toggleView() {
this.currentView = this.currentView === 'edu' ? 'chat' : 'edu';
},
gotoEdu() {
this.setView('edu');
},
gotoChat() {
this.setView('chat');
},
},
});
+322
View File
@@ -0,0 +1,322 @@
// stores/edu.js —— 教育科研工作台状态(Pinia options API,风格同 settings.js)
// 覆盖左侧 ①~⑦ 菜单、面板共享数据、③→④ 报告衔接、D3 投稿台账(localStorage)。
// 面板拟用 <component :is> 切换(切菜单卸载旧面板):
// - 进行中的请求由 store action 持有 → 面板卸载不中断;
// - 表单草稿存 drafts → 切菜单不丢。
import { defineStore } from 'pinia';
import { compileTex, formatCheck, formatErrorMessage, getConferences, getDirectory, getJournals, presubmit, recommend, reformatTex, reviewErrorMessage } from '../api/review.js';
const STORAGE_KEY = 'plantai.edu.submissions'; // D3 台账持久化
const SECTIONS = ['journals', 'recommend', 'presubmit', 'report', 'format', 'reformat', 'tracker', 'calendar'];
// ⑥ 台账状态枚举(labels 由 step-7 UI 层给;store 只存字符串)
export const SUBMISSION_STATUS = ['draft', 'submitted', 'under_review', 'revision', 'accepted', 'rejected'];
function safeId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
try {
return crypto.randomUUID();
} catch {
/* fallthrough */
}
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function readStorage() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((r) => r && typeof r === 'object' && typeof r.id === 'string');
} catch {
return [];
}
}
export const useEduStore = defineStore('edu', {
state: () => ({
/* —— 菜单(①~⑦,默认①)—— */
section: 'journals',
/* —— ⑥ 投稿台账(D3;构造即加载 localStorage)—— */
submissions: readStorage(), // {id, journal, status, submittedAt, updatedAt, notes}
/* —— 面板数据三态(统一 idle | loading | ready | error)—— */
journals: [], // ① items
journalsMeta: null, // {count, last_updated, data_source_note}
journalsStatus: 'idle',
journalsError: null,
/* —— ①' 期刊目录(journal_directory 1056 本;与 journals 5 本精选三态隔离)—— */
journalsTab: 'en', // 'en' | 'zh' 视图态(切 Tab 全清筛选,见 resetDirectoryQuery)
directory: [], // 当前页 items(后端已过滤/排序/分页)
directoryMeta: null, // {total,page,size,lang,domain,cas_partition,q,domains[],data_source_note,data_imported}
directoryStatus: 'idle', // idle | loading | ready | error
directoryError: null,
// 请求参数与 directory 同生命周期 → 切菜单(面板卸载)不丢筛选
directoryQuery: {
lang: 'en', q: '', domain: '', cas_partition: '',
page: 1, size: 50, field: '', order: '',
},
// 竞态令牌:防抖/连点/切 Tab 时旧响应覆盖新数据(fetchDirectory 内比对)
directorySeq: 0,
conferences: [], // ⑦' 会议时间线 items(后端已过滤/排序;首版全 placeholder)
conferencesStatus: 'idle',
conferencesError: null,
report: null, // ③→④ presubmit 响应 {ok, report, meta}
reportCreatedAt: null, // ④报告头生成时间(ISO;payload 无时间字段,提交成功时盖章,clearReport 重置)
reportStatus: 'idle',
reportError: null,
recommend: null, // ② 推荐响应 {ok, tiers, meta}
recommendStatus: 'idle',
recommendError: null,
formatReport: null, // ⑤ 版式检查响应 {ok, checks, summary, meta}(跨菜单保留)
formatStatus: 'idle',
formatError: null,
/* —— ⑤' 论文自动重排版 P1(.tex 规则变换)响应
{ok, job_id, modified_tex, changes[], warnings[], meta}(跨菜单保留)—— */
reformatResult: null,
reformatStatus: 'idle',
reformatError: null,
/* —— P3a 编译 PDF(tectonic;与 reformatResult 同生命周期,重新排版即失效)—— */
compileResult: null, // {ok, compile_id, pdf_url, log_url, meta}
compileStatus: 'idle', // 'idle' | 'loading' | 'ready' | 'error'
compileError: null,
compileLogTail: '', // COMPILE_FAILED/TIMEOUT 的 log 尾 200 行(err.detail)
/* —— 表单草稿(切菜单不丢;四字段并存:recommend 用 abstract+prefs、
presubmit 用 targetJournal、format 用 formatJournalId(期刊 id 下拉))—— */
drafts: {
abstract: '',
targetJournal: '',
formatJournalId: '',
// ⑤' 重排版目标:'default'(通用规则,首项默认)| 精选期刊 id | 'dir:<id>'(目录刊)
// '__dir_en__'/'__dir_zh__' = 目录搜索模式(过渡态,非合法 target,面板内校验)。
// reformatDirPick = {id,name}(目录选中回显,跨菜单保留防 select 失焦)。
reformatTarget: 'default',
reformatDirPick: null,
// 与 POST /api/review/recommend 的 preferences 同构:
// {max_review_weeks, min_impact_factor, tier_focus} 子字段逐一同名同型,
// 提交时整体映射为 payload.preferences(null 数值剔除)。
// 首版 chips 为纯前端筛选 → tier_focus 恒为默认 'all',不改 payload 语义。
prefs: { max_review_weeks: null, min_impact_factor: null, tier_focus: 'all' },
// 结果区档位筛选(纯视图状态,不参与 payload;随 store 保留跨菜单)
tierFilter: 'all',
},
}),
getters: {
hasReport: (s) => !!(s.report && s.report.ok && s.report.report),
submissionsSorted: (s) =>
[...s.submissions].sort((a, b) => String(b.updatedAt || '').localeCompare(a.updatedAt || '')),
},
actions: {
setSection(k) {
if (SECTIONS.includes(k)) this.section = k;
},
/* —— D3 台账 CRUD(每步 _persist 同步 localStorage)—— */
loadFromStorage() {
this.submissions = readStorage();
},
addSubmission({ journal, status = 'draft', notes = '' }) {
const now = new Date().toISOString();
const rec = { id: safeId(), journal, status, submittedAt: now, updatedAt: now, notes };
this.submissions.unshift(rec);
this._persist();
return rec;
},
updateSubmission(id, patch = {}) {
const i = this.submissions.findIndex((s) => s.id === id);
if (i < 0) return null;
const merged = { ...this.submissions[i], ...patch, id, updatedAt: new Date().toISOString() };
this.submissions[i] = merged;
this._persist();
return merged;
},
removeSubmission(id) {
this.submissions = this.submissions.filter((s) => s.id !== id);
this._persist();
},
_persist() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.submissions));
} catch {
/* 隐私模式/配额满:内存态可用,静默降级 */
}
},
/* —— API actions(错误吞入 *Status/*Error 状态,不 rethrow;成功返回 payload,失败 null)—— */
async fetchJournals(params = {}) {
this.journalsStatus = 'loading';
this.journalsError = null;
try {
const payload = await getJournals(params);
this.journals = (payload && payload.items) || [];
this.journalsMeta = (payload && payload.meta) || null;
this.journalsStatus = 'ready';
return payload;
} catch (err) {
this.journalsStatus = 'error';
this.journalsError = reviewErrorMessage(err);
return null;
}
},
/* —— ①' 目录查询(请求参数合并 → 只读 GET;seq 防乱序覆盖)—— */
async fetchDirectory(partial = {}) {
Object.assign(this.directoryQuery, partial);
const seq = ++this.directorySeq;
this.directoryStatus = 'loading';
this.directoryError = null;
try {
const payload = await getDirectory({ ...this.directoryQuery });
if (seq !== this.directorySeq) return null; // 过期响应:丢弃
this.directory = (payload && payload.items) || [];
this.directoryMeta = (payload && payload.meta) || null;
this.directoryStatus = 'ready';
return payload;
} catch (err) {
if (seq !== this.directorySeq) return null;
this.directoryStatus = 'error';
this.directoryError = reviewErrorMessage(err);
return null;
}
},
/* 复位筛选(保留 size):切 Tab / 清除筛选共用 */
resetDirectoryQuery(newLang = this.directoryQuery.lang) {
this.directoryQuery = {
lang: newLang,
q: '', domain: '', cas_partition: '',
page: 1,
size: this.directoryQuery.size,
field: '', order: '',
};
},
/* 切 Tab:全清 q/domain/cas_partition/page(en/zh 筛选语义不对称,保留会困惑) */
async switchDirectoryTab(tab) {
if (tab !== 'en' && tab !== 'zh' || tab === this.journalsTab) return null;
this.journalsTab = tab;
this.resetDirectoryQuery(tab);
return this.fetchDirectory();
},
async fetchConferences(query = {}) {
this.conferencesStatus = 'loading';
this.conferencesError = null;
try {
const payload = await getConferences(query);
this.conferences = (payload && payload.items) || [];
this.conferencesStatus = 'ready';
return payload;
} catch (err) {
this.conferencesStatus = 'error';
this.conferencesError = reviewErrorMessage(err);
return null;
}
},
async submitPresubmit(formData, opts = {}) {
this.reportStatus = 'loading';
this.reportError = null;
try {
const payload = await presubmit(formData, opts); // opts 透传 onUploadProgress(http.js XHR 分支)
this.report = payload;
this.reportCreatedAt = new Date().toISOString();
this.reportStatus = 'ready';
return payload;
} catch (err) {
this.reportStatus = 'error';
this.reportError = reviewErrorMessage(err);
return null;
}
},
clearReport() {
this.report = null;
this.reportCreatedAt = null;
this.reportStatus = 'idle';
this.reportError = null;
},
async submitRecommend(payload) {
this.recommendStatus = 'loading';
this.recommendError = null;
try {
const res = await recommend(payload);
this.recommend = res;
this.recommendStatus = 'ready';
return res;
} catch (err) {
this.recommendStatus = 'error';
this.recommendError = reviewErrorMessage(err);
return null;
}
},
async submitFormatCheck(formData) {
this.formatStatus = 'loading';
this.formatError = null;
try {
const payload = await formatCheck(formData); // 纯规则引擎,同步秒回
this.formatReport = payload;
this.formatStatus = 'ready';
return payload;
} catch (err) {
this.formatStatus = 'error';
this.formatError = formatErrorMessage(err);
return null;
}
},
clearFormatReport() {
this.formatReport = null;
this.formatStatus = 'idle';
this.formatError = null;
},
/* —— ⑤' 重排版(P1:.tex 源码变换,纯规则同步秒回)—— */
async submitReformat(formData) {
this.clearCompile(); // 新排版结果使旧 PDF 失效
this.reformatStatus = 'loading';
this.reformatError = null;
try {
const payload = await reformatTex(formData);
this.reformatResult = payload;
this.reformatStatus = 'ready';
return payload;
} catch (err) {
this.reformatStatus = 'error';
this.reformatError = formatErrorMessage(err); // VALIDATION/PARSER_FAILED 用后端原话
return null;
}
},
clearReformat() {
this.reformatResult = null;
this.reformatStatus = 'idle';
this.reformatError = null;
this.clearCompile();
},
/* —— P3a 编译 PDF(tectonic 单槽队列;约 10-30 秒)—— */
async submitCompile(jobId) {
if (this.compileStatus === 'loading') return null;
this.compileStatus = 'loading';
this.compileError = null;
this.compileLogTail = '';
const fd = new FormData();
fd.append('job_id', jobId);
try {
const payload = await compileTex(fd);
this.compileResult = payload;
this.compileStatus = 'ready';
return payload;
} catch (err) {
this.compileStatus = 'error';
this.compileError = reviewErrorMessage(err);
this.compileLogTail = err.detail || ''; // COMPILE_FAILED/TIMEOUT 附 log 尾
return null;
}
},
clearCompile() {
this.compileResult = null;
this.compileStatus = 'idle';
this.compileError = null;
this.compileLogTail = '';
},
},
});
+2 -2
View File
@@ -1,9 +1,9 @@
// vite.config.js —— B1 归属,后续由 B1 扩展。此处为 M0 最小可用配置。
// 默认代理指向 mock server (8099);集成阶段由 PM 改为真实 BFF (8090)。
// 默认代理指向真实 BFF (8090);需要 mock 时显式 VITE_BFF_TARGET=http://127.0.0.1:8099。
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
const bffTarget = process.env.VITE_BFF_TARGET || 'http://127.0.0.1:8099';
const bffTarget = process.env.VITE_BFF_TARGET || 'http://127.0.0.1:8090';
export default defineConfig({
plugins: [vue()],