diff --git a/.gitignore b/.gitignore index b36139e..259c747 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/review_service/app/config.py b/review_service/app/config.py index cf9533a..780fbfb 100644 --- a/review_service/app/config.py +++ b/review_service/app/config.py @@ -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("/") diff --git a/review_service/app/main.py b/review_service/app/main.py index 763340e..df24b64 100644 --- a/review_service/app/main.py +++ b/review_service/app/main.py @@ -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 diff --git a/review_service/app/routers/reformat.py b/review_service/app/routers/reformat.py new file mode 100644 index 0000000..a5b835f --- /dev/null +++ b/review_service/app/routers/reformat.py @@ -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") diff --git a/review_service/app/services/reformat.py b/review_service/app/services/reformat.py new file mode 100644 index 0000000..c241eff --- /dev/null +++ b/review_service/app/services/reformat.py @@ -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} diff --git a/review_service/tests/test_reformat.py b/review_service/tests/test_reformat.py new file mode 100644 index 0000000..4f7b4d0 --- /dev/null +++ b/review_service/tests/test_reformat.py @@ -0,0 +1,407 @@ +"""tests/test_reformat.py —— P1 论文重排版单元测试(离线,零 LLM/零网络/零磁盘污染)。 + +覆盖:规则解析链(default/种子占位/目录回链/抓取缓存/未匹配)+ 4 变换器 +(改/不改/保守 warning)+ 结构守卫 + 幂等 + API 信封(校验/落盘/GET/404)。 +运行: cd review_service && /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"] diff --git a/ui/src/api/review.js b/ui/src/api/review.js index d231349..e4419a1 100644 --- a/ui/src/api/review.js +++ b/ui/src/api/review.js @@ -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) { diff --git a/ui/src/components/edu/EduSideNav.vue b/ui/src/components/edu/EduSideNav.vue index f2747a4..ea68aaa 100644 --- a/ui/src/components/edu/EduSideNav.vue +++ b/ui/src/components/edu/EduSideNav.vue @@ -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: '📅' }, ]; diff --git a/ui/src/components/edu/EduWorkspace.vue b/ui/src/components/edu/EduWorkspace.vue index 2abb290..051a5c7 100644 --- a/ui/src/components/edu/EduWorkspace.vue +++ b/ui/src/components/edu/EduWorkspace.vue @@ -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, }; diff --git a/ui/src/components/edu/panels/ReformatPanel.vue b/ui/src/components/edu/panels/ReformatPanel.vue new file mode 100644 index 0000000..dd049a2 --- /dev/null +++ b/ui/src/components/edu/panels/ReformatPanel.vue @@ -0,0 +1,812 @@ + + + + + + diff --git a/ui/src/stores/edu.js b/ui/src/stores/edu.js index ba3040b..388d3e8 100644 --- a/ui/src/stores/edu.js +++ b/ui/src/stores/edu.js @@ -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:'(目录刊) + // '__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 = ''; + }, }, });