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
This commit is contained in:
potaser
2026-10-11 12:19:17 +08:00
parent c747ebd77a
commit 42aeb8fa4c
11 changed files with 2063 additions and 4 deletions
+3
View File
@@ -48,5 +48,8 @@ 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/
+27
View File
@@ -47,6 +47,18 @@ class Settings(BaseSettings):
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)
@@ -61,6 +73,21 @@ class Settings(BaseSettings):
def journals_seed_abs(self) -> "Path":
return self._service_root / self.journals_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("/")
+2 -1
View File
@@ -15,7 +15,7 @@ from fastapi.middleware.cors import CORSMiddleware
from .config import get_settings
from .db import close_db, init_db
from .routers import conferences, format_check, journal_directory, journals, presubmit
from .routers import conferences, format_check, journal_directory, journals, presubmit, reformat
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@@ -68,6 +68,7 @@ def create_app() -> FastAPI:
app.include_router(format_check.router)
app.include_router(conferences.router)
app.include_router(journal_directory.router)
app.include_router(reformat.router)
return app
+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")
+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}
+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"]
+26 -1
View File
@@ -13,11 +13,13 @@ const BASE = '/api/review';
function unwrap(promise) {
return promise.then((payload) => {
if (payload && payload.ok === false && payload.error) {
throw new ApiError(payload.error.message || '服务错误', {
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;
});
@@ -79,6 +81,29 @@ 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) {
+1
View File
@@ -15,6 +15,7 @@ const ITEMS = [
{ 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: '📅' },
];
+2
View File
@@ -11,6 +11,7 @@ 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';
@@ -24,6 +25,7 @@ const panelMap = {
presubmit: PresubmitPanel,
report: ReportPanel,
format: FormatPanel,
reformat: ReformatPanel,
tracker: TrackerPanel,
calendar: CalendarPanel,
};
@@ -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>
+65 -2
View File
@@ -4,10 +4,10 @@
// - 进行中的请求由 store action 持有 → 面板卸载不中断;
// - 表单草稿存 drafts → 切菜单不丢。
import { defineStore } from 'pinia';
import { formatCheck, formatErrorMessage, getConferences, getDirectory, getJournals, presubmit, recommend, reviewErrorMessage } from '../api/review.js';
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', 'tracker', 'calendar'];
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'];
@@ -74,6 +74,16 @@ export const useEduStore = defineStore('edu', {
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 下拉))—— */
@@ -81,6 +91,11 @@ export const useEduStore = defineStore('edu', {
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 数值剔除)。
@@ -255,5 +270,53 @@ export const useEduStore = defineStore('edu', {
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 = '';
},
},
});